History

add managed relay tunnels and standards-based auth

Julius Marminge committed 5 months ago

Commit 556e8931 parent 348a914212 files · +34555 −714Browse files

Unified · Split

212 files
  1. .gitignore +6 −0
  2. AGENTS.md +2 −16
  3. README.md +4 −0
  4. apps/desktop/scripts/dev-electron.mjs +20 −10
  5. apps/desktop/scripts/electron-launcher.mjs +162 −10
  6. apps/desktop/src/app/DesktopApp.ts +4 −1
  7. apps/desktop/src/app/DesktopAppIdentity.test.ts +3 −0
  8. apps/desktop/src/app/DesktopCloudAuth.test.ts +302 −0
  9. apps/desktop/src/app/DesktopCloudAuth.ts +330 −0
  10. apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts +96 −0
  11. apps/desktop/src/app/DesktopCloudAuthTokenStore.ts +155 −0
  12. apps/desktop/src/app/DesktopConfig.ts +1 −0
  13. apps/desktop/src/app/DesktopEnvironment.test.ts +14 −0
  14. apps/desktop/src/app/DesktopEnvironment.ts +3 −1
  15. apps/desktop/src/electron/ElectronApp.test.ts +9 −0
  16. apps/desktop/src/electron/ElectronApp.ts +17 −0
  17. apps/desktop/src/ipc/DesktopIpcHandlers.ts +12 −0
  18. apps/desktop/src/ipc/channels.ts +6 −0
  19. apps/desktop/src/ipc/methods/cloudAuth.ts +128 −0
  20. apps/desktop/src/main.ts +4 −0
  21. apps/desktop/src/preload.ts +17 −0
  22. apps/desktop/src/settings/DesktopSavedEnvironments.ts +12 −14
  23. apps/desktop/src/window/DesktopApplicationMenu.test.ts +3 −0
  24. apps/mobile/app.config.ts +28 −4
  25. apps/mobile/clerk-theme.json +39 −0
  26. apps/mobile/global.css +2 −0
  27. apps/mobile/package.json +8 −0
  28. apps/mobile/src/app/_layout.tsx +24 −9
  29. apps/mobile/src/app/connections/index.tsx +7 −3
  30. apps/mobile/src/app/connections/new.tsx +3 −1
  31. apps/mobile/src/app/index.tsx +5 −10
  32. apps/mobile/src/app/settings/_layout.tsx +41 −0
  33. apps/mobile/src/app/settings/environment-new.tsx +1 −0
  34. apps/mobile/src/app/settings/environments.tsx +335 −0
  35. apps/mobile/src/app/settings/index.tsx +400 −0
  36. apps/mobile/src/app/settings/waitlist.tsx +67 −0
  37. apps/mobile/src/features/agent-awareness/liveActivityController.test.ts +256 −0
  38. apps/mobile/src/features/agent-awareness/liveActivityController.ts +605 −0
  39. apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +129 −0
  40. apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +39 −0
  41. apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts +106 −0
  42. apps/mobile/src/features/agent-awareness/notificationNavigation.ts +35 −0
  43. apps/mobile/src/features/agent-awareness/notificationPayload.ts +106 −0
  44. apps/mobile/src/features/agent-awareness/notificationPermissions.ts +44 −0
  45. apps/mobile/src/features/agent-awareness/registrationPayload.ts +31 −0
  46. apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +409 −0
  47. apps/mobile/src/features/agent-awareness/remoteRegistration.ts +448 −0
  48. apps/mobile/src/features/agent-awareness/shellLiveActivitySync.ts +59 −0
  49. apps/mobile/src/features/agent-awareness/updatedAtLabel.test.ts +16 −0
  50. apps/mobile/src/features/agent-awareness/updatedAtLabel.ts +12 −0
  51. apps/mobile/src/features/cloud/CloudAuthProvider.tsx +89 −0
  52. apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx +208 −0
  53. apps/mobile/src/features/cloud/dpop.test.ts +166 −0
  54. apps/mobile/src/features/cloud/dpop.ts +270 −0
  55. apps/mobile/src/features/cloud/linkEnvironment.test.ts +1030 −0
  56. apps/mobile/src/features/cloud/linkEnvironment.ts +552 −0
  57. apps/mobile/src/features/cloud/managedRelayLayer.ts +42 −0
  58. apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts +134 −0
  59. apps/mobile/src/lib/authClientMetadata.ts +10 −0
  60. apps/mobile/src/lib/connection.test.ts +1 −1
  61. apps/mobile/src/lib/connection.ts +24 −24
  62. apps/mobile/src/lib/runtime.ts +22 −1
  63. apps/mobile/src/lib/storage.ts +35 −3
  64. apps/mobile/src/state/use-remote-environment-registry.test.ts +301 −0
  65. apps/mobile/src/state/use-remote-environment-registry.ts +273 −208
  66. apps/mobile/src/widgets/AgentActivity.tsx +313 −0
  67. apps/server/integration/OrchestrationEngineHarness.integration.ts +7 −0
  68. apps/server/src/auth/EnvironmentAuth.ts +65 −6
  69. apps/server/src/auth/EnvironmentAuthPolicy.ts +1 −1
  70. apps/server/src/auth/PairingGrantStore.test.ts +23 −0
  71. apps/server/src/auth/PairingGrantStore.ts +37 −2
  72. apps/server/src/auth/ServerSecretStore.ts +10 −5
  73. apps/server/src/auth/SessionStore.ts +8 −1
  74. apps/server/src/auth/dpop.test.ts +33 −0
  75. apps/server/src/auth/dpop.ts +92 −0
  76. apps/server/src/auth/http.ts +45 −2
  77. apps/server/src/auth/utils.ts +6 −3
  78. apps/server/src/cloud/ManagedEndpointRuntime.test.ts +244 −0
  79. apps/server/src/cloud/ManagedEndpointRuntime.ts +233 −0
  80. apps/server/src/cloud/config.ts +17 −0
  81. apps/server/src/cloud/http.test.ts +60 −0
  82. apps/server/src/cloud/http.ts +797 −0
  83. apps/server/src/httpCors.ts +1 −0
  84. apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +11 −0
  85. apps/server/src/orchestration/Layers/OrchestrationReactor.ts +3 −0
  86. apps/server/src/persistence/Layers/AuthPairingLinks.ts +10 −1
  87. apps/server/src/persistence/Migrations.ts +2 −0
  88. apps/server/src/persistence/Migrations/032_AuthPairingProofKeyThumbprint.ts +16 −0
  89. apps/server/src/persistence/Services/AuthPairingLinks.ts +3 −0
  90. apps/server/src/relay/AgentAwarenessRelay.test.ts +634 −0
  91. apps/server/src/relay/AgentAwarenessRelay.ts +459 −0
  92. apps/server/src/server.test.ts +2009 −265
  93. apps/server/src/server.ts +9 −0
  94. apps/server/src/serverRuntimeStartup.ts +5 −2
  95. apps/web/package.json +4 −0
  96. apps/web/src/clientPersistenceStorage.ts +14 −2
  97. apps/web/src/cloud/desktopAuth.test.ts +24 −0
  98. apps/web/src/cloud/desktopAuth.ts +116 −0
  99. apps/web/src/cloud/desktopClerk.tsx +286 −0
  100. apps/web/src/cloud/dpop.test.ts +32 −0
  101. apps/web/src/cloud/dpop.ts +188 −0
  102. apps/web/src/cloud/linkEnvironment.test.ts +797 −0
  103. apps/web/src/cloud/linkEnvironment.ts +605 −0
  104. apps/web/src/cloud/managedAuth.tsx +22 −0
  105. apps/web/src/cloud/managedRelayLayer.ts +62 −0
  106. apps/web/src/components/settings/CloudSettings.tsx +556 −0
  107. apps/web/src/components/settings/SettingsPanels.browser.tsx +12 −0
  108. apps/web/src/components/settings/SettingsSidebarNav.tsx +3 −0
  109. apps/web/src/environments/primary/auth.ts +2 −1
  110. apps/web/src/environments/primary/index.ts +5 −1
  111. apps/web/src/environments/runtime/catalog.test.ts +43 −0
  112. apps/web/src/environments/runtime/catalog.ts +49 −0
  113. apps/web/src/environments/runtime/index.ts +1 −0
  114. apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +159 −19
  115. apps/web/src/environments/runtime/service.savedEnvironments.test.ts +8 −1
  116. apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +8 −1
  117. apps/web/src/environments/runtime/service.ts +231 −42
  118. apps/web/src/hooks/useTheme.ts +1 −1
  119. apps/web/src/hostedPairing.ts +1 −1
  120. apps/web/src/lib/runtime.ts +21 −1
  121. apps/web/src/localApi.test.ts +12 −0
  122. apps/web/src/main.tsx +21 −1
  123. apps/web/src/routeTree.gen.ts +21 −0
  124. apps/web/src/routes/_chat.index.tsx +4 −4
  125. apps/web/src/routes/settings.cloud.tsx +7 −0
  126. apps/web/src/vite-env.d.ts +1 −0
  127. docs/relay-observability.md +40 −0
  128. docs/t3-cloud-clerk.md +81 −0
  129. docs/t3-code-cloud-auth-flow.html +1820 −0
  130. infra/relay/alchemy.run.ts +38 −0
  131. infra/relay/migrations/postgres/20260527044716_baseline/migration.sql +104 −0
  132. infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json +1267 −0
  133. infra/relay/package.json +32 −0
  134. infra/relay/src/Config.ts +32 −0
  135. infra/relay/src/RelayCrypto.ts +16 −0
  136. infra/relay/src/agentActivityPayloads.ts +63 −0
  137. infra/relay/src/api.test.ts +157 −0
  138. infra/relay/src/api.ts +1016 −0
  139. infra/relay/src/apns.test.ts +116 −0
  140. infra/relay/src/apns.ts +274 −0
  141. infra/relay/src/apnsDeliveryJobs.test.ts +221 −0
  142. infra/relay/src/apnsDeliveryJobs.ts +196 −0
  143. infra/relay/src/db.test.ts +182 −0
  144. infra/relay/src/db.ts +50 −0
  145. infra/relay/src/dpop.test.ts +203 −0
  146. infra/relay/src/dpop.ts +61 −0
  147. infra/relay/src/infra/ManagedEndpointStackConfig.test.ts +27 −0
  148. infra/relay/src/infra/ManagedEndpointStackConfig.ts +26 −0
  149. infra/relay/src/infra/RelayObservability.test.ts +82 −0
  150. infra/relay/src/infra/RelayObservability.ts +58 −0
  151. infra/relay/src/persistence/AgentActivityRows.ts +155 −0
  152. infra/relay/src/persistence/DeliveryAttempts.test.ts +323 −0
  153. infra/relay/src/persistence/DeliveryAttempts.ts +205 −0
  154. infra/relay/src/persistence/Devices.test.ts +160 −0
  155. infra/relay/src/persistence/Devices.ts +134 −0
  156. infra/relay/src/persistence/DpopProofs.test.ts +95 −0
  157. infra/relay/src/persistence/DpopProofs.ts +68 −0
  158. infra/relay/src/persistence/EnvironmentCredentials.test.ts +148 −0
  159. infra/relay/src/persistence/EnvironmentCredentials.ts +176 −0
  160. infra/relay/src/persistence/EnvironmentLinks.test.ts +78 −0
  161. infra/relay/src/persistence/EnvironmentLinks.ts +349 −0
  162. infra/relay/src/persistence/LiveActivities.test.ts +196 −0
  163. infra/relay/src/persistence/LiveActivities.ts +375 −0
  164. infra/relay/src/persistence/json.ts +10 −0
  165. infra/relay/src/queues.ts +7 −0
  166. infra/relay/src/relayTokens.test.ts +196 −0
  167. infra/relay/src/relayTokens.ts +186 −0
  168. infra/relay/src/schema.ts +163 −0
  169. infra/relay/src/services/AgentActivityPublisher.test.ts +627 −0
  170. infra/relay/src/services/AgentActivityPublisher.ts +234 −0
  171. infra/relay/src/services/ApnsDeliveries.test.ts +1126 −0
  172. infra/relay/src/services/ApnsDeliveries.ts +793 −0
  173. infra/relay/src/services/ApnsDeliveryQueue.ts +144 −0
  174. infra/relay/src/services/Auth.ts +19 −0
  175. infra/relay/src/services/EnvironmentConnector.test.ts +583 −0
  176. infra/relay/src/services/EnvironmentConnector.ts +397 −0
  177. infra/relay/src/services/EnvironmentLinker.test.ts +203 −0
  178. infra/relay/src/services/EnvironmentLinker.ts +270 −0
  179. infra/relay/src/services/EnvironmentPublishSignatures.test.ts +160 −0
  180. infra/relay/src/services/EnvironmentPublishSignatures.ts +176 −0
  181. infra/relay/src/services/ManagedEndpointProvider.test.ts +446 −0
  182. infra/relay/src/services/ManagedEndpointProvider.ts +343 −0
  183. infra/relay/src/services/MobileRegistrations.test.ts +461 −0
  184. infra/relay/src/services/MobileRegistrations.ts +101 −0
  185. infra/relay/src/telemetry.test.ts +31 −0
  186. infra/relay/src/telemetry.ts +10 −0
  187. infra/relay/src/worker.ts +295 −0
  188. infra/relay/tsconfig.json +9 −0
  189. packages/client-runtime/src/index.ts +1 −0
  190. packages/client-runtime/src/managedRelay.test.ts +114 −0
  191. packages/client-runtime/src/managedRelay.ts +470 −0
  192. packages/client-runtime/src/remote.test.ts +114 −25
  193. packages/client-runtime/src/remote.ts +106 −7
  194. packages/contracts/package.json +4 −0
  195. packages/contracts/src/auth.ts +4 −1
  196. packages/contracts/src/environmentHttp.ts +169 −1
  197. packages/contracts/src/ipc.ts +28 −0
  198. packages/contracts/src/relay.test.ts +16 −0
  199. packages/contracts/src/relay.ts +868 −0
  200. packages/shared/package.json +27 −0
  201. packages/shared/src/agentAwareness.test.ts +128 −0
  202. packages/shared/src/agentAwareness.ts +142 −0
  203. packages/shared/src/dpop.test.ts +205 −0
  204. packages/shared/src/dpop.ts +200 −0
  205. packages/shared/src/dpopCommon.ts +20 −0
  206. packages/shared/src/oauthScope.ts +11 −1
  207. packages/shared/src/relayAuth.ts +6 −0
  208. packages/shared/src/relayJwt.ts +69 −0
  209. packages/shared/src/relaySigning.test.ts +18 −0
  210. packages/shared/src/relaySigning.ts +17 −0
  211. pnpm-workspace.yaml +7 −0
  212. scripts/build-desktop-artifact.ts +6 −0
.gitignore+6−0
@@ -5,6 +5,7 @@ node_modules · 4 unmodified lines
5*.log5*.log
6*.tsbuildinfo6*.tsbuildinfo
7apps/*/dist7apps/*/dist
8infra/*/dist
8.astro9.astro
9packages/*/dist10packages/*/dist
10.env11.env
@@ -26,3 +27,8 @@ squashfs-root/ · 15 unmodified lines
26.gstack/27.gstack/
27dist-electron/28dist-electron/
28.electron-runtime/29.electron-runtime/
30node_modules/
31.alchemy/
32*.log
33.env*
34!.env.example
AGENTS.md+2−16
@@ -29,22 +29,8 @@ Long term maintainability is a core priority. If you add new functionality, firs · 28 unmodified lines
29- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.29- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
30- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket.30- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket.
31- `packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. Keep this package schema-only — no runtime logic.31- `packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. Keep this package schema-only — no runtime logic.
32- `packages/shared`: Shared runtime utilities consumed by both server and web. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index.32- `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index.
33 33- `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile.
34## Codex App Server (Important)
35
36T3 Code is currently Codex-first. The server starts `codex app-server` (JSON-RPC over stdio) per provider session, then streams structured events to the browser through WebSocket push messages.
37
38How we use it in this codebase:
39
40- Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`.
41- Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`.
42- WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`.
43- Web app consumes orchestration domain events via WebSocket push on channel `orchestration.domainEvent` (provider runtime activity is projected into orchestration events server-side).
44
45Docs:
46
47- Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server
48 34
49## Reference Repos35## Reference Repos
50 36
README.md+4−0
@@ -48,6 +48,10 @@ We are not accepting contributions yet. · 47 unmodified lines
48 48
49Observability guide: [docs/observability.md](./docs/observability.md)49Observability guide: [docs/observability.md](./docs/observability.md)
50 50
51Relay observability: [docs/relay-observability.md](./docs/relay-observability.md)
52
53T3 Cloud Clerk setup: [docs/t3-cloud-clerk.md](./docs/t3-cloud-clerk.md)
54
51## If you REALLY want to contribute still.... read this first55## If you REALLY want to contribute still.... read this first
52 56
53Before local development, prepare the environment and install dependencies:57Before local development, prepare the environment and install dependencies:
apps/desktop/scripts/dev-electron.mjs+20−10
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process"; · 1 unmodified line
2import { watch } from "node:fs";2import { watch } from "node:fs";
3import { join } from "node:path";3import { join } from "node:path";
4 4
5import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";5import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs";
6import { waitForResources } from "./wait-for-resources.mjs";6import { waitForResources } from "./wait-for-resources.mjs";
7 7
8const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();8const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
@@ -28,6 +28,7 @@ const watchedDirectories = [ · 19 unmodified lines
28const forcedShutdownTimeoutMs = 1_500;28const forcedShutdownTimeoutMs = 1_500;
29const restartDebounceMs = 120;29const restartDebounceMs = 120;
30const childTreeGracePeriodMs = 1_200;30const childTreeGracePeriodMs = 1_200;
31const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim();
31 32
32await waitForResources({33await waitForResources({
33 baseDir: desktopDir,34 baseDir: desktopDir,
@@ -38,6 +39,11 @@ await waitForResources({ · 4 unmodified lines
38 39
39const childEnv = { ...process.env };40const childEnv = { ...process.env };
40delete childEnv.ELECTRON_RUN_AS_NODE;41delete childEnv.ELECTRON_RUN_AS_NODE;
42const devProtocolClient = resolveDevProtocolClient();
43if (devProtocolClient) {
44 childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId;
45 childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1";
46}
41 47
42let shuttingDown = false;48let shuttingDown = false;
43let restartTimer = null;49let restartTimer = null;
@@ -67,15 +73,17 @@ function startApp() { · 23 unmodified lines
67 return;73 return;
68 }74 }
69 75
70 const app = spawn(76 const electronArgs = remoteDebuggingPort
71 resolveElectronPath(),77 ? [`--remote-debugging-port=${remoteDebuggingPort}`]
72 [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"],78 : [];
73 {79 const launchArgs = devProtocolClient
74 cwd: desktopDir,80 ? electronArgs
75 env: childEnv,81 : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"];
76 stdio: "inherit",82 const app = spawn(resolveElectronPath(), launchArgs, {
77 },83 cwd: desktopDir,
78 );84 env: childEnv,
85 stdio: "inherit",
86 });
79 87
80 currentApp = app;88 currentApp = app;
81 89
@@ -125,6 +133,7 @@ async function stopApp() { · 43 unmodified lines
125 app.once("exit", finish);133 app.once("exit", finish);
126 app.kill("SIGTERM");134 app.kill("SIGTERM");
127 killChildTreeByPid(app.pid, "TERM");135 killChildTreeByPid(app.pid, "TERM");
136 cleanupStaleDevApps();
128 137
129 setTimeout(() => {138 setTimeout(() => {
130 if (settled) {139 if (settled) {
@@ -133,6 +142,7 @@ async function stopApp() { · 2 unmodified lines
133 142
134 app.kill("SIGKILL");143 app.kill("SIGKILL");
135 killChildTreeByPid(app.pid, "KILL");144 killChildTreeByPid(app.pid, "KILL");
145 cleanupStaleDevApps();
136 finish();146 finish();
137 }, forcedShutdownTimeoutMs).unref();147 }, forcedShutdownTimeoutMs).unref();
138 });148 });
apps/desktop/scripts/electron-launcher.mjs+162−10
@@ -3,6 +3,7 @@ · 2 unmodified lines
3import { spawnSync } from "node:child_process";3import { spawnSync } from "node:child_process";
4import {4import {
5 copyFileSync,5 copyFileSync,
6 chmodSync,
6 cpSync,7 cpSync,
7 existsSync,8 existsSync,
8 mkdirSync,9 mkdirSync,
@@ -13,21 +14,34 @@ import { · 4 unmodified lines
13 writeFileSync,14 writeFileSync,
14} from "node:fs";15} from "node:fs";
15import { createRequire } from "node:module";16import { createRequire } from "node:module";
16import { dirname, join, resolve } from "node:path";17import { basename, dirname, join, resolve } from "node:path";
17import { fileURLToPath } from "node:url";18import { fileURLToPath } from "node:url";
18import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";19import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";
19 20
20const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);21const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
21const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
22const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
23const LAUNCHER_VERSION = 2;
24
25const __dirname = dirname(fileURLToPath(import.meta.url));22const __dirname = dirname(fileURLToPath(import.meta.url));
26export const desktopDir = resolve(__dirname, "..");23export const desktopDir = resolve(__dirname, "..");
27const repoRoot = resolve(desktopDir, "..", "..");24const repoRoot = resolve(desktopDir, "..", "..");
25const devBundleIdSuffix = basename(repoRoot)
26 .toLowerCase()
27 .replaceAll(/[^a-z0-9]+/g, "");
28export const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
29export const APP_BUNDLE_ID = isDevelopment
30 ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
31 : "com.t3tools.t3code";
32const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
33const LAUNCHER_VERSION = 10;
28const defaultIconPath = join(desktopDir, "resources", "icon.icns");34const defaultIconPath = join(desktopDir, "resources", "icon.icns");
29const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");35const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");
30 36
37function resolveDevelopmentProtocolCallbackPort() {
38 const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10);
39 if (Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort < 65535) {
40 return configuredPort + 1;
41 }
42 return 13774;
43}
44
31function setPlistString(plistPath, key, value) {45function setPlistString(plistPath, key, value) {
32 const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], {46 const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], {
33 encoding: "utf8",47 encoding: "utf8",
@@ -47,6 +61,26 @@ function setPlistString(plistPath, key, value) { · 13 unmodified lines
47 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());61 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());
48}62}
49 63
64function setPlistJson(plistPath, key, value) {
65 const serialized = JSON.stringify(value);
66 const replaceResult = spawnSync("plutil", ["-replace", key, "-json", serialized, plistPath], {
67 encoding: "utf8",
68 });
69 if (replaceResult.status === 0) {
70 return;
71 }
72
73 const insertResult = spawnSync("plutil", ["-insert", key, "-json", serialized, plistPath], {
74 encoding: "utf8",
75 });
76 if (insertResult.status === 0) {
77 return;
78 }
79
80 const details = [replaceResult.stderr, insertResult.stderr].filter(Boolean).join("\n");
81 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());
82}
83
50function runChecked(command, args) {84function runChecked(command, args) {
51 const result = spawnSync(command, args, { encoding: "utf8" });85 const result = spawnSync(command, args, { encoding: "utf8" });
52 if (result.status === 0) {86 if (result.status === 0) {
@@ -57,6 +91,71 @@ function runChecked(command, args) { · 4 unmodified lines
57 throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim());91 throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim());
58}92}
59 93
94function shellSingleQuote(value) {
95 return `'${value.replaceAll("'", "'\\''")}'`;
96}
97
98function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) {
99 const mainEntryPath = join(desktopDir, "dist-electron", "main.cjs");
100 const protocolCallbackUrl = `http://127.0.0.1:${resolveDevelopmentProtocolCallbackPort()}/auth/callback`;
101 const envEntries = [
102 ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL],
103 ["T3CODE_PORT", process.env.T3CODE_PORT],
104 ["T3CODE_HOME", process.env.T3CODE_HOME],
105 ["T3CODE_COMMIT_HASH", process.env.T3CODE_COMMIT_HASH],
106 ["T3CODE_OTLP_TRACES_URL", process.env.T3CODE_OTLP_TRACES_URL],
107 ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS],
108 ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID],
109 ["T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED", "1"],
110 ["T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL", protocolCallbackUrl],
111 ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0);
112 writeFileSync(
113 targetBinaryPath,
114 [
115 "#!/bin/sh",
116 ...envEntries.map(([name, value]) => `export ${name}=${shellSingleQuote(value)}`),
117 'for arg in "$@"; do',
118 ' case "$arg" in',
119 " t3code-dev://auth/callback*)",
120 ' if [ -n "$T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL" ]; then',
121 ' /usr/bin/curl -fsS --max-time 2 -X POST --data-binary "$arg" "$T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL" >/dev/null 2>&1 && exit 0',
122 " fi",
123 " ;;",
124 " esac",
125 "done",
126 `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopDir)} ${shellSingleQuote(mainEntryPath)} "$@"`,
127 "",
128 ].join("\n"),
129 );
130 chmodSync(targetBinaryPath, 0o755);
131}
132
133function registerMacLauncherBundle(appBundlePath) {
134 runChecked(
135 "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
136 ["-f", appBundlePath],
137 );
138
139 if (!isDevelopment) {
140 return;
141 }
142
143 for (const scheme of APP_PROTOCOL_SCHEMES) {
144 runChecked("osascript", [
145 "-l",
146 "JavaScript",
147 "-e",
148 [
149 'ObjC.import("CoreServices");',
150 `const scheme = $.NSString.alloc.initWithUTF8String(${JSON.stringify(scheme)});`,
151 `const bundle = $.NSString.alloc.initWithUTF8String(${JSON.stringify(APP_BUNDLE_ID)});`,
152 "const status = $.LSSetDefaultHandlerForURLScheme(scheme, bundle);",
153 "if (status !== 0) throw new Error(`LSSetDefaultHandlerForURLScheme failed: ${status}`);",
154 ].join(" "),
155 ]);
156 }
157}
158
60function ensureDevelopmentIconIcns(runtimeDir) {159function ensureDevelopmentIconIcns(runtimeDir) {
61 const generatedIconPath = join(runtimeDir, "icon-dev.icns");160 const generatedIconPath = join(runtimeDir, "icon-dev.icns");
62 mkdirSync(runtimeDir, { recursive: true });161 mkdirSync(runtimeDir, { recursive: true });
@@ -115,12 +214,49 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { · 52 unmodified lines
115 setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME);214 setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME);
116 setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID);215 setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID);
117 setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns");216 setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns");
217 setPlistJson(infoPlistPath, "CFBundleURLTypes", [
218 {
219 CFBundleURLName: APP_BUNDLE_ID,
220 CFBundleURLSchemes: APP_PROTOCOL_SCHEMES,
221 },
222 ]);
118 223
119 const resourcesDir = join(appBundlePath, "Contents", "Resources");224 const resourcesDir = join(appBundlePath, "Contents", "Resources");
120 copyFileSync(iconPath, join(resourcesDir, "icon.icns"));225 copyFileSync(iconPath, join(resourcesDir, "icon.icns"));
121 copyFileSync(iconPath, join(resourcesDir, "electron.icns"));226 copyFileSync(iconPath, join(resourcesDir, "electron.icns"));
122}227}
123 228
229function patchHelperBundleInfoPlists(appBundlePath) {
230 const helperBundleNames = [
231 ["Electron Helper.app", "helper", `${APP_DISPLAY_NAME} Helper`],
232 ["Electron Helper (GPU).app", "helper.gpu", `${APP_DISPLAY_NAME} Helper (GPU)`],
233 ["Electron Helper (Plugin).app", "helper.plugin", `${APP_DISPLAY_NAME} Helper (Plugin)`],
234 ["Electron Helper (Renderer).app", "helper.renderer", `${APP_DISPLAY_NAME} Helper (Renderer)`],
235 ];
236
237 for (const [bundleName, bundleIdentifierSuffix, bundleDisplayName] of helperBundleNames) {
238 const infoPlistPath = join(
239 appBundlePath,
240 "Contents",
241 "Frameworks",
242 bundleName,
243 "Contents",
244 "Info.plist",
245 );
246 if (!existsSync(infoPlistPath)) {
247 continue;
248 }
249
250 setPlistString(infoPlistPath, "CFBundleDisplayName", bundleDisplayName);
251 setPlistString(infoPlistPath, "CFBundleName", bundleDisplayName);
252 setPlistString(
253 infoPlistPath,
254 "CFBundleIdentifier",
255 `${APP_BUNDLE_ID}.${bundleIdentifierSuffix}`,
256 );
257 }
258}
259
124function readJson(path) {260function readJson(path) {
125 try {261 try {
126 return JSON.parse(readFileSync(path, "utf8"));262 return JSON.parse(readFileSync(path, "utf8"));
@@ -144,6 +280,8 @@ function buildMacLauncher(electronBinaryPath) { · 17 unmodified lines
144 sourceAppBundlePath,280 sourceAppBundlePath,
145 sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs,281 sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs,
146 iconMtimeMs: statSync(iconPath).mtimeMs,282 iconMtimeMs: statSync(iconPath).mtimeMs,
283 appBundleId: APP_BUNDLE_ID,
284 appProtocolSchemes: APP_PROTOCOL_SCHEMES,
147 };285 };
148 286
149 const currentMetadata = readJson(metadataPath);287 const currentMetadata = readJson(metadataPath);
@@ -152,13 +290,19 @@ function buildMacLauncher(electronBinaryPath) { · 2 unmodified lines
152 currentMetadata &&290 currentMetadata &&
153 JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata)291 JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata)
154 ) {292 ) {
293 registerMacLauncherBundle(targetAppBundlePath);
155 return targetBinaryPath;294 return targetBinaryPath;
156 }295 }
157 296
158 rmSync(targetAppBundlePath, { recursive: true, force: true });297 rmSync(targetAppBundlePath, { recursive: true, force: true });
159 cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true });298 cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true });
160 patchMainBundleInfoPlist(targetAppBundlePath, iconPath);299 patchMainBundleInfoPlist(targetAppBundlePath, iconPath);
300 patchHelperBundleInfoPlists(targetAppBundlePath);
301 if (isDevelopment) {
302 writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath);
303 }
161 writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`);304 writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`);
305 registerMacLauncherBundle(targetAppBundlePath);
162 306
163 return targetBinaryPath;307 return targetBinaryPath;
164}308}
@@ -173,11 +317,19 @@ export function resolveElectronPath() { · 8 unmodified lines
173 return electronBinaryPath;317 return electronBinaryPath;
174 }318 }
175 319
176 // Dev launches do not need a renamed app bundle badly enough to risk breaking320 return buildMacLauncher(electronBinaryPath);
177 // Electron helper resource lookup on macOS.321}
178 if (isDevelopment) {322
179 return electronBinaryPath;323export function resolveDevProtocolClient() {
324 if (process.platform !== "darwin" || !isDevelopment) {
325 return null;
180 }326 }
181 327
182 return buildMacLauncher(electronBinaryPath);328 const require = createRequire(import.meta.url);
329 const electronBinaryPath = require("electron");
330 const launcherBinaryPath = buildMacLauncher(electronBinaryPath);
331 return {
332 appBundlePath: resolve(launcherBinaryPath, "..", "..", ".."),
333 appBundleId: APP_BUNDLE_ID,
334 };
183}335}
apps/desktop/src/app/DesktopApp.ts+4−1
@@ -1,16 +1,17 @@
1import * as Cause from "effect/Cause";1import * as Cause from "effect/Cause";
2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";2import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";4import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";5import * as Ref from "effect/Ref";
7 6
8import * as NetService from "@t3tools/shared/Net";7import * as NetService from "@t3tools/shared/Net";
8import * as Crypto from "effect/Crypto";
9import * as ElectronApp from "../electron/ElectronApp.ts";9import * as ElectronApp from "../electron/ElectronApp.ts";
10import * as ElectronDialog from "../electron/ElectronDialog.ts";10import * as ElectronDialog from "../electron/ElectronDialog.ts";
11import * as ElectronProtocol from "../electron/ElectronProtocol.ts";11import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
12import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";12import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
13import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";13import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
14import * as DesktopCloudAuth from "./DesktopCloudAuth.ts";
14import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";15import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
15import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";16import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
16import * as DesktopEnvironment from "./DesktopEnvironment.ts";17import * as DesktopEnvironment from "./DesktopEnvironment.ts";
@@ -190,6 +191,7 @@ const startup = Effect.gen(function* () { · 173 unmodified lines
190 const electronApp = yield* ElectronApp.ElectronApp;191 const electronApp = yield* ElectronApp.ElectronApp;
191 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;192 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
192 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;193 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
194 const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth;
193 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;195 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
194 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;196 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
195 const updates = yield* DesktopUpdates.DesktopUpdates;197 const updates = yield* DesktopUpdates.DesktopUpdates;
@@ -207,6 +209,7 @@ const startup = Effect.gen(function* () { · 11 unmodified lines
207 209
208 yield* appIdentity.configure;210 yield* appIdentity.configure;
209 yield* lifecycle.register;211 yield* lifecycle.register;
212 yield* cloudAuth.configure;
210 213
211 yield* electronApp.whenReady.pipe(214 yield* electronApp.whenReady.pipe(
212 Effect.withSpan("desktop.electron.whenReady"),215 Effect.withSpan("desktop.electron.whenReady"),
apps/desktop/src/app/DesktopAppIdentity.test.ts+3−0
@@ -53,6 +53,9 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => · 52 unmodified lines
53 calls.setAboutPanelOptions.push(options);53 calls.setAboutPanelOptions.push(options);
54 }),54 }),
55 setAppUserModelId: () => Effect.void,55 setAppUserModelId: () => Effect.void,
56 requestSingleInstanceLock: Effect.succeed(true),
57 isDefaultProtocolClient: () => Effect.succeed(false),
58 setAsDefaultProtocolClient: () => Effect.succeed(true),
56 setDesktopName: () => Effect.void,59 setDesktopName: () => Effect.void,
57 setDockIcon: (iconPath) =>60 setDockIcon: (iconPath) =>
58 Effect.sync(() => {61 Effect.sync(() => {
apps/desktop/src/app/DesktopCloudAuth.test.ts+302−0
@@ -0,0 +1,302 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import * as ElectronApp from "../electron/ElectronApp.ts";
8import * as ElectronWindow from "../electron/ElectronWindow.ts";
9import * as IpcChannels from "../ipc/channels.ts";
10import * as DesktopCloudAuth from "./DesktopCloudAuth.ts";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12
13interface CloudAuthHarness {
14 readonly app: ElectronApp.ElectronAppShape;
15 readonly window: ElectronWindow.ElectronWindowShape;
16 readonly listeners: Map<string, readonly ((...args: readonly unknown[]) => void)[]>;
17 readonly protocolRegistrations: {
18 readonly protocol: string;
19 readonly path?: string;
20 readonly args?: readonly string[];
21 }[];
22 readonly sends: { readonly channel: string; readonly args: readonly unknown[] }[];
23 readonly reveals: unknown[];
24 readonly layer: Layer.Layer<
25 | DesktopCloudAuth.DesktopCloudAuth
26 | DesktopEnvironment.DesktopEnvironment
27 | ElectronApp.ElectronApp
28 | ElectronWindow.ElectronWindow
29 >;
30}
31
32function makeHarness(input: { readonly isDevelopment: boolean }): CloudAuthHarness {
33 const listeners = new Map<string, ((...args: readonly unknown[]) => void)[]>();
34 const protocolRegistrations: CloudAuthHarness["protocolRegistrations"] = [];
35 const sends: CloudAuthHarness["sends"] = [];
36 const reveals: unknown[] = [];
37 const mainWindow = { id: "main-window" };
38
39 const app = ElectronApp.ElectronApp.of({
40 metadata: Effect.succeed({
41 appVersion: "0.0.0-test",
42 appPath: "/tmp/t3-code-test",
43 isPackaged: !input.isDevelopment,
44 resourcesPath: "/tmp/t3-code-test/resources",
45 runningUnderArm64Translation: false,
46 }),
47 name: Effect.succeed("T3 Code"),
48 whenReady: Effect.void,
49 quit: Effect.void,
50 exit: () => Effect.void,
51 relaunch: () => Effect.void,
52 setPath: () => Effect.void,
53 setName: () => Effect.void,
54 setAboutPanelOptions: () => Effect.void,
55 setAppUserModelId: () => Effect.void,
56 requestSingleInstanceLock: Effect.succeed(true),
57 isDefaultProtocolClient: () => Effect.succeed(false),
58 setAsDefaultProtocolClient: (protocol, path, args) =>
59 Effect.sync(() => {
60 protocolRegistrations.push({
61 protocol,
62 ...(path === undefined ? {} : { path }),
63 ...(args === undefined ? {} : { args }),
64 });
65 return true;
66 }),
67 setDesktopName: () => Effect.void,
68 setDockIcon: () => Effect.void,
69 appendCommandLineSwitch: () => Effect.void,
70 on: (eventName, listener) =>
71 Effect.sync(() => {
72 const erasedListener = listener as (...args: readonly unknown[]) => void;
73 listeners.set(eventName, [...(listeners.get(eventName) ?? []), erasedListener]);
74 }),
75 });
76
77 const window = ElectronWindow.ElectronWindow.of({
78 create: () => Effect.die("not used"),
79 main: Effect.succeed(Option.some(mainWindow as never)),
80 currentMainOrFirst: Effect.succeed(Option.some(mainWindow as never)),
81 focusedMainOrFirst: Effect.succeed(Option.some(mainWindow as never)),
82 setMain: () => Effect.void,
83 clearMain: () => Effect.void,
84 reveal: (target) =>
85 Effect.sync(() => {
86 reveals.push(target);
87 }),
88 sendAll: (channel, ...args) =>
89 Effect.sync(() => {
90 sends.push({ channel, args });
91 }),
92 destroyAll: Effect.void,
93 syncAllAppearance: () => Effect.void,
94 });
95
96 const environment = DesktopEnvironment.DesktopEnvironment.of({
97 isDevelopment: input.isDevelopment,
98 } as DesktopEnvironment.DesktopEnvironmentShape);
99 const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment);
100
101 return {
102 app,
103 window,
104 listeners,
105 protocolRegistrations,
106 sends,
107 reveals,
108 layer: Layer.mergeAll(
109 DesktopCloudAuth.layer.pipe(
110 Layer.provideMerge(environmentLayer),
111 Layer.provide(NodeServices.layer),
112 ),
113 Layer.succeed(ElectronApp.ElectronApp, app),
114 Layer.succeed(ElectronWindow.ElectronWindow, window),
115 ),
116 };
117}
118
119function emitAppEvent(
120 harness: CloudAuthHarness,
121 eventName: string,
122 ...args: readonly unknown[]
123): void {
124 for (const listener of harness.listeners.get(eventName) ?? []) {
125 listener(...args);
126 }
127}
128
129const flushCloudAuthDispatch = Effect.promise(() => Promise.resolve());
130
131describe("DesktopCloudAuth", () => {
132 it("uses separate callback schemes for packaged and development builds", () => {
133 assert.equal(
134 DesktopCloudAuth.resolveCloudAuthCallbackScheme({ isDevelopment: false }),
135 "t3code",
136 );
137 assert.equal(
138 DesktopCloudAuth.resolveCloudAuthCallbackScheme({ isDevelopment: true }),
139 "t3code-dev",
140 );
141 });
142
143 it("builds a native callback URL with request state", () => {
144 assert.equal(
145 DesktopCloudAuth.buildCloudAuthCallbackUrl({
146 scheme: "t3code",
147 state: "state-1",
148 }),
149 "t3code://auth/callback?t3_state=state-1",
150 );
151 });
152
153 it("accepts only the expected scheme, host, path, and state", () => {
154 assert.isNotNull(
155 DesktopCloudAuth.parseCloudAuthCallbackUrl({
156 rawUrl: "t3code://auth/callback?rotating_token_nonce=nonce&t3_state=state-1",
157 scheme: "t3code",
158 state: "state-1",
159 }),
160 );
161 assert.isNull(
162 DesktopCloudAuth.parseCloudAuthCallbackUrl({
163 rawUrl: "t3code://auth/callback?rotating_token_nonce=nonce&t3_state=wrong",
164 scheme: "t3code",
165 state: "state-1",
166 }),
167 );
168 assert.isNull(
169 DesktopCloudAuth.parseCloudAuthCallbackUrl({
170 rawUrl: "https://example.com/callback?rotating_token_nonce=nonce&t3_state=state-1",
171 scheme: "t3code",
172 state: "state-1",
173 }),
174 );
175 });
176
177 it("builds a native development callback URL with request state", () => {
178 assert.equal(
179 DesktopCloudAuth.buildCloudAuthCallbackUrl({
180 scheme: "t3code-dev",
181 state: "state-1",
182 }),
183 "t3code-dev://auth/callback?t3_state=state-1",
184 );
185 });
186
187 it.effect("registers the development protocol client and dispatches matching callbacks", () => {
188 const harness = makeHarness({ isDevelopment: true });
189
190 return Effect.gen(function* () {
191 const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth;
192 yield* cloudAuth.configure;
193 const redirectUrl = yield* cloudAuth.createRequest;
194 const callbackUrl = new URL(redirectUrl);
195 callbackUrl.searchParams.set("rotating_token_nonce", "nonce-1");
196
197 let prevented = false;
198 emitAppEvent(
199 harness,
200 "open-url",
201 { preventDefault: () => (prevented = true) },
202 callbackUrl.toString(),
203 );
204 yield* flushCloudAuthDispatch;
205
206 assert.isTrue(prevented);
207 assert.deepEqual(
208 harness.protocolRegistrations.map((registration) => registration.protocol),
209 ["t3code-dev"],
210 );
211 assert.isString(harness.protocolRegistrations[0]?.path);
212 assert.isArray(harness.protocolRegistrations[0]?.args);
213 assert.deepEqual(harness.sends, [
214 {
215 channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL,
216 args: [callbackUrl.toString()],
217 },
218 ]);
219 assert.lengthOf(harness.reveals, 1);
220 }).pipe(Effect.provide(harness.layer), Effect.scoped);
221 });
222
223 it.effect("rejects mismatched callback state and only consumes the pending request once", () => {
224 const harness = makeHarness({ isDevelopment: false });
225
226 return Effect.gen(function* () {
227 const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth;
228 yield* cloudAuth.configure;
229 const redirectUrl = yield* cloudAuth.createRequest;
230 const validCallback = new URL(redirectUrl);
231 validCallback.searchParams.set("rotating_token_nonce", "nonce-1");
232 const invalidCallback = new URL(validCallback);
233 invalidCallback.searchParams.set(DesktopCloudAuth.CLOUD_AUTH_CALLBACK_STATE_PARAM, "wrong");
234
235 emitAppEvent(
236 harness,
237 "open-url",
238 { preventDefault: () => undefined },
239 invalidCallback.toString(),
240 );
241 yield* flushCloudAuthDispatch;
242 assert.deepEqual(harness.sends, []);
243
244 emitAppEvent(
245 harness,
246 "open-url",
247 { preventDefault: () => undefined },
248 validCallback.toString(),
249 );
250 yield* flushCloudAuthDispatch;
251 emitAppEvent(
252 harness,
253 "open-url",
254 { preventDefault: () => undefined },
255 validCallback.toString(),
256 );
257 yield* flushCloudAuthDispatch;
258
259 assert.deepEqual(
260 harness.protocolRegistrations.map((registration) => registration.protocol),
261 ["t3code"],
262 );
263 assert.deepEqual(harness.sends, [
264 {
265 channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL,
266 args: [validCallback.toString()],
267 },
268 ]);
269 }).pipe(Effect.provide(harness.layer), Effect.scoped);
270 });
271
272 it.effect(
273 "routes second-instance callbacks and reveals the window for non-callback launches",
274 () => {
275 const harness = makeHarness({ isDevelopment: true });
276
277 return Effect.gen(function* () {
278 const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth;
279 yield* cloudAuth.configure;
280 const redirectUrl = yield* cloudAuth.createRequest;
281 const callbackUrl = new URL(redirectUrl);
282 callbackUrl.searchParams.set("rotating_token_nonce", "nonce-1");
283
284 emitAppEvent(harness, "second-instance", {}, ["electron", callbackUrl.toString()]);
285 yield* flushCloudAuthDispatch;
286
287 const revealCountAfterCallback = harness.reveals.length;
288 emitAppEvent(harness, "second-instance", {}, ["electron", "--opened-from-dock"]);
289 yield* flushCloudAuthDispatch;
290
291 assert.deepEqual(harness.sends, [
292 {
293 channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL,
294 args: [callbackUrl.toString()],
295 },
296 ]);
297 assert.equal(revealCountAfterCallback, 1);
298 assert.equal(harness.reveals.length, 2);
299 }).pipe(Effect.provide(harness.layer), Effect.scoped);
300 },
301 );
302});
apps/desktop/src/app/DesktopCloudAuth.ts+330−0
@@ -0,0 +1,330 @@
1import * as Context from "effect/Context";
2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Scope from "effect/Scope";
8import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
9
10import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
11import * as ElectronApp from "../electron/ElectronApp.ts";
12import * as ElectronWindow from "../electron/ElectronWindow.ts";
13import * as IpcChannels from "../ipc/channels.ts";
14import * as DesktopEnvironment from "./DesktopEnvironment.ts";
15import type * as Electron from "electron";
16
17export const CLOUD_AUTH_CALLBACK_HOST = "auth";
18export const CLOUD_AUTH_CALLBACK_PATHNAME = "/callback";
19export const CLOUD_AUTH_CALLBACK_STATE_PARAM = "t3_state";
20export const CLOUD_AUTH_CALLBACK_SCHEME = "t3code";
21export const DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME = "t3code-dev";
22
23const CLOUD_AUTH_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
24
25export class DesktopCloudAuthCallbackServerError extends Data.TaggedError(
26 "DesktopCloudAuthCallbackServerError",
27)<{
28 readonly cause: unknown;
29}> {
30 override get message() {
31 return "Failed to start the desktop cloud auth callback server.";
32 }
33}
34
35interface PendingCloudAuthRequest {
36 readonly state: string;
37 readonly redirectUrl: string;
38 readonly close: () => void;
39}
40
41export interface DesktopCloudAuthShape {
42 readonly createRequest: Effect.Effect<string, DesktopCloudAuthCallbackServerError>;
43 readonly configure: Effect.Effect<
44 void,
45 never,
46 ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope
47 >;
48}
49
50export class DesktopCloudAuth extends Context.Service<DesktopCloudAuth, DesktopCloudAuthShape>()(
51 "@t3tools/desktop/app/DesktopCloudAuth",
52) {}
53
54export function resolveCloudAuthCallbackScheme(input: { readonly isDevelopment: boolean }): string {
55 return input.isDevelopment ? DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME : CLOUD_AUTH_CALLBACK_SCHEME;
56}
57
58export function buildCloudAuthCallbackUrl(input: {
59 readonly scheme: string;
60 readonly state: string;
61}): string {
62 const url = new URL(
63 `${input.scheme}://${CLOUD_AUTH_CALLBACK_HOST}${CLOUD_AUTH_CALLBACK_PATHNAME}`,
64 );
65 url.searchParams.set(CLOUD_AUTH_CALLBACK_STATE_PARAM, input.state);
66 return url.toString();
67}
68
69export function parseCloudAuthCallbackUrl(input: {
70 readonly rawUrl: unknown;
71 readonly scheme: string;
72 readonly state: string;
73}): URL | null {
74 if (typeof input.rawUrl !== "string") {
75 return null;
76 }
77
78 try {
79 const url = new URL(input.rawUrl);
80 if (url.protocol !== `${input.scheme}:`) return null;
81 if (url.hostname !== CLOUD_AUTH_CALLBACK_HOST) return null;
82 if (url.pathname !== CLOUD_AUTH_CALLBACK_PATHNAME) return null;
83 if (url.searchParams.get(CLOUD_AUTH_CALLBACK_STATE_PARAM) !== input.state) return null;
84 return url;
85 } catch {
86 return null;
87 }
88}
89
90export function findCloudAuthCallbackUrl(input: {
91 readonly values: readonly unknown[];
92 readonly scheme: string;
93 readonly state: string;
94}): URL | null {
95 for (const value of input.values) {
96 const url = parseCloudAuthCallbackUrl({
97 rawUrl: value,
98 scheme: input.scheme,
99 state: input.state,
100 });
101 if (url) return url;
102 }
103 return null;
104}
105
106export function resolveProtocolClientLaunchArgs(input: {
107 readonly argv: readonly string[];
108}): readonly string[] {
109 return input.argv.slice(1);
110}
111
112function resolveConfiguredProtocolClient(): {
113 readonly path: string;
114 readonly args: readonly string[];
115} | null {
116 const path = process.env.T3CODE_DESKTOP_PROTOCOL_CLIENT_PATH?.trim();
117 if (!path) return null;
118
119 return {
120 path,
121 args: (process.env.T3CODE_DESKTOP_PROTOCOL_CLIENT_ARGS ?? "")
122 .split("\n")
123 .map((arg) => arg.trim())
124 .filter((arg) => arg.length > 0),
125 };
126}
127
128function isProtocolRegistrationManagedExternally(): boolean {
129 return process.env.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED?.trim() === "1";
130}
131
132function resolveProtocolCallbackForwardUrl(): URL | null {
133 const rawUrl = process.env.T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL?.trim();
134 if (!rawUrl) return null;
135
136 try {
137 const url = new URL(rawUrl);
138 if (url.protocol !== "http:") return null;
139 if (url.hostname !== "127.0.0.1") return null;
140 if (url.pathname !== "/auth/callback") return null;
141 if (!url.port) return null;
142 return url;
143 } catch {
144 return null;
145 }
146}
147
148const closeCloudAuthRequest = (request: PendingCloudAuthRequest | null): null => {
149 request?.close();
150 return null;
151};
152
153function createCloudAuthRequestTimeout(onExpire: () => void): ReturnType<typeof setTimeout> {
154 // @effect-diagnostics-next-line globalTimers:off - Auth request expiry is tied to an Electron callback server, not fiber scheduling.
155 return setTimeout(onExpire, CLOUD_AUTH_REQUEST_TIMEOUT_MS);
156}
157
158function ignoreCloudAuthCallback(_rawUrl: string) {}
159
160function startProtocolCallbackForwardServer(
161 callbackUrl: URL,
162 dispatch: (rawUrl: string) => void,
163): Effect.Effect<void, never, Scope.Scope> {
164 const port = Number.parseInt(callbackUrl.port, 10);
165 const routesLayer = HttpRouter.add(
166 "POST",
167 "/auth/callback",
168 Effect.gen(function* () {
169 const request = yield* HttpServerRequest.HttpServerRequest;
170 const rawUrl = yield* request.text;
171 yield* Effect.sync(() => {
172 dispatch(rawUrl);
173 });
174 return HttpServerResponse.empty({ status: 204 });
175 }),
176 );
177
178 return Effect.gen(function* () {
179 const NodeHttp = yield* Effect.promise(() => import("node:http"));
180 const serverLayer = NodeHttpServer.layer(NodeHttp.createServer, {
181 host: callbackUrl.hostname,
182 port,
183 });
184 yield* Layer.launch(HttpRouter.serve(routesLayer).pipe(Layer.provideMerge(serverLayer))).pipe(
185 Effect.forkScoped,
186 );
187 });
188}
189
190const make = Effect.gen(function* () {
191 const crypto = yield* Crypto.Crypto;
192 const environment = yield* DesktopEnvironment.DesktopEnvironment;
193 let pendingAuthRequest: PendingCloudAuthRequest | null = null;
194 let dispatchCloudAuthCallback: (rawUrl: string) => void = ignoreCloudAuthCallback;
195 const makeCloudAuthRequestState = Effect.gen(function* () {
196 const [left, right] = yield* Effect.all([crypto.randomUUIDv4, crypto.randomUUIDv4]);
197 return `${left}${right}`.replaceAll("-", "");
198 });
199
200 return DesktopCloudAuth.of({
201 createRequest: Effect.gen(function* () {
202 const scheme = resolveCloudAuthCallbackScheme({
203 isDevelopment: environment.isDevelopment,
204 });
205 const state = yield* makeCloudAuthRequestState.pipe(
206 Effect.mapError((cause) => new DesktopCloudAuthCallbackServerError({ cause })),
207 );
208
209 pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest);
210
211 const redirectUrl = buildCloudAuthCallbackUrl({ scheme, state });
212 const timeout = createCloudAuthRequestTimeout(() => {
213 pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest);
214 });
215 pendingAuthRequest = {
216 state,
217 redirectUrl,
218 close: () => clearTimeout(timeout),
219 };
220 return redirectUrl;
221 }),
222 configure: Effect.gen(function* () {
223 const electronApp = yield* ElectronApp.ElectronApp;
224 const electronWindow = yield* ElectronWindow.ElectronWindow;
225 const scope = yield* Scope.Scope;
226 const context = yield* Effect.context<ElectronWindow.ElectronWindow>();
227 const runPromise = Effect.runPromiseWith(context);
228 const scheme = resolveCloudAuthCallbackScheme({
229 isDevelopment: environment.isDevelopment,
230 });
231
232 yield* Scope.addFinalizer(
233 scope,
234 Effect.sync(() => {
235 pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest);
236 }),
237 );
238
239 if (isProtocolRegistrationManagedExternally()) {
240 // Development macOS launchers set the default URL handler before the stock Electron
241 // process starts so LaunchServices binds the scheme to the worktree-specific app bundle.
242 } else if (environment.isDevelopment) {
243 const configuredClient = resolveConfiguredProtocolClient();
244 if (configuredClient) {
245 yield* electronApp.setAsDefaultProtocolClient(
246 scheme,
247 configuredClient.path,
248 configuredClient.args,
249 );
250 } else {
251 yield* electronApp.setAsDefaultProtocolClient(
252 scheme,
253 process.execPath,
254 resolveProtocolClientLaunchArgs({ argv: process.argv }),
255 );
256 }
257 } else {
258 yield* electronApp.setAsDefaultProtocolClient(scheme);
259 }
260
261 dispatchCloudAuthCallback = (rawUrl: string) => {
262 const pending = pendingAuthRequest;
263 const callbackUrl = pending
264 ? parseCloudAuthCallbackUrl({ rawUrl, scheme, state: pending.state })
265 : null;
266 if (!callbackUrl) {
267 return;
268 }
269
270 pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest);
271 void runPromise(
272 Effect.gen(function* () {
273 yield* electronWindow.sendAll(
274 IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL,
275 callbackUrl.toString(),
276 );
277 const mainWindow = yield* electronWindow.currentMainOrFirst;
278 if (Option.isSome(mainWindow)) {
279 yield* electronWindow.reveal(mainWindow.value);
280 }
281 }),
282 );
283 };
284
285 const protocolCallbackForwardUrl = resolveProtocolCallbackForwardUrl();
286 if (environment.isDevelopment && protocolCallbackForwardUrl) {
287 yield* startProtocolCallbackForwardServer(
288 protocolCallbackForwardUrl,
289 dispatchCloudAuthCallback,
290 );
291 }
292
293 const hasInstanceLock = yield* electronApp.requestSingleInstanceLock;
294 if (!hasInstanceLock) {
295 return yield* electronApp.quit;
296 }
297
298 yield* electronApp.on<[Electron.Event, string]>("open-url", (event, rawUrl) => {
299 event.preventDefault?.();
300 dispatchCloudAuthCallback(rawUrl);
301 });
302
303 yield* electronApp.on<[Electron.Event, readonly string[]]>(
304 "second-instance",
305 (_event, argv) => {
306 const values = resolveProtocolClientLaunchArgs({ argv });
307 const pending = pendingAuthRequest;
308 const callbackUrl = pending
309 ? findCloudAuthCallbackUrl({ values, scheme, state: pending.state })
310 : null;
311 if (callbackUrl) {
312 dispatchCloudAuthCallback(callbackUrl.toString());
313 return;
314 }
315
316 void runPromise(
317 Effect.gen(function* () {
318 const mainWindow = yield* electronWindow.currentMainOrFirst;
319 if (Option.isSome(mainWindow)) {
320 yield* electronWindow.reveal(mainWindow.value);
321 }
322 }),
323 );
324 },
325 );
326 }).pipe(Effect.withSpan("desktop.cloudAuth.configure")),
327 });
328});
329
330export const layer = Layer.effect(DesktopCloudAuth, make);
apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts+96−0
@@ -0,0 +1,96 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7
8import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
9import * as DesktopConfig from "./DesktopConfig.ts";
10import * as DesktopEnvironment from "./DesktopEnvironment.ts";
11import * as DesktopCloudAuthTokenStore from "./DesktopCloudAuthTokenStore.ts";
12
13const textDecoder = new TextDecoder();
14const textEncoder = new TextEncoder();
15
16function makeSafeStorageLayer(input: { readonly available: boolean }) {
17 return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, {
18 isEncryptionAvailable: Effect.succeed(input.available),
19 encryptString: (value) => Effect.succeed(textEncoder.encode(`enc:${value}`)),
20 decryptString: (value) => {
21 const decoded = textDecoder.decode(value);
22 if (!decoded.startsWith("enc:")) {
23 return Effect.fail(
24 new ElectronSafeStorage.ElectronSafeStorageDecryptError({
25 cause: new Error("invalid encrypted token"),
26 }),
27 );
28 }
29 return Effect.succeed(decoded.slice("enc:".length));
30 },
31 } satisfies ElectronSafeStorage.ElectronSafeStorageShape);
32}
33
34function makeLayer(baseDir: string, input?: { readonly encryptionAvailable?: boolean }) {
35 const environmentLayer = DesktopEnvironment.layer({
36 dirname: "/repo/apps/desktop/src",
37 homeDirectory: baseDir,
38 platform: "darwin",
39 processArch: "x64",
40 appVersion: "1.2.3",
41 appPath: "/repo",
42 isPackaged: true,
43 resourcesPath: "/missing/resources",
44 runningUnderArm64Translation: false,
45 }).pipe(
46 Layer.provide(
47 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
48 ),
49 );
50
51 return DesktopCloudAuthTokenStore.layer.pipe(
52 Layer.provideMerge(environmentLayer),
53 Layer.provideMerge(makeSafeStorageLayer({ available: input?.encryptionAvailable ?? true })),
54 Layer.provideMerge(NodeServices.layer),
55 );
56}
57
58const withTokenStore = <A, E, R>(
59 effect: Effect.Effect<A, E, R | DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore>,
60 input?: { readonly encryptionAvailable?: boolean },
61) =>
62 Effect.gen(function* () {
63 const fileSystem = yield* FileSystem.FileSystem;
64 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
65 prefix: "t3-desktop-cloud-auth-token-test-",
66 });
67 return yield* effect.pipe(Effect.provide(makeLayer(baseDir, input)));
68 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
69
70describe("DesktopCloudAuthTokenStore", () => {
71 it.effect("persists, reads, and clears the encrypted Clerk client JWT", () =>
72 withTokenStore(
73 Effect.gen(function* () {
74 const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore;
75
76 assert.isTrue(yield* tokenStore.set("__client=test.jwt"));
77 assert.deepStrictEqual(yield* tokenStore.get, Option.some("__client=test.jwt"));
78
79 yield* tokenStore.clear;
80 assert.deepStrictEqual(yield* tokenStore.get, Option.none());
81 }),
82 ),
83 );
84
85 it.effect("does not persist a token when Electron safe storage is unavailable", () =>
86 withTokenStore(
87 Effect.gen(function* () {
88 const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore;
89
90 assert.isFalse(yield* tokenStore.set("__client=test.jwt"));
91 assert.deepStrictEqual(yield* tokenStore.get, Option.none());
92 }),
93 { encryptionAvailable: false },
94 ),
95 );
96});
apps/desktop/src/app/DesktopCloudAuthTokenStore.ts+155−0
@@ -0,0 +1,155 @@
1import { fromLenientJson } from "@t3tools/shared/schemaJson";
2import * as Context from "effect/Context";
3import * as Crypto from "effect/Crypto";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as PlatformError from "effect/PlatformError";
12import * as Schema from "effect/Schema";
13
14import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
15import * as DesktopEnvironment from "./DesktopEnvironment.ts";
16
17interface CloudAuthTokenDocument {
18 readonly version: number;
19 readonly encryptedClientJwt: string;
20}
21
22const CloudAuthTokenDocumentSchema = Schema.Struct({
23 version: Schema.Number,
24 encryptedClientJwt: Schema.String,
25});
26
27const CloudAuthTokenDocumentJson = fromLenientJson(CloudAuthTokenDocumentSchema);
28const decodeCloudAuthTokenDocumentJson = Schema.decodeEffect(CloudAuthTokenDocumentJson);
29const encodeCloudAuthTokenDocumentJson = Schema.encodeEffect(CloudAuthTokenDocumentJson);
30
31export class DesktopCloudAuthTokenStoreWriteError extends Data.TaggedError(
32 "DesktopCloudAuthTokenStoreWriteError",
33)<{
34 readonly cause: PlatformError.PlatformError | Schema.SchemaError;
35}> {
36 override get message() {
37 return `Failed to write desktop cloud auth token: ${this.cause.message}`;
38 }
39}
40
41export class DesktopCloudAuthTokenStoreDecodeError extends Data.TaggedError(
42 "DesktopCloudAuthTokenStoreDecodeError",
43)<{
44 readonly cause: Encoding.EncodingError;
45}> {
46 override get message() {
47 return "Failed to decode desktop cloud auth token.";
48 }
49}
50
51export interface DesktopCloudAuthTokenStoreShape {
52 readonly get: Effect.Effect<
53 Option.Option<string>,
54 | DesktopCloudAuthTokenStoreDecodeError
55 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
56 | ElectronSafeStorage.ElectronSafeStorageDecryptError
57 >;
58 readonly set: (
59 token: string,
60 ) => Effect.Effect<
61 boolean,
62 | DesktopCloudAuthTokenStoreWriteError
63 | ElectronSafeStorage.ElectronSafeStorageAvailabilityError
64 | ElectronSafeStorage.ElectronSafeStorageEncryptError
65 >;
66 readonly clear: Effect.Effect<void>;
67}
68
69export class DesktopCloudAuthTokenStore extends Context.Service<
70 DesktopCloudAuthTokenStore,
71 DesktopCloudAuthTokenStoreShape
72>()("@t3tools/desktop/app/DesktopCloudAuthTokenStore") {}
73
74function decodeSecretBytes(
75 encoded: string,
76): Effect.Effect<Uint8Array, DesktopCloudAuthTokenStoreDecodeError> {
77 return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe(
78 Effect.mapError((cause) => new DesktopCloudAuthTokenStoreDecodeError({ cause })),
79 );
80}
81
82const readDocument = (
83 fileSystem: FileSystem.FileSystem,
84 tokenPath: string,
85): Effect.Effect<Option.Option<CloudAuthTokenDocument>> =>
86 fileSystem.readFileString(tokenPath).pipe(
87 Effect.option,
88 Effect.flatMap(
89 Option.match({
90 onNone: () => Effect.succeed(Option.none<CloudAuthTokenDocument>()),
91 onSome: (raw) => decodeCloudAuthTokenDocumentJson(raw).pipe(Effect.option),
92 }),
93 ),
94 );
95
96const writeDocument = Effect.fn("desktop.cloudAuthTokenStore.writeDocument")(function* (input: {
97 readonly fileSystem: FileSystem.FileSystem;
98 readonly path: Path.Path;
99 readonly tokenPath: string;
100 readonly document: CloudAuthTokenDocument;
101 readonly suffix: string;
102}): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> {
103 const directory = input.path.dirname(input.tokenPath);
104 const tempPath = `${input.tokenPath}.${process.pid}.${input.suffix}.tmp`;
105 const encoded = yield* encodeCloudAuthTokenDocumentJson(input.document);
106 yield* input.fileSystem.makeDirectory(directory, { recursive: true });
107 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`);
108 yield* input.fileSystem.rename(tempPath, input.tokenPath);
109});
110
111export const layer = Layer.effect(
112 DesktopCloudAuthTokenStore,
113 Effect.gen(function* () {
114 const environment = yield* DesktopEnvironment.DesktopEnvironment;
115 const fileSystem = yield* FileSystem.FileSystem;
116 const path = yield* Path.Path;
117 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
118 const crypto = yield* Crypto.Crypto;
119 const tokenPath = path.join(environment.stateDir, "cloud-auth-token.json");
120
121 return DesktopCloudAuthTokenStore.of({
122 get: Effect.gen(function* () {
123 const document = yield* readDocument(fileSystem, tokenPath);
124 if (Option.isNone(document) || !(yield* safeStorage.isEncryptionAvailable)) {
125 return Option.none<string>();
126 }
127
128 const secretBytes = yield* decodeSecretBytes(document.value.encryptedClientJwt);
129 return Option.some(yield* safeStorage.decryptString(secretBytes));
130 }).pipe(Effect.withSpan("desktop.cloudAuthTokenStore.get")),
131 set: Effect.fn("desktop.cloudAuthTokenStore.set")(function* (token) {
132 if (!(yield* safeStorage.isEncryptionAvailable)) {
133 return false;
134 }
135
136 const encryptedClientJwt = Encoding.encodeBase64(yield* safeStorage.encryptString(token));
137 const suffix = (yield* crypto.randomUUIDv4.pipe(
138 Effect.mapError((cause) => new DesktopCloudAuthTokenStoreWriteError({ cause })),
139 )).replace(/-/g, "");
140 yield* writeDocument({
141 fileSystem,
142 path,
143 tokenPath,
144 document: { version: 1, encryptedClientJwt },
145 suffix,
146 }).pipe(Effect.mapError((cause) => new DesktopCloudAuthTokenStoreWriteError({ cause })));
147 return true;
148 }),
149 clear: fileSystem.remove(tokenPath, { force: true }).pipe(
150 Effect.catch(() => Effect.void),
151 Effect.withSpan("desktop.cloudAuthTokenStore.clear"),
152 ),
153 });
154 }),
155);
apps/desktop/src/app/DesktopConfig.ts+1−0
@@ -37,6 +37,7 @@ export const DesktopConfig = Config.all({ · 36 unmodified lines
37 xdgConfigHome: trimmedString("XDG_CONFIG_HOME"),37 xdgConfigHome: trimmedString("XDG_CONFIG_HOME"),
38 t3Home: trimmedString("T3CODE_HOME"),38 t3Home: trimmedString("T3CODE_HOME"),
39 devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option),39 devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option),
40 appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"),
40 devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"),41 devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"),
41 configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option),42 configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option),
42 commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"),43 commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"),
apps/desktop/src/app/DesktopEnvironment.test.ts+14−0
@@ -93,6 +93,20 @@ describe("DesktopEnvironment", () => { · 92 unmodified lines
93 }),93 }),
94 );94 );
95 95
96 it.effect("uses a configured app user model id override", () =>
97 Effect.gen(function* () {
98 const environment = yield* makeEnvironment(
99 {},
100 {
101 T3CODE_DESKTOP_APP_USER_MODEL_ID: " com.t3tools.t3code.dev.local ",
102 VITE_DEV_SERVER_URL: "http://localhost:5173",
103 },
104 );
105
106 assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev.local");
107 }),
108 );
109
96 it.effect("resolves picker defaults without nullish sentinels", () =>110 it.effect("resolves picker defaults without nullish sentinels", () =>
97 Effect.gen(function* () {111 Effect.gen(function* () {
98 const environment = yield* makeEnvironment();112 const environment = yield* makeEnvironment();
apps/desktop/src/app/DesktopEnvironment.ts+3−1
@@ -199,7 +199,9 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( · 198 unmodified lines
199 otlpExportIntervalMs: config.otlpExportIntervalMs,199 otlpExportIntervalMs: config.otlpExportIntervalMs,
200 branding,200 branding,
201 displayName,201 displayName,
202 appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code",202 appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () =>
203 isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code",
204 ),
203 linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop",205 linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop",
204 linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",206 linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
205 userDataDirName,207 userDataDirName,
apps/desktop/src/electron/ElectronApp.test.ts+9−0
@@ -7,12 +7,15 @@ const { · 6 unmodified lines
7 exitMock,7 exitMock,
8 getAppPathMock,8 getAppPathMock,
9 getVersionMock,9 getVersionMock,
10 isDefaultProtocolClientMock,
10 onMock,11 onMock,
11 quitMock,12 quitMock,
12 relaunchMock,13 relaunchMock,
13 removeListenerMock,14 removeListenerMock,
15 requestSingleInstanceLockMock,
14 setAboutPanelOptionsMock,16 setAboutPanelOptionsMock,
15 setAppUserModelIdMock,17 setAppUserModelIdMock,
18 setAsDefaultProtocolClientMock,
16 setDesktopNameMock,19 setDesktopNameMock,
17 setDockIconMock,20 setDockIconMock,
18 setNameMock,21 setNameMock,
@@ -23,12 +26,15 @@ const { · 4 unmodified lines
23 exitMock: vi.fn(),26 exitMock: vi.fn(),
24 getAppPathMock: vi.fn(() => "/app"),27 getAppPathMock: vi.fn(() => "/app"),
25 getVersionMock: vi.fn(() => "1.2.3"),28 getVersionMock: vi.fn(() => "1.2.3"),
29 isDefaultProtocolClientMock: vi.fn(() => false),
26 onMock: vi.fn(),30 onMock: vi.fn(),
27 quitMock: vi.fn(),31 quitMock: vi.fn(),
28 relaunchMock: vi.fn(),32 relaunchMock: vi.fn(),
29 removeListenerMock: vi.fn(),33 removeListenerMock: vi.fn(),
34 requestSingleInstanceLockMock: vi.fn(() => true),
30 setAboutPanelOptionsMock: vi.fn(),35 setAboutPanelOptionsMock: vi.fn(),
31 setAppUserModelIdMock: vi.fn(),36 setAppUserModelIdMock: vi.fn(),
37 setAsDefaultProtocolClientMock: vi.fn(() => true),
32 setDesktopNameMock: vi.fn(),38 setDesktopNameMock: vi.fn(),
33 setDockIconMock: vi.fn(),39 setDockIconMock: vi.fn(),
34 setNameMock: vi.fn(),40 setNameMock: vi.fn(),
@@ -46,14 +52,17 @@ vi.mock("electron", () => ({ · 11 unmodified lines
46 },52 },
47 getAppPath: getAppPathMock,53 getAppPath: getAppPathMock,
48 getVersion: getVersionMock,54 getVersion: getVersionMock,
55 isDefaultProtocolClient: isDefaultProtocolClientMock,
49 isPackaged: true,56 isPackaged: true,
50 name: "T3 Code",57 name: "T3 Code",
51 on: onMock,58 on: onMock,
52 quit: quitMock,59 quit: quitMock,
53 relaunch: relaunchMock,60 relaunch: relaunchMock,
54 removeListener: removeListenerMock,61 removeListener: removeListenerMock,
62 requestSingleInstanceLock: requestSingleInstanceLockMock,
55 runningUnderARM64Translation: false,63 runningUnderARM64Translation: false,
56 setAboutPanelOptions: setAboutPanelOptionsMock,64 setAboutPanelOptions: setAboutPanelOptionsMock,
65 setAsDefaultProtocolClient: setAsDefaultProtocolClientMock,
57 setAppUserModelId: setAppUserModelIdMock,66 setAppUserModelId: setAppUserModelIdMock,
58 setDesktopName: setDesktopNameMock,67 setDesktopName: setDesktopNameMock,
59 setName: setNameMock,68 setName: setNameMock,
apps/desktop/src/electron/ElectronApp.ts+17−0
@@ -29,6 +29,13 @@ export interface ElectronAppShape { · 28 unmodified lines
29 options: Electron.AboutPanelOptionsOptions,29 options: Electron.AboutPanelOptionsOptions,
30 ) => Effect.Effect<void>;30 ) => Effect.Effect<void>;
31 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;31 readonly setAppUserModelId: (id: string) => Effect.Effect<void>;
32 readonly requestSingleInstanceLock: Effect.Effect<boolean>;
33 readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>;
34 readonly setAsDefaultProtocolClient: (
35 protocol: string,
36 path?: string,
37 args?: readonly string[],
38 ) => Effect.Effect<boolean>;
32 readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;39 readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;
33 readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;40 readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;
34 readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;41 readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;
@@ -93,6 +100,16 @@ const make = ElectronApp.of({ · 58 unmodified lines
93 Effect.sync(() => {100 Effect.sync(() => {
94 Electron.app.setAppUserModelId(id);101 Electron.app.setAppUserModelId(id);
95 }),102 }),
103 requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()),
104 isDefaultProtocolClient: (protocol) =>
105 Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)),
106 setAsDefaultProtocolClient: (protocol, path, args) =>
107 Effect.sync(() => {
108 if (path === undefined) {
109 return Electron.app.setAsDefaultProtocolClient(protocol);
110 }
111 return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]);
112 }),
96 setDesktopName: (desktopName) =>113 setDesktopName: (desktopName) =>
97 Effect.sync(() => {114 Effect.sync(() => {
98 const linuxApp = Electron.app as Electron.App & {115 const linuxApp = Electron.app as Electron.App & {
apps/desktop/src/ipc/DesktopIpcHandlers.ts+12−0
@@ -1,6 +1,13 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2 2
3import * as DesktopIpc from "./DesktopIpc.ts";3import * as DesktopIpc from "./DesktopIpc.ts";
4import {
5 clearCloudAuthToken,
6 createCloudAuthRequest,
7 fetchCloudAuth,
8 getCloudAuthToken,
9 setCloudAuthToken,
10} from "./methods/cloudAuth.ts";
4import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";11import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
5import {12import {
6 getSavedEnvironmentRegistry,13 getSavedEnvironmentRegistry,
@@ -75,6 +82,11 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { · 68 unmodified lines
75 yield* ipc.handle(setTheme);82 yield* ipc.handle(setTheme);
76 yield* ipc.handle(showContextMenu);83 yield* ipc.handle(showContextMenu);
77 yield* ipc.handle(openExternal);84 yield* ipc.handle(openExternal);
85 yield* ipc.handle(createCloudAuthRequest);
86 yield* ipc.handle(getCloudAuthToken);
87 yield* ipc.handle(setCloudAuthToken);
88 yield* ipc.handle(clearCloudAuthToken);
89 yield* ipc.handle(fetchCloudAuth);
78 90
79 yield* ipc.handle(getUpdateState);91 yield* ipc.handle(getUpdateState);
80 yield* ipc.handle(setUpdateChannel);92 yield* ipc.handle(setUpdateChannel);
apps/desktop/src/ipc/channels.ts+6−0
@@ -3,6 +3,12 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; · 2 unmodified lines
3export const SET_THEME_CHANNEL = "desktop:set-theme";3export const SET_THEME_CHANNEL = "desktop:set-theme";
4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
6export const CREATE_CLOUD_AUTH_REQUEST_CHANNEL = "desktop:create-cloud-auth-request";
7export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token";
8export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token";
9export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token";
10export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth";
11export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback";
6export const MENU_ACTION_CHANNEL = "desktop:menu-action";12export const MENU_ACTION_CHANNEL = "desktop:menu-action";
7export const UPDATE_STATE_CHANNEL = "desktop:update-state";13export const UPDATE_STATE_CHANNEL = "desktop:update-state";
8export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";14export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
apps/desktop/src/ipc/methods/cloudAuth.ts+128−0
@@ -0,0 +1,128 @@
1import {
2 DesktopCloudAuthFetchInputSchema,
3 DesktopCloudAuthFetchResultSchema,
4} from "@t3tools/contracts";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientRequest } from "effect/unstable/http";
10
11import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts";
12import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts";
13import * as IpcChannels from "../channels.ts";
14import { makeIpcMethod } from "../DesktopIpc.ts";
15
16export class DesktopCloudAuthFetchError extends Data.TaggedError("DesktopCloudAuthFetchError")<{
17 readonly reason: string;
18 readonly cause?: unknown;
19}> {
20 override get message() {
21 return this.reason;
22 }
23}
24
25const allowedClerkFrontendApiHosts = (hostname: string): boolean =>
26 hostname.endsWith(".clerk.accounts.dev") || hostname.endsWith(".clerk.accounts.com");
27
28function validateClerkFrontendApiUrl(rawUrl: string): URL {
29 const url = new URL(rawUrl);
30 if (url.protocol !== "https:" || !allowedClerkFrontendApiHosts(url.hostname)) {
31 throw new DesktopCloudAuthFetchError({
32 reason: "Desktop cloud auth fetch is restricted to Clerk Frontend API HTTPS hosts.",
33 });
34 }
35 return url;
36}
37
38export const createCloudAuthRequest = makeIpcMethod({
39 channel: IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL,
40 payload: Schema.Void,
41 result: Schema.String,
42 handler: Effect.fn("desktop.ipc.cloudAuth.createRequest")(function* () {
43 const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth;
44 return yield* cloudAuth.createRequest;
45 }),
46});
47
48export const getCloudAuthToken = makeIpcMethod({
49 channel: IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL,
50 payload: Schema.Void,
51 result: Schema.NullOr(Schema.String),
52 handler: Effect.fn("desktop.ipc.cloudAuth.getToken")(function* () {
53 const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore;
54 return Option.getOrNull(yield* tokenStore.get);
55 }),
56});
57
58export const setCloudAuthToken = makeIpcMethod({
59 channel: IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL,
60 payload: Schema.String,
61 result: Schema.Boolean,
62 handler: Effect.fn("desktop.ipc.cloudAuth.setToken")(function* (token) {
63 const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore;
64 return yield* tokenStore.set(token);
65 }),
66});
67
68export const clearCloudAuthToken = makeIpcMethod({
69 channel: IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL,
70 payload: Schema.Void,
71 result: Schema.Void,
72 handler: Effect.fn("desktop.ipc.cloudAuth.clearToken")(function* () {
73 const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore;
74 yield* tokenStore.clear;
75 }),
76});
77
78export const fetchCloudAuth = makeIpcMethod({
79 channel: IpcChannels.FETCH_CLOUD_AUTH_CHANNEL,
80 payload: DesktopCloudAuthFetchInputSchema,
81 result: DesktopCloudAuthFetchResultSchema,
82 handler: Effect.fn("desktop.ipc.cloudAuth.fetch")(function* (input) {
83 const url = yield* Effect.try({
84 try: () => validateClerkFrontendApiUrl(input.url),
85 catch: (cause) =>
86 cause instanceof DesktopCloudAuthFetchError
87 ? cause
88 : new DesktopCloudAuthFetchError({
89 reason: "Desktop cloud auth fetch received an invalid URL.",
90 cause,
91 }),
92 });
93
94 const request = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")(url, {
95 headers: input.headers,
96 }).pipe(
97 input.body === undefined ? (request) => request : HttpClientRequest.bodyText(input.body),
98 );
99
100 const response = yield* HttpClient.execute(request).pipe(
101 Effect.mapError(
102 (cause) =>
103 new DesktopCloudAuthFetchError({
104 reason: "Desktop cloud auth fetch failed.",
105 cause,
106 }),
107 ),
108 );
109
110 const body = yield* response.text.pipe(
111 Effect.mapError(
112 (cause) =>
113 new DesktopCloudAuthFetchError({
114 reason: "Desktop cloud auth fetch response could not be read.",
115 cause,
116 }),
117 ),
118 );
119
120 return {
121 ok: response.status >= 200 && response.status < 300,
122 status: response.status,
123 statusText: "",
124 headers: response.headers,
125 body,
126 };
127 }),
128});
apps/desktop/src/main.ts+4−0
@@ -26,6 +26,8 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; · 25 unmodified lines
26import * as ElectronWindow from "./electron/ElectronWindow.ts";26import * as ElectronWindow from "./electron/ElectronWindow.ts";
27import * as DesktopApp from "./app/DesktopApp.ts";27import * as DesktopApp from "./app/DesktopApp.ts";
28import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";28import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
29import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts";
30import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts";
29import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";31import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
30import * as DesktopAssets from "./app/DesktopAssets.ts";32import * as DesktopAssets from "./app/DesktopAssets.ts";
31import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";33import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
@@ -111,6 +113,7 @@ const desktopFoundationLayer = Layer.mergeAll( · 79 unmodified lines
111 DesktopAppSettings.layer,113 DesktopAppSettings.layer,
112 DesktopClientSettings.layer,114 DesktopClientSettings.layer,
113 DesktopSavedEnvironments.layer,115 DesktopSavedEnvironments.layer,
116 DesktopCloudAuthTokenStore.layer,
114 DesktopAssets.layer,117 DesktopAssets.layer,
115 DesktopObservability.layer,118 DesktopObservability.layer,
116).pipe(Layer.provideMerge(desktopEnvironmentLayer));119).pipe(Layer.provideMerge(desktopEnvironmentLayer));
@@ -135,6 +138,7 @@ const desktopBackendLayer = DesktopBackendManager.layer.pipe( · 18 unmodified lines
135const desktopApplicationLayer = Layer.mergeAll(138const desktopApplicationLayer = Layer.mergeAll(
136 DesktopLifecycle.layer,139 DesktopLifecycle.layer,
137 DesktopApplicationMenu.layer,140 DesktopApplicationMenu.layer,
141 DesktopCloudAuth.layer,
138 DesktopShellEnvironment.layer,142 DesktopShellEnvironment.layer,
139 desktopSshLayer,143 desktopSshLayer,
140).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer));144).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer));
apps/desktop/src/preload.ts+17−0
@@ -96,6 +96,23 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 95 unmodified lines
96 ...(position === undefined ? {} : { position }),96 ...(position === undefined ? {} : { position }),
97 }),97 }),
98 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),98 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
99 createCloudAuthRequest: () => ipcRenderer.invoke(IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL),
100 getCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL),
101 setCloudAuthToken: (token: string) =>
102 ipcRenderer.invoke(IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, token),
103 clearCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL),
104 fetchCloudAuth: (input) => ipcRenderer.invoke(IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, input),
105 onCloudAuthCallback: (listener) => {
106 const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => {
107 if (typeof rawUrl !== "string") return;
108 listener(rawUrl);
109 };
110
111 ipcRenderer.on(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener);
112 return () => {
113 ipcRenderer.removeListener(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener);
114 };
115 },
99 onMenuAction: (listener) => {116 onMenuAction: (listener) => {
100 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {117 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
101 if (typeof action !== "string") return;118 if (typeof action !== "string") return;
apps/desktop/src/settings/DesktopSavedEnvironments.ts+12−14
@@ -53,6 +53,7 @@ const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ · 52 unmodified lines
53 createdAt: Schema.String,53 createdAt: Schema.String,
54 lastConnectedAt: Schema.NullOr(Schema.String),54 lastConnectedAt: Schema.NullOr(Schema.String),
55 desktopSsh: Schema.optionalKey(DesktopSshTargetSchema),55 desktopSsh: Schema.optionalKey(DesktopSshTargetSchema),
56 relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })),
56 encryptedBearerToken: Schema.optionalKey(Schema.String),57 encryptedBearerToken: Schema.optionalKey(Schema.String),
57});58});
58 59
@@ -134,7 +135,11 @@ function toPersistedSavedEnvironmentRecord( · 75 unmodified lines
134 createdAt: record.createdAt,135 createdAt: record.createdAt,
135 lastConnectedAt: record.lastConnectedAt,136 lastConnectedAt: record.lastConnectedAt,
136 };137 };
137 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;138 return {
139 ...nextRecord,
140 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
141 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
142 };
138}143}
139 144
140function toSavedEnvironmentStorageRecord(145function toSavedEnvironmentStorageRecord(
@@ -149,20 +154,13 @@ function toSavedEnvironmentStorageRecord( · 8 unmodified lines
149 createdAt: record.createdAt,154 createdAt: record.createdAt,
150 lastConnectedAt: record.lastConnectedAt,155 lastConnectedAt: record.lastConnectedAt,
151 };156 };
152 const desktopSsh = record.desktopSsh;157 const metadata = {
153 if (desktopSsh) {158 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
154 return Option.match(encryptedBearerToken, {159 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
155 onNone: () => ({ ...nextRecord, desktopSsh }),160 };
156 onSome: (value) => ({
157 ...nextRecord,
158 desktopSsh,
159 encryptedBearerToken: value,
160 }),
161 });
162 }
163 return Option.match(encryptedBearerToken, {161 return Option.match(encryptedBearerToken, {
164 onNone: () => nextRecord,162 onNone: () => ({ ...nextRecord, ...metadata }),
165 onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }),163 onSome: (value) => ({ ...nextRecord, ...metadata, encryptedBearerToken: value }),
166 });164 });
167}165}
168 166
apps/desktop/src/window/DesktopApplicationMenu.test.ts+3−0
@@ -39,6 +39,9 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { · 38 unmodified lines
39 setName: () => Effect.void,39 setName: () => Effect.void,
40 setAboutPanelOptions: () => Effect.void,40 setAboutPanelOptions: () => Effect.void,
41 setAppUserModelId: () => Effect.void,41 setAppUserModelId: () => Effect.void,
42 requestSingleInstanceLock: Effect.succeed(true),
43 isDefaultProtocolClient: () => Effect.succeed(false),
44 setAsDefaultProtocolClient: () => Effect.succeed(true),
42 setDesktopName: () => Effect.void,45 setDesktopName: () => Effect.void,
43 setDockIcon: () => Effect.void,46 setDockIcon: () => Effect.void,
44 appendCommandLineSwitch: () => Effect.void,47 appendCommandLineSwitch: () => Effect.void,
apps/mobile/app.config.ts+28−4
@@ -96,6 +96,11 @@ const config: ExpoConfig = { · 95 unmodified lines
96 favicon: "./assets/favicon.png",96 favicon: "./assets/favicon.png",
97 },97 },
98 plugins: [98 plugins: [
99 "expo-router",
100 "expo-font",
101 "expo-secure-store",
102 ["@clerk/expo", { theme: "./clerk-theme.json" }],
103 "expo-web-browser",
99 [104 [
100 "expo-camera",105 "expo-camera",
101 {106 {
@@ -120,17 +125,36 @@ const config: ExpoConfig = { · 18 unmodified lines
120 "expo-build-properties",125 "expo-build-properties",
121 {126 {
122 ios: {127 ios: {
123 deploymentTarget: "16.4",128 deploymentTarget: "18.0",
124 },129 },
125 },130 },
126 ],131 ],
127 "expo-secure-store",132 [
128 "expo-router",133 "expo-widgets",
129 "expo-font",134 {
135 bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,
136 groupIdentifier: `group.${variant.iosBundleIdentifier}`,
137 enablePushNotifications: true,
138 widgets: [
139 {
140 name: "AgentActivity",
141 displayName: "Agent Activity",
142 description: "Shows the current state of active T3 Code agents.",
143 supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"],
144 },
145 ],
146 },
147 ],
130 "./plugins/withAndroidCleartextTraffic.cjs",148 "./plugins/withAndroidCleartextTraffic.cjs",
131 ],149 ],
132 extra: {150 extra: {
133 appVariant: APP_VARIANT,151 appVariant: APP_VARIANT,
152 relay: {
153 url: process.env.T3_RELAY_URL ?? null,
154 },
155 clerk: {
156 publishableKey: process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,
157 },
134 eas: {158 eas: {
135 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",159 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
136 },160 },
apps/mobile/clerk-theme.json+39−0
@@ -0,0 +1,39 @@
1{
2 "colors": {
3 "primary": "#262626",
4 "background": "#F2F2F7",
5 "input": "#FFFFFF",
6 "danger": "#DC2626",
7 "success": "#059669",
8 "warning": "#D97706",
9 "foreground": "#262626",
10 "mutedForeground": "#737373",
11 "primaryForeground": "#FFFFFF",
12 "inputForeground": "#262626",
13 "neutral": "#F5F5F5",
14 "border": "#E5E5EA",
15 "ring": "#A3A3A3",
16 "muted": "#F5F5F5",
17 "shadow": "#000000"
18 },
19 "darkColors": {
20 "primary": "#F5F5F5",
21 "background": "#0E0E0E",
22 "input": "#171717",
23 "danger": "#FCA5A5",
24 "success": "#34D399",
25 "warning": "#FBBF24",
26 "foreground": "#F5F5F5",
27 "mutedForeground": "#A3A3A3",
28 "primaryForeground": "#0A0A0A",
29 "inputForeground": "#F5F5F5",
30 "neutral": "#1C1C1C",
31 "border": "#2A2A2A",
32 "ring": "#525252",
33 "muted": "#1C1C1C",
34 "shadow": "#000000"
35 },
36 "design": {
37 "borderRadius": 18
38 }
39}
apps/mobile/global.css+2−0
@@ -38,6 +38,7 @@ · 37 unmodified lines
38 --color-secondary: #ffffff;38 --color-secondary: #ffffff;
39 --color-secondary-foreground: #262626;39 --color-secondary-foreground: #262626;
40 --color-secondary-border: rgba(0, 0, 0, 0.08);40 --color-secondary-border: rgba(0, 0, 0, 0.08);
41 --color-switch-active: #34c759;
41 42
42 /* Danger */43 /* Danger */
43 --color-danger: #fef2f2;44 --color-danger: #fef2f2;
@@ -125,6 +126,7 @@ · 81 unmodified lines
125 --color-secondary: rgba(255, 255, 255, 0.04);126 --color-secondary: rgba(255, 255, 255, 0.04);
126 --color-secondary-foreground: #f5f5f5;127 --color-secondary-foreground: #f5f5f5;
127 --color-secondary-border: rgba(255, 255, 255, 0.06);128 --color-secondary-border: rgba(255, 255, 255, 0.06);
129 --color-switch-active: #30d158;
128 130
129 /* Danger */131 /* Danger */
130 --color-danger: rgba(239, 68, 68, 0.14);132 --color-danger: rgba(239, 68, 68, 0.14);
apps/mobile/package.json+8−0
@@ -39,10 +39,13 @@ · 38 unmodified lines
39 },39 },
40 "dependencies": {40 "dependencies": {
41 "@callstack/liquid-glass": "^0.7.1",41 "@callstack/liquid-glass": "^0.7.1",
42 "@clerk/expo": "^3.3.0",
42 "@effect/atom-react": "catalog:",43 "@effect/atom-react": "catalog:",
43 "@expo-google-fonts/dm-sans": "^0.4.2",44 "@expo-google-fonts/dm-sans": "^0.4.2",
44 "@expo/ui": "~56.0.8",45 "@expo/ui": "~56.0.8",
45 "@legendapp/list": "3.0.0-beta.44",46 "@legendapp/list": "3.0.0-beta.44",
47 "@noble/curves": "catalog:",
48 "@noble/hashes": "catalog:",
46 "@pierre/diffs": "catalog:",49 "@pierre/diffs": "catalog:",
47 "@react-native-menu/menu": "^2.0.0",50 "@react-native-menu/menu": "^2.0.0",
48 "@shikijs/core": "3.23.0",51 "@shikijs/core": "3.23.0",
@@ -58,6 +61,7 @@ · 9 unmodified lines
58 "diff": "8.0.3",61 "diff": "8.0.3",
59 "effect": "catalog:",62 "effect": "catalog:",
60 "expo": "^56.0.0",63 "expo": "^56.0.0",
64 "expo-auth-session": "~56.0.12",
61 "expo-build-properties": "~56.0.15",65 "expo-build-properties": "~56.0.15",
62 "expo-camera": "~56.0.7",66 "expo-camera": "~56.0.7",
63 "expo-clipboard": "~56.0.3",67 "expo-clipboard": "~56.0.3",
@@ -70,12 +74,15 @@ · 6 unmodified lines
70 "expo-haptics": "~56.0.3",74 "expo-haptics": "~56.0.3",
71 "expo-image-picker": "~56.0.14",75 "expo-image-picker": "~56.0.14",
72 "expo-linking": "~56.0.12",76 "expo-linking": "~56.0.12",
77 "expo-notifications": "~56.0.14",
73 "expo-paste-input": "^0.1.15",78 "expo-paste-input": "^0.1.15",
74 "expo-router": "~56.2.7",79 "expo-router": "~56.2.7",
75 "expo-secure-store": "~56.0.4",80 "expo-secure-store": "~56.0.4",
76 "expo-splash-screen": "~56.0.10",81 "expo-splash-screen": "~56.0.10",
77 "expo-symbols": "~56.0.5",82 "expo-symbols": "~56.0.5",
78 "expo-updates": "~56.0.17",83 "expo-updates": "~56.0.17",
84 "expo-web-browser": "~56.0.5",
85 "expo-widgets": "~56.0.15",
79 "punycode": "^2.3.1",86 "punycode": "^2.3.1",
80 "react": "19.2.3",87 "react": "19.2.3",
81 "react-dom": "19.2.3",88 "react-dom": "19.2.3",
@@ -96,6 +103,7 @@ · 14 unmodified lines
96 "uniwind": "^1.6.2"103 "uniwind": "^1.6.2"
97 },104 },
98 "devDependencies": {105 "devDependencies": {
106 "@effect/vitest": "catalog:",
99 "@types/react": "~19.2.0",107 "@types/react": "~19.2.0",
100 "babel-preset-expo": "~56.0.0",108 "babel-preset-expo": "~56.0.0",
101 "tailwindcss": "^4.0.0",109 "tailwindcss": "^4.0.0",
apps/mobile/src/app/_layout.tsx+24−9
@@ -20,12 +20,15 @@ import { · 19 unmodified lines
20} from "../state/use-remote-environment-registry";20} from "../state/use-remote-environment-registry";
21import { RegistryContext } from "@effect/atom-react";21import { RegistryContext } from "@effect/atom-react";
22import { appAtomRegistry } from "../state/atom-registry";22import { appAtomRegistry } from "../state/atom-registry";
23import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";
24import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation";
23 25
24function AppNavigator() {26function AppNavigator() {
25 const { isLoadingSavedConnection } = useRemoteEnvironmentState();27 const { isLoadingSavedConnection } = useRemoteEnvironmentState();
26 const colorScheme = useColorScheme();28 const colorScheme = useColorScheme();
27 const statusBarBg = useCSSVariable("--color-status-bar");29 const statusBarBg = useCSSVariable("--color-status-bar");
28 const sheetStyle = useResolveClassNames("bg-sheet");30 const sheetStyle = useResolveClassNames("bg-sheet");
31 useAgentNotificationNavigation();
29 32
30 const newTaskScreenOptions = {33 const newTaskScreenOptions = {
31 contentStyle: sheetStyle,34 contentStyle: sheetStyle,
@@ -45,6 +48,11 @@ function AppNavigator() { · 13 unmodified lines
45 sheetGrabberVisible: true,48 sheetGrabberVisible: true,
46 };49 };
47 50
51 const settingsSheetScreenOptions = {
52 ...connectionSheetScreenOptions,
53 sheetAllowedDetents: [0.7],
54 };
55
48 if (isLoadingSavedConnection) {56 if (isLoadingSavedConnection) {
49 return <LoadingScreen message="Loading remote workspace…" />;57 return <LoadingScreen message="Loading remote workspace…" />;
50 }58 }
@@ -66,6 +74,7 @@ function AppNavigator() { · 15 unmodified lines
66 headerShadowVisible: false,74 headerShadowVisible: false,
67 }}75 }}
68 />76 />
77 <Stack.Screen name="settings" options={settingsSheetScreenOptions} />
69 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />78 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />
70 <Stack.Screen name="new" options={newTaskScreenOptions} />79 <Stack.Screen name="new" options={newTaskScreenOptions} />
71 <Stack.Screen80 <Stack.Screen
@@ -91,14 +100,20 @@ export default function RootLayout() { · 19 unmodified lines
91 useRemoteEnvironmentBootstrap();100 useRemoteEnvironmentBootstrap();
92 101
93 return (102 return (
94 <RegistryContext value={appAtomRegistry}>103 <RegistryContext.Provider value={appAtomRegistry}>
95 <GestureHandlerRootView style={{ flex: 1 }}>104 <CloudAuthProvider>
96 <KeyboardProvider statusBarTranslucent>105 <GestureHandlerRootView style={{ flex: 1 }}>
97 <SafeAreaProvider>106 <KeyboardProvider statusBarTranslucent>
98 {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />}107 <SafeAreaProvider>
99 </SafeAreaProvider>108 {fontsLoaded ? (
100 </KeyboardProvider>109 <AppNavigator />
101 </GestureHandlerRootView>110 ) : (
102 </RegistryContext>111 <LoadingScreen message="Loading remote workspace…" />
112 )}
113 </SafeAreaProvider>
114 </KeyboardProvider>
115 </GestureHandlerRootView>
116 </CloudAuthProvider>
117 </RegistryContext.Provider>
103 );118 );
104}119}
apps/mobile/src/app/connections/index.tsx+7−3
@@ -22,8 +22,8 @@ export default function ConnectionsRouteScreen() { · 21 unmodified lines
22 const hasEnvironments = connectedEnvironments.length > 0;22 const hasEnvironments = connectedEnvironments.length > 0;
23 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);23 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
24 24
25 const primaryFg = useThemeColor("--color-primary-foreground");
26 const accentColor = useThemeColor("--color-icon-muted");25 const accentColor = useThemeColor("--color-icon-muted");
26 const iconColor = useThemeColor("--color-icon");
27 27
28 const handleToggle = useCallback((environmentId: EnvironmentId) => {28 const handleToggle = useCallback((environmentId: EnvironmentId) => {
29 setExpandedId((prev) => (prev === environmentId ? null : environmentId));29 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
@@ -36,11 +36,15 @@ export default function ConnectionsRouteScreen() { · 6 unmodified lines
36 title: "Environments",36 title: "Environments",
37 headerRight: () => (37 headerRight: () => (
38 <Link href="/connections/new" asChild>38 <Link href="/connections/new" asChild>
39 <Pressable className="h-10 w-10 items-center justify-center rounded-full bg-primary active:opacity-70">39 <Pressable
40 accessibilityLabel="Add environment"
41 accessibilityRole="button"
42 className="h-10 w-10 items-center justify-center active:opacity-70"
43 >
40 <SymbolView44 <SymbolView
41 name="plus"45 name="plus"
42 size={18}46 size={18}
43 tintColor={primaryFg}47 tintColor={iconColor}
44 type="monochrome"48 type="monochrome"
45 weight="semibold"49 weight="semibold"
46 />50 />
apps/mobile/src/app/connections/new.tsx+3−1
@@ -130,7 +130,9 @@ export default function ConnectionsNewRouteScreen() { · 129 unmodified lines
130 title: showScanner ? "Scan QR Code" : "Add Environment",130 title: showScanner ? "Scan QR Code" : "Add Environment",
131 headerRight: () => (131 headerRight: () => (
132 <Pressable132 <Pressable
133 className="h-10 w-10 items-center justify-center rounded-full border border-border bg-secondary"133 accessibilityLabel={showScanner ? "Close QR scanner" : "Scan pairing QR code"}
134 accessibilityRole="button"
135 className="h-10 w-10 items-center justify-center active:opacity-70"
134 onPress={() => {136 onPress={() => {
135 if (showScanner) {137 if (showScanner) {
136 closeScanner();138 closeScanner();
apps/mobile/src/app/index.tsx+5−10
@@ -78,16 +78,11 @@ export default function HomeRouteScreen() { · 77 unmodified lines
78 </Stack.Toolbar>78 </Stack.Toolbar>
79 79
80 <Stack.Toolbar placement="right">80 <Stack.Toolbar placement="right">
81 <Stack.Toolbar.Menu icon="ellipsis.circle" separateBackground>81 <Stack.Toolbar.Button
82 <Stack.Toolbar.Label>More</Stack.Toolbar.Label>82 icon="gearshape"
83 <Stack.Toolbar.MenuAction83 onPress={() => router.push("/settings")}
84 icon="desktopcomputer"84 separateBackground
85 onPress={() => router.push("/connections")}85 />
86 subtitle="Manage connected hosts"
87 >
88 <Stack.Toolbar.Label>Environments</Stack.Toolbar.Label>
89 </Stack.Toolbar.MenuAction>
90 </Stack.Toolbar.Menu>
91 </Stack.Toolbar>86 </Stack.Toolbar>
92 87
93 {/* Bottom toolbar: search + compose, visually split like iMessage */}88 {/* Bottom toolbar: search + compose, visually split like iMessage */}
apps/mobile/src/app/settings/_layout.tsx+41−0
@@ -0,0 +1,41 @@
1import Stack from "expo-router/stack";
2import { useResolveClassNames } from "uniwind";
3
4import { useThemeColor } from "../../lib/useThemeColor";
5
6export const unstable_settings = {
7 anchor: "index",
8};
9
10export default function SettingsLayout() {
11 const contentStyle = useResolveClassNames("bg-sheet");
12 const sheetBg = String(useThemeColor("--color-sheet"));
13 const headerTint = String(useThemeColor("--color-icon"));
14
15 return (
16 <Stack
17 screenOptions={{
18 contentStyle,
19 headerBackButtonDisplayMode: "minimal",
20 headerShadowVisible: false,
21 headerStyle: { backgroundColor: sheetBg },
22 headerTintColor: headerTint,
23 headerTitleStyle: { fontFamily: "DMSans_700Bold" },
24 }}
25 >
26 <Stack.Screen name="index" options={{ animation: "none", title: "Settings" }} />
27 <Stack.Screen
28 name="environments"
29 options={{ animation: "slide_from_right", title: "Environments" }}
30 />
31 <Stack.Screen
32 name="environment-new"
33 options={{ animation: "slide_from_right", title: "Add Environment" }}
34 />
35 <Stack.Screen
36 name="waitlist"
37 options={{ animation: "slide_from_right", title: "Join the waitlist" }}
38 />
39 </Stack>
40 );
41}
apps/mobile/src/app/settings/environment-new.tsx+1−0
@@ -0,0 +1 @@
1export { default } from "../connections/new";
apps/mobile/src/app/settings/environments.tsx+335−0
@@ -0,0 +1,335 @@
1import { useAuth } from "@clerk/expo";
2import { Link, Stack } from "expo-router";
3import { SymbolView } from "expo-symbols";
4import type { EnvironmentId } from "@t3tools/contracts";
5import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
6import * as Effect from "effect/Effect";
7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10
11import { AppText as Text } from "../../components/AppText";
12import {
13 cloudEnvironmentsPendingStatus,
14 type CloudEnvironmentRecordWithStatus,
15 connectCloudEnvironment,
16 listCloudEnvironments,
17 loadCloudEnvironmentStatuses,
18} from "../../features/cloud/linkEnvironment";
19import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
20import { cn } from "../../lib/cn";
21import { mobileRuntime } from "../../lib/runtime";
22import { useThemeColor } from "../../lib/useThemeColor";
23import {
24 connectSavedEnvironment,
25 useRemoteConnections,
26 useRemoteEnvironmentState,
27} from "../../state/use-remote-environment-registry";
28
29export default function SettingsEnvironmentsRouteScreen() {
30 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
31 const getTokenRef = useRef(getToken);
32 getTokenRef.current = getToken;
33 const {
34 connectedEnvironments,
35 onReconnectEnvironment,
36 onRemoveEnvironmentPress,
37 onUpdateEnvironment,
38 } = useRemoteConnections();
39 const { savedConnectionsById } = useRemoteEnvironmentState();
40 const insets = useSafeAreaInsets();
41 const hasEnvironments = connectedEnvironments.length > 0;
42 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
43 const [cloudEnvironments, setCloudEnvironments] = useState<
44 ReadonlyArray<CloudEnvironmentRecordWithStatus>
45 >([]);
46 const [cloudStatus, setCloudStatus] = useState<"idle" | "loading" | "error">("idle");
47 const [cloudError, setCloudError] = useState<string | null>(null);
48 const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState<string | null>(
49 null,
50 );
51
52 const accentColor = useThemeColor("--color-icon-muted");
53 const iconColor = useThemeColor("--color-icon");
54
55 const handleToggle = useCallback((environmentId: EnvironmentId) => {
56 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
57 }, []);
58
59 const availableCloudEnvironments = useMemo(
60 () =>
61 cloudEnvironments.filter(
62 (record) => savedConnectionsById[record.environment.environmentId] === undefined,
63 ),
64 [cloudEnvironments, savedConnectionsById],
65 );
66
67 const refreshCloudEnvironments = useCallback(async () => {
68 if (!isLoaded || !isSignedIn) {
69 setCloudEnvironments([]);
70 setCloudStatus("idle");
71 setCloudError(null);
72 return;
73 }
74
75 setCloudStatus("loading");
76 setCloudError(null);
77 try {
78 const token = await getTokenRef.current(RELAY_CLERK_TOKEN_OPTIONS);
79 if (!token) {
80 setCloudEnvironments([]);
81 setCloudStatus("idle");
82 return;
83 }
84 const environments = await mobileRuntime.runPromise(
85 listCloudEnvironments({ clerkToken: token }),
86 );
87 setCloudEnvironments(cloudEnvironmentsPendingStatus(environments));
88 const records = await mobileRuntime.runPromise(
89 loadCloudEnvironmentStatuses({ clerkToken: token, environments }),
90 );
91 setCloudEnvironments(records);
92 setCloudStatus("idle");
93 } catch (error) {
94 setCloudStatus("error");
95 setCloudError(
96 error instanceof Error ? error.message : "Could not load T3 Cloud environments.",
97 );
98 }
99 }, [isLoaded, isSignedIn]);
100
101 useEffect(() => {
102 void refreshCloudEnvironments();
103 }, [refreshCloudEnvironments]);
104
105 const handleConnectCloudEnvironment = useCallback(
106 async (record: CloudEnvironmentRecordWithStatus) => {
107 setConnectingCloudEnvironmentId(record.environment.environmentId);
108 try {
109 const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS);
110 if (!token) {
111 throw new Error("Sign in to T3 Cloud before connecting.");
112 }
113 await mobileRuntime.runPromise(
114 connectCloudEnvironment({
115 clerkToken: token,
116 environment: record.environment,
117 }).pipe(Effect.flatMap(connectSavedEnvironment)),
118 );
119 setCloudEnvironments((records) =>
120 records.filter(
121 (candidate) => candidate.environment.environmentId !== record.environment.environmentId,
122 ),
123 );
124 } catch (error) {
125 Alert.alert(
126 "Connect failed",
127 error instanceof Error ? error.message : "Could not connect to this environment.",
128 );
129 } finally {
130 setConnectingCloudEnvironmentId(null);
131 }
132 },
133 [getToken],
134 );
135
136 return (
137 <View collapsable={false} className="flex-1 bg-sheet">
138 <Stack.Screen
139 options={{
140 title: "Environments",
141 headerRight: () => (
142 <Link href="/settings/environment-new" asChild>
143 <Pressable
144 accessibilityLabel="Add environment"
145 accessibilityRole="button"
146 className="h-10 w-10 items-center justify-center active:opacity-70"
147 >
148 <SymbolView
149 name="plus"
150 size={18}
151 tintColor={iconColor}
152 type="monochrome"
153 weight="semibold"
154 />
155 </Pressable>
156 </Link>
157 ),
158 }}
159 />
160 <ScrollView
161 contentInsetAdjustmentBehavior="automatic"
162 showsVerticalScrollIndicator={false}
163 style={{ flex: 1 }}
164 contentContainerStyle={{
165 paddingBottom: Math.max(insets.bottom, 18) + 18,
166 paddingHorizontal: 20,
167 paddingTop: 16,
168 }}
169 >
170 {hasEnvironments ? (
171 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
172 {connectedEnvironments.map((environment, index) => (
173 <View
174 key={environment.environmentId}
175 collapsable={false}
176 style={{
177 borderTopWidth: index === 0 ? 0 : 1,
178 }}
179 className={cn(index !== 0 && "border-border")}
180 >
181 <ConnectionEnvironmentRow
182 environment={environment}
183 expanded={expandedId === environment.environmentId}
184 onToggle={() => handleToggle(environment.environmentId)}
185 onReconnect={onReconnectEnvironment}
186 onRemove={onRemoveEnvironmentPress}
187 onUpdate={onUpdateEnvironment}
188 />
189 </View>
190 ))}
191 </View>
192 ) : (
193 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
194 <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle">
195 <SymbolView
196 name="point.3.connected.trianglepath.dotted"
197 size={20}
198 tintColor={accentColor}
199 type="monochrome"
200 />
201 </View>
202 <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
203 No environments connected yet.{"\n"}Tap{" "}
204 <Text className="font-t3-bold text-foreground">+</Text> to add one.
205 </Text>
206 </View>
207 )}
208
209 {isSignedIn ? (
210 <View collapsable={false} className="mt-5 gap-3">
211 <View className="flex-row items-center justify-between px-1">
212 <Text className="text-[13px] font-t3-bold uppercase text-foreground-muted">
213 T3 Cloud
214 </Text>
215 <Pressable
216 accessibilityRole="button"
217 disabled={cloudStatus === "loading"}
218 onPress={refreshCloudEnvironments}
219 className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
220 >
221 {cloudStatus === "loading" ? (
222 <ActivityIndicator color={iconColor} size="small" />
223 ) : (
224 <SymbolView
225 name="arrow.clockwise"
226 size={14}
227 tintColor={iconColor}
228 type="monochrome"
229 />
230 )}
231 </Pressable>
232 </View>
233
234 {availableCloudEnvironments.length > 0 ? (
235 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
236 {availableCloudEnvironments.map((record, index) => (
237 <CloudEnvironmentRow
238 key={record.environment.environmentId}
239 record={record}
240 borderTop={index !== 0}
241 isConnecting={connectingCloudEnvironmentId === record.environment.environmentId}
242 onConnect={() => handleConnectCloudEnvironment(record)}
243 />
244 ))}
245 </View>
246 ) : cloudStatus === "loading" ? (
247 <View
248 collapsable={false}
249 className="items-center gap-3 rounded-[24px] bg-card px-6 py-6"
250 >
251 <ActivityIndicator color={iconColor} />
252 <Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
253 Loading linked cloud environments.
254 </Text>
255 </View>
256 ) : cloudStatus === "error" ? (
257 <View collapsable={false} className="gap-3 rounded-[24px] bg-card px-5 py-5">
258 <Text className="text-[15px] font-t3-bold text-foreground">
259 Could not load T3 Cloud environments
260 </Text>
261 <Text className="text-[13px] leading-[18px] text-foreground-muted">
262 {cloudError}
263 </Text>
264 </View>
265 ) : (
266 <View collapsable={false} className="rounded-[24px] bg-card px-5 py-5">
267 <Text className="text-[14px] leading-[20px] text-foreground-muted">
268 No additional linked cloud environments.
269 </Text>
270 </View>
271 )}
272 </View>
273 ) : null}
274 </ScrollView>
275 </View>
276 );
277}
278
279function CloudEnvironmentRow(props: {
280 readonly record: CloudEnvironmentRecordWithStatus;
281 readonly borderTop: boolean;
282 readonly isConnecting: boolean;
283 readonly onConnect: () => void;
284}) {
285 const mutedColor = useThemeColor("--color-icon-muted");
286 const { environment, status, statusError } = props.record;
287 const disabled = props.isConnecting;
288 const statusText =
289 status === null
290 ? (statusError ?? "Status unavailable")
291 : status.status === "online"
292 ? "Online"
293 : (status.error ?? "Offline");
294
295 return (
296 <View
297 collapsable={false}
298 className={cn(
299 "flex-row items-center gap-3 bg-card px-4 py-3.5",
300 props.borderTop && "border-t border-border",
301 )}
302 >
303 <View className="h-9 w-9 items-center justify-center rounded-[14px] bg-subtle">
304 <SymbolView
305 name="cloud"
306 size={17}
307 tintColor={mutedColor}
308 type="monochrome"
309 weight="semibold"
310 />
311 </View>
312 <View className="min-w-0 flex-1 gap-0.5">
313 <Text className="text-[16px] font-t3-bold leading-[21px] text-foreground" numberOfLines={1}>
314 {environment.label}
315 </Text>
316 <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}>
317 {environment.endpoint.httpBaseUrl}
318 </Text>
319 <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}>
320 {statusText}
321 </Text>
322 </View>
323 <Pressable
324 accessibilityRole="button"
325 disabled={disabled}
326 onPress={props.onConnect}
327 className="min-h-[40px] min-w-[88px] items-center justify-center rounded-[14px] bg-primary px-4 active:opacity-70 disabled:opacity-50"
328 >
329 <Text className="text-[13px] font-t3-bold text-primary-foreground">
330 {props.isConnecting ? "Connecting" : "Connect"}
331 </Text>
332 </Pressable>
333 </View>
334 );
335}
apps/mobile/src/app/settings/index.tsx+400−0
@@ -0,0 +1,400 @@
1import { useAuth, useUser, useUserProfileModal } from "@clerk/expo";
2import * as Notifications from "expo-notifications";
3import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
4import { Link, Stack, useRouter } from "expo-router";
5import { SymbolView } from "expo-symbols";
6import * as Effect from "effect/Effect";
7import { useCallback, useEffect, useMemo, useState } from "react";
8import type { ComponentProps, ReactNode } from "react";
9import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native";
10import { useSafeAreaInsets } from "react-native-safe-area-context";
11
12import { AppText as Text } from "../../components/AppText";
13import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences";
14import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";
15import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration";
16import { mobileRuntime } from "../../lib/runtime";
17import { loadPreferences } from "../../lib/storage";
18import { useThemeColor } from "../../lib/useThemeColor";
19import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
20
21type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
22type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
23
24export default function SettingsRouteScreen() {
25 const insets = useSafeAreaInsets();
26 const { push } = useRouter();
27 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
28 const { user } = useUser();
29 const { isAvailable: isUserProfileModalAvailable, presentUserProfile } = useUserProfileModal();
30 const { savedConnectionsById } = useRemoteEnvironmentState();
31 const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking");
32 const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking");
33
34 const icon = useThemeColor("--color-icon");
35 const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]);
36 const environmentCount = connections.length;
37 const accountLabel = useMemo(() => {
38 if (!isLoaded) return "Checking";
39 if (!isSignedIn) return "Request access";
40 return user?.primaryEmailAddress?.emailAddress ?? "Signed in";
41 }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]);
42
43 const refreshNotifications = useCallback(async () => {
44 if (process.env.EXPO_OS !== "ios") {
45 setNotificationStatus("unsupported");
46 return;
47 }
48 const permission = await Notifications.getPermissionsAsync();
49 setNotificationStatus(permission.granted ? "enabled" : "disabled");
50 }, []);
51
52 useEffect(() => {
53 void refreshNotifications();
54 }, [refreshNotifications]);
55
56 useEffect(() => {
57 if (!isLoaded) {
58 setLiveActivityStatus("checking");
59 return;
60 }
61 if (!isSignedIn) {
62 setLiveActivityStatus("signed-out");
63 return;
64 }
65 void loadPreferences().then(
66 (preferences) => {
67 setLiveActivityStatus(preferences.liveActivitiesEnabled === false ? "disabled" : "enabled");
68 },
69 () => {
70 setLiveActivityStatus("enabled");
71 },
72 );
73 }, [isLoaded, isSignedIn]);
74
75 const requestNotifications = useCallback(async () => {
76 try {
77 const result = await mobileRuntime.runPromise(
78 requestAgentNotificationPermission.pipe(
79 Effect.tap((permission) =>
80 permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void,
81 ),
82 ),
83 );
84 if (result.type === "granted") {
85 setNotificationStatus("enabled");
86 Alert.alert(
87 "Notifications enabled",
88 "Live Activity notifications are enabled for this device.",
89 );
90 return;
91 }
92 if (result.type === "unsupported") {
93 setNotificationStatus("unsupported");
94 Alert.alert(
95 "Notifications unavailable",
96 "Live Activity notifications are only available on iOS.",
97 );
98 return;
99 }
100 setNotificationStatus("disabled");
101 if (result.canAskAgain) {
102 Alert.alert("Notifications disabled", "Notifications were not enabled.");
103 return;
104 }
105 Alert.alert(
106 "Notifications disabled",
107 "Notifications were denied for this app. Open Settings to enable them.",
108 [
109 { text: "Cancel", style: "cancel" },
110 { text: "Open Settings", onPress: () => void Linking.openSettings() },
111 ],
112 );
113 } catch (error) {
114 Alert.alert(
115 "Notifications unavailable",
116 error instanceof Error ? error.message : "Could not request notification permission.",
117 );
118 }
119 }, []);
120
121 const promptSignIn = useCallback(() => {
122 Alert.alert(
123 "Request T3 Cloud access",
124 "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.",
125 [
126 { text: "Cancel", style: "cancel" },
127 { text: "Continue", onPress: () => push("/settings/waitlist") },
128 ],
129 );
130 }, [push]);
131
132 const linkEnvironments = useCallback(async () => {
133 if (!isSignedIn) {
134 promptSignIn();
135 return;
136 }
137
138 setLiveActivityStatus("linking");
139 try {
140 const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS);
141 if (!token) {
142 promptSignIn();
143 setLiveActivityStatus("signed-out");
144 return;
145 }
146
147 await mobileRuntime.runPromise(
148 setLiveActivityUpdatesEnabled({
149 enabled: true,
150 clerkToken: token,
151 connections,
152 }),
153 );
154 setLiveActivityStatus("enabled");
155 Alert.alert(
156 "Live Activities enabled",
157 environmentCount > 0
158 ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.`
159 : "Live Activity updates are enabled. Add an environment to start receiving updates.",
160 );
161 } catch (error) {
162 setLiveActivityStatus("disabled");
163 Alert.alert(
164 "Live Activities unavailable",
165 error instanceof Error ? error.message : "Could not enable Live Activity updates.",
166 );
167 }
168 }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]);
169
170 const handleDeviceNotificationsChange = useCallback(
171 (enabled: boolean) => {
172 if (enabled) {
173 void requestNotifications();
174 return;
175 }
176
177 Alert.alert(
178 "Disable notifications",
179 "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.",
180 [
181 { text: "Cancel", style: "cancel" },
182 { text: "Open Settings", onPress: () => void Linking.openSettings() },
183 ],
184 );
185 },
186 [requestNotifications],
187 );
188
189 const handleLiveActivitiesChange = useCallback(
190 (enabled: boolean) => {
191 if (!enabled) {
192 setLiveActivityStatus("disabled");
193 void (async () => {
194 try {
195 const token = isSignedIn ? await getToken(RELAY_CLERK_TOKEN_OPTIONS) : null;
196 await mobileRuntime.runPromise(
197 setLiveActivityUpdatesEnabled({
198 enabled: false,
199 clerkToken: token,
200 connections,
201 }),
202 );
203 } catch {
204 // The switch is optimistic; a future refresh reconciles relay state.
205 }
206 })();
207 return;
208 }
209
210 if (!isSignedIn) {
211 promptSignIn();
212 return;
213 }
214
215 void linkEnvironments();
216 },
217 [connections, getToken, isSignedIn, linkEnvironments, promptSignIn],
218 );
219
220 const openAccount = useCallback(() => {
221 if (!isLoaded) return;
222 if (!isSignedIn) {
223 push("/settings/waitlist");
224 return;
225 }
226 if (isUserProfileModalAvailable) {
227 void presentUserProfile();
228 return;
229 }
230 Alert.alert(
231 "T3 Cloud unavailable",
232 "Native T3 Cloud account management is not available in this build.",
233 );
234 }, [isLoaded, isSignedIn, isUserProfileModalAvailable, presentUserProfile, push]);
235
236 return (
237 <View collapsable={false} className="flex-1 bg-sheet">
238 <Stack.Screen options={{ title: "Settings" }} />
239 <ScrollView
240 contentInsetAdjustmentBehavior="automatic"
241 showsVerticalScrollIndicator={false}
242 style={{ flex: 1 }}
243 contentContainerStyle={{
244 gap: 24,
245 paddingBottom: Math.max(insets.bottom, 18) + 18,
246 paddingHorizontal: 20,
247 paddingTop: 16,
248 }}
249 >
250 <View className="gap-3">
251 <SettingsSection title="Account">
252 <SettingsRow
253 icon="person.crop.circle"
254 label="T3 Account"
255 value={accountLabel}
256 onPress={openAccount}
257 />
258 </SettingsSection>
259 <Text className="px-2 text-[13px] leading-[18px] text-foreground-muted">
260 T3 Code works locally without signing in. Cloud features are optional.
261 </Text>
262 </View>
263
264 <SettingsSection title="Configuration">
265 <SettingsRow
266 icon="desktopcomputer"
267 label="Environments"
268 value={`${environmentCount}`}
269 href="/settings/environments"
270 />
271 <SettingsSwitchRow
272 icon="bell.badge"
273 label="Device Notifications"
274 disabled={notificationStatus === "checking" || notificationStatus === "unsupported"}
275 value={notificationStatus === "enabled"}
276 onValueChange={handleDeviceNotificationsChange}
277 />
278 <SettingsSwitchRow
279 disabled={
280 !isLoaded || liveActivityStatus === "checking" || liveActivityStatus === "linking"
281 }
282 icon="bolt.circle"
283 label="Live Activity Updates"
284 value={liveActivityStatus === "enabled" || liveActivityStatus === "linking"}
285 onValueChange={handleLiveActivitiesChange}
286 />
287 </SettingsSection>
288
289 <SettingsSection title="App">
290 <View className="flex-row items-center gap-4 px-4 py-4">
291 <SymbolView
292 name="info.circle"
293 size={22}
294 tintColor={icon}
295 type="monochrome"
296 weight="regular"
297 />
298 <Text className="flex-1 text-[17px] text-foreground">Version</Text>
299 <Text className="text-[17px] text-foreground-muted">Alpha</Text>
300 </View>
301 </SettingsSection>
302 </ScrollView>
303 </View>
304 );
305}
306
307type SymbolName = ComponentProps<typeof SymbolView>["name"];
308
309function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) {
310 return (
311 <View className="gap-2">
312 <Text className="px-2 font-t3-bold text-[17px] text-foreground-tertiary">{props.title}</Text>
313 <View
314 className="overflow-hidden rounded-[28px] bg-card"
315 style={{ borderCurve: "continuous" }}
316 >
317 {props.children}
318 </View>
319 </View>
320 );
321}
322
323function SettingsRow(props: {
324 readonly disabled?: boolean;
325 readonly icon: SymbolName;
326 readonly label: string;
327 readonly value?: string;
328 readonly href?: "/settings/environments";
329 readonly onPress?: () => void;
330}) {
331 const icon = useThemeColor("--color-icon");
332 const chevron = useThemeColor("--color-chevron");
333 const content = (
334 <View
335 className="flex-row items-center gap-4 px-4 py-4"
336 style={{ opacity: props.disabled ? 0.45 : 1 }}
337 >
338 <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
339 <Text className="flex-1 text-[17px] text-foreground">{props.label}</Text>
340 {props.value ? (
341 <Text
342 className="max-w-[180px] text-right text-[16px] text-foreground-muted"
343 numberOfLines={1}
344 >
345 {props.value}
346 </Text>
347 ) : null}
348 <SymbolView
349 name="chevron.right"
350 size={16}
351 tintColor={chevron}
352 type="monochrome"
353 weight="semibold"
354 />
355 </View>
356 );
357
358 if (props.href) {
359 return (
360 <Link href={props.href} asChild>
361 <Pressable>{content}</Pressable>
362 </Link>
363 );
364 }
365
366 return (
367 <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}>
368 {content}
369 </Pressable>
370 );
371}
372
373function SettingsSwitchRow(props: {
374 readonly disabled?: boolean;
375 readonly icon: SymbolName;
376 readonly label: string;
377 readonly value: boolean;
378 readonly onValueChange: (value: boolean) => void;
379}) {
380 const icon = useThemeColor("--color-icon");
381 const activeTrack = String(useThemeColor("--color-switch-active"));
382 const track = String(useThemeColor("--color-secondary-border"));
383
384 return (
385 <View
386 className="flex-row items-center gap-4 px-4 py-4"
387 style={{ opacity: props.disabled ? 0.45 : 1 }}
388 >
389 <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" />
390 <Text className="flex-1 text-[17px] text-foreground">{props.label}</Text>
391 <Switch
392 disabled={props.disabled}
393 ios_backgroundColor={track}
394 onValueChange={props.onValueChange}
395 trackColor={{ false: track, true: activeTrack }}
396 value={props.value}
397 />
398 </View>
399 );
400}
apps/mobile/src/app/settings/waitlist.tsx+67−0
@@ -0,0 +1,67 @@
1import Constants from "expo-constants";
2import { Stack } from "expo-router";
3import { ScrollView, Text, View } from "react-native";
4
5import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment";
6import { useNativeClerkAuthModal } from "../../features/cloud/useNativeClerkAuthModal";
7import { useThemeColor } from "../../lib/useThemeColor";
8
9function hasClerkConfig(): boolean {
10 const clerkConfig = Constants.expoConfig?.extra?.clerk as
11 | { readonly publishableKey?: string | null }
12 | undefined;
13 return Boolean(clerkConfig?.publishableKey);
14}
15
16export default function SettingsWaitlistRouteScreen() {
17 const { presentAuth } = useNativeClerkAuthModal();
18 const foreground = String(useThemeColor("--color-foreground"));
19 const secondaryForeground = String(useThemeColor("--color-foreground-secondary"));
20
21 return (
22 <>
23 <Stack.Screen options={{ title: "Join the waitlist" }} />
24 <ScrollView
25 automaticallyAdjustKeyboardInsets
26 contentContainerStyle={{
27 paddingBottom: 32,
28 paddingHorizontal: 20,
29 paddingTop: 12,
30 }}
31 contentInsetAdjustmentBehavior="automatic"
32 keyboardDismissMode="interactive"
33 keyboardShouldPersistTaps="handled"
34 showsVerticalScrollIndicator={false}
35 >
36 {hasClerkConfig() ? (
37 <CloudWaitlistEnrollment onSignIn={() => void presentAuth()} />
38 ) : (
39 <View className="gap-3 px-4">
40 <Text
41 style={{
42 color: foreground,
43 fontFamily: "DMSans_700Bold",
44 fontSize: 22,
45 textAlign: "center",
46 }}
47 >
48 T3 Cloud is not configured
49 </Text>
50 <Text
51 selectable
52 style={{
53 color: secondaryForeground,
54 fontFamily: "DMSans_400Regular",
55 fontSize: 16,
56 lineHeight: 22,
57 textAlign: "center",
58 }}
59 >
60 Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to this build to enable waitlist enrollment.
61 </Text>
62 </View>
63 )}
64 </ScrollView>
65 </>
66 );
67}
apps/mobile/src/features/agent-awareness/liveActivityController.test.ts+256−0
@@ -0,0 +1,256 @@
1import { afterEach, beforeEach, vi } from "vitest";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import { ManagedRelayClient } from "@t3tools/client-runtime";
5
6import type { EnvironmentId, OrchestrationShellSnapshot, ThreadId } from "@t3tools/contracts";
7import {
8 __resetAgentLiveActivitiesForTest,
9 refreshActiveLiveActivityRemoteRegistration,
10 syncAgentLiveActivitiesForSnapshot,
11} from "./liveActivityController";
12import { registerLiveActivityPushToken } from "./remoteRegistration";
13
14const mocks = vi.hoisted(() => {
15 const existingActivity = {
16 update: vi.fn(() => Promise.resolve()),
17 end: vi.fn((_dismissal: unknown, _props?: unknown, _endedAt?: unknown) => Promise.resolve()),
18 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
19 addPushTokenListener: vi.fn(),
20 };
21 return {
22 existingActivity,
23 getInstances: vi.fn(() => [existingActivity]),
24 start: vi.fn(() => existingActivity),
25 registerLiveActivityPushToken: vi.fn(
26 (): Effect.Effect<boolean, unknown> => Effect.succeed(true),
27 ),
28 };
29});
30
31vi.mock("react-native", () => ({
32 Platform: {
33 OS: "ios",
34 },
35}));
36
37vi.mock("expo-linking", () => ({
38 default: {
39 createURL: vi.fn((path: string) => `t3code://${path}`),
40 },
41 createURL: vi.fn((path: string) => `t3code://${path}`),
42}));
43
44vi.mock("expo-widgets", () => ({
45 after: (date: Date) => ({ after: date }),
46}));
47
48vi.mock("../../lib/runtime", () => ({
49 mobileRuntime: {
50 runPromise: <A, E>(operation: Effect.Effect<A, E>) => Effect.runPromise(operation),
51 },
52}));
53
54vi.mock("../../widgets/AgentActivity", () => ({
55 default: {
56 getInstances: mocks.getInstances,
57 start: mocks.start,
58 },
59}));
60
61vi.mock("./remoteRegistration", () => ({
62 registerLiveActivityPushToken: mocks.registerLiveActivityPushToken,
63}));
64
65const runManagedRelayEffect = <A, E>(effect: Effect.Effect<A, E, ManagedRelayClient>): Promise<A> =>
66 Effect.runPromise(effect.pipe(Effect.provideService(ManagedRelayClient, null as never)));
67
68function runningSnapshot(): OrchestrationShellSnapshot {
69 return {
70 projects: [
71 {
72 id: "project-1",
73 title: "Project",
74 },
75 ],
76 threads: [
77 {
78 id: "thread-1" as ThreadId,
79 projectId: "project-1",
80 title: "Thread",
81 modelSelection: {
82 model: "gpt-5",
83 },
84 session: {
85 status: "running",
86 providerName: "Codex",
87 },
88 latestTurn: {
89 state: "running",
90 },
91 updatedAt: "2026-05-25T00:00:00.000Z",
92 hasPendingApprovals: false,
93 hasPendingUserInput: false,
94 },
95 ],
96 } as unknown as OrchestrationShellSnapshot;
97}
98
99function completedSnapshot(): OrchestrationShellSnapshot {
100 return {
101 projects: [
102 {
103 id: "project-1",
104 title: "Project",
105 },
106 ],
107 threads: [
108 {
109 id: "thread-1" as ThreadId,
110 projectId: "project-1",
111 title: "Thread",
112 modelSelection: {
113 model: "gpt-5",
114 },
115 session: null,
116 latestTurn: {
117 state: "completed",
118 },
119 updatedAt: "2026-05-25T00:01:00.000Z",
120 hasPendingApprovals: false,
121 hasPendingUserInput: false,
122 },
123 ],
124 } as unknown as OrchestrationShellSnapshot;
125}
126
127describe("liveActivityController", () => {
128 beforeEach(() => {
129 vi.stubGlobal("__DEV__", false);
130 __resetAgentLiveActivitiesForTest();
131 mocks.existingActivity.update.mockClear();
132 mocks.existingActivity.end.mockClear();
133 mocks.existingActivity.getPushToken.mockClear();
134 mocks.existingActivity.addPushTokenListener.mockClear();
135 mocks.getInstances.mockClear();
136 mocks.getInstances.mockReturnValue([mocks.existingActivity]);
137 mocks.start.mockClear();
138 mocks.registerLiveActivityPushToken.mockClear();
139 mocks.registerLiveActivityPushToken.mockReturnValue(Effect.succeed(true));
140 });
141
142 afterEach(() => {
143 vi.useRealTimers();
144 });
145
146 it("adopts an existing remote-started Live Activity instead of creating a duplicate", async () => {
147 await Effect.runPromise(
148 syncAgentLiveActivitiesForSnapshot({
149 environmentId: "env-1" as EnvironmentId,
150 snapshot: runningSnapshot(),
151 }),
152 );
153
154 expect(mocks.getInstances).toHaveBeenCalledTimes(1);
155 expect(mocks.existingActivity.update).toHaveBeenCalledTimes(1);
156 expect(mocks.start).not.toHaveBeenCalled();
157 expect(registerLiveActivityPushToken).toHaveBeenCalledWith({
158 activity: mocks.existingActivity,
159 });
160 });
161
162 it("keeps skipped remote token registrations retryable after cloud sign-in", async () => {
163 mocks.registerLiveActivityPushToken
164 .mockReturnValueOnce(Effect.succeed(false))
165 .mockReturnValueOnce(Effect.succeed(true));
166
167 await Effect.runPromise(
168 syncAgentLiveActivitiesForSnapshot({
169 environmentId: "env-1" as EnvironmentId,
170 snapshot: runningSnapshot(),
171 }),
172 );
173 await runManagedRelayEffect(refreshActiveLiveActivityRemoteRegistration());
174
175 expect(registerLiveActivityPushToken).toHaveBeenCalledTimes(2);
176 expect(registerLiveActivityPushToken).toHaveBeenNthCalledWith(2, {
177 activity: mocks.existingActivity,
178 });
179 });
180
181 it("retries cloud sign-in Live Activity token refresh failures", async () => {
182 vi.useFakeTimers();
183 mocks.registerLiveActivityPushToken
184 .mockReturnValueOnce(Effect.succeed(true))
185 .mockReturnValueOnce(Effect.fail(new Error("relay unavailable")))
186 .mockReturnValueOnce(Effect.succeed(true));
187
188 await Effect.runPromise(
189 syncAgentLiveActivitiesForSnapshot({
190 environmentId: "env-1" as EnvironmentId,
191 snapshot: runningSnapshot(),
192 }),
193 );
194 await runManagedRelayEffect(refreshActiveLiveActivityRemoteRegistration());
195
196 expect(registerLiveActivityPushToken).toHaveBeenCalledTimes(2);
197
198 await vi.advanceTimersByTimeAsync(15_000);
199
200 expect(registerLiveActivityPushToken).toHaveBeenCalledTimes(3);
201 expect(registerLiveActivityPushToken).toHaveBeenNthCalledWith(3, {
202 activity: mocks.existingActivity,
203 });
204 });
205
206 it("retries adopted remote-start Live Activity token registration when the token is initially unavailable", async () => {
207 vi.useFakeTimers();
208 mocks.registerLiveActivityPushToken
209 .mockReturnValueOnce(Effect.succeed(false))
210 .mockReturnValueOnce(Effect.succeed(true));
211
212 await Effect.runPromise(
213 syncAgentLiveActivitiesForSnapshot({
214 environmentId: "env-1" as EnvironmentId,
215 snapshot: runningSnapshot(),
216 }),
217 );
218
219 expect(registerLiveActivityPushToken).toHaveBeenCalledTimes(1);
220
221 await vi.advanceTimersByTimeAsync(15_000);
222
223 expect(registerLiveActivityPushToken).toHaveBeenCalledTimes(2);
224 expect(registerLiveActivityPushToken).toHaveBeenNthCalledWith(2, {
225 activity: mocks.existingActivity,
226 });
227 });
228
229 it("ends active Live Activities with the terminal thread content state", async () => {
230 await Effect.runPromise(
231 syncAgentLiveActivitiesForSnapshot({
232 environmentId: "env-1" as EnvironmentId,
233 snapshot: runningSnapshot(),
234 }),
235 );
236 await Effect.runPromise(
237 syncAgentLiveActivitiesForSnapshot({
238 environmentId: "env-1" as EnvironmentId,
239 snapshot: completedSnapshot(),
240 }),
241 );
242
243 expect(mocks.existingActivity.end).toHaveBeenCalledTimes(1);
244 expect(mocks.existingActivity.end.mock.calls[0]?.[1]).toMatchObject({
245 activeCount: 1,
246 activities: [
247 {
248 environmentId: "env-1",
249 threadId: "thread-1",
250 phase: "completed",
251 status: "Done",
252 },
253 ],
254 });
255 });
256});
apps/mobile/src/features/agent-awareness/liveActivityController.ts+605−0
@@ -0,0 +1,605 @@
1import {
2 isTerminalAgentAwarenessPhase,
3 projectThreadAwareness,
4 type AgentAwarenessState,
5} from "@t3tools/shared/agentAwareness";
6import type {
7 EnvironmentId,
8 OrchestrationProjectShell,
9 OrchestrationShellSnapshot,
10} from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as Option from "effect/Option";
15import * as Semaphore from "effect/Semaphore";
16import { after, type LiveActivity } from "expo-widgets";
17import * as Linking from "expo-linking";
18import { Platform } from "react-native";
19import { ManagedRelayClient } from "@t3tools/client-runtime";
20
21import { mobileRuntime } from "../../lib/runtime";
22import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
23import { registerLiveActivityPushToken } from "./remoteRegistration";
24
25const MAX_LOCAL_LIVE_ACTIVITIES = 3;
26const FINAL_ACTIVITY_DISMISSAL_MS = 5 * 60 * 1_000;
27const REMOTE_TOKEN_REGISTRATION_RETRY_MS = 15_000;
28const MISSING_STATE_GRACE_MS = 30_000;
29
30interface ActiveDeviceActivity {
31 readonly instance: LiveActivity<AgentActivityProps>;
32 readonly props: AgentActivityProps;
33 readonly lastRemoteTokenRegistrationAttemptAt: number | null;
34 readonly remoteTokenRegistered: boolean;
35 readonly remoteTokenRetryScheduled: boolean;
36 readonly missingSince: number | null;
37}
38
39let activeActivity: ActiveDeviceActivity | null = null;
40const environmentStates = new Map<EnvironmentId, ReadonlyArray<AgentAwarenessState>>();
41let localLiveActivitiesEnabled = true;
42const liveActivitySyncSemaphore = Effect.runSync(Semaphore.make(1));
43
44function canUseLocalLiveActivities(): boolean {
45 return localLiveActivitiesEnabled && Platform.OS === "ios";
46}
47
48function logLiveActivityDebug(context: string, details?: unknown): void {
49 if (!__DEV__) {
50 return;
51 }
52 console.log(`[agent-awareness] ${context}`, details ?? "");
53}
54
55function logLiveActivityWarning(context: string, error: unknown): void {
56 if (!__DEV__) {
57 return;
58 }
59 console.warn(
60 `[agent-awareness] ${context}`,
61 error instanceof Error ? error.message : String(error),
62 );
63}
64
65function statusForPhase(phase: AgentAwarenessState["phase"]): string {
66 switch (phase) {
67 case "waiting_for_approval":
68 return "Approval";
69 case "waiting_for_input":
70 return "Input";
71 case "completed":
72 return "Done";
73 case "failed":
74 return "Failed";
75 case "starting":
76 return "Starting";
77 case "running":
78 return "Working";
79 case "stale":
80 return "Waiting";
81 }
82}
83
84function epochMillis(iso: string): number {
85 return Option.match(DateTime.make(iso), {
86 onNone: () => Number.NaN,
87 onSome: (dt) => dt.epochMilliseconds,
88 });
89}
90
91function currentTimeMillis(): number {
92 return Date.now();
93}
94
95function currentDateTime(): DateTime.Utc {
96 return DateTime.nowUnsafe();
97}
98
99function toActivityProps(states: ReadonlyArray<AgentAwarenessState>): AgentActivityProps {
100 const updatedAt = states.reduce<string | null>((latest, state) => {
101 if (latest === null) {
102 return state.updatedAt;
103 }
104 return epochMillis(state.updatedAt) > epochMillis(latest) ? state.updatedAt : latest;
105 }, null);
106
107 return {
108 title: "T3 Code",
109 subtitle: "Agent work in progress",
110 activeCount: states.length,
111 updatedAt: updatedAt ?? DateTime.formatIso(currentDateTime()),
112 activities: states.slice(0, MAX_LOCAL_LIVE_ACTIVITIES).map((state) => ({
113 environmentId: state.environmentId,
114 threadId: state.threadId,
115 projectTitle: state.projectTitle,
116 threadTitle: state.threadTitle,
117 modelTitle: state.modelTitle,
118 phase: state.phase,
119 status: statusForPhase(state.phase),
120 updatedAt: state.updatedAt,
121 deepLink: state.deepLink,
122 })),
123 };
124}
125
126function hasSameActivityContent(left: AgentActivityProps, right: AgentActivityProps): boolean {
127 return JSON.stringify(left) === JSON.stringify(right);
128}
129
130function sortByUpdatedAtDesc(left: AgentAwarenessState, right: AgentAwarenessState): number {
131 return epochMillis(right.updatedAt) - epochMillis(left.updatedAt);
132}
133
134function projectSnapshotAwareness(input: {
135 readonly environmentId: EnvironmentId;
136 readonly snapshot: OrchestrationShellSnapshot;
137}): ReadonlyArray<AgentAwarenessState> {
138 const projectsById = new Map<string, OrchestrationProjectShell>(
139 input.snapshot.projects.map((project) => [project.id, project]),
140 );
141 const states: AgentAwarenessState[] = [];
142
143 for (const thread of input.snapshot.threads) {
144 const project = projectsById.get(thread.projectId);
145 if (!project) {
146 continue;
147 }
148 const state = projectThreadAwareness({
149 environmentId: input.environmentId,
150 project,
151 thread,
152 });
153 if (state) {
154 states.push(state);
155 }
156 }
157
158 return states;
159}
160
161export function syncAgentLiveActivitiesForSnapshot(input: {
162 readonly environmentId: EnvironmentId;
163 readonly snapshot: OrchestrationShellSnapshot | null;
164}): Effect.Effect<void, unknown> {
165 return enqueueLiveActivitySync(
166 Effect.gen(function* () {
167 if (!canUseLocalLiveActivities()) {
168 return;
169 }
170
171 if (input.snapshot === null) {
172 return yield* endEnvironmentLiveActivitiesNow(input.environmentId);
173 }
174
175 const states = projectSnapshotAwareness({
176 environmentId: input.environmentId,
177 snapshot: input.snapshot,
178 });
179 environmentStates.set(input.environmentId, states);
180 return yield* syncAgentLiveActivities();
181 }),
182 );
183}
184
185export function endEnvironmentLiveActivities(
186 environmentId: EnvironmentId,
187): Effect.Effect<void, unknown> {
188 return enqueueLiveActivitySync(endEnvironmentLiveActivitiesNow(environmentId));
189}
190
191function endEnvironmentLiveActivitiesNow(
192 environmentId: EnvironmentId,
193): Effect.Effect<void, unknown> {
194 return Effect.gen(function* () {
195 environmentStates.delete(environmentId);
196 return yield* syncAgentLiveActivities();
197 });
198}
199
200export function endAllAgentLiveActivities(): Effect.Effect<void, unknown> {
201 return enqueueLiveActivitySync(endAllAgentLiveActivitiesNow());
202}
203
204function endAllAgentLiveActivitiesNow(): Effect.Effect<void, unknown> {
205 return Effect.gen(function* () {
206 environmentStates.clear();
207 return yield* endActiveActivity(null, "all-ended");
208 });
209}
210
211function enqueueLiveActivitySync<T, R>(
212 operation: Effect.Effect<T, unknown, R>,
213): Effect.Effect<T, unknown, R> {
214 return liveActivitySyncSemaphore.withPermits(1)(operation);
215}
216
217function syncAgentLiveActivities(): Effect.Effect<void, unknown> {
218 const states = [...environmentStates.values()].flat().sort(sortByUpdatedAtDesc);
219 const activeNonTerminalStates = states.filter(
220 (state) => !isTerminalAgentAwarenessPhase(state.phase),
221 );
222 const terminalState = states.find((state) => isTerminalAgentAwarenessPhase(state.phase)) ?? null;
223
224 if (activeNonTerminalStates.length === 0) {
225 if (!activeActivity) {
226 return Effect.void;
227 }
228 if (terminalState) {
229 return endActiveActivity(terminalState, "terminal-state");
230 }
231 const now = currentTimeMillis();
232 if (activeActivity.missingSince === null) {
233 activeActivity = { ...activeActivity, missingSince: now };
234 logLiveActivityDebug("live activity missing state; waiting before ending", {
235 graceMs: MISSING_STATE_GRACE_MS,
236 });
237 return Effect.void;
238 }
239 if (now - activeActivity.missingSince >= MISSING_STATE_GRACE_MS) {
240 return endActiveActivity(null, "missing-state");
241 }
242 return Effect.void;
243 }
244
245 return startOrUpdateActivity(activeNonTerminalStates);
246}
247
248function startOrUpdateActivity(
249 states: ReadonlyArray<AgentAwarenessState>,
250): Effect.Effect<void, unknown> {
251 const props = toActivityProps(states);
252 const primaryState = states[0];
253 if (activeActivity) {
254 const active = activeActivity;
255 if (!hasSameActivityContent(active.props, props)) {
256 logLiveActivityDebug("updating live activity", {
257 activeCount: props.activeCount,
258 primaryThreadId: primaryState?.threadId ?? null,
259 });
260 return runLiveActivityOperation(
261 "update",
262 Effect.tryPromise({
263 try: () => active.instance.update(props),
264 catch: (error) => error,
265 }),
266 ).pipe(
267 Effect.flatMap((updated) =>
268 Effect.sync(() => {
269 if (!updated.ok) {
270 activeActivity = null;
271 return;
272 }
273 activeActivity = { ...active, props, missingSince: null };
274 retryRemoteTokenRegistration(activeActivity.instance);
275 }),
276 ),
277 );
278 } else if (activeActivity.missingSince !== null) {
279 activeActivity = { ...activeActivity, missingSince: null };
280 }
281 retryRemoteTokenRegistration(activeActivity.instance);
282 return Effect.void;
283 }
284
285 return adoptExistingActivity(props, primaryState).pipe(
286 Effect.flatMap((adopted) => {
287 if (adopted) {
288 return Effect.void;
289 }
290
291 logLiveActivityDebug("starting live activity", {
292 activeCount: props.activeCount,
293 primaryThreadId: primaryState?.threadId ?? null,
294 });
295 return runLiveActivityOperation(
296 "start",
297 Effect.try({
298 try: () => AgentActivity.start(props, Linking.createURL(primaryState?.deepLink ?? "/")),
299 catch: (error) => error,
300 }),
301 ).pipe(
302 Effect.flatMap((started) =>
303 Effect.sync(() => {
304 const instance = started.ok ? started.value : undefined;
305 if (instance) {
306 logLiveActivityDebug("live activity started", {
307 primaryThreadId: primaryState?.threadId ?? null,
308 localActivityCount: AgentActivity.getInstances().length,
309 rowCount: props.activeCount,
310 });
311 activeActivity = {
312 instance,
313 props,
314 lastRemoteTokenRegistrationAttemptAt: null,
315 remoteTokenRegistered: false,
316 remoteTokenRetryScheduled: false,
317 missingSince: null,
318 };
319 retryRemoteTokenRegistration(instance);
320 }
321 }),
322 ),
323 );
324 }),
325 );
326}
327
328function adoptExistingActivity(
329 props: AgentActivityProps,
330 primaryState: AgentAwarenessState | undefined,
331): Effect.Effect<boolean, unknown> {
332 const existingActivity = AgentActivity.getInstances()[0];
333 if (!existingActivity) {
334 return Effect.succeed(false);
335 }
336
337 logLiveActivityDebug("adopting existing live activity", {
338 activeCount: props.activeCount,
339 primaryThreadId: primaryState?.threadId ?? null,
340 });
341 return runLiveActivityOperation(
342 "update",
343 Effect.tryPromise({
344 try: () => existingActivity.update(props),
345 catch: (error) => error,
346 }),
347 ).pipe(
348 Effect.flatMap((updated) =>
349 Effect.sync(() => {
350 if (!updated.ok) {
351 return false;
352 }
353
354 activeActivity = {
355 instance: existingActivity,
356 props,
357 lastRemoteTokenRegistrationAttemptAt: null,
358 remoteTokenRegistered: false,
359 remoteTokenRetryScheduled: false,
360 missingSince: null,
361 };
362 retryRemoteTokenRegistration(existingActivity);
363 return true;
364 }),
365 ),
366 );
367}
368
369function retryRemoteTokenRegistration(activity: LiveActivity<AgentActivityProps>): void {
370 if (!activeActivity) {
371 return;
372 }
373
374 if (activeActivity.remoteTokenRegistered) {
375 return;
376 }
377 const now = currentTimeMillis();
378 if (
379 activeActivity.lastRemoteTokenRegistrationAttemptAt !== null &&
380 now - activeActivity.lastRemoteTokenRegistrationAttemptAt < REMOTE_TOKEN_REGISTRATION_RETRY_MS
381 ) {
382 return;
383 }
384
385 activeActivity = {
386 ...activeActivity,
387 lastRemoteTokenRegistrationAttemptAt: now,
388 };
389 void mobileRuntime
390 .runPromise(
391 registerRemoteActivityToken(activity).pipe(
392 Effect.flatMap((registered) =>
393 Effect.sync(() => {
394 if (!registered) {
395 scheduleRemoteTokenRegistrationRetry(activity);
396 }
397 }),
398 ),
399 Effect.catch((error) =>
400 Effect.sync(() => {
401 logLiveActivityWarning(
402 "live activity token registration failed; retry scheduled",
403 error,
404 );
405 scheduleRemoteTokenRegistrationRetry(activity);
406 }),
407 ),
408 ),
409 )
410 .catch((error: unknown) => {
411 logLiveActivityWarning("unexpected live activity token registration failure", error);
412 });
413}
414
415function registerRemoteActivityToken(
416 activity: LiveActivity<AgentActivityProps>,
417): Effect.Effect<boolean, unknown, ManagedRelayClient> {
418 return registerLiveActivityPushToken({ activity }).pipe(
419 Effect.flatMap((registered) =>
420 Effect.sync(() => {
421 if (registered && activeActivity?.instance === activity) {
422 activeActivity = {
423 ...activeActivity,
424 remoteTokenRegistered: true,
425 remoteTokenRetryScheduled: false,
426 };
427 }
428 return registered;
429 }),
430 ),
431 );
432}
433
434function scheduleRemoteTokenRegistrationRetry(activity: LiveActivity<AgentActivityProps>): void {
435 if (
436 !activeActivity ||
437 activeActivity.instance !== activity ||
438 activeActivity.remoteTokenRegistered ||
439 activeActivity.remoteTokenRetryScheduled
440 ) {
441 return;
442 }
443
444 activeActivity = {
445 ...activeActivity,
446 remoteTokenRetryScheduled: true,
447 };
448
449 void mobileRuntime
450 .runPromise(
451 Effect.sleep(Duration.millis(REMOTE_TOKEN_REGISTRATION_RETRY_MS)).pipe(
452 Effect.flatMap(() =>
453 enqueueLiveActivitySync(
454 Effect.sync(() => {
455 if (!activeActivity || activeActivity.instance !== activity) {
456 return;
457 }
458 activeActivity = {
459 ...activeActivity,
460 remoteTokenRetryScheduled: false,
461 lastRemoteTokenRegistrationAttemptAt: null,
462 };
463 retryRemoteTokenRegistration(activity);
464 }),
465 ),
466 ),
467 Effect.catch((error) =>
468 Effect.sync(() => {
469 logLiveActivityWarning("live activity token retry failed", error);
470 }),
471 ),
472 ),
473 )
474 .catch((error: unknown) => {
475 logLiveActivityWarning("unexpected live activity token retry failure", error);
476 });
477}
478
479export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect<
480 void,
481 unknown,
482 ManagedRelayClient
483> {
484 return enqueueLiveActivitySync(
485 Effect.gen(function* () {
486 if (!activeActivity || !canUseLocalLiveActivities()) {
487 return;
488 }
489 const activity = activeActivity.instance;
490 activeActivity = {
491 ...activeActivity,
492 remoteTokenRegistered: false,
493 remoteTokenRetryScheduled: false,
494 lastRemoteTokenRegistrationAttemptAt: currentTimeMillis(),
495 };
496 yield* registerRemoteActivityToken(activity).pipe(
497 Effect.flatMap((registered) =>
498 Effect.sync(() => {
499 if (!registered) {
500 scheduleRemoteTokenRegistrationRetry(activity);
501 }
502 }),
503 ),
504 Effect.catch((error) =>
505 Effect.sync(() => {
506 logLiveActivityWarning("live activity token refresh failed; retry scheduled", error);
507 scheduleRemoteTokenRegistrationRetry(activity);
508 }),
509 ),
510 );
511 }),
512 );
513}
514
515function endActiveActivity(
516 finalState: AgentAwarenessState | null,
517 reason: string,
518): Effect.Effect<void, unknown> {
519 const active = activeActivity;
520 if (!active) {
521 return Effect.void;
522 }
523
524 activeActivity = null;
525 logLiveActivityDebug("ending live activity", {
526 reason,
527 finalPhase: finalState?.phase ?? null,
528 });
529 if (finalState) {
530 const now = currentDateTime();
531 return runLiveActivityOperation(
532 "end",
533 Effect.tryPromise({
534 try: () =>
535 active.instance.end(
536 after(
537 DateTime.toDateUtc(DateTime.add(now, { milliseconds: FINAL_ACTIVITY_DISMISSAL_MS })),
538 ),
539 toActivityProps([finalState]),
540 DateTime.toDateUtc(now),
541 ),
542 catch: (error) => error,
543 }),
544 ).pipe(Effect.asVoid);
545 }
546
547 return runLiveActivityOperation(
548 "end",
549 Effect.tryPromise({
550 try: () => active.instance.end("immediate"),
551 catch: (error) => error,
552 }),
553 ).pipe(Effect.asVoid);
554}
555
556type LiveActivityOperationResult<T> =
557 | {
558 readonly ok: true;
559 readonly value: T;
560 }
561 | {
562 readonly ok: false;
563 };
564
565function runLiveActivityOperation<T>(
566 operationName: "start" | "update" | "end",
567 operation: Effect.Effect<T, unknown>,
568): Effect.Effect<LiveActivityOperationResult<T>> {
569 return operation.pipe(
570 Effect.match({
571 onSuccess: (value) => ({ ok: true, value }) as const,
572 onFailure: (error): LiveActivityOperationResult<T> => {
573 if (
574 (operationName === "end" || operationName === "update") &&
575 isMissingNativeLiveActivityError(error)
576 ) {
577 logLiveActivityWarning(
578 `live activity ${operationName} skipped; native activity was already gone`,
579 error,
580 );
581 return { ok: false };
582 }
583
584 logLiveActivityWarning(
585 "live activity operation failed; disabling local live activities",
586 error,
587 );
588 localLiveActivitiesEnabled = false;
589 activeActivity = null;
590 environmentStates.clear();
591 return { ok: false };
592 },
593 }),
594 );
595}
596
597function isMissingNativeLiveActivityError(error: unknown): boolean {
598 return error instanceof Error && error.message.includes("Can't find live activity with id:");
599}
600
601export function __resetAgentLiveActivitiesForTest(): void {
602 localLiveActivitiesEnabled = true;
603 activeActivity = null;
604 environmentStates.clear();
605}
apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts+129−0
@@ -0,0 +1,129 @@
1import { beforeEach, vi } from "vitest";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import type { EnvironmentId } from "@t3tools/contracts";
5import { ManagedRelayClient } from "@t3tools/client-runtime";
6import { HttpClient } from "effect/unstable/http";
7
8import type { SavedRemoteConnection } from "../../lib/connection";
9import { savePreferencesPatch } from "../../lib/storage";
10import { linkEnvironmentToCloud } from "../cloud/linkEnvironment";
11import { endAllAgentLiveActivities } from "./liveActivityController";
12import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences";
13import { refreshAgentAwarenessRegistration } from "./remoteRegistration";
14
15vi.mock("../../lib/storage", () => ({
16 savePreferencesPatch: vi.fn(() => Promise.resolve()),
17}));
18
19vi.mock("../cloud/linkEnvironment", () => ({
20 linkEnvironmentToCloud: vi.fn(() => Effect.void),
21}));
22
23vi.mock("./liveActivityController", () => ({
24 endAllAgentLiveActivities: vi.fn(() => Effect.void),
25}));
26
27vi.mock("./remoteRegistration", () => ({
28 refreshAgentAwarenessRegistration: vi.fn(() => Effect.void),
29}));
30
31const connection: SavedRemoteConnection = {
32 environmentId: "env-1" as EnvironmentId,
33 environmentLabel: "Desktop",
34 pairingUrl: "https://desktop.example.test/",
35 displayUrl: "https://desktop.example.test/",
36 httpBaseUrl: "https://desktop.example.test/",
37 wsBaseUrl: "wss://desktop.example.test/ws",
38 bearerToken: "local-bearer",
39};
40
41const runWithHttpClient = <A, E>(
42 effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient>,
43): Promise<A> =>
44 Effect.runPromise(
45 effect.pipe(
46 Effect.provideService(ManagedRelayClient, null as never),
47 Effect.provideService(
48 HttpClient.HttpClient,
49 HttpClient.make(() => Effect.die("unexpected HTTP request")),
50 ),
51 ),
52 );
53
54describe("liveActivityPreferences", () => {
55 beforeEach(() => {
56 vi.clearAllMocks();
57 });
58
59 it("pushes disabled Live Activity preferences to local and relay registrations", async () => {
60 await runWithHttpClient(
61 setLiveActivityUpdatesEnabled({
62 enabled: false,
63 clerkToken: "clerk-token",
64 connections: [connection],
65 }),
66 );
67
68 expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false });
69 expect(endAllAgentLiveActivities).toHaveBeenCalledTimes(1);
70 expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
71 expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
72 clerkToken: "clerk-token",
73 connection,
74 });
75 });
76
77 it("pushes enabled Live Activity preferences without ending active local activities", async () => {
78 await runWithHttpClient(
79 setLiveActivityUpdatesEnabled({
80 enabled: true,
81 clerkToken: "clerk-token",
82 connections: [connection],
83 }),
84 );
85
86 expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true });
87 expect(endAllAgentLiveActivities).not.toHaveBeenCalled();
88 expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
89 expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
90 clerkToken: "clerk-token",
91 connection,
92 });
93 });
94
95 it("keeps local preferences refreshable when signed out", async () => {
96 await runWithHttpClient(
97 setLiveActivityUpdatesEnabled({
98 enabled: false,
99 clerkToken: null,
100 connections: [connection],
101 }),
102 );
103
104 expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false });
105 expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
106 expect(linkEnvironmentToCloud).not.toHaveBeenCalled();
107 });
108
109 it("does not try to re-link managed relay connections without bearer credentials", async () => {
110 const managedConnection: SavedRemoteConnection = {
111 ...connection,
112 bearerToken: null,
113 };
114
115 await runWithHttpClient(
116 setLiveActivityUpdatesEnabled({
117 enabled: true,
118 clerkToken: "clerk-token",
119 connections: [connection, managedConnection],
120 }),
121 );
122
123 expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1);
124 expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
125 clerkToken: "clerk-token",
126 connection,
127 });
128 });
129});
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+39−0
@@ -0,0 +1,39 @@
1import * as Effect from "effect/Effect";
2import { HttpClient } from "effect/unstable/http";
3import { ManagedRelayClient } from "@t3tools/client-runtime";
4
5import type { SavedRemoteConnection } from "../../lib/connection";
6import { savePreferencesPatch } from "../../lib/storage";
7import { linkEnvironmentToCloud } from "../cloud/linkEnvironment";
8import { endAllAgentLiveActivities } from "./liveActivityController";
9import { refreshAgentAwarenessRegistration } from "./remoteRegistration";
10
11export function setLiveActivityUpdatesEnabled(input: {
12 readonly enabled: boolean;
13 readonly clerkToken: string | null;
14 readonly connections: ReadonlyArray<SavedRemoteConnection>;
15}): Effect.Effect<void, unknown, HttpClient.HttpClient | ManagedRelayClient> {
16 return Effect.gen(function* () {
17 yield* Effect.tryPromise({
18 try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }),
19 catch: (error) => error,
20 });
21
22 if (!input.enabled) {
23 yield* endAllAgentLiveActivities();
24 }
25
26 yield* refreshAgentAwarenessRegistration();
27
28 const clerkToken = input.clerkToken;
29 if (!clerkToken) {
30 return;
31 }
32
33 yield* Effect.forEach(
34 input.connections.filter((connection) => connection.bearerToken !== null),
35 (connection) => linkEnvironmentToCloud({ clerkToken, connection }),
36 { concurrency: "unbounded" },
37 );
38 });
39}
apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts+106−0
@@ -0,0 +1,106 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 extractAgentNotificationDeepLink,
5 routeAgentNotificationResponseOnce,
6} from "./notificationPayload";
7
8function responseWithData(data: Record<string, unknown>, identifier = "notification-1") {
9 return {
10 notification: {
11 request: {
12 identifier,
13 content: {
14 data,
15 },
16 },
17 },
18 };
19}
20
21describe("extractAgentNotificationDeepLink", () => {
22 it("uses explicit deep links from APNs payload data", () => {
23 expect(
24 extractAgentNotificationDeepLink(
25 responseWithData({
26 deepLink: "/threads/env/thread",
27 environmentId: "ignored",
28 threadId: "ignored",
29 }),
30 ),
31 ).toBe("/threads/env/thread");
32 });
33
34 it("normalizes explicit thread deep links from APNs payload data", () => {
35 expect(
36 extractAgentNotificationDeepLink(
37 responseWithData({
38 deepLink: "/threads/env%201/thread%2F2",
39 }),
40 ),
41 ).toBe("/threads/env%201/thread%2F2");
42 });
43
44 it("falls back to the thread route from environment and thread ids", () => {
45 expect(
46 extractAgentNotificationDeepLink(
47 responseWithData({
48 environmentId: "env 1",
49 threadId: "thread/2",
50 }),
51 ),
52 ).toBe("/threads/env%201/thread%2F2");
53 });
54
55 it("falls back to ids when explicit deep link is not an agent thread route", () => {
56 expect(
57 extractAgentNotificationDeepLink(
58 responseWithData({
59 deepLink: "/",
60 environmentId: "env",
61 threadId: "thread",
62 }),
63 ),
64 ).toBe("/threads/env/thread");
65 });
66
67 it("ignores malformed or external links", () => {
68 expect(
69 extractAgentNotificationDeepLink(responseWithData({ deepLink: "https://example.com" })),
70 ).toBeNull();
71 expect(
72 extractAgentNotificationDeepLink(responseWithData({ deepLink: "/settings" })),
73 ).toBeNull();
74 expect(
75 extractAgentNotificationDeepLink(responseWithData({ deepLink: "//example.com" })),
76 ).toBeNull();
77 expect(
78 extractAgentNotificationDeepLink(responseWithData({ deepLink: "/threads/env/thread?x=1" })),
79 ).toBeNull();
80 expect(extractAgentNotificationDeepLink({})).toBeNull();
81 });
82});
83
84describe("routeAgentNotificationResponseOnce", () => {
85 it("does not navigate twice when the initial and listener responses refer to one notification", () => {
86 const handledResponseIds = new Set<string>();
87 const navigations: Array<string> = [];
88 const response = responseWithData({
89 environmentId: "env",
90 threadId: "thread",
91 });
92
93 routeAgentNotificationResponseOnce({
94 handledResponseIds,
95 response,
96 navigate: (deepLink) => navigations.push(deepLink),
97 });
98 routeAgentNotificationResponseOnce({
99 handledResponseIds,
100 response,
101 navigate: (deepLink) => navigations.push(deepLink),
102 });
103
104 expect(navigations).toEqual(["/threads/env/thread"]);
105 });
106});
apps/mobile/src/features/agent-awareness/notificationNavigation.ts+35−0
@@ -0,0 +1,35 @@
1import { useEffect, useRef } from "react";
2import * as Notifications from "expo-notifications";
3import { useRouter } from "expo-router";
4
5import { routeAgentNotificationResponseOnce } from "./notificationPayload";
6
7export function useAgentNotificationNavigation(): void {
8 const router = useRouter();
9 const handledResponseIds = useRef(new Set<string>());
10
11 useEffect(() => {
12 const handleResponse = (response: Notifications.NotificationResponse): void => {
13 routeAgentNotificationResponseOnce({
14 handledResponseIds: handledResponseIds.current,
15 response,
16 navigate: (deepLink) => router.push(deepLink as never),
17 });
18 };
19
20 const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse);
21 void Notifications.getLastNotificationResponseAsync()
22 .then((response) => {
23 if (response) {
24 handleResponse(response);
25 return Notifications.clearLastNotificationResponseAsync();
26 }
27 return undefined;
28 })
29 .catch(() => undefined);
30
31 return () => {
32 subscription.remove();
33 };
34 }, [router]);
35}
apps/mobile/src/features/agent-awareness/notificationPayload.ts+106−0
@@ -0,0 +1,106 @@
1function dataFromNotificationResponse(response: unknown): Record<string, unknown> | null {
2 if (typeof response !== "object" || response === null) {
3 return null;
4 }
5 const notification = (response as { readonly notification?: unknown }).notification;
6 if (typeof notification !== "object" || notification === null) {
7 return null;
8 }
9 const request = (notification as { readonly request?: unknown }).request;
10 if (typeof request !== "object" || request === null) {
11 return null;
12 }
13 const content = (request as { readonly content?: unknown }).content;
14 if (typeof content !== "object" || content === null) {
15 return null;
16 }
17 const data = (content as { readonly data?: unknown }).data;
18 return typeof data === "object" && data !== null ? (data as Record<string, unknown>) : null;
19}
20
21function identifierFromNotificationResponse(response: unknown): string | null {
22 if (typeof response !== "object" || response === null) {
23 return null;
24 }
25 const notification = (response as { readonly notification?: unknown }).notification;
26 if (typeof notification !== "object" || notification === null) {
27 return null;
28 }
29 const request = (notification as { readonly request?: unknown }).request;
30 if (typeof request !== "object" || request === null) {
31 return null;
32 }
33 const identifier = (request as { readonly identifier?: unknown }).identifier;
34 return typeof identifier === "string" ? identifier : null;
35}
36
37function encodeThreadDeepLink(input: {
38 readonly environmentId: string;
39 readonly threadId: string;
40}): string | null {
41 if (input.environmentId.length === 0 || input.threadId.length === 0) {
42 return null;
43 }
44 return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`;
45}
46
47function normalizeThreadDeepLink(value: string): string | null {
48 if (
49 value.trim() !== value ||
50 value.startsWith("//") ||
51 value.includes("?") ||
52 value.includes("#")
53 ) {
54 return null;
55 }
56
57 const parts = value.split("/");
58 if (parts.length !== 4 || parts[0] !== "" || parts[1] !== "threads") {
59 return null;
60 }
61
62 try {
63 return encodeThreadDeepLink({
64 environmentId: decodeURIComponent(parts[2] ?? ""),
65 threadId: decodeURIComponent(parts[3] ?? ""),
66 });
67 } catch {
68 return null;
69 }
70}
71
72export function extractAgentNotificationDeepLink(response: unknown): string | null {
73 const data = dataFromNotificationResponse(response);
74 const deepLink = data?.deepLink;
75 if (typeof deepLink === "string") {
76 const normalizedDeepLink = normalizeThreadDeepLink(deepLink);
77 if (normalizedDeepLink) {
78 return normalizedDeepLink;
79 }
80 }
81
82 const environmentId = data?.environmentId;
83 const threadId = data?.threadId;
84 if (typeof environmentId === "string" && typeof threadId === "string") {
85 return encodeThreadDeepLink({ environmentId, threadId });
86 }
87 return null;
88}
89
90export function routeAgentNotificationResponseOnce(input: {
91 readonly handledResponseIds: Set<string>;
92 readonly response: unknown;
93 readonly navigate: (deepLink: string) => void;
94}): void {
95 const responseId = identifierFromNotificationResponse(input.response);
96 if (responseId && input.handledResponseIds.has(responseId)) {
97 return;
98 }
99 if (responseId) {
100 input.handledResponseIds.add(responseId);
101 }
102 const deepLink = extractAgentNotificationDeepLink(input.response);
103 if (deepLink) {
104 input.navigate(deepLink);
105 }
106}
apps/mobile/src/features/agent-awareness/notificationPermissions.ts+44−0
@@ -0,0 +1,44 @@
1import * as Notifications from "expo-notifications";
2import * as Effect from "effect/Effect";
3import { Platform } from "react-native";
4
5export type NotificationPermissionResult =
6 | { readonly type: "unsupported" }
7 | { readonly type: "granted" }
8 | { readonly type: "denied"; readonly canAskAgain: boolean };
9
10export const requestAgentNotificationPermission: Effect.Effect<
11 NotificationPermissionResult,
12 unknown
13> = Effect.gen(function* () {
14 if (Platform.OS !== "ios") {
15 return { type: "unsupported" };
16 }
17
18 const existing = yield* Effect.tryPromise({
19 try: () => Notifications.getPermissionsAsync(),
20 catch: (error) => error,
21 });
22 if (existing.granted) {
23 return { type: "granted" };
24 }
25
26 if (!existing.canAskAgain) {
27 return { type: "denied", canAskAgain: false };
28 }
29
30 const requested = yield* Effect.tryPromise({
31 try: () =>
32 Notifications.requestPermissionsAsync({
33 ios: {
34 allowAlert: true,
35 allowBadge: true,
36 allowSound: true,
37 },
38 }),
39 catch: (error) => error,
40 });
41 return requested.granted
42 ? { type: "granted" }
43 : { type: "denied", canAskAgain: requested.canAskAgain };
44});
apps/mobile/src/features/agent-awareness/registrationPayload.ts+31−0
@@ -0,0 +1,31 @@
1import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
2
3import type { MobilePreferences } from "../../lib/storage";
4
5export function makeRelayDeviceRegistrationRequest(input: {
6 readonly deviceId: string;
7 readonly iosMajorVersion: number;
8 readonly appVersion?: string;
9 readonly pushToken?: string;
10 readonly pushToStartToken?: string;
11 readonly notificationsEnabled: boolean;
12 readonly preferences: MobilePreferences;
13}): RelayDeviceRegistrationRequest {
14 const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false;
15 return {
16 deviceId: input.deviceId,
17 platform: "ios",
18 iosMajorVersion: input.iosMajorVersion,
19 appVersion: input.appVersion,
20 ...(input.pushToken ? { pushToken: input.pushToken } : {}),
21 ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
22 preferences: {
23 liveActivitiesEnabled,
24 notificationsEnabled: input.notificationsEnabled,
25 notifyOnApproval: true,
26 notifyOnInput: true,
27 notifyOnCompletion: true,
28 notifyOnFailure: true,
29 },
30 };
31}
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+409−0
@@ -0,0 +1,409 @@
1/// <reference types="node" />
2
3import * as NodeCrypto from "node:crypto";
4
5import { beforeEach, vi } from "vitest";
6import { describe, expect, it } from "@effect/vitest";
7import Constants from "expo-constants";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import { FetchHttpClient } from "effect/unstable/http";
11import type { ManagedRelayClient } from "@t3tools/client-runtime";
12
13import type { EnvironmentId } from "@t3tools/contracts";
14import { verifyDpopProof } from "@t3tools/shared/dpop";
15import type { SavedRemoteConnection } from "../../lib/connection";
16import { mobileCryptoLayer } from "../cloud/dpop";
17import { mobileManagedRelayClientLayer } from "../cloud/managedRelayLayer";
18import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
19import {
20 __resetAgentAwarenessRemoteRegistrationForTest,
21 refreshAgentAwarenessRegistration,
22 normalizeAgentAwarenessRelayBaseUrl,
23 registerAgentAwarenessConnection,
24 registerLiveActivityPushToken,
25 setAgentAwarenessRelayTokenProvider,
26 shouldRegisterAgentAwarenessDeviceForProvider,
27 unregisterAgentAwarenessConnection,
28} from "./remoteRegistration";
29import * as Notifications from "expo-notifications";
30
31const secureStore = vi.hoisted(() => new Map<string, string>());
32
33vi.mock("expo-constants", () => ({
34 default: {
35 expoConfig: {
36 version: "1.0.0",
37 extra: {},
38 },
39 },
40}));
41
42vi.mock("expo-widgets", () => ({
43 addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })),
44}));
45
46vi.mock("expo-notifications", () => ({
47 addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })),
48 getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })),
49 getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true })),
50}));
51
52vi.mock("expo-crypto", () => ({
53 CryptoDigestAlgorithm: {
54 SHA1: "SHA-1",
55 SHA256: "SHA-256",
56 SHA384: "SHA-384",
57 SHA512: "SHA-512",
58 },
59 getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)),
60 getRandomBytesAsync: (byteCount: number) =>
61 Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))),
62 digest: (algorithm: string, data: unknown) => {
63 if (!(data instanceof Uint8Array)) {
64 return Promise.reject(new TypeError("expo-crypto digest data must be a typed array."));
65 }
66 return Promise.resolve(
67 new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer,
68 );
69 },
70}));
71
72vi.mock("expo-secure-store", () => ({
73 getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null),
74 setItemAsync: (key: string, value: string) => {
75 secureStore.set(key, value);
76 return Promise.resolve();
77 },
78}));
79
80vi.mock("react-native", () => ({
81 Platform: {
82 OS: "ios",
83 Version: "18.0",
84 },
85}));
86
87vi.mock("../../lib/runtime", () => ({
88 mobileRuntime: {
89 runPromise: <A, E>(operation: Effect.Effect<A, E>) =>
90 Effect.runPromise(
91 operation.pipe(
92 Effect.provide(
93 mobileManagedRelayClientLayer("https://relay.example.test").pipe(
94 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)),
95 ),
96 ),
97 ),
98 ),
99 },
100}));
101
102vi.mock("../../lib/storage", () => ({
103 loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
104 loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
105 loadPreferences: vi.fn(() => Promise.resolve({})),
106}));
107
108function proofIat(proof: string): number {
109 const payload = proof.split(".")[1];
110 if (!payload) {
111 throw new Error("Missing DPoP payload.");
112 }
113 const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
114 readonly iat: number;
115 };
116 return decoded.iat;
117}
118
119function savedConnection(): SavedRemoteConnection {
120 return {
121 environmentId: "env-1" as EnvironmentId,
122 environmentLabel: "Desktop",
123 pairingUrl: "https://desktop.example/pair",
124 displayUrl: "https://desktop.example",
125 httpBaseUrl: "https://desktop.example",
126 wsBaseUrl: "wss://desktop.example/ws",
127 bearerToken: "bearer-token",
128 };
129}
130
131const runRegistrationEffect = <A, E>(effect: Effect.Effect<A, E, ManagedRelayClient>): Promise<A> =>
132 Effect.runPromise(
133 effect.pipe(
134 Effect.provide(
135 mobileManagedRelayClientLayer("https://relay.example.test").pipe(
136 Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)),
137 ),
138 ),
139 ),
140 );
141
142async function waitForFetchCalls(
143 fetchMock: ReturnType<typeof vi.fn>,
144 count: number,
145): Promise<void> {
146 for (let attempt = 0; attempt < 20; attempt += 1) {
147 if (fetchMock.mock.calls.length >= count) {
148 return;
149 }
150 await new Promise((resolve) => setTimeout(resolve, 0));
151 }
152}
153
154describe("makeRelayDeviceRegistrationRequest", () => {
155 beforeEach(() => {
156 vi.unstubAllGlobals();
157 vi.stubGlobal("__DEV__", false);
158 secureStore.clear();
159 Constants.expoConfig!.extra = {};
160 __resetAgentAwarenessRemoteRegistrationForTest();
161 });
162
163 it("preserves disabled Live Activity preferences in relay registrations", () => {
164 expect(
165 makeRelayDeviceRegistrationRequest({
166 deviceId: "device-1",
167 iosMajorVersion: 18,
168 appVersion: "1.0.0",
169 pushToken: "apns-token",
170 pushToStartToken: "push-to-start-token",
171 notificationsEnabled: true,
172 preferences: {
173 liveActivitiesEnabled: false,
174 },
175 }),
176 ).toEqual({
177 deviceId: "device-1",
178 platform: "ios",
179 iosMajorVersion: 18,
180 appVersion: "1.0.0",
181 pushToken: "apns-token",
182 pushToStartToken: "push-to-start-token",
183 preferences: {
184 liveActivitiesEnabled: false,
185 notificationsEnabled: true,
186 notifyOnApproval: true,
187 notifyOnInput: true,
188 notifyOnCompletion: true,
189 notifyOnFailure: true,
190 },
191 });
192 });
193
194 it("marks notification delivery disabled when APNs permission is unavailable", () => {
195 expect(
196 makeRelayDeviceRegistrationRequest({
197 deviceId: "device-1",
198 iosMajorVersion: 18,
199 appVersion: "1.0.0",
200 pushToStartToken: "push-to-start-token",
201 notificationsEnabled: false,
202 preferences: {
203 liveActivitiesEnabled: true,
204 },
205 }),
206 ).toEqual({
207 deviceId: "device-1",
208 platform: "ios",
209 iosMajorVersion: 18,
210 appVersion: "1.0.0",
211 pushToStartToken: "push-to-start-token",
212 preferences: {
213 liveActivitiesEnabled: true,
214 notificationsEnabled: false,
215 notifyOnApproval: true,
216 notifyOnInput: true,
217 notifyOnCompletion: true,
218 notifyOnFailure: true,
219 },
220 });
221 });
222
223 it("normalizes relay base URLs for APNs registration requests", () => {
224 expect(normalizeAgentAwarenessRelayBaseUrl(" https://relay.example.test/// ")).toBe(
225 "https://relay.example.test",
226 );
227 expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull();
228 });
229
230 it("registers at most one listener while a Live Activity push token is pending", async () => {
231 registerAgentAwarenessConnection(savedConnection());
232 const addPushTokenListener = vi.fn();
233 const activity = {
234 getPushToken: vi.fn(() => Promise.resolve(null)),
235 addPushTokenListener,
236 };
237
238 await expect(
239 runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })),
240 ).resolves.toBe(false);
241 await expect(
242 runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })),
243 ).resolves.toBe(false);
244
245 expect(activity.getPushToken).toHaveBeenCalledTimes(2);
246 expect(addPushTokenListener).toHaveBeenCalledTimes(1);
247 });
248
249 it("reports Live Activity token registration as skipped when relay auth is unavailable", async () => {
250 registerAgentAwarenessConnection(savedConnection());
251 const activity = {
252 getPushToken: vi.fn(() => Promise.resolve("activity-token")),
253 addPushTokenListener: vi.fn(),
254 };
255
256 await expect(
257 runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })),
258 ).resolves.toBe(false);
259 });
260
261 it("refreshes APNs registration for connected environments after settings changes", async () => {
262 registerAgentAwarenessConnection(savedConnection());
263 await new Promise((resolve) => setTimeout(resolve, 0));
264 vi.mocked(Notifications.getDevicePushTokenAsync).mockClear();
265
266 await runRegistrationEffect(refreshAgentAwarenessRegistration());
267
268 expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);
269 });
270
271 it.effect("registers the APNs device when cloud auth becomes available", () => {
272 const fetchMock = vi.fn((request: RequestInfo | URL) => {
273 const url = request instanceof Request ? request.url : String(request);
274 return Promise.resolve(
275 Response.json(
276 url.endsWith("/v1/client/dpop-token")
277 ? {
278 access_token: "relay-dpop-token",
279 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
280 token_type: "DPoP",
281 expires_in: 300,
282 scope: "mobile:registration",
283 }
284 : { ok: true },
285 ),
286 );
287 });
288 vi.stubGlobal("fetch", fetchMock);
289 Constants.expoConfig!.extra = {
290 relay: {
291 url: "https://relay.example.test/",
292 },
293 };
294
295 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
296
297 return Effect.gen(function* () {
298 yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2));
299
300 expect(fetchMock).toHaveBeenCalledTimes(2);
301 const [request, init] = fetchMock.mock.calls[1] as unknown as [
302 unknown,
303 RequestInit | undefined,
304 ];
305 const url = request instanceof Request ? request.url : String(request);
306 const method = request instanceof Request ? request.method : init?.method;
307 const headers = request instanceof Request ? request.headers : new Headers(init?.headers);
308 const dpop = headers.get("dpop");
309 expect(url).toBe("https://relay.example.test/v1/mobile/devices");
310 expect(method).toBe("POST");
311 expect(headers.get("authorization")).toBe("DPoP relay-dpop-token");
312 expect(dpop).toEqual(expect.any(String));
313 if (!dpop) {
314 throw new Error("Missing DPoP header.");
315 }
316 expect(
317 verifyDpopProof({
318 proof: dpop,
319 method: "POST",
320 url: "https://relay.example.test/v1/mobile/devices",
321 expectedAccessToken: "relay-dpop-token",
322 nowEpochSeconds: proofIat(dpop),
323 }),
324 ).toMatchObject({ ok: true });
325 });
326 });
327
328 it("only registers again when the authenticated identity changes", () => {
329 expect(shouldRegisterAgentAwarenessDeviceForProvider(null, "user-a")).toBe(true);
330 expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", "user-a")).toBe(false);
331 expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", "user-b")).toBe(true);
332 expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", undefined)).toBe(true);
333 });
334
335 it("registers rotated APNs tokens without rereading the native token", async () => {
336 const fetchMock = vi.fn((request: RequestInfo | URL) => {
337 const url = request instanceof Request ? request.url : String(request);
338 return Promise.resolve(
339 Response.json(
340 url.endsWith("/v1/client/dpop-token")
341 ? {
342 access_token: "relay-dpop-token",
343 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
344 token_type: "DPoP",
345 expires_in: 300,
346 scope: "mobile:registration",
347 }
348 : { ok: true },
349 ),
350 );
351 });
352 vi.stubGlobal("fetch", fetchMock);
353 Constants.expoConfig!.extra = {
354 relay: {
355 url: "https://relay.example.test/",
356 },
357 };
358
359 vi.mocked(Notifications.getDevicePushTokenAsync).mockClear();
360 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
361
362 const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0];
363 expect(tokenListener).toBeDefined();
364 tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never);
365
366 await new Promise((resolve) => setTimeout(resolve, 0));
367
368 expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1);
369 });
370
371 it.effect(
372 "keeps the user-scoped relay APNs device when an environment connection is removed",
373 () => {
374 const fetchMock = vi.fn((request: RequestInfo | URL) => {
375 const url = request instanceof Request ? request.url : String(request);
376 return Promise.resolve(
377 Response.json(
378 url.endsWith("/v1/client/dpop-token")
379 ? {
380 access_token: "relay-dpop-token",
381 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
382 token_type: "DPoP",
383 expires_in: 300,
384 scope: "mobile:registration",
385 }
386 : { ok: true },
387 ),
388 );
389 });
390 vi.stubGlobal("fetch", fetchMock);
391 Constants.expoConfig!.extra = {
392 relay: {
393 url: "https://relay.example.test/",
394 },
395 };
396
397 registerAgentAwarenessConnection(savedConnection());
398 setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
399 return Effect.gen(function* () {
400 yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2));
401 fetchMock.mockClear();
402
403 unregisterAgentAwarenessConnection(savedConnection().environmentId);
404
405 expect(fetchMock).not.toHaveBeenCalled();
406 });
407 },
408 );
409});
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+448−0
@@ -0,0 +1,448 @@
1import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets";
2import Constants from "expo-constants";
3import * as Notifications from "expo-notifications";
4import * as Effect from "effect/Effect";
5import { Platform } from "react-native";
6import type { EnvironmentId } from "@t3tools/contracts";
7import {
8 type RelayDeviceRegistrationRequest,
9 type RelayLiveActivityRegistrationRequest,
10} from "@t3tools/contracts/relay";
11import { ManagedRelayClient } from "@t3tools/client-runtime";
12
13import type { SavedRemoteConnection } from "../../lib/connection";
14import { mobileRuntime } from "../../lib/runtime";
15import {
16 loadAgentAwarenessDeviceId,
17 loadOrCreateAgentAwarenessDeviceId,
18 loadPreferences,
19} from "../../lib/storage";
20import type { AgentActivityProps } from "../../widgets/AgentActivity";
21import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
22
23const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>();
24const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>();
25let pushToStartSubscription: { remove: () => void } | null = null;
26let pushTokenSubscription: { remove: () => void } | null = null;
27let relayTokenProvider: (() => Promise<string | null>) | null = null;
28let relayTokenProviderIdentity: string | null = null;
29
30export function normalizeAgentAwarenessRelayBaseUrl(
31 value: string | null | undefined,
32): string | null {
33 const trimmed = value?.trim();
34 if (!trimmed) {
35 return null;
36 }
37 return trimmed.replace(/\/+$/g, "");
38}
39
40function readRelayConfig(): { readonly url: string } | null {
41 const relayConfig = Constants.expoConfig?.extra?.relay as
42 | { readonly url?: string | null }
43 | undefined;
44 const relayUrl = normalizeAgentAwarenessRelayBaseUrl(relayConfig?.url);
45 if (!relayUrl) {
46 logRegistrationDebug("relay registration skipped; relay config missing");
47 return null;
48 }
49
50 return { url: relayUrl };
51}
52
53function canRegisterRemoteLiveActivities(): boolean {
54 return Platform.OS === "ios";
55}
56
57export function shouldRegisterAgentAwarenessDeviceForProvider(
58 previousIdentity: string | null,
59 identity: string | undefined,
60): boolean {
61 return identity === undefined || identity !== previousIdentity;
62}
63
64export function setAgentAwarenessRelayTokenProvider(
65 provider: (() => Promise<string | null>) | null,
66 identity?: string,
67): void {
68 const isExistingIdentity =
69 provider !== null &&
70 !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity);
71 relayTokenProvider = provider;
72 relayTokenProviderIdentity = provider ? (identity ?? null) : null;
73 if (!provider) {
74 pushToStartSubscription?.remove();
75 pushToStartSubscription = null;
76 pushTokenSubscription?.remove();
77 pushTokenSubscription = null;
78 return;
79 }
80 ensurePushToStartListener();
81 ensurePushTokenListener();
82 if (isExistingIdentity) {
83 return;
84 }
85 runRegistrationInBackground(registerDevice(), "device registration after cloud sign-in failed");
86}
87
88function iosMajorVersion(): number {
89 const version = Platform.Version;
90 if (typeof version === "number") {
91 return Math.floor(version);
92 }
93 const major = Number.parseInt(version.split(".")[0] ?? "", 10);
94 return Number.isFinite(major) ? major : 18;
95}
96
97function nativePushTokenRegistration(observedPushToken?: string) {
98 return Effect.gen(function* () {
99 if (!canRegisterRemoteLiveActivities()) {
100 return { notificationsEnabled: false, pushToken: null };
101 }
102 if (observedPushToken) {
103 return { notificationsEnabled: true, pushToken: observedPushToken };
104 }
105 const permissions = yield* Effect.tryPromise({
106 try: () => Notifications.getPermissionsAsync(),
107 catch: (error) => error,
108 });
109 if (!permissions.granted) {
110 return { notificationsEnabled: false, pushToken: null };
111 }
112 const token = yield* Effect.tryPromise({
113 try: () => Notifications.getDevicePushTokenAsync(),
114 catch: (error) => error,
115 }).pipe(
116 Effect.tapError((error) =>
117 Effect.sync(() => {
118 logRegistrationError("native APNs token lookup failed", error);
119 }),
120 ),
121 Effect.orElseSucceed(() => null),
122 );
123 const pushToken =
124 token?.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0
125 ? token.data.trim()
126 : null;
127 return { notificationsEnabled: pushToken !== null, pushToken };
128 });
129}
130
131const relayToken = Effect.gen(function* () {
132 const provider = relayTokenProvider;
133 if (!provider) {
134 return null;
135 }
136 return yield* Effect.tryPromise({
137 try: provider,
138 catch: (error) => error,
139 });
140});
141
142function registerDeviceWithRelay(
143 body: RelayDeviceRegistrationRequest,
144): Effect.Effect<void, unknown, ManagedRelayClient> {
145 return Effect.gen(function* () {
146 if (!readRelayConfig()) return;
147 const token = yield* relayToken;
148 if (!token) {
149 logRegistrationDebug("relay device registration skipped; user is not signed in");
150 return;
151 }
152
153 const client = yield* ManagedRelayClient;
154 yield* client.registerDevice({
155 clerkToken: token,
156 payload: body,
157 });
158 });
159}
160
161function unregisterDeviceWithRelay(input: {
162 readonly deviceId: string;
163 readonly tokenProvider: () => Promise<string | null>;
164}): Effect.Effect<void, unknown, ManagedRelayClient> {
165 return Effect.gen(function* () {
166 if (!readRelayConfig()) return;
167 const token = yield* Effect.tryPromise({
168 try: input.tokenProvider,
169 catch: (error) => error,
170 });
171 if (!token) {
172 logRegistrationDebug("relay device unregistration skipped; user is not signed in");
173 return;
174 }
175
176 const client = yield* ManagedRelayClient;
177 yield* client.unregisterDevice({
178 clerkToken: token,
179 deviceId: input.deviceId,
180 });
181 });
182}
183
184function registerLiveActivityWithRelay(
185 body: RelayLiveActivityRegistrationRequest,
186): Effect.Effect<boolean, unknown, ManagedRelayClient> {
187 return Effect.gen(function* () {
188 if (!readRelayConfig()) return false;
189 const token = yield* relayToken;
190 if (!token) {
191 logRegistrationDebug("relay live activity registration skipped; user is not signed in");
192 return false;
193 }
194
195 const client = yield* ManagedRelayClient;
196 yield* client.registerLiveActivity({
197 clerkToken: token,
198 payload: body,
199 });
200 return true;
201 });
202}
203
204function logRegistrationError(context: string, error: unknown): void {
205 if (!__DEV__) {
206 return;
207 }
208 console.warn(
209 `[agent-awareness] ${context}`,
210 error instanceof Error ? error.message : String(error),
211 );
212}
213
214function logRegistrationDebug(context: string, details?: unknown): void {
215 if (!__DEV__) {
216 return;
217 }
218 console.log(`[agent-awareness] ${context}`, details ?? "");
219}
220
221function runRegistrationInBackground(
222 operation: Effect.Effect<unknown, unknown, ManagedRelayClient>,
223 context: string,
224): void {
225 void mobileRuntime.runPromise(operation).catch((error: unknown) => {
226 logRegistrationError(context, error);
227 });
228}
229
230function registerDevice(input?: {
231 readonly pushToStartToken?: string;
232 readonly observedPushToken?: string;
233}): Effect.Effect<void, unknown, ManagedRelayClient> {
234 return Effect.gen(function* () {
235 if (!canRegisterRemoteLiveActivities()) {
236 return;
237 }
238
239 const [deviceId, preferences] = yield* Effect.all([
240 Effect.tryPromise({
241 try: () => loadOrCreateAgentAwarenessDeviceId(),
242 catch: (error) => error,
243 }),
244 Effect.tryPromise({
245 try: () => loadPreferences(),
246 catch: (error) => error,
247 }),
248 ]);
249 const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken);
250 yield* registerDeviceWithRelay(
251 makeRelayDeviceRegistrationRequest({
252 deviceId,
253 iosMajorVersion: iosMajorVersion(),
254 appVersion: Constants.expoConfig?.version,
255 ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}),
256 ...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
257 notificationsEnabled: pushTokenRegistration.notificationsEnabled,
258 preferences,
259 }),
260 );
261 });
262}
263
264function registerDeviceForCurrentUser(
265 pushToStartToken?: string,
266): Effect.Effect<void, unknown, ManagedRelayClient> {
267 return registerDevice(pushToStartToken ? { pushToStartToken } : undefined);
268}
269
270function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void {
271 runRegistrationInBackground(
272 registerDeviceForCurrentUser(pushToStartToken),
273 "push-to-start token registration failed",
274 );
275}
276
277function ensurePushToStartListener(): void {
278 if (pushToStartSubscription || !canRegisterRemoteLiveActivities()) {
279 return;
280 }
281
282 pushToStartSubscription = addPushToStartTokenListener((event) => {
283 const token = event.activityPushToStartToken;
284 if (token) {
285 registerPushToStartTokenForCurrentUser(token);
286 }
287 });
288}
289
290function ensurePushTokenListener(): void {
291 if (pushTokenSubscription || !canRegisterRemoteLiveActivities()) {
292 return;
293 }
294
295 pushTokenSubscription = Notifications.addPushTokenListener((token) => {
296 if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) {
297 runRegistrationInBackground(
298 registerDevice({ observedPushToken: token.data.trim() }),
299 "native APNs token rotation registration failed",
300 );
301 }
302 });
303}
304
305export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void {
306 if (!canRegisterRemoteLiveActivities()) {
307 return;
308 }
309
310 environmentConnections.set(connection.environmentId, connection);
311 ensurePushToStartListener();
312 ensurePushTokenListener();
313 runRegistrationInBackground(registerDevice(), "device registration failed");
314}
315
316function removeAgentAwarenessConnection(environmentId: EnvironmentId): void {
317 environmentConnections.delete(environmentId);
318}
319
320export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId): void {
321 removeAgentAwarenessConnection(environmentId);
322}
323
324export function unregisterAllAgentAwarenessConnections(): void {
325 environmentConnections.clear();
326 pushToStartSubscription?.remove();
327 pushToStartSubscription = null;
328 pushTokenSubscription?.remove();
329 pushTokenSubscription = null;
330}
331
332export function refreshAgentAwarenessRegistration(): Effect.Effect<
333 void,
334 never,
335 ManagedRelayClient
336> {
337 return registerDeviceForCurrentUser().pipe(
338 Effect.catch((error) =>
339 Effect.sync(() => {
340 logRegistrationError("device registration refresh failed", error);
341 }),
342 ),
343 );
344}
345
346export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
347 environmentConnections.clear();
348 pushToStartSubscription?.remove();
349 pushToStartSubscription = null;
350 pushTokenSubscription?.remove();
351 pushTokenSubscription = null;
352 relayTokenProvider = null;
353 relayTokenProviderIdentity = null;
354}
355
356export function unregisterAgentAwarenessDeviceForCurrentUser(
357 tokenProvider: () => Promise<string | null>,
358): Effect.Effect<void, never, ManagedRelayClient> {
359 return Effect.gen(function* () {
360 const deviceId = yield* Effect.tryPromise({
361 try: () => loadAgentAwarenessDeviceId(),
362 catch: (error) => error,
363 });
364 if (!deviceId) {
365 return;
366 }
367 yield* unregisterDeviceWithRelay({ deviceId, tokenProvider });
368 }).pipe(
369 Effect.catch((error) =>
370 Effect.sync(() => {
371 logRegistrationError("device unregistration failed", error);
372 }),
373 ),
374 );
375}
376
377export function registerLiveActivityPushToken(input: {
378 readonly activity: LiveActivity<AgentActivityProps>;
379}): Effect.Effect<boolean, unknown, ManagedRelayClient> {
380 return Effect.gen(function* () {
381 if (!canRegisterRemoteLiveActivities()) {
382 return false;
383 }
384
385 const activityPushToken = yield* Effect.tryPromise({
386 try: () => input.activity.getPushToken(),
387 catch: (error) => error,
388 });
389 if (!activityPushToken) {
390 if (activityPushTokenListeners.has(input.activity)) {
391 logRegistrationDebug(
392 "live activity push token not available yet; token listener already registered",
393 {
394 connectionCount: environmentConnections.size,
395 },
396 );
397 return false;
398 }
399
400 logRegistrationDebug(
401 "live activity push token not available yet; listening for token event",
402 {
403 connectionCount: environmentConnections.size,
404 },
405 );
406 activityPushTokenListeners.add(input.activity);
407 input.activity.addPushTokenListener((event) => {
408 if (event.pushToken) {
409 logRegistrationDebug("live activity push token event received", {
410 tokenSuffix: event.pushToken.slice(-8),
411 });
412 runRegistrationInBackground(
413 registerLiveActivityPushTokenValue({
414 activityPushToken: event.pushToken,
415 }),
416 "live activity token listener registration failed",
417 );
418 }
419 });
420 return false;
421 }
422
423 return yield* registerLiveActivityPushTokenValue({
424 activityPushToken,
425 });
426 });
427}
428
429function registerLiveActivityPushTokenValue(input: {
430 readonly activityPushToken: string;
431}): Effect.Effect<boolean, unknown, ManagedRelayClient> {
432 return Effect.gen(function* () {
433 const deviceId = yield* Effect.tryPromise({
434 try: () => loadOrCreateAgentAwarenessDeviceId(),
435 catch: (error) => error,
436 });
437 const registered = yield* registerLiveActivityWithRelay({
438 deviceId,
439 activityPushToken: input.activityPushToken,
440 });
441 if (registered) {
442 logRegistrationDebug("live activity push token registered", {
443 tokenSuffix: input.activityPushToken.slice(-8),
444 });
445 }
446 return registered;
447 });
448}
apps/mobile/src/features/agent-awareness/shellLiveActivitySync.ts+59−0
@@ -0,0 +1,59 @@
1import { shellSnapshotStateAtom } from "@t3tools/client-runtime";
2import type { EnvironmentId } from "@t3tools/contracts";
3
4import type { SavedRemoteConnection } from "../../lib/connection";
5import { mobileRuntime } from "../../lib/runtime";
6import { appAtomRegistry } from "../../state/atom-registry";
7import {
8 endAllAgentLiveActivities,
9 endEnvironmentLiveActivities,
10 syncAgentLiveActivitiesForSnapshot,
11} from "./liveActivityController";
12import {
13 registerAgentAwarenessConnection,
14 unregisterAgentAwarenessConnection,
15 unregisterAllAgentAwarenessConnections,
16} from "./remoteRegistration";
17
18const environmentUnsubscribers = new Map<EnvironmentId, () => void>();
19
20export function startAgentAwarenessForEnvironment(connection: SavedRemoteConnection): void {
21 const { environmentId } = connection;
22 if (environmentUnsubscribers.has(environmentId)) {
23 return;
24 }
25
26 registerAgentAwarenessConnection(connection);
27
28 const sync = () => {
29 const state = appAtomRegistry.get(shellSnapshotStateAtom(environmentId));
30 void mobileRuntime
31 .runPromise(
32 syncAgentLiveActivitiesForSnapshot({
33 environmentId,
34 snapshot: state.data,
35 }),
36 )
37 .catch(() => undefined);
38 };
39
40 const unsubscribe = appAtomRegistry.subscribe(shellSnapshotStateAtom(environmentId), sync);
41 environmentUnsubscribers.set(environmentId, unsubscribe);
42 sync();
43}
44
45export function stopAgentAwarenessForEnvironment(environmentId: EnvironmentId): void {
46 environmentUnsubscribers.get(environmentId)?.();
47 environmentUnsubscribers.delete(environmentId);
48 unregisterAgentAwarenessConnection(environmentId);
49 void mobileRuntime.runPromise(endEnvironmentLiveActivities(environmentId)).catch(() => undefined);
50}
51
52export function stopAllAgentAwareness(): void {
53 for (const unsubscribe of environmentUnsubscribers.values()) {
54 unsubscribe();
55 }
56 environmentUnsubscribers.clear();
57 unregisterAllAgentAwarenessConnections();
58 void mobileRuntime.runPromise(endAllAgentLiveActivities()).catch(() => undefined);
59}
apps/mobile/src/features/agent-awareness/updatedAtLabel.test.ts+16−0
@@ -0,0 +1,16 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { formatAgentActivityUpdatedAtLabel } from "./updatedAtLabel";
4
5describe("formatAgentActivityUpdatedAtLabel", () => {
6 it("formats ISO timestamps without using ambient time APIs", () => {
7 expect(formatAgentActivityUpdatedAtLabel("2026-05-25T00:03:00.000Z")).toBe("12:03");
8 expect(formatAgentActivityUpdatedAtLabel("2026-05-25T09:45:00.000Z")).toBe("9:45");
9 expect(formatAgentActivityUpdatedAtLabel("2026-05-25T13:07:00.000Z")).toBe("1:07");
10 });
11
12 it("uses now for malformed timestamps", () => {
13 expect(formatAgentActivityUpdatedAtLabel("not-a-date")).toBe("now");
14 expect(formatAgentActivityUpdatedAtLabel("2026-05-25T24:00:00.000Z")).toBe("now");
15 });
16});
apps/mobile/src/features/agent-awareness/updatedAtLabel.ts+12−0
@@ -0,0 +1,12 @@
1const ISO_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T(?<hours>\d{2}):(?<minutes>\d{2}):/;
2
3export function formatAgentActivityUpdatedAtLabel(updatedAt: string): string {
4 const match = ISO_TIME_PATTERN.exec(updatedAt);
5 const hours24 = Number(match?.groups?.hours);
6 const minutes = match?.groups?.minutes;
7 if (!Number.isInteger(hours24) || hours24 < 0 || hours24 > 23 || !minutes) {
8 return "now";
9 }
10
11 return `${hours24 % 12 || 12}:${minutes}`;
12}
apps/mobile/src/features/cloud/CloudAuthProvider.tsx+89−0
@@ -0,0 +1,89 @@
1import { ClerkProvider, useAuth } from "@clerk/expo";
2import { tokenCache } from "@clerk/expo/token-cache";
3import Constants from "expo-constants";
4import { type ReactNode, useEffect, useRef } from "react";
5import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
6
7import { mobileRuntime } from "../../lib/runtime";
8import {
9 setAgentAwarenessRelayTokenProvider,
10 unregisterAgentAwarenessDeviceForCurrentUser,
11} from "../agent-awareness/remoteRegistration";
12import { refreshActiveLiveActivityRemoteRegistration } from "../agent-awareness/liveActivityController";
13
14function readClerkPublishableKey(): string | null {
15 const clerkConfig = Constants.expoConfig?.extra?.clerk as
16 | { readonly publishableKey?: string | null }
17 | undefined;
18 return clerkConfig?.publishableKey ?? null;
19}
20
21function CloudAuthBridge(props: { readonly children: ReactNode }) {
22 const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false });
23 const previousTokenProviderRef = useRef<{
24 readonly userId: string;
25 readonly provider: () => Promise<string | null>;
26 } | null>(null);
27
28 useEffect(() => {
29 if (!isLoaded) {
30 return;
31 }
32 if (!isSignedIn || !userId) {
33 const previous = previousTokenProviderRef.current;
34 previousTokenProviderRef.current = null;
35 if (previous) {
36 void mobileRuntime
37 .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider))
38 .catch(() => undefined);
39 }
40 setAgentAwarenessRelayTokenProvider(null);
41 return;
42 }
43
44 const previous = previousTokenProviderRef.current;
45 if (previous && previous.userId !== userId) {
46 void mobileRuntime
47 .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider))
48 .catch(() => undefined);
49 }
50 const tokenProvider = () => getToken(RELAY_CLERK_TOKEN_OPTIONS);
51 previousTokenProviderRef.current = { userId, provider: tokenProvider };
52 setAgentAwarenessRelayTokenProvider(tokenProvider, userId);
53 if (!previous || previous.userId !== userId) {
54 void mobileRuntime
55 .runPromise(refreshActiveLiveActivityRemoteRegistration())
56 .catch(() => undefined);
57 }
58 }, [getToken, isLoaded, isSignedIn, userId]);
59
60 useEffect(
61 () => () => {
62 previousTokenProviderRef.current = null;
63 setAgentAwarenessRelayTokenProvider(null);
64 },
65 [],
66 );
67
68 return props.children;
69}
70
71export function CloudAuthProvider(props: { readonly children: ReactNode }) {
72 const publishableKey = readClerkPublishableKey();
73
74 useEffect(() => {
75 if (!publishableKey) {
76 setAgentAwarenessRelayTokenProvider(null);
77 }
78 }, [publishableKey]);
79
80 if (!publishableKey) {
81 return props.children;
82 }
83
84 return (
85 <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
86 <CloudAuthBridge>{props.children}</CloudAuthBridge>
87 </ClerkProvider>
88 );
89}
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx+208−0
@@ -0,0 +1,208 @@
1import { useWaitlist } from "@clerk/expo";
2import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
3import { useState } from "react";
4
5import { useThemeColor } from "../../lib/useThemeColor";
6
7export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) {
8 const { errors, fetchStatus, waitlist } = useWaitlist();
9 const colors = useCloudWaitlistColors();
10 const [emailAddress, setEmailAddress] = useState("");
11 const [requestError, setRequestError] = useState<string | null>(null);
12 const isSubmitting = fetchStatus === "fetching";
13 const fieldError = errors.fields.emailAddress?.longMessage;
14
15 const joinWaitlist = async () => {
16 const normalizedEmailAddress = emailAddress.trim();
17 if (!normalizedEmailAddress || isSubmitting) {
18 return;
19 }
20
21 setRequestError(null);
22 try {
23 const { error } = await waitlist.join({ emailAddress: normalizedEmailAddress });
24 if (error) {
25 setRequestError("Could not join the waitlist. Check your email address and try again.");
26 }
27 } catch {
28 setRequestError("Could not join the waitlist. Check your connection and try again.");
29 }
30 };
31
32 if (waitlist.id) {
33 return (
34 <View style={styles.content}>
35 <Text style={[styles.title, { color: colors.foreground }]}>You are on the waitlist</Text>
36 <Text style={[styles.body, styles.confirmationBody, { color: colors.secondaryForeground }]}>
37 We will email you when your T3 Cloud access is ready.
38 </Text>
39 <SignInAction onPress={props.onSignIn} />
40 </View>
41 );
42 }
43
44 return (
45 <View style={styles.content}>
46 <Text style={[styles.body, { color: colors.secondaryForeground }]}>
47 Enter your email and we will let you know when access is ready.
48 </Text>
49
50 <View style={styles.field}>
51 <Text style={[styles.label, { color: colors.secondaryForeground }]}>Email address</Text>
52 <TextInput
53 accessibilityLabel="Email address"
54 autoCapitalize="none"
55 autoComplete="email"
56 autoCorrect={false}
57 keyboardType="email-address"
58 onChangeText={(value) => {
59 setEmailAddress(value);
60 setRequestError(null);
61 }}
62 onSubmitEditing={() => void joinWaitlist()}
63 placeholder="Enter your email address"
64 placeholderTextColor={colors.placeholder}
65 returnKeyType="join"
66 style={[
67 styles.input,
68 {
69 backgroundColor: colors.input,
70 borderColor:
71 fieldError || requestError ? colors.dangerForeground : colors.inputBorder,
72 color: colors.foreground,
73 },
74 ]}
75 textContentType="emailAddress"
76 value={emailAddress}
77 />
78 {fieldError || requestError ? (
79 <Text
80 accessibilityLiveRegion="polite"
81 style={[styles.error, { color: colors.dangerForeground }]}
82 selectable
83 >
84 {fieldError ?? requestError}
85 </Text>
86 ) : null}
87 </View>
88
89 <Pressable
90 accessibilityRole="button"
91 accessibilityState={{
92 busy: isSubmitting,
93 disabled: isSubmitting || emailAddress.trim().length === 0,
94 }}
95 disabled={isSubmitting || emailAddress.trim().length === 0}
96 onPress={() => void joinWaitlist()}
97 style={[
98 styles.primaryButton,
99 {
100 backgroundColor: colors.primary,
101 opacity: isSubmitting || emailAddress.trim().length === 0 ? 0.45 : 1,
102 },
103 ]}
104 >
105 {isSubmitting ? <ActivityIndicator color={colors.primaryForeground} size="small" /> : null}
106 <Text style={[styles.buttonText, { color: colors.primaryForeground }]}>
107 {isSubmitting ? "Joining" : "Join the waitlist"}
108 </Text>
109 </Pressable>
110
111 <SignInAction onPress={props.onSignIn} />
112 </View>
113 );
114}
115
116function SignInAction(props: { readonly onPress: () => void }) {
117 const colors = useCloudWaitlistColors();
118 return (
119 <View style={styles.signInRow}>
120 <Text style={[styles.body, { color: colors.secondaryForeground }]}>Already have access?</Text>
121 <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}>
122 <Text style={[styles.signInText, { color: colors.foreground }]}>Sign in</Text>
123 </Pressable>
124 </View>
125 );
126}
127
128function useCloudWaitlistColors() {
129 return {
130 dangerForeground: String(useThemeColor("--color-danger-foreground")),
131 foreground: String(useThemeColor("--color-foreground")),
132 input: String(useThemeColor("--color-input")),
133 inputBorder: String(useThemeColor("--color-input-border")),
134 placeholder: String(useThemeColor("--color-placeholder")),
135 primary: String(useThemeColor("--color-primary")),
136 primaryForeground: String(useThemeColor("--color-primary-foreground")),
137 secondaryForeground: String(useThemeColor("--color-foreground-secondary")),
138 };
139}
140
141const styles = StyleSheet.create({
142 body: {
143 fontFamily: "DMSans_400Regular",
144 fontSize: 15,
145 lineHeight: 21,
146 },
147 buttonText: {
148 fontFamily: "DMSans_700Bold",
149 fontSize: 16,
150 },
151 content: {
152 gap: 18,
153 },
154 confirmationBody: {
155 textAlign: "center",
156 },
157 error: {
158 fontFamily: "DMSans_400Regular",
159 fontSize: 13,
160 lineHeight: 18,
161 },
162 field: {
163 gap: 8,
164 },
165 input: {
166 borderCurve: "continuous",
167 borderRadius: 16,
168 borderWidth: 1,
169 fontFamily: "DMSans_400Regular",
170 fontSize: 17,
171 minHeight: 54,
172 paddingHorizontal: 16,
173 paddingVertical: 14,
174 },
175 label: {
176 fontFamily: "DMSans_700Bold",
177 fontSize: 13,
178 lineHeight: 18,
179 },
180 primaryButton: {
181 alignItems: "center",
182 borderRadius: 999,
183 flexDirection: "row",
184 gap: 8,
185 justifyContent: "center",
186 minHeight: 54,
187 paddingHorizontal: 20,
188 paddingVertical: 12,
189 },
190 signInRow: {
191 alignItems: "center",
192 flexDirection: "row",
193 gap: 4,
194 justifyContent: "center",
195 paddingTop: 4,
196 },
197 signInText: {
198 fontFamily: "DMSans_700Bold",
199 fontSize: 15,
200 lineHeight: 21,
201 },
202 title: {
203 fontFamily: "DMSans_700Bold",
204 fontSize: 20,
205 lineHeight: 26,
206 textAlign: "center",
207 },
208});
apps/mobile/src/features/cloud/dpop.test.ts+166−0
@@ -0,0 +1,166 @@
1/// <reference types="node" />
2
3import * as NodeCrypto from "node:crypto";
4
5import { vi } from "vitest";
6import { describe, expect, it } from "@effect/vitest";
7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";
9import { verifyDpopProof } from "@t3tools/shared/dpop";
10
11import {
12 createDpopProof,
13 generateDpopProofKeyPair,
14 loadOrCreateDpopProofKeyPair,
15 mobileCryptoLayer,
16} from "./dpop";
17
18vi.mock("expo-crypto", () => ({
19 CryptoDigestAlgorithm: {
20 SHA1: "SHA-1",
21 SHA256: "SHA-256",
22 SHA384: "SHA-384",
23 SHA512: "SHA-512",
24 },
25 getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)),
26 getRandomBytesAsync: (byteCount: number) =>
27 Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))),
28 digest: (algorithm: string, data: unknown) => {
29 if (!(data instanceof Uint8Array)) {
30 return Promise.reject(new TypeError("expo-crypto digest data must be a typed array."));
31 }
32 return Promise.resolve(
33 new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer,
34 );
35 },
36}));
37
38const secureStore = new Map<string, string>();
39vi.mock("expo-secure-store", () => ({
40 getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null),
41 setItemAsync: (key: string, value: string) => {
42 secureStore.set(key, value);
43 return Promise.resolve();
44 },
45}));
46
47function proofIat(proof: string): number {
48 const payload = proof.split(".")[1];
49 if (!payload) {
50 throw new Error("Missing DPoP payload.");
51 }
52 const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
53 readonly iat: number;
54 };
55 return decoded.iat;
56}
57
58function proofHtu(proof: string): string {
59 const payload = proof.split(".")[1];
60 if (!payload) {
61 throw new Error("Missing DPoP payload.");
62 }
63 const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
64 readonly htu: string;
65 };
66 return decoded.htu;
67}
68
69describe("mobile DPoP", () => {
70 it.effect("passes typed-array digest input through the Expo Crypto adapter", () =>
71 Effect.gen(function* () {
72 const crypto = yield* Crypto.Crypto;
73 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode("typed-array"));
74
75 expect(Buffer.from(digest).toString("hex")).toBe(
76 NodeCrypto.createHash("sha256").update("typed-array").digest("hex"),
77 );
78 }).pipe(Effect.provide(mobileCryptoLayer)),
79 );
80
81 it.effect("persists and reuses the installation proof key", () =>
82 Effect.gen(function* () {
83 secureStore.clear();
84 const first = yield* loadOrCreateDpopProofKeyPair();
85 const second = yield* loadOrCreateDpopProofKeyPair();
86
87 expect(second.thumbprint).toBe(first.thumbprint);
88 expect(second.privateJwk).toEqual(first.privateJwk);
89 }).pipe(Effect.provide(mobileCryptoLayer)),
90 );
91
92 it.effect("rejects malformed persisted proof keys", () =>
93 Effect.gen(function* () {
94 secureStore.set("t3code.cloud.dpop-proof-key", `{"kty":"EC","crv":"P-256","d":42}`);
95
96 const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip);
97
98 expect(error.message).toBe("Stored DPoP proof key is invalid.");
99 }).pipe(Effect.provide(mobileCryptoLayer)),
100 );
101
102 it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () =>
103 Effect.gen(function* () {
104 const proofKey = yield* generateDpopProofKeyPair();
105 const connect = yield* createDpopProof({
106 method: "POST",
107 url: "https://relay.example.test/v1/environments/env-1/connect",
108 accessToken: "clerk-token",
109 proofKey,
110 });
111 const bootstrap = yield* createDpopProof({
112 method: "POST",
113 url: "https://desktop.example.test/oauth/token",
114 proofKey,
115 });
116
117 expect(connect.thumbprint).toBe(proofKey.thumbprint);
118 expect(bootstrap.thumbprint).toBe(proofKey.thumbprint);
119 expect(
120 verifyDpopProof({
121 proof: connect.proof,
122 method: "POST",
123 url: "https://relay.example.test/v1/environments/env-1/connect",
124 expectedThumbprint: proofKey.thumbprint,
125 expectedAccessToken: "clerk-token",
126 nowEpochSeconds: proofIat(connect.proof),
127 }),
128 ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint });
129 expect(
130 verifyDpopProof({
131 proof: bootstrap.proof,
132 method: "POST",
133 url: "https://desktop.example.test/oauth/token",
134 expectedThumbprint: proofKey.thumbprint,
135 nowEpochSeconds: proofIat(bootstrap.proof),
136 }),
137 ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint });
138 }).pipe(Effect.provide(mobileCryptoLayer)),
139 );
140
141 it.effect("signs DPoP proofs with RFC 9449 htu normalization", () =>
142 Effect.gen(function* () {
143 const proofKey = yield* generateDpopProofKeyPair();
144 const proof = yield* createDpopProof({
145 method: "POST",
146 url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored",
147 accessToken: "clerk-token",
148 proofKey,
149 });
150
151 expect(proofHtu(proof.proof)).toBe(
152 "https://relay.example.test/v1/environments/env-1/connect",
153 );
154 expect(
155 verifyDpopProof({
156 proof: proof.proof,
157 method: "POST",
158 url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored",
159 expectedThumbprint: proofKey.thumbprint,
160 expectedAccessToken: "clerk-token",
161 nowEpochSeconds: proofIat(proof.proof),
162 }),
163 ).toMatchObject({ ok: true });
164 }).pipe(Effect.provide(mobileCryptoLayer)),
165 );
166});
apps/mobile/src/features/cloud/dpop.ts+270−0
@@ -0,0 +1,270 @@
1import * as Clock from "effect/Clock";
2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Encoding from "effect/Encoding";
6import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";
8import * as ExpoCrypto from "expo-crypto";
9import * as SecureStore from "expo-secure-store";
10import { p256 } from "@noble/curves/nist";
11import {
12 computeDpopAccessTokenHash,
13 computeDpopJwkThumbprint,
14 DpopPublicJwk,
15 normalizeDpopHtu,
16} from "@t3tools/shared/dpop";
17import * as Layer from "effect/Layer";
18
19export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
20 readonly message: string;
21 readonly cause?: unknown;
22}> {}
23
24function cloudDpopError(message: string) {
25 return (cause: unknown) => new CloudDpopError({ message, cause });
26}
27
28const DpopPrivateJwkSchema = Schema.Struct({
29 ...DpopPublicJwk.fields,
30 d: Schema.String,
31});
32
33const DpopPrivateJwkJson = Schema.fromJsonString(DpopPrivateJwkSchema);
34const decodeDpopPrivateJwkJson = Schema.decodeUnknownEffect(DpopPrivateJwkJson);
35const encodeDpopPrivateJwkJson = Schema.encodeEffect(DpopPrivateJwkJson);
36
37const DpopJwtHeaderJson = Schema.fromJsonString(
38 Schema.Struct({
39 typ: Schema.Literal("dpop+jwt"),
40 alg: Schema.Literal("ES256"),
41 jwk: DpopPublicJwk,
42 }),
43);
44
45const DpopJwtPayloadJson = Schema.fromJsonString(
46 Schema.Struct({
47 htm: Schema.String,
48 htu: Schema.String,
49 jti: Schema.String,
50 iat: Schema.Int,
51 ath: Schema.optionalKey(Schema.String),
52 }),
53);
54
55const encodeDpopJwtHeaderJson = Schema.encodeEffect(DpopJwtHeaderJson);
56const encodeDpopJwtPayloadJson = Schema.encodeEffect(DpopJwtPayloadJson);
57
58function toExpoDigestAlgorithm(
59 algorithm: Crypto.DigestAlgorithm,
60): ExpoCrypto.CryptoDigestAlgorithm {
61 switch (algorithm) {
62 case "SHA-1":
63 return ExpoCrypto.CryptoDigestAlgorithm.SHA1;
64 case "SHA-256":
65 return ExpoCrypto.CryptoDigestAlgorithm.SHA256;
66 case "SHA-384":
67 return ExpoCrypto.CryptoDigestAlgorithm.SHA384;
68 case "SHA-512":
69 return ExpoCrypto.CryptoDigestAlgorithm.SHA512;
70 }
71}
72
73export const mobileCryptoLayer = Layer.succeed(
74 Crypto.Crypto,
75 Crypto.make({
76 randomBytes: ExpoCrypto.getRandomBytes,
77 digest: (algorithm, data) =>
78 Effect.promise(async () => {
79 const input = new Uint8Array(data.length);
80 input.set(data);
81 return new Uint8Array(await ExpoCrypto.digest(toExpoDigestAlgorithm(algorithm), input));
82 }),
83 }),
84);
85
86type DpopPrivateJwk = typeof DpopPrivateJwkSchema.Type;
87
88export interface DpopProofKeyPair {
89 readonly privateJwk: DpopPrivateJwk;
90 readonly publicJwk: DpopPublicJwk;
91 readonly thumbprint: string;
92}
93
94const DPOP_PROOF_KEY_STORAGE_KEY = "t3code.cloud.dpop-proof-key";
95
96function base64UrlToBytes(value: string): Uint8Array {
97 return Result.getOrThrow(Encoding.decodeBase64Url(value));
98}
99
100function sha256Digest(
101 data: Uint8Array,
102 message: string,
103): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {
104 return Crypto.Crypto.pipe(
105 Effect.flatMap((crypto) => crypto.digest("SHA-256", data)),
106 Effect.mapError(cloudDpopError(message)),
107 );
108}
109
110function secureRandomBytes(
111 byteCount: number,
112 message: string,
113): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> {
114 return Crypto.Crypto.pipe(
115 Effect.flatMap((crypto) => crypto.randomBytes(byteCount)),
116 Effect.mapError(cloudDpopError(message)),
117 );
118}
119
120function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk {
121 if (publicKey.length !== 65 || publicKey[0] !== 0x04) {
122 throw new Error("Generated DPoP public key is not an uncompressed P-256 point.");
123 }
124 return {
125 kty: "EC",
126 crv: "P-256",
127 x: Encoding.encodeBase64Url(publicKey.slice(1, 33)),
128 y: Encoding.encodeBase64Url(publicKey.slice(33, 65)),
129 };
130}
131
132function privateJwkFromPrivateKey(
133 privateKey: Uint8Array,
134 publicJwk: DpopPublicJwk,
135): DpopPrivateJwk {
136 return { ...publicJwk, d: Encoding.encodeBase64Url(privateKey) };
137}
138
139export function generateDpopProofKeyPair(): Effect.Effect<
140 DpopProofKeyPair,
141 CloudDpopError,
142 Crypto.Crypto
143> {
144 return Effect.gen(function* () {
145 let privateKey: Uint8Array;
146 do {
147 privateKey = yield* secureRandomBytes(
148 p256.CURVE.nByteLength,
149 "Could not generate DPoP key pair randomness.",
150 );
151 } while (!p256.utils.isValidPrivateKey(privateKey));
152 const publicJwk = yield* Effect.try({
153 try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)),
154 catch: cloudDpopError("Generated DPoP public key is invalid."),
155 });
156 const thumbprint = computeDpopJwkThumbprint(publicJwk);
157 return {
158 privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk),
159 publicJwk,
160 thumbprint,
161 };
162 });
163}
164
165export function loadOrCreateDpopProofKeyPair(): Effect.Effect<
166 DpopProofKeyPair,
167 CloudDpopError,
168 Crypto.Crypto
169> {
170 return Effect.gen(function* () {
171 const stored = yield* Effect.tryPromise({
172 try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY),
173 catch: cloudDpopError("Could not read the DPoP proof key."),
174 });
175 if (stored) {
176 const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe(
177 Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")),
178 );
179 const restored = yield* Effect.try({
180 try: () => {
181 const privateKey = base64UrlToBytes(storedPrivateJwk.d);
182 const publicJwk = publicJwkFromUncompressedPublicKey(
183 p256.getPublicKey(privateKey, false),
184 );
185 if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) {
186 throw new Error("Stored DPoP key does not match its public key.");
187 }
188 return { privateJwk: storedPrivateJwk, publicJwk };
189 },
190 catch: cloudDpopError("Stored DPoP proof key is invalid."),
191 });
192 return {
193 ...restored,
194 thumbprint: computeDpopJwkThumbprint(restored.publicJwk),
195 };
196 }
197 const generated = yield* generateDpopProofKeyPair();
198 const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe(
199 Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")),
200 );
201 yield* Effect.tryPromise({
202 try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk),
203 catch: cloudDpopError("Could not store the DPoP proof key."),
204 });
205 return generated;
206 });
207}
208
209function normalizeHtu(url: string): Effect.Effect<string, CloudDpopError> {
210 const normalized = normalizeDpopHtu(url);
211 return normalized
212 ? Effect.succeed(normalized)
213 : Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." }));
214}
215
216export function createDpopProof(input: {
217 readonly method: string;
218 readonly url: string;
219 readonly accessToken?: string;
220 readonly proofKey?: DpopProofKeyPair;
221}): Effect.Effect<
222 { readonly proof: string; readonly thumbprint: string },
223 CloudDpopError,
224 Crypto.Crypto
225> {
226 return Effect.gen(function* () {
227 const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair());
228 const privateKey = yield* Effect.try({
229 try: () => base64UrlToBytes(keyPair.privateJwk.d),
230 catch: cloudDpopError("Could not import DPoP private key."),
231 });
232 const nowMs = yield* Clock.currentTimeMillis;
233 const jti = yield* Crypto.Crypto.pipe(
234 Effect.flatMap((crypto) => crypto.randomUUIDv4),
235 Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")),
236 );
237 const htu = yield* normalizeHtu(input.url);
238 const header = yield* encodeDpopJwtHeaderJson({
239 typ: "dpop+jwt",
240 alg: "ES256",
241 jwk: keyPair.publicJwk,
242 }).pipe(
243 Effect.map(Encoding.encodeBase64Url),
244 Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")),
245 );
246 const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null;
247 const payload = yield* encodeDpopJwtPayloadJson({
248 htm: input.method.toUpperCase(),
249 htu,
250 jti,
251 iat: Math.floor(nowMs / 1_000),
252 ...(ath ? { ath } : {}),
253 }).pipe(
254 Effect.map(Encoding.encodeBase64Url),
255 Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")),
256 );
257 const signatureInputHash = yield* sha256Digest(
258 new TextEncoder().encode(`${header}.${payload}`),
259 "Could not hash DPoP signing input.",
260 );
261 const signature = yield* Effect.try({
262 try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(),
263 catch: cloudDpopError("Could not sign DPoP proof."),
264 });
265 return {
266 proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`,
267 thumbprint: keyPair.thumbprint,
268 };
269 });
270}
apps/mobile/src/features/cloud/linkEnvironment.test.ts+1030−0
@@ -0,0 +1,1030 @@
1import { beforeEach, vi } from "vitest";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { EnvironmentId } from "@t3tools/contracts";
6import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import {
8 managedRelayClientLayer,
9 ManagedRelayClient,
10 ManagedRelayDpopSigner,
11 remoteHttpClientLayer,
12} from "@t3tools/client-runtime";
13import { HttpClient } from "effect/unstable/http";
14
15import {
16 cloudEnvironmentsPendingStatus,
17 linkEnvironmentToCloud,
18 connectCloudEnvironment,
19 listCloudEnvironments,
20 listCloudEnvironmentsWithStatus,
21 normalizeRelayBaseUrl,
22} from "./linkEnvironment";
23
24vi.mock("expo-constants", () => ({
25 default: {
26 expoConfig: {
27 extra: {
28 relay: {
29 url: "https://relay.example.test",
30 },
31 },
32 },
33 },
34}));
35
36vi.mock("react-native", () => ({
37 Platform: {
38 OS: "ios",
39 },
40}));
41
42vi.mock("../../lib/storage", () => ({
43 loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
44 loadPreferences: vi.fn(() => Promise.resolve({})),
45}));
46
47const savedConnection = {
48 environmentId: EnvironmentId.make("env-1"),
49 environmentLabel: "Desktop",
50 pairingUrl: "https://desktop.example.test/",
51 displayUrl: "https://desktop.example.test/",
52 httpBaseUrl: "https://desktop.example.test/",
53 wsBaseUrl: "wss://desktop.example.test/ws",
54 bearerToken: "local-bearer",
55};
56
57const createProofMock = vi.fn(
58 (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
59 Effect.succeed(`dpop:${input.method}:${input.url}`),
60);
61const testDpopSignerLayer = Layer.succeed(
62 ManagedRelayDpopSigner,
63 ManagedRelayDpopSigner.of({
64 thumbprint: Effect.succeed("client-proof-key-thumbprint"),
65 createProof: (input) => createProofMock(input),
66 }),
67);
68
69function cloudClientLayer() {
70 const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));
71 return Layer.mergeAll(
72 httpClientLayer,
73 managedRelayClientLayer({
74 relayUrl: "https://relay.example.test",
75 clientId: RelayMobileClientId,
76 }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)),
77 );
78}
79
80const withCloudServices = <A, E>(
81 effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>,
82) => effect.pipe(Effect.provide(cloudClientLayer()));
83
84function validLinkProof() {
85 return "signed-environment-link-jwt";
86}
87
88function validLinkResponse(environmentId = "env-1") {
89 return {
90 ok: true,
91 environmentId,
92 endpoint: {
93 httpBaseUrl: "https://managed.example.test/",
94 wsBaseUrl: "wss://managed.example.test/ws",
95 providerKind: "cloudflare_tunnel",
96 },
97 endpointRuntime: {
98 providerKind: "cloudflare_tunnel",
99 connectorToken: "connector-token",
100 },
101 relayIssuer: "https://relay.example.test",
102 cloudUserId: "user_123",
103 environmentCredential: "environment-credential",
104 cloudMintPublicKey: "cloud-mint-public-key",
105 };
106}
107
108function validLinkChallengeResponse() {
109 return {
110 challenge: "link-challenge",
111 expiresAt: "2026-05-25T00:05:00.000Z",
112 };
113}
114
115function requestBodyText(body: BodyInit | null | undefined): string {
116 return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
117}
118
119function validDpopAccessTokenResponse(scope = "environment:status environment:connect") {
120 return {
121 access_token: "relay-dpop-token",
122 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
123 token_type: "DPoP",
124 expires_in: 300,
125 scope,
126 };
127}
128
129function listedEnvironment(environmentId: string) {
130 return {
131 environmentId: EnvironmentId.make(environmentId),
132 label: "Desktop",
133 endpoint: {
134 httpBaseUrl: `https://${environmentId}.example.test/`,
135 wsBaseUrl: `wss://${environmentId}.example.test/ws`,
136 providerKind: "cloudflare_tunnel" as const,
137 },
138 linkedAt: "2026-05-25T00:00:00.000Z",
139 };
140}
141
142describe("mobile cloud link environment client", () => {
143 beforeEach(() => {
144 vi.restoreAllMocks();
145 createProofMock.mockClear();
146 });
147
148 it("normalizes configured relay base URLs before building DPoP-bound requests", () => {
149 expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe(
150 "https://relay.example.test",
151 );
152 expect(normalizeRelayBaseUrl(" ")).toBeNull();
153 });
154
155 it("makes linked environments visible while their status is still loading", () => {
156 expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([
157 {
158 environment: { environmentId: "env-1", label: "Desktop" },
159 status: null,
160 statusError: "Checking status...",
161 },
162 ]);
163 });
164
165 it.effect("decodes relay environment list responses before returning records", () =>
166 Effect.gen(function* () {
167 vi.stubGlobal(
168 "fetch",
169 vi.fn(() =>
170 Promise.resolve(
171 Response.json({
172 environments: [
173 {
174 environmentId: "env-1",
175 label: "Desktop",
176 endpoint: {
177 httpBaseUrl: "https://desktop.example.test/",
178 wsBaseUrl: "wss://desktop.example.test/ws",
179 providerKind: "cloudflare_tunnel",
180 },
181 linkedAt: "2026-05-25T00:00:00.000Z",
182 },
183 ],
184 }),
185 ),
186 ),
187 );
188
189 const records = yield* withCloudServices(
190 listCloudEnvironments({ clerkToken: "clerk-token" }),
191 );
192 expect(records).toEqual([
193 {
194 environmentId: "env-1",
195 label: "Desktop",
196 endpoint: {
197 httpBaseUrl: "https://desktop.example.test/",
198 wsBaseUrl: "wss://desktop.example.test/ws",
199 providerKind: "cloudflare_tunnel",
200 },
201 linkedAt: "2026-05-25T00:00:00.000Z",
202 },
203 ]);
204 }),
205 );
206
207 it.effect("rejects malformed relay environment list responses", () =>
208 Effect.gen(function* () {
209 vi.stubGlobal(
210 "fetch",
211 vi.fn(() =>
212 Promise.resolve(
213 Response.json({
214 environments: [
215 {
216 environmentId: "env-1",
217 label: "Desktop",
218 endpoint: {
219 httpBaseUrl: "",
220 wsBaseUrl: "wss://desktop.example.test/ws",
221 providerKind: "cloudflare_tunnel",
222 },
223 linkedAt: "2026-05-25T00:00:00.000Z",
224 },
225 ],
226 }),
227 ),
228 ),
229 );
230
231 const error = yield* withCloudServices(
232 listCloudEnvironments({ clerkToken: "clerk-token" }),
233 ).pipe(Effect.flip);
234 expect(error).toMatchObject({
235 _tag: "CloudEnvironmentLinkError",
236 message: "https://relay.example.test/v1/environments failed",
237 });
238 }),
239 );
240
241 it.effect("loads signed status for each advertised cloud environment", () =>
242 Effect.gen(function* () {
243 const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => {
244 if (String(url) === "https://relay.example.test/v1/environments") {
245 return Promise.resolve(
246 Response.json({
247 environments: [
248 {
249 environmentId: "env-1",
250 label: "Desktop",
251 endpoint: {
252 httpBaseUrl: "https://desktop.example.test/",
253 wsBaseUrl: "wss://desktop.example.test/ws",
254 providerKind: "cloudflare_tunnel",
255 },
256 linkedAt: "2026-05-25T00:00:00.000Z",
257 },
258 ],
259 }),
260 );
261 }
262 if (String(url) === "https://relay.example.test/v1/client/dpop-token") {
263 return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
264 }
265 expect(String(url)).toBe("https://relay.example.test/v1/environments/env-1/status");
266 return Promise.resolve(
267 Response.json({
268 environmentId: "env-1",
269 endpoint: {
270 httpBaseUrl: "https://desktop.example.test/",
271 wsBaseUrl: "wss://desktop.example.test/ws",
272 providerKind: "cloudflare_tunnel",
273 },
274 status: "online",
275 checkedAt: "2026-05-25T00:01:00.000Z",
276 descriptor: {
277 environmentId: "env-1",
278 label: "Desktop",
279 platform: { os: "darwin", arch: "arm64" },
280 serverVersion: "0.0.0-test",
281 capabilities: { repositoryIdentity: true },
282 },
283 }),
284 );
285 });
286 vi.stubGlobal("fetch", fetchMock);
287
288 const records = yield* withCloudServices(
289 listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }),
290 );
291 expect(records).toMatchObject([
292 {
293 environment: {
294 environmentId: "env-1",
295 label: "Desktop",
296 },
297 status: {
298 environmentId: "env-1",
299 status: "online",
300 checkedAt: "2026-05-25T00:01:00.000Z",
301 },
302 statusError: null,
303 },
304 ]);
305 expect(String(fetchMock.mock.calls[2]?.[0])).toBe(
306 "https://relay.example.test/v1/environments/env-1/status",
307 );
308 expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST");
309 const statusHeaders = new Headers(fetchMock.mock.calls[2]?.[1]?.headers);
310 expect(statusHeaders.get("authorization")).toBe("DPoP relay-dpop-token");
311 expect(statusHeaders.get("dpop")).toBe(
312 "dpop:POST:https://relay.example.test/v1/environments/env-1/status",
313 );
314 expect(createProofMock).toHaveBeenCalledWith({
315 method: "POST",
316 url: "https://relay.example.test/v1/environments/env-1/status",
317 accessToken: "relay-dpop-token",
318 });
319 }),
320 );
321
322 it.effect("reuses one valid DPoP access token while probing multiple environment statuses", () =>
323 Effect.gen(function* () {
324 const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => {
325 if (String(url).endsWith("/v1/environments")) {
326 return Promise.resolve(
327 Response.json({
328 environments: [listedEnvironment("env-1"), listedEnvironment("env-2")],
329 }),
330 );
331 }
332 if (String(url).endsWith("/v1/client/dpop-token")) {
333 return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
334 }
335 const environmentId = String(url).includes("/env-1/") ? "env-1" : "env-2";
336 return Promise.resolve(
337 Response.json({
338 environmentId,
339 endpoint: listedEnvironment(environmentId).endpoint,
340 status: "online",
341 checkedAt: "2026-05-25T00:01:00.000Z",
342 descriptor: {
343 environmentId,
344 label: "Desktop",
345 platform: { os: "darwin", arch: "arm64" },
346 serverVersion: "0.0.0-test",
347 capabilities: { repositoryIdentity: true },
348 },
349 }),
350 );
351 });
352 vi.stubGlobal("fetch", fetchMock);
353
354 yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }));
355
356 expect(
357 fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")),
358 ).toHaveLength(1);
359 }),
360 );
361
362 it.effect("reuses the status-and-connect token when connecting from the cloud list", () =>
363 Effect.gen(function* () {
364 const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => {
365 if (String(url).endsWith("/v1/environments")) {
366 return Promise.resolve(
367 Response.json({
368 environments: [listedEnvironment("env-1")],
369 }),
370 );
371 }
372 if (String(url).endsWith("/v1/client/dpop-token")) {
373 return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
374 }
375 if (String(url).endsWith("/status")) {
376 return Promise.resolve(
377 Response.json({
378 environmentId: "env-1",
379 endpoint: listedEnvironment("env-1").endpoint,
380 status: "online",
381 checkedAt: "2026-05-25T00:01:00.000Z",
382 descriptor: {
383 environmentId: "env-1",
384 label: "Desktop",
385 platform: { os: "darwin", arch: "arm64" },
386 serverVersion: "0.0.0-test",
387 capabilities: { repositoryIdentity: true },
388 },
389 }),
390 );
391 }
392 if (String(url).endsWith("/connect")) {
393 return Promise.resolve(
394 Response.json({
395 environmentId: "env-1",
396 endpoint: listedEnvironment("env-1").endpoint,
397 credential: "one-time-cloud-credential",
398 expiresAt: "2026-05-25T00:05:00.000Z",
399 }),
400 );
401 }
402 if (String(url).endsWith("/.well-known/t3/environment")) {
403 return Promise.resolve(
404 Response.json({
405 environmentId: "env-1",
406 label: "Desktop",
407 platform: { os: "darwin", arch: "arm64" },
408 serverVersion: "0.0.0-test",
409 capabilities: { repositoryIdentity: true },
410 }),
411 );
412 }
413 return Promise.resolve(
414 Response.json({
415 access_token: "environment-dpop-token",
416 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
417 token_type: "DPoP",
418 expires_in: 3600,
419 scope: "orchestration:read orchestration:operate terminal:operate review:write",
420 }),
421 );
422 });
423 vi.stubGlobal("fetch", fetchMock);
424
425 yield* withCloudServices(
426 Effect.gen(function* () {
427 const records = yield* listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" });
428 yield* connectCloudEnvironment({
429 clerkToken: "clerk-token",
430 environment: records[0]!.environment,
431 });
432 }),
433 );
434
435 expect(
436 fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")),
437 ).toHaveLength(1);
438 const exchangeRequest = fetchMock.mock.calls.find(([url]) =>
439 String(url).endsWith("/v1/client/dpop-token"),
440 )?.[1];
441 expect(new URLSearchParams(requestBodyText(exchangeRequest?.body)).get("scope")).toBe(
442 "environment:status environment:connect",
443 );
444 const environmentTokenRequest = fetchMock.mock.calls.find(([url]) =>
445 String(url).endsWith("/oauth/token"),
446 )?.[1];
447 const environmentTokenBody = new URLSearchParams(
448 requestBodyText(environmentTokenRequest?.body),
449 );
450 expect(environmentTokenBody.get("client_label")).toBe("T3 Code Mobile");
451 expect(environmentTokenBody.get("client_device_type")).toBe("mobile");
452 expect(environmentTokenBody.get("client_os")).toBe("iOS");
453 }),
454 );
455
456 it.effect("keeps advertised environments visible when status probing fails", () =>
457 Effect.gen(function* () {
458 vi.stubGlobal(
459 "fetch",
460 vi.fn((url: string | URL) => {
461 if (String(url) === "https://relay.example.test/v1/environments") {
462 return Promise.resolve(
463 Response.json({
464 environments: [
465 {
466 environmentId: "env-1",
467 label: "Desktop",
468 endpoint: {
469 httpBaseUrl: "https://desktop.example.test/",
470 wsBaseUrl: "wss://desktop.example.test/ws",
471 providerKind: "cloudflare_tunnel",
472 },
473 linkedAt: "2026-05-25T00:00:00.000Z",
474 },
475 ],
476 }),
477 );
478 }
479 if (String(url) === "https://relay.example.test/v1/client/dpop-token") {
480 return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
481 }
482 return Promise.resolve(Response.json({ error: "offline" }, { status: 503 }));
483 }),
484 );
485
486 const records = yield* withCloudServices(
487 listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }),
488 );
489 expect(records).toMatchObject([
490 {
491 environment: {
492 environmentId: "env-1",
493 label: "Desktop",
494 },
495 status: null,
496 statusError: "https://relay.example.test/v1/environments/env-1/status failed",
497 },
498 ]);
499 }),
500 );
501
502 it.effect("rejects status responses for a different advertised environment", () =>
503 Effect.gen(function* () {
504 vi.stubGlobal(
505 "fetch",
506 vi.fn((url: string | URL) => {
507 if (String(url) === "https://relay.example.test/v1/environments") {
508 return Promise.resolve(
509 Response.json({
510 environments: [
511 {
512 environmentId: "env-1",
513 label: "Desktop",
514 endpoint: {
515 httpBaseUrl: "https://desktop.example.test/",
516 wsBaseUrl: "wss://desktop.example.test/ws",
517 providerKind: "cloudflare_tunnel",
518 },
519 linkedAt: "2026-05-25T00:00:00.000Z",
520 },
521 ],
522 }),
523 );
524 }
525 if (String(url) === "https://relay.example.test/v1/client/dpop-token") {
526 return Promise.resolve(Response.json(validDpopAccessTokenResponse()));
527 }
528 return Promise.resolve(
529 Response.json({
530 environmentId: "env-other",
531 endpoint: {
532 httpBaseUrl: "https://desktop.example.test/",
533 wsBaseUrl: "wss://desktop.example.test/ws",
534 providerKind: "cloudflare_tunnel",
535 },
536 status: "online",
537 checkedAt: "2026-05-25T00:01:00.000Z",
538 descriptor: {
539 environmentId: "env-other",
540 label: "Other Desktop",
541 platform: { os: "darwin", arch: "arm64" },
542 serverVersion: "0.0.0-test",
543 capabilities: { repositoryIdentity: true },
544 },
545 }),
546 );
547 }),
548 );
549
550 const records = yield* withCloudServices(
551 listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }),
552 );
553 expect(records).toMatchObject([
554 {
555 environment: {
556 environmentId: "env-1",
557 label: "Desktop",
558 },
559 status: null,
560 statusError: "Relay returned status for a different environment.",
561 },
562 ]);
563 }),
564 );
565
566 it.effect(
567 "rejects relay link credentials for a different environment before persisting relay config",
568 () =>
569 Effect.gen(function* () {
570 const fetchMock = vi.fn((url: string | URL) => {
571 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
572 return Promise.resolve(Response.json(validLinkChallengeResponse()));
573 }
574 if (String(url).endsWith("/api/cloud/link-proof")) {
575 return Promise.resolve(Response.json(validLinkProof()));
576 }
577 return Promise.resolve(Response.json(validLinkResponse("env-other")));
578 });
579 vi.stubGlobal("fetch", fetchMock);
580
581 const error = yield* withCloudServices(
582 linkEnvironmentToCloud({
583 clerkToken: "clerk-token",
584 connection: savedConnection,
585 }),
586 ).pipe(Effect.flip);
587 expect(error).toMatchObject({
588 _tag: "CloudEnvironmentLinkError",
589 message: "Relay returned credentials for a different environment.",
590 });
591 expect(fetchMock).toHaveBeenCalledTimes(3);
592 }),
593 );
594
595 it.effect("preserves typed local environment failures while obtaining a link proof", () =>
596 Effect.gen(function* () {
597 const fetchMock = vi.fn((url: string | URL) => {
598 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
599 return Promise.resolve(Response.json(validLinkChallengeResponse()));
600 }
601 return Promise.resolve(
602 Response.json(
603 {
604 _tag: "EnvironmentHttpUnauthorizedError",
605 message: "Invalid environment bearer session.",
606 },
607 { status: 401 },
608 ),
609 );
610 });
611 vi.stubGlobal("fetch", fetchMock);
612
613 const error = yield* withCloudServices(
614 linkEnvironmentToCloud({
615 clerkToken: "clerk-token",
616 connection: savedConnection,
617 }),
618 ).pipe(Effect.flip);
619 expect(error._tag).toBe("CloudEnvironmentLinkError");
620 expect(error.message).toBe(
621 "Could not obtain environment link proof: Invalid environment bearer session.",
622 );
623 expect(fetchMock).toHaveBeenCalledTimes(2);
624 }),
625 );
626
627 it.effect("preserves typed relay error bodies while linking environments", () =>
628 Effect.gen(function* () {
629 const fetchMock = vi.fn((url: string | URL) => {
630 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
631 return Promise.resolve(Response.json(validLinkChallengeResponse()));
632 }
633 if (String(url).endsWith("/api/cloud/link-proof")) {
634 return Promise.resolve(Response.json(validLinkProof()));
635 }
636 return Promise.resolve(
637 Response.json(
638 {
639 _tag: "RelayEnvironmentLinkProofInvalidError",
640 code: "environment_link_proof_invalid",
641 reason: "origin_not_allowed",
642 traceId: "trace-test",
643 },
644 { status: 400 },
645 ),
646 );
647 });
648 vi.stubGlobal("fetch", fetchMock);
649
650 const error = yield* withCloudServices(
651 linkEnvironmentToCloud({
652 clerkToken: "clerk-token",
653 connection: savedConnection,
654 }),
655 ).pipe(Effect.flip);
656 expect(error).toMatchObject({
657 _tag: "CloudEnvironmentLinkError",
658 message:
659 "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).",
660 });
661 expect(fetchMock).toHaveBeenCalledTimes(3);
662 }),
663 );
664
665 it.effect("rejects relay link credentials for a different managed endpoint provider", () =>
666 Effect.gen(function* () {
667 const fetchMock = vi.fn((url: string | URL) => {
668 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
669 return Promise.resolve(Response.json(validLinkChallengeResponse()));
670 }
671 if (String(url).endsWith("/api/cloud/link-proof")) {
672 return Promise.resolve(Response.json(validLinkProof()));
673 }
674 return Promise.resolve(
675 Response.json({
676 ...validLinkResponse(),
677 endpoint: {
678 ...validLinkResponse().endpoint,
679 providerKind: "manual",
680 },
681 }),
682 );
683 });
684 vi.stubGlobal("fetch", fetchMock);
685
686 const error = yield* withCloudServices(
687 linkEnvironmentToCloud({
688 clerkToken: "clerk-token",
689 connection: savedConnection,
690 }),
691 ).pipe(Effect.flip);
692 expect(error).toMatchObject({
693 _tag: "CloudEnvironmentLinkError",
694 message: "Relay returned credentials for a different endpoint provider.",
695 });
696 expect(fetchMock).toHaveBeenCalledTimes(3);
697 }),
698 );
699
700 it.effect("preserves disabled Live Activity preferences when linking an environment", () =>
701 Effect.gen(function* () {
702 const storage = yield* Effect.promise(() => import("../../lib/storage"));
703 vi.mocked(storage.loadPreferences).mockResolvedValueOnce({
704 liveActivitiesEnabled: false,
705 });
706 const bodies: Array<unknown> = [];
707 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
708 if (init?.body) {
709 // @effect-diagnostics-next-line preferSchemaOverJson:off
710 bodies.push(JSON.parse(requestBodyText(init.body)));
711 }
712 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
713 return Promise.resolve(Response.json(validLinkChallengeResponse()));
714 }
715 if (String(url).endsWith("/api/cloud/link-proof")) {
716 return Promise.resolve(Response.json(validLinkProof()));
717 }
718 if (String(url).endsWith("/v1/client/environment-links")) {
719 return Promise.resolve(Response.json(validLinkResponse()));
720 }
721 return Promise.resolve(
722 Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }),
723 );
724 });
725 vi.stubGlobal("fetch", fetchMock);
726
727 yield* withCloudServices(
728 linkEnvironmentToCloud({
729 clerkToken: "clerk-token",
730 connection: savedConnection,
731 }),
732 );
733
734 expect(bodies[1]).toMatchObject({
735 endpoint: {
736 httpBaseUrl: "https://desktop.example.test/",
737 wsBaseUrl: "wss://desktop.example.test/ws",
738 providerKind: "cloudflare_tunnel",
739 },
740 origin: {
741 localHttpHost: "127.0.0.1",
742 localHttpPort: 443,
743 },
744 });
745 expect(bodies[2]).toMatchObject({
746 deviceId: "device-1",
747 notificationsEnabled: true,
748 liveActivitiesEnabled: false,
749 managedTunnelsEnabled: true,
750 });
751 expect(bodies[3]).toMatchObject({
752 cloudUserId: "user_123",
753 environmentCredential: "environment-credential",
754 });
755 }),
756 );
757
758 it.effect(
759 "does not persist cloud connect bootstrap credentials in saved connection records",
760 () =>
761 Effect.gen(function* () {
762 let connectRequestBody = "";
763 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
764 if (String(url).endsWith("/v1/client/dpop-token")) {
765 return Promise.resolve(
766 Response.json(validDpopAccessTokenResponse("environment:connect")),
767 );
768 }
769 if (String(url).endsWith("/.well-known/t3/environment")) {
770 return Promise.resolve(
771 Response.json({
772 environmentId: "env-1",
773 label: "Desktop",
774 platform: { os: "darwin", arch: "arm64" },
775 serverVersion: "0.0.0-test",
776 capabilities: { repositoryIdentity: true },
777 }),
778 );
779 }
780 if (String(url).endsWith("/oauth/token")) {
781 return Promise.resolve(
782 Response.json({
783 access_token: "environment-dpop-token",
784 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
785 token_type: "DPoP",
786 expires_in: 3600,
787 scope: "orchestration:read orchestration:operate terminal:operate review:write",
788 }),
789 );
790 }
791 connectRequestBody = requestBodyText(init?.body);
792 return Promise.resolve(
793 Response.json({
794 environmentId: "env-1",
795 endpoint: {
796 httpBaseUrl: "https://desktop.example.test/",
797 wsBaseUrl: "wss://desktop.example.test/ws",
798 providerKind: "cloudflare_tunnel",
799 },
800 credential: "one-time-cloud-credential",
801 expiresAt: "2026-05-25T00:05:00.000Z",
802 }),
803 );
804 });
805 vi.stubGlobal("fetch", fetchMock);
806
807 const connection = yield* withCloudServices(
808 connectCloudEnvironment({
809 clerkToken: "clerk-token",
810 environment: {
811 environmentId: EnvironmentId.make("env-1"),
812 label: "Desktop",
813 endpoint: {
814 httpBaseUrl: "https://desktop.example.test/",
815 wsBaseUrl: "wss://desktop.example.test/ws",
816 providerKind: "cloudflare_tunnel",
817 },
818 linkedAt: "2026-05-25T00:00:00.000Z",
819 },
820 }),
821 );
822
823 expect(connection.pairingUrl).toBe("https://desktop.example.test/");
824 expect(connection.pairingUrl).not.toContain("one-time-cloud-credential");
825 expect(connection.bearerToken).toBeNull();
826 expect(connection.authenticationMethod).toBe("dpop");
827 expect(connection.dpopAccessToken).toBe("environment-dpop-token");
828 // @effect-diagnostics-next-line preferSchemaOverJson:off
829 expect(JSON.parse(connectRequestBody)).toMatchObject({
830 deviceId: "device-1",
831 clientKeyThumbprint: "client-proof-key-thumbprint",
832 });
833 expect(createProofMock).toHaveBeenCalledWith({
834 method: "POST",
835 url: "https://relay.example.test/v1/environments/env-1/connect",
836 accessToken: "relay-dpop-token",
837 });
838 expect(createProofMock).toHaveBeenCalledWith({
839 method: "POST",
840 url: "https://desktop.example.test/oauth/token",
841 });
842 }),
843 );
844
845 it.effect("rejects relay connect responses for a different environment", () =>
846 Effect.gen(function* () {
847 vi.stubGlobal(
848 "fetch",
849 vi.fn((url: string | URL) =>
850 Promise.resolve(
851 String(url).endsWith("/v1/client/dpop-token")
852 ? Response.json(validDpopAccessTokenResponse("environment:connect"))
853 : Response.json({
854 environmentId: "env-other",
855 endpoint: {
856 httpBaseUrl: "https://desktop.example.test/",
857 wsBaseUrl: "wss://desktop.example.test/ws",
858 providerKind: "cloudflare_tunnel",
859 },
860 credential: "one-time-cloud-credential",
861 expiresAt: "2026-05-25T00:05:00.000Z",
862 }),
863 ),
864 ),
865 );
866
867 const error = yield* withCloudServices(
868 connectCloudEnvironment({
869 clerkToken: "clerk-token",
870 environment: {
871 environmentId: EnvironmentId.make("env-1"),
872 label: "Desktop",
873 endpoint: {
874 httpBaseUrl: "https://desktop.example.test/",
875 wsBaseUrl: "wss://desktop.example.test/ws",
876 providerKind: "cloudflare_tunnel",
877 },
878 linkedAt: "2026-05-25T00:00:00.000Z",
879 },
880 }),
881 ).pipe(Effect.flip);
882 expect(error).toMatchObject({
883 _tag: "CloudEnvironmentLinkError",
884 message: "Relay returned credentials for a different environment.",
885 });
886 }),
887 );
888
889 it.effect("preserves relay DPoP auth failures while connecting environments", () =>
890 Effect.gen(function* () {
891 vi.stubGlobal(
892 "fetch",
893 vi.fn((url: string | URL) =>
894 Promise.resolve(
895 String(url).endsWith("/v1/client/dpop-token")
896 ? Response.json(validDpopAccessTokenResponse("environment:connect"))
897 : Response.json(
898 {
899 _tag: "RelayAuthInvalidError",
900 code: "auth_invalid",
901 reason: "invalid_dpop",
902 traceId: "trace-connect",
903 },
904 { status: 401 },
905 ),
906 ),
907 ),
908 );
909
910 const error = yield* withCloudServices(
911 connectCloudEnvironment({
912 clerkToken: "clerk-token",
913 environment: {
914 environmentId: EnvironmentId.make("env-1"),
915 label: "Desktop",
916 endpoint: {
917 httpBaseUrl: "https://desktop.example.test/",
918 wsBaseUrl: "wss://desktop.example.test/ws",
919 providerKind: "cloudflare_tunnel",
920 },
921 linkedAt: "2026-05-25T00:00:00.000Z",
922 },
923 }),
924 ).pipe(Effect.flip);
925 expect(error).toMatchObject({
926 _tag: "CloudEnvironmentLinkError",
927 message:
928 "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.",
929 });
930 }),
931 );
932
933 it.effect("rejects relay connect responses for a different endpoint", () =>
934 Effect.gen(function* () {
935 vi.stubGlobal(
936 "fetch",
937 vi.fn((url: string | URL) =>
938 Promise.resolve(
939 String(url).endsWith("/v1/client/dpop-token")
940 ? Response.json(validDpopAccessTokenResponse("environment:connect"))
941 : Response.json({
942 environmentId: "env-1",
943 endpoint: {
944 httpBaseUrl: "https://other-desktop.example.test/",
945 wsBaseUrl: "wss://other-desktop.example.test/ws",
946 providerKind: "cloudflare_tunnel",
947 },
948 credential: "one-time-cloud-credential",
949 expiresAt: "2026-05-25T00:05:00.000Z",
950 }),
951 ),
952 ),
953 );
954
955 const error = yield* withCloudServices(
956 connectCloudEnvironment({
957 clerkToken: "clerk-token",
958 environment: {
959 environmentId: EnvironmentId.make("env-1"),
960 label: "Desktop",
961 endpoint: {
962 httpBaseUrl: "https://desktop.example.test/",
963 wsBaseUrl: "wss://desktop.example.test/ws",
964 providerKind: "cloudflare_tunnel",
965 },
966 linkedAt: "2026-05-25T00:00:00.000Z",
967 },
968 }),
969 ).pipe(Effect.flip);
970 expect(error).toMatchObject({
971 _tag: "CloudEnvironmentLinkError",
972 message: "Relay returned credentials for a different endpoint.",
973 });
974 }),
975 );
976
977 it.effect(
978 "rejects managed endpoints whose descriptor does not match the selected environment",
979 () =>
980 Effect.gen(function* () {
981 vi.stubGlobal(
982 "fetch",
983 vi.fn((url: string | URL) =>
984 Promise.resolve(
985 String(url).endsWith("/v1/client/dpop-token")
986 ? Response.json(validDpopAccessTokenResponse("environment:connect"))
987 : String(url).endsWith("/.well-known/t3/environment")
988 ? Response.json({
989 environmentId: "env-other",
990 label: "Other Desktop",
991 platform: { os: "darwin", arch: "arm64" },
992 serverVersion: "0.0.0-test",
993 capabilities: { repositoryIdentity: true },
994 })
995 : Response.json({
996 environmentId: "env-1",
997 endpoint: {
998 httpBaseUrl: "https://desktop.example.test/",
999 wsBaseUrl: "wss://desktop.example.test/ws",
1000 providerKind: "cloudflare_tunnel",
1001 },
1002 credential: "one-time-cloud-credential",
1003 expiresAt: "2026-05-25T00:05:00.000Z",
1004 }),
1005 ),
1006 ),
1007 );
1008
1009 const error = yield* withCloudServices(
1010 connectCloudEnvironment({
1011 clerkToken: "clerk-token",
1012 environment: {
1013 environmentId: EnvironmentId.make("env-1"),
1014 label: "Desktop",
1015 endpoint: {
1016 httpBaseUrl: "https://desktop.example.test/",
1017 wsBaseUrl: "wss://desktop.example.test/ws",
1018 providerKind: "cloudflare_tunnel",
1019 },
1020 linkedAt: "2026-05-25T00:00:00.000Z",
1021 },
1022 }),
1023 ).pipe(Effect.flip);
1024 expect(error).toMatchObject({
1025 _tag: "CloudEnvironmentLinkError",
1026 message: "Connected endpoint descriptor does not match the selected environment.",
1027 });
1028 }),
1029 );
1030});
apps/mobile/src/features/cloud/linkEnvironment.ts+552−0
@@ -0,0 +1,552 @@
1import Constants from "expo-constants";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5import { HttpClient } from "effect/unstable/http";
6import {
7 EnvironmentCloudEndpointUnavailableError,
8 EnvironmentHttpBadRequestError,
9 EnvironmentHttpConflictError,
10 EnvironmentHttpForbiddenError,
11 EnvironmentHttpInternalServerError,
12 EnvironmentHttpUnauthorizedError,
13} from "@t3tools/contracts";
14import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";
15import {
16 type RelayEnvironmentConnectResponse as RelayEnvironmentConnectResponseType,
17 type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType,
18 RelayEnvironmentConnectScope,
19 RelayEnvironmentStatusScope,
20 RelayProtectedError,
21 type RelayDpopAccessTokenScope,
22 type RelayProtectedError as RelayProtectedErrorType,
23 type RelayClientEnvironmentRecord,
24 type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,
25 type RelayManagedEndpointProviderKind,
26} from "@t3tools/contracts/relay";
27import {
28 exchangeRemoteDpopAccessToken,
29 fetchRemoteEnvironmentDescriptor,
30 makeEnvironmentHttpApiClient,
31 ManagedRelayClient,
32 ManagedRelayDpopSigner,
33} from "@t3tools/client-runtime";
34
35import { mobileAuthClientMetadata } from "../../lib/authClientMetadata";
36import type { SavedRemoteConnection } from "../../lib/connection";
37import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage";
38
39const RELAY_STATUS_AND_CONNECT_SCOPES = [
40 RelayEnvironmentStatusScope,
41 RelayEnvironmentConnectScope,
42] satisfies ReadonlyArray<RelayDpopAccessTokenScope>;
43
44export function normalizeRelayBaseUrl(value: string | null | undefined): string | null {
45 const trimmed = value?.trim();
46 if (!trimmed) {
47 return null;
48 }
49 return trimmed.replace(/\/+$/g, "");
50}
51
52function readRelayUrl(): string | null {
53 const relayConfig = Constants.expoConfig?.extra?.relay as
54 | { readonly url?: string | null }
55 | undefined;
56 return normalizeRelayBaseUrl(relayConfig?.url);
57}
58
59export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
60 readonly message: string;
61 readonly cause?: unknown;
62}> {}
63
64export interface CloudEnvironmentRecordWithStatus {
65 readonly environment: RelayClientEnvironmentRecord;
66 readonly status: RelayEnvironmentStatusResponseType | null;
67 readonly statusError: string | null;
68}
69
70const isRelayProtectedError = Schema.is(RelayProtectedError);
71const isEnvironmentCloudApiError = Schema.is(
72 Schema.Union([
73 EnvironmentHttpBadRequestError,
74 EnvironmentHttpUnauthorizedError,
75 EnvironmentHttpForbiddenError,
76 EnvironmentHttpConflictError,
77 EnvironmentHttpInternalServerError,
78 EnvironmentCloudEndpointUnavailableError,
79 ]),
80);
81
82const MANAGED_ENDPOINT_PROVIDER_KIND =
83 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
84
85function cloudEnvironmentLinkError(message: string) {
86 return (cause: unknown) => {
87 const environmentError = findEnvironmentCloudApiError(cause);
88 return new CloudEnvironmentLinkError({
89 message: environmentError
90 ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
91 : withDevCause(message, cause),
92 cause,
93 });
94 };
95}
96
97function isDevRuntime(): boolean {
98 return typeof __DEV__ !== "undefined" && __DEV__;
99}
100
101function causeMessage(cause: unknown): string | null {
102 if (cause instanceof Error && cause.message) {
103 return cause.message;
104 }
105 if (typeof cause === "object" && cause !== null) {
106 const record = cause as { readonly message?: unknown; readonly cause?: unknown };
107 if (typeof record.message === "string" && record.message.length > 0) {
108 const nested = causeMessage(record.cause);
109 return nested ? `${record.message}: ${nested}` : record.message;
110 }
111 }
112 return null;
113}
114
115function withDevCause(message: string, cause: unknown): string {
116 if (!isDevRuntime()) {
117 return message;
118 }
119 const detail = causeMessage(cause);
120 return detail ? `${message} (${detail})` : message;
121}
122
123function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
124 switch (error._tag) {
125 case "RelayAuthInvalidError":
126 switch (error.reason) {
127 case "missing_bearer":
128 case "invalid_bearer":
129 return "Relay rejected the cloud session token.";
130 case "invalid_dpop":
131 return "Relay rejected the DPoP proof.";
132 case "not_authorized":
133 return "Relay rejected the authenticated request.";
134 }
135 case "RelayEnvironmentLinkProofExpiredError":
136 return "Relay rejected an expired environment link proof.";
137 case "RelayEnvironmentLinkProofInvalidError":
138 return `Relay rejected the environment link proof (${error.reason}).`;
139 case "RelayEnvironmentConnectNotAuthorizedError":
140 return "Relay rejected the environment connection request.";
141 case "RelayEnvironmentEndpointUnavailableError":
142 return `Relay could not reach the environment endpoint (${error.reason}).`;
143 case "RelayEnvironmentEndpointTimedOutError":
144 return "Relay timed out while contacting the environment endpoint.";
145 case "RelayEnvironmentLinkFailedError":
146 return `Relay could not link the environment (${error.reason}).`;
147 case "RelayEnvironmentLinkUnavailableError":
148 return `Relay cannot provision the managed endpoint (${error.reason}).`;
149 case "RelayAgentActivityPublishProofExpiredError":
150 return "Relay rejected an expired agent activity publish proof.";
151 case "RelayAgentActivityPublishProofInvalidError":
152 return `Relay rejected the agent activity publish proof (${error.reason}).`;
153 case "RelayInternalError":
154 return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`;
155 }
156}
157
158function decodedRelayClientError(message: string) {
159 return (cause: unknown) => {
160 const relayError = findRelayProtectedError(cause);
161 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
162 return new CloudEnvironmentLinkError({
163 message: detail ? `${message}: ${detail}` : message,
164 cause,
165 });
166 };
167}
168
169function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null {
170 if (isRelayProtectedError(cause)) {
171 return cause;
172 }
173 if (typeof cause !== "object" || cause === null) {
174 return null;
175 }
176 return "cause" in cause ? findRelayProtectedError(cause.cause) : null;
177}
178
179function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null {
180 if (isEnvironmentCloudApiError(cause)) {
181 return cause;
182 }
183 if (typeof cause !== "object" || cause === null) {
184 return null;
185 }
186 return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
187}
188
189function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> {
190 const relayUrl = readRelayUrl();
191 return relayUrl
192 ? Effect.succeed(relayUrl)
193 : Effect.fail(new CloudEnvironmentLinkError({ message: "Relay URL is not configured." }));
194}
195
196function endpointOrigin(httpBaseUrl: string) {
197 const url = new URL(httpBaseUrl);
198 return {
199 localHttpHost: "127.0.0.1",
200 localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
201 };
202}
203
204function ensureLinkedEnvironmentMatches(input: {
205 readonly expectedEnvironmentId: string;
206 readonly expectedProviderKind: RelayManagedEndpointProviderKind;
207 readonly link: RelayEnvironmentLinkResponseType;
208}): Effect.Effect<void, CloudEnvironmentLinkError> {
209 if (input.link.environmentId !== input.expectedEnvironmentId) {
210 return new CloudEnvironmentLinkError({
211 message: "Relay returned credentials for a different environment.",
212 });
213 }
214 if (input.link.endpoint.providerKind !== input.expectedProviderKind) {
215 return new CloudEnvironmentLinkError({
216 message: "Relay returned credentials for a different endpoint provider.",
217 });
218 }
219 return Effect.void;
220}
221
222function endpointMatches(
223 left: RelayClientEnvironmentRecord["endpoint"],
224 right: RelayClientEnvironmentRecord["endpoint"],
225): boolean {
226 return (
227 left.httpBaseUrl === right.httpBaseUrl &&
228 left.wsBaseUrl === right.wsBaseUrl &&
229 left.providerKind === right.providerKind
230 );
231}
232
233function ensureStatusMatchesEnvironment(input: {
234 readonly environment: RelayClientEnvironmentRecord;
235 readonly status: RelayEnvironmentStatusResponseType;
236}): Effect.Effect<void, CloudEnvironmentLinkError> {
237 if (input.status.environmentId !== input.environment.environmentId) {
238 return new CloudEnvironmentLinkError({
239 message: "Relay returned status for a different environment.",
240 });
241 }
242 if (!endpointMatches(input.status.endpoint, input.environment.endpoint)) {
243 return new CloudEnvironmentLinkError({
244 message: "Relay returned status for a different endpoint.",
245 });
246 }
247 if (
248 input.status.descriptor &&
249 input.status.descriptor.environmentId !== input.environment.environmentId
250 ) {
251 return new CloudEnvironmentLinkError({
252 message: "Relay returned status descriptor for a different environment.",
253 });
254 }
255 return Effect.void;
256}
257
258function ensureConnectEndpointMatchesEnvironment(input: {
259 readonly environment: RelayClientEnvironmentRecord;
260 readonly connect: RelayEnvironmentConnectResponseType;
261}): Effect.Effect<void, CloudEnvironmentLinkError> {
262 if (!endpointMatches(input.connect.endpoint, input.environment.endpoint)) {
263 return new CloudEnvironmentLinkError({
264 message: "Relay returned credentials for a different endpoint.",
265 });
266 }
267 return Effect.void;
268}
269
270export function linkEnvironmentToCloud(input: {
271 readonly connection: SavedRemoteConnection;
272 readonly clerkToken: string;
273}): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> {
274 return Effect.gen(function* () {
275 if (!input.connection.bearerToken) {
276 return yield* new CloudEnvironmentLinkError({
277 message: "Only a locally paired bearer connection can be linked to the cloud.",
278 });
279 }
280 const localBearerToken = input.connection.bearerToken;
281 const relayUrl = yield* requireRelayUrl();
282 const relayClient = yield* ManagedRelayClient;
283 const deviceId = yield* Effect.tryPromise({
284 try: () => loadOrCreateAgentAwarenessDeviceId(),
285 catch: cloudEnvironmentLinkError("Could not load the mobile device id."),
286 });
287 const preferences = yield* Effect.tryPromise({
288 try: () => loadPreferences(),
289 catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."),
290 });
291 const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false;
292 const challenge = yield* relayClient
293 .createEnvironmentLinkChallenge({
294 clerkToken: input.clerkToken,
295 payload: {
296 notificationsEnabled: true,
297 liveActivitiesEnabled,
298 managedTunnelsEnabled: true,
299 },
300 })
301 .pipe(
302 Effect.mapError(
303 decodedRelayClientError(`${relayUrl}/v1/client/environment-link-challenges failed`),
304 ),
305 );
306 const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl);
307 const proof = yield* environmentClient.cloud
308 .linkProof({
309 headers: { authorization: `Bearer ${localBearerToken}` },
310 payload: {
311 challenge: challenge.challenge,
312 relayIssuer: relayUrl,
313 endpoint: {
314 httpBaseUrl: input.connection.httpBaseUrl,
315 wsBaseUrl: input.connection.wsBaseUrl,
316 providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
317 },
318 origin: endpointOrigin(input.connection.httpBaseUrl),
319 },
320 })
321 .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof.")));
322 const link = yield* relayClient
323 .linkEnvironment({
324 clerkToken: input.clerkToken,
325 payload: {
326 deviceId,
327 proof,
328 notificationsEnabled: true,
329 liveActivitiesEnabled,
330 managedTunnelsEnabled: true,
331 },
332 })
333 .pipe(
334 Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/client/environment-links failed`)),
335 );
336 yield* ensureLinkedEnvironmentMatches({
337 expectedEnvironmentId: input.connection.environmentId,
338 expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND,
339 link,
340 });
341
342 yield* environmentClient.cloud
343 .relayConfig({
344 headers: { authorization: `Bearer ${localBearerToken}` },
345 payload: {
346 relayUrl,
347 relayIssuer: link.relayIssuer,
348 cloudUserId: link.cloudUserId,
349 environmentCredential: link.environmentCredential,
350 cloudMintPublicKey: link.cloudMintPublicKey,
351 endpointRuntime: link.endpointRuntime,
352 },
353 })
354 .pipe(
355 Effect.mapError(cloudEnvironmentLinkError("Could not configure environment relay access.")),
356 );
357 });
358}
359
360export function listCloudEnvironments(input: {
361 readonly clerkToken: string;
362}): Effect.Effect<
363 ReadonlyArray<RelayClientEnvironmentRecord>,
364 CloudEnvironmentLinkError,
365 ManagedRelayClient
366> {
367 return Effect.gen(function* () {
368 const relayUrl = yield* requireRelayUrl();
369 const relayClient = yield* ManagedRelayClient;
370
371 return yield* relayClient
372 .listEnvironments({
373 clerkToken: input.clerkToken,
374 })
375 .pipe(Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/environments failed`)));
376 });
377}
378
379export function getCloudEnvironmentStatus(input: {
380 readonly clerkToken: string;
381 readonly environment: RelayClientEnvironmentRecord;
382 readonly relayScopes?: ReadonlyArray<RelayDpopAccessTokenScope>;
383}): Effect.Effect<
384 RelayEnvironmentStatusResponseType,
385 CloudEnvironmentLinkError,
386 ManagedRelayClient
387> {
388 return Effect.gen(function* () {
389 const relayUrl = yield* requireRelayUrl();
390 const relayClient = yield* ManagedRelayClient;
391 const status = yield* relayClient
392 .getEnvironmentStatus({
393 clerkToken: input.clerkToken,
394 scopes: input.relayScopes ?? [RelayEnvironmentStatusScope],
395 environmentId: input.environment.environmentId,
396 })
397 .pipe(
398 Effect.mapError(
399 decodedRelayClientError(
400 `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/status failed`,
401 ),
402 ),
403 );
404 yield* ensureStatusMatchesEnvironment({ environment: input.environment, status });
405 return status;
406 });
407}
408
409export function cloudEnvironmentsPendingStatus(
410 environments: ReadonlyArray<RelayClientEnvironmentRecord>,
411): ReadonlyArray<CloudEnvironmentRecordWithStatus> {
412 return environments.map((environment) => ({
413 environment,
414 status: null,
415 statusError: "Checking status...",
416 }));
417}
418
419export function loadCloudEnvironmentStatuses(input: {
420 readonly clerkToken: string;
421 readonly environments: ReadonlyArray<RelayClientEnvironmentRecord>;
422}): Effect.Effect<
423 ReadonlyArray<CloudEnvironmentRecordWithStatus>,
424 CloudEnvironmentLinkError,
425 ManagedRelayClient
426> {
427 return Effect.forEach(
428 input.environments,
429 (environment) =>
430 getCloudEnvironmentStatus({
431 clerkToken: input.clerkToken,
432 environment,
433 relayScopes: RELAY_STATUS_AND_CONNECT_SCOPES,
434 }).pipe(
435 Effect.match({
436 onFailure: (error) => ({
437 environment,
438 status: null,
439 statusError: error.message,
440 }),
441 onSuccess: (status) => ({
442 environment,
443 status,
444 statusError: null,
445 }),
446 }),
447 ),
448 { concurrency: "unbounded" },
449 );
450}
451
452export function listCloudEnvironmentsWithStatus(input: {
453 readonly clerkToken: string;
454}): Effect.Effect<
455 ReadonlyArray<CloudEnvironmentRecordWithStatus>,
456 CloudEnvironmentLinkError,
457 ManagedRelayClient
458> {
459 return Effect.gen(function* () {
460 const environments = yield* listCloudEnvironments(input);
461 return yield* loadCloudEnvironmentStatuses({
462 clerkToken: input.clerkToken,
463 environments,
464 });
465 });
466}
467
468export function connectCloudEnvironment(input: {
469 readonly clerkToken: string;
470 readonly environment: RelayClientEnvironmentRecord;
471}): Effect.Effect<
472 SavedRemoteConnection,
473 CloudEnvironmentLinkError,
474 HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner
475> {
476 return Effect.gen(function* () {
477 const relayUrl = yield* requireRelayUrl();
478 const relayClient = yield* ManagedRelayClient;
479
480 const deviceId = yield* Effect.tryPromise({
481 try: () => loadOrCreateAgentAwarenessDeviceId(),
482 catch: cloudEnvironmentLinkError("Could not load the mobile device id."),
483 });
484 const connect = yield* relayClient
485 .connectEnvironment({
486 clerkToken: input.clerkToken,
487 scopes: [RelayEnvironmentConnectScope],
488 environmentId: input.environment.environmentId,
489 deviceId,
490 })
491 .pipe(
492 Effect.mapError(
493 decodedRelayClientError(
494 `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/connect failed`,
495 ),
496 ),
497 );
498 if (connect.environmentId !== input.environment.environmentId) {
499 return yield* new CloudEnvironmentLinkError({
500 message: "Relay returned credentials for a different environment.",
501 });
502 }
503 yield* ensureConnectEndpointMatchesEnvironment({
504 environment: input.environment,
505 connect,
506 });
507
508 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
509 httpBaseUrl: connect.endpoint.httpBaseUrl,
510 }).pipe(
511 Effect.mapError(
512 cloudEnvironmentLinkError("Could not fetch the connected environment descriptor."),
513 ),
514 );
515 if (descriptor.environmentId !== connect.environmentId) {
516 return yield* new CloudEnvironmentLinkError({
517 message: "Connected endpoint descriptor does not match the selected environment.",
518 });
519 }
520 const signer = yield* ManagedRelayDpopSigner;
521 const bootstrapDpop = yield* signer
522 .createProof({
523 method: "POST",
524 url: new URL("/oauth/token", connect.endpoint.httpBaseUrl).toString(),
525 })
526 .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not create bootstrap DPoP proof.")));
527 const bootstrap = yield* exchangeRemoteDpopAccessToken({
528 httpBaseUrl: connect.endpoint.httpBaseUrl,
529 credential: connect.credential,
530 dpopProof: bootstrapDpop,
531 clientMetadata: mobileAuthClientMetadata(),
532 }).pipe(
533 Effect.mapError(
534 cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."),
535 ),
536 );
537 const pairingUrl = new URL(connect.endpoint.httpBaseUrl);
538 pairingUrl.hash = new URLSearchParams([["token", connect.credential]]).toString();
539
540 return {
541 environmentId: descriptor.environmentId,
542 environmentLabel: descriptor.label,
543 pairingUrl: stripPairingTokenFromUrl(pairingUrl).toString(),
544 displayUrl: connect.endpoint.httpBaseUrl,
545 httpBaseUrl: connect.endpoint.httpBaseUrl,
546 wsBaseUrl: connect.endpoint.wsBaseUrl,
547 bearerToken: null,
548 authenticationMethod: "dpop",
549 dpopAccessToken: bootstrap.access_token,
550 };
551 });
552}
apps/mobile/src/features/cloud/managedRelayLayer.ts+42−0
@@ -0,0 +1,42 @@
1import {
2 managedRelayClientLayer,
3 ManagedRelayDpopSigner,
4 ManagedRelayDpopSignerError,
5} from "@t3tools/client-runtime";
6import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10
11import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";
12
13const mobileRelayDpopSignerLayer = Layer.effect(
14 ManagedRelayDpopSigner,
15 Effect.gen(function* () {
16 const crypto = yield* Crypto.Crypto;
17 return ManagedRelayDpopSigner.of({
18 thumbprint: Effect.suspend(() =>
19 loadOrCreateDpopProofKeyPair().pipe(
20 Effect.provideService(Crypto.Crypto, crypto),
21 Effect.map((proofKey) => proofKey.thumbprint),
22 Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })),
23 ),
24 ),
25 createProof: (input) =>
26 Effect.gen(function* () {
27 const proofKey = yield* loadOrCreateDpopProofKeyPair().pipe(
28 Effect.provideService(Crypto.Crypto, crypto),
29 );
30 return yield* createDpopProof({ ...input, proofKey }).pipe(
31 Effect.provideService(Crypto.Crypto, crypto),
32 Effect.map((proof) => proof.proof),
33 );
34 }).pipe(Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause }))),
35 });
36 }),
37);
38
39export const mobileManagedRelayClientLayer = (relayUrl: string) =>
40 managedRelayClientLayer({ relayUrl, clientId: RelayMobileClientId }).pipe(
41 Layer.provideMerge(mobileRelayDpopSignerLayer),
42 );
apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts+134−0
@@ -0,0 +1,134 @@
1import { getClerkInstance } from "@clerk/expo";
2import { tokenCache } from "@clerk/expo/token-cache";
3import * as Data from "effect/Data";
4import { useCallback, useRef } from "react";
5import type { TurboModule } from "react-native";
6import { TurboModuleRegistry } from "react-native";
7
8const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt";
9
10interface NativeClerkModule extends TurboModule {
11 readonly getClientToken?: () => Promise<string | null>;
12 readonly presentAuth?: (options: {
13 readonly dismissable: boolean;
14 readonly mode: "signInOrUp";
15 }) => Promise<NativeAuthResult | null>;
16}
17
18interface NativeAuthResult {
19 readonly cancelled?: boolean;
20 readonly session?: {
21 readonly id?: string;
22 };
23 readonly sessionId?: string;
24}
25
26interface ClerkWithNativeSync {
27 readonly __internal_reloadInitialResources?: () => Promise<void>;
28 readonly setActive?: (params: { readonly session: string }) => Promise<void>;
29}
30
31const NativeClerk = TurboModuleRegistry.get<NativeClerkModule>("ClerkExpo");
32
33class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{
34 readonly message: string;
35 readonly cause?: unknown;
36}> {}
37
38async function syncNativeSession(sessionId: string): Promise<void> {
39 const getClientToken = NativeClerk?.getClientToken;
40 let nativeClientToken: string | null = null;
41 if (getClientToken) {
42 try {
43 nativeClientToken = await getClientToken();
44 } catch (cause) {
45 throw new NativeClerkAuthError({
46 message: "Could not read native Clerk client token.",
47 cause,
48 });
49 }
50 }
51 if (nativeClientToken) {
52 const saveToken = tokenCache?.saveToken;
53 if (saveToken) {
54 try {
55 await saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken);
56 } catch (cause) {
57 throw new NativeClerkAuthError({
58 message: "Could not save native Clerk client token.",
59 cause,
60 });
61 }
62 }
63 }
64
65 const clerk = getClerkInstance();
66 const clerkWithNativeSync = clerk as ClerkWithNativeSync;
67 const reloadInitialResources = clerkWithNativeSync.__internal_reloadInitialResources;
68 if (reloadInitialResources) {
69 try {
70 await reloadInitialResources();
71 } catch (cause) {
72 throw new NativeClerkAuthError({
73 message: "Could not reload Clerk resources after native auth.",
74 cause,
75 });
76 }
77 }
78 const setActive = clerkWithNativeSync.setActive;
79 if (setActive) {
80 try {
81 await setActive({ session: sessionId });
82 } catch (cause) {
83 throw new NativeClerkAuthError({
84 message: "Could not activate native Clerk session.",
85 cause,
86 });
87 }
88 }
89}
90
91export function useNativeClerkAuthModal() {
92 const presentingRef = useRef(false);
93
94 const presentAuth = useCallback(async (): Promise<void> => {
95 if (presentingRef.current || !NativeClerk?.presentAuth) {
96 return;
97 }
98
99 presentingRef.current = true;
100 const presentNativeAuth = NativeClerk.presentAuth;
101 try {
102 // Clerk's iOS AuthView is not inline. It presents this same native modal
103 // internally; call the presenter directly so Expo Router does not render
104 // an empty formSheet behind it.
105 let result: NativeAuthResult | null;
106 try {
107 result = await presentNativeAuth({
108 dismissable: true,
109 mode: "signInOrUp",
110 });
111 } catch (cause) {
112 throw new NativeClerkAuthError({
113 message: "Native Clerk auth presentation failed.",
114 cause,
115 });
116 }
117 const sessionId = result?.sessionId ?? result?.session?.id ?? null;
118 if (sessionId && !result?.cancelled) {
119 await syncNativeSession(sessionId);
120 }
121 } catch (error) {
122 if (__DEV__) {
123 console.error("[useNativeClerkAuthModal] presentAuth failed:", error);
124 }
125 } finally {
126 presentingRef.current = false;
127 }
128 }, []);
129
130 return {
131 isAvailable: !!NativeClerk?.presentAuth,
132 presentAuth,
133 };
134}
apps/mobile/src/lib/authClientMetadata.ts+10−0
@@ -0,0 +1,10 @@
1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
2import { Platform } from "react-native";
3
4export function mobileAuthClientMetadata(): AuthClientPresentationMetadata {
5 return {
6 label: "T3 Code Mobile",
7 deviceType: "mobile",
8 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
9 };
10}
apps/mobile/src/lib/connection.test.ts+1−1
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; · 2 unmodified lines
3import { mobileAuthClientMetadata, redactPairingCredential } from "./connection";3import { mobileAuthClientMetadata, redactPairingCredential } from "./connection";
4 4
5vi.mock("./runtime", () => ({5vi.mock("./runtime", () => ({
6 mobileRemoteHttpRuntime: {6 mobileRuntime: {
7 runPromise: vi.fn(),7 runPromise: vi.fn(),
8 },8 },
9}));9}));
apps/mobile/src/lib/connection.ts+24−24
@@ -1,11 +1,14 @@
1import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import {2import {
3 bootstrapRemoteBearerSession,3 bootstrapRemoteBearerSession,
4 fetchRemoteEnvironmentDescriptor,4 fetchRemoteEnvironmentDescriptor,
5} from "@t3tools/client-runtime";5} from "@t3tools/client-runtime";
6import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";6import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";
7import { Platform } from "react-native";7import * as Effect from "effect/Effect";
8import { mobileRemoteHttpRuntime } from "./runtime";8import { mobileAuthClientMetadata } from "./authClientMetadata";
9import { mobileRuntime } from "./runtime";
10
11export { mobileAuthClientMetadata } from "./authClientMetadata";
9 12
10export interface RemoteConnectionInput {13export interface RemoteConnectionInput {
11 readonly pairingUrl: string;14 readonly pairingUrl: string;
@@ -18,7 +21,9 @@ export interface SavedRemoteConnection { · 6 unmodified lines
18 readonly displayUrl: string;21 readonly displayUrl: string;
19 readonly httpBaseUrl: string;22 readonly httpBaseUrl: string;
20 readonly wsBaseUrl: string;23 readonly wsBaseUrl: string;
21 readonly bearerToken: string;24 readonly bearerToken: string | null;
25 readonly authenticationMethod?: "bearer" | "dpop";
26 readonly dpopAccessToken?: string;
22}27}
23 28
24export type RemoteClientConnectionState =29export type RemoteClientConnectionState =
@@ -37,14 +42,6 @@ export function redactPairingCredential(pairingUrl: string): string { · 12 unmodified lines
37 }42 }
38}43}
39 44
40export function mobileAuthClientMetadata(): AuthClientPresentationMetadata {
41 return {
42 label: "T3 Code Mobile",
43 deviceType: "mobile",
44 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
45 };
46}
47
48export async function bootstrapRemoteConnection(45export async function bootstrapRemoteConnection(
49 input: RemoteConnectionInput,46 input: RemoteConnectionInput,
50): Promise<SavedRemoteConnection> {47): Promise<SavedRemoteConnection> {
@@ -52,18 +49,20 @@ export async function bootstrapRemoteConnection( · 1 unmodified line
52 pairingUrl: input.pairingUrl,49 pairingUrl: input.pairingUrl,
53 });50 });
54 51
55 const descriptor = await mobileRemoteHttpRuntime.runPromise(52 const { descriptor, bootstrap } = await mobileRuntime.runPromise(
56 fetchRemoteEnvironmentDescriptor({53 Effect.all(
57 httpBaseUrl: target.httpBaseUrl,54 {
58 }),55 descriptor: fetchRemoteEnvironmentDescriptor({
59 );56 httpBaseUrl: target.httpBaseUrl,
60 57 }),
61 const bootstrap = await mobileRemoteHttpRuntime.runPromise(58 bootstrap: bootstrapRemoteBearerSession({
62 bootstrapRemoteBearerSession({59 httpBaseUrl: target.httpBaseUrl,
63 httpBaseUrl: target.httpBaseUrl,60 credential: target.credential,
64 credential: target.credential,61 clientMetadata: mobileAuthClientMetadata(),
65 clientMetadata: mobileAuthClientMetadata(),62 }),
66 }),63 },
64 { concurrency: "unbounded" },
65 ),
67 );66 );
68 67
69 return {68 return {
@@ -74,5 +73,6 @@ export async function bootstrapRemoteConnection( · 4 unmodified lines
74 httpBaseUrl: target.httpBaseUrl,73 httpBaseUrl: target.httpBaseUrl,
75 wsBaseUrl: target.wsBaseUrl,74 wsBaseUrl: target.wsBaseUrl,
76 bearerToken: bootstrap.access_token,75 bearerToken: bootstrap.access_token,
76 authenticationMethod: "bearer",
77 };77 };
78}78}
apps/mobile/src/lib/runtime.ts+22−1
@@ -1,5 +1,26 @@
1import Constants from "expo-constants";
2import * as Layer from "effect/Layer";
1import * as ManagedRuntime from "effect/ManagedRuntime";3import * as ManagedRuntime from "effect/ManagedRuntime";
2 4
3import { remoteHttpClientLayer } from "@t3tools/client-runtime";5import { remoteHttpClientLayer } from "@t3tools/client-runtime";
4 6
5export const mobileRemoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(fetch));7import { mobileCryptoLayer } from "../features/cloud/dpop";
8import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer";
9
10function configuredRelayUrl(): string {
11 const relay = Constants.expoConfig?.extra?.relay as { readonly url?: string | null } | undefined;
12 const value = relay?.url?.trim();
13 return value ? value.replace(/\/+$/g, "") : "http://relay.invalid";
14}
15
16const mobileHttpClientLayer = remoteHttpClientLayer(fetch);
17
18export const mobileRuntime = ManagedRuntime.make(
19 Layer.mergeAll(
20 mobileHttpClientLayer,
21 mobileCryptoLayer,
22 mobileManagedRelayClientLayer(configuredRelayUrl()).pipe(
23 Layer.provide(Layer.mergeAll(mobileHttpClientLayer, mobileCryptoLayer)),
24 ),
25 ),
26);
apps/mobile/src/lib/storage.ts+35−3
@@ -9,6 +9,7 @@ import type { SavedRemoteConnection } from "./connection"; · 8 unmodified lines
9 9
10const CONNECTIONS_KEY = "t3code.connections";10const CONNECTIONS_KEY = "t3code.connections";
11const PREFERENCES_KEY = "t3code.preferences";11const PREFERENCES_KEY = "t3code.preferences";
12const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
12const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;13const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
13const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";14const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
14 15
@@ -20,6 +21,7 @@ export interface CachedShellSnapshot { · 5 unmodified lines
20}21}
21 22
22export interface MobilePreferences {23export interface MobilePreferences {
24 readonly liveActivitiesEnabled?: boolean;
23 readonly terminalFontSize?: number;25 readonly terminalFontSize?: number;
24}26}
25 27
@@ -133,7 +135,12 @@ export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteC · 107 unmodified lines
133 135
134 return pipe(136 return pipe(
135 parsed.connections ?? [],137 parsed.connections ?? [],
136 Arr.filter((c) => !!c.environmentId && !!c.bearerToken?.trim()),138 Arr.filter(
139 (c) =>
140 !!c.environmentId &&
141 (!!c.bearerToken?.trim() ||
142 (c.authenticationMethod === "dpop" && !!c.dpopAccessToken?.trim())),
143 ),
137 );144 );
138}145}
139 146
@@ -164,11 +171,19 @@ export async function loadPreferences(): Promise<MobilePreferences> { · 24 unmodified lines
164 return {};171 return {};
165 }172 }
166 173
174 const preferences: {
175 liveActivitiesEnabled?: boolean;
176 terminalFontSize?: number;
177 } = {};
178
179 if (typeof parsed.liveActivitiesEnabled === "boolean") {
180 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
181 }
167 if (typeof parsed.terminalFontSize === "number") {182 if (typeof parsed.terminalFontSize === "number") {
168 return { terminalFontSize: parsed.terminalFontSize };183 preferences.terminalFontSize = parsed.terminalFontSize;
169 }184 }
170 185
171 return {};186 return preferences;
172}187}
173 188
174export async function savePreferencesPatch(189export async function savePreferencesPatch(
@@ -182,3 +197,20 @@ export async function savePreferencesPatch( · 7 unmodified lines
182 await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next));197 await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next));
183 return next;198 return next;
184}199}
200
201export async function loadOrCreateAgentAwarenessDeviceId(): Promise<string> {
202 const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY);
203 if (existing?.trim()) {
204 return existing;
205 }
206
207 const { uuidv4 } = await import("./uuid");
208 const deviceId = uuidv4();
209 await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId);
210 return deviceId;
211}
212
213export async function loadAgentAwarenessDeviceId(): Promise<string | null> {
214 const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY);
215 return existing?.trim() ? existing : null;
216}
apps/mobile/src/state/use-remote-environment-registry.test.ts+301−0
@@ -0,0 +1,301 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3import { ManagedRelayDpopSigner } from "@t3tools/client-runtime";
4import * as Effect from "effect/Effect";
5import { beforeEach, vi } from "vitest";
6
7const mocks = vi.hoisted(() => {
8 const environmentConnection = {
9 ensureBootstrapped: vi.fn(() => Promise.resolve()),
10 dispose: vi.fn(() => Promise.resolve()),
11 };
12 const sessionConnection = {
13 dispose: vi.fn(() => Promise.resolve()),
14 };
15 return {
16 environmentConnection,
17 sessionConnection,
18 createEnvironmentConnection: vi.fn(() => environmentConnection),
19 createKnownEnvironment: vi.fn((input: unknown) => input),
20 createWsRpcClient: vi.fn(() => ({ rpc: true })),
21 wsTransportConstructor: vi.fn(),
22 resolveRemoteWebSocketConnectionUrl: vi.fn(() => ({ _tag: "remote-ws-url-effect" })),
23 resolveRemoteDpopWebSocketConnectionUrl: vi.fn(),
24 remoteEndpointUrl: vi.fn((baseUrl: string, path: string) => new URL(path, baseUrl).toString()),
25 createDpopProof: vi.fn(),
26 bootstrapRemoteConnection: vi.fn(),
27 clearCachedShellSnapshot: vi.fn(() => Promise.resolve()),
28 clearSavedConnection: vi.fn(() => Promise.resolve()),
29 saveConnection: vi.fn(() => Promise.resolve()),
30 saveCachedShellSnapshot: vi.fn(() => Promise.resolve()),
31 mobileRunPromise: vi.fn((_effect?: unknown) =>
32 Promise.resolve("wss://desktop.example/ws?wsTicket=token"),
33 ),
34 removeEnvironmentSession: vi.fn(() => null),
35 setEnvironmentSession: vi.fn(),
36 notifyEnvironmentConnectionListeners: vi.fn(),
37 stopAgentAwarenessForEnvironment: vi.fn(),
38 startAgentAwarenessForEnvironment: vi.fn(),
39 shellSnapshotInvalidate: vi.fn(),
40 shellSnapshotMarkPending: vi.fn(),
41 environmentRuntimeInvalidate: vi.fn(),
42 environmentRuntimePatch: vi.fn(),
43 clearCachedShellSnapshotMetadata: vi.fn(),
44 invalidateSourceControlDiscoveryForEnvironment: vi.fn(),
45 terminalSessionInvalidateEnvironment: vi.fn(),
46 subscribeTerminalMetadata: vi.fn(() => vi.fn()),
47 terminalDebugLog: vi.fn(),
48 WsTransport: function WsTransport(...args: ReadonlyArray<unknown>) {
49 mocks.wsTransportConstructor(...args);
50 },
51 };
52});
53
54vi.mock("react-native", () => ({
55 Alert: {
56 alert: vi.fn(),
57 },
58}));
59
60vi.mock("@t3tools/client-runtime", async (importOriginal) => {
61 const actual = await importOriginal<typeof import("@t3tools/client-runtime")>();
62 return {
63 ...actual,
64 WsTransport: mocks.WsTransport,
65 createEnvironmentConnection: mocks.createEnvironmentConnection,
66 createKnownEnvironment: mocks.createKnownEnvironment,
67 createWsRpcClient: mocks.createWsRpcClient,
68 remoteEndpointUrl: mocks.remoteEndpointUrl,
69 resolveRemoteDpopWebSocketConnectionUrl: mocks.resolveRemoteDpopWebSocketConnectionUrl,
70 resolveRemoteWebSocketConnectionUrl: mocks.resolveRemoteWebSocketConnectionUrl,
71 };
72});
73
74vi.mock("../lib/connection", () => ({
75 bootstrapRemoteConnection: mocks.bootstrapRemoteConnection,
76}));
77
78vi.mock("../lib/storage", () => ({
79 clearCachedShellSnapshot: mocks.clearCachedShellSnapshot,
80 clearSavedConnection: mocks.clearSavedConnection,
81 loadCachedShellSnapshot: vi.fn(() => Promise.resolve(null)),
82 loadSavedConnections: vi.fn(() => Promise.resolve([])),
83 saveCachedShellSnapshot: mocks.saveCachedShellSnapshot,
84 saveConnection: mocks.saveConnection,
85}));
86
87vi.mock("../lib/runtime", () => ({
88 mobileRuntime: {
89 runPromise: mocks.mobileRunPromise,
90 },
91}));
92
93vi.mock("./environment-session-registry", () => ({
94 drainEnvironmentSessions: vi.fn(() => []),
95 notifyEnvironmentConnectionListeners: mocks.notifyEnvironmentConnectionListeners,
96 removeEnvironmentSession: mocks.removeEnvironmentSession,
97 setEnvironmentSession: mocks.setEnvironmentSession,
98}));
99
100vi.mock("../features/agent-awareness/shellLiveActivitySync", () => ({
101 startAgentAwarenessForEnvironment: mocks.startAgentAwarenessForEnvironment,
102 stopAgentAwarenessForEnvironment: mocks.stopAgentAwarenessForEnvironment,
103 stopAllAgentAwareness: vi.fn(),
104}));
105
106vi.mock("../features/terminal/terminalDebugLog", () => ({
107 terminalDebugLog: mocks.terminalDebugLog,
108}));
109
110vi.mock("./use-environment-runtime", () => ({
111 environmentRuntimeManager: {
112 invalidate: mocks.environmentRuntimeInvalidate,
113 patch: mocks.environmentRuntimePatch,
114 },
115 useEnvironmentRuntimeStates: vi.fn(() => ({})),
116}));
117
118vi.mock("./use-shell-snapshot", () => ({
119 clearCachedShellSnapshotMetadata: mocks.clearCachedShellSnapshotMetadata,
120 hydrateCachedShellSnapshot: vi.fn(),
121 markShellSnapshotLive: vi.fn(),
122 shellSnapshotManager: {
123 applyEvent: vi.fn(),
124 invalidate: mocks.shellSnapshotInvalidate,
125 markPending: mocks.shellSnapshotMarkPending,
126 syncSnapshot: vi.fn(),
127 },
128}));
129
130vi.mock("./use-source-control-discovery", () => ({
131 invalidateSourceControlDiscoveryForEnvironment:
132 mocks.invalidateSourceControlDiscoveryForEnvironment,
133 resetSourceControlDiscoveryState: vi.fn(),
134}));
135
136vi.mock("./use-terminal-session", () => ({
137 subscribeTerminalMetadata: mocks.subscribeTerminalMetadata,
138 terminalSessionManager: {
139 invalidate: vi.fn(),
140 invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment,
141 },
142}));
143
144import { connectSavedEnvironment, disconnectEnvironment } from "./use-remote-environment-registry";
145
146const environmentId = EnvironmentId.make("env-mobile-test");
147
148const connection = {
149 environmentId,
150 environmentLabel: "Mobile Test Desktop",
151 pairingUrl: "https://desktop.example/",
152 displayUrl: "https://desktop.example/",
153 httpBaseUrl: "https://desktop.example/",
154 wsBaseUrl: "wss://desktop.example/",
155 bearerToken: "remote-access-token",
156} as const;
157
158describe("mobile remote environment registry effects", () => {
159 beforeEach(() => {
160 vi.clearAllMocks();
161 mocks.createEnvironmentConnection.mockReturnValue(mocks.environmentConnection);
162 mocks.environmentConnection.ensureBootstrapped.mockResolvedValue(undefined);
163 mocks.environmentConnection.dispose.mockResolvedValue(undefined);
164 mocks.sessionConnection.dispose.mockResolvedValue(undefined);
165 mocks.removeEnvironmentSession.mockReturnValue(null);
166 mocks.mobileRunPromise.mockResolvedValue("wss://desktop.example/ws?wsTicket=token");
167 mocks.createDpopProof.mockReturnValue(Effect.succeed("dpop-proof"));
168 mocks.resolveRemoteDpopWebSocketConnectionUrl.mockReturnValue(
169 Effect.succeed("wss://desktop.example/ws?wsTicket=dpop-token"),
170 );
171 });
172
173 it.effect("connects a saved managed endpoint environment through Effect-wrapped APIs", () =>
174 Effect.gen(function* () {
175 yield* connectSavedEnvironment(connection);
176
177 expect(mocks.saveConnection).toHaveBeenCalledWith(connection);
178 expect(mocks.wsTransportConstructor).toHaveBeenCalledTimes(1);
179 expect(mocks.createEnvironmentConnection).toHaveBeenCalledTimes(1);
180 expect(mocks.setEnvironmentSession).toHaveBeenCalledWith(
181 connection.environmentId,
182 expect.objectContaining({
183 connection: mocks.environmentConnection,
184 }),
185 );
186 expect(mocks.subscribeTerminalMetadata).toHaveBeenCalledWith(
187 expect.objectContaining({ environmentId: connection.environmentId }),
188 );
189 expect(mocks.startAgentAwarenessForEnvironment).toHaveBeenCalledWith(connection);
190 expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1);
191 }),
192 );
193
194 it.effect("uses DPoP-bound admission for a managed DPoP connection", () =>
195 Effect.gen(function* () {
196 const dpopConnection = {
197 ...connection,
198 bearerToken: null,
199 authenticationMethod: "dpop",
200 dpopAccessToken: "environment-dpop-token",
201 } as const;
202 mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) =>
203 Effect.runPromise(
204 (effect as Effect.Effect<string, unknown, ManagedRelayDpopSigner>).pipe(
205 Effect.provideService(
206 ManagedRelayDpopSigner,
207 ManagedRelayDpopSigner.of({
208 thumbprint: Effect.succeed("mobile-key-thumbprint"),
209 createProof: mocks.createDpopProof,
210 }),
211 ),
212 ),
213 ),
214 );
215
216 yield* connectSavedEnvironment(dpopConnection);
217 const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as
218 | (() => Promise<string>)
219 | undefined;
220 expect(openSocket).toBeDefined();
221 yield* Effect.promise(() => openSocket!());
222
223 expect(mocks.createDpopProof).toHaveBeenCalledWith({
224 method: "POST",
225 url: "https://desktop.example/api/auth/websocket-ticket",
226 accessToken: "environment-dpop-token",
227 });
228 expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({
229 wsBaseUrl: dpopConnection.wsBaseUrl,
230 httpBaseUrl: dpopConnection.httpBaseUrl,
231 accessToken: "environment-dpop-token",
232 dpopProof: "dpop-proof",
233 });
234 expect(mocks.resolveRemoteWebSocketConnectionUrl).not.toHaveBeenCalled();
235 }),
236 );
237
238 it.effect("fails interactive connects when the managed endpoint bootstrap fails", () =>
239 Effect.gen(function* () {
240 mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce(
241 new Error("bootstrap failed"),
242 );
243 mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({
244 connection: mocks.sessionConnection,
245 } as never);
246
247 const result = yield* Effect.exit(connectSavedEnvironment(connection));
248
249 expect(result._tag).toBe("Failure");
250 expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith(
251 { environmentId: connection.environmentId },
252 expect.any(Function),
253 );
254 expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1);
255 expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled();
256 expect(mocks.startAgentAwarenessForEnvironment).not.toHaveBeenCalled();
257 }),
258 );
259
260 it.effect("can suppress bootstrap failures during best-effort startup reconnect", () =>
261 Effect.gen(function* () {
262 mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce(
263 new Error("bootstrap failed"),
264 );
265 mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({
266 connection: mocks.sessionConnection,
267 } as never);
268
269 yield* connectSavedEnvironment(connection, {
270 persist: false,
271 suppressBootstrapError: true,
272 });
273
274 expect(mocks.saveConnection).not.toHaveBeenCalled();
275 expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1);
276 expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1);
277 expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled();
278 expect(mocks.startAgentAwarenessForEnvironment).not.toHaveBeenCalled();
279 expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith(
280 { environmentId: connection.environmentId },
281 expect.any(Function),
282 );
283 }),
284 );
285
286 it.effect("disconnects and removes persisted managed endpoint state when requested", () =>
287 Effect.gen(function* () {
288 mocks.removeEnvironmentSession.mockReturnValue({
289 connection: mocks.sessionConnection,
290 } as never);
291
292 yield* disconnectEnvironment(connection.environmentId, { removeSaved: true });
293
294 expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1);
295 expect(mocks.stopAgentAwarenessForEnvironment).toHaveBeenCalledWith(connection.environmentId);
296 expect(mocks.clearSavedConnection).toHaveBeenCalledWith(connection.environmentId);
297 expect(mocks.clearCachedShellSnapshot).toHaveBeenCalledWith(connection.environmentId);
298 expect(mocks.clearCachedShellSnapshotMetadata).toHaveBeenCalledWith(connection.environmentId);
299 }),
300 );
301});
apps/mobile/src/state/use-remote-environment-registry.ts+273−208
@@ -9,11 +9,16 @@ import { · 8 unmodified lines
9 createKnownEnvironment,9 createKnownEnvironment,
10 createWsRpcClient,10 createWsRpcClient,
11 EnvironmentConnectionState,11 EnvironmentConnectionState,
12 ManagedRelayDpopSigner,
12 WsTransport,13 WsTransport,
14 remoteEndpointUrl,
15 resolveRemoteDpopWebSocketConnectionUrl,
13 resolveRemoteWebSocketConnectionUrl,16 resolveRemoteWebSocketConnectionUrl,
14} from "@t3tools/client-runtime";17} from "@t3tools/client-runtime";
15import type { EnvironmentId } from "@t3tools/contracts";18import type { EnvironmentId } from "@t3tools/contracts";
16import * as Arr from "effect/Array";19import * as Arr from "effect/Array";
20import * as Duration from "effect/Duration";
21import * as Effect from "effect/Effect";
17import * as Order from "effect/Order";22import * as Order from "effect/Order";
18import * as Option from "effect/Option";23import * as Option from "effect/Option";
19import { pipe } from "effect/Function";24import { pipe } from "effect/Function";
@@ -29,7 +34,7 @@ import { · 9 unmodified lines
29 saveConnection,34 saveConnection,
30} from "../lib/storage";35} from "../lib/storage";
31import { appAtomRegistry } from "./atom-registry";36import { appAtomRegistry } from "./atom-registry";
32import { mobileRemoteHttpRuntime } from "../lib/runtime";37import { mobileRuntime } from "../lib/runtime";
33import {38import {
34 drainEnvironmentSessions,39 drainEnvironmentSessions,
35 notifyEnvironmentConnectionListeners,40 notifyEnvironmentConnectionListeners,
@@ -41,6 +46,11 @@ import { · 5 unmodified lines
41 invalidateSourceControlDiscoveryForEnvironment,46 invalidateSourceControlDiscoveryForEnvironment,
42 resetSourceControlDiscoveryState,47 resetSourceControlDiscoveryState,
43} from "./use-source-control-discovery";48} from "./use-source-control-discovery";
49import {
50 startAgentAwarenessForEnvironment,
51 stopAgentAwarenessForEnvironment,
52 stopAllAgentAwareness,
53} from "../features/agent-awareness/shellLiveActivitySync";
44import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";54import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";
45import {55import {
46 clearCachedShellSnapshotMetadata,56 clearCachedShellSnapshotMetadata,
@@ -153,223 +163,265 @@ function setEnvironmentConnectionStatus( · 106 unmodified lines
153 }));163 }));
154}164}
155 165
156function withTimeout<T>(166function fromPromise<T>(tryPromise: () => Promise<T>): Effect.Effect<T, unknown> {
157 promise: Promise<T>,167 return Effect.tryPromise({
158 timeoutMs: number,168 try: tryPromise,
159 timeoutMessage: string,169 catch: (cause) => cause,
160): Promise<T> {
161 return new Promise<T>((resolve, reject) => {
162 const timeoutId = setTimeout(() => {
163 reject(new Error(timeoutMessage));
164 }, timeoutMs);
165
166 promise.then(
167 (value) => {
168 clearTimeout(timeoutId);
169 resolve(value);
170 },
171 (error: unknown) => {
172 clearTimeout(timeoutId);
173 reject(error);
174 },
175 );
176 });170 });
177}171}
178 172
179export async function disconnectEnvironment(173export function disconnectEnvironment(
180 environmentId: EnvironmentId,174 environmentId: EnvironmentId,
181 options?: {175 options?: {
182 readonly preserveShellSnapshot?: boolean;176 readonly preserveShellSnapshot?: boolean;
183 readonly removeSaved?: boolean;177 readonly removeSaved?: boolean;
184 readonly preserveConnectionAttempt?: boolean;178 readonly preserveConnectionAttempt?: boolean;
185 },179 },
186) {180): Effect.Effect<void, unknown> {
187 if (!options?.preserveConnectionAttempt) {181 return Effect.gen(function* () {
188 environmentConnectionAttempts.cancel(environmentId);182 if (!options?.preserveConnectionAttempt) {
189 }183 environmentConnectionAttempts.cancel(environmentId);
190 184 }
191 const session = removeEnvironmentSession(environmentId);
192 notifyEnvironmentConnectionListeners();
193 await session?.connection.dispose();
194 terminalMetadataUnsubscribers.get(environmentId)?.();
195 terminalMetadataUnsubscribers.delete(environmentId);
196 if (!options?.preserveShellSnapshot) {
197 shellSnapshotManager.invalidate({ environmentId });
198 }
199 invalidateSourceControlDiscoveryForEnvironment(environmentId);
200 terminalSessionManager.invalidateEnvironment(environmentId);
201 environmentRuntimeManager.invalidate({ environmentId });
202
203 if (options?.removeSaved) {
204 await clearSavedConnection(environmentId);
205 await clearCachedShellSnapshot(environmentId);
206 clearCachedShellSnapshotMetadata(environmentId);
207 removeSavedConnection(environmentId);
208 }
209}
210
211export async function connectSavedEnvironment(
212 connection: SavedRemoteConnection,
213 options?: { readonly persist?: boolean },
214) {
215 const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId);
216 const isCurrentAttempt = connectionAttempt.isCurrent;
217 185
218 await disconnectEnvironment(connection.environmentId, {186 const session = removeEnvironmentSession(environmentId);
219 preserveShellSnapshot: true,187 notifyEnvironmentConnectionListeners();
220 preserveConnectionAttempt: true,188 if (session) {
189 yield* fromPromise(() => session.connection.dispose());
190 }
191 terminalMetadataUnsubscribers.get(environmentId)?.();
192 terminalMetadataUnsubscribers.delete(environmentId);
193 stopAgentAwarenessForEnvironment(environmentId);
194 if (!options?.preserveShellSnapshot) {
195 shellSnapshotManager.invalidate({ environmentId });
196 }
197 invalidateSourceControlDiscoveryForEnvironment(environmentId);
198 terminalSessionManager.invalidateEnvironment(environmentId);
199 environmentRuntimeManager.invalidate({ environmentId });
200
201 if (options?.removeSaved) {
202 yield* Effect.all(
203 [
204 fromPromise(() => clearSavedConnection(environmentId)),
205 fromPromise(() => clearCachedShellSnapshot(environmentId)),
206 ],
207 { concurrency: 2 },
208 );
209 clearCachedShellSnapshotMetadata(environmentId);
210 removeSavedConnection(environmentId);
211 }
221 });212 });
222 if (!isCurrentAttempt()) {213}
223 return;
224 }
225 214
226 if (options?.persist !== false) {215export function connectSavedEnvironment(
227 await saveConnection(connection);216 connection: SavedRemoteConnection,
217 options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean },
218): Effect.Effect<void, unknown> {
219 return Effect.gen(function* () {
220 const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId);
221 const isCurrentAttempt = connectionAttempt.isCurrent;
222
223 yield* disconnectEnvironment(connection.environmentId, {
224 preserveShellSnapshot: true,
225 preserveConnectionAttempt: true,
226 });
228 if (!isCurrentAttempt()) {227 if (!isCurrentAttempt()) {
229 return;228 return;
230 }229 }
231 }
232 230
233 upsertSavedConnection(connection);231 if (options?.persist !== false) {
234 setEnvironmentConnectionStatus(connection.environmentId, "connecting", null);232 yield* fromPromise(() => saveConnection(connection));
235 shellSnapshotManager.markPending({ environmentId: connection.environmentId });233 if (!isCurrentAttempt()) {
234 return;
235 }
236 }
236 237
237 const transport = new WsTransport(238 upsertSavedConnection(connection);
238 () =>239 setEnvironmentConnectionStatus(connection.environmentId, "connecting", null);
239 mobileRemoteHttpRuntime.runPromise(240 shellSnapshotManager.markPending({ environmentId: connection.environmentId });
240 resolveRemoteWebSocketConnectionUrl({241
241 wsBaseUrl: connection.wsBaseUrl,242 const dpopAccessToken =
242 httpBaseUrl: connection.httpBaseUrl,243 connection.authenticationMethod === "dpop" ? connection.dpopAccessToken : undefined;
243 bearerToken: connection.bearerToken,244 const transport = new WsTransport(
244 }),245 () =>
245 ),246 mobileRuntime.runPromise(
246 {247 dpopAccessToken
247 onAttempt: () => {248 ? Effect.gen(function* () {
248 if (!isCurrentAttempt()) {249 const signer = yield* ManagedRelayDpopSigner;
249 return;250 const dpop = yield* signer.createProof({
250 }251 method: "POST",
252 url: remoteEndpointUrl(connection.httpBaseUrl, "/api/auth/websocket-ticket"),
253 accessToken: dpopAccessToken,
254 });
255 return yield* resolveRemoteDpopWebSocketConnectionUrl({
256 wsBaseUrl: connection.wsBaseUrl,
257 httpBaseUrl: connection.httpBaseUrl,
258 accessToken: dpopAccessToken,
259 dpopProof: dpop,
260 });
261 })
262 : resolveRemoteWebSocketConnectionUrl({
263 wsBaseUrl: connection.wsBaseUrl,
264 httpBaseUrl: connection.httpBaseUrl,
265 bearerToken: connection.bearerToken ?? "",
266 }),
267 ),
268 {
269 onAttempt: () => {
270 if (!isCurrentAttempt()) {
271 return;
272 }
273
274 environmentRuntimeManager.patch(
275 { environmentId: connection.environmentId },
276 (previous) => {
277 const nextState =
278 previous.connectionState === "ready" || previous.connectionState === "reconnecting"
279 ? "reconnecting"
280 : "connecting";
281 const keepSettledFailure =
282 previous.connectionState === "disconnected" && previous.connectionError !== null;
283 return {
284 ...previous,
285 connectionState: keepSettledFailure ? "disconnected" : nextState,
286 connectionError: keepSettledFailure ? previous.connectionError : null,
287 };
288 },
289 );
290 },
291 onError: (message) => {
292 if (isCurrentAttempt()) {
293 setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message);
294 }
295 },
296 onClose: (details) => {
297 if (!isCurrentAttempt()) {
298 return;
299 }
251 300
252 environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => {301 const reason =
253 const nextState =302 details.reason.trim().length > 0
254 previous.connectionState === "ready" || previous.connectionState === "reconnecting"303 ? details.reason
255 ? "reconnecting"304 : details.code === 1000
256 : "connecting";305 ? null
257 const keepSettledFailure =306 : `Remote connection closed (${details.code}).`;
258 previous.connectionState === "disconnected" && previous.connectionError !== null;307 setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason);
259 return {308 },
260 ...previous,
261 connectionState: keepSettledFailure ? "disconnected" : nextState,
262 connectionError: keepSettledFailure ? previous.connectionError : null,
263 };
264 });
265 },309 },
266 onError: (message) => {310 );
311
312 const client = createWsRpcClient(transport);
313 const environmentConnection = createEnvironmentConnection({
314 kind: "saved",
315 knownEnvironment: {
316 ...createKnownEnvironment({
317 id: connection.environmentId,
318 label: connection.environmentLabel,
319 source: "manual",
320 target: {
321 httpBaseUrl: connection.httpBaseUrl,
322 wsBaseUrl: connection.wsBaseUrl,
323 },
324 }),
325 environmentId: connection.environmentId,
326 },
327 client,
328 applyShellEvent: (event, environmentId) => {
267 if (isCurrentAttempt()) {329 if (isCurrentAttempt()) {
268 setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message);330 shellSnapshotManager.applyEvent({ environmentId }, event);
269 }331 }
270 },332 },
271 onClose: (details) => {333 syncShellSnapshot: (snapshot, environmentId) => {
272 if (!isCurrentAttempt()) {334 if (!isCurrentAttempt()) {
273 return;335 return;
274 }336 }
275 337
276 const reason =338 shellSnapshotManager.syncSnapshot({ environmentId }, snapshot);
277 details.reason.trim().length > 0339 markShellSnapshotLive(environmentId);
278 ? details.reason340 void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined);
279 : details.code === 1000341 environmentRuntimeManager.patch({ environmentId }, (runtime) => ({
280 ? null
281 : `Remote connection closed (${details.code}).`;
282 setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason);
283 },
284 },
285 );
286
287 const client = createWsRpcClient(transport);
288 const environmentConnection = createEnvironmentConnection({
289 kind: "saved",
290 knownEnvironment: {
291 ...createKnownEnvironment({
292 id: connection.environmentId,
293 label: connection.environmentLabel,
294 source: "manual",
295 target: {
296 httpBaseUrl: connection.httpBaseUrl,
297 wsBaseUrl: connection.wsBaseUrl,
298 },
299 }),
300 environmentId: connection.environmentId,
301 },
302 client,
303 applyShellEvent: (event, environmentId) => {
304 if (isCurrentAttempt()) {
305 shellSnapshotManager.applyEvent({ environmentId }, event);
306 }
307 },
308 syncShellSnapshot: (snapshot, environmentId) => {
309 if (!isCurrentAttempt()) {
310 return;
311 }
312
313 shellSnapshotManager.syncSnapshot({ environmentId }, snapshot);
314 markShellSnapshotLive(environmentId);
315 void saveCachedShellSnapshot(environmentId, snapshot);
316 environmentRuntimeManager.patch({ environmentId }, (runtime) => ({
317 ...runtime,
318 connectionState: "ready",
319 connectionError: null,
320 }));
321 },
322 onShellResubscribe: (environmentId) => {
323 if (isCurrentAttempt()) {
324 shellSnapshotManager.markPending({ environmentId });
325 }
326 },
327 onConfigSnapshot: (serverConfig) => {
328 if (isCurrentAttempt()) {
329 environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (runtime) => ({
330 ...runtime,342 ...runtime,
331 serverConfig,343 connectionState: "ready",
344 connectionError: null,
332 }));345 }));
333 }346 },
334 },347 onShellResubscribe: (environmentId) => {
335 });348 if (isCurrentAttempt()) {
349 shellSnapshotManager.markPending({ environmentId });
350 }
351 },
352 onConfigSnapshot: (serverConfig) => {
353 if (isCurrentAttempt()) {
354 environmentRuntimeManager.patch(
355 { environmentId: connection.environmentId },
356 (runtime) => ({
357 ...runtime,
358 serverConfig,
359 }),
360 );
361 }
362 },
363 });
336 364
337 if (!isCurrentAttempt()) {365 if (!isCurrentAttempt()) {
338 await environmentConnection.dispose();366 yield* fromPromise(() => environmentConnection.dispose());
339 return;367 return;
340 }368 }
341 369
342 setEnvironmentSession(connection.environmentId, {370 setEnvironmentSession(connection.environmentId, {
343 client,
344 connection: environmentConnection,
345 });
346 terminalMetadataUnsubscribers.set(
347 connection.environmentId,
348 subscribeTerminalMetadata({
349 environmentId: connection.environmentId,
350 client,371 client,
351 }),372 connection: environmentConnection,
352 );373 });
353 terminalDebugLog("registry:terminal-metadata-subscribed", {374
354 environmentId: connection.environmentId,375 const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe(
355 });376 Effect.timeoutOption(Duration.millis(SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS)),
356 notifyEnvironmentConnectionListeners();377 Effect.flatMap((result) =>
357 378 Option.match(result, {
358 try {379 onNone: () =>
359 await withTimeout(380 Effect.fail(new Error("Environment did not respond before the connection timeout.")),
360 environmentConnection.ensureBootstrapped(),381 onSome: Effect.succeed,
361 SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS,382 }),
362 "Environment did not respond before the connection timeout.",383 ),
384 Effect.tapError((error: unknown) =>
385 isCurrentAttempt()
386 ? Effect.gen(function* () {
387 setEnvironmentConnectionStatus(
388 connection.environmentId,
389 "disconnected",
390 error instanceof Error ? error.message : "Failed to bootstrap remote connection.",
391 );
392 const pendingSession = removeEnvironmentSession(connection.environmentId);
393 notifyEnvironmentConnectionListeners();
394 if (pendingSession) {
395 yield* fromPromise(() => pendingSession.connection.dispose());
396 }
397 })
398 : Effect.void,
399 ),
363 );400 );
364 } catch (error) {401 const bootstrapped = yield* options?.suppressBootstrapError
365 if (isCurrentAttempt()) {402 ? bootstrap.pipe(
366 setEnvironmentConnectionStatus(403 Effect.as(true),
367 connection.environmentId,404 Effect.catch(() => Effect.succeed(false)),
368 "disconnected",405 )
369 error instanceof Error ? error.message : "Failed to bootstrap remote connection.",406 : bootstrap.pipe(Effect.as(true));
370 );407
408 if (!bootstrapped || !isCurrentAttempt()) {
409 return;
371 }410 }
372 }411
412 terminalMetadataUnsubscribers.set(
413 connection.environmentId,
414 subscribeTerminalMetadata({
415 environmentId: connection.environmentId,
416 client,
417 }),
418 );
419 terminalDebugLog("registry:terminal-metadata-subscribed", {
420 environmentId: connection.environmentId,
421 });
422 startAgentAwarenessForEnvironment(connection);
423 notifyEnvironmentConnectionListeners();
424 });
373}425}
374 426
375const environmentsSortOrder = Order.mapInput(427const environmentsSortOrder = Order.mapInput(
@@ -404,8 +456,9 @@ export function useRemoteEnvironmentBootstrap() { · 28 unmodified lines
404 useEffect(() => {456 useEffect(() => {
405 let cancelled = false;457 let cancelled = false;
406 458
407 void loadSavedConnections()459 void (async () => {
408 .then((connections) => {460 try {
461 const connections = await loadSavedConnections();
409 if (cancelled) {462 if (cancelled) {
410 return;463 return;
411 }464 }
@@ -418,34 +471,36 @@ export function useRemoteEnvironmentBootstrap() { · 6 unmodified lines
418 471
419 setIsLoadingSavedConnection(false);472 setIsLoadingSavedConnection(false);
420 473
421 void (async () => {474 await Promise.all(
422 await Promise.all(475 connections.map(async (connection) => {
423 connections.map(async (connection) => {476 const cached = await loadCachedShellSnapshot(connection.environmentId);
424 const cached = await loadCachedShellSnapshot(connection.environmentId);477 if (!cancelled && cached) {
425 if (!cancelled && cached) {478 hydrateCachedShellSnapshot(cached);
426 hydrateCachedShellSnapshot(cached);479 }
427 }480 }),
428 }),481 );
429 );
430 482
431 if (cancelled) {483 if (cancelled) {
432 return;484 return;
433 }485 }
434 486
435 await Promise.all(487 await mobileRuntime.runPromise(
488 Effect.all(
436 connections.map((connection) =>489 connections.map((connection) =>
437 connectSavedEnvironment(connection, {490 connectSavedEnvironment(connection, {
438 persist: false,491 persist: false,
492 suppressBootstrapError: true,
439 }),493 }),
440 ),494 ),
441 );495 { concurrency: "unbounded" },
442 })();496 ),
443 })497 );
444 .catch(() => {498 } catch {
445 if (!cancelled) {499 if (!cancelled) {
446 setIsLoadingSavedConnection(false);500 setIsLoadingSavedConnection(false);
447 }501 }
448 });502 }
503 })();
449 504
450 return () => {505 return () => {
451 cancelled = true;506 cancelled = true;
@@ -457,6 +512,7 @@ export function useRemoteEnvironmentBootstrap() { · 5 unmodified lines
457 }512 }
458 terminalMetadataUnsubscribers.clear();513 terminalMetadataUnsubscribers.clear();
459 environmentConnectionAttempts.clear();514 environmentConnectionAttempts.clear();
515 stopAllAgentAwareness();
460 environmentRuntimeManager.invalidate();516 environmentRuntimeManager.invalidate();
461 shellSnapshotManager.invalidate();517 shellSnapshotManager.invalidate();
462 resetSourceControlDiscoveryState();518 resetSourceControlDiscoveryState();
@@ -538,7 +594,7 @@ export function useRemoteConnections() { · 75 unmodified lines
538 const nextPairingUrl = pairingUrl ?? connectionPairingUrl;594 const nextPairingUrl = pairingUrl ?? connectionPairingUrl;
539 const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl });595 const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl });
540 clearPendingConnectionError();596 clearPendingConnectionError();
541 await connectSavedEnvironment(connection);597 await mobileRuntime.runPromise(connectSavedEnvironment(connection));
542 clearConnectionPairingUrl();598 clearConnectionPairingUrl();
543 } catch (error) {599 } catch (error) {
544 setPendingConnectionError(600 setPendingConnectionError(
@@ -577,7 +633,14 @@ export function useRemoteConnections() { · 32 unmodified lines
577 if (!connection) {633 if (!connection) {
578 return;634 return;
579 }635 }
580 void connectSavedEnvironment(connection, { persist: false });636 void mobileRuntime
637 .runPromise(
638 connectSavedEnvironment(connection, {
639 persist: false,
640 suppressBootstrapError: true,
641 }),
642 )
643 .catch(() => undefined);
581 }, []);644 }, []);
582 645
583 const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => {646 const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => {
@@ -595,7 +658,9 @@ export function useRemoteConnections() { · 11 unmodified lines
595 text: "Remove",658 text: "Remove",
596 style: "destructive",659 style: "destructive",
597 onPress: () => {660 onPress: () => {
598 void disconnectEnvironment(environmentId, { removeSaved: true });661 void mobileRuntime
662 .runPromise(disconnectEnvironment(environmentId, { removeSaved: true }))
663 .catch(() => undefined);
599 },664 },
600 },665 },
601 ],666 ],
apps/mobile/src/widgets/AgentActivity.tsx+313−0
@@ -0,0 +1,313 @@
1import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui";
2import { font, foregroundStyle, lineLimit, padding } from "@expo/ui/swift-ui/modifiers";
3import {
4 createLiveActivity,
5 type LiveActivityComponent,
6 type LiveActivityLayout,
7} from "expo-widgets";
8import { formatAgentActivityUpdatedAtLabel } from "../features/agent-awareness/updatedAtLabel";
9
10type LiveActivityEnvironment = Parameters<LiveActivityComponent<AgentActivityProps>>[1];
11
12export type AgentActivityPhase =
13 | "starting"
14 | "running"
15 | "waiting_for_approval"
16 | "waiting_for_input"
17 | "completed"
18 | "failed"
19 | "stale";
20
21export interface AgentActivityRowProps {
22 readonly environmentId: string;
23 readonly threadId: string;
24 readonly projectTitle: string;
25 readonly threadTitle: string;
26 readonly modelTitle: string;
27 readonly phase: AgentActivityPhase;
28 readonly status: string;
29 readonly updatedAt: string;
30 readonly deepLink: string;
31}
32
33export interface AgentActivityProps {
34 readonly title: string;
35 readonly subtitle: string;
36 readonly activeCount: number;
37 readonly updatedAt: string;
38 readonly activities: ReadonlyArray<AgentActivityRowProps>;
39}
40
41function AgentActivity(
42 props: AgentActivityProps,
43 environment: LiveActivityEnvironment,
44): LiveActivityLayout {
45 "widget";
46
47 const row0 = props.activities[0];
48 const row1 = props.activities[1];
49 const row2 = props.activities[2];
50 const updatedAt = formatAgentActivityUpdatedAtLabel(props.updatedAt);
51 const activeLabel = `${props.activeCount} active`;
52 const isLight = environment.colorScheme === "light";
53 const primaryForeground = isLight ? "#0f172a" : "#ffffff";
54 const secondaryForeground = isLight ? "#475569" : "#cbd5e1";
55 const mutedForeground = isLight ? "#64748b" : "#94a3b8";
56 const tint = environment.isLuminanceReduced
57 ? secondaryForeground
58 : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input"
59 ? "#f97316"
60 : row0?.phase === "failed"
61 ? "#ef4444"
62 : "#14b8a6";
63
64 return {
65 banner: (
66 <VStack modifiers={[padding({ all: 14 })]}>
67 <HStack>
68 <VStack>
69 <Text
70 modifiers={[font({ weight: "bold", size: 15 }), foregroundStyle(primaryForeground)]}
71 >
72 {props.title}
73 </Text>
74 <Text
75 modifiers={[font({ size: 12 }), foregroundStyle(secondaryForeground), lineLimit(1)]}
76 >
77 {props.subtitle}
78 </Text>
79 </VStack>
80 <Spacer minLength={8} />
81 <Text modifiers={[font({ weight: "semibold", size: 12 }), foregroundStyle(tint)]}>
82 {activeLabel}
83 </Text>
84 </HStack>
85 {row0 ? (
86 <HStack modifiers={[padding({ vertical: 4 })]}>
87 <VStack>
88 <Text
89 modifiers={[
90 font({ weight: "bold", size: 13 }),
91 foregroundStyle(primaryForeground),
92 lineLimit(1),
93 ]}
94 >
95 {row0.threadTitle}
96 </Text>
97 <Text
98 modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}
99 >
100 {row0.projectTitle} - {row0.modelTitle}
101 </Text>
102 </VStack>
103 <Spacer minLength={8} />
104 <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}>
105 {row0.status}
106 </Text>
107 </HStack>
108 ) : null}
109 {row1 ? (
110 <HStack modifiers={[padding({ vertical: 4 })]}>
111 <VStack>
112 <Text
113 modifiers={[
114 font({ weight: "bold", size: 13 }),
115 foregroundStyle(primaryForeground),
116 lineLimit(1),
117 ]}
118 >
119 {row1.threadTitle}
120 </Text>
121 <Text
122 modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}
123 >
124 {row1.projectTitle} - {row1.modelTitle}
125 </Text>
126 </VStack>
127 <Spacer minLength={8} />
128 <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}>
129 {row1.status}
130 </Text>
131 </HStack>
132 ) : null}
133 {row2 ? (
134 <HStack modifiers={[padding({ vertical: 4 })]}>
135 <VStack>
136 <Text
137 modifiers={[
138 font({ weight: "bold", size: 13 }),
139 foregroundStyle(primaryForeground),
140 lineLimit(1),
141 ]}
142 >
143 {row2.threadTitle}
144 </Text>
145 <Text
146 modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}
147 >
148 {row2.projectTitle} - {row2.modelTitle}
149 </Text>
150 </VStack>
151 <Spacer minLength={8} />
152 <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}>
153 {row2.status}
154 </Text>
155 </HStack>
156 ) : null}
157 <Text modifiers={[font({ size: 11 }), foregroundStyle(mutedForeground)]}>
158 Updated {updatedAt}
159 </Text>
160 </VStack>
161 ),
162 bannerSmall: (
163 <VStack modifiers={[padding({ all: 12 })]}>
164 <HStack>
165 <Text
166 modifiers={[font({ weight: "bold", size: 13 }), foregroundStyle(primaryForeground)]}
167 >
168 {props.title}
169 </Text>
170 <Spacer minLength={6} />
171 <Text modifiers={[font({ weight: "semibold", size: 12 }), foregroundStyle(tint)]}>
172 {activeLabel}
173 </Text>
174 </HStack>
175 {row0 ? (
176 <VStack>
177 <Text
178 modifiers={[
179 font({ weight: "bold", size: 12 }),
180 foregroundStyle(primaryForeground),
181 lineLimit(1),
182 ]}
183 >
184 {row0.threadTitle}
185 </Text>
186 <Text
187 modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}
188 >
189 {row0.projectTitle} - {row0.status}
190 </Text>
191 </VStack>
192 ) : null}
193 </VStack>
194 ),
195 compactLeading: (
196 <Text modifiers={[font({ weight: "bold", size: 11 }), foregroundStyle(tint)]}>T3</Text>
197 ),
198 compactTrailing: (
199 <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}>
200 {activeLabel}
201 </Text>
202 ),
203 minimal: (
204 <Text modifiers={[font({ weight: "bold", size: 11 }), foregroundStyle(tint)]}>T3</Text>
205 ),
206 expandedLeading: (
207 <VStack modifiers={[padding({ all: 8 })]}>
208 <Text modifiers={[font({ weight: "bold", size: 12 }), foregroundStyle(tint)]}>
209 {activeLabel}
210 </Text>
211 </VStack>
212 ),
213 expandedCenter: row0 ? (
214 <VStack>
215 <Text
216 modifiers={[
217 font({ weight: "bold", size: 12 }),
218 foregroundStyle(primaryForeground),
219 lineLimit(1),
220 ]}
221 >
222 {row0.threadTitle}
223 </Text>
224 <Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}>
225 {row0.projectTitle} - {row0.status}
226 </Text>
227 </VStack>
228 ) : null,
229 expandedTrailing: (
230 <Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground)]}>
231 Updated {updatedAt}
232 </Text>
233 ),
234 expandedBottom: (
235 <VStack modifiers={[padding({ all: 8 })]}>
236 {row0 ? (
237 <HStack modifiers={[padding({ vertical: 4 })]}>
238 <VStack>
239 <Text
240 modifiers={[
241 font({ weight: "bold", size: 13 }),
242 foregroundStyle(primaryForeground),
243 lineLimit(1),
244 ]}
245 >
246 {row0.threadTitle}
247 </Text>
248 <Text
249 modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}
250 >
251 {row0.projectTitle} - {row0.modelTitle}
252 </Text>
253 </VStack>
254 <Spacer minLength={8} />
255 <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}>
256 {row0.status}
257 </Text>
258 </HStack>
259 ) : null}
260 {row1 ? (
261 <HStack modifiers={[padding({ vertical: 4 })]}>
262 <VStack>
263 <Text
264 modifiers={[
265 font({ weight: "bold", size: 13 }),
266 foregroundStyle(primaryForeground),
267 lineLimit(1),
268 ]}
269 >
270 {row1.threadTitle}
271 </Text>
272 <Text
273 modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}
274 >
275 {row1.projectTitle} - {row1.modelTitle}
276 </Text>
277 </VStack>
278 <Spacer minLength={8} />
279 <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}>
280 {row1.status}
281 </Text>
282 </HStack>
283 ) : null}
284 {row2 ? (
285 <HStack modifiers={[padding({ vertical: 4 })]}>
286 <VStack>
287 <Text
288 modifiers={[
289 font({ weight: "bold", size: 13 }),
290 foregroundStyle(primaryForeground),
291 lineLimit(1),
292 ]}
293 >
294 {row2.threadTitle}
295 </Text>
296 <Text
297 modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}
298 >
299 {row2.projectTitle} - {row2.modelTitle}
300 </Text>
301 </VStack>
302 <Spacer minLength={8} />
303 <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}>
304 {row2.status}
305 </Text>
306 </HStack>
307 ) : null}
308 </VStack>
309 ),
310 };
311}
312
313export default createLiveActivity<AgentActivityProps>("AgentActivity", AgentActivity);
apps/server/integration/OrchestrationEngineHarness.integration.ts+7−0
@@ -77,6 +77,7 @@ import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; · 76 unmodified lines
77import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";77import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
78import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";78import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";
79import * as VcsProcess from "../src/vcs/VcsProcess.ts";79import * as VcsProcess from "../src/vcs/VcsProcess.ts";
80import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts";
80 81
81const decodeCodexSettings = Schema.decodeEffect(CodexSettings);82const decodeCodexSettings = Schema.decodeEffect(CodexSettings);
82 83
@@ -364,6 +365,12 @@ export const makeOrchestrationIntegrationHarness = ( · 281 unmodified lines
364 drain: Effect.void,365 drain: Effect.void,
365 }),366 }),
366 ),367 ),
368 Layer.provideMerge(
369 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
370 publishThread: () => Effect.void,
371 start: () => Effect.void,
372 }),
373 ),
367 );374 );
368 const layer = Layer.empty.pipe(375 const layer = Layer.empty.pipe(
369 Layer.provideMerge(runtimeServicesLayer),376 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/auth/EnvironmentAuth.ts+65−6
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
19} from "@t3tools/contracts";19} from "@t3tools/contracts";
20import { encodeOAuthScope } from "@t3tools/shared/oauthScope";20import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
21import * as Context from "effect/Context";21import * as Context from "effect/Context";
22import * as Crypto from "effect/Crypto";
22import * as Data from "effect/Data";23import * as Data from "effect/Data";
23import * as DateTime from "effect/DateTime";24import * as DateTime from "effect/DateTime";
24import * as Duration from "effect/Duration";25import * as Duration from "effect/Duration";
@@ -31,6 +32,7 @@ import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; · 6 unmodified lines
31import * as PairingGrantStore from "./PairingGrantStore.ts";32import * as PairingGrantStore from "./PairingGrantStore.ts";
32import * as ServerSecretStore from "./ServerSecretStore.ts";33import * as ServerSecretStore from "./ServerSecretStore.ts";
33import * as SessionStore from "./SessionStore.ts";34import * as SessionStore from "./SessionStore.ts";
35import { verifyRequestDpopProof } from "./dpop.ts";
34import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";36import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
35 37
36export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";38export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
@@ -61,6 +63,7 @@ export interface AuthenticatedSession { · 24 unmodified lines
61 readonly subject: string;63 readonly subject: string;
62 readonly method: ServerAuthSessionMethod;64 readonly method: ServerAuthSessionMethod;
63 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;65 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
66 readonly proofKeyThumbprint?: string;
64 readonly expiresAt?: DateTime.DateTime;67 readonly expiresAt?: DateTime.DateTime;
65}68}
66 69
@@ -107,6 +110,9 @@ export interface EnvironmentAuthShape { · 40 unmodified lines
107 credential: string,110 credential: string,
108 requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,111 requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,
109 requestMetadata: AuthClientMetadata,112 requestMetadata: AuthClientMetadata,
113 input?: {
114 readonly proofKeyThumbprint?: string;
115 },
110 ) => Effect.Effect<116 ) => Effect.Effect<
111 AuthAccessTokenResult,117 AuthAccessTokenResult,
112 ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError118 ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError
@@ -116,6 +122,7 @@ export interface EnvironmentAuthShape { · 3 unmodified lines
116 readonly label?: string;122 readonly label?: string;
117 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;123 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
118 readonly subject?: string;124 readonly subject?: string;
125 readonly proofKeyThumbprint?: string;
119 }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;126 }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
120 readonly issuePairingCredential: (127 readonly issuePairingCredential: (
121 input?: AuthCreatePairingCredentialInput,128 input?: AuthCreatePairingCredentialInput,
@@ -184,6 +191,7 @@ type BootstrapExchangeResult = { · 62 unmodified lines
184};191};
185 192
186const AUTHORIZATION_PREFIX = "Bearer ";193const AUTHORIZATION_PREFIX = "Bearer ";
194const DPOP_AUTHORIZATION_PREFIX = "DPoP ";
187const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket";195const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket";
188 196
189const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {197const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
@@ -245,10 +253,21 @@ function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string · 55 unmodified lines
245 return token.length > 0 ? token : null;253 return token.length > 0 ? token : null;
246}254}
247 255
256function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | null {
257 const header = request.headers["authorization"];
258 if (typeof header !== "string" || !header.startsWith(DPOP_AUTHORIZATION_PREFIX)) {
259 return null;
260 }
261 const token = header.slice(DPOP_AUTHORIZATION_PREFIX.length).trim();
262 return token.length > 0 ? token : null;
263}
264
248export const make = Effect.fn("makeEnvironmentAuth")(function* () {265export const make = Effect.fn("makeEnvironmentAuth")(function* () {
249 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;266 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
250 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;267 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
251 const sessions = yield* SessionStore.SessionStore;268 const sessions = yield* SessionStore.SessionStore;
269 const secretStore = yield* ServerSecretStore.ServerSecretStore;
270 const crypto = yield* Crypto.Crypto;
252 const descriptor = yield* policy.getDescriptor();271 const descriptor = yield* policy.getDescriptor();
253 272
254 const authenticateToken = (273 const authenticateToken = (
@@ -270,6 +289,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 15 unmodified lines
270 subject: session.subject,289 subject: session.subject,
271 method: session.method,290 method: session.method,
272 scopes: session.scopes,291 scopes: session.scopes,
292 ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}),
273 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),293 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
274 })),294 })),
275 mapSessionVerificationErrors,295 mapSessionVerificationErrors,
@@ -278,11 +298,43 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 2 unmodified lines
278 const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {298 const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
279 const cookieToken = request.cookies[sessions.cookieName];299 const cookieToken = request.cookies[sessions.cookieName];
280 const bearerToken = parseBearerToken(request);300 const bearerToken = parseBearerToken(request);
281 const credential = cookieToken ?? bearerToken;301 const dpopToken = parseDpopToken(request);
302 const credential = cookieToken ?? bearerToken ?? dpopToken;
282 if (!credential) {303 if (!credential) {
283 return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" }));304 return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" }));
284 }305 }
285 return authenticateToken(credential);306 return authenticateToken(credential).pipe(
307 Effect.flatMap((session) => {
308 if (session.proofKeyThumbprint) {
309 if (!dpopToken || dpopToken !== credential) {
310 return Effect.fail(
311 new ServerAuthInvalidCredentialError({
312 reason: "invalid_credential",
313 cause: "DPoP-bound access token requires DPoP authorization.",
314 }),
315 );
316 }
317 return verifyRequestDpopProof({
318 request,
319 expectedThumbprint: session.proofKeyThumbprint,
320 expectedAccessToken: dpopToken,
321 }).pipe(
322 Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore),
323 Effect.provideService(Crypto.Crypto, crypto),
324 Effect.as(session),
325 );
326 }
327 if (dpopToken) {
328 return Effect.fail(
329 new ServerAuthInvalidCredentialError({
330 reason: "invalid_credential",
331 cause: "DPoP authorization requires a proof-bound access token.",
332 }),
333 );
334 }
335 return Effect.succeed(session);
336 }),
337 );
286 };338 };
287 339
288 const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) =>340 const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) =>
@@ -349,8 +401,8 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 60 unmodified lines
349 );401 );
350 402
351 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] =403 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] =
352 (credential, requestedScopes, requestMetadata) =>404 (credential, requestedScopes, requestMetadata, input) =>
353 bootstrapCredentials.consume(credential).pipe(405 bootstrapCredentials.consume(credential, input).pipe(
354 Effect.mapError(toBootstrapExchangeError),406 Effect.mapError(toBootstrapExchangeError),
355 Effect.flatMap((grant) =>407 Effect.flatMap((grant) =>
356 Effect.gen(function* () {408 Effect.gen(function* () {
@@ -362,9 +414,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 5 unmodified lines
362 }414 }
363 return yield* sessions415 return yield* sessions
364 .issue({416 .issue({
365 method: "bearer-access-token",417 method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",
366 subject: grant.subject,418 subject: grant.subject,
367 scopes: grantedScopes,419 scopes: grantedScopes,
420 ...(input?.proofKeyThumbprint
421 ? {
422 proofKeyThumbprint: input.proofKeyThumbprint,
423 ttl: Duration.hours(1),
424 }
425 : {}),
368 client: {426 client: {
369 ...requestMetadata,427 ...requestMetadata,
370 ...(grant.label ? { label: grant.label } : {}),428 ...(grant.label ? { label: grant.label } : {}),
@@ -388,7 +446,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 17 unmodified lines
388 ({446 ({
389 access_token: session.token,447 access_token: session.token,
390 issued_token_type: AuthAccessTokenType,448 issued_token_type: AuthAccessTokenType,
391 token_type: "Bearer",449 token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer",
392 expires_in: Math.max(450 expires_in: Math.max(
393 0,451 0,
394 Math.floor(452 Math.floor(
@@ -434,6 +492,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 39 unmodified lines
434 subject: input?.subject ?? "one-time-token",492 subject: input?.subject ?? "one-time-token",
435 ...(input?.ttl ? { ttl: input.ttl } : {}),493 ...(input?.ttl ? { ttl: input.ttl } : {}),
436 ...(input?.label ? { label: input.label } : {}),494 ...(input?.label ? { label: input.label } : {}),
495 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
437 });496 });
438 return {497 return {
439 id: issued.id,498 id: issued.id,
apps/server/src/auth/EnvironmentAuthPolicy.ts+1−1
@@ -39,7 +39,7 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { · 38 unmodified lines
39 const descriptor: ServerAuthDescriptor = {39 const descriptor: ServerAuthDescriptor = {
40 policy,40 policy,
41 bootstrapMethods,41 bootstrapMethods,
42 sessionMethods: ["browser-session-cookie", "bearer-access-token"],42 sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"],
43 sessionCookieName: resolveSessionCookieName({43 sessionCookieName: resolveSessionCookieName({
44 mode: config.mode,44 mode: config.mode,
45 port: config.port,45 port: config.port,
apps/server/src/auth/PairingGrantStore.test.ts+23−0
@@ -92,6 +92,29 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { · 91 unmodified lines
92 }).pipe(Effect.provide(makePairingGrantStoreLayer())),92 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
93 );93 );
94 94
95 it.effect("requires the bound proof key thumbprint when present", () =>
96 Effect.gen(function* () {
97 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
98 const token = yield* bootstrapCredentials.issueOneTimeToken({
99 proofKeyThumbprint: "client-proof-key-thumbprint",
100 });
101
102 const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential));
103 const wrong = yield* Effect.flip(
104 bootstrapCredentials.consume(token.credential, {
105 proofKeyThumbprint: "other-proof-key-thumbprint",
106 }),
107 );
108 const consumed = yield* bootstrapCredentials.consume(token.credential, {
109 proofKeyThumbprint: "client-proof-key-thumbprint",
110 });
111
112 expect(missing.message).toContain("proof key mismatch");
113 expect(wrong.message).toContain("proof key mismatch");
114 expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint");
115 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
116 );
117
95 it.effect("seeds the desktop bootstrap credential as a one-time grant", () =>118 it.effect("seeds the desktop bootstrap credential as a one-time grant", () =>
96 Effect.gen(function* () {119 Effect.gen(function* () {
97 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;120 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
apps/server/src/auth/PairingGrantStore.ts+37−2
@@ -26,6 +26,7 @@ export interface BootstrapGrant { · 25 unmodified lines
26 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;26 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
27 readonly subject: string;27 readonly subject: string;
28 readonly label?: string;28 readonly label?: string;
29 readonly proofKeyThumbprint?: string;
29 readonly expiresAt: DateTime.DateTime;30 readonly expiresAt: DateTime.DateTime;
30}31}
31 32
@@ -50,6 +51,7 @@ export interface IssuedBootstrapCredential { · 18 unmodified lines
50 readonly id: string;51 readonly id: string;
51 readonly credential: string;52 readonly credential: string;
52 readonly label?: string;53 readonly label?: string;
54 readonly proofKeyThumbprint?: string;
53 readonly expiresAt: DateTime.Utc;55 readonly expiresAt: DateTime.Utc;
54}56}
55 57
@@ -69,6 +71,7 @@ export interface PairingGrantStoreShape { · 13 unmodified lines
69 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;71 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
70 readonly subject?: string;72 readonly subject?: string;
71 readonly label?: string;73 readonly label?: string;
74 readonly proofKeyThumbprint?: string;
72 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;75 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
73 readonly listActive: () => Effect.Effect<76 readonly listActive: () => Effect.Effect<
74 ReadonlyArray<AuthPairingLink>,77 ReadonlyArray<AuthPairingLink>,
@@ -76,7 +79,12 @@ export interface PairingGrantStoreShape { · 1 unmodified line
76 >;79 >;
77 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;80 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
78 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;81 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;
79 readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;82 readonly consume: (
83 credential: string,
84 input?: {
85 readonly proofKeyThumbprint?: string;
86 },
87 ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
80}88}
81 89
82export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()(90export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()(
@@ -232,6 +240,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 149 unmodified lines
232 id,240 id,
233 credential,241 credential,
234 ...(input?.label ? { label: input.label } : {}),242 ...(input?.label ? { label: input.label } : {}),
243 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
235 expiresAt,244 expiresAt,
236 };245 };
237 yield* pairingLinks.create({246 yield* pairingLinks.create({
@@ -241,6 +250,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 3 unmodified lines
241 scopes: input?.scopes ?? AuthStandardClientScopes,250 scopes: input?.scopes ?? AuthStandardClientScopes,
242 subject: input?.subject ?? "one-time-token",251 subject: input?.subject ?? "one-time-token",
243 label: input?.label ?? null,252 label: input?.label ?? null,
253 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
244 createdAt: now,254 createdAt: now,
245 expiresAt: expiresAt,255 expiresAt: expiresAt,
246 });256 });
@@ -259,7 +269,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 12 unmodified lines
259 );269 );
260 270
261 const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")(271 const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")(
262 function* (credential) {272 function* (credential, input) {
263 const now = yield* DateTime.now;273 const now = yield* DateTime.now;
264 const seededResult: ConsumeResult = yield* Ref.modify(274 const seededResult: ConsumeResult = yield* Ref.modify(
265 seededGrantsRef,275 seededGrantsRef,
@@ -289,6 +299,17 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 23 unmodified lines
289 ];299 ];
290 }300 }
291 301
302 if (grant.proofKeyThumbprint && grant.proofKeyThumbprint !== input?.proofKeyThumbprint) {
303 return [
304 {
305 _tag: "error",
306 reason: "not-found",
307 error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."),
308 },
309 next,
310 ];
311 }
312
292 const remainingUses = grant.remainingUses;313 const remainingUses = grant.remainingUses;
293 if (typeof remainingUses === "number") {314 if (typeof remainingUses === "number") {
294 if (remainingUses <= 1) {315 if (remainingUses <= 1) {
@@ -309,6 +330,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 14 unmodified lines
309 scopes: grant.scopes,330 scopes: grant.scopes,
310 subject: grant.subject,331 subject: grant.subject,
311 ...(grant.label ? { label: grant.label } : {}),332 ...(grant.label ? { label: grant.label } : {}),
333 ...(grant.proofKeyThumbprint
334 ? { proofKeyThumbprint: grant.proofKeyThumbprint }
335 : {}),
312 expiresAt: grant.expiresAt,336 expiresAt: grant.expiresAt,
313 } satisfies BootstrapGrant,337 } satisfies BootstrapGrant,
314 },338 },
@@ -326,6 +350,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 11 unmodified lines
326 350
327 const consumed = yield* pairingLinks.consumeAvailable({351 const consumed = yield* pairingLinks.consumeAvailable({
328 credential,352 credential,
353 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
329 consumedAt: now,354 consumedAt: now,
330 now,355 now,
331 });356 });
@@ -337,6 +362,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 5 unmodified lines
337 scopes: consumed.value.scopes,362 scopes: consumed.value.scopes,
338 subject: consumed.value.subject,363 subject: consumed.value.subject,
339 ...(consumed.value.label ? { label: consumed.value.label } : {}),364 ...(consumed.value.label ? { label: consumed.value.label } : {}),
365 ...(consumed.value.proofKeyThumbprint
366 ? { proofKeyThumbprint: consumed.value.proofKeyThumbprint }
367 : {}),
340 expiresAt: consumed.value.expiresAt,368 expiresAt: consumed.value.expiresAt,
341 } satisfies BootstrapGrant;369 } satisfies BootstrapGrant;
342 }370 }
@@ -360,6 +388,13 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 17 unmodified lines
360 return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");388 return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
361 }389 }
362 390
391 if (
392 matching.value.proofKeyThumbprint !== null &&
393 matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint
394 ) {
395 return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch.");
396 }
397
363 return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");398 return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
364 },399 },
365 Effect.mapError((cause) =>400 Effect.mapError((cause) =>
apps/server/src/auth/ServerSecretStore.ts+10−5
@@ -15,9 +15,16 @@ export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ · 14 unmodified lines
15 readonly cause?: unknown;15 readonly cause?: unknown;
16}> {}16}> {}
17 17
18const isPlatformError = (value: unknown): value is PlatformError.PlatformError =>
19 Predicate.isTagged(value, "PlatformError");
20
21export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean =>
22 isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists";
23
18export interface ServerSecretStoreShape {24export interface ServerSecretStoreShape {
19 readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>;25 readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>;
20 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;26 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
27 readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
21 readonly getOrCreateRandom: (28 readonly getOrCreateRandom: (
22 name: string,29 name: string,
23 bytes: number,30 bytes: number,
@@ -48,9 +55,6 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 24 unmodified lines
48 55
49 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);56 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
50 57
51 const isPlatformError = (u: unknown): u is PlatformError.PlatformError =>
52 Predicate.isTagged(u, "PlatformError");
53
54 const get: ServerSecretStoreShape["get"] = (name) =>58 const get: ServerSecretStoreShape["get"] = (name) =>
55 fileSystem.readFile(resolveSecretPath(name)).pipe(59 fileSystem.readFile(resolveSecretPath(name)).pipe(
56 Effect.map((bytes) => Uint8Array.from(bytes)),60 Effect.map((bytes) => Uint8Array.from(bytes)),
@@ -104,7 +108,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 47 unmodified lines
104 );108 );
105 };109 };
106 110
107 const create: ServerSecretStoreShape["set"] = (name, value) => {111 const create: ServerSecretStoreShape["create"] = (name, value) => {
108 const secretPath = resolveSecretPath(name);112 const secretPath = resolveSecretPath(name);
109 return Effect.scoped(113 return Effect.scoped(
110 Effect.gen(function* () {114 Effect.gen(function* () {
@@ -146,7 +150,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 35 unmodified lines
146 create(name, generated).pipe(150 create(name, generated).pipe(
147 Effect.as(Uint8Array.from(generated)),151 Effect.as(Uint8Array.from(generated)),
148 Effect.catchTag("SecretStoreError", (error) =>152 Effect.catchTag("SecretStoreError", (error) =>
149 isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"153 isSecretAlreadyExistsError(error)
150 ? get(name).pipe(154 ? get(name).pipe(
151 Effect.flatMap((created) =>155 Effect.flatMap((created) =>
152 created !== null156 created !== null
@@ -185,6 +189,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 32 unmodified lines
185 return {189 return {
186 get,190 get,
187 set,191 set,
192 create,
188 getOrCreateRandom,193 getOrCreateRandom,
189 remove,194 remove,
190 } satisfies ServerSecretStoreShape;195 } satisfies ServerSecretStoreShape;
apps/server/src/auth/SessionStore.ts+8−1
@@ -40,6 +40,7 @@ export interface IssuedSession { · 39 unmodified lines
40 readonly client: AuthClientMetadata;40 readonly client: AuthClientMetadata;
41 readonly expiresAt: DateTime.DateTime;41 readonly expiresAt: DateTime.DateTime;
42 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;42 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
43 readonly proofKeyThumbprint?: string;
43}44}
44 45
45export interface VerifiedSession {46export interface VerifiedSession {
@@ -50,6 +51,7 @@ export interface VerifiedSession { · 4 unmodified lines
50 readonly expiresAt?: DateTime.DateTime;51 readonly expiresAt?: DateTime.DateTime;
51 readonly subject: string;52 readonly subject: string;
52 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;53 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
54 readonly proofKeyThumbprint?: string;
53}55}
54 56
55export type SessionCredentialChange =57export type SessionCredentialChange =
@@ -86,6 +88,7 @@ export interface SessionStoreShape { · 30 unmodified lines
86 readonly method?: ServerAuthSessionMethod;88 readonly method?: ServerAuthSessionMethod;
87 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;89 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
88 readonly client?: AuthClientMetadata;90 readonly client?: AuthClientMetadata;
91 readonly proofKeyThumbprint?: string;
89 }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;92 }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
90 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;93 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
91 readonly issueWebSocketToken: (94 readonly issueWebSocketToken: (
@@ -132,7 +135,8 @@ const SessionClaims = Schema.Struct({ · 40 unmodified lines
132 sid: AuthSessionId,135 sid: AuthSessionId,
133 sub: Schema.String,136 sub: Schema.String,
134 scopes: AuthEnvironmentScopes,137 scopes: AuthEnvironmentScopes,
135 method: Schema.Literals(["browser-session-cookie", "bearer-access-token"]),138 method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]),
139 jkt: Schema.optionalKey(Schema.String),
136 iat: Schema.Number,140 iat: Schema.Number,
137 exp: Schema.Number,141 exp: Schema.Number,
138});142});
@@ -310,6 +314,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 171 unmodified lines
310 sub: input?.subject ?? "browser",314 sub: input?.subject ?? "browser",
311 scopes: input?.scopes ?? AuthStandardClientScopes,315 scopes: input?.scopes ?? AuthStandardClientScopes,
312 method: input?.method ?? "browser-session-cookie",316 method: input?.method ?? "browser-session-cookie",
317 ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}),
313 iat: issuedAt.epochMilliseconds,318 iat: issuedAt.epochMilliseconds,
314 exp: expiresAt.epochMilliseconds,319 exp: expiresAt.epochMilliseconds,
315 };320 };
@@ -360,6 +365,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 44 unmodified lines
360 client,365 client,
361 expiresAt: expiresAt,366 expiresAt: expiresAt,
362 scopes: claims.scopes,367 scopes: claims.scopes,
368 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
363 } satisfies IssuedSession;369 } satisfies IssuedSession;
364 },370 },
365 Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")),371 Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")),
@@ -425,6 +431,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 59 unmodified lines
425 expiresAt: expiresAt.value,431 expiresAt: expiresAt.value,
426 subject: claims.sub,432 subject: claims.sub,
427 scopes: claims.scopes,433 scopes: claims.scopes,
434 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
428 } satisfies VerifiedSession;435 } satisfies VerifiedSession;
429 },436 },
430 Effect.mapError((cause) =>437 Effect.mapError((cause) =>
apps/server/src/auth/dpop.test.ts+33−0
@@ -0,0 +1,33 @@
1import { describe, expect, it } from "vitest";
2import * as PlatformError from "effect/PlatformError";
3
4import * as ServerSecretStore from "./ServerSecretStore.ts";
5import { mapDpopReplayStoreError } from "./dpop.ts";
6
7const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
8 new ServerSecretStore.SecretStoreError({
9 message: "Failed to persist DPoP proof.",
10 cause: PlatformError.systemError({
11 _tag: tag,
12 module: "FileSystem",
13 method: "open",
14 pathOrDescriptor: "dpop-proof.bin",
15 }),
16 });
17
18describe("mapDpopReplayStoreError", () => {
19 it("reports replay conflicts as invalid credentials", () => {
20 const error = mapDpopReplayStoreError(storeFailure("AlreadyExists"));
21
22 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
23 });
24
25 it("reports replay-store availability failures as internal errors", () => {
26 const error = mapDpopReplayStoreError(storeFailure("PermissionDenied"));
27
28 expect(error._tag).toBe("ServerAuthInternalError");
29 if (error._tag === "ServerAuthInternalError") {
30 expect(error.message).toBe("Failed to record DPoP proof replay state.");
31 }
32 });
33});
apps/server/src/auth/dpop.ts+92−0
@@ -0,0 +1,92 @@
1import { verifyDpopProof } from "@t3tools/shared/dpop";
2import * as Crypto from "effect/Crypto";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Encoding from "effect/Encoding";
6import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
7
8import * as EnvironmentAuth from "./EnvironmentAuth.ts";
9import * as ServerSecretStore from "./ServerSecretStore.ts";
10
11function firstHeaderValue(value: string | undefined): string | undefined {
12 const first = value?.split(",")[0]?.trim();
13 return first && first.length > 0 ? first : undefined;
14}
15
16export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string {
17 try {
18 return new URL(request.originalUrl).href;
19 } catch {
20 const host = firstHeaderValue(request.headers.host) ?? "127.0.0.1";
21 const forwardedProto = firstHeaderValue(request.headers["x-forwarded-proto"]);
22 const proto = forwardedProto === "https" || forwardedProto === "http" ? forwardedProto : "http";
23 return new URL(request.originalUrl, `${proto}://${host}`).href;
24 }
25}
26
27export const mapDpopReplayStoreError = (
28 error: ServerSecretStore.SecretStoreError,
29): EnvironmentAuth.ServerAuthInvalidCredentialError | EnvironmentAuth.ServerAuthInternalError =>
30 ServerSecretStore.isSecretAlreadyExistsError(error)
31 ? new EnvironmentAuth.ServerAuthInvalidCredentialError({
32 reason: "invalid_credential",
33 cause: "DPoP proof replayed.",
34 })
35 : new EnvironmentAuth.ServerAuthInternalError({
36 message: "Failed to record DPoP proof replay state.",
37 cause: error,
38 });
39
40export const verifyRequestDpopProof = (input: {
41 readonly request: HttpServerRequest.HttpServerRequest;
42 readonly expectedThumbprint?: string;
43 readonly expectedAccessToken?: string;
44}) =>
45 Effect.gen(function* () {
46 const proof = input.request.headers.dpop;
47 const now = yield* DateTime.now;
48 const result = verifyDpopProof({
49 proof,
50 method: input.request.method,
51 url: requestAbsoluteUrl(input.request),
52 nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000),
53 ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}),
54 ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
55 });
56 if (!result.ok) {
57 return yield* new EnvironmentAuth.ServerAuthInvalidCredentialError({
58 reason: "invalid_credential",
59 cause: result.reason,
60 });
61 }
62 const secretStore = yield* ServerSecretStore.ServerSecretStore;
63 const replayKey = yield* Crypto.Crypto.pipe(
64 Effect.flatMap((crypto) =>
65 crypto.digest("SHA-256", new TextEncoder().encode(`${result.thumbprint}:${result.jti}`)),
66 ),
67 Effect.map(Encoding.encodeBase64Url),
68 Effect.mapError(
69 (cause) =>
70 new EnvironmentAuth.ServerAuthInternalError({
71 message: "Failed to calculate DPoP replay key.",
72 cause,
73 }),
74 ),
75 );
76 yield* secretStore
77 .create(
78 `dpop-proof-${replayKey}`,
79 new TextEncoder().encode(
80 [
81 `thumbprint=${result.thumbprint}`,
82 `jti=${result.jti}`,
83 `iat=${result.iat}`,
84 `consumedAt=${DateTime.formatIso(now)}`,
85 ].join("\n"),
86 ),
87 )
88 .pipe(
89 Effect.catchTag("SecretStoreError", (error) => Effect.fail(mapDpopReplayStoreError(error))),
90 );
91 return result.thumbprint;
92 });
apps/server/src/auth/http.ts+45−2
@@ -34,6 +34,32 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; · 33 unmodified lines
34import * as EnvironmentAuth from "./EnvironmentAuth.ts";34import * as EnvironmentAuth from "./EnvironmentAuth.ts";
35import * as SessionStore from "./SessionStore.ts";35import * as SessionStore from "./SessionStore.ts";
36import { deriveAuthClientMetadata } from "./utils.ts";36import { deriveAuthClientMetadata } from "./utils.ts";
37import { verifyRequestDpopProof } from "./dpop.ts";
38
39const CREDENTIAL_RESPONSE_HEADERS = {
40 "cache-control": "no-store",
41 pragma: "no-cache",
42} as const;
43
44const appendCredentialResponseHeaders = HttpEffect.appendPreResponseHandler((_request, response) =>
45 Effect.succeed(HttpServerResponse.setHeaders(response, CREDENTIAL_RESPONSE_HEADERS)),
46);
47
48const appendDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) =>
49 Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", "DPoP")),
50);
51
52const appendDpopChallengeOnUnauthorized = (error: EnvironmentAuthInvalidError) =>
53 Effect.gen(function* () {
54 const request = yield* HttpServerRequest.HttpServerRequest;
55 const usesDpop =
56 (request.originalUrl.startsWith("/oauth/token") && request.headers.dpop !== undefined) ||
57 request.headers.authorization?.startsWith("DPoP ") === true;
58 if (usesDpop) {
59 yield* appendDpopChallengeHeader;
60 }
61 return yield* error;
62 });
37 63
38export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe(64export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe(
39 Effect.map((span) => span.traceId),65 Effect.map((span) => span.traceId),
@@ -152,7 +178,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( · 112 unmodified lines
152 scopes: new Set(session.scopes),178 scopes: new Set(session.scopes),
153 }),179 }),
154 );180 );
155 });181 }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized));
156 }),182 }),
157);183);
158 184
@@ -199,6 +225,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 40 unmodified lines
199 yield* HttpEffect.appendPreResponseHandler((_request, response) =>225 yield* HttpEffect.appendPreResponseHandler((_request, response) =>
200 Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)),226 Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)),
201 );227 );
228 yield* appendCredentialResponseHeaders;
202 return result.response;229 return result.response;
203 },230 },
204 Effect.catchTags({231 Effect.catchTags({
@@ -230,8 +257,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 25 unmodified lines
230 AuthRelayWriteScope,257 AuthRelayWriteScope,
231 ]),258 ]),
232 });259 });
233 if (requestedScopes === null)260 if (requestedScopes === null) {
234 return yield* failEnvironmentInvalidRequest("invalid_scope");261 return yield* failEnvironmentInvalidRequest("invalid_scope");
262 }
263 const proofKeyThumbprint = args.headers.dpop
264 ? yield* verifyRequestDpopProof({ request }).pipe(
265 Effect.catchTags({
266 ServerAuthInvalidCredentialError: () =>
267 appendDpopChallengeHeader.pipe(
268 Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),
269 ),
270 ServerAuthInternalError: (error) =>
271 failEnvironmentInternal("access_token_issuance_failed", error),
272 }),
273 )
274 : undefined;
275 yield* appendCredentialResponseHeaders;
235 return yield* serverAuth.exchangeBootstrapCredentialForAccessToken(276 return yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
236 args.payload.subject_token,277 args.payload.subject_token,
237 requestedScopes,278 requestedScopes,
@@ -245,6 +286,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 7 unmodified lines
245 ...(args.payload.client_os ? { os: args.payload.client_os } : {}),286 ...(args.payload.client_os ? { os: args.payload.client_os } : {}),
246 },287 },
247 }),288 }),
289 proofKeyThumbprint ? { proofKeyThumbprint } : undefined,
248 );290 );
249 },291 },
250 Effect.catchTags({292 Effect.catchTags({
@@ -261,6 +303,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 10 unmodified lines
261 function* (args) {303 function* (args) {
262 yield* annotateEnvironmentRequest(args.endpoint.name);304 yield* annotateEnvironmentRequest(args.endpoint.name);
263 const session = yield* EnvironmentAuthenticatedPrincipal;305 const session = yield* EnvironmentAuthenticatedPrincipal;
306 yield* appendCredentialResponseHeaders;
264 return yield* serverAuth.issueWebSocketTicket(session);307 return yield* serverAuth.issueWebSocketTicket(session);
265 },308 },
266 Effect.catchTag("ServerAuthInternalError", (error) =>309 Effect.catchTag("ServerAuthInternalError", (error) =>
apps/server/src/auth/utils.ts+6−3
@@ -5,6 +5,8 @@ import type { · 4 unmodified lines
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";6import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
7import * as Crypto from "node:crypto";7import * as Crypto from "node:crypto";
8import * as Encoding from "effect/Encoding";
9import * as Result from "effect/Result";
8 10
9const SESSION_COOKIE_NAME = "t3_session";11const SESSION_COOKIE_NAME = "t3_session";
10 12
@@ -20,12 +22,13 @@ export function resolveSessionCookieName(input: { · 9 unmodified lines
20}22}
21 23
22export function base64UrlEncode(input: string | Uint8Array): string {24export function base64UrlEncode(input: string | Uint8Array): string {
23 const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input);25 return typeof input === "string"
24 return buffer.toString("base64url");26 ? Encoding.encodeBase64Url(new TextEncoder().encode(input))
27 : Encoding.encodeBase64Url(input);
25}28}
26 29
27export function base64UrlDecodeUtf8(input: string): string {30export function base64UrlDecodeUtf8(input: string): string {
28 return Buffer.from(input, "base64url").toString("utf8");31 return Result.getOrThrow(Encoding.decodeBase64UrlString(input));
29}32}
30 33
31export function signPayload(payload: string, secret: Uint8Array): string {34export function signPayload(payload: string, secret: Uint8Array): string {
apps/server/src/cloud/ManagedEndpointRuntime.test.ts+244−0
@@ -0,0 +1,244 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as PlatformError from "effect/PlatformError";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
9
10import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts";
11
12function makeHandle(input: {
13 readonly pid: number;
14 readonly onKill: () => void;
15 readonly isRunning?: () => boolean;
16 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
17}) {
18 return ChildProcessSpawner.makeHandle({
19 pid: ChildProcessSpawner.ProcessId(input.pid),
20 exitCode: input.exitCode ?? Effect.never,
21 isRunning: Effect.sync(() => input.isRunning?.() ?? true),
22 kill: () =>
23 Effect.sync(() => {
24 input.onKill();
25 }),
26 unref: Effect.succeed(Effect.void),
27 stdin: Sink.drain,
28 stdout: Stream.empty,
29 stderr: Stream.empty,
30 all: Stream.empty,
31 getInputFd: () => Sink.drain,
32 getOutputFd: () => Stream.empty,
33 });
34}
35
36describe("CloudManagedEndpointRuntime", () => {
37 it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () =>
38 Effect.gen(function* () {
39 const spawned: Array<ChildProcess.StandardCommand> = [];
40 const killed: Array<number> = [];
41 let nextPid = 100;
42 const spawner = ChildProcessSpawner.make((command) =>
43 Effect.gen(function* () {
44 if (!ChildProcess.isStandardCommand(command)) {
45 throw new Error("Expected standard command.");
46 }
47 spawned.push(command);
48 const pid = nextPid;
49 nextPid += 1;
50 const handle = makeHandle({
51 pid,
52 onKill: () => {
53 killed.push(pid);
54 },
55 });
56 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
57 return handle;
58 }),
59 );
60 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
61 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
62 );
63
64 yield* runtime.applyConfig({
65 providerKind: "cloudflare_tunnel",
66 connectorToken: "token-1",
67 tunnelId: "tunnel-1",
68 tunnelName: "t3-code-env-1",
69 });
70 yield* runtime.applyConfig({
71 providerKind: "cloudflare_tunnel",
72 connectorToken: "token-1",
73 tunnelId: "tunnel-1",
74 tunnelName: "t3-code-env-1",
75 });
76 yield* runtime.applyConfig({
77 providerKind: "cloudflare_tunnel",
78 connectorToken: "token-2",
79 tunnelId: "tunnel-1",
80 tunnelName: "t3-code-env-1",
81 });
82 const stopped = yield* runtime.applyConfig(null);
83
84 expect(spawned.map((command) => command.command)).toEqual(["cloudflared", "cloudflared"]);
85 expect(spawned.map((command) => command.args)).toEqual([
86 ["tunnel", "run", "--token", "token-1"],
87 ["tunnel", "run", "--token", "token-2"],
88 ]);
89 expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]);
90 expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]);
91 expect(killed).toEqual([100, 101]);
92 expect(stopped).toEqual({ status: "disabled" });
93 }),
94 );
95
96 it.effect("stops an active connector when a non-Cloudflare runtime config is applied", () =>
97 Effect.gen(function* () {
98 const killed: Array<number> = [];
99 const spawner = ChildProcessSpawner.make(() =>
100 Effect.gen(function* () {
101 const handle = makeHandle({
102 pid: 200,
103 onKill: () => {
104 killed.push(200);
105 },
106 });
107 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
108 return handle;
109 }),
110 );
111 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
112 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
113 );
114
115 const started = yield* runtime.applyConfig({
116 providerKind: "cloudflare_tunnel",
117 connectorToken: "token",
118 });
119 const unsupported = yield* runtime.applyConfig({
120 providerKind: "manual",
121 connectorToken: "manual-token",
122 });
123
124 expect(started.status).toBe("running");
125 expect(unsupported).toEqual({ status: "unsupported", providerKind: "manual" });
126 expect(killed).toEqual([200]);
127 }),
128 );
129
130 it.effect("restarts the connector when the active process has exited", () =>
131 Effect.gen(function* () {
132 const spawned: Array<number> = [];
133 const killed: Array<number> = [];
134 let firstRunning = true;
135 const spawner = ChildProcessSpawner.make(() =>
136 Effect.gen(function* () {
137 const pid = spawned.length === 0 ? 300 : 301;
138 spawned.push(pid);
139 const handle = makeHandle({
140 pid,
141 isRunning: () => (pid === 300 ? firstRunning : true),
142 onKill: () => {
143 killed.push(pid);
144 },
145 });
146 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
147 return handle;
148 }),
149 );
150 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
151 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
152 );
153 const config = {
154 providerKind: "cloudflare_tunnel" as const,
155 connectorToken: "token",
156 tunnelId: "tunnel-1",
157 };
158
159 const first = yield* runtime.applyConfig(config);
160 firstRunning = false;
161 const second = yield* runtime.applyConfig(config);
162
163 expect(first).toMatchObject({ status: "running", pid: 300 });
164 expect(second).toMatchObject({ status: "running", pid: 301 });
165 expect(spawned).toEqual([300, 301]);
166 expect(killed).toEqual([300]);
167 }),
168 );
169
170 it.effect("supervises the active connector and restarts it after process exit", () =>
171 Effect.gen(function* () {
172 const spawned: Array<number> = [];
173 const killed: Array<number> = [];
174 const firstExit = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
175 const secondSpawned = yield* Deferred.make<void>();
176 const spawner = ChildProcessSpawner.make(() =>
177 Effect.gen(function* () {
178 const pid = spawned.length === 0 ? 400 : 401;
179 spawned.push(pid);
180 if (pid === 401) {
181 yield* Deferred.succeed(secondSpawned, undefined);
182 }
183 const handle = makeHandle({
184 pid,
185 exitCode:
186 pid === 400
187 ? Deferred.await(firstExit)
188 : (Effect.never as Effect.Effect<ChildProcessSpawner.ExitCode>),
189 onKill: () => {
190 killed.push(pid);
191 },
192 });
193 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
194 return handle;
195 }),
196 );
197 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
198 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
199 );
200
201 const started = yield* runtime.applyConfig({
202 providerKind: "cloudflare_tunnel",
203 connectorToken: "token",
204 tunnelId: "tunnel-1",
205 });
206 yield* Deferred.succeed(firstExit, ChildProcessSpawner.ExitCode(1));
207 yield* Deferred.await(secondSpawned);
208
209 expect(started).toMatchObject({ status: "running", pid: 400 });
210 expect(spawned).toEqual([400, 401]);
211 expect(killed).toEqual([]);
212 }),
213 );
214
215 it.effect("reports connector spawn failures", () =>
216 Effect.gen(function* () {
217 const spawner = ChildProcessSpawner.make(() =>
218 Effect.fail(
219 PlatformError.systemError({
220 _tag: "NotFound",
221 module: "ChildProcess",
222 method: "spawn",
223 description: "cloudflared missing",
224 }),
225 ),
226 );
227 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
228 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
229 );
230
231 const status = yield* runtime.applyConfig({
232 providerKind: "cloudflare_tunnel",
233 connectorToken: "token",
234 tunnelId: "tunnel-1",
235 });
236
237 expect(status).toMatchObject({
238 status: "failed",
239 providerKind: "cloudflare_tunnel",
240 tunnelId: "tunnel-1",
241 });
242 }),
243 );
244});
apps/server/src/cloud/ManagedEndpointRuntime.ts+233−0
@@ -0,0 +1,233 @@
1import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Ref from "effect/Ref";
8import * as Scope from "effect/Scope";
9import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
10
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts";
13
14function bytesToString(bytes: Uint8Array): string {
15 return new TextDecoder().decode(bytes);
16}
17
18const readRuntimeConfig = Effect.gen(function* () {
19 const secrets = yield* ServerSecretStore.ServerSecretStore;
20 const bytes = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG);
21 if (!bytes) {
22 return null;
23 }
24 return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes)));
25});
26
27export interface CloudManagedEndpointRuntimeShape {
28 readonly applyConfig: (
29 config: RelayManagedEndpointRuntimeConfig | null,
30 ) => Effect.Effect<CloudManagedEndpointRuntimeStatus>;
31}
32
33export class CloudManagedEndpointRuntime extends Context.Service<
34 CloudManagedEndpointRuntime,
35 CloudManagedEndpointRuntimeShape
36>()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {}
37
38export type CloudManagedEndpointRuntimeStatus =
39 | {
40 readonly status: "disabled";
41 }
42 | {
43 readonly status: "failed";
44 readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"];
45 readonly reason: string;
46 readonly tunnelId?: string;
47 readonly tunnelName?: string;
48 }
49 | {
50 readonly status: "running";
51 readonly providerKind: "cloudflare_tunnel";
52 readonly pid: number;
53 readonly tunnelId?: string;
54 readonly tunnelName?: string;
55 }
56 | {
57 readonly status: "unsupported";
58 readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"];
59 };
60
61interface ActiveConnector {
62 readonly child: ChildProcessSpawner.ChildProcessHandle;
63 readonly scope: Scope.Closeable;
64 readonly configKey: string;
65 readonly config: RelayManagedEndpointRuntimeConfig;
66}
67
68function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string {
69 return JSON.stringify({
70 providerKind: config.providerKind,
71 connectorToken: config.connectorToken,
72 tunnelId: config.tunnelId ?? null,
73 tunnelName: config.tunnelName ?? null,
74 });
75}
76
77const stopConnector = (connector: ActiveConnector | null) =>
78 connector
79 ? Scope.close(connector.scope, Exit.void).pipe(
80 Effect.tap(() =>
81 Effect.logInfo("Cloudflare managed endpoint connector stopped", {
82 pid: Number(connector.child.pid),
83 }),
84 ),
85 Effect.ignore,
86 )
87 : Effect.void;
88
89export const makeCloudManagedEndpointRuntime = Effect.gen(function* () {
90 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
91 const activeRef = yield* Ref.make<ActiveConnector | null>(null);
92 let applyConfig: CloudManagedEndpointRuntimeShape["applyConfig"];
93
94 const stopActive = Effect.gen(function* () {
95 const active = yield* Ref.getAndSet(activeRef, null);
96 yield* stopConnector(active);
97 });
98
99 const superviseConnector = (connector: ActiveConnector) =>
100 Effect.gen(function* () {
101 const exitCode = yield* connector.child.exitCode;
102 const active = yield* Ref.get(activeRef);
103 if (active?.child.pid !== connector.child.pid || active.configKey !== connector.configKey) {
104 return;
105 }
106 yield* Ref.set(activeRef, null);
107 yield* Effect.logWarning("Cloudflare managed endpoint connector exited; restarting", {
108 pid: Number(connector.child.pid),
109 exitCode: Number(exitCode),
110 tunnelId: connector.config.tunnelId,
111 tunnelName: connector.config.tunnelName,
112 });
113 yield* applyConfig(connector.config);
114 }).pipe(
115 Effect.catch((cause) =>
116 Effect.logWarning("Cloudflare managed endpoint connector supervisor failed", { cause }),
117 ),
118 );
119
120 applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")(function* (config) {
121 if (!config || config.providerKind !== "cloudflare_tunnel") {
122 yield* stopActive;
123 return config
124 ? { status: "unsupported", providerKind: config.providerKind }
125 : { status: "disabled" };
126 }
127
128 const nextConfigKey = runtimeConfigKey(config);
129 const active = yield* Ref.get(activeRef);
130 if (active?.configKey === nextConfigKey) {
131 const isRunning = yield* active.child.isRunning.pipe(
132 Effect.catch(() => Effect.succeed(false)),
133 );
134 if (isRunning) {
135 return {
136 status: "running",
137 providerKind: "cloudflare_tunnel",
138 pid: Number(active.child.pid),
139 ...(active.config.tunnelId ? { tunnelId: active.config.tunnelId } : {}),
140 ...(active.config.tunnelName ? { tunnelName: active.config.tunnelName } : {}),
141 } satisfies CloudManagedEndpointRuntimeStatus;
142 }
143 }
144
145 yield* stopActive;
146
147 const connectorScope = yield* Scope.make("sequential");
148 const child = yield* spawner
149 .spawn(
150 ChildProcess.make("cloudflared", ["tunnel", "run", "--token", config.connectorToken], {
151 shell: process.platform === "win32",
152 stderr: "ignore",
153 stdout: "ignore",
154 }),
155 )
156 .pipe(
157 Effect.provideService(Scope.Scope, connectorScope),
158 Effect.tap(() =>
159 Effect.logInfo("Cloudflare managed endpoint connector started", {
160 tunnelId: config.tunnelId,
161 tunnelName: config.tunnelName,
162 }),
163 ),
164 Effect.catch((cause) =>
165 Effect.logWarning("Failed to start Cloudflare managed endpoint connector", {
166 cause,
167 tunnelId: config.tunnelId,
168 tunnelName: config.tunnelName,
169 }).pipe(
170 Effect.andThen(Scope.close(connectorScope, Exit.void).pipe(Effect.ignore)),
171 Effect.as({
172 status: "failed",
173 providerKind: "cloudflare_tunnel",
174 reason: String(cause),
175 ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
176 ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
177 } satisfies CloudManagedEndpointRuntimeStatus),
178 ),
179 ),
180 );
181
182 if ("status" in child && child.status === "failed") {
183 return child;
184 }
185
186 if (!("status" in child)) {
187 const connector = {
188 child,
189 scope: connectorScope,
190 configKey: nextConfigKey,
191 config,
192 } satisfies ActiveConnector;
193 yield* Ref.set(activeRef, connector);
194 yield* Effect.forkIn(superviseConnector(connector), connectorScope);
195 return {
196 status: "running",
197 providerKind: "cloudflare_tunnel",
198 pid: Number(child.pid),
199 ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
200 ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
201 } satisfies CloudManagedEndpointRuntimeStatus;
202 }
203
204 return {
205 status: "failed",
206 providerKind: "cloudflare_tunnel",
207 reason: "Cloudflare connector did not start.",
208 ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
209 ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
210 } satisfies CloudManagedEndpointRuntimeStatus;
211 });
212
213 return CloudManagedEndpointRuntime.of({
214 applyConfig,
215 });
216});
217
218export const layer = Layer.effect(
219 CloudManagedEndpointRuntime,
220 Effect.gen(function* () {
221 const runtime = yield* makeCloudManagedEndpointRuntime;
222 const initialConfig = yield* readRuntimeConfig.pipe(
223 Effect.catch((cause) =>
224 Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe(
225 Effect.as(null),
226 ),
227 ),
228 );
229 yield* runtime.applyConfig(initialConfig);
230 yield* Effect.addFinalizer(() => runtime.applyConfig(null));
231 return runtime;
232 }),
233);
apps/server/src/cloud/config.ts+17−0
@@ -0,0 +1,17 @@
1import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2import * as Schema from "effect/Schema";
3
4export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";
5export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";
6export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id";
7export const RELAY_URL_SECRET = "cloud-relay-url";
8export const RELAY_ISSUER_SECRET = "cloud-relay-issuer";
9export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential";
10
11export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect(
12 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
13);
14
15export const decodeRuntimeConfig = Schema.decodeUnknownOption(
16 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
17);
apps/server/src/cloud/http.test.ts+60−0
@@ -0,0 +1,60 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as PlatformError from "effect/PlatformError";
4
5import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
6import { consumeCloudReplayGuards } from "./http.ts";
7
8const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
9 new ServerSecretStore.SecretStoreError({
10 message: "Failed to persist cloud replay guard.",
11 cause: PlatformError.systemError({
12 _tag: tag,
13 module: "FileSystem",
14 method: "open",
15 pathOrDescriptor: "cloud-replay-guard.bin",
16 }),
17 });
18
19const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation");
20
21function makeSecretStore(
22 create: ServerSecretStore.ServerSecretStoreShape["create"],
23): ServerSecretStore.ServerSecretStoreShape {
24 return {
25 get: unusedSecretStoreOperation,
26 set: unusedSecretStoreOperation,
27 create,
28 getOrCreateRandom: unusedSecretStoreOperation,
29 remove: unusedSecretStoreOperation,
30 };
31}
32
33describe("consumeCloudReplayGuards", () => {
34 it.effect("reports already-created guards as replay conflicts", () =>
35 Effect.gen(function* () {
36 const consumed = yield* consumeCloudReplayGuards({
37 secrets: makeSecretStore(() => Effect.fail(storeFailure("AlreadyExists"))),
38 names: ["cloud-jti", "cloud-nonce"],
39 value: new Uint8Array(),
40 });
41
42 expect(consumed).toBe(false);
43 }),
44 );
45
46 it.effect("preserves replay-store availability failures", () =>
47 Effect.gen(function* () {
48 const failure = storeFailure("PermissionDenied");
49 const error = yield* Effect.flip(
50 consumeCloudReplayGuards({
51 secrets: makeSecretStore(() => Effect.fail(failure)),
52 names: ["cloud-jti", "cloud-nonce"],
53 value: new Uint8Array(),
54 }),
55 );
56
57 expect(error).toBe(failure);
58 }),
59 );
60});
apps/server/src/cloud/http.ts+797−0
@@ -0,0 +1,797 @@
1import * as NodeCrypto from "node:crypto";
2import {
3 AuthRelayManageScope,
4 AuthStandardClientScopes,
5 EnvironmentCloudEndpointUnavailableError,
6 EnvironmentCloudLinkStateResult,
7 EnvironmentCloudRelayConfigResult,
8 EnvironmentHttpApi,
9 EnvironmentHttpBadRequestError,
10 EnvironmentHttpConflictError,
11 EnvironmentHttpInternalServerError,
12 EnvironmentHttpUnauthorizedError,
13} from "@t3tools/contracts";
14import {
15 RelayCloudEnvironmentHealthProofPayload,
16 RelayCloudEnvironmentHealthRequest,
17 RelayCloudMintCredentialProofPayload,
18 RelayCloudMintCredentialRequest,
19 RelayEnvironmentHealthResponseProofPayload,
20 type RelayEnvironmentHealthResponse as RelayEnvironmentHealthResponseShape,
21 RelayEnvironmentConfigRequest,
22 RelayEnvironmentMintResponseProofPayload,
23 type RelayEnvironmentMintResponse as RelayEnvironmentMintResponseShape,
24 RelayEnvironmentLinkProof,
25 RelayEnvironmentLinkProofPayload,
26 RelayLinkProofRequest,
27 RelayManagedEndpointOrigin,
28} from "@t3tools/contracts/relay";
29import {
30 normalizeRelayIssuer,
31 RELAY_HEALTH_REQUEST_TYP,
32 RELAY_HEALTH_RESPONSE_TYP,
33 RELAY_LINK_PROOF_TYP,
34 RELAY_MINT_REQUEST_TYP,
35 RELAY_MINT_RESPONSE_TYP,
36 signRelayJwt,
37 verifyRelayJwt,
38} from "@t3tools/shared/relayJwt";
39import * as DateTime from "effect/DateTime";
40import * as Crypto from "effect/Crypto";
41import * as Duration from "effect/Duration";
42import * as Effect from "effect/Effect";
43import * as Option from "effect/Option";
44import * as Schema from "effect/Schema";
45import * as HttpEffect from "effect/unstable/http/HttpEffect";
46import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
47import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
48
49import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
50import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
51import { requireEnvironmentScope } from "../auth/http.ts";
52import {
53 ServerEnvironment,
54 type ServerEnvironmentShape,
55} from "../environment/Services/ServerEnvironment.ts";
56import {
57 CloudManagedEndpointRuntime,
58 type CloudManagedEndpointRuntimeShape,
59} from "./ManagedEndpointRuntime.ts";
60import {
61 CLOUD_ENDPOINT_RUNTIME_CONFIG,
62 CLOUD_LINKED_USER_ID,
63 CLOUD_MINT_PUBLIC_KEY,
64 encodeEndpointRuntimeConfigJson,
65 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
66 RELAY_ISSUER_SECRET,
67 RELAY_URL_SECRET,
68} from "./config.ts";
69
70const CLOUD_LINK_PRIVATE_KEY = "cloud-link-ed25519-private-key";
71const CLOUD_LINK_PUBLIC_KEY = "cloud-link-ed25519-public-key";
72const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-";
73const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-";
74const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-";
75const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-";
76const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60;
77const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60;
78const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
79const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
80 "cache-control": "no-store",
81 pragma: "no-cache",
82} as const;
83
84const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler(
85 (_request, response) =>
86 Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)),
87);
88
89const failEnvironmentCloudInternalError =
90 (message: string) =>
91 (cause: unknown): Effect.Effect<never, EnvironmentHttpInternalServerError> =>
92 Effect.logError(message, { cause }).pipe(
93 Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))),
94 );
95
96function bytesToString(bytes: Uint8Array): string {
97 return new TextDecoder().decode(bytes);
98}
99
100function stringToBytes(value: string): Uint8Array {
101 return new TextEncoder().encode(value);
102}
103
104export function consumeCloudReplayGuards(input: {
105 readonly secrets: ServerSecretStore.ServerSecretStoreShape;
106 readonly names: ReadonlyArray<string>;
107 readonly value: Uint8Array;
108}) {
109 return Effect.all(
110 input.names.map((name) =>
111 input.secrets.create(name, input.value).pipe(
112 Effect.as(true),
113 Effect.catchTag("SecretStoreError", (error) =>
114 ServerSecretStore.isSecretAlreadyExistsError(error)
115 ? Effect.succeed(false)
116 : Effect.fail(error),
117 ),
118 ),
119 ),
120 { concurrency: input.names.length },
121 ).pipe(Effect.map((created) => created.every(Boolean)));
122}
123
124function normalizePemForSignedPayload(value: string): string {
125 return value.trim();
126}
127
128function normalizeHostname(hostname: string): string {
129 return hostname
130 .trim()
131 .toLowerCase()
132 .replace(/^\[(.*)\]$/, "$1");
133}
134
135function validateCloudMintPublicKey(
136 publicKey: string,
137): Effect.Effect<void, EnvironmentHttpBadRequestError> {
138 return Effect.try({
139 try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")),
140 catch: () =>
141 new EnvironmentHttpBadRequestError({
142 message: "Cloud mint public key must be a valid Ed25519 public key.",
143 }),
144 }).pipe(
145 Effect.flatMap((key) =>
146 key.asymmetricKeyType === "ed25519"
147 ? Effect.void
148 : Effect.fail(
149 new EnvironmentHttpBadRequestError({
150 message: "Cloud mint public key must be a valid Ed25519 public key.",
151 }),
152 ),
153 ),
154 );
155}
156
157function isSecureRelayUrl(value: string): boolean {
158 try {
159 const url = new URL(value);
160 return (
161 url.protocol === "https:" &&
162 url.username.length === 0 &&
163 url.password.length === 0 &&
164 url.hash.length === 0
165 );
166 } catch {
167 return false;
168 }
169}
170
171function validateRelayConfigPayload(
172 payload: RelayEnvironmentConfigRequest,
173): Effect.Effect<void, EnvironmentHttpBadRequestError> {
174 if (!isSecureRelayUrl(payload.relayUrl)) {
175 return Effect.fail(
176 new EnvironmentHttpBadRequestError({
177 message: "Relay URL must be a secure absolute HTTPS URL.",
178 }),
179 );
180 }
181 if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) {
182 return Effect.fail(
183 new EnvironmentHttpBadRequestError({
184 message: "Relay issuer must be a secure absolute HTTPS URL.",
185 }),
186 );
187 }
188 if (payload.environmentCredential.trim().length === 0) {
189 return Effect.fail(
190 new EnvironmentHttpBadRequestError({
191 message: "Relay environment credential is required.",
192 }),
193 );
194 }
195 if (payload.cloudUserId.trim().length === 0) {
196 return Effect.fail(
197 new EnvironmentHttpBadRequestError({
198 message: "Cloud user id is required.",
199 }),
200 );
201 }
202 return Effect.void;
203}
204
205function validateLinkedCloudUser(input: {
206 readonly secrets: ServerSecretStore.ServerSecretStoreShape;
207 readonly cloudUserId: string;
208}): Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError | EnvironmentHttpConflictError> {
209 return input.secrets.get(CLOUD_LINKED_USER_ID).pipe(
210 Effect.mapError(
211 (cause) =>
212 new EnvironmentAuth.ServerAuthInternalError({
213 message: "Could not verify the linked cloud account.",
214 cause,
215 }),
216 ),
217 Effect.flatMap((existing) => {
218 if (!existing) {
219 return Effect.void;
220 }
221 const existingCloudUserId = bytesToString(existing);
222 return existingCloudUserId === input.cloudUserId
223 ? Effect.void
224 : Effect.fail(
225 new EnvironmentHttpConflictError({
226 message:
227 "This environment is already linked to a different cloud account. Unlink it before switching accounts.",
228 }),
229 );
230 }),
231 );
232}
233
234function readInstalledCloudUserId(
235 secrets: ServerSecretStore.ServerSecretStoreShape,
236): Effect.Effect<string, EnvironmentAuth.ServerAuthInternalError> {
237 return secrets.get(CLOUD_LINKED_USER_ID).pipe(
238 Effect.mapError(
239 (cause) =>
240 new EnvironmentAuth.ServerAuthInternalError({
241 message: "Could not read the linked cloud account.",
242 cause,
243 }),
244 ),
245 Effect.flatMap((bytes) =>
246 bytes
247 ? Effect.succeed(bytesToString(bytes))
248 : Effect.fail(
249 new EnvironmentAuth.ServerAuthInternalError({
250 message: "Cloud linked user is not installed for this environment.",
251 }),
252 ),
253 ),
254 );
255}
256
257function isLoopbackHostname(hostname: string): boolean {
258 return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname));
259}
260
261function firstForwardedHeaderValue(value: string | undefined): string | undefined {
262 const first = value?.split(",")[0]?.trim();
263 return first && first.length > 0 ? first : undefined;
264}
265
266function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string | null {
267 try {
268 return new URL(request.originalUrl).href;
269 } catch {
270 const host = firstForwardedHeaderValue(request.headers.host) ?? "127.0.0.1";
271 try {
272 return new URL(request.originalUrl, `http://${host}`).href;
273 } catch {
274 return null;
275 }
276 }
277}
278
279function hasForwardedAuthorityHeaders(request: HttpServerRequest.HttpServerRequest): boolean {
280 return (
281 firstForwardedHeaderValue(request.headers["x-forwarded-host"]) !== undefined ||
282 firstForwardedHeaderValue(request.headers["x-forwarded-proto"]) !== undefined
283 );
284}
285
286function endpointRequestPort(url: URL): number {
287 return Number(url.port || (url.protocol === "https:" ? 443 : 80));
288}
289
290function isAllowedEndpointOrigin(input: {
291 readonly origin: RelayManagedEndpointOrigin;
292 readonly requestUrl: string;
293}): boolean {
294 if (!isLoopbackHostname(input.origin.localHttpHost)) {
295 return false;
296 }
297
298 const url = new URL(input.requestUrl);
299 if (!isLoopbackHostname(url.hostname)) {
300 return false;
301 }
302
303 return input.origin.localHttpPort === endpointRequestPort(url);
304}
305
306function providerKindMatchesRequestedLinkScopes(request: RelayLinkProofRequest): boolean {
307 return request.endpoint.providerKind === "cloudflare_tunnel";
308}
309
310function hasExactScope(input: {
311 readonly scopes: ReadonlyArray<string>;
312 readonly expected: string;
313}): boolean {
314 return input.scopes.length === 1 && input.scopes[0] === input.expected;
315}
316
317function hasBoundedCloudProofLifetime(input: {
318 readonly iat: number;
319 readonly exp: number;
320 readonly nowSeconds: number;
321}): boolean {
322 return (
323 input.exp > input.iat &&
324 input.exp - input.iat <= CLOUD_PROOF_MAX_LIFETIME_SECONDS &&
325 input.iat <= input.nowSeconds + CLOUD_PROOF_CLOCK_SKEW_SECONDS
326 );
327}
328
329const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentHealthProofPayload);
330const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload);
331
332export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* (
333 secrets: ServerSecretStore.ServerSecretStoreShape,
334) {
335 const existingPrivate = yield* secrets.get(CLOUD_LINK_PRIVATE_KEY);
336 const existingPublic = yield* secrets.get(CLOUD_LINK_PUBLIC_KEY);
337 if (existingPrivate && existingPublic) {
338 return {
339 privateKey: bytesToString(existingPrivate),
340 publicKey: bytesToString(existingPublic),
341 };
342 }
343
344 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
345 privateKeyEncoding: { format: "pem", type: "pkcs8" },
346 publicKeyEncoding: { format: "pem", type: "spki" },
347 });
348 yield* secrets.set(CLOUD_LINK_PRIVATE_KEY, stringToBytes(keyPair.privateKey));
349 yield* secrets.set(CLOUD_LINK_PUBLIC_KEY, stringToBytes(keyPair.publicKey));
350 return {
351 privateKey: keyPair.privateKey,
352 publicKey: keyPair.publicKey,
353 };
354});
355
356interface CloudHttpDependencies {
357 readonly secrets: ServerSecretStore.ServerSecretStoreShape;
358 readonly environment: ServerEnvironmentShape;
359 readonly endpointRuntime: CloudManagedEndpointRuntimeShape;
360 readonly environmentAuth: EnvironmentAuth.EnvironmentAuthShape;
361}
362
363const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")(
364 function* (dependencies: CloudHttpDependencies, request: RelayLinkProofRequest) {
365 yield* requireEnvironmentScope(AuthRelayManageScope);
366 const httpRequest = yield* HttpServerRequest.HttpServerRequest;
367 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets);
368 const requestUrl = requestAbsoluteUrl(httpRequest);
369 if (
370 requestUrl === null ||
371 hasForwardedAuthorityHeaders(httpRequest) ||
372 !providerKindMatchesRequestedLinkScopes(request) ||
373 !isAllowedEndpointOrigin({
374 origin: request.origin,
375 requestUrl,
376 })
377 ) {
378 return yield* new EnvironmentHttpBadRequestError({
379 message: "Invalid managed endpoint origin.",
380 });
381 }
382 const now = yield* DateTime.now;
383 const expiresAt = DateTime.add(now, { minutes: 5 });
384 const nowSeconds = Math.floor(now.epochMilliseconds / 1_000);
385 const descriptor = yield* dependencies.environment.getDescriptor;
386 const payload = {
387 iss: `t3-env:${descriptor.environmentId}`,
388 aud: normalizeRelayIssuer(request.relayIssuer),
389 sub: descriptor.environmentId,
390 jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)),
391 iat: nowSeconds,
392 exp: Math.floor(expiresAt.epochMilliseconds / 1_000),
393 challenge: request.challenge,
394 descriptor,
395 environmentId: descriptor.environmentId,
396 environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey),
397 endpoint: request.endpoint,
398 origin: request.origin,
399 scopes: ["agent_activity_notifications", "managed_tunnels"],
400 } satisfies RelayEnvironmentLinkProofPayload;
401 const proof = yield* signRelayJwt({
402 privateKey: keyPair.privateKey,
403 typ: RELAY_LINK_PROOF_TYP,
404 payload,
405 }).pipe(
406 Effect.mapError(
407 (cause) =>
408 new EnvironmentAuth.ServerAuthInternalError({
409 message: "Failed to sign cloud link JWT.",
410 cause,
411 }),
412 ),
413 );
414 yield* appendCloudCredentialResponseHeaders;
415 return proof satisfies RelayEnvironmentLinkProof;
416 },
417 Effect.catchTag("ServerAuthInternalError", (error) =>
418 failEnvironmentCloudInternalError(error.message)(error.cause),
419 ),
420 Effect.catchTags({
421 PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."),
422 SecretStoreError: failEnvironmentCloudInternalError(
423 "Could not generate environment link proof.",
424 ),
425 }),
426);
427
428const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")(
429 function* (dependencies: CloudHttpDependencies, payload: RelayEnvironmentConfigRequest) {
430 yield* requireEnvironmentScope(AuthRelayManageScope);
431 yield* validateRelayConfigPayload(payload);
432 yield* validateLinkedCloudUser({
433 secrets: dependencies.secrets,
434 cloudUserId: payload.cloudUserId,
435 });
436 yield* validateCloudMintPublicKey(payload.cloudMintPublicKey);
437 const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(
438 payload.endpointRuntime,
439 );
440 const ok =
441 endpointRuntimeStatus.status === "disabled" || endpointRuntimeStatus.status === "running";
442 if (!ok) {
443 return yield* new EnvironmentCloudEndpointUnavailableError({
444 message: "Managed endpoint runtime could not be started.",
445 endpointRuntimeStatus,
446 });
447 }
448
449 yield* dependencies.secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl));
450 yield* dependencies.secrets.set(
451 RELAY_ISSUER_SECRET,
452 stringToBytes(payload.relayIssuer ?? payload.relayUrl),
453 );
454 yield* dependencies.secrets.set(CLOUD_LINKED_USER_ID, stringToBytes(payload.cloudUserId));
455 yield* dependencies.secrets.set(
456 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
457 stringToBytes(payload.environmentCredential),
458 );
459 yield* dependencies.secrets.set(
460 CLOUD_MINT_PUBLIC_KEY,
461 stringToBytes(payload.cloudMintPublicKey),
462 );
463 if (payload.endpointRuntime) {
464 const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime);
465 yield* dependencies.secrets.set(
466 CLOUD_ENDPOINT_RUNTIME_CONFIG,
467 stringToBytes(endpointRuntimeJson),
468 );
469 } else {
470 yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG);
471 }
472 return { ok, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult;
473 },
474 Effect.catchTag("ServerAuthInternalError", (error) =>
475 failEnvironmentCloudInternalError(error.message)(error.cause),
476 ),
477 Effect.catchTags({
478 SchemaError: failEnvironmentCloudInternalError(
479 "Could not persist environment relay configuration.",
480 ),
481 SecretStoreError: failEnvironmentCloudInternalError(
482 "Could not persist environment relay configuration.",
483 ),
484 }),
485);
486
487const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")(
488 function* (dependencies: CloudHttpDependencies) {
489 yield* requireEnvironmentScope(AuthRelayManageScope);
490 const [cloudUserId, relayUrl, relayIssuer] = yield* Effect.all(
491 [
492 dependencies.secrets.get(CLOUD_LINKED_USER_ID),
493 dependencies.secrets.get(RELAY_URL_SECRET),
494 dependencies.secrets.get(RELAY_ISSUER_SECRET),
495 ],
496 { concurrency: 3 },
497 );
498 const response = {
499 linked: cloudUserId !== null,
500 cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null,
501 relayUrl: relayUrl ? bytesToString(relayUrl) : null,
502 relayIssuer: relayIssuer ? bytesToString(relayIssuer) : null,
503 } satisfies EnvironmentCloudLinkStateResult;
504 return response;
505 },
506 Effect.catchTag(
507 "SecretStoreError",
508 failEnvironmentCloudInternalError("Could not read environment relay configuration."),
509 ),
510);
511
512const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")(
513 function* (dependencies: CloudHttpDependencies) {
514 yield* requireEnvironmentScope(AuthRelayManageScope);
515 const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(null);
516 yield* Effect.all(
517 [
518 dependencies.secrets.remove(CLOUD_LINKED_USER_ID),
519 dependencies.secrets.remove(RELAY_URL_SECRET),
520 dependencies.secrets.remove(RELAY_ISSUER_SECRET),
521 dependencies.secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
522 dependencies.secrets.remove(CLOUD_MINT_PUBLIC_KEY),
523 dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG),
524 ],
525 { concurrency: 6 },
526 );
527 return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult;
528 },
529 Effect.catchTag(
530 "SecretStoreError",
531 failEnvironmentCloudInternalError("Could not remove environment relay configuration."),
532 ),
533);
534
535const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")(
536 function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) {
537 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets);
538 const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe(
539 Effect.flatMap((bytes) =>
540 bytes
541 ? Effect.succeed(bytesToString(bytes))
542 : Effect.fail(
543 new EnvironmentAuth.ServerAuthInternalError({
544 message: "Cloud mint public key is not installed for this environment.",
545 }),
546 ),
547 ),
548 );
549 const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe(
550 Effect.flatMap((bytes) =>
551 bytes
552 ? Effect.succeed(bytesToString(bytes))
553 : dependencies.secrets.get(RELAY_URL_SECRET).pipe(
554 Effect.flatMap((fallbackBytes) =>
555 fallbackBytes
556 ? Effect.succeed(bytesToString(fallbackBytes))
557 : Effect.fail(
558 new EnvironmentAuth.ServerAuthInternalError({
559 message: "Cloud relay issuer is not installed for this environment.",
560 }),
561 ),
562 ),
563 ),
564 ),
565 );
566 const environmentId = yield* dependencies.environment.getEnvironmentId;
567 const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets);
568 const now = yield* DateTime.now;
569 const nowSeconds = Math.floor(now.epochMilliseconds / 1_000);
570 const proofOption = yield* verifyRelayJwt({
571 publicKey: cloudMintPublicKey,
572 token: request.proof,
573 typ: RELAY_HEALTH_REQUEST_TYP,
574 issuer: normalizeRelayIssuer(relayIssuer),
575 audience: `t3-env:${environmentId}`,
576 nowEpochSeconds: nowSeconds,
577 }).pipe(Effect.flatMap(decodeCloudHealthProof), Effect.option);
578 if (
579 Option.isNone(proofOption) ||
580 proofOption.value.environmentId !== environmentId ||
581 proofOption.value.sub !== linkedCloudUserId ||
582 !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) ||
583 !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" })
584 ) {
585 return yield* new EnvironmentHttpUnauthorizedError({
586 message: "Invalid cloud health request.",
587 });
588 }
589 const proof = proofOption.value;
590
591 const jtiSecretName = `${CLOUD_HEALTH_JTI_PREFIX}${proof.jti}`;
592 const nonceSecretName = `${CLOUD_HEALTH_NONCE_PREFIX}${proof.nonce}`;
593 const consumedReplayGuards = yield* consumeCloudReplayGuards({
594 secrets: dependencies.secrets,
595 names: [jtiSecretName, nonceSecretName],
596 value: stringToBytes(DateTime.formatIso(now)),
597 });
598 if (!consumedReplayGuards) {
599 return yield* new EnvironmentHttpConflictError({
600 message: "Cloud health request was already consumed.",
601 });
602 }
603
604 const descriptor = yield* dependencies.environment.getDescriptor;
605 const responseExpiresAt = DateTime.add(now, { minutes: 5 });
606 const responsePayload = {
607 iss: `t3-env:${environmentId}`,
608 aud: normalizeRelayIssuer(relayIssuer),
609 sub: environmentId,
610 jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)),
611 iat: nowSeconds,
612 exp: Math.floor(responseExpiresAt.epochMilliseconds / 1_000),
613 environmentId,
614 requestNonce: proof.nonce,
615 status: "online",
616 descriptor,
617 checkedAt: DateTime.formatIso(now),
618 } satisfies RelayEnvironmentHealthResponseProofPayload;
619 const responseProof = yield* signRelayJwt({
620 privateKey: keyPair.privateKey,
621 typ: RELAY_HEALTH_RESPONSE_TYP,
622 payload: responsePayload,
623 }).pipe(
624 Effect.mapError(
625 (cause) =>
626 new EnvironmentAuth.ServerAuthInternalError({
627 message: "Failed to sign cloud health JWT.",
628 cause,
629 }),
630 ),
631 );
632 const response = {
633 environmentId,
634 status: "online",
635 descriptor,
636 checkedAt: responsePayload.checkedAt,
637 proof: responseProof,
638 } satisfies RelayEnvironmentHealthResponseShape;
639
640 yield* appendCloudCredentialResponseHeaders;
641 return response;
642 },
643 Effect.catchTag("ServerAuthInternalError", (error) =>
644 failEnvironmentCloudInternalError(error.message)(error.cause),
645 ),
646 Effect.catchTags({
647 PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."),
648 SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."),
649 }),
650);
651
652const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")(
653 function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) {
654 const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets);
655 const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe(
656 Effect.flatMap((bytes) =>
657 bytes
658 ? Effect.succeed(bytesToString(bytes))
659 : Effect.fail(
660 new EnvironmentAuth.ServerAuthInternalError({
661 message: "Cloud mint public key is not installed for this environment.",
662 }),
663 ),
664 ),
665 );
666 const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe(
667 Effect.flatMap((bytes) =>
668 bytes
669 ? Effect.succeed(bytesToString(bytes))
670 : dependencies.secrets.get(RELAY_URL_SECRET).pipe(
671 Effect.flatMap((fallbackBytes) =>
672 fallbackBytes
673 ? Effect.succeed(bytesToString(fallbackBytes))
674 : Effect.fail(
675 new EnvironmentAuth.ServerAuthInternalError({
676 message: "Cloud relay issuer is not installed for this environment.",
677 }),
678 ),
679 ),
680 ),
681 ),
682 );
683 const environmentId = yield* dependencies.environment.getEnvironmentId;
684 const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets);
685 const now = yield* DateTime.now;
686 const nowSeconds = Math.floor(now.epochMilliseconds / 1_000);
687 const proofOption = yield* verifyRelayJwt({
688 publicKey: cloudMintPublicKey,
689 token: request.proof,
690 typ: RELAY_MINT_REQUEST_TYP,
691 issuer: normalizeRelayIssuer(relayIssuer),
692 audience: `t3-env:${environmentId}`,
693 nowEpochSeconds: nowSeconds,
694 }).pipe(Effect.flatMap(decodeCloudMintProof), Effect.option);
695 if (
696 Option.isNone(proofOption) ||
697 proofOption.value.environmentId !== environmentId ||
698 proofOption.value.sub !== linkedCloudUserId ||
699 proofOption.value.cnf.jkt !== proofOption.value.clientProofKeyThumbprint ||
700 !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) ||
701 !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" })
702 ) {
703 return yield* new EnvironmentHttpUnauthorizedError({
704 message: "Invalid cloud mint request.",
705 });
706 }
707 const proof = proofOption.value;
708
709 const jtiSecretName = `${CLOUD_MINT_JTI_PREFIX}${proof.jti}`;
710 const nonceSecretName = `${CLOUD_MINT_NONCE_PREFIX}${proof.nonce}`;
711 const consumedReplayGuards = yield* consumeCloudReplayGuards({
712 secrets: dependencies.secrets,
713 names: [jtiSecretName, nonceSecretName],
714 value: stringToBytes(DateTime.formatIso(now)),
715 });
716 if (!consumedReplayGuards) {
717 return yield* new EnvironmentHttpConflictError({
718 message: "Cloud mint request was already consumed.",
719 });
720 }
721
722 const issued = yield* dependencies.environmentAuth.createPairingLink({
723 scopes: AuthStandardClientScopes,
724 subject: "cloud-connect",
725 ttl: Duration.minutes(2),
726 label: "T3 Cloud connect",
727 proofKeyThumbprint: proof.clientProofKeyThumbprint,
728 });
729 const responsePayload = {
730 iss: `t3-env:${environmentId}`,
731 aud: normalizeRelayIssuer(relayIssuer),
732 sub: environmentId,
733 jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)),
734 iat: nowSeconds,
735 exp: Math.floor(issued.expiresAt.epochMilliseconds / 1_000),
736 environmentId,
737 clientProofKeyThumbprint: proof.clientProofKeyThumbprint,
738 requestNonce: proof.nonce,
739 credential: issued.credential,
740 } satisfies RelayEnvironmentMintResponseProofPayload;
741 const responseProof = yield* signRelayJwt({
742 privateKey: keyPair.privateKey,
743 typ: RELAY_MINT_RESPONSE_TYP,
744 payload: responsePayload,
745 }).pipe(
746 Effect.mapError(
747 (cause) =>
748 new EnvironmentAuth.ServerAuthInternalError({
749 message: "Failed to sign cloud mint JWT.",
750 cause,
751 }),
752 ),
753 );
754 const response = {
755 credential: issued.credential,
756 expiresAt: DateTime.formatIso(issued.expiresAt),
757 proof: responseProof,
758 } satisfies RelayEnvironmentMintResponseShape;
759
760 yield* appendCloudCredentialResponseHeaders;
761 return response;
762 },
763 Effect.catchTag("ServerAuthInternalError", (error) =>
764 failEnvironmentCloudInternalError(error.message)(error.cause),
765 ),
766 Effect.catchTags({
767 PlatformError: failEnvironmentCloudInternalError(
768 "Could not issue cloud connection credential.",
769 ),
770 SecretStoreError: failEnvironmentCloudInternalError(
771 "Could not issue cloud connection credential.",
772 ),
773 }),
774);
775
776export const cloudHttpApiLayer = HttpApiBuilder.group(
777 EnvironmentHttpApi,
778 "cloud",
779 Effect.fnUntraced(function* (handlers) {
780 const dependencies: CloudHttpDependencies = {
781 secrets: yield* ServerSecretStore.ServerSecretStore,
782 environment: yield* ServerEnvironment,
783 endpointRuntime: yield* CloudManagedEndpointRuntime,
784 environmentAuth: yield* EnvironmentAuth.EnvironmentAuth,
785 };
786 return handlers
787 .handle("linkProof", ({ payload }) => cloudLinkProofHandler(dependencies, payload))
788 .handle("relayConfig", ({ payload }) => cloudRelayConfigHandler(dependencies, payload))
789 .handle("linkState", () => cloudLinkStateHandler(dependencies))
790 .handle("unlink", () => cloudUnlinkHandler(dependencies))
791 .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload))
792 .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload))
793 .handle("t3MintCredential", ({ payload }) =>
794 cloudMintCredentialHandler(dependencies, payload),
795 );
796 }),
797);
apps/server/src/httpCors.ts+1−0
@@ -4,6 +4,7 @@ export const browserApiCorsAllowedHeaders = [ · 3 unmodified lines
4 "b3",4 "b3",
5 "traceparent",5 "traceparent",
6 "content-type",6 "content-type",
7 "dpop",
7] as const;8] as const;
8 9
9export const browserApiCorsHeaders = {10export const browserApiCorsHeaders = {
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+11−0
@@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge · 10 unmodified lines
11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";12import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
13import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";13import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
14import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
14 15
15describe("OrchestrationReactor", () => {16describe("OrchestrationReactor", () => {
16 let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null;17 let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null;
@@ -63,6 +64,15 @@ describe("OrchestrationReactor", () => { · 46 unmodified lines
63 drain: Effect.void,64 drain: Effect.void,
64 }),65 }),
65 ),66 ),
67 Layer.provideMerge(
68 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
69 publishThread: () => Effect.void,
70 start: () => {
71 started.push("agent-awareness-relay");
72 return Effect.void;
73 },
74 }),
75 ),
66 ),76 ),
67 );77 );
68 78
@@ -75,6 +85,7 @@ describe("OrchestrationReactor", () => { · 6 unmodified lines
75 "provider-command-reactor",85 "provider-command-reactor",
76 "checkpoint-reactor",86 "checkpoint-reactor",
77 "thread-deletion-reactor",87 "thread-deletion-reactor",
88 "agent-awareness-relay",
78 ]);89 ]);
79 90
80 await Effect.runPromise(Scope.close(scope, Exit.void));91 await Effect.runPromise(Scope.close(scope, Exit.void));
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3−0
@@ -9,18 +9,21 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; · 8 unmodified lines
9import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";9import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
12 13
13export const makeOrchestrationReactor = Effect.gen(function* () {14export const makeOrchestrationReactor = Effect.gen(function* () {
14 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;15 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;
15 const providerCommandReactor = yield* ProviderCommandReactor;16 const providerCommandReactor = yield* ProviderCommandReactor;
16 const checkpointReactor = yield* CheckpointReactor;17 const checkpointReactor = yield* CheckpointReactor;
17 const threadDeletionReactor = yield* ThreadDeletionReactor;18 const threadDeletionReactor = yield* ThreadDeletionReactor;
19 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
18 20
19 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {21 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
20 yield* providerRuntimeIngestion.start();22 yield* providerRuntimeIngestion.start();
21 yield* providerCommandReactor.start();23 yield* providerCommandReactor.start();
22 yield* checkpointReactor.start();24 yield* checkpointReactor.start();
23 yield* threadDeletionReactor.start();25 yield* threadDeletionReactor.start();
26 yield* agentAwarenessRelay.start();
24 });27 });
25 28
26 return {29 return {
apps/server/src/persistence/Layers/AuthPairingLinks.ts+10−1
@@ -41,6 +41,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 40 unmodified lines
41 scopes,41 scopes,
42 subject,42 subject,
43 label,43 label,
44 proof_key_thumbprint,
44 created_at,45 created_at,
45 expires_at,46 expires_at,
46 consumed_at,47 consumed_at,
@@ -53,6 +54,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 6 unmodified lines
53 ${JSON.stringify(input.scopes)},54 ${JSON.stringify(input.scopes)},
54 ${input.subject},55 ${input.subject},
55 ${input.label},56 ${input.label},
57 ${input.proofKeyThumbprint},
56 ${input.createdAt},58 ${input.createdAt},
57 ${input.expiresAt},59 ${input.expiresAt},
58 NULL,60 NULL,
@@ -64,7 +66,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 5 unmodified lines
64 const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({66 const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({
65 Request: ConsumeAuthPairingLinkInput,67 Request: ConsumeAuthPairingLinkInput,
66 Result: AuthPairingLinkRecord,68 Result: AuthPairingLinkRecord,
67 execute: ({ credential, consumedAt, now }) =>69 execute: ({ credential, proofKeyThumbprint, consumedAt, now }) =>
68 sql`70 sql`
69 UPDATE auth_pairing_links71 UPDATE auth_pairing_links
70 SET consumed_at = ${consumedAt}72 SET consumed_at = ${consumedAt}
@@ -72,6 +74,10 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 1 unmodified line
72 AND revoked_at IS NULL74 AND revoked_at IS NULL
73 AND consumed_at IS NULL75 AND consumed_at IS NULL
74 AND expires_at > ${now}76 AND expires_at > ${now}
77 AND (
78 proof_key_thumbprint IS NULL
79 OR proof_key_thumbprint = ${proofKeyThumbprint}
80 )
75 RETURNING81 RETURNING
76 id AS "id",82 id AS "id",
77 credential AS "credential",83 credential AS "credential",
@@ -79,6 +85,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 1 unmodified line
79 scopes AS "scopes",85 scopes AS "scopes",
80 subject AS "subject",86 subject AS "subject",
81 label AS "label",87 label AS "label",
88 proof_key_thumbprint AS "proofKeyThumbprint",
82 created_at AS "createdAt",89 created_at AS "createdAt",
83 expires_at AS "expiresAt",90 expires_at AS "expiresAt",
84 consumed_at AS "consumedAt",91 consumed_at AS "consumedAt",
@@ -98,6 +105,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 13 unmodified lines
98 scopes AS "scopes",105 scopes AS "scopes",
99 subject AS "subject",106 subject AS "subject",
100 label AS "label",107 label AS "label",
108 proof_key_thumbprint AS "proofKeyThumbprint",
101 created_at AS "createdAt",109 created_at AS "createdAt",
102 expires_at AS "expiresAt",110 expires_at AS "expiresAt",
103 consumed_at AS "consumedAt",111 consumed_at AS "consumedAt",
@@ -136,6 +144,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 32 unmodified lines
136 scopes AS "scopes",144 scopes AS "scopes",
137 subject AS "subject",145 subject AS "subject",
138 label AS "label",146 label AS "label",
147 proof_key_thumbprint AS "proofKeyThumbprint",
139 created_at AS "createdAt",148 created_at AS "createdAt",
140 expires_at AS "expiresAt",149 expires_at AS "expiresAt",
141 consumed_at AS "consumedAt",150 consumed_at AS "consumedAt",
apps/server/src/persistence/Migrations.ts+2−0
@@ -44,6 +44,7 @@ import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts · 43 unmodified lines
44import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";44import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";
45import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";45import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";
46import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";46import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
47import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
47 48
48/**49/**
49 * Migration loader with all migrations defined inline.50 * Migration loader with all migrations defined inline.
@@ -87,6 +88,7 @@ export const migrationEntries = [ · 37 unmodified lines
87 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],88 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],
88 [30, "ProjectionThreadShellArchiveIndexes", Migration0030],89 [30, "ProjectionThreadShellArchiveIndexes", Migration0030],
89 [31, "AuthAuthorizationScopes", Migration0031],90 [31, "AuthAuthorizationScopes", Migration0031],
91 [32, "AuthPairingProofKeyThumbprint", Migration0032],
90] as const;92] as const;
91 93
92export const makeMigrationLoader = (throughId?: number) =>94export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/032_AuthPairingProofKeyThumbprint.ts+16−0
@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 const pairingLinkColumns = yield* sql<{ readonly name: string }>`
8 PRAGMA table_info(auth_pairing_links)
9 `;
10 if (!pairingLinkColumns.some((column) => column.name === "proof_key_thumbprint")) {
11 yield* sql`
12 ALTER TABLE auth_pairing_links
13 ADD COLUMN proof_key_thumbprint TEXT
14 `;
15 }
16});
apps/server/src/persistence/Services/AuthPairingLinks.ts+3−0
@@ -13,6 +13,7 @@ export const AuthPairingLinkRecord = Schema.Struct({ · 12 unmodified lines
13 scopes: Schema.fromJsonString(AuthEnvironmentScopes),13 scopes: Schema.fromJsonString(AuthEnvironmentScopes),
14 subject: Schema.String,14 subject: Schema.String,
15 label: Schema.NullOr(Schema.String),15 label: Schema.NullOr(Schema.String),
16 proofKeyThumbprint: Schema.NullOr(Schema.String),
16 createdAt: Schema.DateTimeUtcFromString,17 createdAt: Schema.DateTimeUtcFromString,
17 expiresAt: Schema.DateTimeUtcFromString,18 expiresAt: Schema.DateTimeUtcFromString,
18 consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString),19 consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
@@ -27,6 +28,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ · 8 unmodified lines
27 scopes: AuthEnvironmentScopes,28 scopes: AuthEnvironmentScopes,
28 subject: Schema.String,29 subject: Schema.String,
29 label: Schema.NullOr(Schema.String),30 label: Schema.NullOr(Schema.String),
31 proofKeyThumbprint: Schema.NullOr(Schema.String),
30 createdAt: Schema.DateTimeUtcFromString,32 createdAt: Schema.DateTimeUtcFromString,
31 expiresAt: Schema.DateTimeUtcFromString,33 expiresAt: Schema.DateTimeUtcFromString,
32});34});
@@ -34,6 +36,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; · 1 unmodified line
34 36
35export const ConsumeAuthPairingLinkInput = Schema.Struct({37export const ConsumeAuthPairingLinkInput = Schema.Struct({
36 credential: Schema.String,38 credential: Schema.String,
39 proofKeyThumbprint: Schema.NullOr(Schema.String),
37 consumedAt: Schema.DateTimeUtcFromString,40 consumedAt: Schema.DateTimeUtcFromString,
38 now: Schema.DateTimeUtcFromString,41 now: Schema.DateTimeUtcFromString,
39});42});
apps/server/src/relay/AgentAwarenessRelay.test.ts+634−0
@@ -0,0 +1,634 @@
1import * as NodeCrypto from "node:crypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3
4import type {
5 EnvironmentId,
6 ExecutionEnvironmentDescriptor,
7 OrchestrationEvent,
8 OrchestrationProjectShell,
9 OrchestrationShellSnapshot,
10 OrchestrationThreadShell,
11 ProjectId,
12 ThreadId,
13 TurnId,
14} from "@t3tools/contracts";
15import type {
16 RelayAgentActivityPublishProofPayload,
17 RelayAgentActivityState,
18} from "@t3tools/contracts/relay";
19import { CommandId, ProviderInstanceId } from "@t3tools/contracts";
20import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";
21import { describe, expect, it } from "@effect/vitest";
22import * as Deferred from "effect/Deferred";
23import * as Effect from "effect/Effect";
24import * as Layer from "effect/Layer";
25import * as Option from "effect/Option";
26import * as Queue from "effect/Queue";
27import * as Stream from "effect/Stream";
28
29import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
30import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
31import {
32 OrchestrationEngineService,
33 type OrchestrationEngineShape,
34} from "../orchestration/Services/OrchestrationEngine.ts";
35import {
36 ProjectionSnapshotQuery,
37 type ProjectionSnapshotQueryShape,
38} from "../orchestration/Services/ProjectionSnapshotQuery.ts";
39import {
40 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
41 RELAY_ISSUER_SECRET,
42 RELAY_URL_SECRET,
43} from "../cloud/config.ts";
44import * as AgentAwarenessRelay from "./AgentAwarenessRelay.ts";
45
46const state: RelayAgentActivityState = {
47 environmentId: "env" as RelayAgentActivityState["environmentId"],
48 threadId: "thread" as RelayAgentActivityState["threadId"],
49 projectTitle: "Project",
50 threadTitle: "Thread",
51 modelTitle: "gpt-5.4",
52 phase: "running",
53 headline: "Running",
54 updatedAt: "2026-05-25T00:00:00.000Z",
55 deepLink: "/threads/env/thread",
56};
57
58const encodeSecret = (value: string): Uint8Array => new TextEncoder().encode(value);
59
60function makeMemorySecretStore() {
61 const values = new Map<string, Uint8Array>();
62 const store = {
63 get: ((name) =>
64 Effect.sync(
65 () => values.get(name) ?? null,
66 )) satisfies ServerSecretStore.ServerSecretStoreShape["get"],
67 set: ((name, value) =>
68 Effect.sync(() => {
69 values.set(name, Uint8Array.from(value));
70 })) satisfies ServerSecretStore.ServerSecretStoreShape["set"],
71 create: ((name, value) =>
72 Effect.sync(() => {
73 values.set(name, Uint8Array.from(value));
74 })) satisfies ServerSecretStore.ServerSecretStoreShape["create"],
75 getOrCreateRandom: ((name, bytes) =>
76 Effect.sync(() => {
77 const existing = values.get(name);
78 if (existing) {
79 return existing;
80 }
81 const generated = new Uint8Array(bytes);
82 values.set(name, generated);
83 return generated;
84 })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"],
85 remove: ((name) =>
86 Effect.sync(() => {
87 values.delete(name);
88 })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"],
89 } satisfies ServerSecretStore.ServerSecretStoreShape;
90 return {
91 store,
92 setString: (name: string, value: string) => store.set(name, encodeSecret(value)),
93 };
94}
95
96describe.sequential("signRelayAgentActivityPublishProof", () => {
97 it("derives the thread id from the aggregate id for thread events without payload thread ids", () => {
98 const threadId = "thread-aggregate-1" as ThreadId;
99 const now = "2026-05-25T00:00:00.000Z";
100 const event = {
101 type: "thread.activity-appended",
102 sequence: 1,
103 eventId: "evt-aggregate-1",
104 commandId: CommandId.make("cmd-1"),
105 aggregateKind: "thread",
106 aggregateId: threadId,
107 actor: { kind: "server" },
108 payload: {},
109 occurredAt: now,
110 } as unknown as OrchestrationEvent;
111
112 expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId);
113 });
114
115 it("does not publish streaming content or non-awareness activity events", () => {
116 const now = "2026-05-25T00:00:00.000Z";
117 const base = {
118 sequence: 1,
119 eventId: "evt-1",
120 commandId: CommandId.make("cmd-1"),
121 aggregateKind: "thread",
122 aggregateId: "thread-1" as ThreadId,
123 occurredAt: now,
124 };
125
126 expect(
127 AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
128 ...base,
129 type: "thread.message-sent",
130 payload: {
131 threadId: "thread-1" as ThreadId,
132 streaming: true,
133 },
134 } as unknown as OrchestrationEvent),
135 ).toBe(false);
136 expect(
137 AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
138 ...base,
139 type: "thread.activity-appended",
140 payload: {
141 threadId: "thread-1" as ThreadId,
142 activity: {
143 kind: "task.progress",
144 },
145 },
146 } as unknown as OrchestrationEvent),
147 ).toBe(false);
148 expect(
149 AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
150 ...base,
151 type: "thread.activity-appended",
152 payload: {
153 threadId: "thread-1" as ThreadId,
154 activity: {
155 kind: "approval.requested",
156 },
157 },
158 } as unknown as OrchestrationEvent),
159 ).toBe(true);
160 expect(
161 AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({
162 ...base,
163 type: "thread.message-sent",
164 payload: {
165 threadId: "thread-1" as ThreadId,
166 streaming: false,
167 },
168 } as unknown as OrchestrationEvent),
169 ).toBe(true);
170 });
171
172 it("deduplicates awareness state updates whose only change is their event timestamp", () => {
173 expect(AgentAwarenessRelay.agentAwarenessPublishIdentity(state)).toBe(
174 AgentAwarenessRelay.agentAwarenessPublishIdentity({
175 ...state,
176 updatedAt: "2026-05-25T00:10:00.000Z",
177 }),
178 );
179 expect(AgentAwarenessRelay.agentAwarenessPublishIdentity(state)).not.toBe(
180 AgentAwarenessRelay.agentAwarenessPublishIdentity({
181 ...state,
182 phase: "completed",
183 headline: "Agent finished",
184 }),
185 );
186 });
187
188 it("resolves a null publish state when a thread or project snapshot disappeared", () => {
189 const environmentId = "env-1" as EnvironmentId;
190 const threadId = "thread-1" as ThreadId;
191 const thread = {
192 id: threadId,
193 projectId: "project-1" as ProjectId,
194 title: "Deleted thread",
195 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
196 session: null,
197 latestTurn: null,
198 updatedAt: "2026-05-25T00:00:00.000Z",
199 hasPendingApprovals: false,
200 hasPendingUserInput: false,
201 } as OrchestrationThreadShell;
202
203 expect(
204 AgentAwarenessRelay.resolveAgentAwarenessRelayPublishSnapshot({
205 environmentId,
206 threadId,
207 thread: Option.none(),
208 project: Option.none(),
209 }),
210 ).toEqual({
211 projectId: null,
212 state: null,
213 reason: "thread-not-found",
214 });
215
216 expect(
217 AgentAwarenessRelay.resolveAgentAwarenessRelayPublishSnapshot({
218 environmentId,
219 threadId,
220 thread: Option.some(thread),
221 project: Option.none(),
222 }),
223 ).toEqual({
224 projectId: "project-1",
225 state: null,
226 reason: "project-not-found",
227 });
228 });
229
230 it("selects only active shell snapshot threads for startup catch-up", () => {
231 const now = "2026-05-25T00:00:00.000Z";
232 const environmentId = "env-1" as EnvironmentId;
233 const projectId = "project-1" as ProjectId;
234 const activeThreadId = "thread-active" as ThreadId;
235 const idleThreadId = "thread-idle" as ThreadId;
236
237 const baseThread = {
238 projectId,
239 title: "Run remote agent",
240 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
241 runtimeMode: "full-access",
242 interactionMode: "default",
243 branch: null,
244 worktreePath: null,
245 latestTurn: null,
246 createdAt: now,
247 updatedAt: now,
248 archivedAt: null,
249 session: null,
250 latestUserMessageAt: null,
251 hasPendingApprovals: false,
252 hasPendingUserInput: false,
253 hasActionableProposedPlan: false,
254 } satisfies Omit<OrchestrationThreadShell, "id">;
255
256 expect(
257 AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({
258 environmentId,
259 projects: [
260 {
261 id: projectId,
262 title: "T3 Code",
263 },
264 ],
265 threads: [
266 {
267 ...baseThread,
268 id: activeThreadId,
269 latestTurn: {
270 turnId: "turn-1" as TurnId,
271 state: "running",
272 requestedAt: now,
273 startedAt: now,
274 completedAt: null,
275 assistantMessageId: null,
276 },
277 },
278 {
279 ...baseThread,
280 id: idleThreadId,
281 },
282 {
283 ...baseThread,
284 id: "thread-missing-project" as ThreadId,
285 projectId: "missing-project" as ProjectId,
286 latestTurn: {
287 turnId: "turn-2" as TurnId,
288 state: "running",
289 requestedAt: now,
290 startedAt: now,
291 completedAt: null,
292 assistantMessageId: null,
293 },
294 },
295 ],
296 }),
297 ).toEqual([activeThreadId]);
298 });
299
300 it("signs the activity publish JWT and rejects tampering", async () => {
301 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
302 privateKeyEncoding: { format: "pem", type: "pkcs8" },
303 publicKeyEncoding: { format: "pem", type: "spki" },
304 });
305 const payload = {
306 iss: "t3-env:env",
307 aud: "https://relay.example.test",
308 sub: "env",
309 jti: "nonce-1",
310 iat: 100,
311 exp: 200,
312 environmentId: state.environmentId,
313 threadId: state.threadId,
314 state,
315 } satisfies RelayAgentActivityPublishProofPayload;
316 const proof = await Effect.runPromise(
317 AgentAwarenessRelay.signRelayAgentActivityPublishProof({
318 privateKey: keyPair.privateKey,
319 payload,
320 }),
321 );
322
323 await expect(
324 Effect.runPromise(
325 verifyRelayJwt({
326 publicKey: keyPair.publicKey,
327 token: proof,
328 typ: RELAY_ACTIVITY_PUBLISH_TYP,
329 issuer: "t3-env:env",
330 audience: "https://relay.example.test",
331 nowEpochSeconds: 150,
332 }),
333 ),
334 ).resolves.toMatchObject({ jti: "nonce-1", state });
335 await expect(
336 Effect.runPromise(
337 verifyRelayJwt({
338 publicKey: keyPair.publicKey,
339 token: (() => {
340 const [header, body, signature = ""] = proof.split(".");
341 const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`;
342 return `${header}.${body}.${corruptedSignature}`;
343 })(),
344 typ: RELAY_ACTIVITY_PUBLISH_TYP,
345 issuer: "t3-env:env",
346 audience: "https://relay.example.test",
347 nowEpochSeconds: 150,
348 }),
349 ),
350 ).rejects.toBeDefined();
351 });
352
353 it.effect("keeps the orchestration listener armed until relay config is installed", () =>
354 Effect.scoped(
355 Effect.gen(function* () {
356 const events = yield* Queue.unbounded<OrchestrationEvent>();
357 const threadShellRequested = yield* Deferred.make<void>();
358 const secrets = makeMemorySecretStore();
359 const now = "2026-05-25T00:00:00.000Z";
360 const projectId = "project-1" as ProjectId;
361 const threadId = "thread-1" as ThreadId;
362 const environmentId = "env-1" as EnvironmentId;
363
364 const project = {
365 id: projectId,
366 title: "T3 Code",
367 workspaceRoot: "/workspace",
368 repositoryIdentity: null,
369 defaultModelSelection: null,
370 scripts: [],
371 createdAt: now,
372 updatedAt: now,
373 } satisfies OrchestrationProjectShell;
374
375 const thread = {
376 id: threadId,
377 projectId,
378 title: "Run remote agent",
379 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
380 runtimeMode: "full-access",
381 interactionMode: "default",
382 branch: null,
383 worktreePath: null,
384 latestTurn: {
385 turnId: "turn-1" as TurnId,
386 state: "running",
387 requestedAt: now,
388 startedAt: now,
389 completedAt: null,
390 assistantMessageId: null,
391 },
392 createdAt: now,
393 updatedAt: now,
394 archivedAt: null,
395 session: {
396 threadId,
397 status: "running",
398 providerName: "Codex",
399 runtimeMode: "full-access",
400 activeTurnId: "turn-1" as TurnId,
401 lastError: null,
402 updatedAt: now,
403 },
404 latestUserMessageAt: now,
405 hasPendingApprovals: false,
406 hasPendingUserInput: false,
407 hasActionableProposedPlan: false,
408 } satisfies OrchestrationThreadShell;
409
410 const orchestrationEngine = {
411 readEvents: () => Stream.empty,
412 dispatch: () => Effect.succeed({ sequence: 1 }),
413 streamDomainEvents: Stream.fromQueue(events),
414 } satisfies OrchestrationEngineShape;
415
416 const snapshotQuery = {
417 getShellSnapshot: () =>
418 Effect.succeed({
419 snapshotSequence: 1,
420 projects: [project],
421 threads: [thread],
422 updatedAt: now,
423 } satisfies OrchestrationShellSnapshot),
424 getThreadShellById: () =>
425 Deferred.succeed(threadShellRequested, undefined).pipe(
426 Effect.ignore,
427 Effect.as(Option.some(thread)),
428 ),
429 getProjectShellById: () => Effect.succeed(Option.some(project)),
430 } as unknown as ProjectionSnapshotQueryShape;
431
432 const descriptor = {
433 environmentId,
434 label: "Test Desktop",
435 platform: {
436 os: "darwin",
437 arch: "arm64",
438 },
439 serverVersion: "0.0.0-test",
440 capabilities: {
441 repositoryIdentity: true,
442 },
443 } satisfies ExecutionEnvironmentDescriptor;
444
445 const layer = Layer.mergeAll(
446 Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store),
447 Layer.succeed(ServerEnvironment, {
448 getEnvironmentId: Effect.succeed(environmentId),
449 getDescriptor: Effect.succeed(descriptor),
450 }),
451 Layer.succeed(OrchestrationEngineService, orchestrationEngine),
452 Layer.succeed(ProjectionSnapshotQuery, snapshotQuery),
453 );
454
455 yield* Effect.gen(function* () {
456 const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
457 yield* relay.start();
458 yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1");
459 yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential");
460 yield* Queue.offer(events, {
461 type: "thread.activity-appended",
462 sequence: 1,
463 eventId: "evt-1",
464 commandId: CommandId.make("cmd-1"),
465 aggregateKind: "thread",
466 aggregateId: threadId,
467 actor: { kind: "server" },
468 payload: {
469 threadId,
470 activity: {
471 kind: "approval.requested",
472 },
473 },
474 occurredAt: now,
475 } as unknown as OrchestrationEvent);
476
477 yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds"));
478 }).pipe(
479 Effect.provide(
480 AgentAwarenessRelay.layer.pipe(
481 Layer.provide(layer),
482 Layer.provideMerge(NodeServices.layer),
483 ),
484 ),
485 );
486 }),
487 ),
488 );
489
490 it.effect("publishes agent activity to the relay transport URL, not the relay issuer", () =>
491 Effect.scoped(
492 Effect.gen(function* () {
493 const originalFetch = globalThis.fetch;
494 const context = yield* Effect.context<never>();
495 const runFork = Effect.runForkWith(context);
496 const events = yield* Queue.unbounded<OrchestrationEvent>();
497 const fetchSeen = yield* Deferred.make<URL>();
498 const secrets = makeMemorySecretStore();
499 const now = "2026-05-25T00:00:00.000Z";
500 const projectId = "project-1" as ProjectId;
501 const threadId = "thread-1" as ThreadId;
502 const environmentId = "env-1" as EnvironmentId;
503
504 const project = {
505 id: projectId,
506 title: "T3 Code",
507 workspaceRoot: "/workspace",
508 repositoryIdentity: null,
509 defaultModelSelection: null,
510 scripts: [],
511 createdAt: now,
512 updatedAt: now,
513 } satisfies OrchestrationProjectShell;
514
515 const thread = {
516 id: threadId,
517 projectId,
518 title: "Run remote agent",
519 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
520 runtimeMode: "full-access",
521 interactionMode: "default",
522 branch: null,
523 worktreePath: null,
524 latestTurn: {
525 turnId: "turn-1" as TurnId,
526 state: "running",
527 requestedAt: now,
528 startedAt: now,
529 completedAt: null,
530 assistantMessageId: null,
531 },
532 createdAt: now,
533 updatedAt: now,
534 archivedAt: null,
535 session: {
536 threadId,
537 status: "running",
538 providerName: "Codex",
539 runtimeMode: "full-access",
540 activeTurnId: "turn-1" as TurnId,
541 lastError: null,
542 updatedAt: now,
543 },
544 latestUserMessageAt: now,
545 hasPendingApprovals: false,
546 hasPendingUserInput: false,
547 hasActionableProposedPlan: false,
548 } satisfies OrchestrationThreadShell;
549
550 const descriptor = {
551 environmentId,
552 label: "Test Desktop",
553 platform: {
554 os: "darwin",
555 arch: "arm64",
556 },
557 serverVersion: "0.0.0-test",
558 capabilities: {
559 repositoryIdentity: true,
560 },
561 } satisfies ExecutionEnvironmentDescriptor;
562
563 globalThis.fetch = ((input: Parameters<typeof fetch>[0]) => {
564 const url = new URL(input instanceof Request ? input.url : input.toString());
565 runFork(Deferred.succeed(fetchSeen, url));
566 return Promise.resolve(Response.json({ ok: true, deliveries: [] }));
567 }) as unknown as typeof fetch;
568 yield* Effect.addFinalizer(() =>
569 Effect.sync(() => {
570 globalThis.fetch = originalFetch;
571 }),
572 );
573
574 const layer = Layer.mergeAll(
575 Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store),
576 Layer.succeed(ServerEnvironment, {
577 getEnvironmentId: Effect.succeed(environmentId),
578 getDescriptor: Effect.succeed(descriptor),
579 }),
580 Layer.succeed(OrchestrationEngineService, {
581 readEvents: () => Stream.empty,
582 dispatch: () => Effect.succeed({ sequence: 1 }),
583 streamDomainEvents: Stream.fromQueue(events),
584 } satisfies OrchestrationEngineShape),
585 Layer.succeed(ProjectionSnapshotQuery, {
586 getShellSnapshot: () =>
587 Effect.succeed({
588 snapshotSequence: 1,
589 projects: [project],
590 threads: [thread],
591 updatedAt: now,
592 } satisfies OrchestrationShellSnapshot),
593 getThreadShellById: () => Effect.succeed(Option.some(thread)),
594 getProjectShellById: () => Effect.succeed(Option.some(project)),
595 } as unknown as ProjectionSnapshotQueryShape),
596 );
597
598 yield* Effect.gen(function* () {
599 const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
600 yield* secrets.setString(RELAY_URL_SECRET, "https://transport.example.test");
601 yield* secrets.setString(RELAY_ISSUER_SECRET, "https://issuer.example.test");
602 yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential");
603 yield* relay.start();
604 yield* Queue.offer(events, {
605 type: "thread.activity-appended",
606 sequence: 1,
607 eventId: "evt-1",
608 commandId: CommandId.make("cmd-1"),
609 aggregateKind: "thread",
610 aggregateId: threadId,
611 actor: { kind: "server" },
612 payload: {
613 threadId,
614 activity: {
615 kind: "approval.requested",
616 },
617 },
618 occurredAt: now,
619 } as unknown as OrchestrationEvent);
620
621 const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds"));
622 expect(url.origin).toBe("https://transport.example.test");
623 }).pipe(
624 Effect.provide(
625 AgentAwarenessRelay.layer.pipe(
626 Layer.provide(layer),
627 Layer.provideMerge(NodeServices.layer),
628 ),
629 ),
630 );
631 }),
632 ),
633 );
634});
apps/server/src/relay/AgentAwarenessRelay.ts+459−0
@@ -0,0 +1,459 @@
1import {
2 RelayApi,
3 type RelayAgentActivityPublishProofPayload,
4 type RelayAgentActivityState,
5} from "@t3tools/contracts/relay";
6import type {
7 EnvironmentId,
8 OrchestrationEvent,
9 OrchestrationProjectShell,
10 OrchestrationThreadShell,
11 ThreadId,
12} from "@t3tools/contracts";
13import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
14import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
15import {
16 RELAY_ACTIVITY_PUBLISH_TYP,
17 signRelayJwt,
18 normalizeRelayIssuer,
19} from "@t3tools/shared/relayJwt";
20import * as Cause from "effect/Cause";
21import * as Context from "effect/Context";
22import * as Crypto from "effect/Crypto";
23import * as DateTime from "effect/DateTime";
24import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";
27import * as Ref from "effect/Ref";
28import type * as Scope from "effect/Scope";
29import * as Stream from "effect/Stream";
30import { FetchHttpClient } from "effect/unstable/http";
31import * as HttpClient from "effect/unstable/http/HttpClient";
32import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
33import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
34
35import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
36import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/http.ts";
37import {
38 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
39 RELAY_ISSUER_SECRET,
40 RELAY_URL_SECRET,
41} from "../cloud/config.ts";
42import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
43import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
44import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
45
46export interface AgentAwarenessRelayShape {
47 readonly publishThread: (threadId: ThreadId) => Effect.Effect<void>;
48 readonly start: () => Effect.Effect<void, never, Scope.Scope>;
49}
50
51export class AgentAwarenessRelay extends Context.Service<
52 AgentAwarenessRelay,
53 AgentAwarenessRelayShape
54>()("t3/relay/AgentAwarenessRelay") {}
55
56export function eventThreadId(event: OrchestrationEvent): ThreadId | null {
57 const payload = event.payload as { readonly threadId?: unknown };
58 if (typeof payload.threadId === "string") {
59 return payload.threadId as ThreadId;
60 }
61 if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") {
62 return event.aggregateId as ThreadId;
63 }
64 return null;
65}
66
67export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean {
68 switch (event.type) {
69 case "thread.message-sent":
70 return !event.payload.streaming;
71 case "thread.proposed-plan-upserted":
72 case "thread.runtime-mode-set":
73 case "thread.interaction-mode-set":
74 return false;
75 case "thread.activity-appended":
76 return (
77 event.payload.activity.kind === "approval.requested" ||
78 event.payload.activity.kind === "approval.resolved" ||
79 event.payload.activity.kind === "provider.approval.respond.failed" ||
80 event.payload.activity.kind === "user-input.requested" ||
81 event.payload.activity.kind === "user-input.resolved" ||
82 event.payload.activity.kind === "runtime.error"
83 );
84 default:
85 return true;
86 }
87}
88
89export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): string {
90 if (state === null) {
91 return "null";
92 }
93 const { updatedAt: _updatedAt, ...meaningfulState } = state;
94 return JSON.stringify(meaningfulState);
95}
96
97function relayEnvironmentClient(token: string) {
98 return HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", `Bearer ${token}`));
99}
100
101function deliveryStats(
102 deliveries: ReadonlyArray<{
103 readonly ok: boolean;
104 readonly queued?: boolean | undefined;
105 readonly kind: string;
106 readonly apnsStatus?: number | null;
107 readonly apnsReason?: string | null;
108 }>,
109) {
110 let queued = 0;
111 let successful = 0;
112 let failed = 0;
113 const failedReasons: string[] = [];
114 const kinds = new Set<string>();
115
116 for (const delivery of deliveries) {
117 kinds.add(delivery.kind);
118 if (delivery.queued) {
119 queued += 1;
120 continue;
121 }
122 if (delivery.ok) {
123 successful += 1;
124 continue;
125 }
126 failed += 1;
127 failedReasons.push(`${delivery.apnsStatus ?? "transport"}:${delivery.apnsReason ?? "unknown"}`);
128 }
129
130 return {
131 total: deliveries.length,
132 queued,
133 successful,
134 failed,
135 kinds: [...kinds],
136 failedReasons,
137 };
138}
139
140export function signRelayAgentActivityPublishProof(input: {
141 readonly privateKey: string;
142 readonly payload: RelayAgentActivityPublishProofPayload;
143}) {
144 return signRelayJwt({
145 privateKey: input.privateKey,
146 typ: RELAY_ACTIVITY_PUBLISH_TYP,
147 payload: input.payload,
148 });
149}
150
151const makePublishProof = Effect.fn("makePublishProof")(function* (input: {
152 readonly privateKey: string;
153 readonly relayIssuer: string;
154 readonly environmentId: string;
155 readonly threadId: ThreadId;
156 readonly state: RelayAgentActivityState | null;
157 readonly jti: string;
158}) {
159 const now = yield* DateTime.now;
160 const expiresAt = DateTime.add(now, { minutes: 5 });
161 const payload = {
162 iss: `t3-env:${input.environmentId}`,
163 aud: normalizeRelayIssuer(input.relayIssuer),
164 sub: input.environmentId,
165 jti: input.jti,
166 iat: Math.floor(now.epochMilliseconds / 1_000),
167 exp: Math.floor(expiresAt.epochMilliseconds / 1_000),
168 environmentId: input.environmentId as RelayAgentActivityPublishProofPayload["environmentId"],
169 threadId: input.threadId,
170 state: input.state,
171 } satisfies RelayAgentActivityPublishProofPayload;
172 return yield* signRelayAgentActivityPublishProof({ privateKey: input.privateKey, payload });
173});
174
175export function resolveAgentAwarenessRelayPublishSnapshot(input: {
176 readonly environmentId: EnvironmentId;
177 readonly threadId: ThreadId;
178 readonly thread: Option.Option<OrchestrationThreadShell>;
179 readonly project: Option.Option<OrchestrationProjectShell>;
180}): {
181 readonly projectId: string | null;
182 readonly state: RelayAgentActivityState | null;
183 readonly reason: "snapshot" | "thread-not-found" | "project-not-found";
184} {
185 if (Option.isNone(input.thread)) {
186 return {
187 projectId: null,
188 state: null,
189 reason: "thread-not-found",
190 };
191 }
192 if (Option.isNone(input.project)) {
193 return {
194 projectId: input.thread.value.projectId,
195 state: null,
196 reason: "project-not-found",
197 };
198 }
199 return {
200 projectId: input.thread.value.projectId,
201 state: projectThreadAwareness({
202 environmentId: input.environmentId,
203 project: input.project.value,
204 thread: input.thread.value,
205 }),
206 reason: "snapshot",
207 };
208}
209
210export function resolveAgentAwarenessRelayActiveThreadIds(input: {
211 readonly environmentId: EnvironmentId;
212 readonly projects: ReadonlyArray<Pick<OrchestrationProjectShell, "id" | "title">>;
213 readonly threads: ReadonlyArray<OrchestrationThreadShell>;
214}): ReadonlyArray<ThreadId> {
215 const projectById = new Map(input.projects.map((project) => [project.id, project]));
216 return input.threads
217 .filter((thread) => {
218 const project = projectById.get(thread.projectId);
219 if (!project) {
220 return false;
221 }
222 return (
223 projectThreadAwareness({
224 environmentId: input.environmentId,
225 project,
226 thread,
227 }) !== null
228 );
229 })
230 .map((thread) => thread.id);
231}
232
233const make = Effect.gen(function* () {
234 const secrets = yield* ServerSecretStore.ServerSecretStore;
235 const serverEnvironment = yield* ServerEnvironment;
236 const snapshotQuery = yield* ProjectionSnapshotQuery;
237 const orchestrationEngine = yield* OrchestrationEngineService;
238 const crypto = yield* Crypto.Crypto;
239 const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
240 const activeSnapshotPublishedRef = yield* Ref.make(false);
241 const publishedStateByThreadRef = yield* Ref.make(new Map<ThreadId, string>());
242
243 const readSecretString = (name: string) =>
244 secrets.get(name).pipe(Effect.map((bytes) => (bytes ? new TextDecoder().decode(bytes) : null)));
245
246 const readRelayConfig = Effect.gen(function* () {
247 const [url, issuer, environmentCredential] = yield* Effect.all([
248 readSecretString(RELAY_URL_SECRET),
249 readSecretString(RELAY_ISSUER_SECRET),
250 readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
251 ]);
252 return url && environmentCredential
253 ? { url, issuer: issuer ?? url, environmentCredential }
254 : null;
255 });
256
257 const makeRelayClient = (relayConfig: {
258 readonly url: string;
259 readonly environmentCredential: string;
260 }) =>
261 HttpApiClient.make(RelayApi, {
262 baseUrl: relayConfig.url,
263 transformClient: relayEnvironmentClient(relayConfig.environmentCredential),
264 }).pipe(Effect.provide(FetchHttpClient.layer));
265
266 const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) {
267 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
268 if (!relayConfig) {
269 yield* Effect.logDebug("agent awareness relay publish skipped; relay config missing", {
270 threadId,
271 });
272 return;
273 }
274 const relayClient = yield* makeRelayClient(relayConfig);
275 const environmentId = yield* serverEnvironment.getEnvironmentId;
276
277 const publishState = (input: {
278 readonly projectId: string | null;
279 readonly state: RelayAgentActivityState | null;
280 readonly reason: string;
281 }) =>
282 Effect.gen(function* () {
283 const proof = yield* makePublishProof({
284 privateKey: cloudLinkKeyPair.privateKey,
285 relayIssuer: relayConfig.issuer,
286 environmentId,
287 threadId,
288 state: input.state,
289 jti: yield* crypto.randomUUIDv4,
290 });
291
292 yield* Effect.logInfo("agent awareness relay publishing thread", {
293 environmentId,
294 threadId,
295 projectId: input.projectId,
296 statePhase: input.state?.phase ?? null,
297 hasState: input.state !== null,
298 reason: input.reason,
299 });
300
301 const response = yield* relayClient.server.publishAgentActivity({
302 params: {
303 environmentId,
304 threadId,
305 },
306 payload: {
307 state: input.state,
308 proof,
309 },
310 });
311
312 yield* Effect.logInfo("agent awareness relay publish completed", {
313 environmentId,
314 threadId,
315 ok: response.ok,
316 deliveries: deliveryStats(response.deliveries),
317 });
318 });
319
320 const thread = yield* snapshotQuery.getThreadShellById(threadId);
321 const project = Option.isSome(thread)
322 ? yield* snapshotQuery.getProjectShellById(thread.value.projectId)
323 : Option.none<OrchestrationProjectShell>();
324 const snapshot = resolveAgentAwarenessRelayPublishSnapshot({
325 environmentId,
326 threadId,
327 thread,
328 project,
329 });
330 const publishIdentity = agentAwarenessPublishIdentity(snapshot.state);
331 const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef);
332 if (publishedStateByThread.get(threadId) === publishIdentity) {
333 yield* Effect.logDebug("agent awareness relay publish skipped; projected state unchanged", {
334 environmentId,
335 threadId,
336 reason: snapshot.reason,
337 });
338 return;
339 }
340
341 if (snapshot.reason === "thread-not-found") {
342 yield* Effect.logDebug("agent awareness relay publishing tombstone; thread not found", {
343 environmentId,
344 threadId,
345 });
346 } else if (snapshot.reason === "project-not-found") {
347 yield* Effect.logDebug("agent awareness relay publishing tombstone; project not found", {
348 environmentId,
349 threadId,
350 projectId: snapshot.projectId,
351 });
352 }
353
354 yield* publishState({
355 projectId: snapshot.projectId,
356 state: snapshot.state,
357 reason: snapshot.reason,
358 });
359 yield* Ref.update(publishedStateByThreadRef, (publishedStates) => {
360 const nextPublishedStates = new Map(publishedStates);
361 nextPublishedStates.set(threadId, publishIdentity);
362 return nextPublishedStates;
363 });
364 });
365
366 const publishThread: AgentAwarenessRelayShape["publishThread"] = (threadId) =>
367 publishThreadUnsafe(threadId).pipe(
368 Effect.catchCause((cause) => {
369 return Effect.logWarning("agent awareness relay publish failed", {
370 threadId,
371 cause: Cause.pretty(cause),
372 });
373 }),
374 Effect.withSpan("AgentAwarenessRelay.publishThread"),
375 );
376
377 const publishActiveThreadsUnsafe = Effect.gen(function* () {
378 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
379 if (!relayConfig) {
380 yield* Effect.logDebug("agent awareness relay active snapshot skipped; relay config missing");
381 return false;
382 }
383 const environmentId = yield* serverEnvironment.getEnvironmentId;
384 const snapshot = yield* snapshotQuery.getShellSnapshot();
385 const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({
386 environmentId,
387 projects: snapshot.projects,
388 threads: snapshot.threads,
389 });
390 if (activeThreadIds.length === 0) {
391 yield* Effect.logDebug("agent awareness relay active snapshot has no publishable threads");
392 return true;
393 }
394 yield* Effect.logInfo("agent awareness relay publishing active snapshot", {
395 count: activeThreadIds.length,
396 });
397 yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true });
398 return true;
399 });
400
401 const publishActiveThreadsOnceWhenConfigured = Effect.gen(function* () {
402 while (!(yield* Ref.get(activeSnapshotPublishedRef))) {
403 const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false));
404 if (published) {
405 yield* Ref.set(activeSnapshotPublishedRef, true);
406 return;
407 }
408 yield* Effect.sleep("5 seconds");
409 }
410 });
411
412 const worker = yield* makeDrainableWorker(publishThread);
413
414 const start: AgentAwarenessRelayShape["start"] = Effect.fn("AgentAwarenessRelay.start")(
415 function* () {
416 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
417 if (!relayConfig) {
418 yield* Effect.logInfo("agent awareness relay standby; relay config missing");
419 } else {
420 yield* Effect.logInfo("agent awareness relay enabled", {
421 relayUrl: relayConfig.url,
422 });
423 }
424 yield* Effect.forkScoped(
425 Effect.sleep("1 second").pipe(Effect.andThen(publishActiveThreadsOnceWhenConfigured)),
426 );
427 yield* Effect.forkScoped(
428 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
429 const threadId = eventThreadId(event);
430 if (threadId === null) {
431 return Effect.logDebug("agent awareness relay ignored event without thread id", {
432 eventType: event.type,
433 });
434 }
435 if (!shouldPublishAgentAwarenessEvent(event)) {
436 return Effect.logDebug(
437 "agent awareness relay ignored event without awareness changes",
438 {
439 eventType: event.type,
440 threadId,
441 },
442 );
443 }
444 return Effect.logDebug("agent awareness relay queued thread publish", {
445 eventType: event.type,
446 threadId,
447 }).pipe(Effect.andThen(worker.enqueue(threadId)));
448 }),
449 );
450 },
451 );
452
453 return {
454 publishThread,
455 start,
456 } satisfies AgentAwarenessRelayShape;
457});
458
459export const layer = Layer.effect(AgentAwarenessRelay, make);
apps/server/src/server.test.ts+2009−265
@@ -1,6 +1,7 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeSocket from "@effect/platform-node/NodeSocket";2import * as NodeSocket from "@effect/platform-node/NodeSocket";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as NodeCrypto from "node:crypto";
4 5
5import {6import {
6 AuthAccessTokenType,7 AuthAccessTokenType,
@@ -28,6 +29,12 @@ import { · 21 unmodified lines
28 WsRpcGroup,29 WsRpcGroup,
29 EditorId,30 EditorId,
30} from "@t3tools/contracts";31} from "@t3tools/contracts";
32import {
33 computeDpopAccessTokenHash,
34 computeDpopJwkThumbprint,
35 type DpopPublicJwk,
36} from "@t3tools/shared/dpop";
37import { RELAY_HEALTH_REQUEST_TYP, RELAY_MINT_REQUEST_TYP } from "@t3tools/shared/relayJwt";
31import { assert, it } from "@effect/vitest";38import { assert, it } from "@effect/vitest";
32import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";39import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
33import * as Clock from "effect/Clock";40import * as Clock from "effect/Clock";
@@ -43,13 +50,13 @@ import * as Path from "effect/Path"; · 9 unmodified lines
43import * as Stream from "effect/Stream";50import * as Stream from "effect/Stream";
44import { ChildProcessSpawner } from "effect/unstable/process";51import { ChildProcessSpawner } from "effect/unstable/process";
45import {52import {
46 Cookies,
47 FetchHttpClient,53 FetchHttpClient,
48 HttpBody,54 HttpBody,
49 HttpClient,55 HttpClient,
56 HttpClientRequest,
57 HttpClientResponse,
50 HttpRouter,58 HttpRouter,
51 HttpServer,59 HttpServer,
52 UrlParams,
53} from "effect/unstable/http";60} from "effect/unstable/http";
54import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";61import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
55import { RpcClient, RpcSerialization } from "effect/unstable/rpc";62import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
@@ -120,6 +127,10 @@ import * as ReviewService from "./review/ReviewService.ts"; · 64 unmodified lines
120import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";127import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
121import * as ServerSecretStore from "./auth/ServerSecretStore.ts";128import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
122import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";129import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
130import {
131 CloudManagedEndpointRuntime,
132 type CloudManagedEndpointRuntimeShape,
133} from "./cloud/ManagedEndpointRuntime.ts";
123import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";134import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
124import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";135import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
125import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";136import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
@@ -347,6 +358,7 @@ const buildAppUnderTest = (options?: { · 221 unmodified lines
347 serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>;358 serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>;
348 serverEnvironment?: Partial<ServerEnvironmentShape>;359 serverEnvironment?: Partial<ServerEnvironmentShape>;
349 repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>;360 repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>;
361 cloudManagedEndpointRuntime?: Partial<CloudManagedEndpointRuntimeShape>;
350 };362 };
351}) =>363}) =>
352 Effect.gen(function* () {364 Effect.gen(function* () {
@@ -741,7 +753,17 @@ const buildAppUnderTest = (options?: { · 388 unmodified lines
741 ...options?.layers?.repositoryIdentityResolver,753 ...options?.layers?.repositoryIdentityResolver,
742 }),754 }),
743 ),755 ),
756 Layer.provide(
757 Layer.succeed(
758 CloudManagedEndpointRuntime,
759 CloudManagedEndpointRuntime.of({
760 applyConfig: () => Effect.succeed({ status: "disabled" }),
761 ...options?.layers?.cloudManagedEndpointRuntime,
762 }),
763 ),
764 ),
744 Layer.provideMerge(makeAuthTestLayer()),765 Layer.provideMerge(makeAuthTestLayer()),
766 Layer.provideMerge(ServerSecretStore.layer),
745 Layer.provide(workspaceAndProjectServicesLayer),767 Layer.provide(workspaceAndProjectServicesLayer),
746 Layer.provideMerge(FetchHttpClient.layer),768 Layer.provideMerge(FetchHttpClient.layer),
747 Layer.provide(layerConfig),769 Layer.provide(layerConfig),
@@ -799,6 +821,13 @@ const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => · 51 unmodified lines
799 return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`;821 return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`;
800};822};
801 823
824const getHttpServerUrl = (pathname = "") =>
825 Effect.gen(function* () {
826 const server = yield* HttpServer.HttpServer;
827 const address = server.address as HttpServer.TcpAddress;
828 return `http://127.0.0.1:${address.port}${pathname}`;
829 });
830
802const bootstrapBrowserSession = (831const bootstrapBrowserSession = (
803 credential = defaultDesktopBootstrapToken,832 credential = defaultDesktopBootstrapToken,
804 options?: {833 options?: {
@@ -806,19 +835,26 @@ const bootstrapBrowserSession = ( · 1 unmodified line
806 },835 },
807) =>836) =>
808 Effect.gen(function* () {837 Effect.gen(function* () {
809 const response = yield* HttpClient.post("/api/auth/browser-session", {838 const bootstrapUrl = yield* getHttpServerUrl("/api/auth/browser-session");
810 headers: options?.headers,839 const response = yield* fetchEffect(bootstrapUrl, {
811 body: yield* HttpBody.json({ credential }),840 method: "POST",
841 headers: {
842 "content-type": "application/json",
843 ...options?.headers,
844 },
845 body: jsonRequestBody({
846 credential,
847 }),
812 });848 });
813 const body = (yield* response.json) as {849 const body = yield* responseJsonEffect<{
814 readonly authenticated: boolean;850 readonly authenticated: boolean;
815 readonly sessionMethod: string;851 readonly sessionMethod: string;
816 readonly expiresAt: string;852 readonly expiresAt: string;
817 };853 }>(response);
818 return {854 return {
819 response,855 response,
820 body,856 body,
821 cookie: Cookies.toSetCookieHeaders(response.cookies)[0] ?? null,857 cookie: response.headers["set-cookie"],
822 };858 };
823 });859 });
824 860
@@ -835,48 +871,227 @@ const exchangeAccessToken = ( · 10 unmodified lines
835 },871 },
836) =>872) =>
837 Effect.gen(function* () {873 Effect.gen(function* () {
838 const response = yield* HttpClient.post("/oauth/token", {874 const tokenUrl = yield* getHttpServerUrl("/oauth/token");
839 headers: options?.headers,875 const response = yield* fetchEffect(tokenUrl, {
840 body: HttpBody.urlParams(876 method: "POST",
841 UrlParams.fromInput({877 headers: {
842 grant_type: AuthTokenExchangeGrantType,878 "content-type": "application/x-www-form-urlencoded",
843 subject_token: credential,879 ...options?.headers,
844 subject_token_type: AuthEnvironmentBootstrapTokenType,880 },
845 requested_token_type: AuthAccessTokenType,881 body: new URLSearchParams({
846 scope:882 grant_type: AuthTokenExchangeGrantType,
847 options?.scope ??883 subject_token: credential,
848 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",884 subject_token_type: AuthEnvironmentBootstrapTokenType,
849 ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}),885 requested_token_type: AuthAccessTokenType,
850 ...(options?.clientMetadata?.deviceType886 scope:
851 ? { client_device_type: options.clientMetadata.deviceType }887 options?.scope ??
852 : {}),888 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
853 ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}),889 ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}),
854 }),890 ...(options?.clientMetadata?.deviceType
855 ),891 ? { client_device_type: options.clientMetadata.deviceType }
892 : {}),
893 ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}),
894 }).toString(),
856 });895 });
857 const body = (yield* response.json) as {896 const body = yield* responseJsonEffect<{
858 readonly access_token?: string;897 readonly access_token?: string;
859 readonly issued_token_type?: string;898 readonly issued_token_type?: string;
860 readonly token_type?: string;899 readonly token_type?: string;
861 readonly expires_in?: number;900 readonly expires_in?: number;
862 readonly scope?: string;901 readonly scope?: string;
863 readonly _tag?: string;902 readonly _tag?: string;
864 readonly message?: string;903 readonly code?: string;
865 };904 readonly reason?: string;
905 readonly traceId?: string;
906 }>(response);
866 return {907 return {
867 response,908 response,
868 body,909 body,
869 };910 };
870 });911 });
871 912
913const makeDpopProof = (input: {
914 readonly method: string;
915 readonly url: string;
916 readonly iat: number;
917 readonly accessToken?: string;
918 readonly jti?: string;
919 readonly privateKey?: NodeCrypto.KeyObject;
920 readonly publicJwk?: DpopPublicJwk;
921}) => {
922 const keyPair =
923 input.privateKey && input.publicJwk
924 ? { privateKey: input.privateKey, publicJwk: input.publicJwk }
925 : (() => {
926 const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", {
927 namedCurve: "P-256",
928 });
929 return { privateKey, publicJwk: publicKey.export({ format: "jwk" }) as DpopPublicJwk };
930 })();
931 const header = Buffer.from(
932 JSON.stringify({
933 typ: "dpop+jwt",
934 alg: "ES256",
935 jwk: keyPair.publicJwk,
936 }),
937 ).toString("base64url");
938 const payload = Buffer.from(
939 JSON.stringify({
940 htm: input.method,
941 htu: input.url,
942 jti: input.jti ?? "proof-1",
943 iat: input.iat,
944 ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}),
945 }),
946 ).toString("base64url");
947 const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), {
948 key: keyPair.privateKey,
949 dsaEncoding: "ieee-p1363",
950 }).toString("base64url");
951 return {
952 proof: `${header}.${payload}.${signature}`,
953 thumbprint: computeDpopJwkThumbprint(keyPair.publicJwk),
954 privateKey: keyPair.privateKey,
955 publicJwk: keyPair.publicJwk,
956 };
957};
958
959const makeCloudMintCredentialRequest = (input: {
960 readonly privateKey: string;
961 readonly environmentId: EnvironmentId;
962 readonly clientProofKeyThumbprint: string;
963 readonly issuer?: string;
964 readonly audience?: string;
965 readonly subject?: string;
966 readonly jti?: string;
967 readonly nonce: string;
968 readonly issuedAt: string;
969 readonly expiresAt: string;
970 readonly scope?: ReadonlyArray<"environment:connect">;
971}) => {
972 const payload = {
973 iss: input.issuer ?? "https://relay.example.test",
974 aud: input.audience ?? `t3-env:${input.environmentId}`,
975 sub: input.subject ?? "user_123",
976 jti: input.jti ?? "cloud-mint-jti-1",
977 environmentId: input.environmentId,
978 clientProofKeyThumbprint: input.clientProofKeyThumbprint,
979 cnf: {
980 jkt: input.clientProofKeyThumbprint,
981 },
982 nonce: input.nonce,
983 iat: Math.floor(DateTime.makeUnsafe(input.issuedAt).epochMilliseconds / 1_000),
984 exp: Math.floor(DateTime.makeUnsafe(input.expiresAt).epochMilliseconds / 1_000),
985 scope: input.scope ?? ["environment:connect"],
986 } as const;
987 const header = Buffer.from(
988 JSON.stringify({ alg: "EdDSA", typ: RELAY_MINT_REQUEST_TYP }),
989 ).toString("base64url");
990 const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
991 const signingInput = `${header}.${encodedPayload}`;
992 return {
993 proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`,
994 };
995};
996
997const makeCloudEnvironmentHealthRequest = (input: {
998 readonly privateKey: string;
999 readonly environmentId: EnvironmentId;
1000 readonly issuer?: string;
1001 readonly audience?: string;
1002 readonly subject?: string;
1003 readonly jti?: string;
1004 readonly nonce: string;
1005 readonly issuedAt: string;
1006 readonly expiresAt: string;
1007 readonly scope?: ReadonlyArray<"environment:status">;
1008}) => {
1009 const payload = {
1010 iss: input.issuer ?? "https://relay.example.test",
1011 aud: input.audience ?? `t3-env:${input.environmentId}`,
1012 sub: input.subject ?? "user_123",
1013 jti: input.jti ?? "cloud-health-jti-1",
1014 environmentId: input.environmentId,
1015 nonce: input.nonce,
1016 iat: Math.floor(DateTime.makeUnsafe(input.issuedAt).epochMilliseconds / 1_000),
1017 exp: Math.floor(DateTime.makeUnsafe(input.expiresAt).epochMilliseconds / 1_000),
1018 scope: input.scope ?? ["environment:status"],
1019 } as const;
1020 const header = Buffer.from(
1021 JSON.stringify({ alg: "EdDSA", typ: RELAY_HEALTH_REQUEST_TYP }),
1022 ).toString("base64url");
1023 const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
1024 const signingInput = `${header}.${encodedPayload}`;
1025 return {
1026 proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`,
1027 };
1028};
1029
1030const decodeCompactJwtPayload = <A>(token: string): A => {
1031 const encodedPayload = token.split(".")[1];
1032 if (!encodedPayload) {
1033 throw new Error("JWT does not contain a payload.");
1034 }
1035 return JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")) as A;
1036};
1037
872class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{1038class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{
873 readonly message: string;1039 readonly message: string;
874}> {}1040}> {}
875 1041
1042class TestHttpRequestError extends Data.TaggedError("TestHttpRequestError")<{
1043 readonly cause: unknown;
1044}> {}
1045
1046const testRequestUrl = (input: Parameters<typeof fetch>[0]): string => {
1047 const value = input.toString();
1048 if (!/^https?:\/\//i.test(value)) {
1049 return value;
1050 }
1051 const url = new URL(value);
1052 return `${url.pathname}${url.search}`;
1053};
1054
1055const fetchEffect = (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
1056 const request = HttpClientRequest.make((init?.method ?? "GET") as "GET" | "POST")(
1057 testRequestUrl(input),
1058 {
1059 headers: init?.headers as Record<string, string> | undefined,
1060 },
1061 ).pipe(
1062 typeof init?.body === "string"
1063 ? HttpClientRequest.bodyText(
1064 init.body,
1065 (init.headers as Record<string, string> | undefined)?.["content-type"] ??
1066 "application/json",
1067 )
1068 : (request) => request,
1069 );
1070 const effect = HttpClient.execute(request);
1071 return (
1072 init?.redirect === "manual"
1073 ? effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }))
1074 : effect
1075 ).pipe(Effect.mapError((cause) => new TestHttpRequestError({ cause })));
1076};
1077
1078const jsonRequestBody = (value: unknown): string => {
1079 return JSON.stringify(value);
1080};
1081
1082const responseJsonEffect = <A>(response: HttpClientResponse.HttpClientResponse) =>
1083 response.json.pipe(
1084 Effect.map((json) => json as A),
1085 Effect.mapError((cause) => new TestHttpRequestError({ cause })),
1086 );
1087
1088const responseOk = (response: HttpClientResponse.HttpClientResponse) =>
1089 response.status >= 200 && response.status < 300;
1090
876const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) =>1091const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) =>
877 Effect.gen(function* () {1092 Effect.gen(function* () {
878 const { response, cookie } = yield* bootstrapBrowserSession(credential);1093 const { response, cookie } = yield* bootstrapBrowserSession(credential);
879 if (response.status !== 200) {1094 if (!responseOk(response)) {
880 return yield* new AuthenticationGetterError({1095 return yield* new AuthenticationGetterError({
881 message: `Expected bootstrap session response to succeed, got ${response.status}`,1096 message: `Expected bootstrap session response to succeed, got ${response.status}`,
882 });1097 });
@@ -894,7 +1109,7 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra · 11 unmodified lines
894const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) =>1109const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) =>
895 Effect.gen(function* () {1110 Effect.gen(function* () {
896 const { response, body } = yield* exchangeAccessToken(credential);1111 const { response, body } = yield* exchangeAccessToken(credential);
897 if (response.status !== 200) {1112 if (!responseOk(response)) {
898 return yield* new AuthenticationGetterError({1113 return yield* new AuthenticationGetterError({
899 message: `Expected bearer bootstrap response to succeed, got ${response.status}`,1114 message: `Expected bearer bootstrap response to succeed, got ${response.status}`,
900 });1115 });
@@ -918,7 +1133,7 @@ const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { · 17 unmodified lines
918 return token;1133 return token;
919};1134};
920 1135
921const splitHeaderTokens = (value: string | null) =>1136const splitHeaderTokens = (value: string | null | undefined) =>
922 (value ?? "")1137 (value ?? "")
923 .split(",")1138 .split(",")
924 .map((token) => token.trim())1139 .map((token) => token.trim())
@@ -940,10 +1155,11 @@ const assertBrowserApiCorsPreflightHeaders = ( · 15 unmodified lines
940 "OPTIONS",1155 "OPTIONS",
941 "POST",1156 "POST",
942 ]);1157 ]);
943 assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"] ?? null), [1158 assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"]), [
944 "authorization",1159 "authorization",
945 "b3",1160 "b3",
946 "content-type",1161 "content-type",
1162 "dpop",
947 "traceparent",1163 "traceparent",
948 ]);1164 ]);
949};1165};
@@ -989,9 +1205,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 39 unmodified lines
989 config: { devUrl: new URL("http://127.0.0.1:5173") },1205 config: { devUrl: new URL("http://127.0.0.1:5173") },
990 });1206 });
991 1207
992 const response = yield* HttpClient.get("/foo/bar?token=test-token").pipe(1208 const url = yield* getHttpServerUrl("/foo/bar?token=test-token");
993 Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),1209 const response = yield* fetchEffect(url, { redirect: "manual" });
994 );
995 1210
996 assert.equal(response.status, 302);1211 assert.equal(response.status, 302);
997 assert.equal(response.headers.location, "http://127.0.0.1:5173/foo/bar?token=test-token");1212 assert.equal(response.headers.location, "http://127.0.0.1:5173/foo/bar?token=test-token");
@@ -1057,8 +1272,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 59 unmodified lines
1057 Effect.gen(function* () {1272 Effect.gen(function* () {
1058 yield* buildAppUnderTest();1273 yield* buildAppUnderTest();
1059 1274
1060 const response = yield* HttpClient.get("/.well-known/t3/environment");1275 const url = yield* getHttpServerUrl("/.well-known/t3/environment");
1061 const body = (yield* response.json) as typeof testEnvironmentDescriptor;1276 const response = yield* fetchEffect(url);
1277 const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response);
1062 1278
1063 assert.equal(response.status, 200);1279 assert.equal(response.status, 200);
1064 assert.deepEqual(body, testEnvironmentDescriptor);1280 assert.deepEqual(body, testEnvironmentDescriptor);
@@ -1069,12 +1285,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 4 unmodified lines
1069 Effect.gen(function* () {1285 Effect.gen(function* () {
1070 yield* buildAppUnderTest();1286 yield* buildAppUnderTest();
1071 1287
1072 const response = yield* HttpClient.get("/.well-known/t3/environment", {1288 const url = yield* getHttpServerUrl("/.well-known/t3/environment");
1289 const response = yield* fetchEffect(url, {
1073 headers: {1290 headers: {
1074 origin: crossOriginClientOrigin,1291 origin: crossOriginClientOrigin,
1075 },1292 },
1076 });1293 });
1077 const body = (yield* response.json) as typeof testEnvironmentDescriptor;1294 const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response);
1078 1295
1079 assert.equal(response.status, 200);1296 assert.equal(response.status, 200);
1080 assertBrowserApiCorsResponseHeaders(response.headers);1297 assertBrowserApiCorsResponseHeaders(response.headers);
@@ -1086,8 +1303,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines
1086 Effect.gen(function* () {1303 Effect.gen(function* () {
1087 yield* buildAppUnderTest();1304 yield* buildAppUnderTest();
1088 1305
1089 const response = yield* HttpClient.get("/api/auth/session");1306 const url = yield* getHttpServerUrl("/api/auth/session");
1090 const body = (yield* response.json) as {1307 const response = yield* fetchEffect(url);
1308 const body = yield* responseJsonEffect<{
1091 readonly authenticated: boolean;1309 readonly authenticated: boolean;
1092 readonly auth: {1310 readonly auth: {
1093 readonly policy: string;1311 readonly policy: string;
@@ -1095,13 +1313,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 1 unmodified line
1095 readonly sessionMethods: ReadonlyArray<string>;1313 readonly sessionMethods: ReadonlyArray<string>;
1096 readonly sessionCookieName: string;1314 readonly sessionCookieName: string;
1097 };1315 };
1098 };1316 }>(response);
1099 1317
1100 assert.equal(response.status, 200);1318 assert.equal(response.status, 200);
1101 assert.equal(body.authenticated, false);1319 assert.equal(body.authenticated, false);
1102 assert.equal(body.auth.policy, "desktop-managed-local");1320 assert.equal(body.auth.policy, "desktop-managed-local");
1103 assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]);1321 assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]);
1104 assert.deepEqual(body.auth.sessionMethods, ["browser-session-cookie", "bearer-access-token"]);1322 assert.deepEqual(body.auth.sessionMethods, [
1323 "browser-session-cookie",
1324 "bearer-access-token",
1325 "dpop-access-token",
1326 ]);
1105 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));1327 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));
1106 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1328 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1107 );1329 );
@@ -1122,15 +1344,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 14 unmodified lines
1122 assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken);1344 assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken);
1123 assert.isDefined(setCookie);1345 assert.isDefined(setCookie);
1124 1346
1125 const sessionResponse = yield* HttpClient.get("/api/auth/session", {1347 const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
1348 const sessionResponse = yield* fetchEffect(sessionUrl, {
1126 headers: {1349 headers: {
1127 cookie: setCookie?.split(";")[0] ?? "",1350 cookie: setCookie?.split(";")[0] ?? "",
1128 },1351 },
1129 });1352 });
1130 const sessionBody = (yield* sessionResponse.json) as {1353 const sessionBody = yield* responseJsonEffect<{
1131 readonly authenticated: boolean;1354 readonly authenticated: boolean;
1132 readonly sessionMethod?: string;1355 readonly sessionMethod?: string;
1133 };1356 }>(sessionResponse);
1134 1357
1135 assert.equal(sessionResponse.status, 200);1358 assert.equal(sessionResponse.status, 200);
1136 assert.equal(sessionBody.authenticated, true);1359 assert.equal(sessionBody.authenticated, true);
@@ -1142,9 +1365,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines
1142 Effect.gen(function* () {1365 Effect.gen(function* () {
1143 yield* buildAppUnderTest();1366 yield* buildAppUnderTest();
1144 1367
1145 const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken();1368 const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken();
1146 1369
1147 assert.equal(bootstrapResponse.status, 200);1370 assert.equal(tokenResponse.status, 200);
1148 assert.equal(tokenBody.issued_token_type, AuthAccessTokenType);1371 assert.equal(tokenBody.issued_token_type, AuthAccessTokenType);
1149 assert.equal(tokenBody.token_type, "Bearer");1372 assert.equal(tokenBody.token_type, "Bearer");
1150 assert.equal(1373 assert.equal(
@@ -1152,18 +1375,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 1 unmodified line
1152 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",1375 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
1153 );1376 );
1154 assert.equal(typeof tokenBody.access_token, "string");1377 assert.equal(typeof tokenBody.access_token, "string");
1155 assert.isTrue((tokenBody.access_token?.length ?? 0) > 0);
1156 1378
1157 const sessionResponse = yield* HttpClient.get("/api/auth/session", {1379 const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
1380 const sessionResponse = yield* fetchEffect(sessionUrl, {
1158 headers: {1381 headers: {
1159 authorization: `Bearer ${tokenBody.access_token ?? ""}`,1382 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1160 },1383 },
1161 });1384 });
1162 const sessionBody = (yield* sessionResponse.json) as {1385 const sessionBody = yield* responseJsonEffect<{
1163 readonly authenticated: boolean;1386 readonly authenticated: boolean;
1164 readonly sessionMethod?: string;1387 readonly sessionMethod?: string;
1165 readonly scopes?: ReadonlyArray<string>;1388 readonly scopes?: ReadonlyArray<string>;
1166 };1389 }>(sessionResponse);
1167 1390
1168 assert.equal(sessionResponse.status, 200);1391 assert.equal(sessionResponse.status, 200);
1169 assert.equal(sessionBody.authenticated, true);1392 assert.equal(sessionBody.authenticated, true);
@@ -1181,187 +1404,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 11 unmodified lines
1181 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1404 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1182 );1405 );
1183 1406
1184 it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () =>
1185 Effect.gen(function* () {
1186 yield* buildAppUnderTest();
1187
1188 const bearerToken = yield* getAuthenticatedBearerSessionToken();
1189 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
1190 headers: {
1191 authorization: `Bearer ${bearerToken}`,
1192 },
1193 });
1194 const wsTicketBody = (yield* wsTicketResponse.json) as {
1195 readonly ticket: string;
1196 readonly expiresAt: string;
1197 };
1198
1199 assert.equal(wsTicketResponse.status, 200);
1200 assert.equal(typeof wsTicketBody.ticket, "string");
1201 assert.isTrue(wsTicketBody.ticket.length > 0);
1202 assert.equal(typeof wsTicketBody.expiresAt, "string");
1203 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1204 );
1205
1206 it.effect("does not allow management-only access tokens to operate the environment", () =>
1207 Effect.gen(function* () {
1208 yield* buildAppUnderTest();
1209
1210 const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken(
1211 defaultDesktopBootstrapToken,
1212 { scope: "access:write" },
1213 );
1214 assert.equal(exchangeResponse.status, 200);
1215 assert.equal(tokenBody.scope, "access:write");
1216 assert.isDefined(tokenBody.access_token);
1217
1218 const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1219 headers: {
1220 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1221 },
1222 body: yield* HttpBody.json({}),
1223 });
1224 const overbroadPairingBody = (yield* overbroadPairingResponse.json) as {
1225 readonly requiredScope: string;
1226 };
1227 const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1228 headers: {
1229 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1230 },
1231 body: yield* HttpBody.json({ scopes: ["access:write"] }),
1232 });
1233 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
1234 headers: {
1235 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1236 },
1237 });
1238 const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string };
1239 const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", {
1240 headers: {
1241 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1242 },
1243 });
1244 const faviconBody = (yield* faviconResponse.json) as {
1245 readonly _tag: string;
1246 readonly code: string;
1247 readonly requiredScope: string;
1248 readonly traceId: string;
1249 };
1250
1251 assert.equal(overbroadPairingResponse.status, 403);
1252 assert.equal(overbroadPairingBody.requiredScope, "orchestration:read");
1253 assert.equal(pairingResponse.status, 200);
1254 assert.equal(wsTicketResponse.status, 200);
1255 assert.equal(faviconResponse.status, 403);
1256 assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError");
1257 assert.equal(faviconBody.code, "insufficient_scope");
1258 assert.equal(faviconBody.requiredScope, "orchestration:read");
1259 assert.equal(typeof faviconBody.traceId, "string");
1260
1261 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
1262 const rpcError = yield* Effect.flip(
1263 Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))),
1264 );
1265 assert.equal(rpcError._tag, "EnvironmentAuthorizationError");
1266 if (rpcError._tag === "EnvironmentAuthorizationError") {
1267 assert.equal(rpcError.requiredScope, "orchestration:read");
1268 }
1269 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1270 );
1271
1272 it.effect("includes CORS headers on remote auth success responses", () =>
1273 Effect.gen(function* () {
1274 yield* buildAppUnderTest();
1275
1276 const origin = crossOriginClientOrigin;
1277 const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken(
1278 defaultDesktopBootstrapToken,
1279 {
1280 headers: { origin },
1281 },
1282 );
1283
1284 assert.equal(bootstrapResponse.status, 200);
1285 assertBrowserApiCorsResponseHeaders(bootstrapResponse.headers);
1286 assert.equal(tokenBody.token_type, "Bearer");
1287 assert.equal(typeof tokenBody.access_token, "string");
1288
1289 const sessionResponse = yield* HttpClient.get("/api/auth/session", {
1290 headers: {
1291 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1292 origin,
1293 },
1294 });
1295 const sessionBody = (yield* sessionResponse.json) as {
1296 readonly authenticated: boolean;
1297 readonly sessionMethod?: string;
1298 };
1299
1300 assert.equal(sessionResponse.status, 200);
1301 assertBrowserApiCorsResponseHeaders(sessionResponse.headers);
1302 assert.equal(sessionBody.authenticated, true);
1303 assert.equal(sessionBody.sessionMethod, "bearer-access-token");
1304
1305 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
1306 headers: {
1307 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
1308 origin,
1309 },
1310 });
1311 const wsTicketBody = (yield* wsTicketResponse.json) as {
1312 readonly ticket: string;
1313 };
1314
1315 assert.equal(wsTicketResponse.status, 200);
1316 assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers);
1317 assert.equal(typeof wsTicketBody.ticket, "string");
1318 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1319 );
1320
1321 it.effect(
1322 "responds to remote auth websocket-ticket preflight requests with authorization CORS headers",
1323 () =>
1324 Effect.gen(function* () {
1325 yield* buildAppUnderTest();
1326
1327 const response = yield* HttpClient.options("/api/auth/websocket-ticket", {
1328 headers: {
1329 origin: crossOriginClientOrigin,
1330 "access-control-request-method": "POST",
1331 "access-control-request-headers": "authorization",
1332 },
1333 });
1334
1335 assert.equal(response.status, 204);
1336 assertBrowserApiCorsPreflightHeaders(response.headers);
1337 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1338 );
1339
1340 it.effect("includes CORS headers on remote websocket-ticket auth failures", () =>
1341 Effect.gen(function* () {
1342 yield* buildAppUnderTest();
1343
1344 const response = yield* HttpClient.post("/api/auth/websocket-ticket", {
1345 headers: {
1346 origin: crossOriginClientOrigin,
1347 },
1348 });
1349 const body = (yield* response.json) as {
1350 readonly _tag?: string;
1351 readonly code?: string;
1352 readonly reason?: string;
1353 readonly traceId?: string;
1354 };
1355
1356 assert.equal(response.status, 401);
1357 assertBrowserApiCorsResponseHeaders(response.headers);
1358 assert.equal(body._tag, "EnvironmentAuthInvalidError");
1359 assert.equal(body.code, "auth_invalid");
1360 assert.equal(body.reason, "missing_credential");
1361 assert.equal(typeof body.traceId, "string");
1362 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1363 );
1364
1365 it.effect("persists token exchange client display metadata for authorized-client listings", () =>1407 it.effect("persists token exchange client display metadata for authorized-client listings", () =>
1366 Effect.gen(function* () {1408 Effect.gen(function* () {
1367 yield* buildAppUnderTest({1409 yield* buildAppUnderTest({
@@ -1385,7 +1427,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 17 unmodified lines
1385 headers: {1427 headers: {
1386 "user-agent": "undici",1428 "user-agent": "undici",
1387 },1429 },
1388 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",1430 scope: "orchestration:read orchestration:operate terminal:operate review:write",
1389 clientMetadata: {1431 clientMetadata: {
1390 label: "T3 Code Mobile",1432 label: "T3 Code Mobile",
1391 deviceType: "mobile",1433 deviceType: "mobile",
@@ -1423,23 +1465,1711 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 31 unmodified lines
1423 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1465 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1424 );1466 );
1425 1467
1426 it.effect("issues authenticated one-time pairing credentials for additional clients", () =>1468 it.effect(
1469 "exchanges a bootstrap credential for a DPoP-bound access token without bearer downgrade",
1470 () =>
1471 Effect.gen(function* () {
1472 yield* buildAppUnderTest();
1473
1474 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1475 const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1476 headers: { cookie: ownerCookie },
1477 body: yield* HttpBody.json({}),
1478 });
1479 const credential = (yield* credentialResponse.json) as { readonly credential: string };
1480 const tokenUrl = yield* getHttpServerUrl("/oauth/token");
1481 const now = yield* DateTime.now;
1482 const tokenProof = makeDpopProof({
1483 method: "POST",
1484 url: tokenUrl,
1485 iat: Math.floor(now.epochMilliseconds / 1_000),
1486 jti: "token-exchange-proof",
1487 });
1488 const tokenResponse = yield* fetchEffect(tokenUrl, {
1489 method: "POST",
1490 headers: {
1491 "content-type": "application/x-www-form-urlencoded",
1492 dpop: tokenProof.proof,
1493 },
1494 body: new URLSearchParams({
1495 grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
1496 subject_token: credential.credential,
1497 subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap",
1498 requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
1499 scope: "orchestration:read orchestration:operate terminal:operate review:write",
1500 }).toString(),
1501 });
1502 const token = yield* responseJsonEffect<{
1503 readonly access_token: string;
1504 readonly token_type: string;
1505 }>(tokenResponse);
1506
1507 assert.equal(tokenResponse.status, 200);
1508 assert.equal(tokenResponse.headers["cache-control"], "no-store");
1509 assert.equal(token.token_type, "DPoP");
1510
1511 const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
1512 const bearerResponse = yield* fetchEffect(sessionUrl, {
1513 headers: { authorization: `Bearer ${token.access_token}` },
1514 });
1515 const bearerState = yield* responseJsonEffect<{ readonly authenticated: boolean }>(
1516 bearerResponse,
1517 );
1518 assert.equal(bearerState.authenticated, false);
1519
1520 const sessionProof = makeDpopProof({
1521 method: "GET",
1522 url: sessionUrl,
1523 iat: Math.floor(now.epochMilliseconds / 1_000),
1524 jti: "session-proof",
1525 accessToken: token.access_token,
1526 privateKey: tokenProof.privateKey,
1527 publicJwk: tokenProof.publicJwk,
1528 });
1529 const dpopResponse = yield* fetchEffect(sessionUrl, {
1530 headers: {
1531 authorization: `DPoP ${token.access_token}`,
1532 dpop: sessionProof.proof,
1533 },
1534 });
1535 const dpopState = yield* responseJsonEffect<{
1536 readonly authenticated: boolean;
1537 readonly sessionMethod?: string;
1538 }>(dpopResponse);
1539 assert.equal(dpopState.authenticated, true);
1540 assert.equal(dpopState.sessionMethod, "dpop-access-token");
1541 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1542 );
1543
1544 it.effect("rejects replayed DPoP proofs across token exchanges", () =>
1427 Effect.gen(function* () {1545 Effect.gen(function* () {
1428 yield* buildAppUnderTest();1546 yield* buildAppUnderTest();
1429 1547
1430 const response = yield* HttpClient.post("/api/auth/pairing-token", {1548 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1549 const firstCredentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1431 headers: {1550 headers: {
1432 cookie: yield* getAuthenticatedSessionCookieHeader(),1551 cookie: ownerCookie,
1433 },1552 },
1434 body: yield* HttpBody.json({}),1553 body: yield* HttpBody.json({}),
1435 });1554 });
1436 const body = (yield* response.json) as {1555 const firstCredential = (yield* firstCredentialResponse.json) as {
1437 readonly credential: string;1556 readonly credential: string;
1438 readonly expiresAt: string;
1439 };1557 };
1440 1558 const secondCredentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1441 assert.equal(response.status, 200);1559 headers: {
1442 assert.equal(typeof body.credential, "string");1560 cookie: ownerCookie,
1561 },
1562 body: yield* HttpBody.json({}),
1563 });
1564 const secondCredential = (yield* secondCredentialResponse.json) as {
1565 readonly credential: string;
1566 };
1567 const tokenUrl = yield* getHttpServerUrl("/oauth/token");
1568 const now = yield* DateTime.now;
1569 const dpop = makeDpopProof({
1570 method: "POST",
1571 url: tokenUrl,
1572 iat: Math.floor(now.epochMilliseconds / 1_000),
1573 });
1574
1575 const firstBootstrap = yield* exchangeAccessToken(firstCredential.credential, {
1576 headers: {
1577 dpop: dpop.proof,
1578 },
1579 scope: "orchestration:read orchestration:operate terminal:operate review:write",
1580 });
1581 const replayBootstrap = yield* exchangeAccessToken(secondCredential.credential, {
1582 headers: {
1583 dpop: dpop.proof,
1584 },
1585 scope: "orchestration:read orchestration:operate terminal:operate review:write",
1586 });
1587
1588 assert.equal(firstBootstrap.response.status, 200);
1589 assert.equal(replayBootstrap.response.status, 401);
1590 assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError");
1591 assert.equal(replayBootstrap.body.code, "auth_invalid");
1592 assert.equal(replayBootstrap.body.reason, "invalid_credential");
1593 assert.equal(typeof replayBootstrap.body.traceId, "string");
1594 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1595 );
1596
1597 it.effect("ignores forwarded host headers when validating token exchange DPoP URLs", () =>
1598 Effect.gen(function* () {
1599 yield* buildAppUnderTest();
1600
1601 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1602 const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1603 headers: {
1604 cookie: ownerCookie,
1605 },
1606 body: yield* HttpBody.json({}),
1607 });
1608 const credential = (yield* credentialResponse.json) as {
1609 readonly credential: string;
1610 };
1611 const tokenUrl = yield* getHttpServerUrl("/oauth/token");
1612 const now = yield* DateTime.now;
1613 const dpop = makeDpopProof({
1614 method: "POST",
1615 url: tokenUrl,
1616 iat: Math.floor(now.epochMilliseconds / 1_000),
1617 });
1618
1619 const bootstrap = yield* exchangeAccessToken(credential.credential, {
1620 headers: {
1621 dpop: dpop.proof,
1622 "x-forwarded-host": "environment.example.test",
1623 },
1624 scope: "orchestration:read orchestration:operate terminal:operate review:write",
1625 });
1626
1627 assert.equal(bootstrap.response.status, 200);
1628 assert.equal(bootstrap.body.token_type, "DPoP");
1629 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1630 );
1631
1632 it.effect("rejects token exchange DPoP proofs bound to spoofed forwarded hosts", () =>
1633 Effect.gen(function* () {
1634 yield* buildAppUnderTest();
1635
1636 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1637 const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1638 headers: {
1639 cookie: ownerCookie,
1640 },
1641 body: yield* HttpBody.json({}),
1642 });
1643 const credential = (yield* credentialResponse.json) as {
1644 readonly credential: string;
1645 };
1646 const tokenUrl = yield* getHttpServerUrl("/oauth/token");
1647 const spoofedUrl = new URL(tokenUrl);
1648 spoofedUrl.hostname = "environment.example.test";
1649 const now = yield* DateTime.now;
1650 const dpop = makeDpopProof({
1651 method: "POST",
1652 url: spoofedUrl.href,
1653 iat: Math.floor(now.epochMilliseconds / 1_000),
1654 });
1655
1656 const bootstrap = yield* exchangeAccessToken(credential.credential, {
1657 headers: {
1658 dpop: dpop.proof,
1659 "x-forwarded-host": spoofedUrl.host,
1660 },
1661 scope: "orchestration:read orchestration:operate terminal:operate review:write",
1662 });
1663
1664 assert.equal(bootstrap.response.status, 401);
1665 assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError");
1666 assert.equal(bootstrap.body.code, "auth_invalid");
1667 assert.equal(bootstrap.body.reason, "invalid_credential");
1668 assert.equal(typeof bootstrap.body.traceId, "string");
1669 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1670 );
1671
1672 it.effect("rejects cloud link proofs for non-loopback managed endpoint origins", () =>
1673 Effect.gen(function* () {
1674 yield* buildAppUnderTest();
1675
1676 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1677 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1678 const linkProofResponse = yield* fetchEffect(linkProofUrl, {
1679 method: "POST",
1680 headers: {
1681 cookie: ownerCookie,
1682 "content-type": "application/json",
1683 },
1684 body: jsonRequestBody({
1685 challenge: "relay-link-challenge",
1686 relayIssuer: "https://relay.example.test",
1687 endpoint: {
1688 httpBaseUrl: "https://environment.example.test/",
1689 wsBaseUrl: "wss://environment.example.test/ws",
1690 providerKind: "manual",
1691 },
1692 origin: {
1693 localHttpHost: "192.168.1.42",
1694 localHttpPort: 3773,
1695 },
1696 }),
1697 });
1698 const body = yield* responseJsonEffect<{
1699 readonly _tag?: string;
1700 readonly message?: string;
1701 }>(linkProofResponse);
1702
1703 assert.equal(linkProofResponse.status, 400);
1704 assert.equal(body._tag, "EnvironmentHttpBadRequestError");
1705 assert.equal(body.message, "Invalid managed endpoint origin.");
1706 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1707 );
1708
1709 it.effect("rejects managed cloud link proofs for manual endpoint providers", () =>
1710 Effect.gen(function* () {
1711 yield* buildAppUnderTest();
1712
1713 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1714 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1715 const serverPort = Number(new URL(linkProofUrl).port);
1716 const linkProofResponse = yield* fetchEffect(linkProofUrl, {
1717 method: "POST",
1718 headers: {
1719 cookie: ownerCookie,
1720 "content-type": "application/json",
1721 },
1722 body: jsonRequestBody({
1723 challenge: "relay-link-challenge",
1724 relayIssuer: "https://relay.example.test",
1725 endpoint: {
1726 httpBaseUrl: linkProofUrl.replace("/api/cloud/link-proof", ""),
1727 wsBaseUrl: linkProofUrl
1728 .replace("http://", "ws://")
1729 .replace("/api/cloud/link-proof", "/ws"),
1730 providerKind: "manual",
1731 },
1732 origin: {
1733 localHttpHost: "127.0.0.1",
1734 localHttpPort: serverPort,
1735 },
1736 }),
1737 });
1738 const body = yield* responseJsonEffect<{
1739 readonly _tag?: string;
1740 readonly message?: string;
1741 }>(linkProofResponse);
1742
1743 assert.equal(linkProofResponse.status, 400);
1744 assert.equal(body._tag, "EnvironmentHttpBadRequestError");
1745 assert.equal(body.message, "Invalid managed endpoint origin.");
1746 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1747 );
1748
1749 it.effect("rejects cloud link proofs requested through a public managed endpoint", () =>
1750 Effect.gen(function* () {
1751 yield* buildAppUnderTest();
1752
1753 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1754 const serverPort = Number(new URL(linkProofUrl).port);
1755 const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", {
1756 headers: {
1757 cookie: yield* getAuthenticatedSessionCookieHeader(),
1758 "content-type": "application/json",
1759 host: "environment.example.test",
1760 "x-forwarded-host": "environment.example.test",
1761 "x-forwarded-proto": "https",
1762 },
1763 body: HttpBody.text(
1764 jsonRequestBody({
1765 challenge: "relay-link-challenge",
1766 relayIssuer: "https://relay.example.test",
1767 endpoint: {
1768 httpBaseUrl: "https://environment.example.test/",
1769 wsBaseUrl: "wss://environment.example.test/ws",
1770 providerKind: "manual",
1771 },
1772 origin: {
1773 localHttpHost: "127.0.0.1",
1774 localHttpPort: serverPort,
1775 },
1776 }),
1777 "application/json",
1778 ),
1779 });
1780 const body = (yield* linkProofResponse.json) as {
1781 readonly _tag?: string;
1782 readonly message?: string;
1783 };
1784
1785 assert.equal(linkProofResponse.status, 400);
1786 assert.equal(body._tag, "EnvironmentHttpBadRequestError");
1787 assert.equal(body.message, "Invalid managed endpoint origin.");
1788 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1789 );
1790
1791 it.effect(
1792 "rejects cloud link proofs when a public request spoofs loopback forwarded headers",
1793 () =>
1794 Effect.gen(function* () {
1795 yield* buildAppUnderTest();
1796
1797 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1798 const serverPort = Number(new URL(linkProofUrl).port);
1799 const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", {
1800 headers: {
1801 cookie: yield* getAuthenticatedSessionCookieHeader(),
1802 "content-type": "application/json",
1803 host: "environment.example.test",
1804 "x-forwarded-host": `127.0.0.1:${serverPort}`,
1805 "x-forwarded-proto": "http",
1806 },
1807 body: HttpBody.text(
1808 jsonRequestBody({
1809 challenge: "relay-link-challenge",
1810 relayIssuer: "https://relay.example.test",
1811 endpoint: {
1812 httpBaseUrl: "https://environment.example.test/",
1813 wsBaseUrl: "wss://environment.example.test/ws",
1814 providerKind: "manual",
1815 },
1816 origin: {
1817 localHttpHost: "127.0.0.1",
1818 localHttpPort: serverPort,
1819 },
1820 }),
1821 "application/json",
1822 ),
1823 });
1824 const body = (yield* linkProofResponse.json) as {
1825 readonly _tag?: string;
1826 readonly message?: string;
1827 };
1828
1829 assert.equal(linkProofResponse.status, 400);
1830 assert.equal(body._tag, "EnvironmentHttpBadRequestError");
1831 assert.equal(body.message, "Invalid managed endpoint origin.");
1832 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1833 );
1834
1835 it.effect("rejects cloud link proofs with malformed forwarded request hosts", () =>
1836 Effect.gen(function* () {
1837 yield* buildAppUnderTest();
1838
1839 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1840 const serverPort = Number(new URL(linkProofUrl).port);
1841 const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", {
1842 headers: {
1843 cookie: yield* getAuthenticatedSessionCookieHeader(),
1844 "content-type": "application/json",
1845 host: "bad host",
1846 "x-forwarded-host": "bad host",
1847 "x-forwarded-proto": "https",
1848 },
1849 body: HttpBody.text(
1850 jsonRequestBody({
1851 challenge: "relay-link-challenge",
1852 relayIssuer: "https://relay.example.test",
1853 endpoint: {
1854 httpBaseUrl: "https://environment.example.test/",
1855 wsBaseUrl: "wss://environment.example.test/ws",
1856 providerKind: "manual",
1857 },
1858 origin: {
1859 localHttpHost: "127.0.0.1",
1860 localHttpPort: serverPort,
1861 },
1862 }),
1863 "application/json",
1864 ),
1865 });
1866 const body = (yield* linkProofResponse.json) as {
1867 readonly _tag?: string;
1868 readonly message?: string;
1869 };
1870
1871 assert.equal(linkProofResponse.status, 400);
1872 assert.equal(body._tag, "EnvironmentHttpBadRequestError");
1873 assert.equal(body.message, "Invalid managed endpoint origin.");
1874 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1875 );
1876
1877 it.effect("rejects local cloud link proofs for a different loopback port", () =>
1878 Effect.gen(function* () {
1879 yield* buildAppUnderTest();
1880
1881 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1882 const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof");
1883 const serverPort = Number(new URL(linkProofUrl).port);
1884 const linkProofResponse = yield* fetchEffect(linkProofUrl, {
1885 method: "POST",
1886 headers: {
1887 cookie: ownerCookie,
1888 "content-type": "application/json",
1889 },
1890 body: jsonRequestBody({
1891 challenge: "relay-link-challenge",
1892 relayIssuer: "https://relay.example.test",
1893 endpoint: {
1894 httpBaseUrl: "https://environment.example.test/",
1895 wsBaseUrl: "wss://environment.example.test/ws",
1896 providerKind: "manual",
1897 },
1898 origin: {
1899 localHttpHost: "127.0.0.1",
1900 localHttpPort: serverPort === 65_535 ? serverPort - 1 : serverPort + 1,
1901 },
1902 }),
1903 });
1904 const body = yield* responseJsonEffect<{
1905 readonly _tag?: string;
1906 readonly message?: string;
1907 }>(linkProofResponse);
1908
1909 assert.equal(linkProofResponse.status, 400);
1910 assert.equal(body._tag, "EnvironmentHttpBadRequestError");
1911 assert.equal(body.message, "Invalid managed endpoint origin.");
1912 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1913 );
1914
1915 it.effect("rejects managed relay configuration reads without relay management scope", () =>
1916 Effect.gen(function* () {
1917 yield* buildAppUnderTest();
1918
1919 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1920 const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1921 headers: { cookie: ownerCookie },
1922 body: yield* HttpBody.json({}),
1923 });
1924 const credential = (yield* credentialResponse.json) as { readonly credential: string };
1925 const pairedCookie = yield* getAuthenticatedSessionCookieHeader(credential.credential);
1926 const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state");
1927 const response = yield* fetchEffect(linkStateUrl, {
1928 headers: { cookie: pairedCookie },
1929 });
1930 const body = yield* responseJsonEffect<{
1931 readonly _tag?: string;
1932 readonly code?: string;
1933 readonly requiredScope?: string;
1934 readonly traceId?: string;
1935 }>(response);
1936
1937 assert.equal(response.status, 403);
1938 assert.equal(body._tag, "EnvironmentScopeRequiredError");
1939 assert.equal(body.code, "insufficient_scope");
1940 assert.equal(body.requiredScope, "relay:manage");
1941 assert.equal(typeof body.traceId, "string");
1942 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1943 );
1944
1945 it.effect("rejects relay config with an invalid cloud mint public key", () =>
1946 Effect.gen(function* () {
1947 yield* buildAppUnderTest();
1948
1949 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1950 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
1951 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
1952 method: "POST",
1953 headers: {
1954 cookie: ownerCookie,
1955 "content-type": "application/json",
1956 },
1957 body: jsonRequestBody({
1958 relayUrl: "https://relay.example.test",
1959 cloudUserId: "user_123",
1960 environmentCredential: "t3env_test_credential",
1961 cloudMintPublicKey: "not-a-public-key",
1962 endpointRuntime: null,
1963 }),
1964 });
1965 const body = yield* responseJsonEffect<{
1966 readonly _tag?: string;
1967 readonly message?: string;
1968 }>(relayConfigResponse);
1969
1970 assert.equal(relayConfigResponse.status, 400);
1971 assert.equal(body._tag, "EnvironmentHttpBadRequestError");
1972 assert.equal(body.message, "Cloud mint public key must be a valid Ed25519 public key.");
1973 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1974 );
1975
1976 it.effect("rejects relay config with insecure relay metadata or empty credentials", () =>
1977 Effect.gen(function* () {
1978 yield* buildAppUnderTest();
1979
1980 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
1981 privateKeyEncoding: { format: "pem", type: "pkcs8" },
1982 publicKeyEncoding: { format: "pem", type: "spki" },
1983 });
1984 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1985 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
1986 const postRelayConfig = (body: {
1987 readonly relayUrl: string;
1988 readonly relayIssuer?: string;
1989 readonly cloudUserId: string;
1990 readonly environmentCredential: string;
1991 }) =>
1992 fetchEffect(relayConfigUrl, {
1993 method: "POST",
1994 headers: {
1995 cookie: ownerCookie,
1996 "content-type": "application/json",
1997 },
1998 body: jsonRequestBody({
1999 ...body,
2000 cloudMintPublicKey: cloudKeyPair.publicKey,
2001 endpointRuntime: null,
2002 }),
2003 });
2004
2005 const insecureRelayUrl = yield* postRelayConfig({
2006 relayUrl: "http://relay.example.test",
2007 cloudUserId: "user_123",
2008 environmentCredential: "t3env_test_credential",
2009 });
2010 const insecureRelayIssuer = yield* postRelayConfig({
2011 relayUrl: "https://relay.example.test",
2012 cloudUserId: "user_123",
2013 relayIssuer: "http://relay.example.test",
2014 environmentCredential: "t3env_test_credential",
2015 });
2016 const emptyCredential = yield* postRelayConfig({
2017 relayUrl: "https://relay.example.test",
2018 cloudUserId: "user_123",
2019 environmentCredential: " ",
2020 });
2021 const insecureRelayUrlBody = yield* responseJsonEffect<{ readonly message?: string }>(
2022 insecureRelayUrl,
2023 );
2024 const insecureRelayIssuerBody = yield* responseJsonEffect<{ readonly message?: string }>(
2025 insecureRelayIssuer,
2026 );
2027 const emptyCredentialBody = yield* responseJsonEffect<{ readonly message?: string }>(
2028 emptyCredential,
2029 );
2030
2031 assert.equal(insecureRelayUrl.status, 400);
2032 assert.equal(insecureRelayUrlBody.message, "Relay URL must be a secure absolute HTTPS URL.");
2033 assert.equal(insecureRelayIssuer.status, 400);
2034 assert.equal(
2035 insecureRelayIssuerBody.message,
2036 "Relay issuer must be a secure absolute HTTPS URL.",
2037 );
2038 assert.equal(emptyCredential.status, 400);
2039 assert.equal(emptyCredentialBody.message, "Relay environment credential is required.");
2040 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2041 );
2042
2043 it.effect("rejects relay config replacement from a different cloud account", () =>
2044 Effect.gen(function* () {
2045 yield* buildAppUnderTest();
2046
2047 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2048 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2049 publicKeyEncoding: { format: "pem", type: "spki" },
2050 });
2051 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2052 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2053 const postRelayConfig = (cloudUserId: string, environmentCredential: string) =>
2054 fetchEffect(relayConfigUrl, {
2055 method: "POST",
2056 headers: {
2057 cookie: ownerCookie,
2058 "content-type": "application/json",
2059 },
2060 body: jsonRequestBody({
2061 relayUrl: "https://relay.example.test",
2062 cloudUserId,
2063 environmentCredential,
2064 cloudMintPublicKey: cloudKeyPair.publicKey,
2065 endpointRuntime: null,
2066 }),
2067 });
2068
2069 const firstResponse = yield* postRelayConfig("user_123", "t3env_first_credential");
2070 const replacementResponse = yield* postRelayConfig("user_456", "t3env_second_credential");
2071 const replacementBody = yield* responseJsonEffect<{
2072 readonly _tag?: string;
2073 readonly message?: string;
2074 }>(replacementResponse);
2075
2076 assert.equal(firstResponse.status, 200);
2077 assert.equal(replacementResponse.status, 409);
2078 assert.equal(replacementBody._tag, "EnvironmentHttpConflictError");
2079 assert.equal(
2080 replacementBody.message,
2081 "This environment is already linked to a different cloud account. Unlink it before switching accounts.",
2082 );
2083 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2084 );
2085
2086 it.effect("reports local cloud link state from persisted relay config", () =>
2087 Effect.gen(function* () {
2088 yield* buildAppUnderTest();
2089
2090 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2091 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2092 publicKeyEncoding: { format: "pem", type: "spki" },
2093 });
2094 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2095 const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state");
2096 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2097
2098 const initialResponse = yield* fetchEffect(linkStateUrl, {
2099 headers: {
2100 cookie: ownerCookie,
2101 },
2102 });
2103 const initialBody = yield* responseJsonEffect<{
2104 readonly linked?: boolean;
2105 readonly cloudUserId?: string | null;
2106 }>(initialResponse);
2107 assert.equal(initialResponse.status, 200);
2108 assert.equal(initialBody.linked, false);
2109 assert.equal(initialBody.cloudUserId, null);
2110
2111 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2112 method: "POST",
2113 headers: {
2114 cookie: ownerCookie,
2115 "content-type": "application/json",
2116 },
2117 body: jsonRequestBody({
2118 relayUrl: "https://transport.example.test",
2119 relayIssuer: "https://relay.example.test",
2120 cloudUserId: "user_123",
2121 environmentCredential: "t3env_test_credential",
2122 cloudMintPublicKey: cloudKeyPair.publicKey,
2123 endpointRuntime: null,
2124 }),
2125 });
2126 assert.equal(relayConfigResponse.status, 200);
2127
2128 const linkedResponse = yield* fetchEffect(linkStateUrl, {
2129 headers: {
2130 cookie: ownerCookie,
2131 },
2132 });
2133 const linkedBody = yield* responseJsonEffect<{
2134 readonly linked?: boolean;
2135 readonly cloudUserId?: string | null;
2136 readonly relayUrl?: string | null;
2137 readonly relayIssuer?: string | null;
2138 }>(linkedResponse);
2139
2140 assert.equal(linkedResponse.status, 200);
2141 assert.equal(linkedBody.linked, true);
2142 assert.equal(linkedBody.cloudUserId, "user_123");
2143 assert.equal(linkedBody.relayUrl, "https://transport.example.test");
2144 assert.equal(linkedBody.relayIssuer, "https://relay.example.test");
2145 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2146 );
2147
2148 it.effect("unlinks local cloud state and disables the managed endpoint runtime", () =>
2149 Effect.gen(function* () {
2150 const appliedRuntimeConfigs: Array<unknown> = [];
2151 yield* buildAppUnderTest({
2152 layers: {
2153 cloudManagedEndpointRuntime: {
2154 applyConfig: (config) => {
2155 appliedRuntimeConfigs.push(config);
2156 if (!config) {
2157 return Effect.succeed({ status: "disabled" });
2158 }
2159 return Effect.succeed({
2160 status: "running",
2161 providerKind: "cloudflare_tunnel",
2162 pid: 123,
2163 ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
2164 ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
2165 });
2166 },
2167 },
2168 },
2169 });
2170
2171 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2172 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2173 publicKeyEncoding: { format: "pem", type: "spki" },
2174 });
2175 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2176 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2177 const unlinkUrl = yield* getHttpServerUrl("/api/cloud/unlink");
2178 const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state");
2179
2180 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2181 method: "POST",
2182 headers: {
2183 cookie: ownerCookie,
2184 "content-type": "application/json",
2185 },
2186 body: jsonRequestBody({
2187 relayUrl: "https://transport.example.test",
2188 relayIssuer: "https://relay.example.test",
2189 cloudUserId: "user_123",
2190 environmentCredential: "t3env_test_credential",
2191 cloudMintPublicKey: cloudKeyPair.publicKey,
2192 endpointRuntime: {
2193 providerKind: "cloudflare_tunnel",
2194 connectorToken: "connector-token",
2195 tunnelId: "tunnel-id",
2196 tunnelName: "tunnel-name",
2197 },
2198 }),
2199 });
2200 assert.equal(relayConfigResponse.status, 200);
2201
2202 const unlinkResponse = yield* fetchEffect(unlinkUrl, {
2203 method: "POST",
2204 headers: {
2205 cookie: ownerCookie,
2206 },
2207 });
2208 const unlinkBody = yield* responseJsonEffect<{
2209 readonly ok?: boolean;
2210 readonly endpointRuntimeStatus?: { readonly status?: string };
2211 }>(unlinkResponse);
2212 assert.equal(unlinkResponse.status, 200);
2213 assert.equal(unlinkBody.ok, true);
2214 assert.equal(unlinkBody.endpointRuntimeStatus?.status, "disabled");
2215
2216 const linkStateResponse = yield* fetchEffect(linkStateUrl, {
2217 headers: {
2218 cookie: ownerCookie,
2219 },
2220 });
2221 const linkStateBody = yield* responseJsonEffect<{
2222 readonly linked?: boolean;
2223 readonly cloudUserId?: string | null;
2224 readonly relayUrl?: string | null;
2225 readonly relayIssuer?: string | null;
2226 }>(linkStateResponse);
2227 assert.equal(linkStateResponse.status, 200);
2228 assert.equal(linkStateBody.linked, false);
2229 assert.equal(linkStateBody.cloudUserId, null);
2230 assert.equal(linkStateBody.relayUrl, null);
2231 assert.equal(linkStateBody.relayIssuer, null);
2232 assert.deepEqual(appliedRuntimeConfigs, [
2233 {
2234 providerKind: "cloudflare_tunnel",
2235 connectorToken: "connector-token",
2236 tunnelId: "tunnel-id",
2237 tunnelName: "tunnel-name",
2238 },
2239 null,
2240 ]);
2241 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2242 );
2243
2244 it.effect("rejects replayed cloud mint requests atomically", () =>
2245 Effect.gen(function* () {
2246 yield* buildAppUnderTest();
2247
2248 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2249 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2250 publicKeyEncoding: { format: "pem", type: "spki" },
2251 });
2252 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2253 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2254 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2255 method: "POST",
2256 headers: {
2257 cookie: ownerCookie,
2258 "content-type": "application/json",
2259 },
2260 body: jsonRequestBody({
2261 relayUrl: "https://relay.example.test",
2262 cloudUserId: "user_123",
2263 environmentCredential: "t3env_test_credential",
2264 cloudMintPublicKey: cloudKeyPair.publicKey,
2265 endpointRuntime: null,
2266 }),
2267 });
2268 assert.equal(relayConfigResponse.status, 200);
2269
2270 const now = yield* DateTime.now;
2271 const request = makeCloudMintCredentialRequest({
2272 privateKey: cloudKeyPair.privateKey,
2273 environmentId: testEnvironmentDescriptor.environmentId,
2274 clientProofKeyThumbprint: "client-proof-key-thumbprint",
2275 nonce: "cloud-mint-nonce-1",
2276 issuedAt: DateTime.formatIso(now),
2277 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2278 });
2279 const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential");
2280 const postMint = () =>
2281 fetchEffect(mintUrl, {
2282 method: "POST",
2283 headers: {
2284 "content-type": "application/json",
2285 },
2286 body: jsonRequestBody(request),
2287 });
2288
2289 const firstResponse = yield* postMint();
2290 const replayResponse = yield* postMint();
2291 const replayBody = yield* responseJsonEffect<{
2292 readonly _tag?: string;
2293 readonly message?: string;
2294 }>(replayResponse);
2295
2296 assert.equal(firstResponse.status, 200);
2297 assert.equal(replayResponse.status, 409);
2298 assert.equal(replayBody._tag, "EnvironmentHttpConflictError");
2299 assert.equal(replayBody.message, "Cloud mint request was already consumed.");
2300 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2301 );
2302
2303 it.effect("serves the documented T3 Cloud mint credential endpoint", () =>
2304 Effect.gen(function* () {
2305 yield* buildAppUnderTest();
2306
2307 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2308 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2309 publicKeyEncoding: { format: "pem", type: "spki" },
2310 });
2311 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2312 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2313 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2314 method: "POST",
2315 headers: {
2316 cookie: ownerCookie,
2317 "content-type": "application/json",
2318 },
2319 body: jsonRequestBody({
2320 relayUrl: "https://relay.example.test",
2321 cloudUserId: "user_123",
2322 environmentCredential: "t3env_test_credential",
2323 cloudMintPublicKey: cloudKeyPair.publicKey,
2324 endpointRuntime: null,
2325 }),
2326 });
2327 assert.equal(relayConfigResponse.status, 200);
2328
2329 const now = yield* DateTime.now;
2330 const request = makeCloudMintCredentialRequest({
2331 privateKey: cloudKeyPair.privateKey,
2332 environmentId: testEnvironmentDescriptor.environmentId,
2333 clientProofKeyThumbprint: "client-proof-key-thumbprint",
2334 jti: "cloud-mint-jti-documented-endpoint",
2335 nonce: "cloud-mint-nonce-documented-endpoint",
2336 issuedAt: DateTime.formatIso(now),
2337 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2338 });
2339 const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential");
2340 const response = yield* fetchEffect(mintUrl, {
2341 method: "POST",
2342 headers: {
2343 "content-type": "application/json",
2344 },
2345 body: jsonRequestBody(request),
2346 });
2347
2348 assert.equal(response.status, 200);
2349 const body = yield* responseJsonEffect<{
2350 readonly credential?: string;
2351 readonly proof?: string;
2352 }>(response);
2353 assert.equal(typeof body.credential, "string");
2354 assert.equal(typeof body.proof, "string");
2355 assert.equal(
2356 decodeCompactJwtPayload<{ readonly requestNonce?: string }>(body.proof!).requestNonce,
2357 "cloud-mint-nonce-documented-endpoint",
2358 );
2359 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2360 );
2361
2362 it.effect("serves signed T3 Cloud environment health checks", () =>
2363 Effect.gen(function* () {
2364 yield* buildAppUnderTest();
2365
2366 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2367 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2368 publicKeyEncoding: { format: "pem", type: "spki" },
2369 });
2370 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2371 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2372 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2373 method: "POST",
2374 headers: {
2375 cookie: ownerCookie,
2376 "content-type": "application/json",
2377 },
2378 body: jsonRequestBody({
2379 relayUrl: "https://relay.example.test",
2380 cloudUserId: "user_123",
2381 environmentCredential: "t3env_test_credential",
2382 cloudMintPublicKey: cloudKeyPair.publicKey,
2383 endpointRuntime: null,
2384 }),
2385 });
2386 assert.equal(relayConfigResponse.status, 200);
2387
2388 const now = yield* DateTime.now;
2389 const request = makeCloudEnvironmentHealthRequest({
2390 privateKey: cloudKeyPair.privateKey,
2391 environmentId: testEnvironmentDescriptor.environmentId,
2392 jti: "cloud-health-jti-documented-endpoint",
2393 nonce: "cloud-health-nonce-documented-endpoint",
2394 issuedAt: DateTime.formatIso(now),
2395 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2396 });
2397 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
2398 const response = yield* fetchEffect(healthUrl, {
2399 method: "POST",
2400 headers: {
2401 "content-type": "application/json",
2402 },
2403 body: jsonRequestBody(request),
2404 });
2405
2406 assert.equal(response.status, 200);
2407 const body = yield* responseJsonEffect<{
2408 readonly status?: string;
2409 readonly descriptor?: { readonly environmentId?: string };
2410 readonly proof?: string;
2411 }>(response);
2412 assert.equal(body.status, "online");
2413 assert.equal(body.descriptor?.environmentId, testEnvironmentDescriptor.environmentId);
2414 assert.equal(typeof body.proof, "string");
2415 assert.equal(
2416 decodeCompactJwtPayload<{ readonly requestNonce?: string }>(body.proof!).requestNonce,
2417 "cloud-health-nonce-documented-endpoint",
2418 );
2419 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2420 );
2421
2422 it.effect("rejects replayed cloud health requests atomically", () =>
2423 Effect.gen(function* () {
2424 yield* buildAppUnderTest();
2425
2426 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2427 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2428 publicKeyEncoding: { format: "pem", type: "spki" },
2429 });
2430 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2431 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2432 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2433 method: "POST",
2434 headers: {
2435 cookie: ownerCookie,
2436 "content-type": "application/json",
2437 },
2438 body: jsonRequestBody({
2439 relayUrl: "https://relay.example.test",
2440 cloudUserId: "user_123",
2441 environmentCredential: "t3env_test_credential",
2442 cloudMintPublicKey: cloudKeyPair.publicKey,
2443 endpointRuntime: null,
2444 }),
2445 });
2446 assert.equal(relayConfigResponse.status, 200);
2447
2448 const now = yield* DateTime.now;
2449 const request = makeCloudEnvironmentHealthRequest({
2450 privateKey: cloudKeyPair.privateKey,
2451 environmentId: testEnvironmentDescriptor.environmentId,
2452 jti: "cloud-health-jti-replay",
2453 nonce: "cloud-health-nonce-replay",
2454 issuedAt: DateTime.formatIso(now),
2455 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2456 });
2457 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
2458 const postHealth = () =>
2459 fetchEffect(healthUrl, {
2460 method: "POST",
2461 headers: {
2462 "content-type": "application/json",
2463 },
2464 body: jsonRequestBody(request),
2465 });
2466
2467 const firstResponse = yield* postHealth();
2468 const replayResponse = yield* postHealth();
2469 const replayBody = yield* responseJsonEffect<{
2470 readonly _tag?: string;
2471 readonly message?: string;
2472 }>(replayResponse);
2473
2474 assert.equal(firstResponse.status, 200);
2475 assert.equal(replayResponse.status, 409);
2476 assert.equal(replayBody._tag, "EnvironmentHttpConflictError");
2477 assert.equal(replayBody.message, "Cloud health request was already consumed.");
2478 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2479 );
2480
2481 it.effect(
2482 "validates cloud proofs against the configured relay issuer, not the transport URL",
2483 () =>
2484 Effect.gen(function* () {
2485 yield* buildAppUnderTest();
2486
2487 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2488 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2489 publicKeyEncoding: { format: "pem", type: "spki" },
2490 });
2491 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2492 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2493 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2494 method: "POST",
2495 headers: {
2496 cookie: ownerCookie,
2497 "content-type": "application/json",
2498 },
2499 body: jsonRequestBody({
2500 relayUrl: "https://transport.example.test",
2501 cloudUserId: "user_123",
2502 relayIssuer: "https://relay.example.test",
2503 environmentCredential: "t3env_test_credential",
2504 cloudMintPublicKey: cloudKeyPair.publicKey,
2505 endpointRuntime: null,
2506 }),
2507 });
2508 assert.equal(relayConfigResponse.status, 200);
2509
2510 const now = yield* DateTime.now;
2511 const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential");
2512 const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) =>
2513 fetchEffect(mintUrl, {
2514 method: "POST",
2515 headers: {
2516 "content-type": "application/json",
2517 },
2518 body: jsonRequestBody(request),
2519 });
2520
2521 const acceptedResponse = yield* postMint(
2522 makeCloudMintCredentialRequest({
2523 privateKey: cloudKeyPair.privateKey,
2524 environmentId: testEnvironmentDescriptor.environmentId,
2525 clientProofKeyThumbprint: "client-proof-key-thumbprint",
2526 issuer: "https://relay.example.test",
2527 jti: "cloud-mint-jti-explicit-relay-issuer",
2528 nonce: "cloud-mint-nonce-explicit-relay-issuer",
2529 issuedAt: DateTime.formatIso(now),
2530 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2531 }),
2532 );
2533 const rejectedResponse = yield* postMint(
2534 makeCloudMintCredentialRequest({
2535 privateKey: cloudKeyPair.privateKey,
2536 environmentId: testEnvironmentDescriptor.environmentId,
2537 clientProofKeyThumbprint: "client-proof-key-thumbprint",
2538 issuer: "https://transport.example.test",
2539 jti: "cloud-mint-jti-transport-url",
2540 nonce: "cloud-mint-nonce-transport-url",
2541 issuedAt: DateTime.formatIso(now),
2542 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2543 }),
2544 );
2545
2546 assert.equal(acceptedResponse.status, 200);
2547 assert.equal(rejectedResponse.status, 401);
2548 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2549 );
2550
2551 it.effect("fails relay config when the managed endpoint connector cannot start", () =>
2552 Effect.gen(function* () {
2553 yield* buildAppUnderTest({
2554 layers: {
2555 cloudManagedEndpointRuntime: {
2556 applyConfig: () =>
2557 Effect.succeed({
2558 status: "failed",
2559 providerKind: "cloudflare_tunnel",
2560 reason: "cloudflared missing",
2561 tunnelId: "tunnel-1",
2562 }),
2563 },
2564 },
2565 });
2566
2567 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2568 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2569 publicKeyEncoding: { format: "pem", type: "spki" },
2570 });
2571 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2572 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2573 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2574 method: "POST",
2575 headers: {
2576 cookie: ownerCookie,
2577 "content-type": "application/json",
2578 },
2579 body: jsonRequestBody({
2580 relayUrl: "https://relay.example.test",
2581 cloudUserId: "user_123",
2582 environmentCredential: "t3env_test_credential",
2583 cloudMintPublicKey: cloudKeyPair.publicKey,
2584 endpointRuntime: {
2585 providerKind: "cloudflare_tunnel",
2586 connectorToken: "connector-token",
2587 tunnelId: "tunnel-1",
2588 },
2589 }),
2590 });
2591
2592 assert.equal(relayConfigResponse.status, 503);
2593 const relayConfigBody = yield* responseJsonEffect<{
2594 _tag?: string;
2595 message?: string;
2596 endpointRuntimeStatus?: { status?: string; reason?: string };
2597 }>(relayConfigResponse);
2598 assert.equal(relayConfigBody._tag, "EnvironmentCloudEndpointUnavailableError");
2599 assert.equal(relayConfigBody.message, "Managed endpoint runtime could not be started.");
2600 assert.equal(relayConfigBody.endpointRuntimeStatus?.status, "failed");
2601 assert.equal(relayConfigBody.endpointRuntimeStatus?.reason, "cloudflared missing");
2602
2603 const now = yield* DateTime.now;
2604 const healthRequest = makeCloudEnvironmentHealthRequest({
2605 privateKey: cloudKeyPair.privateKey,
2606 environmentId: testEnvironmentDescriptor.environmentId,
2607 nonce: "cloud-health-after-failed-runtime",
2608 issuedAt: DateTime.formatIso(now),
2609 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2610 });
2611 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
2612 const healthResponse = yield* fetchEffect(healthUrl, {
2613 method: "POST",
2614 headers: {
2615 "content-type": "application/json",
2616 },
2617 body: jsonRequestBody(healthRequest),
2618 });
2619 const healthBody = yield* responseJsonEffect<{
2620 _tag?: string;
2621 message?: string;
2622 }>(healthResponse);
2623 assert.equal(healthResponse.status, 500);
2624 assert.equal(healthBody._tag, "EnvironmentHttpInternalServerError");
2625 assert.equal(
2626 healthBody.message,
2627 "Cloud mint public key is not installed for this environment.",
2628 );
2629 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2630 );
2631
2632 it.effect("rejects cloud mint requests with the wrong issuer or audience", () =>
2633 Effect.gen(function* () {
2634 yield* buildAppUnderTest();
2635
2636 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2637 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2638 publicKeyEncoding: { format: "pem", type: "spki" },
2639 });
2640 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2641 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2642 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2643 method: "POST",
2644 headers: {
2645 cookie: ownerCookie,
2646 "content-type": "application/json",
2647 },
2648 body: jsonRequestBody({
2649 relayUrl: "https://relay.example.test/",
2650 cloudUserId: "user_123",
2651 environmentCredential: "t3env_test_credential",
2652 cloudMintPublicKey: cloudKeyPair.publicKey,
2653 endpointRuntime: null,
2654 }),
2655 });
2656 assert.equal(relayConfigResponse.status, 200);
2657
2658 const now = yield* DateTime.now;
2659 const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential");
2660 const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) =>
2661 fetchEffect(mintUrl, {
2662 method: "POST",
2663 headers: {
2664 "content-type": "application/json",
2665 },
2666 body: jsonRequestBody(request),
2667 });
2668
2669 const wrongIssuer = yield* postMint(
2670 makeCloudMintCredentialRequest({
2671 privateKey: cloudKeyPair.privateKey,
2672 environmentId: testEnvironmentDescriptor.environmentId,
2673 clientProofKeyThumbprint: "client-proof-key-thumbprint",
2674 issuer: "https://attacker.example.test",
2675 jti: "cloud-mint-jti-wrong-issuer",
2676 nonce: "cloud-mint-nonce-wrong-issuer",
2677 issuedAt: DateTime.formatIso(now),
2678 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2679 }),
2680 );
2681 const wrongAudience = yield* postMint(
2682 makeCloudMintCredentialRequest({
2683 privateKey: cloudKeyPair.privateKey,
2684 environmentId: testEnvironmentDescriptor.environmentId,
2685 clientProofKeyThumbprint: "client-proof-key-thumbprint",
2686 audience: "t3-env:other-environment",
2687 jti: "cloud-mint-jti-wrong-audience",
2688 nonce: "cloud-mint-nonce-wrong-audience",
2689 issuedAt: DateTime.formatIso(now),
2690 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2691 }),
2692 );
2693
2694 assert.equal(wrongIssuer.status, 401);
2695 assert.equal(wrongAudience.status, 401);
2696 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2697 );
2698
2699 it.effect("rejects cloud mint requests for a cloud subject other than the linked user", () =>
2700 Effect.gen(function* () {
2701 yield* buildAppUnderTest();
2702
2703 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2704 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2705 publicKeyEncoding: { format: "pem", type: "spki" },
2706 });
2707 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2708 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2709 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2710 method: "POST",
2711 headers: {
2712 cookie: ownerCookie,
2713 "content-type": "application/json",
2714 },
2715 body: jsonRequestBody({
2716 relayUrl: "https://relay.example.test/",
2717 cloudUserId: "user_123",
2718 environmentCredential: "t3env_test_credential",
2719 cloudMintPublicKey: cloudKeyPair.publicKey,
2720 endpointRuntime: null,
2721 }),
2722 });
2723 assert.equal(relayConfigResponse.status, 200);
2724
2725 const now = yield* DateTime.now;
2726 const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential");
2727 const response = yield* fetchEffect(mintUrl, {
2728 method: "POST",
2729 headers: {
2730 "content-type": "application/json",
2731 },
2732 body: jsonRequestBody(
2733 makeCloudMintCredentialRequest({
2734 privateKey: cloudKeyPair.privateKey,
2735 environmentId: testEnvironmentDescriptor.environmentId,
2736 clientProofKeyThumbprint: "client-proof-key-thumbprint",
2737 subject: "user_other",
2738 jti: "cloud-mint-jti-wrong-subject",
2739 nonce: "cloud-mint-nonce-wrong-subject",
2740 issuedAt: DateTime.formatIso(now),
2741 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2742 }),
2743 ),
2744 });
2745
2746 assert.equal(response.status, 401);
2747 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2748 );
2749
2750 it.effect("rejects cloud mint requests without the exact connect scope", () =>
2751 Effect.gen(function* () {
2752 yield* buildAppUnderTest();
2753
2754 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2755 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2756 publicKeyEncoding: { format: "pem", type: "spki" },
2757 });
2758 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2759 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2760 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2761 method: "POST",
2762 headers: {
2763 cookie: ownerCookie,
2764 "content-type": "application/json",
2765 },
2766 body: jsonRequestBody({
2767 relayUrl: "https://relay.example.test/",
2768 cloudUserId: "user_123",
2769 environmentCredential: "t3env_test_credential",
2770 cloudMintPublicKey: cloudKeyPair.publicKey,
2771 endpointRuntime: null,
2772 }),
2773 });
2774 assert.equal(relayConfigResponse.status, 200);
2775
2776 const now = yield* DateTime.now;
2777 const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential");
2778 const response = yield* fetchEffect(mintUrl, {
2779 method: "POST",
2780 headers: {
2781 "content-type": "application/json",
2782 },
2783 body: jsonRequestBody(
2784 makeCloudMintCredentialRequest({
2785 privateKey: cloudKeyPair.privateKey,
2786 environmentId: testEnvironmentDescriptor.environmentId,
2787 clientProofKeyThumbprint: "client-proof-key-thumbprint",
2788 jti: "cloud-mint-jti-duplicate-scope",
2789 nonce: "cloud-mint-nonce-duplicate-scope",
2790 issuedAt: DateTime.formatIso(now),
2791 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2792 scope: ["environment:connect", "environment:connect"],
2793 }),
2794 ),
2795 });
2796
2797 assert.equal(response.status, 401);
2798 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2799 );
2800
2801 it.effect("rejects cloud health requests with the wrong issuer or audience", () =>
2802 Effect.gen(function* () {
2803 yield* buildAppUnderTest();
2804
2805 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2806 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2807 publicKeyEncoding: { format: "pem", type: "spki" },
2808 });
2809 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2810 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2811 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2812 method: "POST",
2813 headers: {
2814 cookie: ownerCookie,
2815 "content-type": "application/json",
2816 },
2817 body: jsonRequestBody({
2818 relayUrl: "https://relay.example.test/",
2819 cloudUserId: "user_123",
2820 environmentCredential: "t3env_test_credential",
2821 cloudMintPublicKey: cloudKeyPair.publicKey,
2822 endpointRuntime: null,
2823 }),
2824 });
2825 assert.equal(relayConfigResponse.status, 200);
2826
2827 const now = yield* DateTime.now;
2828 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
2829 const postHealth = (request: ReturnType<typeof makeCloudEnvironmentHealthRequest>) =>
2830 fetchEffect(healthUrl, {
2831 method: "POST",
2832 headers: {
2833 "content-type": "application/json",
2834 },
2835 body: jsonRequestBody(request),
2836 });
2837
2838 const wrongIssuer = yield* postHealth(
2839 makeCloudEnvironmentHealthRequest({
2840 privateKey: cloudKeyPair.privateKey,
2841 environmentId: testEnvironmentDescriptor.environmentId,
2842 issuer: "https://attacker.example.test",
2843 jti: "cloud-health-jti-wrong-issuer",
2844 nonce: "cloud-health-nonce-wrong-issuer",
2845 issuedAt: DateTime.formatIso(now),
2846 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2847 }),
2848 );
2849 const wrongAudience = yield* postHealth(
2850 makeCloudEnvironmentHealthRequest({
2851 privateKey: cloudKeyPair.privateKey,
2852 environmentId: testEnvironmentDescriptor.environmentId,
2853 audience: "t3-env:other-environment",
2854 jti: "cloud-health-jti-wrong-audience",
2855 nonce: "cloud-health-nonce-wrong-audience",
2856 issuedAt: DateTime.formatIso(now),
2857 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2858 }),
2859 );
2860
2861 assert.equal(wrongIssuer.status, 401);
2862 assert.equal(wrongAudience.status, 401);
2863 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2864 );
2865
2866 it.effect("rejects cloud health requests for a cloud subject other than the linked user", () =>
2867 Effect.gen(function* () {
2868 yield* buildAppUnderTest();
2869
2870 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2871 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2872 publicKeyEncoding: { format: "pem", type: "spki" },
2873 });
2874 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2875 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2876 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2877 method: "POST",
2878 headers: {
2879 cookie: ownerCookie,
2880 "content-type": "application/json",
2881 },
2882 body: jsonRequestBody({
2883 relayUrl: "https://relay.example.test/",
2884 cloudUserId: "user_123",
2885 environmentCredential: "t3env_test_credential",
2886 cloudMintPublicKey: cloudKeyPair.publicKey,
2887 endpointRuntime: null,
2888 }),
2889 });
2890 assert.equal(relayConfigResponse.status, 200);
2891
2892 const now = yield* DateTime.now;
2893 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
2894 const response = yield* fetchEffect(healthUrl, {
2895 method: "POST",
2896 headers: {
2897 "content-type": "application/json",
2898 },
2899 body: jsonRequestBody(
2900 makeCloudEnvironmentHealthRequest({
2901 privateKey: cloudKeyPair.privateKey,
2902 environmentId: testEnvironmentDescriptor.environmentId,
2903 subject: "user_other",
2904 jti: "cloud-health-jti-wrong-subject",
2905 nonce: "cloud-health-nonce-wrong-subject",
2906 issuedAt: DateTime.formatIso(now),
2907 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2908 }),
2909 ),
2910 });
2911
2912 assert.equal(response.status, 401);
2913 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2914 );
2915
2916 it.effect("rejects cloud health requests without the exact status scope", () =>
2917 Effect.gen(function* () {
2918 yield* buildAppUnderTest();
2919
2920 const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
2921 privateKeyEncoding: { format: "pem", type: "pkcs8" },
2922 publicKeyEncoding: { format: "pem", type: "spki" },
2923 });
2924 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
2925 const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config");
2926 const relayConfigResponse = yield* fetchEffect(relayConfigUrl, {
2927 method: "POST",
2928 headers: {
2929 cookie: ownerCookie,
2930 "content-type": "application/json",
2931 },
2932 body: jsonRequestBody({
2933 relayUrl: "https://relay.example.test/",
2934 cloudUserId: "user_123",
2935 environmentCredential: "t3env_test_credential",
2936 cloudMintPublicKey: cloudKeyPair.publicKey,
2937 endpointRuntime: null,
2938 }),
2939 });
2940 assert.equal(relayConfigResponse.status, 200);
2941
2942 const now = yield* DateTime.now;
2943 const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health");
2944 const response = yield* fetchEffect(healthUrl, {
2945 method: "POST",
2946 headers: {
2947 "content-type": "application/json",
2948 },
2949 body: jsonRequestBody(
2950 makeCloudEnvironmentHealthRequest({
2951 privateKey: cloudKeyPair.privateKey,
2952 environmentId: testEnvironmentDescriptor.environmentId,
2953 jti: "cloud-health-jti-duplicate-scope",
2954 nonce: "cloud-health-nonce-duplicate-scope",
2955 issuedAt: DateTime.formatIso(now),
2956 expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })),
2957 scope: ["environment:status", "environment:status"],
2958 }),
2959 ),
2960 });
2961
2962 assert.equal(response.status, 401);
2963 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2964 );
2965
2966 it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () =>
2967 Effect.gen(function* () {
2968 yield* buildAppUnderTest();
2969
2970 const bearerToken = yield* getAuthenticatedBearerSessionToken();
2971 const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket");
2972 const wsTicketResponse = yield* fetchEffect(wsTicketUrl, {
2973 method: "POST",
2974 headers: {
2975 authorization: `Bearer ${bearerToken}`,
2976 },
2977 });
2978 const wsTicketBody = yield* responseJsonEffect<{
2979 readonly ticket: string;
2980 readonly expiresAt: string;
2981 }>(wsTicketResponse);
2982
2983 assert.equal(wsTicketResponse.status, 200);
2984 assert.equal(typeof wsTicketBody.ticket, "string");
2985 assert.isTrue(wsTicketBody.ticket.length > 0);
2986 assert.equal(typeof wsTicketBody.expiresAt, "string");
2987 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2988 );
2989
2990 it.effect("does not allow management-only access tokens to operate the environment", () =>
2991 Effect.gen(function* () {
2992 yield* buildAppUnderTest();
2993
2994 const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken(
2995 defaultDesktopBootstrapToken,
2996 { scope: "access:write" },
2997 );
2998 assert.equal(exchangeResponse.status, 200);
2999 assert.equal(tokenBody.scope, "access:write");
3000 assert.isDefined(tokenBody.access_token);
3001
3002 const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
3003 headers: {
3004 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
3005 },
3006 body: yield* HttpBody.json({}),
3007 });
3008 const overbroadPairingBody = (yield* overbroadPairingResponse.json) as {
3009 readonly requiredScope: string;
3010 };
3011 const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
3012 headers: {
3013 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
3014 },
3015 body: yield* HttpBody.json({ scopes: ["access:write"] }),
3016 });
3017 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
3018 headers: {
3019 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
3020 },
3021 });
3022 const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string };
3023 const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", {
3024 headers: {
3025 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
3026 },
3027 });
3028 const faviconBody = (yield* faviconResponse.json) as {
3029 readonly _tag: string;
3030 readonly code: string;
3031 readonly requiredScope: string;
3032 readonly traceId: string;
3033 };
3034
3035 assert.equal(overbroadPairingResponse.status, 403);
3036 assert.equal(overbroadPairingBody.requiredScope, "orchestration:read");
3037 assert.equal(pairingResponse.status, 200);
3038 assert.equal(wsTicketResponse.status, 200);
3039 assert.equal(faviconResponse.status, 403);
3040 assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError");
3041 assert.equal(faviconBody.code, "insufficient_scope");
3042 assert.equal(faviconBody.requiredScope, "orchestration:read");
3043 assert.equal(typeof faviconBody.traceId, "string");
3044
3045 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
3046 const rpcError = yield* Effect.flip(
3047 Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))),
3048 );
3049 assert.equal(rpcError._tag, "EnvironmentAuthorizationError");
3050 if (rpcError._tag === "EnvironmentAuthorizationError") {
3051 assert.equal(rpcError.requiredScope, "orchestration:read");
3052 }
3053 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3054 );
3055
3056 it.effect("includes CORS headers on remote auth success responses", () =>
3057 Effect.gen(function* () {
3058 yield* buildAppUnderTest();
3059
3060 const origin = crossOriginClientOrigin;
3061 const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken(
3062 defaultDesktopBootstrapToken,
3063 {
3064 headers: { origin },
3065 },
3066 );
3067
3068 assert.equal(tokenResponse.status, 200);
3069 assertBrowserApiCorsResponseHeaders(tokenResponse.headers);
3070 assert.equal(tokenBody.token_type, "Bearer");
3071 assert.equal(typeof tokenBody.access_token, "string");
3072
3073 const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
3074 const sessionResponse = yield* fetchEffect(sessionUrl, {
3075 headers: {
3076 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
3077 origin,
3078 },
3079 });
3080 const sessionBody = yield* responseJsonEffect<{
3081 readonly authenticated: boolean;
3082 readonly sessionMethod?: string;
3083 }>(sessionResponse);
3084
3085 assert.equal(sessionResponse.status, 200);
3086 assertBrowserApiCorsResponseHeaders(sessionResponse.headers);
3087 assert.equal(sessionBody.authenticated, true);
3088 assert.equal(sessionBody.sessionMethod, "bearer-access-token");
3089
3090 const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket");
3091 const wsTicketResponse = yield* fetchEffect(wsTicketUrl, {
3092 method: "POST",
3093 headers: {
3094 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
3095 origin,
3096 },
3097 });
3098 const wsTicketBody = yield* responseJsonEffect<{
3099 readonly ticket: string;
3100 }>(wsTicketResponse);
3101
3102 assert.equal(wsTicketResponse.status, 200);
3103 assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers);
3104 assert.equal(typeof wsTicketBody.ticket, "string");
3105 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3106 );
3107
3108 it.effect(
3109 "responds to remote auth websocket-ticket preflight requests with authorization CORS headers",
3110 () =>
3111 Effect.gen(function* () {
3112 yield* buildAppUnderTest();
3113
3114 const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket");
3115 const response = yield* fetchEffect(wsTicketUrl, {
3116 method: "OPTIONS",
3117 headers: {
3118 origin: crossOriginClientOrigin,
3119 "access-control-request-method": "POST",
3120 "access-control-request-headers": "authorization",
3121 },
3122 });
3123
3124 assert.equal(response.status, 204);
3125 assertBrowserApiCorsPreflightHeaders(response.headers);
3126 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3127 );
3128
3129 it.effect("includes CORS headers on remote websocket-ticket auth failures", () =>
3130 Effect.gen(function* () {
3131 yield* buildAppUnderTest();
3132
3133 const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket");
3134 const response = yield* fetchEffect(wsTicketUrl, {
3135 method: "POST",
3136 headers: {
3137 origin: crossOriginClientOrigin,
3138 },
3139 });
3140 const body = yield* responseJsonEffect<{
3141 readonly _tag?: string;
3142 readonly code?: string;
3143 readonly reason?: string;
3144 readonly traceId?: string;
3145 }>(response);
3146
3147 assert.equal(response.status, 401);
3148 assertBrowserApiCorsResponseHeaders(response.headers);
3149 assert.equal(body._tag, "EnvironmentAuthInvalidError");
3150 assert.equal(body.code, "auth_invalid");
3151 assert.equal(body.reason, "missing_credential");
3152 assert.equal(typeof body.traceId, "string");
3153 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
3154 );
3155
3156 it.effect("issues authenticated one-time pairing credentials for additional clients", () =>
3157 Effect.gen(function* () {
3158 yield* buildAppUnderTest();
3159
3160 const response = yield* HttpClient.post("/api/auth/pairing-token", {
3161 headers: {
3162 cookie: yield* getAuthenticatedSessionCookieHeader(),
3163 },
3164 body: yield* HttpBody.json({}),
3165 });
3166 const body = (yield* response.json) as {
3167 readonly credential: string;
3168 readonly expiresAt: string;
3169 };
3170
3171 assert.equal(response.status, 200);
3172 assert.equal(typeof body.credential, "string");
1443 assert.isTrue(body.credential.length > 0);3173 assert.isTrue(body.credential.length > 0);
1444 assert.equal(typeof body.expiresAt, "string");3174 assert.equal(typeof body.expiresAt, "string");
1445 3175
@@ -1538,8 +3268,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 92 unmodified lines
1538 const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", {3268 const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", {
1539 headers: {3269 headers: {
1540 cookie: ownerCookie,3270 cookie: ownerCookie,
3271 "content-type": "application/json",
1541 },3272 },
1542 body: yield* HttpBody.json({ id: createdBody.id }),3273 body: HttpBody.text(jsonRequestBody({ id: createdBody.id }), "application/json"),
1543 });3274 });
1544 const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential);3275 const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential);
1545 3276
@@ -1601,16 +3332,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 55 unmodified lines
1601 });3332 });
1602 3333
1603 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();3334 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1604 const ownerPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {3335 const pairingTokenUrl = yield* getHttpServerUrl("/api/auth/pairing-token");
3336 const ownerPairingResponse = yield* fetchEffect(pairingTokenUrl, {
3337 method: "POST",
1605 headers: {3338 headers: {
1606 cookie: ownerCookie,3339 cookie: ownerCookie,
3340 "content-type": "application/json",
1607 },3341 },
1608 body: yield* HttpBody.json({ label: "Julius iPhone" }),3342 body: jsonRequestBody({
3343 label: "Julius iPhone",
3344 }),
1609 });3345 });
1610 const ownerPairingBody = (yield* ownerPairingResponse.json) as {3346 const ownerPairingBody = yield* responseJsonEffect<{
1611 readonly credential: string;3347 readonly credential: string;
1612 readonly label?: string;3348 readonly label?: string;
1613 };3349 }>(ownerPairingResponse);
1614 assert.equal(ownerPairingResponse.status, 200);3350 assert.equal(ownerPairingResponse.status, 200);
1615 const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, {3351 const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, {
1616 headers: {3352 headers: {
@@ -1794,8 +3530,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 177 unmodified lines
1794 const revokeResponse = yield* HttpClient.post("/api/auth/clients/revoke", {3530 const revokeResponse = yield* HttpClient.post("/api/auth/clients/revoke", {
1795 headers: {3531 headers: {
1796 cookie: ownerCookie,3532 cookie: ownerCookie,
3533 "content-type": "application/json",
1797 },3534 },
1798 body: yield* HttpBody.json({ sessionId: pairedSessionId }),3535 body: HttpBody.text(jsonRequestBody({ sessionId: pairedSessionId }), "application/json"),
1799 });3536 });
1800 const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {3537 const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1801 headers: {3538 headers: {
@@ -1890,20 +3627,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 88 unmodified lines
1890 );3627 );
1891 3628
1892 it.effect(3629 it.effect(
1893 "accepts websocket rpc handshake with a dedicated websocket token in the query string",3630 "accepts websocket rpc handshake with a dedicated websocket ticket in the query string",
1894 () =>3631 () =>
1895 Effect.gen(function* () {3632 Effect.gen(function* () {
1896 yield* buildAppUnderTest();3633 yield* buildAppUnderTest();
1897 3634
1898 const bearerToken = yield* getAuthenticatedBearerSessionToken();3635 const bearerToken = yield* getAuthenticatedBearerSessionToken();
1899 const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {3636 const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket");
3637 const wsTicketResponse = yield* fetchEffect(wsTicketUrl, {
3638 method: "POST",
1900 headers: {3639 headers: {
1901 authorization: `Bearer ${bearerToken}`,3640 authorization: `Bearer ${bearerToken}`,
1902 },3641 },
1903 });3642 });
1904 const wsTicketBody = (yield* wsTicketResponse.json) as {3643 const wsTicketBody = yield* responseJsonEffect<{
1905 readonly ticket: string;3644 readonly ticket: string;
1906 };3645 }>(wsTicketResponse);
1907 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;3646 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
1908 3647
1909 const response = yield* Effect.scoped(3648 const response = yield* Effect.scoped(
@@ -2103,7 +3842,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 193 unmodified lines
2103 "content-type": "application/json",3842 "content-type": "application/json",
2104 origin: "http://localhost:5733",3843 origin: "http://localhost:5733",
2105 },3844 },
2106 body: yield* HttpBody.json(payload),3845 // @effect-diagnostics-next-line preferSchemaOverJson:off
3846 body: HttpBody.text(JSON.stringify(payload), "application/json"),
2107 });3847 });
2108 3848
2109 assert.equal(response.status, 204);3849 assert.equal(response.status, 204);
@@ -2148,8 +3888,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 38 unmodified lines
2148 ]);3888 ]);
2149 assert.deepEqual(upstreamRequests, [3889 assert.deepEqual(upstreamRequests, [
2150 {3890 {
2151 // @effect-diagnostics-next-line preferSchemaOverJson:off3891 body: jsonRequestBody(payload),
2152 body: JSON.stringify(payload),
2153 contentType: "application/json",3892 contentType: "application/json",
2154 },3893 },
2155 ]);3894 ]);
@@ -2170,14 +3909,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 14 unmodified lines
2170 3909
2171 assert.equal(response.status, 204);3910 assert.equal(response.status, 204);
2172 assert.equal(response.headers["access-control-allow-origin"], "*");3911 assert.equal(response.headers["access-control-allow-origin"], "*");
2173 assert.deepEqual(3912 assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-methods"]), [
2174 splitHeaderTokens(response.headers["access-control-allow-methods"] ?? null),3913 "GET",
2175 ["GET", "OPTIONS", "POST"],3914 "OPTIONS",
2176 );3915 "POST",
2177 assert.deepEqual(3916 ]);
2178 splitHeaderTokens(response.headers["access-control-allow-headers"] ?? null),3917 assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-headers"]), [
2179 ["authorization", "b3", "content-type", "traceparent"],3918 "authorization",
2180 );3919 "b3",
3920 "content-type",
3921 "dpop",
3922 "traceparent",
3923 ]);
2181 }).pipe(Effect.provide(NodeHttpServer.layerTest)),3924 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2182 );3925 );
2183 3926
@@ -2214,7 +3957,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 30 unmodified lines
2214 cookie: yield* getAuthenticatedSessionCookieHeader(),3957 cookie: yield* getAuthenticatedSessionCookieHeader(),
2215 "content-type": "application/json",3958 "content-type": "application/json",
2216 },3959 },
2217 body: yield* HttpBody.json(payload),3960 // @effect-diagnostics-next-line preferSchemaOverJson:off
3961 body: HttpBody.text(JSON.stringify(payload), "application/json"),
2218 });3962 });
2219 3963
2220 assert.equal(response.status, 204);3964 assert.equal(response.status, 204);
apps/server/src/server.ts+9−0
@@ -44,6 +44,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun · 43 unmodified lines
44import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";44import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
45import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";45import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
46import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";46import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
47import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
47import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";48import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
48import { ServerSettingsLive } from "./serverSettings.ts";49import { ServerSettingsLive } from "./serverSettings.ts";
49import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";50import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
@@ -67,6 +68,8 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts · 17 unmodified lines
67import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";68import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
68import * as ServerSecretStore from "./auth/ServerSecretStore.ts";69import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
69import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";70import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
71import { cloudHttpApiLayer } from "./cloud/http.ts";
72import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
70import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";73import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
71import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";74import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
72import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";75import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
@@ -134,6 +137,7 @@ const ReactorLayerLive = Layer.empty.pipe( · 61 unmodified lines
134 Layer.provideMerge(ProviderCommandReactorLive),137 Layer.provideMerge(ProviderCommandReactorLive),
135 Layer.provideMerge(CheckpointReactorLive),138 Layer.provideMerge(CheckpointReactorLive),
136 Layer.provideMerge(ThreadDeletionReactorLive),139 Layer.provideMerge(ThreadDeletionReactorLive),
140 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
137 Layer.provideMerge(RuntimeReceiptBusLive),141 Layer.provideMerge(RuntimeReceiptBusLive),
138);142);
139 143
@@ -271,6 +275,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 131 unmodified lines
271 Layer.provideMerge(RepositoryIdentityResolverLive),275 Layer.provideMerge(RepositoryIdentityResolverLive),
272 Layer.provideMerge(ServerEnvironmentLive),276 Layer.provideMerge(ServerEnvironmentLive),
273 Layer.provideMerge(AuthLayerLive),277 Layer.provideMerge(AuthLayerLive),
278 Layer.provideMerge(ServerSecretStore.layer),
279 Layer.provideMerge(
280 CloudManagedEndpointRuntime.layer.pipe(Layer.provide(ServerSecretStore.layer)),
281 ),
274);282);
275 283
276const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(284const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
@@ -291,6 +299,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( · 14 unmodified lines
291export const makeRoutesLayer = Layer.mergeAll(299export const makeRoutesLayer = Layer.mergeAll(
292 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(300 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
293 Layer.provide(authHttpApiLayer),301 Layer.provide(authHttpApiLayer),
302 Layer.provide(cloudHttpApiLayer),
294 Layer.provide(orchestrationHttpApiLayer),303 Layer.provide(orchestrationHttpApiLayer),
295 Layer.provide(serverEnvironmentHttpApiLayer),304 Layer.provide(serverEnvironmentHttpApiLayer),
296 Layer.provide(environmentAuthenticatedAuthLayer),305 Layer.provide(environmentAuthenticatedAuthLayer),
apps/server/src/serverRuntimeStartup.ts+5−2
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 ThreadId,8 ThreadId,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import * as Data from "effect/Data";10import * as Data from "effect/Data";
11import * as Crypto from "effect/Crypto";
11import * as Deferred from "effect/Deferred";12import * as Deferred from "effect/Deferred";
12import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";14import * as Exit from "effect/Exit";
@@ -19,7 +20,6 @@ import * as Ref from "effect/Ref"; · 5 unmodified lines
19import * as Scope from "effect/Scope";20import * as Scope from "effect/Scope";
20import * as Context from "effect/Context";21import * as Context from "effect/Context";
21import * as Console from "effect/Console";22import * as Console from "effect/Console";
22import * as Crypto from "effect/Crypto";
23import * as DateTime from "effect/DateTime";23import * as DateTime from "effect/DateTime";
24 24
25import { ServerConfig } from "./config.ts";25import { ServerConfig } from "./config.ts";
@@ -289,6 +289,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { · 263 unmodified lines
289 const lifecycleEvents = yield* ServerLifecycleEvents;289 const lifecycleEvents = yield* ServerLifecycleEvents;
290 const serverSettings = yield* ServerSettingsService;290 const serverSettings = yield* ServerSettingsService;
291 const serverEnvironment = yield* ServerEnvironment;291 const serverEnvironment = yield* ServerEnvironment;
292 const crypto = yield* Crypto.Crypto;
292 293
293 const commandGate = yield* makeCommandGate;294 const commandGate = yield* makeCommandGate;
294 const httpListening = yield* Deferred.make<void>();295 const httpListening = yield* Deferred.make<void>();
@@ -361,7 +362,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { · 66 unmodified lines
361 runStartupPhase(362 runStartupPhase(
362 "welcome.autobootstrap",363 "welcome.autobootstrap",
363 Effect.gen(function* () {364 Effect.gen(function* () {
364 const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets;365 const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe(
366 Effect.provideService(Crypto.Crypto, crypto),
367 );
365 if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) {368 if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) {
366 return;369 return;
367 }370 }
apps/web/package.json+4−0
@@ -14,6 +14,8 @@ · 13 unmodified lines
14 },14 },
15 "dependencies": {15 "dependencies": {
16 "@base-ui/react": "^1.4.1",16 "@base-ui/react": "^1.4.1",
17 "@clerk/clerk-js": "^6.13.0",
18 "@clerk/react": "^6.7.2",
17 "@dnd-kit/core": "^6.3.1",19 "@dnd-kit/core": "^6.3.1",
18 "@dnd-kit/modifiers": "^9.0.0",20 "@dnd-kit/modifiers": "^9.0.0",
19 "@dnd-kit/sortable": "^10.0.0",21 "@dnd-kit/sortable": "^10.0.0",
@@ -33,6 +35,7 @@ · 13 unmodified lines
33 "@xterm/xterm": "^6.0.0",35 "@xterm/xterm": "^6.0.0",
34 "class-variance-authority": "^0.7.1",36 "class-variance-authority": "^0.7.1",
35 "effect": "catalog:",37 "effect": "catalog:",
38 "jose": "catalog:",
36 "lexical": "^0.41.0",39 "lexical": "^0.41.0",
37 "lucide-react": "^0.564.0",40 "lucide-react": "^0.564.0",
38 "react": "19.2.6",41 "react": "19.2.6",
@@ -44,6 +47,7 @@ · 5 unmodified lines
44 },47 },
45 "devDependencies": {48 "devDependencies": {
46 "@effect/platform-node": "catalog:",49 "@effect/platform-node": "catalog:",
50 "@effect/vitest": "catalog:",
47 "@rolldown/plugin-babel": "^0.2.0",51 "@rolldown/plugin-babel": "^0.2.0",
48 "@tailwindcss/vite": "^4.0.0",52 "@tailwindcss/vite": "^4.0.0",
49 "@tanstack/router-plugin": "^1.161.0",53 "@tanstack/router-plugin": "^1.161.0",
apps/web/src/clientPersistenceStorage.ts+14−2
@@ -27,6 +27,7 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ · 26 unmodified lines
27 port: Schema.NullOr(Schema.Number),27 port: Schema.NullOr(Schema.Number),
28 }),28 }),
29 ),29 ),
30 relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })),
30 bearerToken: Schema.optionalKey(Schema.String),31 bearerToken: Schema.optionalKey(Schema.String),
31});32});
32type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type;33type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type;
@@ -53,7 +54,11 @@ function toPersistedSavedEnvironmentRecord( · 20 unmodified lines
53 createdAt: record.createdAt,54 createdAt: record.createdAt,
54 lastConnectedAt: record.lastConnectedAt,55 lastConnectedAt: record.lastConnectedAt,
55 };56 };
56 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;57 return {
58 ...nextRecord,
59 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
60 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
61 };
57}62}
58 63
59export function readBrowserClientSettings(): ClientSettings | null {64export function readBrowserClientSettings(): ClientSettings | null {
@@ -145,6 +150,7 @@ export function writeBrowserSavedEnvironmentRegistry( · 85 unmodified lines
145 createdAt: record.createdAt,150 createdAt: record.createdAt,
146 lastConnectedAt: record.lastConnectedAt,151 lastConnectedAt: record.lastConnectedAt,
147 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),152 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
153 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
148 bearerToken,154 bearerToken,
149 }155 }
150 : toPersistedSavedEnvironmentRecord(record);156 : toPersistedSavedEnvironmentRecord(record);
@@ -171,6 +177,8 @@ export function writeBrowserSavedEnvironmentSecret( · 20 unmodified lines
171 let found = false;177 let found = false;
172 writeBrowserSavedEnvironmentRegistryDocument({178 writeBrowserSavedEnvironmentRegistryDocument({
173 version: document.version ?? 1,179 version: document.version ?? 1,
180 // The persistence update is copy-on-write so storage subscribers observe a new document.
181 // oxlint-disable-next-line oxc/no-map-spread
174 records: records.map((record) => {182 records: records.map((record) => {
175 if (record.environmentId !== environmentId) {183 if (record.environmentId !== environmentId) {
176 return record;184 return record;
@@ -185,7 +193,11 @@ export function writeBrowserSavedEnvironmentSecret( · 8 unmodified lines
185 lastConnectedAt: record.lastConnectedAt,193 lastConnectedAt: record.lastConnectedAt,
186 bearerToken: secret,194 bearerToken: secret,
187 };195 };
188 return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord;196 return {
197 ...nextRecord,
198 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
199 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
200 };
189 }),201 }),
190 });202 });
191 return found;203 return found;
apps/web/src/cloud/desktopAuth.test.ts+24−0
@@ -0,0 +1,24 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveDesktopCloudAuthOAuthOptions } from "./desktopAuth";
4
5describe("resolveDesktopCloudAuthOAuthOptions", () => {
6 it("ignores absent social provider settings", () => {
7 expect(
8 resolveDesktopCloudAuthOAuthOptions({
9 environment: {
10 userSettings: {
11 social: {
12 github: null,
13 google: {
14 strategy: "oauth_google",
15 enabled: true,
16 authenticatable: true,
17 },
18 },
19 },
20 },
21 }),
22 ).toEqual([{ strategy: "oauth_google", label: "Google" }]);
23 });
24});
apps/web/src/cloud/desktopAuth.ts+116−0
@@ -0,0 +1,116 @@
1export type DesktopCloudAuthOAuthStrategy = `oauth_${string}`;
2
3export interface DesktopCloudAuthOAuthOption {
4 readonly strategy: DesktopCloudAuthOAuthStrategy;
5 readonly label: string;
6}
7
8interface ClerkOAuthProviderSetting {
9 readonly enabled?: unknown;
10 readonly authenticatable?: unknown;
11 readonly strategy?: unknown;
12 readonly name?: unknown;
13}
14
15interface ClerkUserSettingsLike {
16 readonly authenticatableSocialStrategies?: unknown;
17 readonly social?: unknown;
18}
19
20interface ClerkEnvironmentLike {
21 readonly userSettings?: ClerkUserSettingsLike;
22}
23
24interface ClerkLike {
25 readonly __internal_environment?: ClerkEnvironmentLike;
26 readonly environment?: ClerkEnvironmentLike;
27}
28
29const isClerkOAuthProviderSetting = (value: unknown): value is ClerkOAuthProviderSetting =>
30 typeof value === "object" && value !== null;
31
32const OAUTH_LABELS: Readonly<Record<string, string>> = {
33 oauth_apple: "Apple",
34 oauth_discord: "Discord",
35 oauth_github: "GitHub",
36 oauth_gitlab: "GitLab",
37 oauth_google: "Google",
38 oauth_linear: "Linear",
39 oauth_microsoft: "Microsoft",
40 oauth_slack: "Slack",
41 oauth_x: "X",
42};
43
44export function isDesktopCloudAuthOAuthStrategy(
45 value: unknown,
46): value is DesktopCloudAuthOAuthStrategy {
47 return typeof value === "string" && value.startsWith("oauth_");
48}
49
50export function getDesktopCloudAuthOAuthStrategyLabel(
51 strategy: DesktopCloudAuthOAuthStrategy,
52): string {
53 const mapped = OAUTH_LABELS[strategy];
54 if (mapped) return mapped;
55 return strategy
56 .replace(/^oauth_custom_/, "")
57 .replace(/^oauth_/, "")
58 .split("_")
59 .filter(Boolean)
60 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
61 .join(" ");
62}
63
64export function resolveDesktopCloudAuthOAuthOptions(
65 clerk: unknown,
66): readonly DesktopCloudAuthOAuthOption[] {
67 const environment =
68 (clerk as ClerkLike | null | undefined)?.__internal_environment ??
69 (clerk as ClerkLike | null | undefined)?.environment;
70 const userSettings = environment?.userSettings;
71 const strategies = userSettings?.authenticatableSocialStrategies;
72 if (Array.isArray(strategies)) {
73 return uniqueOptions(
74 strategies.filter(isDesktopCloudAuthOAuthStrategy).map((strategy) => ({
75 strategy,
76 label: getDesktopCloudAuthOAuthStrategyLabel(strategy),
77 })),
78 );
79 }
80
81 const social = userSettings?.social;
82 if (!social || typeof social !== "object") {
83 return [];
84 }
85
86 return uniqueOptions(
87 Object.values(social as Record<string, ClerkOAuthProviderSetting>)
88 .filter(isClerkOAuthProviderSetting)
89 .filter((provider) => provider.enabled !== false && provider.authenticatable !== false)
90 .map((provider) => {
91 const strategy = isDesktopCloudAuthOAuthStrategy(provider.strategy)
92 ? provider.strategy
93 : null;
94 if (!strategy) return null;
95 return {
96 strategy,
97 label:
98 typeof provider.name === "string" && provider.name.trim()
99 ? provider.name
100 : getDesktopCloudAuthOAuthStrategyLabel(strategy),
101 };
102 })
103 .filter((option): option is DesktopCloudAuthOAuthOption => option !== null),
104 );
105}
106
107function uniqueOptions(
108 options: readonly DesktopCloudAuthOAuthOption[],
109): readonly DesktopCloudAuthOAuthOption[] {
110 const seen = new Set<string>();
111 return options.filter((option) => {
112 if (seen.has(option.strategy)) return false;
113 seen.add(option.strategy);
114 return true;
115 });
116}
apps/web/src/cloud/desktopClerk.tsx+286−0
@@ -0,0 +1,286 @@
1import { Clerk } from "@clerk/clerk-js";
2import {
3 buildClerkUIScriptAttributes,
4 clerkUIScriptUrl,
5 InternalClerkProvider,
6} from "@clerk/react/internal";
7import type { ClerkProviderProps } from "@clerk/react";
8import React, { useEffect, useState } from "react";
9
10type DesktopClerkUiCtor = NonNullable<Window["__internal_ClerkUICtor"]>;
11
12interface ClerkFrontendApiRequest {
13 credentials?: RequestCredentials;
14 headers?: Headers;
15 url?: URL;
16}
17
18interface ClerkFrontendApiResponse {
19 headers: Headers;
20 payload?: {
21 errors?: readonly {
22 code?: string;
23 }[];
24 };
25}
26
27interface NativeRequestClerk {
28 readonly publishableKey?: string;
29 __internal_onBeforeRequest?: (
30 listener: (request: ClerkFrontendApiRequest) => void | Promise<void>,
31 ) => void;
32 __internal_onAfterResponse?: (
33 listener: (
34 request: ClerkFrontendApiRequest,
35 response?: ClerkFrontendApiResponse,
36 ) => void | Promise<void>,
37 ) => void;
38 __unstable__onBeforeRequest?: (
39 listener: (request: ClerkFrontendApiRequest) => void | Promise<void>,
40 ) => void;
41 __unstable__onAfterResponse?: (
42 listener: (
43 request: ClerkFrontendApiRequest,
44 response?: ClerkFrontendApiResponse,
45 ) => void | Promise<void>,
46 ) => void;
47}
48
49interface DesktopClerkProviderProps {
50 readonly children: React.ReactNode;
51 readonly publishableKey: string;
52}
53
54let desktopClerk: Clerk | null = null;
55let desktopClerkFetchInstalled = false;
56let desktopClerkUiLoad: Promise<DesktopClerkUiCtor> | null = null;
57
58const isNativeRequestClerk = (value: unknown): value is NativeRequestClerk => {
59 if (typeof value !== "object" || value === null) return false;
60 const candidate = value as {
61 __internal_onBeforeRequest?: unknown;
62 __internal_onAfterResponse?: unknown;
63 __unstable__onBeforeRequest?: unknown;
64 __unstable__onAfterResponse?: unknown;
65 };
66 return (
67 (typeof candidate.__internal_onBeforeRequest === "function" ||
68 typeof candidate.__unstable__onBeforeRequest === "function") &&
69 (typeof candidate.__internal_onAfterResponse === "function" ||
70 typeof candidate.__unstable__onAfterResponse === "function")
71 );
72};
73
74const getStoredClientJwt = (): Promise<string | null> =>
75 window.desktopBridge?.getCloudAuthToken() ?? Promise.resolve(null);
76
77const setStoredClientJwt = (token: string): Promise<boolean> =>
78 window.desktopBridge?.setCloudAuthToken(token) ?? Promise.resolve(false);
79
80const clearStoredClientJwt = (): Promise<void> =>
81 window.desktopBridge?.clearCloudAuthToken() ?? Promise.resolve();
82
83const isClerkFrontendApiUrl = (url: URL): boolean =>
84 url.protocol === "https:" &&
85 (url.hostname.endsWith(".clerk.accounts.dev") || url.hostname.endsWith(".clerk.accounts.com"));
86
87const headersToRecord = (headers: Headers): Record<string, string> => {
88 const record: Record<string, string> = {};
89 headers.forEach((value, key) => {
90 record[key] = value;
91 });
92 return record;
93};
94
95function installDesktopClerkFetchProxy(): void {
96 if (desktopClerkFetchInstalled) return;
97 const bridge = window.desktopBridge;
98 if (!bridge) return;
99
100 const browserFetch = window.fetch.bind(window);
101 window.fetch = async (input, init) => {
102 const request = new Request(input, init);
103 const url = new URL(request.url);
104 if (!isClerkFrontendApiUrl(url)) {
105 return browserFetch(input, init);
106 }
107
108 const body =
109 request.method === "GET" || request.method === "HEAD"
110 ? undefined
111 : await request.clone().text();
112 const result = await bridge.fetchCloudAuth({
113 url: request.url,
114 method: request.method,
115 headers: headersToRecord(request.headers),
116 ...(body === undefined ? {} : { body }),
117 });
118
119 return new Response(result.body, {
120 status: result.status,
121 statusText: result.statusText,
122 headers: result.headers,
123 });
124 };
125 desktopClerkFetchInstalled = true;
126}
127
128function loadDesktopClerkUi(publishableKey: string): Promise<DesktopClerkUiCtor> {
129 if (window.__internal_ClerkUICtor) {
130 return Promise.resolve(window.__internal_ClerkUICtor);
131 }
132 if (desktopClerkUiLoad) {
133 return desktopClerkUiLoad;
134 }
135
136 const load = new Promise<DesktopClerkUiCtor>((resolve, reject) => {
137 const scriptUrl = clerkUIScriptUrl({ publishableKey });
138 const existingScript = document.querySelector<HTMLScriptElement>(
139 "script[data-clerk-ui-script]",
140 );
141
142 const resolveLoadedUi = () => {
143 const ClerkUI = window.__internal_ClerkUICtor;
144 if (ClerkUI) {
145 resolve(ClerkUI);
146 return true;
147 }
148 return false;
149 };
150 if (resolveLoadedUi()) {
151 return;
152 }
153
154 const script = existingScript ?? document.createElement("script");
155 script.async = true;
156 script.crossOrigin = "anonymous";
157 script.src = scriptUrl;
158 script.dataset.clerkUiScript = "true";
159 const attributes = buildClerkUIScriptAttributes({ publishableKey });
160 for (const [name, value] of Object.entries(attributes)) {
161 script.setAttribute(name, value);
162 }
163
164 const timeoutId = window.setTimeout(() => {
165 reject(new Error("Timed out loading Clerk UI for desktop auth."));
166 }, 15_000);
167 script.addEventListener("load", () => {
168 window.clearTimeout(timeoutId);
169 if (!resolveLoadedUi()) {
170 reject(new Error("Clerk UI loaded without exposing the UI constructor."));
171 }
172 });
173 script.addEventListener("error", () => {
174 window.clearTimeout(timeoutId);
175 reject(new Error("Failed to load Clerk UI for desktop auth."));
176 });
177 if (!existingScript) {
178 document.head.append(script);
179 }
180 }).catch((error: unknown) => {
181 desktopClerkUiLoad = null;
182 throw error;
183 });
184
185 desktopClerkUiLoad = load;
186 return load;
187}
188
189function getDesktopClerkInstance(publishableKey: string): Clerk {
190 installDesktopClerkFetchProxy();
191
192 const hasKeyChanged = desktopClerk !== null && desktopClerk.publishableKey !== publishableKey;
193 if (hasKeyChanged) {
194 void clearStoredClientJwt();
195 desktopClerk = null;
196 }
197
198 if (desktopClerk !== null) {
199 return desktopClerk;
200 }
201
202 const nextClerk = new Clerk(publishableKey);
203 if (!isNativeRequestClerk(nextClerk)) {
204 desktopClerk = nextClerk;
205 return nextClerk;
206 }
207
208 const onBeforeRequest =
209 nextClerk.__internal_onBeforeRequest ?? nextClerk.__unstable__onBeforeRequest;
210 const onAfterResponse =
211 nextClerk.__internal_onAfterResponse ?? nextClerk.__unstable__onAfterResponse;
212
213 onBeforeRequest(async (request) => {
214 request.credentials = "omit";
215 request.url?.searchParams.append("_is_native", "1");
216 const headers = new Headers(request.headers);
217
218 const clientJwt = await getStoredClientJwt();
219 headers.set("authorization", clientJwt ?? "");
220 headers.set("x-mobile", "1");
221 request.headers = headers;
222 });
223
224 onAfterResponse(async (_request, response) => {
225 const clientJwt = response?.headers.get("authorization");
226 if (clientJwt) {
227 await setStoredClientJwt(clientJwt);
228 }
229
230 const errorCode = response?.payload?.errors?.[0]?.code;
231 if (errorCode === "native_api_disabled") {
232 console.error(
233 "Clerk Native API is disabled. Enable Native applications in the Clerk dashboard for desktop sign-in.",
234 );
235 }
236 });
237
238 desktopClerk = nextClerk;
239 return nextClerk;
240}
241
242export function DesktopClerkProvider({ children, publishableKey }: DesktopClerkProviderProps) {
243 const [clerkUiCtor, setClerkUiCtor] = useState<DesktopClerkUiCtor | undefined>(
244 () => window.__internal_ClerkUICtor,
245 );
246 const [clerkUiError, setClerkUiError] = useState<unknown>(null);
247
248 useEffect(() => {
249 let isCurrent = true;
250 void loadDesktopClerkUi(publishableKey).then(
251 (ClerkUI) => {
252 if (isCurrent) {
253 setClerkUiCtor(() => ClerkUI);
254 }
255 },
256 (error: unknown) => {
257 if (isCurrent) {
258 setClerkUiError(error);
259 }
260 },
261 );
262 return () => {
263 isCurrent = false;
264 };
265 }, [publishableKey]);
266
267 if (!clerkUiCtor) {
268 if (clerkUiError) {
269 console.error("Failed to load Clerk UI for desktop auth.", clerkUiError);
270 }
271 return null;
272 }
273
274 const clerk = getDesktopClerkInstance(publishableKey);
275 return (
276 <InternalClerkProvider
277 key={publishableKey}
278 publishableKey={publishableKey}
279 Clerk={clerk as ClerkProviderProps["Clerk"]}
280 ui={{ ClerkUI: clerkUiCtor }}
281 standardBrowser={false}
282 >
283 {children}
284 </InternalClerkProvider>
285 );
286}
apps/web/src/cloud/dpop.test.ts+32−0
@@ -0,0 +1,32 @@
1import { verifyDpopProof } from "@t3tools/shared/dpop";
2import * as Effect from "effect/Effect";
3import { describe, expect, it, vi } from "vitest";
4
5import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop";
6
7describe("browser DPoP proofs", () => {
8 it("signs relay resource proofs with an access-token hash", async () => {
9 vi.stubGlobal("indexedDB", undefined);
10 const issuedAt = Math.floor(Date.now() / 1_000);
11 const proofKey = await Effect.runPromise(generateBrowserDpopKey);
12 const proof = await Effect.runPromise(
13 createBrowserDpopProof({
14 method: "POST",
15 url: "https://relay.example.test/v1/environments/env-1/connect?ignored=true",
16 accessToken: "relay-access-token",
17 proofKey,
18 }).pipe(Effect.provide(browserCryptoLayer)),
19 );
20
21 expect(
22 verifyDpopProof({
23 proof: proof.proof,
24 method: "POST",
25 url: "https://relay.example.test/v1/environments/env-1/connect",
26 expectedThumbprint: proof.thumbprint,
27 expectedAccessToken: "relay-access-token",
28 nowEpochSeconds: issuedAt,
29 }),
30 ).toMatchObject({ ok: true });
31 });
32});
apps/web/src/cloud/dpop.ts+188−0
@@ -0,0 +1,188 @@
1import {
2 computeDpopAccessTokenHash,
3 computeDpopJwkThumbprint,
4 DpopPublicJwk,
5} from "@t3tools/shared/dpop";
6import * as Crypto from "effect/Crypto";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Schema from "effect/Schema";
11import { importJWK, SignJWT, type JWK } from "jose";
12
13export interface BrowserDpopKey {
14 readonly privateKey: CryptoKey;
15 readonly publicJwk: DpopPublicJwk;
16 readonly thumbprint: string;
17}
18
19export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{
20 readonly message: string;
21 readonly cause?: unknown;
22}> {}
23
24const DPOP_DATABASE_NAME = "t3code:cloud-auth";
25const DPOP_DATABASE_VERSION = 1;
26const DPOP_KEY_STORE_NAME = "keys";
27const DPOP_KEY_ID = "relay-dpop-proof-key";
28const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk);
29
30export const browserCryptoLayer = Layer.succeed(
31 Crypto.Crypto,
32 Crypto.make({
33 randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)),
34 digest: (algorithm, data) =>
35 Effect.promise(async () => {
36 const input = new Uint8Array(data.length);
37 input.set(data);
38 return new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, input.buffer));
39 }),
40 }),
41);
42
43function dpopError(message: string, cause?: unknown) {
44 return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) });
45}
46
47function openDpopDatabase(): Effect.Effect<IDBDatabase, BrowserDpopError> {
48 return Effect.callback<IDBDatabase, BrowserDpopError>((resume) => {
49 const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION);
50 request.addEventListener("error", () =>
51 resume(
52 Effect.fail(dpopError("Could not open DPoP key storage.", request.error ?? undefined)),
53 ),
54 );
55 request.addEventListener("upgradeneeded", () => {
56 if (!request.result.objectStoreNames.contains(DPOP_KEY_STORE_NAME)) {
57 request.result.createObjectStore(DPOP_KEY_STORE_NAME);
58 }
59 });
60 request.addEventListener("success", () => resume(Effect.succeed(request.result)));
61 });
62}
63
64export function readStoredBrowserDpopKey(): Effect.Effect<BrowserDpopKey | null, BrowserDpopError> {
65 if (typeof indexedDB === "undefined") {
66 return Effect.succeed(null);
67 }
68 return Effect.acquireUseRelease(
69 openDpopDatabase(),
70 (database) =>
71 Effect.callback<BrowserDpopKey | null, BrowserDpopError>((resume) => {
72 const request = database
73 .transaction(DPOP_KEY_STORE_NAME, "readonly")
74 .objectStore(DPOP_KEY_STORE_NAME)
75 .get(DPOP_KEY_ID);
76 request.addEventListener("error", () =>
77 resume(Effect.fail(dpopError("Could not read DPoP key.", request.error ?? undefined))),
78 );
79 request.addEventListener("success", () =>
80 resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)),
81 );
82 }),
83 (database) => Effect.sync(() => database.close()),
84 );
85}
86
87export function writeStoredBrowserDpopKey(
88 key: BrowserDpopKey,
89): Effect.Effect<void, BrowserDpopError> {
90 if (typeof indexedDB === "undefined") {
91 return Effect.void;
92 }
93 return Effect.acquireUseRelease(
94 openDpopDatabase(),
95 (database) =>
96 Effect.callback<void, BrowserDpopError>((resume) => {
97 const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite");
98 transaction.addEventListener("error", () =>
99 resume(
100 Effect.fail(dpopError("Could not write DPoP key.", transaction.error ?? undefined)),
101 ),
102 );
103 transaction.addEventListener("complete", () => resume(Effect.void));
104 transaction.objectStore(DPOP_KEY_STORE_NAME).put(key, DPOP_KEY_ID);
105 }),
106 (database) => Effect.sync(() => database.close()),
107 );
108}
109
110export const generateBrowserDpopKey: Effect.Effect<BrowserDpopKey, BrowserDpopError> = Effect.gen(
111 function* () {
112 const generated = yield* Effect.tryPromise({
113 try: () =>
114 crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
115 "sign",
116 "verify",
117 ]) as Promise<CryptoKeyPair>,
118 catch: (cause) => dpopError("Could not generate DPoP proof key.", cause),
119 });
120 const privateJwk = yield* Effect.tryPromise({
121 try: () => crypto.subtle.exportKey("jwk", generated.privateKey),
122 catch: (cause) => dpopError("Could not export DPoP private key.", cause),
123 });
124 const publicJwk = yield* Effect.tryPromise({
125 try: () => crypto.subtle.exportKey("jwk", generated.publicKey),
126 catch: (cause) => dpopError("Could not export DPoP public key.", cause),
127 }).pipe(
128 Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)),
129 Effect.mapError((cause) =>
130 cause instanceof BrowserDpopError
131 ? cause
132 : dpopError("Generated DPoP public key is invalid.", cause),
133 ),
134 );
135 const privateKey = yield* Effect.tryPromise({
136 try: () =>
137 importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise<CryptoKey>,
138 catch: (cause) => dpopError("Could not import DPoP private key.", cause),
139 });
140 return {
141 privateKey,
142 publicJwk,
143 thumbprint: computeDpopJwkThumbprint(publicJwk),
144 };
145 },
146);
147
148export function createBrowserDpopProof(input: {
149 readonly method: string;
150 readonly url: string;
151 readonly accessToken?: string;
152 readonly proofKey: BrowserDpopKey;
153}): Effect.Effect<
154 { readonly proof: string; readonly thumbprint: string },
155 BrowserDpopError,
156 Crypto.Crypto
157> {
158 return Effect.gen(function* () {
159 const normalizedUrl = yield* Effect.try({
160 try: () => new URL(input.url),
161 catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause),
162 });
163 normalizedUrl.search = "";
164 normalizedUrl.hash = "";
165 const jti = yield* Crypto.Crypto.pipe(
166 Effect.flatMap((crypto) => crypto.randomUUIDv4),
167 Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)),
168 );
169 const proof = yield* Effect.tryPromise({
170 try: () =>
171 new SignJWT({
172 htm: input.method.toUpperCase(),
173 htu: normalizedUrl.toString(),
174 jti,
175 ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}),
176 })
177 .setProtectedHeader({
178 typ: "dpop+jwt",
179 alg: "ES256",
180 jwk: input.proofKey.publicJwk,
181 })
182 .setIssuedAt()
183 .sign(input.proofKey.privateKey),
184 catch: (cause) => dpopError("Could not sign DPoP proof.", cause),
185 });
186 return { proof, thumbprint: input.proofKey.thumbprint };
187 });
188}
apps/web/src/cloud/linkEnvironment.test.ts+797−0
@@ -0,0 +1,797 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { RelayWebClientId } from "@t3tools/contracts/relay";
3import { beforeEach, vi } from "vitest";
4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import { HttpClient } from "effect/unstable/http";
8import {
9 managedRelayClientLayer,
10 ManagedRelayClient,
11 ManagedRelayDpopSigner,
12 remoteHttpClientLayer,
13} from "@t3tools/client-runtime";
14
15import type { SavedEnvironmentRecord } from "../environments/runtime";
16import {
17 connectManagedCloudEnvironment,
18 linkEnvironmentToCloud,
19 linkPrimaryEnvironmentToCloud,
20 listManagedCloudEnvironments,
21 normalizeRelayBaseUrl,
22 readPrimaryCloudLinkState,
23 unlinkPrimaryEnvironmentFromCloud,
24} from "./linkEnvironment";
25import {
26 readPrimaryEnvironmentDescriptor,
27 readPrimaryEnvironmentTarget,
28 resolvePrimaryEnvironmentHttpUrl,
29} from "../environments/primary";
30
31const getSavedEnvironmentSecretMock = vi.fn();
32
33const createProofMock = vi.fn(
34 (_input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
35 Effect.succeed("web-dpop-proof"),
36);
37const testDpopSignerLayer = Layer.succeed(
38 ManagedRelayDpopSigner,
39 ManagedRelayDpopSigner.of({
40 thumbprint: Effect.succeed("web-thumbprint"),
41 createProof: (input) => createProofMock(input),
42 }),
43);
44
45function cloudClientLayer() {
46 const httpClientLayer = remoteHttpClientLayer(globalThis.fetch);
47 return Layer.mergeAll(
48 httpClientLayer,
49 managedRelayClientLayer({
50 relayUrl: "https://relay.example.test",
51 clientId: RelayWebClientId,
52 }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)),
53 );
54}
55
56const withCloudServices = <A, E>(
57 effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>,
58) => effect.pipe(Effect.provide(cloudClientLayer()));
59
60vi.mock("../localApi", () => ({
61 ensureLocalApi: () => ({
62 persistence: {
63 getSavedEnvironmentSecret: getSavedEnvironmentSecretMock,
64 },
65 }),
66}));
67
68vi.mock("../environments/primary", () => ({
69 readPrimaryEnvironmentDescriptor: vi.fn(() => null),
70 readPrimaryEnvironmentTarget: vi.fn(() => null),
71 resolvePrimaryEnvironmentHttpUrl: vi.fn((path: string) => `http://127.0.0.1:3000${path}`),
72}));
73
74const savedEnvironment: SavedEnvironmentRecord = {
75 environmentId: EnvironmentId.make("env-1"),
76 label: "Desktop",
77 httpBaseUrl: "http://127.0.0.1:3000",
78 wsBaseUrl: "ws://127.0.0.1:3000",
79 createdAt: "2026-05-25T00:00:00.000Z",
80 lastConnectedAt: null,
81};
82
83function validProof() {
84 return "signed-environment-link-jwt";
85}
86
87function validChallenge() {
88 return {
89 challenge: "link-challenge",
90 expiresAt: "2026-05-25T00:05:00.000Z",
91 };
92}
93
94function requestBodyText(body: BodyInit | null | undefined): string {
95 return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
96}
97
98describe("web cloud link environment client", () => {
99 beforeEach(() => {
100 vi.restoreAllMocks();
101 createProofMock.mockClear();
102 vi.stubEnv("VITE_T3_RELAY_URL", "https://relay.example.test");
103 getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer");
104 vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue(null);
105 vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue(null);
106 vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation(
107 (path: string) => `http://127.0.0.1:3000${path}`,
108 );
109 });
110
111 it("normalizes configured relay base URLs before building relay requests", () => {
112 expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe(
113 "https://relay.example.test",
114 );
115 expect(normalizeRelayBaseUrl(" ")).toBeNull();
116 });
117
118 it.effect("lists relay-managed environments for hosted and served web clients", () =>
119 Effect.gen(function* () {
120 const fetchMock = vi.fn().mockResolvedValueOnce(
121 Response.json({
122 environments: [
123 {
124 environmentId: "env-1",
125 label: "Managed desktop",
126 endpoint: {
127 httpBaseUrl: "https://managed.example.test",
128 wsBaseUrl: "wss://managed.example.test",
129 providerKind: "cloudflare_tunnel",
130 },
131 linkedAt: "2026-05-25T00:00:00.000Z",
132 },
133 ],
134 }),
135 );
136 vi.stubGlobal("fetch", fetchMock);
137
138 const environments = yield* withCloudServices(
139 listManagedCloudEnvironments({ clerkToken: "clerk-token" }),
140 );
141 expect(environments).toHaveLength(1);
142 expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
143 "https://relay.example.test/v1/environments",
144 );
145 expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token");
146 expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include");
147 }),
148 );
149
150 it.effect("connects web clients to managed environments with a tunnel-only DPoP token", () =>
151 Effect.gen(function* () {
152 const environment = {
153 environmentId: EnvironmentId.make("env-1"),
154 label: "Managed desktop",
155 endpoint: {
156 httpBaseUrl: "https://managed.example.test",
157 wsBaseUrl: "wss://managed.example.test",
158 providerKind: "cloudflare_tunnel" as const,
159 },
160 linkedAt: "2026-05-25T00:00:00.000Z",
161 };
162 const fetchMock = vi
163 .fn()
164 .mockResolvedValueOnce(
165 Response.json({
166 access_token: "relay-access-token",
167 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
168 token_type: "DPoP",
169 expires_in: 300,
170 scope: "environment:connect",
171 }),
172 )
173 .mockResolvedValueOnce(
174 Response.json({
175 environmentId: "env-1",
176 endpoint: environment.endpoint,
177 credential: "environment-bootstrap",
178 expiresAt: "2026-05-25T00:05:00.000Z",
179 }),
180 )
181 .mockResolvedValueOnce(
182 Response.json({
183 environmentId: "env-1",
184 label: "Managed desktop",
185 platform: { os: "darwin", arch: "arm64" },
186 serverVersion: "0.0.0-test",
187 capabilities: { repositoryIdentity: true },
188 }),
189 )
190 .mockResolvedValueOnce(
191 Response.json({
192 access_token: "environment-access-token",
193 issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
194 token_type: "DPoP",
195 expires_in: 3600,
196 scope: "orchestration:read orchestration:operate terminal:operate review:write",
197 }),
198 );
199 vi.stubGlobal("fetch", fetchMock);
200
201 const connection = yield* withCloudServices(
202 connectManagedCloudEnvironment({ clerkToken: "clerk-token", environment }),
203 );
204 expect(connection).toMatchObject({
205 environmentId: "env-1",
206 accessToken: "environment-access-token",
207 });
208
209 const tokenBody = requestBodyText(fetchMock.mock.calls[0]?.[1]?.body);
210 expect(new URLSearchParams(tokenBody).get("client_id")).toBe("t3-web");
211 expect(new URLSearchParams(tokenBody).get("scope")).toBe("environment:connect");
212 expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("DPoP relay-access-token");
213 expect(fetchMock.mock.calls[1]?.[1]?.headers.dpop).toBe("web-dpop-proof");
214 expect(createProofMock).toHaveBeenCalledWith({
215 method: "POST",
216 url: "https://managed.example.test/oauth/token",
217 });
218 }),
219 );
220
221 it.effect("rejects a stored managed connection for another relay origin", () =>
222 Effect.gen(function* () {
223 const environment = {
224 environmentId: EnvironmentId.make("env-1"),
225 label: "Managed desktop",
226 endpoint: {
227 httpBaseUrl: "https://managed.example.test",
228 wsBaseUrl: "wss://managed.example.test",
229 providerKind: "cloudflare_tunnel" as const,
230 },
231 linkedAt: "2026-05-25T00:00:00.000Z",
232 };
233
234 const error = yield* withCloudServices(
235 connectManagedCloudEnvironment({
236 clerkToken: "clerk-token",
237 environment,
238 relayUrl: "https://old-relay.example.test",
239 }),
240 ).pipe(Effect.flip);
241 expect(error).toMatchObject({
242 message: "The saved environment is linked through a different configured relay.",
243 });
244 }),
245 );
246
247 it.effect("rejects malformed local environment link proofs", () =>
248 Effect.gen(function* () {
249 vi.stubGlobal(
250 "fetch",
251 vi
252 .fn()
253 .mockResolvedValueOnce(Response.json(validChallenge()))
254 .mockResolvedValueOnce(
255 Response.json({
256 payload: {
257 environmentId: "env-1",
258 },
259 signature: "signature-1",
260 }),
261 ),
262 );
263
264 const error = yield* withCloudServices(
265 linkEnvironmentToCloud({
266 environment: savedEnvironment,
267 clerkToken: "clerk-token",
268 }),
269 ).pipe(Effect.flip);
270 expect(error).toMatchObject({
271 _tag: "CloudEnvironmentLinkError",
272 message: "Could not obtain environment link proof.",
273 });
274 }),
275 );
276
277 it.effect("preserves typed local environment failures while obtaining a link proof", () =>
278 Effect.gen(function* () {
279 vi.stubGlobal(
280 "fetch",
281 vi
282 .fn()
283 .mockResolvedValueOnce(Response.json(validChallenge()))
284 .mockResolvedValueOnce(
285 Response.json(
286 {
287 _tag: "EnvironmentHttpUnauthorizedError",
288 message: "Invalid environment bearer session.",
289 },
290 { status: 401 },
291 ),
292 ),
293 );
294
295 const error = yield* withCloudServices(
296 linkEnvironmentToCloud({
297 environment: savedEnvironment,
298 clerkToken: "clerk-token",
299 }),
300 ).pipe(Effect.flip);
301 expect(error._tag).toBe("CloudEnvironmentLinkError");
302 expect(error.message).toBe(
303 "Could not obtain environment link proof: Invalid environment bearer session.",
304 );
305 }),
306 );
307
308 it.effect("rejects malformed relay environment link responses", () =>
309 Effect.gen(function* () {
310 vi.stubGlobal(
311 "fetch",
312 vi
313 .fn()
314 .mockResolvedValueOnce(Response.json(validChallenge()))
315 .mockResolvedValueOnce(Response.json(validProof()))
316 .mockResolvedValueOnce(
317 Response.json({
318 ok: true,
319 environmentId: "env-1",
320 endpoint: {
321 httpBaseUrl: "https://desktop.example.test",
322 wsBaseUrl: "wss://desktop.example.test",
323 providerKind: "cloudflare_tunnel",
324 },
325 endpointRuntime: null,
326 relayIssuer: "https://issuer.example.test",
327 cloudUserId: "user_123",
328 environmentCredential: "",
329 cloudMintPublicKey: "cloud-mint-public-key",
330 }),
331 ),
332 );
333
334 const error = yield* withCloudServices(
335 linkEnvironmentToCloud({
336 environment: savedEnvironment,
337 clerkToken: "clerk-token",
338 }),
339 ).pipe(Effect.flip);
340 expect(error).toMatchObject({
341 _tag: "CloudEnvironmentLinkError",
342 message: "https://relay.example.test/v1/client/environment-links failed",
343 });
344 }),
345 );
346
347 it.effect(
348 "links the primary local environment through the relay using the owner cookie session",
349 () =>
350 Effect.gen(function* () {
351 vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({
352 environmentId: EnvironmentId.make("env-1"),
353 label: "Desktop",
354 platform: { os: "darwin", arch: "arm64" },
355 serverVersion: "0.0.0-test",
356 capabilities: { repositoryIdentity: true },
357 });
358 vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({
359 source: "desktop-managed",
360 target: {
361 httpBaseUrl: "http://127.0.0.1:3000",
362 wsBaseUrl: "ws://127.0.0.1:3000",
363 },
364 });
365 vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation(
366 (path: string) => `http://127.0.0.1:3000${path}`,
367 );
368
369 const fetchMock = vi
370 .fn()
371 .mockResolvedValueOnce(Response.json(validChallenge()))
372 .mockResolvedValueOnce(Response.json(validProof()))
373 .mockResolvedValueOnce(
374 Response.json({
375 ok: true,
376 environmentId: "env-1",
377 endpoint: {
378 httpBaseUrl: "https://desktop.example.test",
379 wsBaseUrl: "wss://desktop.example.test",
380 providerKind: "cloudflare_tunnel",
381 },
382 endpointRuntime: {
383 providerKind: "cloudflare_tunnel",
384 connectorToken: "connector-token",
385 tunnelId: "tunnel-id",
386 tunnelName: "tunnel-name",
387 },
388 relayIssuer: "https://issuer.example.test",
389 cloudUserId: "user_123",
390 environmentCredential: "t3env_test_credential",
391 cloudMintPublicKey: "cloud-mint-public-key",
392 }),
393 )
394 .mockResolvedValueOnce(
395 Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }),
396 );
397 vi.stubGlobal("fetch", fetchMock);
398
399 yield* withCloudServices(
400 linkPrimaryEnvironmentToCloud({
401 clerkToken: "clerk-token",
402 }),
403 );
404
405 expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
406 "https://relay.example.test/v1/client/environment-link-challenges",
407 );
408 expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST");
409 expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token");
410 expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include");
411
412 expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
413 "http://127.0.0.1:3000/api/cloud/link-proof",
414 );
415 expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
416 method: "POST",
417 credentials: "include",
418 headers: expect.objectContaining({
419 "content-type": "application/json",
420 }),
421 });
422 // @effect-diagnostics-next-line preferSchemaOverJson:off
423 expect(JSON.parse(requestBodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
424 challenge: "link-challenge",
425 endpoint: {
426 httpBaseUrl: "http://127.0.0.1:3000",
427 wsBaseUrl: "ws://127.0.0.1:3000",
428 providerKind: "cloudflare_tunnel",
429 },
430 origin: {
431 localHttpHost: "127.0.0.1",
432 localHttpPort: 3000,
433 },
434 });
435
436 expect(String(fetchMock.mock.calls[2]?.[0])).toBe(
437 "https://relay.example.test/v1/client/environment-links",
438 );
439 expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST");
440 expect(fetchMock.mock.calls[2]?.[1]?.headers.authorization).toBe("Bearer clerk-token");
441 expect(fetchMock.mock.calls[2]?.[1]?.credentials).not.toBe("include");
442 expect(fetchMock.mock.calls[2]?.[1]?.headers["content-type"]).toBe("application/json");
443 // @effect-diagnostics-next-line preferSchemaOverJson:off
444 expect(JSON.parse(requestBodyText(fetchMock.mock.calls[2]?.[1]?.body))).toMatchObject({
445 proof: validProof(),
446 notificationsEnabled: true,
447 liveActivitiesEnabled: true,
448 managedTunnelsEnabled: true,
449 });
450
451 expect(String(fetchMock.mock.calls[3]?.[0])).toBe(
452 "http://127.0.0.1:3000/api/cloud/relay-config",
453 );
454 expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({
455 method: "POST",
456 credentials: "include",
457 headers: expect.objectContaining({
458 "content-type": "application/json",
459 }),
460 });
461 // @effect-diagnostics-next-line preferSchemaOverJson:off
462 expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({
463 relayUrl: "https://relay.example.test",
464 relayIssuer: "https://issuer.example.test",
465 cloudUserId: "user_123",
466 environmentCredential: "t3env_test_credential",
467 cloudMintPublicKey: "cloud-mint-public-key",
468 endpointRuntime: {
469 providerKind: "cloudflare_tunnel",
470 connectorToken: "connector-token",
471 tunnelId: "tunnel-id",
472 tunnelName: "tunnel-name",
473 },
474 });
475 }),
476 );
477
478 it.effect("reads the primary local cloud link state with the owner cookie session", () =>
479 Effect.gen(function* () {
480 vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({
481 environmentId: EnvironmentId.make("env-1"),
482 label: "Desktop",
483 platform: { os: "darwin", arch: "arm64" },
484 serverVersion: "0.0.0-test",
485 capabilities: { repositoryIdentity: true },
486 });
487 vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({
488 source: "desktop-managed",
489 target: {
490 httpBaseUrl: "http://127.0.0.1:3000",
491 wsBaseUrl: "ws://127.0.0.1:3000",
492 },
493 });
494 const fetchMock = vi.fn().mockResolvedValueOnce(
495 Response.json({
496 linked: true,
497 cloudUserId: "user_123",
498 relayUrl: "https://relay.example.test",
499 relayIssuer: "https://issuer.example.test",
500 }),
501 );
502 vi.stubGlobal("fetch", fetchMock);
503
504 const state = yield* withCloudServices(readPrimaryCloudLinkState());
505 expect(state).toEqual({
506 linked: true,
507 cloudUserId: "user_123",
508 relayUrl: "https://relay.example.test",
509 relayIssuer: "https://issuer.example.test",
510 });
511 expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
512 "http://127.0.0.1:3000/api/cloud/link-state",
513 );
514 expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
515 method: "GET",
516 credentials: "include",
517 });
518 }),
519 );
520
521 it.effect("revokes the primary cloud link before clearing local relay credentials", () =>
522 Effect.gen(function* () {
523 vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({
524 environmentId: EnvironmentId.make("env-1"),
525 label: "Desktop",
526 platform: { os: "darwin", arch: "arm64" },
527 serverVersion: "0.0.0-test",
528 capabilities: { repositoryIdentity: true },
529 });
530 vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({
531 source: "desktop-managed",
532 target: {
533 httpBaseUrl: "http://127.0.0.1:3000",
534 wsBaseUrl: "ws://127.0.0.1:3000",
535 },
536 });
537 const fetchMock = vi
538 .fn()
539 .mockResolvedValueOnce(Response.json({ ok: true }))
540 .mockResolvedValueOnce(
541 Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }),
542 );
543 vi.stubGlobal("fetch", fetchMock);
544
545 yield* withCloudServices(
546 unlinkPrimaryEnvironmentFromCloud({
547 clerkToken: "clerk-token",
548 }),
549 );
550
551 expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
552 "https://relay.example.test/v1/client/environment-links/env-1",
553 );
554 expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("DELETE");
555 expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token");
556 expect(String(fetchMock.mock.calls[1]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink");
557 expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
558 method: "POST",
559 credentials: "include",
560 });
561 }),
562 );
563
564 it.effect("still clears local relay credentials when relay revocation fails", () =>
565 Effect.gen(function* () {
566 vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({
567 environmentId: EnvironmentId.make("env-1"),
568 label: "Desktop",
569 platform: { os: "darwin", arch: "arm64" },
570 serverVersion: "0.0.0-test",
571 capabilities: { repositoryIdentity: true },
572 });
573 vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({
574 source: "desktop-managed",
575 target: {
576 httpBaseUrl: "http://127.0.0.1:3000",
577 wsBaseUrl: "ws://127.0.0.1:3000",
578 },
579 });
580 const fetchMock = vi
581 .fn()
582 .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 }))
583 .mockResolvedValueOnce(
584 Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }),
585 );
586 vi.stubGlobal("fetch", fetchMock);
587
588 yield* withCloudServices(
589 unlinkPrimaryEnvironmentFromCloud({
590 clerkToken: "clerk-token",
591 }),
592 );
593
594 expect(fetchMock).toHaveBeenCalledTimes(2);
595 expect(String(fetchMock.mock.calls[1]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink");
596 expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
597 method: "POST",
598 credentials: "include",
599 });
600 }),
601 );
602
603 it.effect("rejects primary environment linking when the local environment is not ready", () =>
604 Effect.gen(function* () {
605 vi.stubGlobal("fetch", vi.fn());
606
607 const error = yield* withCloudServices(
608 linkPrimaryEnvironmentToCloud({
609 clerkToken: "clerk-token",
610 }),
611 ).pipe(Effect.flip);
612 expect(error).toMatchObject({
613 _tag: "CloudEnvironmentLinkError",
614 message: "Local environment is not ready yet.",
615 });
616 expect(fetch).not.toHaveBeenCalled();
617 }),
618 );
619
620 it.effect("preserves relay transport failures while linking environments", () =>
621 Effect.gen(function* () {
622 vi.stubGlobal(
623 "fetch",
624 vi
625 .fn()
626 .mockResolvedValueOnce(Response.json(validChallenge()))
627 .mockResolvedValueOnce(Response.json(validProof()))
628 .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })),
629 );
630
631 const error = yield* withCloudServices(
632 linkEnvironmentToCloud({
633 environment: savedEnvironment,
634 clerkToken: "clerk-token",
635 }),
636 ).pipe(Effect.flip);
637 expect(error).toMatchObject({
638 _tag: "CloudEnvironmentLinkError",
639 message: "https://relay.example.test/v1/client/environment-links failed",
640 });
641 }),
642 );
643
644 it.effect("preserves typed relay error bodies while linking environments", () =>
645 Effect.gen(function* () {
646 vi.stubGlobal(
647 "fetch",
648 vi
649 .fn()
650 .mockResolvedValueOnce(Response.json(validChallenge()))
651 .mockResolvedValueOnce(Response.json(validProof()))
652 .mockResolvedValueOnce(
653 Response.json(
654 {
655 _tag: "RelayEnvironmentLinkProofInvalidError",
656 code: "environment_link_proof_invalid",
657 reason: "origin_not_allowed",
658 traceId: "trace-test",
659 },
660 { status: 400 },
661 ),
662 ),
663 );
664
665 const error = yield* withCloudServices(
666 linkEnvironmentToCloud({
667 environment: savedEnvironment,
668 clerkToken: "clerk-token",
669 }),
670 ).pipe(Effect.flip);
671 expect(error).toMatchObject({
672 _tag: "CloudEnvironmentLinkError",
673 message:
674 "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).",
675 });
676 }),
677 );
678
679 it.effect("rejects relay credentials for a different environment", () =>
680 Effect.gen(function* () {
681 const fetchMock = vi
682 .fn()
683 .mockResolvedValueOnce(Response.json(validChallenge()))
684 .mockResolvedValueOnce(Response.json(validProof()))
685 .mockResolvedValueOnce(
686 Response.json({
687 ok: true,
688 environmentId: "env-2",
689 endpoint: {
690 httpBaseUrl: "https://desktop.example.test",
691 wsBaseUrl: "wss://desktop.example.test",
692 providerKind: "cloudflare_tunnel",
693 },
694 endpointRuntime: null,
695 relayIssuer: "https://issuer.example.test",
696 cloudUserId: "user_123",
697 environmentCredential: "t3env_test_credential",
698 cloudMintPublicKey: "cloud-mint-public-key",
699 }),
700 );
701 vi.stubGlobal("fetch", fetchMock);
702
703 const error = yield* withCloudServices(
704 linkEnvironmentToCloud({
705 environment: savedEnvironment,
706 clerkToken: "clerk-token",
707 }),
708 ).pipe(Effect.flip);
709 expect(error).toMatchObject({
710 _tag: "CloudEnvironmentLinkError",
711 message: "Relay returned credentials for a different environment.",
712 });
713 expect(fetchMock).toHaveBeenCalledTimes(3);
714 }),
715 );
716
717 it.effect("rejects relay credentials for a different managed endpoint provider", () =>
718 Effect.gen(function* () {
719 const fetchMock = vi
720 .fn()
721 .mockResolvedValueOnce(Response.json(validChallenge()))
722 .mockResolvedValueOnce(Response.json(validProof()))
723 .mockResolvedValueOnce(
724 Response.json({
725 ok: true,
726 environmentId: "env-1",
727 endpoint: {
728 httpBaseUrl: "https://desktop.example.test",
729 wsBaseUrl: "wss://desktop.example.test",
730 providerKind: "manual",
731 },
732 endpointRuntime: null,
733 relayIssuer: "https://issuer.example.test",
734 cloudUserId: "user_123",
735 environmentCredential: "t3env_test_credential",
736 cloudMintPublicKey: "cloud-mint-public-key",
737 }),
738 );
739 vi.stubGlobal("fetch", fetchMock);
740
741 const error = yield* withCloudServices(
742 linkEnvironmentToCloud({
743 environment: savedEnvironment,
744 clerkToken: "clerk-token",
745 }),
746 ).pipe(Effect.flip);
747 expect(error).toMatchObject({
748 _tag: "CloudEnvironmentLinkError",
749 message: "Relay returned credentials for a different endpoint provider.",
750 });
751 expect(fetchMock).toHaveBeenCalledTimes(3);
752 }),
753 );
754
755 it.effect("passes the relay issuer from the link response into local relay config", () =>
756 Effect.gen(function* () {
757 const fetchMock = vi
758 .fn()
759 .mockResolvedValueOnce(Response.json(validChallenge()))
760 .mockResolvedValueOnce(Response.json(validProof()))
761 .mockResolvedValueOnce(
762 Response.json({
763 ok: true,
764 environmentId: "env-1",
765 endpoint: {
766 httpBaseUrl: "https://desktop.example.test",
767 wsBaseUrl: "wss://desktop.example.test",
768 providerKind: "cloudflare_tunnel",
769 },
770 endpointRuntime: null,
771 relayIssuer: "https://issuer.example.test",
772 cloudUserId: "user_123",
773 environmentCredential: "t3env_test_credential",
774 cloudMintPublicKey: "cloud-mint-public-key",
775 }),
776 )
777 .mockResolvedValueOnce(
778 Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }),
779 );
780 vi.stubGlobal("fetch", fetchMock);
781
782 yield* withCloudServices(
783 linkEnvironmentToCloud({
784 environment: savedEnvironment,
785 clerkToken: "clerk-token",
786 }),
787 );
788
789 // @effect-diagnostics-next-line preferSchemaOverJson:off
790 expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({
791 relayUrl: "https://relay.example.test",
792 relayIssuer: "https://issuer.example.test",
793 cloudUserId: "user_123",
794 });
795 }),
796 );
797});
apps/web/src/cloud/linkEnvironment.ts+605−0
@@ -0,0 +1,605 @@
1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import { FetchHttpClient, HttpClient } from "effect/unstable/http";
5import {
6 EnvironmentCloudEndpointUnavailableError,
7 type EnvironmentCloudLinkStateResult,
8 EnvironmentHttpBadRequestError,
9 EnvironmentHttpConflictError,
10 EnvironmentHttpForbiddenError,
11 EnvironmentHttpInternalServerError,
12 EnvironmentHttpUnauthorizedError,
13 EnvironmentId,
14} from "@t3tools/contracts";
15import {
16 RelayEnvironmentConnectScope,
17 type RelayEnvironmentLinkResponse,
18 RelayProtectedError,
19 type RelayClientEnvironmentRecord,
20 type RelayProtectedError as RelayProtectedErrorType,
21 type RelayManagedEndpointProviderKind,
22} from "@t3tools/contracts/relay";
23import {
24 exchangeRemoteDpopAccessToken,
25 fetchRemoteEnvironmentDescriptor,
26 makeEnvironmentHttpApiClient,
27 ManagedRelayClient,
28 ManagedRelayDpopSigner,
29} from "@t3tools/client-runtime";
30
31import { ensureLocalApi } from "../localApi";
32import type { SavedEnvironmentRecord } from "../environments/runtime";
33import {
34 readPrimaryEnvironmentDescriptor,
35 readPrimaryEnvironmentTarget,
36 resolvePrimaryEnvironmentHttpUrl,
37} from "../environments/primary";
38
39export function normalizeRelayBaseUrl(value: string | null | undefined): string | null {
40 const trimmed = value?.trim();
41 if (!trimmed) {
42 return null;
43 }
44 return trimmed.replace(/\/+$/g, "");
45}
46
47function relayUrl(): string | null {
48 return normalizeRelayBaseUrl(import.meta.env.VITE_T3_RELAY_URL as string | undefined);
49}
50
51export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
52 readonly message: string;
53 readonly cause?: unknown;
54}> {}
55
56const isRelayProtectedError = Schema.is(RelayProtectedError);
57const isEnvironmentCloudApiError = Schema.is(
58 Schema.Union([
59 EnvironmentHttpBadRequestError,
60 EnvironmentHttpUnauthorizedError,
61 EnvironmentHttpForbiddenError,
62 EnvironmentHttpConflictError,
63 EnvironmentHttpInternalServerError,
64 EnvironmentCloudEndpointUnavailableError,
65 ]),
66);
67
68function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
69 switch (error._tag) {
70 case "RelayAuthInvalidError":
71 switch (error.reason) {
72 case "missing_bearer":
73 case "invalid_bearer":
74 return "Relay rejected the cloud session token.";
75 case "invalid_dpop":
76 return "Relay rejected the DPoP proof.";
77 case "not_authorized":
78 return "Relay rejected the authenticated request.";
79 }
80 case "RelayEnvironmentLinkProofExpiredError":
81 return "Relay rejected an expired environment link proof.";
82 case "RelayEnvironmentLinkProofInvalidError":
83 return `Relay rejected the environment link proof (${error.reason}).`;
84 case "RelayEnvironmentConnectNotAuthorizedError":
85 return "Relay rejected the environment connection request.";
86 case "RelayEnvironmentEndpointUnavailableError":
87 return `Relay could not reach the environment endpoint (${error.reason}).`;
88 case "RelayEnvironmentEndpointTimedOutError":
89 return "Relay timed out while contacting the environment endpoint.";
90 case "RelayEnvironmentLinkFailedError":
91 return `Relay could not link the environment (${error.reason}).`;
92 case "RelayEnvironmentLinkUnavailableError":
93 return `Relay cannot provision the managed endpoint (${error.reason}).`;
94 case "RelayAgentActivityPublishProofExpiredError":
95 return "Relay rejected an expired agent activity publish proof.";
96 case "RelayAgentActivityPublishProofInvalidError":
97 return `Relay rejected the agent activity publish proof (${error.reason}).`;
98 case "RelayInternalError":
99 return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`;
100 }
101}
102
103function decodedRelayClientError(message: string) {
104 return (cause: unknown) => {
105 const relayError = findRelayProtectedError(cause);
106 const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
107 return new CloudEnvironmentLinkError({
108 message: detail ? `${message}: ${detail}` : message,
109 cause,
110 });
111 };
112}
113
114function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null {
115 if (isRelayProtectedError(cause)) {
116 return cause;
117 }
118 if (typeof cause !== "object" || cause === null) {
119 return null;
120 }
121 return "cause" in cause ? findRelayProtectedError(cause.cause) : null;
122}
123
124function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null {
125 if (isEnvironmentCloudApiError(cause)) {
126 return cause;
127 }
128 if (typeof cause !== "object" || cause === null) {
129 return null;
130 }
131 return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
132}
133
134const environmentApiError = (message: string) => (cause: unknown) => {
135 const environmentError = findEnvironmentCloudApiError(cause);
136 return new CloudEnvironmentLinkError({
137 message: environmentError
138 ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
139 : message,
140 cause,
141 });
142};
143
144const withPrimaryEnvironmentCookies = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
145 effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" }));
146
147function endpointOrigin(httpBaseUrl: string) {
148 const url = new URL(httpBaseUrl);
149 return {
150 localHttpHost: "127.0.0.1",
151 localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
152 };
153}
154
155const MANAGED_ENDPOINT_PROVIDER_KIND =
156 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
157
158function ensureLinkedEnvironmentMatches(input: {
159 readonly expectedEnvironmentId: string;
160 readonly expectedProviderKind: RelayManagedEndpointProviderKind;
161 readonly link: RelayEnvironmentLinkResponse;
162}): Effect.Effect<void, CloudEnvironmentLinkError> {
163 if (input.link.environmentId !== input.expectedEnvironmentId) {
164 return new CloudEnvironmentLinkError({
165 message: "Relay returned credentials for a different environment.",
166 });
167 }
168 if (input.link.endpoint.providerKind !== input.expectedProviderKind) {
169 return new CloudEnvironmentLinkError({
170 message: "Relay returned credentials for a different endpoint provider.",
171 });
172 }
173 return Effect.void;
174}
175
176export interface CloudLinkTarget {
177 readonly environmentId: string;
178 readonly label: string;
179 readonly httpBaseUrl: string;
180 readonly wsBaseUrl: string;
181}
182
183export type CloudLinkState = EnvironmentCloudLinkStateResult;
184
185export interface CloudManagedConnection {
186 readonly environmentId: RelayClientEnvironmentRecord["environmentId"];
187 readonly label: string;
188 readonly httpBaseUrl: string;
189 readonly wsBaseUrl: string;
190 readonly relayUrl: string;
191 readonly accessToken: string;
192}
193
194export function collectCloudLinkTargets(input: {
195 readonly primary: CloudLinkTarget | null;
196 readonly saved: ReadonlyArray<CloudLinkTarget>;
197}): ReadonlyArray<CloudLinkTarget> {
198 const byId = new Map<string, CloudLinkTarget>();
199 if (input.primary) {
200 byId.set(input.primary.environmentId, input.primary);
201 }
202 for (const environment of input.saved) {
203 if (!byId.has(environment.environmentId)) {
204 byId.set(environment.environmentId, environment);
205 }
206 }
207 return [...byId.values()];
208}
209
210export function readPrimaryCloudLinkTarget(): CloudLinkTarget | null {
211 const descriptor = readPrimaryEnvironmentDescriptor();
212 const target = readPrimaryEnvironmentTarget();
213 if (!descriptor || !target) {
214 return null;
215 }
216 return {
217 environmentId: descriptor.environmentId,
218 label: descriptor.label,
219 httpBaseUrl: target.target.httpBaseUrl,
220 wsBaseUrl: target.target.wsBaseUrl,
221 };
222}
223
224export function listManagedCloudEnvironments(input: {
225 readonly clerkToken: string;
226}): Effect.Effect<
227 ReadonlyArray<RelayClientEnvironmentRecord>,
228 CloudEnvironmentLinkError,
229 ManagedRelayClient
230> {
231 return Effect.gen(function* () {
232 const configuredRelayUrl = relayUrl();
233 if (!configuredRelayUrl) {
234 return yield* new CloudEnvironmentLinkError({
235 message: "VITE_T3_RELAY_URL is not configured.",
236 });
237 }
238 const relayClient = yield* ManagedRelayClient;
239 return yield* relayClient
240 .listEnvironments({
241 clerkToken: input.clerkToken,
242 })
243 .pipe(
244 Effect.mapError(
245 (cause) =>
246 new CloudEnvironmentLinkError({
247 message: "Could not list relay-managed environments.",
248 cause,
249 }),
250 ),
251 );
252 });
253}
254
255export function connectManagedCloudEnvironment(input: {
256 readonly clerkToken: string;
257 readonly environment: RelayClientEnvironmentRecord;
258 readonly relayUrl?: string;
259}): Effect.Effect<
260 CloudManagedConnection,
261 CloudEnvironmentLinkError,
262 HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner
263> {
264 return Effect.gen(function* () {
265 const configuredRelayUrl = relayUrl();
266 if (!configuredRelayUrl) {
267 return yield* new CloudEnvironmentLinkError({
268 message: "VITE_T3_RELAY_URL is not configured.",
269 });
270 }
271 const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl);
272 if (persistedRelayUrl && persistedRelayUrl !== configuredRelayUrl) {
273 return yield* new CloudEnvironmentLinkError({
274 message: "The saved environment is linked through a different configured relay.",
275 });
276 }
277 const relayClient = yield* ManagedRelayClient;
278 const connected = yield* relayClient
279 .connectEnvironment({
280 clerkToken: input.clerkToken,
281 scopes: [RelayEnvironmentConnectScope],
282 environmentId: input.environment.environmentId,
283 })
284 .pipe(
285 Effect.mapError(
286 (cause) =>
287 new CloudEnvironmentLinkError({
288 message: "Could not connect to relay-managed environment.",
289 cause,
290 }),
291 ),
292 );
293 if (connected.environmentId !== input.environment.environmentId) {
294 return yield* new CloudEnvironmentLinkError({
295 message: "Relay returned credentials for a different environment.",
296 });
297 }
298 if (
299 connected.endpoint.httpBaseUrl !== input.environment.endpoint.httpBaseUrl ||
300 connected.endpoint.wsBaseUrl !== input.environment.endpoint.wsBaseUrl ||
301 connected.endpoint.providerKind !== input.environment.endpoint.providerKind
302 ) {
303 return yield* new CloudEnvironmentLinkError({
304 message: "Relay returned credentials for a different endpoint.",
305 });
306 }
307 const descriptor = yield* fetchRemoteEnvironmentDescriptor({
308 httpBaseUrl: connected.endpoint.httpBaseUrl,
309 }).pipe(
310 Effect.mapError(
311 (cause) =>
312 new CloudEnvironmentLinkError({
313 message: "Could not read connected environment descriptor.",
314 cause,
315 }),
316 ),
317 );
318 if (descriptor.environmentId !== connected.environmentId) {
319 return yield* new CloudEnvironmentLinkError({
320 message: "Connected endpoint does not match the selected environment.",
321 });
322 }
323 const signer = yield* ManagedRelayDpopSigner;
324 const bootstrapProof = yield* signer
325 .createProof({
326 method: "POST",
327 url: new URL("/oauth/token", connected.endpoint.httpBaseUrl).toString(),
328 })
329 .pipe(
330 Effect.mapError(
331 (cause) =>
332 new CloudEnvironmentLinkError({
333 message: "Could not create environment DPoP proof.",
334 cause,
335 }),
336 ),
337 );
338 const session = yield* exchangeRemoteDpopAccessToken({
339 httpBaseUrl: connected.endpoint.httpBaseUrl,
340 credential: connected.credential,
341 dpopProof: bootstrapProof,
342 }).pipe(
343 Effect.mapError(
344 (cause) =>
345 new CloudEnvironmentLinkError({
346 message: "Could not authorize managed environment.",
347 cause,
348 }),
349 ),
350 );
351 return {
352 environmentId: descriptor.environmentId,
353 label: descriptor.label,
354 httpBaseUrl: connected.endpoint.httpBaseUrl,
355 wsBaseUrl: connected.endpoint.wsBaseUrl,
356 relayUrl: configuredRelayUrl,
357 accessToken: session.access_token,
358 };
359 });
360}
361
362export function readPrimaryCloudLinkState(): Effect.Effect<
363 CloudLinkState | null,
364 CloudEnvironmentLinkError,
365 HttpClient.HttpClient
366> {
367 return Effect.gen(function* () {
368 if (!readPrimaryCloudLinkTarget()) {
369 return null;
370 }
371 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
372 return yield* client.cloud
373 .linkState({ headers: {} })
374 .pipe(
375 withPrimaryEnvironmentCookies,
376 Effect.mapError(environmentApiError("Could not read environment cloud link state.")),
377 );
378 });
379}
380
381export function unlinkPrimaryEnvironmentFromCloud(input: {
382 readonly clerkToken: string | null;
383}): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> {
384 return Effect.gen(function* () {
385 const target = readPrimaryCloudLinkTarget();
386 if (!target) {
387 return yield* new CloudEnvironmentLinkError({
388 message: "Local environment is not ready yet.",
389 });
390 }
391 const configuredRelayUrl = relayUrl();
392 if (configuredRelayUrl && input.clerkToken) {
393 const relayClient = yield* ManagedRelayClient;
394 yield* relayClient
395 .unlinkEnvironment({
396 clerkToken: input.clerkToken,
397 environmentId: EnvironmentId.make(target.environmentId),
398 })
399 .pipe(
400 Effect.catch((cause) =>
401 Effect.logWarning("Could not revoke cloud environment link before local unlink.", {
402 cause,
403 }),
404 ),
405 );
406 }
407
408 const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/"));
409 yield* client.cloud
410 .unlink({ headers: {} })
411 .pipe(
412 withPrimaryEnvironmentCookies,
413 Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")),
414 );
415 });
416}
417
418export function linkEnvironmentToCloud(input: {
419 readonly environment: SavedEnvironmentRecord;
420 readonly clerkToken: string;
421}): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> {
422 return Effect.gen(function* () {
423 const configuredRelayUrl = relayUrl();
424 if (!configuredRelayUrl) {
425 return yield* new CloudEnvironmentLinkError({
426 message: "VITE_T3_RELAY_URL is not configured.",
427 });
428 }
429 const relayClient = yield* ManagedRelayClient;
430 const bearerToken = yield* Effect.tryPromise({
431 try: () =>
432 ensureLocalApi().persistence.getSavedEnvironmentSecret(input.environment.environmentId),
433 catch: (cause) =>
434 new CloudEnvironmentLinkError({
435 message: `Could not read saved bearer token for ${input.environment.label}.`,
436 cause,
437 }),
438 });
439 if (!bearerToken) {
440 return yield* new CloudEnvironmentLinkError({
441 message: `No saved bearer token for ${input.environment.label}.`,
442 });
443 }
444
445 const challenge = yield* relayClient
446 .createEnvironmentLinkChallenge({
447 clerkToken: input.clerkToken,
448 payload: {
449 notificationsEnabled: true,
450 liveActivitiesEnabled: true,
451 managedTunnelsEnabled: true,
452 },
453 })
454 .pipe(
455 Effect.mapError(
456 decodedRelayClientError(
457 `${configuredRelayUrl}/v1/client/environment-link-challenges failed`,
458 ),
459 ),
460 );
461 const environmentClient = yield* makeEnvironmentHttpApiClient(input.environment.httpBaseUrl);
462 const proof = yield* environmentClient.cloud
463 .linkProof({
464 headers: { authorization: `Bearer ${bearerToken}` },
465 payload: {
466 challenge: challenge.challenge,
467 relayIssuer: configuredRelayUrl,
468 endpoint: {
469 httpBaseUrl: input.environment.httpBaseUrl,
470 wsBaseUrl: input.environment.wsBaseUrl,
471 providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
472 },
473 origin: endpointOrigin(input.environment.httpBaseUrl),
474 },
475 })
476 .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof.")));
477 const link = yield* relayClient
478 .linkEnvironment({
479 clerkToken: input.clerkToken,
480 payload: {
481 proof,
482 notificationsEnabled: true,
483 liveActivitiesEnabled: true,
484 managedTunnelsEnabled: true,
485 },
486 })
487 .pipe(
488 Effect.mapError(
489 decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`),
490 ),
491 );
492 yield* ensureLinkedEnvironmentMatches({
493 expectedEnvironmentId: input.environment.environmentId,
494 expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND,
495 link,
496 });
497
498 yield* environmentClient.cloud
499 .relayConfig({
500 headers: { authorization: `Bearer ${bearerToken}` },
501 payload: {
502 relayUrl: configuredRelayUrl,
503 relayIssuer: link.relayIssuer,
504 cloudUserId: link.cloudUserId,
505 environmentCredential: link.environmentCredential,
506 cloudMintPublicKey: link.cloudMintPublicKey,
507 endpointRuntime: link.endpointRuntime,
508 },
509 })
510 .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access.")));
511 });
512}
513
514export function linkPrimaryEnvironmentToCloud(input: {
515 readonly clerkToken: string;
516}): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> {
517 return Effect.gen(function* () {
518 const configuredRelayUrl = relayUrl();
519 if (!configuredRelayUrl) {
520 return yield* new CloudEnvironmentLinkError({
521 message: "VITE_T3_RELAY_URL is not configured.",
522 });
523 }
524 const relayClient = yield* ManagedRelayClient;
525 const target = readPrimaryCloudLinkTarget();
526 if (!target) {
527 return yield* new CloudEnvironmentLinkError({
528 message: "Local environment is not ready yet.",
529 });
530 }
531
532 const challenge = yield* relayClient
533 .createEnvironmentLinkChallenge({
534 clerkToken: input.clerkToken,
535 payload: {
536 notificationsEnabled: true,
537 liveActivitiesEnabled: true,
538 managedTunnelsEnabled: true,
539 },
540 })
541 .pipe(
542 Effect.mapError(
543 decodedRelayClientError(
544 `${configuredRelayUrl}/v1/client/environment-link-challenges failed`,
545 ),
546 ),
547 );
548 const environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl);
549 const proof = yield* environmentClient.cloud
550 .linkProof({
551 headers: {},
552 payload: {
553 challenge: challenge.challenge,
554 relayIssuer: configuredRelayUrl,
555 endpoint: {
556 httpBaseUrl: target.httpBaseUrl,
557 wsBaseUrl: target.wsBaseUrl,
558 providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
559 },
560 origin: endpointOrigin(target.httpBaseUrl),
561 },
562 })
563 .pipe(
564 withPrimaryEnvironmentCookies,
565 Effect.mapError(environmentApiError("Could not obtain environment link proof.")),
566 );
567 const link = yield* relayClient
568 .linkEnvironment({
569 clerkToken: input.clerkToken,
570 payload: {
571 proof,
572 notificationsEnabled: true,
573 liveActivitiesEnabled: true,
574 managedTunnelsEnabled: true,
575 },
576 })
577 .pipe(
578 Effect.mapError(
579 decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`),
580 ),
581 );
582 yield* ensureLinkedEnvironmentMatches({
583 expectedEnvironmentId: target.environmentId,
584 expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND,
585 link,
586 });
587
588 yield* environmentClient.cloud
589 .relayConfig({
590 headers: {},
591 payload: {
592 relayUrl: configuredRelayUrl,
593 relayIssuer: link.relayIssuer,
594 cloudUserId: link.cloudUserId,
595 environmentCredential: link.environmentCredential,
596 cloudMintPublicKey: link.cloudMintPublicKey,
597 endpointRuntime: link.endpointRuntime,
598 },
599 })
600 .pipe(
601 withPrimaryEnvironmentCookies,
602 Effect.mapError(environmentApiError("Could not configure environment relay access.")),
603 );
604 });
605}
apps/web/src/cloud/managedAuth.tsx+22−0
@@ -0,0 +1,22 @@
1import { useAuth } from "@clerk/react";
2import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
3import { useEffect, type ReactNode } from "react";
4
5let relayTokenProvider: (() => Promise<string | null>) | null = null;
6
7export async function readManagedRelayClerkToken(): Promise<string | null> {
8 return relayTokenProvider?.() ?? null;
9}
10
11export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) {
12 const { getToken, isSignedIn } = useAuth();
13
14 useEffect(() => {
15 relayTokenProvider = isSignedIn ? () => getToken(RELAY_CLERK_TOKEN_OPTIONS) : null;
16 return () => {
17 relayTokenProvider = null;
18 };
19 }, [getToken, isSignedIn]);
20
21 return children;
22}
apps/web/src/cloud/managedRelayLayer.ts+62−0
@@ -0,0 +1,62 @@
1import {
2 managedRelayClientLayer,
3 ManagedRelayDpopSigner,
4 ManagedRelayDpopSignerError,
5} from "@t3tools/client-runtime";
6import { RelayWebClientId } from "@t3tools/contracts/relay";
7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Semaphore from "effect/Semaphore";
11
12import {
13 createBrowserDpopProof,
14 generateBrowserDpopKey,
15 readStoredBrowserDpopKey,
16 writeStoredBrowserDpopKey,
17 type BrowserDpopKey,
18} from "./dpop";
19
20export const webRelayDpopSignerLayer = Layer.effect(
21 ManagedRelayDpopSigner,
22 Effect.gen(function* () {
23 const crypto = yield* Crypto.Crypto;
24 const keyLoadSemaphore = yield* Semaphore.make(1);
25 let loadedKey: BrowserDpopKey | null = null;
26 const loadOrCreateBrowserDpopKey = keyLoadSemaphore.withPermit(
27 Effect.gen(function* () {
28 if (loadedKey) {
29 return loadedKey;
30 }
31 const stored = yield* readStoredBrowserDpopKey();
32 if (stored) {
33 loadedKey = stored;
34 return stored;
35 }
36 const generated = yield* generateBrowserDpopKey;
37 yield* writeStoredBrowserDpopKey(generated);
38 loadedKey = generated;
39 return generated;
40 }),
41 );
42 const signerError = (cause: unknown) => new ManagedRelayDpopSignerError({ cause });
43 return ManagedRelayDpopSigner.of({
44 thumbprint: loadOrCreateBrowserDpopKey.pipe(
45 Effect.map((proofKey) => proofKey.thumbprint),
46 Effect.mapError(signerError),
47 ),
48 createProof: (input) =>
49 loadOrCreateBrowserDpopKey.pipe(
50 Effect.flatMap((proofKey) => createBrowserDpopProof({ ...input, proofKey })),
51 Effect.provideService(Crypto.Crypto, crypto),
52 Effect.map((proof) => proof.proof),
53 Effect.mapError(signerError),
54 ),
55 });
56 }),
57);
58
59export const webManagedRelayClientLayer = (relayUrl: string) =>
60 managedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe(
61 Layer.provideMerge(webRelayDpopSignerLayer),
62 );
apps/web/src/components/settings/CloudSettings.tsx+556−0
@@ -0,0 +1,556 @@
1import { UserButton, Waitlist, useAuth, useClerk } from "@clerk/react";
2import { useSignIn, useSignUp } from "@clerk/react/legacy";
3import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
4import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
5import * as Effect from "effect/Effect";
6import { CloudIcon } from "lucide-react";
7import { useCallback, useEffect, useRef, useState } from "react";
8
9import {
10 type DesktopCloudAuthOAuthStrategy,
11 resolveDesktopCloudAuthOAuthOptions,
12} from "../../cloud/desktopAuth";
13import {
14 collectCloudLinkTargets,
15 connectManagedCloudEnvironment,
16 linkEnvironmentToCloud,
17 linkPrimaryEnvironmentToCloud,
18 listManagedCloudEnvironments,
19 readPrimaryCloudLinkState,
20 readPrimaryCloudLinkTarget,
21 type CloudLinkState,
22 unlinkPrimaryEnvironmentFromCloud,
23} from "../../cloud/linkEnvironment";
24import { isElectron } from "../../env";
25import { usePrimaryEnvironmentId } from "../../environments/primary";
26import {
27 addManagedRelayEnvironment,
28 listSavedEnvironmentRecords,
29 useSavedEnvironmentRegistryStore,
30} from "../../environments/runtime";
31import { webRuntime } from "../../lib/runtime";
32import { Button } from "../ui/button";
33import { toastManager } from "../ui/toast";
34import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
35
36function hasClerkConfig(): boolean {
37 return Boolean(import.meta.env.VITE_CLERK_PUBLISHABLE_KEY);
38}
39
40class CloudSettingsOperationError extends Error {
41 override readonly cause?: unknown;
42
43 constructor(message: string, cause?: unknown) {
44 super(message);
45 this.name = "CloudSettingsOperationError";
46 this.cause = cause;
47 }
48}
49
50async function runCloudOperation<T>(operation: () => Promise<T>, message: string): Promise<T> {
51 try {
52 return await operation();
53 } catch (cause) {
54 throw new CloudSettingsOperationError(message, cause);
55 }
56}
57
58function cloudErrorMessage(error: unknown, fallback: string): string {
59 if (error instanceof CloudSettingsOperationError) {
60 const cause = error.cause;
61 if (cause instanceof Error && cause.message && cause.message !== error.message) {
62 return `${error.message}: ${cause.message}`;
63 }
64 return error.message;
65 }
66 return error instanceof Error ? error.message : fallback;
67}
68
69export function CloudSettingsPanel() {
70 if (!hasClerkConfig()) {
71 return (
72 <SettingsPageContainer>
73 <SettingsSection title="T3 Cloud" icon={<CloudIcon className="size-3.5" />}>
74 <SettingsRow
75 title="Cloud account"
76 description="Set VITE_CLERK_PUBLISHABLE_KEY to enable optional cloud features."
77 />
78 </SettingsSection>
79 </SettingsPageContainer>
80 );
81 }
82
83 return <ConfiguredCloudSettingsPanel />;
84}
85
86function ConfiguredCloudSettingsPanel() {
87 const { isLoaded, isSignedIn } = useAuth();
88
89 if (!isLoaded) {
90 return null;
91 }
92
93 return isSignedIn ? <CloudSettingsPanelInner /> : <CloudWaitlistPanel />;
94}
95
96function CloudWaitlistPanel() {
97 return (
98 <SettingsPageContainer className="min-h-full items-center justify-center">
99 <Waitlist />
100 {isElectron ? (
101 <div className="flex flex-col items-center gap-3 text-xs text-muted-foreground">
102 <p>Already approved? Sign in through the desktop app.</p>
103 <DesktopCloudSignInButton />
104 </div>
105 ) : null}
106 </SettingsPageContainer>
107 );
108}
109
110function CloudSettingsPanelInner() {
111 const { getToken, userId } = useAuth();
112 const primaryEnvironmentId = usePrimaryEnvironmentId();
113 const savedEnvironmentCount = useSavedEnvironmentRegistryStore(
114 (state) => Object.keys(state.byId).length,
115 );
116 const [isLinking, setIsLinking] = useState(false);
117 const [isUnlinking, setIsUnlinking] = useState(false);
118 const [primaryLinkState, setPrimaryLinkState] = useState<CloudLinkState | null>(null);
119 const [linkStateError, setLinkStateError] = useState<string | null>(null);
120 const [managedEnvironments, setManagedEnvironments] = useState<
121 ReadonlyArray<RelayClientEnvironmentRecord>
122 >([]);
123 const [isLoadingManaged, setIsLoadingManaged] = useState(false);
124 const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<string | null>(null);
125 const linkableEnvironmentCount = collectCloudLinkTargets({
126 primary: primaryEnvironmentId ? readPrimaryCloudLinkTarget() : null,
127 saved: listSavedEnvironmentRecords().filter((environment) => !environment.relayManaged),
128 }).length;
129 const linkedCloudUserId = primaryLinkState?.cloudUserId ?? null;
130 const hasCloudAccountMismatch = Boolean(
131 userId && linkedCloudUserId && linkedCloudUserId !== userId,
132 );
133
134 const refreshPrimaryLinkState = useCallback(() => {
135 if (!primaryEnvironmentId) {
136 setPrimaryLinkState(null);
137 setLinkStateError(null);
138 return;
139 }
140 void webRuntime.runPromise(readPrimaryCloudLinkState()).then(
141 (state) => {
142 setPrimaryLinkState(state);
143 setLinkStateError(null);
144 },
145 (error: unknown) => {
146 setPrimaryLinkState(null);
147 setLinkStateError(cloudErrorMessage(error, "Could not read local cloud link state."));
148 },
149 );
150 }, [primaryEnvironmentId]);
151
152 useEffect(() => {
153 refreshPrimaryLinkState();
154 }, [refreshPrimaryLinkState]);
155
156 const refreshManagedEnvironments = useCallback(async () => {
157 setIsLoadingManaged(true);
158 try {
159 const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS);
160 if (!token) {
161 setManagedEnvironments([]);
162 return;
163 }
164 setManagedEnvironments(
165 await webRuntime.runPromise(listManagedCloudEnvironments({ clerkToken: token })),
166 );
167 } catch (error) {
168 toastManager.add({
169 type: "error",
170 title: "Cloud environments unavailable",
171 description: cloudErrorMessage(error, "Could not load linked environments."),
172 });
173 } finally {
174 setIsLoadingManaged(false);
175 }
176 }, [getToken]);
177
178 useEffect(() => {
179 void refreshManagedEnvironments();
180 }, [refreshManagedEnvironments]);
181
182 const linkEnvironments = async () => {
183 if (hasCloudAccountMismatch) {
184 toastManager.add({
185 type: "error",
186 title: "Cloud account mismatch",
187 description: "This environment is linked to a different cloud account.",
188 });
189 return;
190 }
191 setIsLinking(true);
192 try {
193 const token = await runCloudOperation(
194 () => getToken(RELAY_CLERK_TOKEN_OPTIONS),
195 "Could not get the current cloud session.",
196 );
197 if (!token) {
198 return;
199 }
200 const primaryTarget = readPrimaryCloudLinkTarget();
201 const savedEnvironments = listSavedEnvironmentRecords().filter(
202 (environment) => !environment.relayManaged,
203 );
204 const savedEnvironmentIds = new Set(primaryTarget ? [primaryTarget.environmentId] : []);
205 if (primaryTarget) {
206 await runCloudOperation(
207 () => webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken: token })),
208 "Could not link the local environment.",
209 );
210 }
211 await runCloudOperation(
212 () =>
213 webRuntime.runPromise(
214 Effect.all(
215 savedEnvironments
216 .filter((environment) => {
217 if (savedEnvironmentIds.has(environment.environmentId)) {
218 return false;
219 }
220 savedEnvironmentIds.add(environment.environmentId);
221 return true;
222 })
223 .map((environment) => linkEnvironmentToCloud({ environment, clerkToken: token })),
224 { concurrency: "unbounded" },
225 ),
226 ),
227 "Could not link environments.",
228 );
229 toastManager.add({
230 type: "success",
231 title: "Environments linked",
232 description: "Relay notifications are enabled for linked environments.",
233 });
234 refreshPrimaryLinkState();
235 } catch (error) {
236 toastManager.add({
237 type: "error",
238 title: "Cloud link failed",
239 description: cloudErrorMessage(error, "Could not link environments."),
240 });
241 } finally {
242 setIsLinking(false);
243 }
244 };
245
246 const unlinkPrimaryEnvironment = async () => {
247 setIsUnlinking(true);
248 try {
249 const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS).catch(() => null);
250 await runCloudOperation(
251 () => webRuntime.runPromise(unlinkPrimaryEnvironmentFromCloud({ clerkToken: token })),
252 "Could not unlink the local environment.",
253 );
254 refreshPrimaryLinkState();
255 toastManager.add({
256 type: "success",
257 title: "Environment unlinked",
258 description: "Local relay credentials and managed endpoint runtime config were removed.",
259 });
260 } catch (error) {
261 toastManager.add({
262 type: "error",
263 title: "Cloud unlink failed",
264 description: cloudErrorMessage(error, "Could not unlink the local environment."),
265 });
266 } finally {
267 setIsUnlinking(false);
268 }
269 };
270
271 const connectManagedEnvironment = async (environment: RelayClientEnvironmentRecord) => {
272 setConnectingEnvironmentId(environment.environmentId);
273 try {
274 const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS);
275 if (!token) {
276 throw new CloudSettingsOperationError("Could not get the current cloud session.");
277 }
278 const connection = await webRuntime.runPromise(
279 connectManagedCloudEnvironment({ clerkToken: token, environment }),
280 );
281 await addManagedRelayEnvironment(connection);
282 toastManager.add({
283 type: "success",
284 title: "Environment connected",
285 description: `${connection.label} is available through its managed tunnel.`,
286 });
287 } catch (error) {
288 toastManager.add({
289 type: "error",
290 title: "Managed connection failed",
291 description: cloudErrorMessage(error, "Could not connect to the cloud environment."),
292 });
293 } finally {
294 setConnectingEnvironmentId(null);
295 }
296 };
297
298 return (
299 <SettingsPageContainer>
300 <SettingsSection title="T3 Cloud" icon={<CloudIcon className="size-3.5" />}>
301 <SettingsRow
302 title="Cloud account"
303 description="Approved users can sign in for relay notifications and managed tunnel connections."
304 control={<UserButton />}
305 />
306 <SettingsRow
307 title="Linked environments"
308 description={
309 hasCloudAccountMismatch
310 ? `Linked as ${linkedCloudUserId}; signed in as ${userId}.`
311 : "Grant linked environments a relay credential for APNs agent activity updates."
312 }
313 status={
314 linkStateError ??
315 (linkedCloudUserId
316 ? `Linked as ${linkedCloudUserId}`
317 : `${linkableEnvironmentCount} linkable environment${linkableEnvironmentCount === 1 ? "" : "s"} (${savedEnvironmentCount} saved)`)
318 }
319 control={
320 <div className="flex items-center gap-2">
321 {linkedCloudUserId ? (
322 <Button
323 size="sm"
324 variant="secondary"
325 disabled={isUnlinking}
326 onClick={() => void unlinkPrimaryEnvironment()}
327 >
328 {isUnlinking ? "Unlinking..." : "Unlink"}
329 </Button>
330 ) : null}
331 <Button
332 size="sm"
333 disabled={
334 isLinking ||
335 isUnlinking ||
336 linkableEnvironmentCount === 0 ||
337 hasCloudAccountMismatch
338 }
339 onClick={() => void linkEnvironments()}
340 >
341 {isLinking ? "Linking..." : "Link"}
342 </Button>
343 </div>
344 }
345 />
346 <SettingsRow
347 title="Managed tunnel environments"
348 description="Connect to linked environments through T3 Cloud from this frontend."
349 status={
350 isLoadingManaged
351 ? "Loading..."
352 : `${managedEnvironments.length} available environment${managedEnvironments.length === 1 ? "" : "s"}`
353 }
354 control={
355 <Button
356 size="sm"
357 variant="secondary"
358 disabled={isLoadingManaged}
359 onClick={() => void refreshManagedEnvironments()}
360 >
361 Refresh
362 </Button>
363 }
364 />
365 {managedEnvironments.map((environment) => (
366 <SettingsRow
367 key={environment.environmentId}
368 title={environment.label}
369 description={environment.endpoint.httpBaseUrl}
370 control={
371 <Button
372 size="sm"
373 disabled={connectingEnvironmentId !== null}
374 onClick={() => void connectManagedEnvironment(environment)}
375 >
376 {connectingEnvironmentId === environment.environmentId
377 ? "Connecting..."
378 : "Connect"}
379 </Button>
380 }
381 />
382 ))}
383 </SettingsSection>
384 </SettingsPageContainer>
385 );
386}
387
388function DesktopCloudSignInButton() {
389 const clerk = useClerk();
390 const { setActive } = clerk;
391 const { isLoaded: signInLoaded, signIn } = useSignIn();
392 const { isLoaded: signUpLoaded, signUp } = useSignUp();
393 const [startingStrategy, setStartingStrategy] = useState<DesktopCloudAuthOAuthStrategy | null>(
394 null,
395 );
396 const oauthOptions = resolveDesktopCloudAuthOAuthOptions(clerk);
397 const callbackCleanupRef = useRef<(() => void) | null>(null);
398
399 const clearCallbackListener = useCallback(() => {
400 callbackCleanupRef.current?.();
401 callbackCleanupRef.current = null;
402 }, []);
403
404 const completeOAuthCallback = useCallback(
405 async (rawUrl: string) => {
406 if (!signInLoaded || !signIn || !signUpLoaded || !signUp) {
407 toastManager.add({
408 type: "error",
409 title: "Cloud sign-in failed",
410 description: "Clerk is still loading. Try signing in again.",
411 });
412 return;
413 }
414
415 let rotatingTokenNonce: string | null = null;
416 let sessionId: string | null = null;
417 try {
418 const callbackUrl = new URL(rawUrl);
419 rotatingTokenNonce = callbackUrl.searchParams.get("rotating_token_nonce");
420 sessionId = callbackUrl.searchParams.get("created_session_id");
421 } catch {
422 // Handled by the explicit nonce check below.
423 }
424 if (!rotatingTokenNonce) {
425 toastManager.add({
426 type: "error",
427 title: "Cloud sign-in failed",
428 description:
429 "Clerk did not return a native session nonce. Verify this redirect URL is allowlisted for native SSO redirects.",
430 });
431 return;
432 }
433
434 try {
435 await runCloudOperation(
436 () => signIn.reload({ rotatingTokenNonce }),
437 "Could not reload the desktop sign-in session.",
438 );
439 sessionId = sessionId || signIn.createdSessionId;
440
441 if (!sessionId && signIn.firstFactorVerification.status === "transferable") {
442 const signUpAttempt = await runCloudOperation(
443 () => signUp.create({ transfer: true }),
444 "Could not transfer the desktop sign-up session.",
445 );
446 sessionId = signUpAttempt.createdSessionId;
447 }
448
449 if (!sessionId) {
450 throw new CloudSettingsOperationError("Clerk did not create a desktop session.");
451 }
452
453 await runCloudOperation(
454 () => setActive({ session: sessionId! }),
455 "Could not activate the desktop cloud session.",
456 );
457 } catch (error) {
458 toastManager.add({
459 type: "error",
460 title: "Cloud sign-in failed",
461 description: cloudErrorMessage(error, "Could not complete cloud sign-in."),
462 });
463 }
464 },
465 [setActive, signIn, signInLoaded, signUp, signUpLoaded],
466 );
467
468 useEffect(() => {
469 return () => {
470 clearCallbackListener();
471 };
472 }, [clearCallbackListener]);
473
474 const startOAuth = async (strategy: DesktopCloudAuthOAuthStrategy) => {
475 if (!signInLoaded || !signIn) {
476 toastManager.add({
477 type: "error",
478 title: "Cloud sign-in failed",
479 description: "Clerk is still loading. Try signing in again.",
480 });
481 return;
482 }
483
484 setStartingStrategy(strategy);
485 clearCallbackListener();
486 try {
487 const redirectUrl = await runCloudOperation(
488 () => window.desktopBridge?.createCloudAuthRequest() ?? Promise.resolve(undefined),
489 "Desktop auth callback is unavailable.",
490 );
491 if (!redirectUrl) {
492 throw new CloudSettingsOperationError("Desktop auth callback is unavailable.");
493 }
494
495 callbackCleanupRef.current =
496 window.desktopBridge?.onCloudAuthCallback((rawUrl) => {
497 clearCallbackListener();
498 void completeOAuthCallback(rawUrl);
499 }) ?? null;
500
501 const signInAttempt = await runCloudOperation(
502 () => signIn.create({ strategy, redirectUrl } as never),
503 "Could not create the desktop OAuth request.",
504 );
505 const externalUrl =
506 signInAttempt.firstFactorVerification.externalVerificationRedirectURL?.toString();
507 if (!externalUrl) {
508 throw new CloudSettingsOperationError(
509 "Clerk did not return an external OAuth redirect URL.",
510 );
511 }
512
513 const opened = await runCloudOperation(
514 () => window.desktopBridge?.openExternal(externalUrl) ?? Promise.resolve(false),
515 "Could not open the system browser.",
516 );
517 if (!opened) {
518 throw new CloudSettingsOperationError("Could not open the system browser.");
519 }
520 } catch (error) {
521 clearCallbackListener();
522 toastManager.add({
523 type: "error",
524 title: "Cloud sign-in failed",
525 description: cloudErrorMessage(error, "Could not start cloud sign-in."),
526 });
527 } finally {
528 setStartingStrategy(null);
529 }
530 };
531
532 const isStarting = startingStrategy !== null;
533
534 if (oauthOptions.length === 0) {
535 return (
536 <Button disabled size="sm">
537 No OAuth providers enabled
538 </Button>
539 );
540 }
541
542 return (
543 <div className="flex flex-wrap justify-end gap-2">
544 {oauthOptions.map((option) => (
545 <Button
546 key={option.strategy}
547 disabled={isStarting}
548 onClick={() => void startOAuth(option.strategy)}
549 size="sm"
550 >
551 {startingStrategy === option.strategy ? "Opening..." : `Continue with ${option.label}`}
552 </Button>
553 ))}
554 </div>
555 );
556}
apps/web/src/components/settings/SettingsPanels.browser.tsx+12−0
@@ -450,6 +450,18 @@ const createDesktopBridgeStub = (overrides?: { · 449 unmodified lines
450 setTheme: vi.fn().mockResolvedValue(undefined),450 setTheme: vi.fn().mockResolvedValue(undefined),
451 showContextMenu: vi.fn().mockResolvedValue(null),451 showContextMenu: vi.fn().mockResolvedValue(null),
452 openExternal: vi.fn().mockResolvedValue(true),452 openExternal: vi.fn().mockResolvedValue(true),
453 createCloudAuthRequest: vi.fn().mockResolvedValue("t3code-dev://auth/callback?t3_state=test"),
454 getCloudAuthToken: vi.fn().mockResolvedValue(null),
455 setCloudAuthToken: vi.fn().mockResolvedValue(true),
456 clearCloudAuthToken: vi.fn().mockResolvedValue(undefined),
457 fetchCloudAuth: vi.fn().mockResolvedValue({
458 ok: true,
459 status: 200,
460 statusText: "OK",
461 headers: {},
462 body: "",
463 }),
464 onCloudAuthCallback: () => () => {},
453 onMenuAction: () => () => {},465 onMenuAction: () => () => {},
454 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),466 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),
455 setUpdateChannel:467 setUpdateChannel:
apps/web/src/components/settings/SettingsSidebarNav.tsx+3−0
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
3 ArchiveIcon,3 ArchiveIcon,
4 ArrowLeftIcon,4 ArrowLeftIcon,
5 BotIcon,5 BotIcon,
6 CloudIcon,
6 GitBranchIcon,7 GitBranchIcon,
7 KeyboardIcon,8 KeyboardIcon,
8 Link2Icon,9 Link2Icon,
@@ -26,6 +27,7 @@ export type SettingsSectionPath = · 17 unmodified lines
26 | "/settings/keybindings"27 | "/settings/keybindings"
27 | "/settings/providers"28 | "/settings/providers"
28 | "/settings/source-control"29 | "/settings/source-control"
30 | "/settings/cloud"
29 | "/settings/connections"31 | "/settings/connections"
30 | "/settings/archived";32 | "/settings/archived";
31 33
@@ -38,6 +40,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ · 6 unmodified lines
38 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },40 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },
39 { label: "Providers", to: "/settings/providers", icon: BotIcon },41 { label: "Providers", to: "/settings/providers", icon: BotIcon },
40 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },42 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
43 { label: "T3 Cloud", to: "/settings/cloud", icon: CloudIcon },
41 { label: "Connections", to: "/settings/connections", icon: Link2Icon },44 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
42 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },45 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
43];46];
apps/web/src/environments/primary/auth.ts+2−1
@@ -3,6 +3,7 @@ import type { · 2 unmodified lines
3 AuthClientMetadata,3 AuthClientMetadata,
4 AuthEnvironmentScope,4 AuthEnvironmentScope,
5 AuthPairingCredentialResult,5 AuthPairingCredentialResult,
6 ServerAuthSessionMethod,
6 AuthSessionId,7 AuthSessionId,
7 AuthSessionState,8 AuthSessionState,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
@@ -45,7 +46,7 @@ export interface ServerClientSessionRecord { · 36 unmodified lines
45 readonly sessionId: AuthSessionId;46 readonly sessionId: AuthSessionId;
46 readonly subject: string;47 readonly subject: string;
47 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;48 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
48 readonly method: "browser-session-cookie" | "bearer-access-token";49 readonly method: ServerAuthSessionMethod;
49 readonly client: AuthClientMetadata;50 readonly client: AuthClientMetadata;
50 readonly issuedAt: string;51 readonly issuedAt: string;
51 readonly expiresAt: string;52 readonly expiresAt: string;
apps/web/src/environments/primary/index.ts+5−1
@@ -34,4 +34,8 @@ export { · 33 unmodified lines
34 34
35export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState";35export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState";
36 36
37export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target";37export {
38 readPrimaryEnvironmentTarget,
39 resolvePrimaryEnvironmentHttpUrl,
40 isLoopbackHostname,
41} from "./target";
apps/web/src/environments/runtime/catalog.test.ts+43−0
@@ -6,11 +6,13 @@ import { · 5 unmodified lines
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7 7
8import {8import {
9 readSavedEnvironmentCredential,
9 resetSavedEnvironmentRegistryStoreForTests,10 resetSavedEnvironmentRegistryStoreForTests,
10 resetSavedEnvironmentRuntimeStoreForTests,11 resetSavedEnvironmentRuntimeStoreForTests,
11 useSavedEnvironmentRegistryStore,12 useSavedEnvironmentRegistryStore,
12 useSavedEnvironmentRuntimeStore,13 useSavedEnvironmentRuntimeStore,
13 waitForSavedEnvironmentRegistryHydration,14 waitForSavedEnvironmentRegistryHydration,
15 writeSavedEnvironmentCredential,
14} from "./catalog";16} from "./catalog";
15 17
16describe("environment runtime catalog stores", () => {18describe("environment runtime catalog stores", () => {
@@ -74,6 +76,47 @@ describe("environment runtime catalog stores", () => { · 57 unmodified lines
74 expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({});76 expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({});
75 });77 });
76 78
79 it("decodes legacy bearer secrets and writes versioned DPoP credentials", async () => {
80 let storedSecret: string | null = "legacy-bearer-token";
81 vi.stubGlobal("window", {
82 nativeApi: {
83 persistence: {
84 getClientSettings: async () => null,
85 setClientSettings: async () => undefined,
86 getSavedEnvironmentRegistry: async () => [],
87 setSavedEnvironmentRegistry: async () => undefined,
88 getSavedEnvironmentSecret: async () => storedSecret,
89 setSavedEnvironmentSecret: async (_environmentId, secret) => {
90 storedSecret = secret;
91 return true;
92 },
93 removeSavedEnvironmentSecret: async () => undefined,
94 },
95 } satisfies Pick<LocalApi, "persistence">,
96 });
97 const { __resetLocalApiForTests } = await import("../../localApi");
98 await __resetLocalApiForTests();
99 const environmentId = EnvironmentId.make("environment-1");
100
101 await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({
102 version: 1,
103 method: "bearer",
104 token: "legacy-bearer-token",
105 });
106 await expect(
107 writeSavedEnvironmentCredential(environmentId, {
108 version: 1,
109 method: "dpop",
110 accessToken: "managed-dpop-access-token",
111 }),
112 ).resolves.toBe(true);
113 await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({
114 version: 1,
115 method: "dpop",
116 accessToken: "managed-dpop-access-token",
117 });
118 });
119
77 it("does not throw when local api lookup fails during registry persistence", async () => {120 it("does not throw when local api lookup fails during registry persistence", async () => {
78 vi.unstubAllGlobals();121 vi.unstubAllGlobals();
79 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});122 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
apps/web/src/environments/runtime/catalog.ts+49−0
@@ -6,6 +6,8 @@ import type { · 5 unmodified lines
6 PersistedSavedEnvironmentRecord,6 PersistedSavedEnvironmentRecord,
7 ServerConfig,7 ServerConfig,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
9import { create } from "zustand";11import { create } from "zustand";
10 12
11import { ensureLocalApi } from "../../localApi";13import { ensureLocalApi } from "../../localApi";
@@ -19,8 +21,29 @@ export interface SavedEnvironmentRecord { · 7 unmodified lines
19 readonly createdAt: string;21 readonly createdAt: string;
20 readonly lastConnectedAt: string | null;22 readonly lastConnectedAt: string | null;
21 readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"];23 readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"];
24 readonly relayManaged?: PersistedSavedEnvironmentRecord["relayManaged"];
22}25}
23 26
27export const SavedEnvironmentCredential = Schema.Union([
28 Schema.Struct({
29 version: Schema.Literal(1),
30 method: Schema.Literal("bearer"),
31 token: Schema.String,
32 }),
33 Schema.Struct({
34 version: Schema.Literal(1),
35 method: Schema.Literal("dpop"),
36 accessToken: Schema.String,
37 }),
38]);
39export type SavedEnvironmentCredential = typeof SavedEnvironmentCredential.Type;
40
41const SavedEnvironmentCredentialJson = Schema.fromJsonString(SavedEnvironmentCredential);
42const decodeSavedEnvironmentCredentialJson = Schema.decodeUnknownOption(
43 SavedEnvironmentCredentialJson,
44);
45const encodeSavedEnvironmentCredentialJson = Schema.encodeSync(SavedEnvironmentCredentialJson);
46
24interface SavedEnvironmentRegistryState {47interface SavedEnvironmentRegistryState {
25 readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>;48 readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>;
26}49}
@@ -47,6 +70,7 @@ export function toPersistedSavedEnvironmentRecord( · 20 unmodified lines
47 createdAt: record.createdAt,70 createdAt: record.createdAt,
48 lastConnectedAt: record.lastConnectedAt,71 lastConnectedAt: record.lastConnectedAt,
49 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),72 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
73 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
50 };74 };
51}75}
52 76
@@ -250,6 +274,31 @@ export async function readSavedEnvironmentBearerToken( · 197 unmodified lines
250 return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId);274 return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId);
251}275}
252 276
277export async function readSavedEnvironmentCredential(
278 environmentId: EnvironmentId,
279): Promise<SavedEnvironmentCredential | null> {
280 const secret = await ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId);
281 if (!secret) {
282 return null;
283 }
284 const decoded = decodeSavedEnvironmentCredentialJson(secret);
285 if (Option.isSome(decoded)) {
286 return decoded.value;
287 }
288 // Legacy bearer secrets were stored directly as strings.
289 return { version: 1, method: "bearer", token: secret };
290}
291
292export async function writeSavedEnvironmentCredential(
293 environmentId: EnvironmentId,
294 credential: SavedEnvironmentCredential,
295): Promise<boolean> {
296 return ensureLocalApi().persistence.setSavedEnvironmentSecret(
297 environmentId,
298 encodeSavedEnvironmentCredentialJson(credential),
299 );
300}
301
253export async function writeSavedEnvironmentBearerToken(302export async function writeSavedEnvironmentBearerToken(
254 environmentId: EnvironmentId,303 environmentId: EnvironmentId,
255 bearerToken: string,304 bearerToken: string,
apps/web/src/environments/runtime/index.ts+1−0
@@ -16,6 +16,7 @@ export { · 15 unmodified lines
16 16
17export {17export {
18 addSavedEnvironment,18 addSavedEnvironment,
19 addManagedRelayEnvironment,
19 connectDesktopSshEnvironment,20 connectDesktopSshEnvironment,
20 disconnectSavedEnvironment,21 disconnectSavedEnvironment,
21 ensureEnvironmentConnectionBootstrapped,22 ensureEnvironmentConnectionBootstrapped,
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+159−19
@@ -1,4 +1,5 @@
1import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";1import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4 5
@@ -10,17 +11,33 @@ const mockResolveRemotePairingTarget = vi.fn(); · 5 unmodified lines
10const mockFetchRemoteEnvironmentDescriptor = vi.fn();11const mockFetchRemoteEnvironmentDescriptor = vi.fn();
11const mockBootstrapRemoteBearerSession = vi.fn();12const mockBootstrapRemoteBearerSession = vi.fn();
12const mockFetchRemoteSessionState = vi.fn();13const mockFetchRemoteSessionState = vi.fn();
14const mockFetchRemoteDpopSessionState = vi.fn();
13const mockResolveRemoteWebSocketConnectionUrl = vi.fn();15const mockResolveRemoteWebSocketConnectionUrl = vi.fn();
14const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);16let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner;
17const mockRemoteHttpRunPromise = vi.fn(<A, E>(effect: Effect.Effect<A, E>) =>
18 Effect.runPromise(
19 effect.pipe(
20 Effect.provideService(
21 managedRelayDpopSigner,
22 managedRelayDpopSigner.of({
23 thumbprint: Effect.succeed("thumbprint"),
24 createProof: () => Effect.succeed("dpop-proof"),
25 }),
26 ),
27 ),
28 ),
29);
15const mockBootstrapSshBearerSession = vi.fn();30const mockBootstrapSshBearerSession = vi.fn();
16const mockFetchSshSessionState = vi.fn();31const mockFetchSshSessionState = vi.fn();
17const mockPersistSavedEnvironmentRecord = vi.fn();32const mockPersistSavedEnvironmentRecord = vi.fn();
18const mockWriteSavedEnvironmentBearerToken = vi.fn();33const mockWriteSavedEnvironmentBearerToken = vi.fn();
34const mockWriteSavedEnvironmentCredential = vi.fn();
19const mockSetSavedEnvironmentRegistry = vi.fn();35const mockSetSavedEnvironmentRegistry = vi.fn();
20const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => {36const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => {
21 return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null;37 return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null;
22});38});
23const mockReadSavedEnvironmentBearerToken = vi.fn();39const mockReadSavedEnvironmentBearerToken = vi.fn();
40const mockReadSavedEnvironmentCredential = vi.fn();
24const mockRemoveSavedEnvironmentBearerToken = vi.fn();41const mockRemoveSavedEnvironmentBearerToken = vi.fn();
25const mockPatchRuntime = vi.fn();42const mockPatchRuntime = vi.fn();
26const mockClearRuntime = vi.fn();43const mockClearRuntime = vi.fn();
@@ -58,6 +75,8 @@ const mockClientGetConfig = vi.fn(async () => ({ · 31 unmodified lines
58 label: "Remote environment",75 label: "Remote environment",
59 },76 },
60}));77}));
78const mockConnectManagedCloudEnvironment = vi.fn();
79const mockReadManagedRelayClerkToken = vi.fn();
61 80
62vi.mock("@t3tools/shared/remote", async (importOriginal) => ({81vi.mock("@t3tools/shared/remote", async (importOriginal) => ({
63 ...(await importOriginal<typeof import("@t3tools/shared/remote")>()),82 ...(await importOriginal<typeof import("@t3tools/shared/remote")>()),
@@ -65,11 +84,19 @@ vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ · 1 unmodified line
65}));84}));
66 85
67vi.mock("../../lib/runtime", () => ({86vi.mock("../../lib/runtime", () => ({
68 remoteHttpRuntime: {87 webRuntime: {
69 runPromise: mockRemoteHttpRunPromise,88 runPromise: mockRemoteHttpRunPromise,
70 },89 },
71}));90}));
72 91
92vi.mock("../../cloud/linkEnvironment", () => ({
93 connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment,
94}));
95
96vi.mock("../../cloud/managedAuth", () => ({
97 readManagedRelayClerkToken: mockReadManagedRelayClerkToken,
98}));
99
73vi.mock("~/localApi", () => ({100vi.mock("~/localApi", () => ({
74 ensureLocalApi: () => ({101 ensureLocalApi: () => ({
75 persistence: {102 persistence: {
@@ -84,6 +111,7 @@ vi.mock("./catalog", () => ({ · 8 unmodified lines
84 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,111 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
85 persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord,112 persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord,
86 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,113 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
114 readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential,
87 removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken,115 removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken,
88 toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord,116 toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord,
89 useSavedEnvironmentRegistryStore: {117 useSavedEnvironmentRegistryStore: {
@@ -105,6 +133,7 @@ vi.mock("./catalog", () => ({ · 15 unmodified lines
105 },133 },
106 waitForSavedEnvironmentRegistryHydration: vi.fn(),134 waitForSavedEnvironmentRegistryHydration: vi.fn(),
107 writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken,135 writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken,
136 writeSavedEnvironmentCredential: mockWriteSavedEnvironmentCredential,
108}));137}));
109 138
110vi.mock("./connection", async (importOriginal) => ({139vi.mock("./connection", async (importOriginal) => ({
@@ -114,6 +143,7 @@ vi.mock("./connection", async (importOriginal) => ({ · 3 unmodified lines
114 143
115vi.mock("@t3tools/client-runtime", async (importOriginal) => {144vi.mock("@t3tools/client-runtime", async (importOriginal) => {
116 const actual = await importOriginal<typeof import("@t3tools/client-runtime")>();145 const actual = await importOriginal<typeof import("@t3tools/client-runtime")>();
146 managedRelayDpopSigner = actual.ManagedRelayDpopSigner;
117 return {147 return {
118 ...actual,148 ...actual,
119 bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession,149 bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession,
@@ -130,6 +160,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { · 10 unmodified lines
130 })),160 })),
131 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,161 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
132 fetchRemoteSessionState: mockFetchRemoteSessionState,162 fetchRemoteSessionState: mockFetchRemoteSessionState,
163 fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState,
133 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,164 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
134 };165 };
135});166});
@@ -172,20 +203,36 @@ describe("addSavedEnvironment", () => { · 36 unmodified lines
172 credential: input.pairingCode ?? "pairing-code",203 credential: input.pairingCode ?? "pairing-code",
173 }),204 }),
174 );205 );
175 mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({206 mockReadSavedEnvironmentCredential.mockImplementation(async () => {
176 environmentId: EnvironmentId.make("environment-1"),207 const token = await mockReadSavedEnvironmentBearerToken();
177 label: "Remote environment",208 return token ? { version: 1, method: "bearer", token } : null;
178 });
179 mockBootstrapRemoteBearerSession.mockResolvedValue({
180 access_token: "bearer-token",
181 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
182 });209 });
183 mockFetchRemoteSessionState.mockResolvedValue({210 mockFetchRemoteEnvironmentDescriptor.mockReturnValue(
184 authenticated: true,211 Effect.succeed({
185 scopes: ["orchestration:read", "access:write"],212 environmentId: EnvironmentId.make("environment-1"),
186 });213 label: "Remote environment",
187 mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue(214 }),
188 "wss://remote.example.com/?wsTicket=remote-token",215 );
216 mockBootstrapRemoteBearerSession.mockReturnValue(
217 Effect.succeed({
218 access_token: "bearer-token",
219 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
220 }),
221 );
222 mockFetchRemoteSessionState.mockReturnValue(
223 Effect.succeed({
224 authenticated: true,
225 scopes: ["orchestration:read", "access:write"],
226 }),
227 );
228 mockFetchRemoteDpopSessionState.mockReturnValue(
229 Effect.succeed({
230 authenticated: true,
231 scopes: ["orchestration:read", "access:write"],
232 }),
233 );
234 mockResolveRemoteWebSocketConnectionUrl.mockReturnValue(
235 Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"),
189 );236 );
190 mockFetchSshEnvironmentDescriptor.mockResolvedValue({237 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
191 environmentId: EnvironmentId.make("environment-1"),238 environmentId: EnvironmentId.make("environment-1"),
@@ -197,6 +244,8 @@ describe("addSavedEnvironment", () => { · 5 unmodified lines
197 });244 });
198 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);245 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);
199 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);246 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);
247 mockWriteSavedEnvironmentCredential.mockResolvedValue(true);
248 mockReadManagedRelayClerkToken.mockResolvedValue(null);
200 mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined);249 mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined);
201 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);250 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
202 mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined);251 mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined);
@@ -323,6 +372,94 @@ describe("addSavedEnvironment", () => { · 120 unmodified lines
323 await resetEnvironmentServiceForTests();372 await resetEnvironmentServiceForTests();
324 });373 });
325 374
375 it("installs relay-managed environments with versioned DPoP credentials", async () => {
376 const { addManagedRelayEnvironment, resetEnvironmentServiceForTests } =
377 await import("./service");
378
379 await addManagedRelayEnvironment({
380 environmentId: EnvironmentId.make("environment-1"),
381 label: "Managed remote",
382 httpBaseUrl: "https://managed.example.com/",
383 wsBaseUrl: "wss://managed.example.com/",
384 relayUrl: "https://relay.example.com",
385 accessToken: "managed-access-token",
386 });
387
388 expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(
389 EnvironmentId.make("environment-1"),
390 {
391 version: 1,
392 method: "dpop",
393 accessToken: "managed-access-token",
394 },
395 );
396 expect(mockFetchRemoteDpopSessionState).toHaveBeenCalledWith({
397 httpBaseUrl: "https://managed.example.com/",
398 accessToken: "managed-access-token",
399 dpopProof: "dpop-proof",
400 });
401 await resetEnvironmentServiceForTests();
402 });
403
404 it("renews expired managed DPoP credentials through the relay", async () => {
405 const environmentId = EnvironmentId.make("environment-1");
406 mockSavedRecords = [
407 {
408 environmentId,
409 label: "Managed remote",
410 httpBaseUrl: "https://managed.example.com/",
411 wsBaseUrl: "wss://managed.example.com/",
412 createdAt: "2026-05-25T00:00:00.000Z",
413 lastConnectedAt: null,
414 relayManaged: { relayUrl: "https://relay.example.com" },
415 },
416 ];
417 mockReadSavedEnvironmentCredential.mockResolvedValue({
418 version: 1,
419 method: "dpop",
420 accessToken: "expired-access-token",
421 });
422 mockFetchRemoteDpopSessionState
423 .mockReturnValueOnce(
424 Effect.fail(
425 decodeEnvironmentAuthInvalidError({
426 _tag: "EnvironmentAuthInvalidError",
427 code: "auth_invalid",
428 reason: "invalid_credential",
429 traceId: "trace-auth-expired",
430 }),
431 ),
432 )
433 .mockReturnValue(Effect.succeed({ authenticated: true, scopes: ["orchestration:read"] }));
434 mockReadManagedRelayClerkToken.mockResolvedValue("clerk-token");
435 mockConnectManagedCloudEnvironment.mockReturnValue(
436 Effect.succeed({
437 environmentId,
438 label: "Managed remote",
439 httpBaseUrl: "https://managed.example.com/",
440 wsBaseUrl: "wss://managed.example.com/",
441 relayUrl: "https://relay.example.com",
442 accessToken: "renewed-access-token",
443 }),
444 );
445
446 const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } =
447 await import("./service");
448 await reconnectSavedEnvironment(environmentId);
449
450 expect(mockConnectManagedCloudEnvironment).toHaveBeenCalledWith({
451 clerkToken: "clerk-token",
452 relayUrl: "https://relay.example.com",
453 environment: expect.objectContaining({ environmentId }),
454 });
455 expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(environmentId, {
456 version: 1,
457 method: "dpop",
458 accessToken: "renewed-access-token",
459 });
460 await resetEnvironmentServiceForTests();
461 });
462
326 it("removes an older ssh record when the same target returns a new environment id", async () => {463 it("removes an older ssh record when the same target returns a new environment id", async () => {
327 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);464 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
328 mockFetchSshEnvironmentDescriptor.mockResolvedValue({465 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
@@ -424,7 +561,7 @@ describe("addSavedEnvironment", () => { · 95 unmodified lines
424 reason: "invalid_credential",561 reason: "invalid_credential",
425 traceId: "trace-auth-test",562 traceId: "trace-auth-test",
426 });563 });
427 mockFetchRemoteSessionState.mockRejectedValueOnce(authError);564 mockFetchRemoteSessionState.mockReturnValueOnce(Effect.fail(authError));
428 565
429 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");566 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
430 567
@@ -747,9 +884,12 @@ describe("addSavedEnvironment", () => { · 316 unmodified lines
747 readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">;884 readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">;
748 }) => void;885 }) => void;
749 mockFetchRemoteSessionState.mockReturnValue(886 mockFetchRemoteSessionState.mockReturnValue(
750 new Promise((resolve) => {887 Effect.promise(
751 resolveSessionState = resolve;888 () =>
752 }),889 new Promise((resolve) => {
890 resolveSessionState = resolve;
891 }),
892 ),
753 );893 );
754 894
755 const {895 const {
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+8−1
@@ -11,6 +11,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); · 10 unmodified lines
11const mockListSavedEnvironmentRecords = vi.fn();11const mockListSavedEnvironmentRecords = vi.fn();
12const mockSavedEnvironmentRegistrySubscribe = vi.fn();12const mockSavedEnvironmentRegistrySubscribe = vi.fn();
13const mockReadSavedEnvironmentBearerToken = vi.fn();13const mockReadSavedEnvironmentBearerToken = vi.fn();
14const mockReadSavedEnvironmentCredential = vi.fn();
14const mockGetSavedEnvironmentRecord = vi.fn();15const mockGetSavedEnvironmentRecord = vi.fn();
15 16
16function MockWsTransport() {17function MockWsTransport() {
@@ -31,7 +32,7 @@ vi.mock("../primary", () => ({ · 14 unmodified lines
31}));32}));
32 33
33vi.mock("../../lib/runtime", () => ({34vi.mock("../../lib/runtime", () => ({
34 remoteHttpRuntime: {35 webRuntime: {
35 runPromise: mockRemoteHttpRunPromise,36 runPromise: mockRemoteHttpRunPromise,
36 },37 },
37}));38}));
@@ -42,6 +43,7 @@ vi.mock("./catalog", () => ({ · 4 unmodified lines
42 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,43 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
43 persistSavedEnvironmentRecord: vi.fn(),44 persistSavedEnvironmentRecord: vi.fn(),
44 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,45 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
46 readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential,
45 removeSavedEnvironmentBearerToken: vi.fn(),47 removeSavedEnvironmentBearerToken: vi.fn(),
46 useSavedEnvironmentRegistryStore: {48 useSavedEnvironmentRegistryStore: {
47 subscribe: mockSavedEnvironmentRegistrySubscribe,49 subscribe: mockSavedEnvironmentRegistrySubscribe,
@@ -61,6 +63,7 @@ vi.mock("./catalog", () => ({ · 13 unmodified lines
61 },63 },
62 waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,64 waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,
63 writeSavedEnvironmentBearerToken: vi.fn(),65 writeSavedEnvironmentBearerToken: vi.fn(),
66 writeSavedEnvironmentCredential: vi.fn(),
64}));67}));
65 68
66vi.mock("./connection", async (importOriginal) => ({69vi.mock("./connection", async (importOriginal) => ({
@@ -231,6 +234,10 @@ describe("saved environment startup", () => { · 164 unmodified lines
231 mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined);234 mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined);
232 mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined);235 mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined);
233 mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token");236 mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token");
237 mockReadSavedEnvironmentCredential.mockImplementation(async () => {
238 const token = await mockReadSavedEnvironmentBearerToken();
239 return token ? { version: 1, method: "bearer", token } : null;
240 });
234 mockCreateWsRpcClient.mockImplementation(() => createClient());241 mockCreateWsRpcClient.mockImplementation(() => createClient());
235 mockCreateEnvironmentConnection.mockImplementation((input) => {242 mockCreateEnvironmentConnection.mockImplementation((input) => {
236 if (input.kind === "saved") {243 if (input.kind === "saved") {
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+8−1
@@ -18,6 +18,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); · 17 unmodified lines
18const mockListSavedEnvironmentRecords = vi.fn();18const mockListSavedEnvironmentRecords = vi.fn();
19const mockGetSavedEnvironmentRecord = vi.fn();19const mockGetSavedEnvironmentRecord = vi.fn();
20const mockReadSavedEnvironmentBearerToken = vi.fn();20const mockReadSavedEnvironmentBearerToken = vi.fn();
21const mockReadSavedEnvironmentCredential = vi.fn();
21const mockSavedEnvironmentRegistrySubscribe = vi.fn();22const mockSavedEnvironmentRegistrySubscribe = vi.fn();
22const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn());23const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn());
23const mockFetchRemoteSessionState = vi.fn();24const mockFetchRemoteSessionState = vi.fn();
@@ -35,7 +36,7 @@ vi.mock("../primary", () => ({ · 11 unmodified lines
35}));36}));
36 37
37vi.mock("../../lib/runtime", () => ({38vi.mock("../../lib/runtime", () => ({
38 remoteHttpRuntime: {39 webRuntime: {
39 runPromise: mockRemoteHttpRunPromise,40 runPromise: mockRemoteHttpRunPromise,
40 },41 },
41}));42}));
@@ -46,6 +47,7 @@ vi.mock("./catalog", () => ({ · 4 unmodified lines
46 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,47 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
47 persistSavedEnvironmentRecord: vi.fn(),48 persistSavedEnvironmentRecord: vi.fn(),
48 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,49 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
50 readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential,
49 removeSavedEnvironmentBearerToken: vi.fn(),51 removeSavedEnvironmentBearerToken: vi.fn(),
50 useSavedEnvironmentRegistryStore: {52 useSavedEnvironmentRegistryStore: {
51 subscribe: mockSavedEnvironmentRegistrySubscribe,53 subscribe: mockSavedEnvironmentRegistrySubscribe,
@@ -65,6 +67,7 @@ vi.mock("./catalog", () => ({ · 13 unmodified lines
65 },67 },
66 waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,68 waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,
67 writeSavedEnvironmentBearerToken: vi.fn(),69 writeSavedEnvironmentBearerToken: vi.fn(),
70 writeSavedEnvironmentCredential: vi.fn(),
68}));71}));
69 72
70vi.mock("./connection", async (importOriginal) => ({73vi.mock("./connection", async (importOriginal) => ({
@@ -301,6 +304,10 @@ describe("retainThreadDetailSubscription", () => { · 230 unmodified lines
301 mockListSavedEnvironmentRecords.mockReturnValue([]);304 mockListSavedEnvironmentRecords.mockReturnValue([]);
302 mockGetSavedEnvironmentRecord.mockReturnValue(null);305 mockGetSavedEnvironmentRecord.mockReturnValue(null);
303 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);306 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
307 mockReadSavedEnvironmentCredential.mockImplementation(async () => {
308 const token = await mockReadSavedEnvironmentBearerToken();
309 return token ? { version: 1, method: "bearer", token } : null;
310 });
304 mockFetchRemoteSessionState.mockResolvedValue({311 mockFetchRemoteSessionState.mockResolvedValue({
305 authenticated: true,312 authenticated: true,
306 scopes: ["orchestration:read"],313 scopes: ["orchestration:read"],
apps/web/src/environments/runtime/service.ts+231−42
@@ -1,6 +1,5 @@
1import {1import {
2 AuthEnvironmentScope,2 AuthEnvironmentScope,
3 AuthStandardClientScopes,
4 type DesktopSshEnvironmentBootstrap,3 type DesktopSshEnvironmentBootstrap,
5 type DesktopSshEnvironmentTarget,4 type DesktopSshEnvironmentTarget,
6 type EnvironmentId,5 type EnvironmentId,
@@ -16,12 +15,17 @@ import { · 9 unmodified lines
16 type WsRpcClient,15 type WsRpcClient,
17 bootstrapRemoteBearerSession,16 bootstrapRemoteBearerSession,
18 fetchRemoteEnvironmentDescriptor,17 fetchRemoteEnvironmentDescriptor,
18 fetchRemoteDpopSessionState,
19 fetchRemoteSessionState,19 fetchRemoteSessionState,
20 type ManagedRelayDpopProofInput,
21 ManagedRelayDpopSigner,
22 resolveRemoteDpopWebSocketConnectionUrl,
20 resolveRemoteWebSocketConnectionUrl,23 resolveRemoteWebSocketConnectionUrl,
21} from "@t3tools/client-runtime";24} from "@t3tools/client-runtime";
22 25
23import { type QueryClient } from "@tanstack/react-query";26import { type QueryClient } from "@tanstack/react-query";
24import { Throttler } from "@tanstack/react-pacer";27import { Throttler } from "@tanstack/react-pacer";
28import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";29import * as Schema from "effect/Schema";
26import {30import {
27 createKnownEnvironment,31 createKnownEnvironment,
@@ -40,21 +44,25 @@ import { ensureLocalApi } from "~/localApi"; · 12 unmodified lines
40import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";44import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";
41import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";45import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
42import { getPrimaryKnownEnvironment } from "../primary";46import { getPrimaryKnownEnvironment } from "../primary";
43import { remoteHttpRuntime } from "../../lib/runtime";47import { webRuntime } from "../../lib/runtime";
48import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment";
49import { readManagedRelayClerkToken } from "../../cloud/managedAuth";
44 50
45import {51import {
46 getSavedEnvironmentRecord,52 getSavedEnvironmentRecord,
47 hasSavedEnvironmentRegistryHydrated,53 hasSavedEnvironmentRegistryHydrated,
48 listSavedEnvironmentRecords,54 listSavedEnvironmentRecords,
49 persistSavedEnvironmentRecord,55 persistSavedEnvironmentRecord,
50 readSavedEnvironmentBearerToken,56 readSavedEnvironmentCredential,
51 removeSavedEnvironmentBearerToken,57 removeSavedEnvironmentBearerToken,
52 type SavedEnvironmentRecord,58 type SavedEnvironmentRecord,
59 type SavedEnvironmentCredential,
53 toPersistedSavedEnvironmentRecord,60 toPersistedSavedEnvironmentRecord,
54 useSavedEnvironmentRegistryStore,61 useSavedEnvironmentRegistryStore,
55 useSavedEnvironmentRuntimeStore,62 useSavedEnvironmentRuntimeStore,
56 waitForSavedEnvironmentRegistryHydration,63 waitForSavedEnvironmentRegistryHydration,
57 writeSavedEnvironmentBearerToken,64 writeSavedEnvironmentBearerToken,
65 writeSavedEnvironmentCredential,
58} from "./catalog";66} from "./catalog";
59import {67import {
60 createEnvironmentConnection,68 createEnvironmentConnection,
@@ -159,6 +167,12 @@ const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; · 98 unmodified lines
159const NOOP = () => undefined;167const NOOP = () => undefined;
160const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;168const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;
161 169
170const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) =>
171 Effect.gen(function* () {
172 const signer = yield* ManagedRelayDpopSigner;
173 return yield* signer.createProof(input);
174 });
175
162function createDeferredPromise<T>() {176function createDeferredPromise<T>() {
163 let resolve: ((value: T) => void) | null = null;177 let resolve: ((value: T) => void) | null = null;
164 const promise = new Promise<T>((nextResolve) => {178 const promise = new Promise<T>((nextResolve) => {
@@ -1156,7 +1170,7 @@ function createPrimaryEnvironmentClient( · 991 unmodified lines
1156 1170
1157function createSavedEnvironmentClient(1171function createSavedEnvironmentClient(
1158 environmentId: EnvironmentId,1172 environmentId: EnvironmentId,
1159 bearerToken: string,1173 credentialRef: { current: SavedEnvironmentCredential },
1160): WsRpcClient {1174): WsRpcClient {
1161 useSavedEnvironmentRuntimeStore.getState().ensure(environmentId);1175 useSavedEnvironmentRuntimeStore.getState().ensure(environmentId);
1162 1176
@@ -1167,19 +1181,70 @@ function createSavedEnvironmentClient( · 4 unmodified lines
1167 if (!record) {1181 if (!record) {
1168 throw new Error(`Saved environment ${environmentId} not found.`);1182 throw new Error(`Saved environment ${environmentId} not found.`);
1169 }1183 }
1170 return record.desktopSsh1184 const credential = credentialRef.current;
1171 ? await resolveDesktopSshWebSocketConnectionUrl(1185 if (record.desktopSsh) {
1172 record.wsBaseUrl,1186 if (credential.method !== "bearer") {
1173 record.httpBaseUrl,1187 throw new Error("SSH environments require bearer credentials.");
1174 bearerToken,1188 }
1175 )1189 return await resolveDesktopSshWebSocketConnectionUrl(
1176 : await remoteHttpRuntime.runPromise(1190 record.wsBaseUrl,
1177 resolveRemoteWebSocketConnectionUrl({1191 record.httpBaseUrl,
1178 wsBaseUrl: record.wsBaseUrl,1192 credential.token,
1179 httpBaseUrl: record.httpBaseUrl,1193 );
1180 bearerToken,1194 }
1181 }),1195 if (credential.method === "dpop") {
1196 try {
1197 return await webRuntime.runPromise(
1198 createManagedRelayDpopProof({
1199 method: "POST",
1200 url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(),
1201 accessToken: credential.accessToken,
1202 }).pipe(
1203 Effect.flatMap((proof) =>
1204 resolveRemoteDpopWebSocketConnectionUrl({
1205 wsBaseUrl: record.wsBaseUrl,
1206 httpBaseUrl: record.httpBaseUrl,
1207 accessToken: credential.accessToken,
1208 dpopProof: proof,
1209 }),
1210 ),
1211 ),
1212 );
1213 } catch (error) {
1214 if (!isEnvironmentAuthInvalidError(error)) {
1215 throw error;
1216 }
1217 const renewed = await renewManagedRelayCredential(record);
1218 if (!renewed || renewed.credential.method !== "dpop") {
1219 throw error;
1220 }
1221 const renewedCredential = renewed.credential;
1222 credentialRef.current = renewedCredential;
1223 return await webRuntime.runPromise(
1224 createManagedRelayDpopProof({
1225 method: "POST",
1226 url: new URL("/api/auth/websocket-ticket", renewed.record.httpBaseUrl).toString(),
1227 accessToken: renewedCredential.accessToken,
1228 }).pipe(
1229 Effect.flatMap((proof) =>
1230 resolveRemoteDpopWebSocketConnectionUrl({
1231 wsBaseUrl: renewed.record.wsBaseUrl,
1232 httpBaseUrl: renewed.record.httpBaseUrl,
1233 accessToken: renewedCredential.accessToken,
1234 dpopProof: proof,
1235 }),
1236 ),
1237 ),
1182 );1238 );
1239 }
1240 }
1241 return await webRuntime.runPromise(
1242 resolveRemoteWebSocketConnectionUrl({
1243 wsBaseUrl: record.wsBaseUrl,
1244 httpBaseUrl: record.httpBaseUrl,
1245 bearerToken: credential.token,
1246 }),
1247 );
1183 },1248 },
1184 {1249 {
1185 getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null,1250 getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null,
@@ -1221,7 +1286,7 @@ function createSavedEnvironmentClient( · 35 unmodified lines
1221 1286
1222async function refreshSavedEnvironmentMetadata(1287async function refreshSavedEnvironmentMetadata(
1223 environmentId: EnvironmentId,1288 environmentId: EnvironmentId,
1224 bearerToken: string,1289 credential: SavedEnvironmentCredential,
1225 client: WsRpcClient,1290 client: WsRpcClient,
1226 scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null,1291 scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null,
1227 configHint?: ServerConfig | null,1292 configHint?: ServerConfig | null,
@@ -1234,13 +1299,31 @@ async function refreshSavedEnvironmentMetadata( · 6 unmodified lines
1234 const [serverConfig, sessionState] = await Promise.all([1299 const [serverConfig, sessionState] = await Promise.all([
1235 configHint ? Promise.resolve(configHint) : client.server.getConfig(),1300 configHint ? Promise.resolve(configHint) : client.server.getConfig(),
1236 record.desktopSsh1301 record.desktopSsh
1237 ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken)1302 ? credential.method === "bearer"
1238 : remoteHttpRuntime.runPromise(1303 ? fetchDesktopSshSessionState(record.httpBaseUrl, credential.token)
1239 fetchRemoteSessionState({1304 : Promise.reject(new Error("SSH environments require bearer credentials."))
1240 httpBaseUrl: record.httpBaseUrl,1305 : credential.method === "dpop"
1241 bearerToken,1306 ? webRuntime.runPromise(
1242 }),1307 createManagedRelayDpopProof({
1243 ),1308 method: "GET",
1309 url: new URL("/api/auth/session", record.httpBaseUrl).toString(),
1310 accessToken: credential.accessToken,
1311 }).pipe(
1312 Effect.flatMap((proof) =>
1313 fetchRemoteDpopSessionState({
1314 httpBaseUrl: record.httpBaseUrl,
1315 accessToken: credential.accessToken,
1316 dpopProof: proof,
1317 }),
1318 ),
1319 ),
1320 )
1321 : webRuntime.runPromise(
1322 fetchRemoteSessionState({
1323 httpBaseUrl: record.httpBaseUrl,
1324 bearerToken: credential.token,
1325 }),
1326 ),
1244 ]);1327 ]);
1245 1328
1246 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {1329 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
@@ -1254,6 +1337,52 @@ async function refreshSavedEnvironmentMetadata( · 7 unmodified lines
1254 .rename(record.environmentId, serverConfig.environment.label);1337 .rename(record.environmentId, serverConfig.environment.label);
1255}1338}
1256 1339
1340async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{
1341 readonly record: SavedEnvironmentRecord;
1342 readonly credential: SavedEnvironmentCredential;
1343} | null> {
1344 if (!record.relayManaged) {
1345 return null;
1346 }
1347 const clerkToken = await readManagedRelayClerkToken();
1348 if (!clerkToken) {
1349 return null;
1350 }
1351 const connected = await webRuntime.runPromise(
1352 connectManagedCloudEnvironment({
1353 clerkToken,
1354 relayUrl: record.relayManaged.relayUrl,
1355 environment: {
1356 environmentId: record.environmentId,
1357 label: record.label,
1358 linkedAt: record.createdAt,
1359 endpoint: {
1360 httpBaseUrl: record.httpBaseUrl,
1361 wsBaseUrl: record.wsBaseUrl,
1362 providerKind: "cloudflare_tunnel",
1363 },
1364 },
1365 }),
1366 );
1367 const nextRecord: SavedEnvironmentRecord = {
1368 ...record,
1369 label: connected.label,
1370 httpBaseUrl: connected.httpBaseUrl,
1371 wsBaseUrl: connected.wsBaseUrl,
1372 };
1373 const credential: SavedEnvironmentCredential = {
1374 version: 1,
1375 method: "dpop",
1376 accessToken: connected.accessToken,
1377 };
1378 await persistSavedEnvironmentRecord(nextRecord);
1379 if (!(await writeSavedEnvironmentCredential(nextRecord.environmentId, credential))) {
1380 throw new Error("Unable to persist refreshed managed environment credentials.");
1381 }
1382 useSavedEnvironmentRegistryStore.getState().upsert(nextRecord);
1383 return { record: nextRecord, credential };
1384}
1385
1257function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {1386function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {
1258 const existing = environmentConnections.get(connection.environmentId);1387 const existing = environmentConnections.get(connection.environmentId);
1259 if (existing && existing !== connection) {1388 if (existing && existing !== connection) {
@@ -1320,8 +1449,10 @@ async function ensureSavedEnvironmentConnection( · 60 unmodified lines
1320 options?: {1449 options?: {
1321 readonly client?: WsRpcClient;1450 readonly client?: WsRpcClient;
1322 readonly bearerToken?: string;1451 readonly bearerToken?: string;
1452 readonly credential?: SavedEnvironmentCredential;
1323 readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null;1453 readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null;
1324 readonly serverConfig?: ServerConfig | null;1454 readonly serverConfig?: ServerConfig | null;
1455 readonly allowManagedRenewal?: boolean;
1325 },1456 },
1326): Promise<EnvironmentConnection> {1457): Promise<EnvironmentConnection> {
1327 const existing = environmentConnections.get(record.environmentId);1458 const existing = environmentConnections.get(record.environmentId);
@@ -1340,14 +1471,17 @@ async function ensureSavedEnvironmentConnection( · 12 unmodified lines
1340 promise: Promise.resolve().then(async () => {1471 promise: Promise.resolve().then(async () => {
1341 let activeRecord = record;1472 let activeRecord = record;
1342 let scopeHint = options?.scopes ?? null;1473 let scopeHint = options?.scopes ?? null;
1343 let bearerToken =1474 let credential =
1344 options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));1475 options?.credential ??
1345 if (!bearerToken) {1476 (options?.bearerToken
1477 ? ({ version: 1, method: "bearer", token: options.bearerToken } as const)
1478 : await readSavedEnvironmentCredential(record.environmentId));
1479 if (!credential) {
1346 if (record.desktopSsh) {1480 if (record.desktopSsh) {
1347 const issued = await issueDesktopSshBearerSession(record);1481 const issued = await issueDesktopSshBearerSession(record);
1348 activeRecord = issued.record;1482 activeRecord = issued.record;
1349 bearerToken = issued.bearerToken;1483 credential = { version: 1, method: "bearer", token: issued.bearerToken };
1350 scopeHint = [...AuthStandardClientScopes];1484 scopeHint = issued.scopes;
1351 } else {1485 } else {
1352 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {1486 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
1353 authState: "requires-auth",1487 authState: "requires-auth",
@@ -1363,10 +1497,10 @@ async function ensureSavedEnvironmentConnection( · 9 unmodified lines
1363 activeRecord = prepared.record;1497 activeRecord = prepared.record;
1364 }1498 }
1365 1499
1366 const activeBearerToken = bearerToken;1500 const activeCredential = { current: credential };
1367 const client =1501 const client =
1368 options?.client ??1502 options?.client ??
1369 createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken);1503 createSavedEnvironmentClient(activeRecord.environmentId, activeCredential);
1370 const initialConfigSnapshot = createDeferredPromise<ServerConfig>();1504 const initialConfigSnapshot = createDeferredPromise<ServerConfig>();
1371 const knownEnvironment = createKnownEnvironment({1505 const knownEnvironment = createKnownEnvironment({
1372 id: activeRecord.environmentId,1506 id: activeRecord.environmentId,
@@ -1387,7 +1521,7 @@ async function ensureSavedEnvironmentConnection( · 14 unmodified lines
1387 refreshMetadata: async () => {1521 refreshMetadata: async () => {
1388 await refreshSavedEnvironmentMetadata(1522 await refreshSavedEnvironmentMetadata(
1389 activeRecord.environmentId,1523 activeRecord.environmentId,
1390 activeBearerToken,1524 activeCredential.current,
1391 client,1525 client,
1392 );1526 );
1393 },1527 },
@@ -1416,7 +1550,7 @@ async function ensureSavedEnvironmentConnection( · 22 unmodified lines
1416 ));1550 ));
1417 await refreshSavedEnvironmentMetadata(1551 await refreshSavedEnvironmentMetadata(
1418 activeRecord.environmentId,1552 activeRecord.environmentId,
1419 activeBearerToken,1553 activeCredential.current,
1420 client,1554 client,
1421 scopeHint,1555 scopeHint,
1422 initialServerConfig,1556 initialServerConfig,
@@ -1429,20 +1563,41 @@ async function ensureSavedEnvironmentConnection( · 6 unmodified lines
1429 throw error;1563 throw error;
1430 }1564 }
1431 if (!activeRecord.desktopSsh) {1565 if (!activeRecord.desktopSsh) {
1566 if (
1567 activeCredential.current.method === "dpop" &&
1568 options?.allowManagedRenewal !== false
1569 ) {
1570 const renewed = await renewManagedRelayCredential(activeRecord);
1571 if (renewed) {
1572 await connection.dispose().catch(() => undefined);
1573 pendingSavedEnvironmentConnections.delete(activeRecord.environmentId);
1574 return await ensureSavedEnvironmentConnection(renewed.record, {
1575 credential: renewed.credential,
1576 scopes: scopeHint,
1577 serverConfig: options?.serverConfig ?? null,
1578 allowManagedRenewal: false,
1579 });
1580 }
1581 }
1432 await removeSavedEnvironmentBearerToken(activeRecord.environmentId);1582 await removeSavedEnvironmentBearerToken(activeRecord.environmentId);
1433 throw new Error("Saved environment credential expired. Pair it again.", {1583 throw new Error(
1434 cause: error,1584 activeCredential.current.method === "dpop"
1435 });1585 ? "Managed tunnel credential expired. Connect it again from T3 Cloud."
1586 : "Saved environment credential expired. Pair it again.",
1587 {
1588 cause: error,
1589 },
1590 );
1436 }1591 }
1437 1592
1438 const issued = await issueDesktopSshBearerSession(activeRecord);1593 const issued = await issueDesktopSshBearerSession(activeRecord);
1439 activeRecord = issued.record;1594 activeRecord = issued.record;
1440 bearerToken = issued.bearerToken;1595 credential = { version: 1, method: "bearer", token: issued.bearerToken };
1441 scopeHint = [...AuthStandardClientScopes];1596 scopeHint = issued.scopes;
1442 await connection.dispose().catch(() => undefined);1597 await connection.dispose().catch(() => undefined);
1443 pendingSavedEnvironmentConnections.delete(activeRecord.environmentId);1598 pendingSavedEnvironmentConnections.delete(activeRecord.environmentId);
1444 return await ensureSavedEnvironmentConnection(activeRecord, {1599 return await ensureSavedEnvironmentConnection(activeRecord, {
1445 bearerToken,1600 credential,
1446 scopes: scopeHint,1601 scopes: scopeHint,
1447 serverConfig: options?.serverConfig ?? null,1602 serverConfig: options?.serverConfig ?? null,
1448 });1603 });
@@ -1647,7 +1802,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P · 198 unmodified lines
1647 await removeConnection(environmentId).catch(() => false);1802 await removeConnection(environmentId).catch(() => false);
1648 await ensureSavedEnvironmentConnection(issued.record, {1803 await ensureSavedEnvironmentConnection(issued.record, {
1649 bearerToken: issued.bearerToken,1804 bearerToken: issued.bearerToken,
1650 scopes: [...AuthStandardClientScopes],1805 scopes: issued.scopes,
1651 });1806 });
1652 return;1807 return;
1653 } catch (recoveryError) {1808 } catch (recoveryError) {
@@ -1686,7 +1841,7 @@ export async function addSavedEnvironment(input: { · 32 unmodified lines
1686 });1841 });
1687 const descriptor = input.desktopSsh1842 const descriptor = input.desktopSsh
1688 ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl)1843 ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl)
1689 : await remoteHttpRuntime.runPromise(1844 : await webRuntime.runPromise(
1690 fetchRemoteEnvironmentDescriptor({1845 fetchRemoteEnvironmentDescriptor({
1691 httpBaseUrl: resolvedTarget.httpBaseUrl,1846 httpBaseUrl: resolvedTarget.httpBaseUrl,
1692 }),1847 }),
@@ -1701,7 +1856,7 @@ export async function addSavedEnvironment(input: { · 8 unmodified lines
1701 1856
1702 const bearerSession = input.desktopSsh1857 const bearerSession = input.desktopSsh
1703 ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential)1858 ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential)
1704 : await remoteHttpRuntime.runPromise(1859 : await webRuntime.runPromise(
1705 bootstrapRemoteBearerSession({1860 bootstrapRemoteBearerSession({
1706 httpBaseUrl: resolvedTarget.httpBaseUrl,1861 httpBaseUrl: resolvedTarget.httpBaseUrl,
1707 credential: resolvedTarget.credential,1862 credential: resolvedTarget.credential,
@@ -1741,6 +1896,40 @@ export async function addSavedEnvironment(input: { · 33 unmodified lines
1741 return record;1896 return record;
1742}1897}
1743 1898
1899export async function addManagedRelayEnvironment(input: {
1900 readonly environmentId: EnvironmentId;
1901 readonly label: string;
1902 readonly httpBaseUrl: string;
1903 readonly wsBaseUrl: string;
1904 readonly relayUrl: string;
1905 readonly accessToken: string;
1906}): Promise<SavedEnvironmentRecord> {
1907 const existingRecord = getSavedEnvironmentRecord(input.environmentId);
1908 const record: SavedEnvironmentRecord = {
1909 environmentId: input.environmentId,
1910 label: input.label.trim() || existingRecord?.label || "Managed environment",
1911 httpBaseUrl: input.httpBaseUrl,
1912 wsBaseUrl: input.wsBaseUrl,
1913 createdAt: existingRecord?.createdAt ?? isoNow(),
1914 lastConnectedAt: isoNow(),
1915 relayManaged: { relayUrl: input.relayUrl },
1916 };
1917 const credential: SavedEnvironmentCredential = {
1918 version: 1,
1919 method: "dpop",
1920 accessToken: input.accessToken,
1921 };
1922
1923 await persistSavedEnvironmentRecord(record);
1924 if (!(await writeSavedEnvironmentCredential(record.environmentId, credential))) {
1925 throw new Error("Unable to persist managed environment credentials.");
1926 }
1927 useSavedEnvironmentRegistryStore.getState().upsert(record);
1928 await removeConnection(record.environmentId).catch(() => false);
1929 await ensureSavedEnvironmentConnection(record, { credential });
1930 return record;
1931}
1932
1744export async function connectDesktopSshEnvironment(1933export async function connectDesktopSshEnvironment(
1745 target: DesktopSshEnvironmentTarget,1934 target: DesktopSshEnvironmentTarget,
1746 options?: { label?: string },1935 options?: { label?: string },
apps/web/src/hooks/useTheme.ts+1−1
@@ -109,7 +109,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { · 108 unmodified lines
109function syncDesktopTheme(theme: Theme) {109function syncDesktopTheme(theme: Theme) {
110 if (typeof window === "undefined") return;110 if (typeof window === "undefined") return;
111 const bridge = window.desktopBridge;111 const bridge = window.desktopBridge;
112 if (!bridge || lastDesktopTheme === theme) {112 if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) {
113 return;113 return;
114 }114 }
115 115
apps/web/src/hostedPairing.ts+1−1
@@ -10,7 +10,7 @@ export interface HostedPairingRequest { · 9 unmodified lines
10 10
11export type HostedAppChannel = "latest" | "nightly";11export type HostedAppChannel = "latest" | "nightly";
12 12
13function configuredHostedAppUrl(): string {13export function configuredHostedAppUrl(): string {
14 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;14 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;
15}15}
16 16
apps/web/src/lib/runtime.ts+21−1
@@ -9,7 +9,17 @@ import { · 8 unmodified lines
9 primaryEnvironmentHttpClientLive,9 primaryEnvironmentHttpClientLive,
10} from "../environments/primary/httpClient";10} from "../environments/primary/httpClient";
11 11
12export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch));12import { browserCryptoLayer } from "../cloud/dpop";
13import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer";
14
15function configuredRelayUrl(): string {
16 const value = (import.meta.env.VITE_T3_RELAY_URL as string | undefined)?.trim();
17 return value ? value.replace(/\/+$/g, "") : "http://relay.invalid";
18}
19
20const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch);
21
22export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer);
13 23
14const primaryHttpRuntime = ManagedRuntime.make(24const primaryHttpRuntime = ManagedRuntime.make(
15 primaryEnvironmentHttpClientLive.pipe(25 primaryEnvironmentHttpClientLive.pipe(
@@ -37,3 +47,13 @@ export const runPrimaryHttp = <A, E>(effect: Effect.Effect<A, E, PrimaryEnvironm · 21 unmodified lines
37export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void {47export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void {
38 primaryHttpRunner = runner ?? livePrimaryHttpRunner;48 primaryHttpRunner = runner ?? livePrimaryHttpRunner;
39}49}
50
51export const webRuntime = ManagedRuntime.make(
52 Layer.mergeAll(
53 webHttpClientLayer,
54 browserCryptoLayer,
55 webManagedRelayClientLayer(configuredRelayUrl()).pipe(
56 Layer.provide(Layer.mergeAll(webHttpClientLayer, browserCryptoLayer)),
57 ),
58 ),
59);
apps/web/src/localApi.test.ts+12−0
@@ -234,6 +234,18 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg · 233 unmodified lines
234 setTheme: async () => undefined,234 setTheme: async () => undefined,
235 showContextMenu: async () => null,235 showContextMenu: async () => null,
236 openExternal: async () => true,236 openExternal: async () => true,
237 createCloudAuthRequest: async () => "t3code-dev://auth/callback?t3_state=test",
238 getCloudAuthToken: async () => null,
239 setCloudAuthToken: async () => true,
240 clearCloudAuthToken: async () => undefined,
241 fetchCloudAuth: async () => ({
242 ok: true,
243 status: 200,
244 statusText: "OK",
245 headers: {},
246 body: "",
247 }),
248 onCloudAuthCallback: () => () => undefined,
237 onMenuAction: () => () => undefined,249 onMenuAction: () => () => undefined,
238 getUpdateState: async () => {250 getUpdateState: async () => {
239 throw new Error("getUpdateState not implemented in test");251 throw new Error("getUpdateState not implemented in test");
apps/web/src/main.tsx+21−1
@@ -1,5 +1,6 @@
1import React from "react";1import React from "react";
2import ReactDOM from "react-dom/client";2import ReactDOM from "react-dom/client";
3import { ClerkProvider } from "@clerk/react";
3import { RouterProvider } from "@tanstack/react-router";4import { RouterProvider } from "@tanstack/react-router";
4import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";5import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
5 6
@@ -7,6 +8,8 @@ import "@xterm/xterm/css/xterm.css"; · 1 unmodified line
7import "./index.css";8import "./index.css";
8 9
9import { isElectron } from "./env";10import { isElectron } from "./env";
11import { DesktopClerkProvider } from "./cloud/desktopClerk";
12import { ManagedRelayAuthProvider } from "./cloud/managedAuth";
10import { getRouter } from "./router";13import { getRouter } from "./router";
11import { APP_DISPLAY_NAME } from "./branding";14import { APP_DISPLAY_NAME } from "./branding";
12import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";15import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
@@ -22,8 +25,25 @@ if (isElectron) { · 9 unmodified lines
22 25
23document.title = APP_DISPLAY_NAME;26document.title = APP_DISPLAY_NAME;
24 27
28const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
29const cloudWaitlistUrl = "/settings/cloud";
30
31const app = <RouterProvider router={router} />;
32
25ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(33ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
26 <React.StrictMode>34 <React.StrictMode>
27 <RouterProvider router={router} />35 {clerkPublishableKey ? (
36 isElectron ? (
37 <DesktopClerkProvider publishableKey={clerkPublishableKey}>
38 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>
39 </DesktopClerkProvider>
40 ) : (
41 <ClerkProvider publishableKey={clerkPublishableKey} waitlistUrl={cloudWaitlistUrl}>
42 <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider>
43 </ClerkProvider>
44 )
45 ) : (
46 app
47 )}
28 </React.StrictMode>,48 </React.StrictMode>,
29);49);
apps/web/src/routeTree.gen.ts+21−0
@@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi · 18 unmodified lines
19import { Route as SettingsGeneralRouteImport } from './routes/settings.general'19import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
20import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'20import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
21import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'21import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
22import { Route as SettingsCloudRouteImport } from './routes/settings.cloud'
22import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'23import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
23import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'24import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
24import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'25import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
@@ -72,6 +73,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ · 47 unmodified lines
72 path: '/connections',73 path: '/connections',
73 getParentRoute: () => SettingsRoute,74 getParentRoute: () => SettingsRoute,
74} as any)75} as any)
76const SettingsCloudRoute = SettingsCloudRouteImport.update({
77 id: '/cloud',
78 path: '/cloud',
79 getParentRoute: () => SettingsRoute,
80} as any)
75const SettingsArchivedRoute = SettingsArchivedRouteImport.update({81const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
76 id: '/archived',82 id: '/archived',
77 path: '/archived',83 path: '/archived',
@@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { · 16 unmodified lines
94 '/pair': typeof PairRoute100 '/pair': typeof PairRoute
95 '/settings': typeof SettingsRouteWithChildren101 '/settings': typeof SettingsRouteWithChildren
96 '/settings/archived': typeof SettingsArchivedRoute102 '/settings/archived': typeof SettingsArchivedRoute
103 '/settings/cloud': typeof SettingsCloudRoute
97 '/settings/connections': typeof SettingsConnectionsRoute104 '/settings/connections': typeof SettingsConnectionsRoute
98 '/settings/diagnostics': typeof SettingsDiagnosticsRoute105 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
99 '/settings/general': typeof SettingsGeneralRoute106 '/settings/general': typeof SettingsGeneralRoute
@@ -107,6 +114,7 @@ export interface FileRoutesByTo { · 7 unmodified lines
107 '/pair': typeof PairRoute114 '/pair': typeof PairRoute
108 '/settings': typeof SettingsRouteWithChildren115 '/settings': typeof SettingsRouteWithChildren
109 '/settings/archived': typeof SettingsArchivedRoute116 '/settings/archived': typeof SettingsArchivedRoute
117 '/settings/cloud': typeof SettingsCloudRoute
110 '/settings/connections': typeof SettingsConnectionsRoute118 '/settings/connections': typeof SettingsConnectionsRoute
111 '/settings/diagnostics': typeof SettingsDiagnosticsRoute119 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
112 '/settings/general': typeof SettingsGeneralRoute120 '/settings/general': typeof SettingsGeneralRoute
@@ -123,6 +131,7 @@ export interface FileRoutesById { · 10 unmodified lines
123 '/pair': typeof PairRoute131 '/pair': typeof PairRoute
124 '/settings': typeof SettingsRouteWithChildren132 '/settings': typeof SettingsRouteWithChildren
125 '/settings/archived': typeof SettingsArchivedRoute133 '/settings/archived': typeof SettingsArchivedRoute
134 '/settings/cloud': typeof SettingsCloudRoute
126 '/settings/connections': typeof SettingsConnectionsRoute135 '/settings/connections': typeof SettingsConnectionsRoute
127 '/settings/diagnostics': typeof SettingsDiagnosticsRoute136 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
128 '/settings/general': typeof SettingsGeneralRoute137 '/settings/general': typeof SettingsGeneralRoute
@@ -140,6 +149,7 @@ export interface FileRouteTypes { · 11 unmodified lines
140 | '/pair'149 | '/pair'
141 | '/settings'150 | '/settings'
142 | '/settings/archived'151 | '/settings/archived'
152 | '/settings/cloud'
143 | '/settings/connections'153 | '/settings/connections'
144 | '/settings/diagnostics'154 | '/settings/diagnostics'
145 | '/settings/general'155 | '/settings/general'
@@ -153,6 +163,7 @@ export interface FileRouteTypes { · 7 unmodified lines
153 | '/pair'163 | '/pair'
154 | '/settings'164 | '/settings'
155 | '/settings/archived'165 | '/settings/archived'
166 | '/settings/cloud'
156 | '/settings/connections'167 | '/settings/connections'
157 | '/settings/diagnostics'168 | '/settings/diagnostics'
158 | '/settings/general'169 | '/settings/general'
@@ -168,6 +179,7 @@ export interface FileRouteTypes { · 9 unmodified lines
168 | '/pair'179 | '/pair'
169 | '/settings'180 | '/settings'
170 | '/settings/archived'181 | '/settings/archived'
182 | '/settings/cloud'
171 | '/settings/connections'183 | '/settings/connections'
172 | '/settings/diagnostics'184 | '/settings/diagnostics'
173 | '/settings/general'185 | '/settings/general'
@@ -257,6 +269,13 @@ declare module '@tanstack/react-router' { · 83 unmodified lines
257 preLoaderRoute: typeof SettingsConnectionsRouteImport269 preLoaderRoute: typeof SettingsConnectionsRouteImport
258 parentRoute: typeof SettingsRoute270 parentRoute: typeof SettingsRoute
259 }271 }
272 '/settings/cloud': {
273 id: '/settings/cloud'
274 path: '/cloud'
275 fullPath: '/settings/cloud'
276 preLoaderRoute: typeof SettingsCloudRouteImport
277 parentRoute: typeof SettingsRoute
278 }
260 '/settings/archived': {279 '/settings/archived': {
261 id: '/settings/archived'280 id: '/settings/archived'
262 path: '/archived'281 path: '/archived'
@@ -297,6 +316,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) · 34 unmodified lines
297 316
298interface SettingsRouteChildren {317interface SettingsRouteChildren {
299 SettingsArchivedRoute: typeof SettingsArchivedRoute318 SettingsArchivedRoute: typeof SettingsArchivedRoute
319 SettingsCloudRoute: typeof SettingsCloudRoute
300 SettingsConnectionsRoute: typeof SettingsConnectionsRoute320 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
301 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute321 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
302 SettingsGeneralRoute: typeof SettingsGeneralRoute322 SettingsGeneralRoute: typeof SettingsGeneralRoute
@@ -307,6 +327,7 @@ interface SettingsRouteChildren { · 4 unmodified lines
307 327
308const SettingsRouteChildren: SettingsRouteChildren = {328const SettingsRouteChildren: SettingsRouteChildren = {
309 SettingsArchivedRoute: SettingsArchivedRoute,329 SettingsArchivedRoute: SettingsArchivedRoute,
330 SettingsCloudRoute: SettingsCloudRoute,
310 SettingsConnectionsRoute: SettingsConnectionsRoute,331 SettingsConnectionsRoute: SettingsConnectionsRoute,
311 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,332 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
312 SettingsGeneralRoute: SettingsGeneralRoute,333 SettingsGeneralRoute: SettingsGeneralRoute,
apps/web/src/routes/_chat.index.tsx+4−4
@@ -48,13 +48,13 @@ function HostedStaticOnboardingState() { · 47 unmodified lines
48 Connect an environment to get started48 Connect an environment to get started
49 </EmptyTitle>49 </EmptyTitle>
50 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">50 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">
51 Open a pairing link from your T3 Code desktop app or add a reachable backend51 Sign in to T3 Cloud to connect a linked environment through its managed tunnel, or
52 manually. Your saved environments stay in this browser.52 add a reachable backend manually.
53 </EmptyDescription>53 </EmptyDescription>
54 <div className="mt-6 flex justify-center">54 <div className="mt-6 flex justify-center">
55 <Button render={<a href="/settings/connections" />} size="sm">55 <Button render={<a href="/settings/cloud" />} size="sm">
56 <PlusIcon className="size-4" />56 <PlusIcon className="size-4" />
57 Add environment57 Open T3 Cloud
58 </Button>58 </Button>
59 </div>59 </div>
60 </EmptyHeader>60 </EmptyHeader>
apps/web/src/routes/settings.cloud.tsx+7−0
@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { CloudSettingsPanel } from "../components/settings/CloudSettings";
4
5export const Route = createFileRoute("/settings/cloud")({
6 component: CloudSettingsPanel,
7});
apps/web/src/vite-env.d.ts+1−0
@@ -7,6 +7,7 @@ interface ImportMetaEnv { · 6 unmodified lines
7 readonly VITE_WS_URL: string;7 readonly VITE_WS_URL: string;
8 readonly VITE_HOSTED_APP_URL: string;8 readonly VITE_HOSTED_APP_URL: string;
9 readonly VITE_HOSTED_APP_CHANNEL: string;9 readonly VITE_HOSTED_APP_CHANNEL: string;
10 readonly VITE_CLERK_PUBLISHABLE_KEY: string;
10 readonly APP_VERSION: string;11 readonly APP_VERSION: string;
11}12}
12 13
docs/relay-observability.md+40−0
@@ -0,0 +1,40 @@
1# Relay observability
2
3The relay Alchemy stack owns a focused Axiom trace setup:
4
5- `t3-code-relay-traces`, an OpenTelemetry trace dataset for Worker requests
6- `t3-code-relay-otel-ingest`, a dataset-scoped ingest token bound to the Worker
7- `t3-code-relay-readonly-query`, a dataset-scoped token for scripted diagnostics
8- `t3-code-relay-recent-spans`, a view of recent request and endpoint spans
9
10Deploy from `infra/relay` with the normal Alchemy workflow:
11
12```sh
13bun run deploy
14```
15
16Alchemy resolves Axiom deployment credentials through its provider. At runtime, the Worker
17receives only the scoped ingest token; it does not receive the diagnostics query token.
18
19The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans.
20Effect's OpenTelemetry exporter stores semantic HTTP attributes below the `attributes.` prefix.
21For example:
22
23```apl
24['t3-code-relay-traces']
25| where name startswith 'http.server'
26| project _time, name, trace_id, duration,
27 ['attributes.http.request.method'],
28 ['attributes.url.path'],
29 ['attributes.http.response.status_code']
30| order by _time desc
31| limit 200
32```
33
34Endpoint failure annotations and other relay-specific attributes are also emitted in the
35`attributes.custom` map when present on a span, for example
36`['attributes.custom']['relay.endpoint']`.
37
38Agents should prefer the provisioned view or APL queries for completed incidents instead of
39tailing the Cloudflare Worker. Use the read-only query token when scripted access is needed;
40keep the ingest token reserved for the Worker.
docs/t3-cloud-clerk.md+81−0
@@ -0,0 +1,81 @@
1# T3 Cloud Clerk Setup
2
3T3 Cloud uses one Clerk application for web, desktop, and mobile authentication. The relay accepts
4Clerk JWTs only when they are generated from the `t3-relay` template with the relay URL as the
5audience.
6
7## Application Keys
8
9Use keys from the same Clerk instance in each location:
10
11| Consumer | Configuration | Value |
12| ------------------------ | --------------------------------------- | ----------------------------------------------------- |
13| Web and desktop renderer | `apps/web/.env` | `VITE_CLERK_PUBLISHABLE_KEY=<publishable key>` |
14| Mobile build | `apps/mobile/.env` or build environment | `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=<publishable key>` |
15| Relay deployment | Alchemy secret | `CLERK_SECRET_KEY=<secret key>` |
16
17Never put `CLERK_SECRET_KEY` in a client application environment.
18
19## JWT Template
20
21In **Clerk Dashboard > JWT templates**, create a template with:
22
23| Setting | Value |
24| ------- | ------------------------------------------------------ |
25| Name | `t3-relay` |
26| Claims | `{ "aud": "https://t3code-relay.ineededadomain.com" }` |
27
28The `aud` value must be the deployed relay public URL, with no trailing slash, and must match
29`VITE_T3_RELAY_URL` and `T3_RELAY_URL`. If the relay domain changes, update all three values.
30
31## Desktop OAuth Redirect Allowlist
32
33The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme.
34In **Clerk Dashboard > Native applications**, enable native application support and add these
35entries under the mobile SSO redirect allowlist:
36
37```text
38t3code-dev://auth/callback
39t3code://auth/callback
40```
41
42The first entry is for local desktop development. The second is for packaged desktop builds.
43The app also adds a request-scoped `t3_state` query parameter and validates it on callback.
44
45The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth
46flow uses a custom redirect URI, add that exact URI to the same allowlist.
47
48## Enable Waitlist Access
49
50For a private beta where people should request access, use **Clerk Dashboard > Waitlist**:
51
521. Toggle on **Enable waitlist** and save.
532. Review requests on the same page and select **Invite** or **Deny**.
54
55Signed-out web and desktop users see Clerk's waitlist enrollment as the T3 Cloud page content,
56while approved signed-in users see cloud settings. The browser app also uses `/settings/cloud` as
57its Clerk waitlist URL.
58
59On mobile, signed-out users open **Settings > T3 Account** to reach `/settings/waitlist` within the
60Settings form sheet. It submits enrollment through Clerk's `useWaitlist()` flow because the prebuilt
61`<Waitlist />` component is web-only in the Expo SDK. Approved users can use **Sign in** from that
62screen.
63
64## Alternative: Known-User Allowlist
65
66For a closed beta where all permitted users are known in advance, use an allowlist instead of a
67request-and-approval waitlist:
68
69To restrict the beta to permitted email addresses or domains:
70
711. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email
72 domain.
732. Enable the allowlist and save.
743. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or
75 manually created without a waitlist request flow.
76
77Do not enable an empty allowlist: it blocks all new sign-ups.
78
79Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud
80access. To remove an already-created user's access, ban that user in Clerk so their active
81sessions are ended and future sign-ins are rejected.
docs/t3-code-cloud-auth-flow.html+1820−0
@@ -0,0 +1,1820 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <title>T3 Code Cloud Control Plane and Managed Endpoint Flow</title>
7 <style>
8 :root {
9 color-scheme: light;
10 --ink: #171717;
11 --muted: #62646b;
12 --hairline: #d8d8dd;
13 --panel: #ffffff;
14 --paper: #f5f5f2;
15 --blue: #1457d9;
16 --green: #1d7d4d;
17 --amber: #a65f00;
18 --red: #b8242c;
19 --violet: #6150b8;
20 --shadow: 0 18px 55px rgba(25, 25, 28, 0.08);
21 }
22
23 * {
24 box-sizing: border-box;
25 }
26
27 body {
28 margin: 0;
29 background:
30 linear-gradient(90deg, rgba(0, 0, 0, 0.035) 1px, transparent 1px),
31 linear-gradient(rgba(0, 0, 0, 0.035) 1px, transparent 1px), var(--paper);
32 background-size: 34px 34px;
33 color: var(--ink);
34 font-family:
35 ui-sans-serif,
36 -apple-system,
37 BlinkMacSystemFont,
38 "Segoe UI",
39 sans-serif;
40 line-height: 1.48;
41 }
42
43 .shell {
44 width: min(1540px, calc(100vw - 40px));
45 margin: 0 auto;
46 padding: 38px 0 72px;
47 }
48
49 header {
50 border-bottom: 2px solid var(--ink);
51 padding-bottom: 24px;
52 margin-bottom: 28px;
53 }
54
55 .eyebrow {
56 font-size: 12px;
57 font-weight: 800;
58 letter-spacing: 0.08em;
59 text-transform: uppercase;
60 color: var(--blue);
61 }
62
63 h1 {
64 max-width: 980px;
65 margin: 8px 0 12px;
66 font-size: clamp(34px, 5vw, 66px);
67 line-height: 0.98;
68 letter-spacing: 0;
69 }
70
71 .lede {
72 max-width: 930px;
73 margin: 0;
74 color: var(--muted);
75 font-size: 18px;
76 }
77
78 h2 {
79 margin: 0 0 14px;
80 font-size: 26px;
81 line-height: 1.08;
82 }
83
84 h3 {
85 margin: 22px 0 8px;
86 font-size: 17px;
87 }
88
89 p {
90 margin: 0 0 12px;
91 }
92
93 code,
94 .mono {
95 font-family:
96 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
97 }
98
99 .grid {
100 display: grid;
101 grid-template-columns: repeat(12, 1fr);
102 gap: 18px;
103 }
104
105 .panel {
106 grid-column: span 12;
107 background: var(--panel);
108 border: 1px solid var(--hairline);
109 border-radius: 10px;
110 box-shadow: var(--shadow);
111 padding: 22px;
112 }
113
114 .panel.compact {
115 padding: 18px;
116 }
117
118 .half {
119 grid-column: span 6;
120 }
121
122 .callout {
123 border-left: 5px solid var(--blue);
124 background: #eef4ff;
125 padding: 14px 16px;
126 margin: 14px 0 0;
127 }
128
129 .callout.warning {
130 border-left-color: var(--amber);
131 background: #fff6e6;
132 }
133
134 .callout.danger {
135 border-left-color: var(--red);
136 background: #fff1f1;
137 }
138
139 .badge-row {
140 display: flex;
141 flex-wrap: wrap;
142 gap: 8px;
143 margin-top: 14px;
144 }
145
146 .badge {
147 border: 1px solid var(--hairline);
148 border-radius: 999px;
149 background: #fff;
150 padding: 5px 9px;
151 font-size: 12px;
152 font-weight: 700;
153 }
154
155 .badge.user {
156 border-color: rgba(20, 87, 217, 0.3);
157 color: var(--blue);
158 }
159
160 .badge.env {
161 border-color: rgba(29, 125, 77, 0.3);
162 color: var(--green);
163 }
164
165 .badge.local {
166 border-color: rgba(166, 95, 0, 0.35);
167 color: var(--amber);
168 }
169
170 .diagram {
171 overflow-x: auto;
172 margin: 12px 0 0;
173 border-radius: 8px;
174 border: 1px solid var(--hairline);
175 background: #fbfbf8;
176 color: var(--ink);
177 padding: 18px;
178 }
179
180 .diagram svg {
181 display: block;
182 width: auto !important;
183 min-width: 100%;
184 max-width: none !important;
185 height: auto;
186 margin: 0 auto;
187 }
188
189 .diagram.mermaid {
190 min-height: 120px;
191 text-align: center;
192 }
193
194 .actor-diagram {
195 max-height: none;
196 overflow: auto;
197 padding: 14px;
198 background: #fbfbf8;
199 }
200
201 .architecture-svg {
202 display: block;
203 height: auto;
204 min-width: 1260px;
205 width: 100%;
206 }
207
208 .architecture-svg .cluster {
209 fill-opacity: 0.5;
210 stroke-dasharray: 7 5;
211 stroke-width: 1.4;
212 }
213
214 .architecture-svg .cluster-label {
215 fill: var(--ink);
216 font-size: 15px;
217 font-weight: 800;
218 }
219
220 .architecture-svg .node {
221 fill: #f7f7f3;
222 rx: 9;
223 stroke: #62646b;
224 stroke-width: 2;
225 }
226
227 .architecture-svg .node.user {
228 fill: #eef4ff;
229 stroke: var(--blue);
230 }
231
232 .architecture-svg .node.control {
233 fill: #fff7e8;
234 stroke: var(--amber);
235 }
236
237 .architecture-svg .node.data {
238 fill: #f4f2ff;
239 stroke: var(--violet);
240 }
241
242 .architecture-svg .node.local {
243 fill: #effaf3;
244 stroke: var(--green);
245 }
246
247 .architecture-svg .svg-label {
248 align-items: center;
249 display: flex;
250 height: 100%;
251 justify-content: center;
252 line-height: 1.16;
253 padding: 8px;
254 text-align: center;
255 }
256
257 .architecture-svg .svg-label strong {
258 display: block;
259 font-size: 16px;
260 }
261
262 .architecture-svg .svg-label span {
263 color: var(--muted);
264 display: block;
265 font-size: 12px;
266 font-weight: 700;
267 margin-top: 3px;
268 }
269
270 .architecture-svg .edge {
271 fill: none;
272 stroke: #62646b;
273 stroke-width: 2.1;
274 }
275
276 .architecture-svg .edge.user-edge {
277 stroke: var(--blue);
278 }
279
280 .architecture-svg .edge.control-edge {
281 stroke: var(--amber);
282 }
283
284 .architecture-svg .edge.data-edge {
285 stroke: var(--violet);
286 }
287
288 .architecture-svg .edge.local-edge {
289 stroke: var(--green);
290 }
291
292 .architecture-svg .edge.private-edge {
293 stroke-dasharray: 8 6;
294 }
295
296 .architecture-svg .edge-label rect {
297 fill: #fbfbf8;
298 stroke: rgba(0, 0, 0, 0.08);
299 }
300
301 .architecture-svg .edge-label text {
302 font-size: 12px;
303 font-weight: 800;
304 }
305
306 .architecture-svg .user-label text {
307 fill: var(--blue);
308 }
309
310 .architecture-svg .control-label text {
311 fill: var(--amber);
312 }
313
314 .architecture-svg .data-label text {
315 fill: var(--violet);
316 }
317
318 .architecture-svg .local-label text {
319 fill: var(--green);
320 }
321
322 .architecture-svg .private-label text {
323 fill: #42444a;
324 }
325
326 .legend {
327 display: grid;
328 grid-template-columns: repeat(4, minmax(0, 1fr));
329 gap: 12px;
330 margin-top: 14px;
331 }
332
333 .legend-item {
334 border: 1px solid var(--hairline);
335 border-radius: 8px;
336 padding: 12px;
337 background: #fafafa;
338 }
339
340 .legend-item strong {
341 display: block;
342 margin-bottom: 4px;
343 }
344
345 table {
346 width: 100%;
347 border-collapse: collapse;
348 font-size: 14px;
349 }
350
351 th,
352 td {
353 border-bottom: 1px solid var(--hairline);
354 padding: 11px 9px;
355 text-align: left;
356 vertical-align: top;
357 }
358
359 th {
360 font-size: 12px;
361 letter-spacing: 0.05em;
362 text-transform: uppercase;
363 color: var(--muted);
364 }
365
366 .flow {
367 display: grid;
368 gap: 10px;
369 margin-top: 12px;
370 }
371
372 .grid[aria-label="Core setup flows"] > .panel,
373 .grid[aria-label="Hosted web and mismatch handling"] > .panel {
374 grid-column: 1 / -1;
375 }
376
377 .grid[aria-label="Core setup flows"] .diagram,
378 .grid[aria-label="Hosted web and mismatch handling"] .diagram {
379 width: 100%;
380 }
381
382 .step {
383 border: 1px solid var(--hairline);
384 border-left: 5px solid var(--green);
385 border-radius: 8px;
386 background: #fff;
387 padding: 12px 14px;
388 }
389
390 .step strong {
391 display: block;
392 margin-bottom: 3px;
393 }
394
395 .split-title {
396 display: flex;
397 align-items: baseline;
398 justify-content: space-between;
399 gap: 16px;
400 border-bottom: 1px solid var(--hairline);
401 padding-bottom: 10px;
402 margin-bottom: 14px;
403 }
404
405 .split-title span {
406 color: var(--muted);
407 font-size: 13px;
408 font-weight: 700;
409 }
410
411 .source-list a {
412 color: var(--blue);
413 font-weight: 700;
414 }
415
416 @media (max-width: 860px) {
417 .half {
418 grid-column: span 12;
419 }
420
421 .legend {
422 grid-template-columns: 1fr;
423 }
424
425 .shell {
426 width: min(100vw - 24px, 1540px);
427 padding-top: 24px;
428 }
429 }
430 </style>
431 </head>
432 <body>
433 <main class="shell">
434 <header>
435 <div class="eyebrow">Draft architecture</div>
436 <h1>T3 Code Cloud Control Plane and Managed Endpoint Flow</h1>
437 <p class="lede">
438 A concrete topology for one user with a MacBook Pro desktop environment, a headless Mac
439 mini environment, the T3 Code mobile app, and the hosted web UI. The control plane owns
440 identity, grants, provisioning, endpoint records, and connection tickets. The managed
441 endpoint carries both normal environment HTTP/WebSocket traffic and short signed
442 control-over-data-plane requests such as credential minting.
443 </p>
444 <div class="badge-row" aria-label="Key implementation boundaries">
445 <span class="badge user">Client -> Cloud: HTTPS + IdP JWT</span>
446 <span class="badge env">Cloud -> Env: HTTPS + cloud-signed request</span>
447 <span class="badge env">Env -> Cloud: env-signed response</span>
448 <span class="badge local">Client -> Env: HTTPS/WSS + local T3 auth</span>
449 </div>
450 </header>
451
452 <section class="grid" aria-label="Security invariant">
453 <article class="panel half">
454 <h2>Security Invariant</h2>
455 <p>
456 A cloud user session is not environment authority. Remote access requires an authorized
457 IdP user, a linked environment authenticated by its registered cloud-link key, and a
458 one-time local bootstrap credential minted by the target T3 server.
459 </p>
460 <div class="callout danger">
461 T3 Cloud must not proxy normal app traffic. It brokers identity, provisioning, grants,
462 endpoint records, and connect tickets. To connect a client, T3 Cloud sends a short-lived
463 signed mint request through the managed endpoint to the target local T3 server. After
464 token exchange, clients talk directly to
465 <code>https://env_abc.tunnels.t3code.com</code> and
466 <code>wss://env_abc.tunnels.t3code.com/ws</code>.
467 </div>
468 </article>
469
470 <article class="panel half">
471 <h2>Minimal Credential Set</h2>
472 <p><strong>1. User session</strong><br />Hosted IdP session/JWT on clients.</p>
473 <p>
474 <strong>2. Cloud signing key</strong><br />Private key controlled by T3 Cloud; public
475 issuer metadata installed into linked environments.
476 </p>
477 <p>
478 <strong>3. Environment cloud-link keypair</strong><br />Private key stored in
479 Keychain/state dir; public key registered with T3 Cloud.
480 </p>
481 <p>
482 <strong>4. Local server authority</strong><br />Local tokens carrying
483 <code>relay:manage</code> mint single-use bootstrap credentials.
484 </p>
485 <p>
486 <strong>5. Managed endpoint runtime credential</strong><br />Endpoint runtime credential
487 used only to expose the environment's managed endpoint.
488 </p>
489 </article>
490 </section>
491
492 <section class="panel" aria-label="Implementation auth and transport matrix">
493 <h2>Implementation Auth and Transport Matrix</h2>
494 <p>
495 This table is the contract to implement and validate against. The managed endpoint
496 transport forwards bytes; it does not authorize T3 sessions.
497 </p>
498 <table>
499 <thead>
500 <tr>
501 <th>Path</th>
502 <th>Transport</th>
503 <th>Auth Material</th>
504 <th>Verifier</th>
505 <th>Purpose</th>
506 </tr>
507 </thead>
508 <tbody>
509 <tr>
510 <td>Client -> T3 Cloud</td>
511 <td>HTTPS</td>
512 <td>Hosted IdP session/JWT</td>
513 <td>T3 Cloud</td>
514 <td>List environments, manage grants, request connect.</td>
515 </tr>
516 <tr>
517 <td>T3 Cloud -> local T3 server</td>
518 <td>HTTPS through managed endpoint</td>
519 <td>Cloud-signed mint or health request with nonce, audience, expiry, and scope.</td>
520 <td>Local T3 server</td>
521 <td>Ask the linked environment to mint a one-time bootstrap credential.</td>
522 </tr>
523 <tr>
524 <td>Local T3 server -> T3 Cloud</td>
525 <td>
526 HTTPS response through managed endpoint, or direct HTTPS cloud API for status.
527 </td>
528 <td>Environment cloud-link signature.</td>
529 <td>T3 Cloud</td>
530 <td>Prove the real linked environment responded or reported status.</td>
531 </tr>
532 <tr>
533 <td>Client -> local T3 token exchange</td>
534 <td>HTTPS through managed endpoint</td>
535 <td>Single-use bootstrap credential minted by the local T3 server.</td>
536 <td>Local T3 server</td>
537 <td>Mint a scoped proof-bound environment access token.</td>
538 </tr>
539 <tr>
540 <td>Client -> local T3 WebSocket ticket API</td>
541 <td>HTTPS through managed endpoint</td>
542 <td>Scoped environment access token plus DPoP proof.</td>
543 <td>Local T3 server</td>
544 <td>Mint a short-lived ticket for one WebSocket connection.</td>
545 </tr>
546 <tr>
547 <td>Client -> local T3 WebSocket</td>
548 <td>WSS through managed endpoint</td>
549 <td><code>wsTicket</code> query parameter.</td>
550 <td>Local T3 server</td>
551 <td>Run the existing app RPC protocol.</td>
552 </tr>
553 <tr>
554 <td>Endpoint runtime -> endpoint provider</td>
555 <td>Provider-specific outbound tunnel connection</td>
556 <td>Cloudflare tunnel token today; T3 relay connector token later.</td>
557 <td>Cloudflare Tunnel or T3 Relay</td>
558 <td>Expose the loopback T3 server as a stable public HTTPS/WSS endpoint.</td>
559 </tr>
560 <tr>
561 <td>APNs -> mobile app</td>
562 <td>APNs push transport</td>
563 <td>Apple push token and APNs provider auth.</td>
564 <td>Apple APNs and mobile app.</td>
565 <td>Wake or update mobile UI with minimal status, not full agent traffic.</td>
566 </tr>
567 </tbody>
568 </table>
569 <div class="callout warning">
570 Do not add an app-session authorization dependency to Cloudflare Tunnel or T3 Relay. The
571 endpoint provider may authenticate the connector, but local T3 auth remains the only
572 verifier for bootstrap credentials, scoped access tokens, WebSocket tickets, and commands.
573 The endpoint runtime credential authenticates the connector to the endpoint provider; it
574 is not a client-facing access gate for the public hostname.
575 </div>
576 </section>
577
578 <section class="panel" aria-label="Overall actor diagram">
579 <div class="split-title">
580 <h2>Overall Actor Diagram</h2>
581 <span>Service-level auth/control/data lanes</span>
582 </div>
583 <div class="diagram actor-diagram">
584 <div
585 id="actorGraph"
586 class="architecture-diagram"
587 role="img"
588 aria-label="T3 Cloud actor graph showing user identity, clients, control plane, data plane, MacBook environment, and Mac mini environment."
589 >
590 <svg
591 class="architecture-svg"
592 viewBox="0 0 1500 760"
593 role="presentation"
594 xmlns="http://www.w3.org/2000/svg"
595 >
596 <defs>
597 <marker
598 id="arrow-blue"
599 markerHeight="9"
600 markerWidth="9"
601 orient="auto"
602 refX="8"
603 refY="4.5"
604 >
605 <path d="M0,0 L9,4.5 L0,9 Z" fill="#1457d9" />
606 </marker>
607 <marker
608 id="arrow-green"
609 markerHeight="9"
610 markerWidth="9"
611 orient="auto"
612 refX="8"
613 refY="4.5"
614 >
615 <path d="M0,0 L9,4.5 L0,9 Z" fill="#1d7d4d" />
616 </marker>
617 <marker
618 id="arrow-amber"
619 markerHeight="9"
620 markerWidth="9"
621 orient="auto"
622 refX="8"
623 refY="4.5"
624 >
625 <path d="M0,0 L9,4.5 L0,9 Z" fill="#a65f00" />
626 </marker>
627 <marker
628 id="arrow-violet"
629 markerHeight="9"
630 markerWidth="9"
631 orient="auto"
632 refX="8"
633 refY="4.5"
634 >
635 <path d="M0,0 L9,4.5 L0,9 Z" fill="#6150b8" />
636 </marker>
637 <marker
638 id="arrow-gray"
639 markerHeight="9"
640 markerWidth="9"
641 orient="auto"
642 refX="8"
643 refY="4.5"
644 >
645 <path d="M0,0 L9,4.5 L0,9 Z" fill="#62646b" />
646 </marker>
647 </defs>
648
649 <rect
650 class="cluster"
651 x="45"
652 y="60"
653 width="250"
654 height="125"
655 rx="14"
656 fill="#eef4ff"
657 stroke="#1457d9"
658 />
659 <text class="cluster-label" x="68" y="88">Identity Provider</text>
660 <rect
661 class="cluster"
662 x="45"
663 y="245"
664 width="300"
665 height="315"
666 rx="14"
667 fill="#eef4ff"
668 stroke="#1457d9"
669 />
670 <text class="cluster-label" x="68" y="273">User Clients</text>
671 <rect
672 class="cluster"
673 x="445"
674 y="210"
675 width="280"
676 height="230"
677 rx="14"
678 fill="#fff7e8"
679 stroke="#a65f00"
680 />
681 <text class="cluster-label" x="468" y="238">T3 Cloud</text>
682 <rect
683 class="cluster"
684 x="445"
685 y="520"
686 width="280"
687 height="160"
688 rx="14"
689 fill="#f4f2ff"
690 stroke="#6150b8"
691 />
692 <text class="cluster-label" x="468" y="548">Managed Endpoint</text>
693 <rect
694 class="cluster"
695 x="870"
696 y="80"
697 width="520"
698 height="245"
699 rx="14"
700 fill="#effaf3"
701 stroke="#1d7d4d"
702 />
703 <text class="cluster-label" x="895" y="108">MacBook Pro</text>
704 <rect
705 class="cluster"
706 x="870"
707 y="405"
708 width="520"
709 height="245"
710 rx="14"
711 fill="#effaf3"
712 stroke="#1d7d4d"
713 />
714 <text class="cluster-label" x="895" y="433">Mac mini</text>
715
716 <rect class="node user" x="85" y="105" width="170" height="70" />
717 <foreignObject x="85" y="105" width="170" height="70">
718 <div xmlns="http://www.w3.org/1999/xhtml" class="svg-label">
719 <div><strong>IdP</strong><span>hosted user auth</span></div>
720 </div>
721 </foreignObject>
722
723 <rect class="node user" x="85" y="295" width="220" height="70" />
724 <foreignObject x="85" y="295" width="220" height="70">
725 <div xmlns="http://www.w3.org/1999/xhtml" class="svg-label">
726 <div><strong>Desktop app</strong><span>MacBook user interface</span></div>
727 </div>
728 </foreignObject>
729 <rect class="node user" x="85" y="380" width="220" height="70" />
730 <foreignObject x="85" y="380" width="220" height="70">
731 <div xmlns="http://www.w3.org/1999/xhtml" class="svg-label">
732 <div><strong>Mobile app</strong><span>IdP session, APNs token</span></div>
733 </div>
734 </foreignObject>
735 <rect class="node user" x="85" y="465" width="220" height="70" />
736 <foreignObject x="85" y="465" width="220" height="70">
737 <div xmlns="http://www.w3.org/1999/xhtml" class="svg-label">
738 <div><strong>Hosted web UI</strong><span>app.t3.codes</span></div>
739 </div>
740 </foreignObject>
741
742 <rect class="node control" x="485" y="270" width="200" height="95" />
743 <foreignObject x="485" y="270" width="200" height="95">
744 <div xmlns="http://www.w3.org/1999/xhtml" class="svg-label">
745 <div>
746 <strong>T3 Cloud control plane</strong
747 ><span>grants, links, endpoint records, connect tickets</span>
748 </div>
749 </div>
750 </foreignObject>
751 <rect class="node data" x="485" y="575" width="200" height="75" />
752 <foreignObject x="485" y="575" width="200" height="75">
753 <div xmlns="http://www.w3.org/1999/xhtml" class="svg-label">
754 <div>
755 <strong>Public environment endpoint</strong><span>HTTPS + WebSocket</span>
756 </div>
757 </div>
758 </foreignObject>
759 <rect class="node control" x="485" y="80" width="200" height="75" />
760 <foreignObject x="485" y="80" width="200" height="75">
761 <div xmlns="http://www.w3.org/1999/xhtml" class="svg-label">
762 <div><strong>APNs</strong><span>push transport</span></div>
763 </div>
764 </foreignObject>
765
766 <rect class="node local" x="930" y="145" width="210" height="95" />
767 <foreignObject x="930" y="145" width="210" height="95">
768 <div xmlns="http://www.w3.org/1999/xhtml" class="svg-label">
769 <div>
770 <strong>env_mbp</strong><span>local T3 server, terminal authority</span>

1050 more lines aren’t shown here.

infra/relay/alchemy.run.ts+38−0

This diff is large: its 39 lines aren’t shown here.

infra/relay/migrations/postgres/20260527044716_baseline/migration.sql+104−0

This diff is large: its 105 lines aren’t shown here.

infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json+1267−0

This diff is large: its 1268 lines aren’t shown here.

infra/relay/package.json+32−0

This diff is large: its 33 lines aren’t shown here.

infra/relay/src/Config.ts+32−0

This diff is large: its 33 lines aren’t shown here.

infra/relay/src/RelayCrypto.ts+16−0

This diff is large: its 17 lines aren’t shown here.

infra/relay/src/agentActivityPayloads.ts+63−0

This diff is large: its 64 lines aren’t shown here.

infra/relay/src/api.test.ts+157−0

This diff is large: its 158 lines aren’t shown here.

infra/relay/src/api.ts+1016−0

This diff is large: its 1017 lines aren’t shown here.

infra/relay/src/apns.test.ts+116−0

This diff is large: its 117 lines aren’t shown here.

infra/relay/src/apns.ts+274−0

This diff is large: its 275 lines aren’t shown here.

infra/relay/src/apnsDeliveryJobs.test.ts+221−0

This diff is large: its 222 lines aren’t shown here.

infra/relay/src/apnsDeliveryJobs.ts+196−0

This diff is large: its 197 lines aren’t shown here.

infra/relay/src/db.test.ts+182−0

This diff is large: its 183 lines aren’t shown here.

infra/relay/src/db.ts+50−0

This diff is large: its 51 lines aren’t shown here.

infra/relay/src/dpop.test.ts+203−0

This diff is large: its 204 lines aren’t shown here.

infra/relay/src/dpop.ts+61−0

This diff is large: its 62 lines aren’t shown here.

infra/relay/src/infra/ManagedEndpointStackConfig.test.ts+27−0

This diff is large: its 28 lines aren’t shown here.

infra/relay/src/infra/ManagedEndpointStackConfig.ts+26−0

This diff is large: its 27 lines aren’t shown here.

infra/relay/src/infra/RelayObservability.test.ts+82−0

This diff is large: its 83 lines aren’t shown here.

infra/relay/src/infra/RelayObservability.ts+58−0

This diff is large: its 59 lines aren’t shown here.

infra/relay/src/persistence/AgentActivityRows.ts+155−0

This diff is large: its 156 lines aren’t shown here.

infra/relay/src/persistence/DeliveryAttempts.test.ts+323−0

This diff is large: its 324 lines aren’t shown here.

infra/relay/src/persistence/DeliveryAttempts.ts+205−0

This diff is large: its 206 lines aren’t shown here.

infra/relay/src/persistence/Devices.test.ts+160−0

This diff is large: its 161 lines aren’t shown here.

infra/relay/src/persistence/Devices.ts+134−0

This diff is large: its 135 lines aren’t shown here.

infra/relay/src/persistence/DpopProofs.test.ts+95−0

This diff is large: its 96 lines aren’t shown here.

infra/relay/src/persistence/DpopProofs.ts+68−0

This diff is large: its 69 lines aren’t shown here.

infra/relay/src/persistence/EnvironmentCredentials.test.ts+148−0

This diff is large: its 149 lines aren’t shown here.

infra/relay/src/persistence/EnvironmentCredentials.ts+176−0

This diff is large: its 177 lines aren’t shown here.

infra/relay/src/persistence/EnvironmentLinks.test.ts+78−0

This diff is large: its 79 lines aren’t shown here.

infra/relay/src/persistence/EnvironmentLinks.ts+349−0

This diff is large: its 350 lines aren’t shown here.

infra/relay/src/persistence/LiveActivities.test.ts+196−0

This diff is large: its 197 lines aren’t shown here.

infra/relay/src/persistence/LiveActivities.ts+375−0

This diff is large: its 376 lines aren’t shown here.

infra/relay/src/persistence/json.ts+10−0

This diff is large: its 11 lines aren’t shown here.

infra/relay/src/queues.ts+7−0

This diff is large: its 8 lines aren’t shown here.

infra/relay/src/relayTokens.test.ts+196−0

This diff is large: its 197 lines aren’t shown here.

infra/relay/src/relayTokens.ts+186−0

This diff is large: its 187 lines aren’t shown here.

infra/relay/src/schema.ts+163−0

This diff is large: its 164 lines aren’t shown here.

infra/relay/src/services/AgentActivityPublisher.test.ts+627−0

This diff is large: its 628 lines aren’t shown here.

infra/relay/src/services/AgentActivityPublisher.ts+234−0

This diff is large: its 235 lines aren’t shown here.

infra/relay/src/services/ApnsDeliveries.test.ts+1126−0

This diff is large: its 1127 lines aren’t shown here.

infra/relay/src/services/ApnsDeliveries.ts+793−0

This diff is large: its 794 lines aren’t shown here.

infra/relay/src/services/ApnsDeliveryQueue.ts+144−0

This diff is large: its 145 lines aren’t shown here.

infra/relay/src/services/Auth.ts+19−0

This diff is large: its 20 lines aren’t shown here.

infra/relay/src/services/EnvironmentConnector.test.ts+583−0

This diff is large: its 584 lines aren’t shown here.

infra/relay/src/services/EnvironmentConnector.ts+397−0

This diff is large: its 398 lines aren’t shown here.

infra/relay/src/services/EnvironmentLinker.test.ts+203−0

This diff is large: its 204 lines aren’t shown here.

infra/relay/src/services/EnvironmentLinker.ts+270−0

This diff is large: its 271 lines aren’t shown here.

infra/relay/src/services/EnvironmentPublishSignatures.test.ts+160−0

This diff is large: its 161 lines aren’t shown here.

infra/relay/src/services/EnvironmentPublishSignatures.ts+176−0

This diff is large: its 177 lines aren’t shown here.

infra/relay/src/services/ManagedEndpointProvider.test.ts+446−0

This diff is large: its 447 lines aren’t shown here.

infra/relay/src/services/ManagedEndpointProvider.ts+343−0

This diff is large: its 344 lines aren’t shown here.

infra/relay/src/services/MobileRegistrations.test.ts+461−0

This diff is large: its 462 lines aren’t shown here.

infra/relay/src/services/MobileRegistrations.ts+101−0

This diff is large: its 102 lines aren’t shown here.

infra/relay/src/telemetry.test.ts+31−0

This diff is large: its 32 lines aren’t shown here.

infra/relay/src/telemetry.ts+10−0

This diff is large: its 11 lines aren’t shown here.

infra/relay/src/worker.ts+295−0

This diff is large: its 296 lines aren’t shown here.

infra/relay/tsconfig.json+9−0

This diff is large: its 10 lines aren’t shown here.

packages/client-runtime/src/index.ts+1−0

This diff is large: its 5 lines aren’t shown here.

packages/client-runtime/src/managedRelay.test.ts+114−0

This diff is large: its 115 lines aren’t shown here.

packages/client-runtime/src/managedRelay.ts+470−0

This diff is large: its 471 lines aren’t shown here.

packages/client-runtime/src/remote.test.ts+114−25

This diff is large: its 185 lines aren’t shown here.

packages/client-runtime/src/remote.ts+106−7

This diff is large: its 179 lines aren’t shown here.

packages/contracts/package.json+4−0

This diff is large: its 11 lines aren’t shown here.

packages/contracts/src/auth.ts+4−1

This diff is large: its 23 lines aren’t shown here.

packages/contracts/src/environmentHttp.ts+169−1

This diff is large: its 215 lines aren’t shown here.

packages/contracts/src/ipc.ts+28−0

This diff is large: its 49 lines aren’t shown here.

packages/contracts/src/relay.test.ts+16−0

This diff is large: its 17 lines aren’t shown here.

packages/contracts/src/relay.ts+868−0

This diff is large: its 869 lines aren’t shown here.

packages/shared/package.json+27−0

This diff is large: its 50 lines aren’t shown here.

packages/shared/src/agentAwareness.test.ts+128−0

This diff is large: its 129 lines aren’t shown here.

packages/shared/src/agentAwareness.ts+142−0

This diff is large: its 143 lines aren’t shown here.

packages/shared/src/dpop.test.ts+205−0

This diff is large: its 206 lines aren’t shown here.

packages/shared/src/dpop.ts+200−0

This diff is large: its 201 lines aren’t shown here.

packages/shared/src/dpopCommon.ts+20−0

This diff is large: its 21 lines aren’t shown here.

packages/shared/src/oauthScope.ts+11−1

This diff is large: its 26 lines aren’t shown here.

packages/shared/src/relayAuth.ts+6−0

This diff is large: its 7 lines aren’t shown here.

packages/shared/src/relayJwt.ts+69−0

This diff is large: its 70 lines aren’t shown here.

packages/shared/src/relaySigning.test.ts+18−0

This diff is large: its 19 lines aren’t shown here.

packages/shared/src/relaySigning.ts+17−0

This diff is large: its 18 lines aren’t shown here.

pnpm-workspace.yaml+7−0

This diff is large: its 35 lines aren’t shown here.

scripts/build-desktop-artifact.ts+6−0

This diff is large: its 13 lines aren’t shown here.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t