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
55 *.log
66 *.tsbuildinfo
77 apps/*/dist
8+infra/*/dist
89 .astro
910 packages/*/dist
1011 .env
@@ -26,3 +27,8 @@ squashfs-root/ · 15 unmodified lines
2627 .gstack/
2728 dist-electron/
2829 .electron-runtime/
30+node_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
2929 - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
3030 - `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket.
3131 - `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.
33−
34−## Codex App Server (Important)
35−
36−T3 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−
38−How 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−
45−Docs:
46−
47−- Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server
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+- `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile.
4834
4935 ## Reference Repos
5036
README.md+4−0
@@ -48,6 +48,10 @@ We are not accepting contributions yet. · 47 unmodified lines
4848
4949 Observability guide: [docs/observability.md](./docs/observability.md)
5050
51+Relay observability: [docs/relay-observability.md](./docs/relay-observability.md)
52+
53+T3 Cloud Clerk setup: [docs/t3-cloud-clerk.md](./docs/t3-cloud-clerk.md)
54+
5155 ## If you REALLY want to contribute still.... read this first
5256
5357 Before 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
22 import { watch } from "node:fs";
33 import { join } from "node:path";
44
5−import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
5+import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs";
66 import { waitForResources } from "./wait-for-resources.mjs";
77
88 const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
@@ -28,6 +28,7 @@ const watchedDirectories = [ · 19 unmodified lines
2828 const forcedShutdownTimeoutMs = 1_500;
2929 const restartDebounceMs = 120;
3030 const childTreeGracePeriodMs = 1_200;
31+const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim();
3132
3233 await waitForResources({
3334 baseDir: desktopDir,
@@ -38,6 +39,11 @@ await waitForResources({ · 4 unmodified lines
3839
3940 const childEnv = { ...process.env };
4041 delete childEnv.ELECTRON_RUN_AS_NODE;
42+const devProtocolClient = resolveDevProtocolClient();
43+if (devProtocolClient) {
44+ childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId;
45+ childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1";
46+}
4147
4248 let shuttingDown = false;
4349 let restartTimer = null;
@@ -67,15 +73,17 @@ function startApp() { · 23 unmodified lines
6773 return;
6874 }
6975
70− const app = spawn(
71− resolveElectronPath(),
72− [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"],
73− {
74− cwd: desktopDir,
75− env: childEnv,
76− stdio: "inherit",
77− },
78− );
76+ const electronArgs = remoteDebuggingPort
77+ ? [`--remote-debugging-port=${remoteDebuggingPort}`]
78+ : [];
79+ const launchArgs = devProtocolClient
80+ ? electronArgs
81+ : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"];
82+ const app = spawn(resolveElectronPath(), launchArgs, {
83+ cwd: desktopDir,
84+ env: childEnv,
85+ stdio: "inherit",
86+ });
7987
8088 currentApp = app;
8189
@@ -125,6 +133,7 @@ async function stopApp() { · 43 unmodified lines
125133 app.once("exit", finish);
126134 app.kill("SIGTERM");
127135 killChildTreeByPid(app.pid, "TERM");
136+ cleanupStaleDevApps();
128137
129138 setTimeout(() => {
130139 if (settled) {
@@ -133,6 +142,7 @@ async function stopApp() { · 2 unmodified lines
133142
134143 app.kill("SIGKILL");
135144 killChildTreeByPid(app.pid, "KILL");
145+ cleanupStaleDevApps();
136146 finish();
137147 }, forcedShutdownTimeoutMs).unref();
138148 });
apps/desktop/scripts/electron-launcher.mjs+162−10
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 import { spawnSync } from "node:child_process";
44 import {
55 copyFileSync,
6+ chmodSync,
67 cpSync,
78 existsSync,
89 mkdirSync,
@@ -13,21 +14,34 @@ import { · 4 unmodified lines
1314 writeFileSync,
1415 } from "node:fs";
1516 import { createRequire } from "node:module";
16−import { dirname, join, resolve } from "node:path";
17+import { basename, dirname, join, resolve } from "node:path";
1718 import { fileURLToPath } from "node:url";
1819 import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";
1920
2021 const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
21−const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
22−const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
23−const LAUNCHER_VERSION = 2;
24−
2522 const __dirname = dirname(fileURLToPath(import.meta.url));
2623 export const desktopDir = resolve(__dirname, "..");
2724 const repoRoot = resolve(desktopDir, "..", "..");
25+const devBundleIdSuffix = basename(repoRoot)
26+ .toLowerCase()
27+ .replaceAll(/[^a-z0-9]+/g, "");
28+export const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
29+export const APP_BUNDLE_ID = isDevelopment
30+ ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
31+ : "com.t3tools.t3code";
32+const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
33+const LAUNCHER_VERSION = 10;
2834 const defaultIconPath = join(desktopDir, "resources", "icon.icns");
2935 const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");
3036
37+function 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+
3145 function setPlistString(plistPath, key, value) {
3246 const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], {
3347 encoding: "utf8",
@@ -47,6 +61,26 @@ function setPlistString(plistPath, key, value) { · 13 unmodified lines
4761 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());
4862 }
4963
64+function 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+
5084 function runChecked(command, args) {
5185 const result = spawnSync(command, args, { encoding: "utf8" });
5286 if (result.status === 0) {
@@ -57,6 +91,71 @@ function runChecked(command, args) { · 4 unmodified lines
5791 throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim());
5892 }
5993
94+function shellSingleQuote(value) {
95+ return `'${value.replaceAll("'", "'\\''")}'`;
96+}
97+
98+function 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+
133+function 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+
60159 function ensureDevelopmentIconIcns(runtimeDir) {
61160 const generatedIconPath = join(runtimeDir, "icon-dev.icns");
62161 mkdirSync(runtimeDir, { recursive: true });
@@ -115,12 +214,49 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { · 52 unmodified lines
115214 setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME);
116215 setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID);
117216 setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns");
217+ setPlistJson(infoPlistPath, "CFBundleURLTypes", [
218+ {
219+ CFBundleURLName: APP_BUNDLE_ID,
220+ CFBundleURLSchemes: APP_PROTOCOL_SCHEMES,
221+ },
222+ ]);
118223
119224 const resourcesDir = join(appBundlePath, "Contents", "Resources");
120225 copyFileSync(iconPath, join(resourcesDir, "icon.icns"));
121226 copyFileSync(iconPath, join(resourcesDir, "electron.icns"));
122227 }
123228
229+function 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+
124260 function readJson(path) {
125261 try {
126262 return JSON.parse(readFileSync(path, "utf8"));
@@ -144,6 +280,8 @@ function buildMacLauncher(electronBinaryPath) { · 17 unmodified lines
144280 sourceAppBundlePath,
145281 sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs,
146282 iconMtimeMs: statSync(iconPath).mtimeMs,
283+ appBundleId: APP_BUNDLE_ID,
284+ appProtocolSchemes: APP_PROTOCOL_SCHEMES,
147285 };
148286
149287 const currentMetadata = readJson(metadataPath);
@@ -152,13 +290,19 @@ function buildMacLauncher(electronBinaryPath) { · 2 unmodified lines
152290 currentMetadata &&
153291 JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata)
154292 ) {
293+ registerMacLauncherBundle(targetAppBundlePath);
155294 return targetBinaryPath;
156295 }
157296
158297 rmSync(targetAppBundlePath, { recursive: true, force: true });
159298 cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true });
160299 patchMainBundleInfoPlist(targetAppBundlePath, iconPath);
300+ patchHelperBundleInfoPlists(targetAppBundlePath);
301+ if (isDevelopment) {
302+ writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath);
303+ }
161304 writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`);
305+ registerMacLauncherBundle(targetAppBundlePath);
162306
163307 return targetBinaryPath;
164308 }
@@ -173,11 +317,19 @@ export function resolveElectronPath() { · 8 unmodified lines
173317 return electronBinaryPath;
174318 }
175319
176− // Dev launches do not need a renamed app bundle badly enough to risk breaking
177− // Electron helper resource lookup on macOS.
178− if (isDevelopment) {
179− return electronBinaryPath;
320+ return buildMacLauncher(electronBinaryPath);
321+}
322+
323+export function resolveDevProtocolClient() {
324+ if (process.platform !== "darwin" || !isDevelopment) {
325+ return null;
180326 }
181327
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+ };
183335 }
apps/desktop/src/app/DesktopApp.ts+4−1
@@ -1,16 +1,17 @@
11 import * as Cause from "effect/Cause";
2−import * as Crypto from "effect/Crypto";
32 import * as Data from "effect/Data";
43 import * as Effect from "effect/Effect";
54 import * as Option from "effect/Option";
65 import * as Ref from "effect/Ref";
76
87 import * as NetService from "@t3tools/shared/Net";
8+import * as Crypto from "effect/Crypto";
99 import * as ElectronApp from "../electron/ElectronApp.ts";
1010 import * as ElectronDialog from "../electron/ElectronDialog.ts";
1111 import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
1212 import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
1313 import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
14+import * as DesktopCloudAuth from "./DesktopCloudAuth.ts";
1415 import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
1516 import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
1617 import * as DesktopEnvironment from "./DesktopEnvironment.ts";
@@ -190,6 +191,7 @@ const startup = Effect.gen(function* () { · 173 unmodified lines
190191 const electronApp = yield* ElectronApp.ElectronApp;
191192 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
192193 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
194+ const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth;
193195 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
194196 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
195197 const updates = yield* DesktopUpdates.DesktopUpdates;
@@ -207,6 +209,7 @@ const startup = Effect.gen(function* () { · 11 unmodified lines
207209
208210 yield* appIdentity.configure;
209211 yield* lifecycle.register;
212+ yield* cloudAuth.configure;
210213
211214 yield* electronApp.whenReady.pipe(
212215 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
5353 calls.setAboutPanelOptions.push(options);
5454 }),
5555 setAppUserModelId: () => Effect.void,
56+ requestSingleInstanceLock: Effect.succeed(true),
57+ isDefaultProtocolClient: () => Effect.succeed(false),
58+ setAsDefaultProtocolClient: () => Effect.succeed(true),
5659 setDesktopName: () => Effect.void,
5760 setDockIcon: (iconPath) =>
5861 Effect.sync(() => {
apps/desktop/src/app/DesktopCloudAuth.test.ts+302−0
@@ -0,0 +1,302 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
6+
7+import * as ElectronApp from "../electron/ElectronApp.ts";
8+import * as ElectronWindow from "../electron/ElectronWindow.ts";
9+import * as IpcChannels from "../ipc/channels.ts";
10+import * as DesktopCloudAuth from "./DesktopCloudAuth.ts";
11+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12+
13+interface 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+
32+function 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+
119+function 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+
129+const flushCloudAuthDispatch = Effect.promise(() => Promise.resolve());
130+
131+describe("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 @@
1+import * as Context from "effect/Context";
2+import * as Crypto from "effect/Crypto";
3+import * as Data from "effect/Data";
4+import * as Effect from "effect/Effect";
5+import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+import * as Scope from "effect/Scope";
8+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
9+
10+import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
11+import * as ElectronApp from "../electron/ElectronApp.ts";
12+import * as ElectronWindow from "../electron/ElectronWindow.ts";
13+import * as IpcChannels from "../ipc/channels.ts";
14+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
15+import type * as Electron from "electron";
16+
17+export const CLOUD_AUTH_CALLBACK_HOST = "auth";
18+export const CLOUD_AUTH_CALLBACK_PATHNAME = "/callback";
19+export const CLOUD_AUTH_CALLBACK_STATE_PARAM = "t3_state";
20+export const CLOUD_AUTH_CALLBACK_SCHEME = "t3code";
21+export const DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME = "t3code-dev";
22+
23+const CLOUD_AUTH_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
24+
25+export 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+
35+interface PendingCloudAuthRequest {
36+ readonly state: string;
37+ readonly redirectUrl: string;
38+ readonly close: () => void;
39+}
40+
41+export 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+
50+export class DesktopCloudAuth extends Context.Service<DesktopCloudAuth, DesktopCloudAuthShape>()(
51+ "@t3tools/desktop/app/DesktopCloudAuth",
52+) {}
53+
54+export function resolveCloudAuthCallbackScheme(input: { readonly isDevelopment: boolean }): string {
55+ return input.isDevelopment ? DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME : CLOUD_AUTH_CALLBACK_SCHEME;
56+}
57+
58+export 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+
69+export 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+
90+export 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+
106+export function resolveProtocolClientLaunchArgs(input: {
107+ readonly argv: readonly string[];
108+}): readonly string[] {
109+ return input.argv.slice(1);
110+}
111+
112+function 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+
128+function isProtocolRegistrationManagedExternally(): boolean {
129+ return process.env.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED?.trim() === "1";
130+}
131+
132+function 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+
148+const closeCloudAuthRequest = (request: PendingCloudAuthRequest | null): null => {
149+ request?.close();
150+ return null;
151+};
152+
153+function 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+
158+function ignoreCloudAuthCallback(_rawUrl: string) {}
159+
160+function 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+
190+const 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+
330+export const layer = Layer.effect(DesktopCloudAuth, make);
apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts+96−0
@@ -0,0 +1,96 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, describe, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as FileSystem from "effect/FileSystem";
5+import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+
8+import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
9+import * as DesktopConfig from "./DesktopConfig.ts";
10+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
11+import * as DesktopCloudAuthTokenStore from "./DesktopCloudAuthTokenStore.ts";
12+
13+const textDecoder = new TextDecoder();
14+const textEncoder = new TextEncoder();
15+
16+function 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+
34+function 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+
58+const 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+
70+describe("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 @@
1+import { fromLenientJson } from "@t3tools/shared/schemaJson";
2+import * as Context from "effect/Context";
3+import * as Crypto from "effect/Crypto";
4+import * as Data from "effect/Data";
5+import * as Effect from "effect/Effect";
6+import * as Encoding from "effect/Encoding";
7+import * as FileSystem from "effect/FileSystem";
8+import * as Layer from "effect/Layer";
9+import * as Option from "effect/Option";
10+import * as Path from "effect/Path";
11+import * as PlatformError from "effect/PlatformError";
12+import * as Schema from "effect/Schema";
13+
14+import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
15+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
16+
17+interface CloudAuthTokenDocument {
18+ readonly version: number;
19+ readonly encryptedClientJwt: string;
20+}
21+
22+const CloudAuthTokenDocumentSchema = Schema.Struct({
23+ version: Schema.Number,
24+ encryptedClientJwt: Schema.String,
25+});
26+
27+const CloudAuthTokenDocumentJson = fromLenientJson(CloudAuthTokenDocumentSchema);
28+const decodeCloudAuthTokenDocumentJson = Schema.decodeEffect(CloudAuthTokenDocumentJson);
29+const encodeCloudAuthTokenDocumentJson = Schema.encodeEffect(CloudAuthTokenDocumentJson);
30+
31+export 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+
41+export 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+
51+export 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+
69+export class DesktopCloudAuthTokenStore extends Context.Service<
70+ DesktopCloudAuthTokenStore,
71+ DesktopCloudAuthTokenStoreShape
72+>()("@t3tools/desktop/app/DesktopCloudAuthTokenStore") {}
73+
74+function 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+
82+const 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+
96+const 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+
111+export 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
3737 xdgConfigHome: trimmedString("XDG_CONFIG_HOME"),
3838 t3Home: trimmedString("T3CODE_HOME"),
3939 devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option),
40+ appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"),
4041 devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"),
4142 configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option),
4243 commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"),
apps/desktop/src/app/DesktopEnvironment.test.ts+14−0
@@ -93,6 +93,20 @@ describe("DesktopEnvironment", () => { · 92 unmodified lines
9393 }),
9494 );
9595
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+
96110 it.effect("resolves picker defaults without nullish sentinels", () =>
97111 Effect.gen(function* () {
98112 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
199199 otlpExportIntervalMs: config.otlpExportIntervalMs,
200200 branding,
201201 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+ ),
203205 linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop",
204206 linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
205207 userDataDirName,
apps/desktop/src/electron/ElectronApp.test.ts+9−0
@@ -7,12 +7,15 @@ const { · 6 unmodified lines
77 exitMock,
88 getAppPathMock,
99 getVersionMock,
10+ isDefaultProtocolClientMock,
1011 onMock,
1112 quitMock,
1213 relaunchMock,
1314 removeListenerMock,
15+ requestSingleInstanceLockMock,
1416 setAboutPanelOptionsMock,
1517 setAppUserModelIdMock,
18+ setAsDefaultProtocolClientMock,
1619 setDesktopNameMock,
1720 setDockIconMock,
1821 setNameMock,
@@ -23,12 +26,15 @@ const { · 4 unmodified lines
2326 exitMock: vi.fn(),
2427 getAppPathMock: vi.fn(() => "/app"),
2528 getVersionMock: vi.fn(() => "1.2.3"),
29+ isDefaultProtocolClientMock: vi.fn(() => false),
2630 onMock: vi.fn(),
2731 quitMock: vi.fn(),
2832 relaunchMock: vi.fn(),
2933 removeListenerMock: vi.fn(),
34+ requestSingleInstanceLockMock: vi.fn(() => true),
3035 setAboutPanelOptionsMock: vi.fn(),
3136 setAppUserModelIdMock: vi.fn(),
37+ setAsDefaultProtocolClientMock: vi.fn(() => true),
3238 setDesktopNameMock: vi.fn(),
3339 setDockIconMock: vi.fn(),
3440 setNameMock: vi.fn(),
@@ -46,14 +52,17 @@ vi.mock("electron", () => ({ · 11 unmodified lines
4652 },
4753 getAppPath: getAppPathMock,
4854 getVersion: getVersionMock,
55+ isDefaultProtocolClient: isDefaultProtocolClientMock,
4956 isPackaged: true,
5057 name: "T3 Code",
5158 on: onMock,
5259 quit: quitMock,
5360 relaunch: relaunchMock,
5461 removeListener: removeListenerMock,
62+ requestSingleInstanceLock: requestSingleInstanceLockMock,
5563 runningUnderARM64Translation: false,
5664 setAboutPanelOptions: setAboutPanelOptionsMock,
65+ setAsDefaultProtocolClient: setAsDefaultProtocolClientMock,
5766 setAppUserModelId: setAppUserModelIdMock,
5867 setDesktopName: setDesktopNameMock,
5968 setName: setNameMock,
apps/desktop/src/electron/ElectronApp.ts+17−0
@@ -29,6 +29,13 @@ export interface ElectronAppShape { · 28 unmodified lines
2929 options: Electron.AboutPanelOptionsOptions,
3030 ) => Effect.Effect<void>;
3131 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>;
3239 readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;
3340 readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;
3441 readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;
@@ -93,6 +100,16 @@ const make = ElectronApp.of({ · 58 unmodified lines
93100 Effect.sync(() => {
94101 Electron.app.setAppUserModelId(id);
95102 }),
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+ }),
96113 setDesktopName: (desktopName) =>
97114 Effect.sync(() => {
98115 const linuxApp = Electron.app as Electron.App & {
apps/desktop/src/ipc/DesktopIpcHandlers.ts+12−0
@@ -1,6 +1,13 @@
11 import * as Effect from "effect/Effect";
22
33 import * as DesktopIpc from "./DesktopIpc.ts";
4+import {
5+ clearCloudAuthToken,
6+ createCloudAuthRequest,
7+ fetchCloudAuth,
8+ getCloudAuthToken,
9+ setCloudAuthToken,
10+} from "./methods/cloudAuth.ts";
411 import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
512 import {
613 getSavedEnvironmentRegistry,
@@ -75,6 +82,11 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { · 68 unmodified lines
7582 yield* ipc.handle(setTheme);
7683 yield* ipc.handle(showContextMenu);
7784 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);
7890
7991 yield* ipc.handle(getUpdateState);
8092 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
33 export const SET_THEME_CHANNEL = "desktop:set-theme";
44 export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
55 export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
6+export const CREATE_CLOUD_AUTH_REQUEST_CHANNEL = "desktop:create-cloud-auth-request";
7+export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token";
8+export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token";
9+export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token";
10+export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth";
11+export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback";
612 export const MENU_ACTION_CHANNEL = "desktop:menu-action";
713 export const UPDATE_STATE_CHANNEL = "desktop:update-state";
814 export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
apps/desktop/src/ipc/methods/cloudAuth.ts+128−0
@@ -0,0 +1,128 @@
1+import {
2+ DesktopCloudAuthFetchInputSchema,
3+ DesktopCloudAuthFetchResultSchema,
4+} from "@t3tools/contracts";
5+import * as Data from "effect/Data";
6+import * as Effect from "effect/Effect";
7+import * as Option from "effect/Option";
8+import * as Schema from "effect/Schema";
9+import { HttpClient, HttpClientRequest } from "effect/unstable/http";
10+
11+import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts";
12+import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts";
13+import * as IpcChannels from "../channels.ts";
14+import { makeIpcMethod } from "../DesktopIpc.ts";
15+
16+export 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+
25+const allowedClerkFrontendApiHosts = (hostname: string): boolean =>
26+ hostname.endsWith(".clerk.accounts.dev") || hostname.endsWith(".clerk.accounts.com");
27+
28+function 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+
38+export 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+
48+export 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+
58+export 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+
68+export 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+
78+export 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
2626 import * as ElectronWindow from "./electron/ElectronWindow.ts";
2727 import * as DesktopApp from "./app/DesktopApp.ts";
2828 import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
29+import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts";
30+import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts";
2931 import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
3032 import * as DesktopAssets from "./app/DesktopAssets.ts";
3133 import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
@@ -111,6 +113,7 @@ const desktopFoundationLayer = Layer.mergeAll( · 79 unmodified lines
111113 DesktopAppSettings.layer,
112114 DesktopClientSettings.layer,
113115 DesktopSavedEnvironments.layer,
116+ DesktopCloudAuthTokenStore.layer,
114117 DesktopAssets.layer,
115118 DesktopObservability.layer,
116119 ).pipe(Layer.provideMerge(desktopEnvironmentLayer));
@@ -135,6 +138,7 @@ const desktopBackendLayer = DesktopBackendManager.layer.pipe( · 18 unmodified lines
135138 const desktopApplicationLayer = Layer.mergeAll(
136139 DesktopLifecycle.layer,
137140 DesktopApplicationMenu.layer,
141+ DesktopCloudAuth.layer,
138142 DesktopShellEnvironment.layer,
139143 desktopSshLayer,
140144 ).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
9696 ...(position === undefined ? {} : { position }),
9797 }),
9898 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+ },
99116 onMenuAction: (listener) => {
100117 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
101118 if (typeof action !== "string") return;
apps/desktop/src/settings/DesktopSavedEnvironments.ts+12−14
@@ -53,6 +53,7 @@ const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ · 52 unmodified lines
5353 createdAt: Schema.String,
5454 lastConnectedAt: Schema.NullOr(Schema.String),
5555 desktopSsh: Schema.optionalKey(DesktopSshTargetSchema),
56+ relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })),
5657 encryptedBearerToken: Schema.optionalKey(Schema.String),
5758 });
5859
@@ -134,7 +135,11 @@ function toPersistedSavedEnvironmentRecord( · 75 unmodified lines
134135 createdAt: record.createdAt,
135136 lastConnectedAt: record.lastConnectedAt,
136137 };
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+ };
138143 }
139144
140145 function toSavedEnvironmentStorageRecord(
@@ -149,20 +154,13 @@ function toSavedEnvironmentStorageRecord( · 8 unmodified lines
149154 createdAt: record.createdAt,
150155 lastConnectedAt: record.lastConnectedAt,
151156 };
152− const desktopSsh = record.desktopSsh;
153− if (desktopSsh) {
154− return Option.match(encryptedBearerToken, {
155− onNone: () => ({ ...nextRecord, desktopSsh }),
156− onSome: (value) => ({
157− ...nextRecord,
158− desktopSsh,
159− encryptedBearerToken: value,
160− }),
161− });
162− }
157+ const metadata = {
158+ ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
159+ ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
160+ };
163161 return Option.match(encryptedBearerToken, {
164− onNone: () => nextRecord,
165− onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }),
162+ onNone: () => ({ ...nextRecord, ...metadata }),
163+ onSome: (value) => ({ ...nextRecord, ...metadata, encryptedBearerToken: value }),
166164 });
167165 }
168166
apps/desktop/src/window/DesktopApplicationMenu.test.ts+3−0
@@ -39,6 +39,9 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { · 38 unmodified lines
3939 setName: () => Effect.void,
4040 setAboutPanelOptions: () => Effect.void,
4141 setAppUserModelId: () => Effect.void,
42+ requestSingleInstanceLock: Effect.succeed(true),
43+ isDefaultProtocolClient: () => Effect.succeed(false),
44+ setAsDefaultProtocolClient: () => Effect.succeed(true),
4245 setDesktopName: () => Effect.void,
4346 setDockIcon: () => Effect.void,
4447 appendCommandLineSwitch: () => Effect.void,
apps/mobile/app.config.ts+28−4
@@ -96,6 +96,11 @@ const config: ExpoConfig = { · 95 unmodified lines
9696 favicon: "./assets/favicon.png",
9797 },
9898 plugins: [
99+ "expo-router",
100+ "expo-font",
101+ "expo-secure-store",
102+ ["@clerk/expo", { theme: "./clerk-theme.json" }],
103+ "expo-web-browser",
99104 [
100105 "expo-camera",
101106 {
@@ -120,17 +125,36 @@ const config: ExpoConfig = { · 18 unmodified lines
120125 "expo-build-properties",
121126 {
122127 ios: {
123− deploymentTarget: "16.4",
128+ deploymentTarget: "18.0",
124129 },
125130 },
126131 ],
127− "expo-secure-store",
128− "expo-router",
129− "expo-font",
132+ [
133+ "expo-widgets",
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+ ],
130148 "./plugins/withAndroidCleartextTraffic.cjs",
131149 ],
132150 extra: {
133151 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+ },
134158 eas: {
135159 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
136160 },
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
3838 --color-secondary: #ffffff;
3939 --color-secondary-foreground: #262626;
4040 --color-secondary-border: rgba(0, 0, 0, 0.08);
41+ --color-switch-active: #34c759;
4142
4243 /* Danger */
4344 --color-danger: #fef2f2;
@@ -125,6 +126,7 @@ · 81 unmodified lines
125126 --color-secondary: rgba(255, 255, 255, 0.04);
126127 --color-secondary-foreground: #f5f5f5;
127128 --color-secondary-border: rgba(255, 255, 255, 0.06);
129+ --color-switch-active: #30d158;
128130
129131 /* Danger */
130132 --color-danger: rgba(239, 68, 68, 0.14);
apps/mobile/package.json+8−0
@@ -39,10 +39,13 @@ · 38 unmodified lines
3939 },
4040 "dependencies": {
4141 "@callstack/liquid-glass": "^0.7.1",
42+ "@clerk/expo": "^3.3.0",
4243 "@effect/atom-react": "catalog:",
4344 "@expo-google-fonts/dm-sans": "^0.4.2",
4445 "@expo/ui": "~56.0.8",
4546 "@legendapp/list": "3.0.0-beta.44",
47+ "@noble/curves": "catalog:",
48+ "@noble/hashes": "catalog:",
4649 "@pierre/diffs": "catalog:",
4750 "@react-native-menu/menu": "^2.0.0",
4851 "@shikijs/core": "3.23.0",
@@ -58,6 +61,7 @@ · 9 unmodified lines
5861 "diff": "8.0.3",
5962 "effect": "catalog:",
6063 "expo": "^56.0.0",
64+ "expo-auth-session": "~56.0.12",
6165 "expo-build-properties": "~56.0.15",
6266 "expo-camera": "~56.0.7",
6367 "expo-clipboard": "~56.0.3",
@@ -70,12 +74,15 @@ · 6 unmodified lines
7074 "expo-haptics": "~56.0.3",
7175 "expo-image-picker": "~56.0.14",
7276 "expo-linking": "~56.0.12",
77+ "expo-notifications": "~56.0.14",
7378 "expo-paste-input": "^0.1.15",
7479 "expo-router": "~56.2.7",
7580 "expo-secure-store": "~56.0.4",
7681 "expo-splash-screen": "~56.0.10",
7782 "expo-symbols": "~56.0.5",
7883 "expo-updates": "~56.0.17",
84+ "expo-web-browser": "~56.0.5",
85+ "expo-widgets": "~56.0.15",
7986 "punycode": "^2.3.1",
8087 "react": "19.2.3",
8188 "react-dom": "19.2.3",
@@ -96,6 +103,7 @@ · 14 unmodified lines
96103 "uniwind": "^1.6.2"
97104 },
98105 "devDependencies": {
106+ "@effect/vitest": "catalog:",
99107 "@types/react": "~19.2.0",
100108 "babel-preset-expo": "~56.0.0",
101109 "tailwindcss": "^4.0.0",
apps/mobile/src/app/_layout.tsx+24−9
@@ -20,12 +20,15 @@ import { · 19 unmodified lines
2020 } from "../state/use-remote-environment-registry";
2121 import { RegistryContext } from "@effect/atom-react";
2222 import { appAtomRegistry } from "../state/atom-registry";
23+import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";
24+import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation";
2325
2426 function AppNavigator() {
2527 const { isLoadingSavedConnection } = useRemoteEnvironmentState();
2628 const colorScheme = useColorScheme();
2729 const statusBarBg = useCSSVariable("--color-status-bar");
2830 const sheetStyle = useResolveClassNames("bg-sheet");
31+ useAgentNotificationNavigation();
2932
3033 const newTaskScreenOptions = {
3134 contentStyle: sheetStyle,
@@ -45,6 +48,11 @@ function AppNavigator() { · 13 unmodified lines
4548 sheetGrabberVisible: true,
4649 };
4750
51+ const settingsSheetScreenOptions = {
52+ ...connectionSheetScreenOptions,
53+ sheetAllowedDetents: [0.7],
54+ };
55+
4856 if (isLoadingSavedConnection) {
4957 return <LoadingScreen message="Loading remote workspace…" />;
5058 }
@@ -66,6 +74,7 @@ function AppNavigator() { · 15 unmodified lines
6674 headerShadowVisible: false,
6775 }}
6876 />
77+ <Stack.Screen name="settings" options={settingsSheetScreenOptions} />
6978 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />
7079 <Stack.Screen name="new" options={newTaskScreenOptions} />
7180 <Stack.Screen
@@ -91,14 +100,20 @@ export default function RootLayout() { · 19 unmodified lines
91100 useRemoteEnvironmentBootstrap();
92101
93102 return (
94− <RegistryContext value={appAtomRegistry}>
95− <GestureHandlerRootView style={{ flex: 1 }}>
96− <KeyboardProvider statusBarTranslucent>
97− <SafeAreaProvider>
98− {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />}
99− </SafeAreaProvider>
100− </KeyboardProvider>
101− </GestureHandlerRootView>
102− </RegistryContext>
103+ <RegistryContext.Provider value={appAtomRegistry}>
104+ <CloudAuthProvider>
105+ <GestureHandlerRootView style={{ flex: 1 }}>
106+ <KeyboardProvider statusBarTranslucent>
107+ <SafeAreaProvider>
108+ {fontsLoaded ? (
109+ <AppNavigator />
110+ ) : (
111+ <LoadingScreen message="Loading remote workspace…" />
112+ )}
113+ </SafeAreaProvider>
114+ </KeyboardProvider>
115+ </GestureHandlerRootView>
116+ </CloudAuthProvider>
117+ </RegistryContext.Provider>
103118 );
104119 }
apps/mobile/src/app/connections/index.tsx+7−3
@@ -22,8 +22,8 @@ export default function ConnectionsRouteScreen() { · 21 unmodified lines
2222 const hasEnvironments = connectedEnvironments.length > 0;
2323 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
2424
25− const primaryFg = useThemeColor("--color-primary-foreground");
2625 const accentColor = useThemeColor("--color-icon-muted");
26+ const iconColor = useThemeColor("--color-icon");
2727
2828 const handleToggle = useCallback((environmentId: EnvironmentId) => {
2929 setExpandedId((prev) => (prev === environmentId ? null : environmentId));
@@ -36,11 +36,15 @@ export default function ConnectionsRouteScreen() { · 6 unmodified lines
3636 title: "Environments",
3737 headerRight: () => (
3838 <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+ >
4044 <SymbolView
4145 name="plus"
4246 size={18}
43− tintColor={primaryFg}
47+ tintColor={iconColor}
4448 type="monochrome"
4549 weight="semibold"
4650 />
apps/mobile/src/app/connections/new.tsx+3−1
@@ -130,7 +130,9 @@ export default function ConnectionsNewRouteScreen() { · 129 unmodified lines
130130 title: showScanner ? "Scan QR Code" : "Add Environment",
131131 headerRight: () => (
132132 <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"
134136 onPress={() => {
135137 if (showScanner) {
136138 closeScanner();
apps/mobile/src/app/index.tsx+5−10
@@ -78,16 +78,11 @@ export default function HomeRouteScreen() { · 77 unmodified lines
7878 </Stack.Toolbar>
7979
8080 <Stack.Toolbar placement="right">
81− <Stack.Toolbar.Menu icon="ellipsis.circle" separateBackground>
82− <Stack.Toolbar.Label>More</Stack.Toolbar.Label>
83− <Stack.Toolbar.MenuAction
84− icon="desktopcomputer"
85− onPress={() => router.push("/connections")}
86− subtitle="Manage connected hosts"
87− >
88− <Stack.Toolbar.Label>Environments</Stack.Toolbar.Label>
89− </Stack.Toolbar.MenuAction>
90− </Stack.Toolbar.Menu>
81+ <Stack.Toolbar.Button
82+ icon="gearshape"
83+ onPress={() => router.push("/settings")}
84+ separateBackground
85+ />
9186 </Stack.Toolbar>
9287
9388 {/* Bottom toolbar: search + compose, visually split like iMessage */}
apps/mobile/src/app/settings/_layout.tsx+41−0
@@ -0,0 +1,41 @@
1+import Stack from "expo-router/stack";
2+import { useResolveClassNames } from "uniwind";
3+
4+import { useThemeColor } from "../../lib/useThemeColor";
5+
6+export const unstable_settings = {
7+ anchor: "index",
8+};
9+
10+export 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 @@
1+export { default } from "../connections/new";
apps/mobile/src/app/settings/environments.tsx+335−0
@@ -0,0 +1,335 @@
1+import { useAuth } from "@clerk/expo";
2+import { Link, Stack } from "expo-router";
3+import { SymbolView } from "expo-symbols";
4+import type { EnvironmentId } from "@t3tools/contracts";
5+import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
6+import * as Effect from "effect/Effect";
7+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8+import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
9+import { useSafeAreaInsets } from "react-native-safe-area-context";
10+
11+import { AppText as Text } from "../../components/AppText";
12+import {
13+ cloudEnvironmentsPendingStatus,
14+ type CloudEnvironmentRecordWithStatus,
15+ connectCloudEnvironment,
16+ listCloudEnvironments,
17+ loadCloudEnvironmentStatuses,
18+} from "../../features/cloud/linkEnvironment";
19+import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
20+import { cn } from "../../lib/cn";
21+import { mobileRuntime } from "../../lib/runtime";
22+import { useThemeColor } from "../../lib/useThemeColor";
23+import {
24+ connectSavedEnvironment,
25+ useRemoteConnections,
26+ useRemoteEnvironmentState,
27+} from "../../state/use-remote-environment-registry";
28+
29+export 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+
279+function 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 @@
1+import { useAuth, useUser, useUserProfileModal } from "@clerk/expo";
2+import * as Notifications from "expo-notifications";
3+import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
4+import { Link, Stack, useRouter } from "expo-router";
5+import { SymbolView } from "expo-symbols";
6+import * as Effect from "effect/Effect";
7+import { useCallback, useEffect, useMemo, useState } from "react";
8+import type { ComponentProps, ReactNode } from "react";
9+import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native";
10+import { useSafeAreaInsets } from "react-native-safe-area-context";
11+
12+import { AppText as Text } from "../../components/AppText";
13+import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences";
14+import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";
15+import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration";
16+import { mobileRuntime } from "../../lib/runtime";
17+import { loadPreferences } from "../../lib/storage";
18+import { useThemeColor } from "../../lib/useThemeColor";
19+import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
20+
21+type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
22+type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
23+
24+export 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+
307+type SymbolName = ComponentProps<typeof SymbolView>["name"];
308+
309+function 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+
323+function 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+
373+function 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 @@
1+import Constants from "expo-constants";
2+import { Stack } from "expo-router";
3+import { ScrollView, Text, View } from "react-native";
4+
5+import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment";
6+import { useNativeClerkAuthModal } from "../../features/cloud/useNativeClerkAuthModal";
7+import { useThemeColor } from "../../lib/useThemeColor";
8+
9+function hasClerkConfig(): boolean {
10+ const clerkConfig = Constants.expoConfig?.extra?.clerk as
11+ | { readonly publishableKey?: string | null }
12+ | undefined;
13+ return Boolean(clerkConfig?.publishableKey);
14+}
15+
16+export 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 @@
1+import { afterEach, beforeEach, vi } from "vitest";
2+import { describe, expect, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import { ManagedRelayClient } from "@t3tools/client-runtime";
5+
6+import type { EnvironmentId, OrchestrationShellSnapshot, ThreadId } from "@t3tools/contracts";
7+import {
8+ __resetAgentLiveActivitiesForTest,
9+ refreshActiveLiveActivityRemoteRegistration,
10+ syncAgentLiveActivitiesForSnapshot,
11+} from "./liveActivityController";
12+import { registerLiveActivityPushToken } from "./remoteRegistration";
13+
14+const 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+
31+vi.mock("react-native", () => ({
32+ Platform: {
33+ OS: "ios",
34+ },
35+}));
36+
37+vi.mock("expo-linking", () => ({
38+ default: {
39+ createURL: vi.fn((path: string) => `t3code://${path}`),
40+ },
41+ createURL: vi.fn((path: string) => `t3code://${path}`),
42+}));
43+
44+vi.mock("expo-widgets", () => ({
45+ after: (date: Date) => ({ after: date }),
46+}));
47+
48+vi.mock("../../lib/runtime", () => ({
49+ mobileRuntime: {
50+ runPromise: <A, E>(operation: Effect.Effect<A, E>) => Effect.runPromise(operation),
51+ },
52+}));
53+
54+vi.mock("../../widgets/AgentActivity", () => ({
55+ default: {
56+ getInstances: mocks.getInstances,
57+ start: mocks.start,
58+ },
59+}));
60+
61+vi.mock("./remoteRegistration", () => ({
62+ registerLiveActivityPushToken: mocks.registerLiveActivityPushToken,
63+}));
64+
65+const runManagedRelayEffect = <A, E>(effect: Effect.Effect<A, E, ManagedRelayClient>): Promise<A> =>
66+ Effect.runPromise(effect.pipe(Effect.provideService(ManagedRelayClient, null as never)));
67+
68+function 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+
99+function 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+
127+describe("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 @@
1+import {
2+ isTerminalAgentAwarenessPhase,
3+ projectThreadAwareness,
4+ type AgentAwarenessState,
5+} from "@t3tools/shared/agentAwareness";
6+import type {
7+ EnvironmentId,
8+ OrchestrationProjectShell,
9+ OrchestrationShellSnapshot,
10+} from "@t3tools/contracts";
11+import * as DateTime from "effect/DateTime";
12+import * as Duration from "effect/Duration";
13+import * as Effect from "effect/Effect";
14+import * as Option from "effect/Option";
15+import * as Semaphore from "effect/Semaphore";
16+import { after, type LiveActivity } from "expo-widgets";
17+import * as Linking from "expo-linking";
18+import { Platform } from "react-native";
19+import { ManagedRelayClient } from "@t3tools/client-runtime";
20+
21+import { mobileRuntime } from "../../lib/runtime";
22+import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
23+import { registerLiveActivityPushToken } from "./remoteRegistration";
24+
25+const MAX_LOCAL_LIVE_ACTIVITIES = 3;
26+const FINAL_ACTIVITY_DISMISSAL_MS = 5 * 60 * 1_000;
27+const REMOTE_TOKEN_REGISTRATION_RETRY_MS = 15_000;
28+const MISSING_STATE_GRACE_MS = 30_000;
29+
30+interface 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+
39+let activeActivity: ActiveDeviceActivity | null = null;
40+const environmentStates = new Map<EnvironmentId, ReadonlyArray<AgentAwarenessState>>();
41+let localLiveActivitiesEnabled = true;
42+const liveActivitySyncSemaphore = Effect.runSync(Semaphore.make(1));
43+
44+function canUseLocalLiveActivities(): boolean {
45+ return localLiveActivitiesEnabled && Platform.OS === "ios";
46+}
47+
48+function logLiveActivityDebug(context: string, details?: unknown): void {
49+ if (!__DEV__) {
50+ return;
51+ }
52+ console.log(`[agent-awareness] ${context}`, details ?? "");
53+}
54+
55+function 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+
65+function 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+
84+function epochMillis(iso: string): number {
85+ return Option.match(DateTime.make(iso), {
86+ onNone: () => Number.NaN,
87+ onSome: (dt) => dt.epochMilliseconds,
88+ });
89+}
90+
91+function currentTimeMillis(): number {
92+ return Date.now();
93+}
94+
95+function currentDateTime(): DateTime.Utc {
96+ return DateTime.nowUnsafe();
97+}
98+
99+function 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+
126+function hasSameActivityContent(left: AgentActivityProps, right: AgentActivityProps): boolean {
127+ return JSON.stringify(left) === JSON.stringify(right);
128+}
129+
130+function sortByUpdatedAtDesc(left: AgentAwarenessState, right: AgentAwarenessState): number {
131+ return epochMillis(right.updatedAt) - epochMillis(left.updatedAt);
132+}
133+
134+function 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+
161+export 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+
185+export function endEnvironmentLiveActivities(
186+ environmentId: EnvironmentId,
187+): Effect.Effect<void, unknown> {
188+ return enqueueLiveActivitySync(endEnvironmentLiveActivitiesNow(environmentId));
189+}
190+
191+function 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+
200+export function endAllAgentLiveActivities(): Effect.Effect<void, unknown> {
201+ return enqueueLiveActivitySync(endAllAgentLiveActivitiesNow());
202+}
203+
204+function endAllAgentLiveActivitiesNow(): Effect.Effect<void, unknown> {
205+ return Effect.gen(function* () {
206+ environmentStates.clear();
207+ return yield* endActiveActivity(null, "all-ended");
208+ });
209+}
210+
211+function enqueueLiveActivitySync<T, R>(
212+ operation: Effect.Effect<T, unknown, R>,
213+): Effect.Effect<T, unknown, R> {
214+ return liveActivitySyncSemaphore.withPermits(1)(operation);
215+}
216+
217+function 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+
248+function 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+
328+function 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+
369+function 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+
415+function 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+
434+function 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+
479+export 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+
515+function 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+
556+type LiveActivityOperationResult<T> =
557+ | {
558+ readonly ok: true;
559+ readonly value: T;
560+ }
561+ | {
562+ readonly ok: false;
563+ };
564+
565+function 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+
597+function isMissingNativeLiveActivityError(error: unknown): boolean {
598+ return error instanceof Error && error.message.includes("Can't find live activity with id:");
599+}
600+
601+export 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 @@
1+import { beforeEach, vi } from "vitest";
2+import { describe, expect, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import type { EnvironmentId } from "@t3tools/contracts";
5+import { ManagedRelayClient } from "@t3tools/client-runtime";
6+import { HttpClient } from "effect/unstable/http";
7+
8+import type { SavedRemoteConnection } from "../../lib/connection";
9+import { savePreferencesPatch } from "../../lib/storage";
10+import { linkEnvironmentToCloud } from "../cloud/linkEnvironment";
11+import { endAllAgentLiveActivities } from "./liveActivityController";
12+import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences";
13+import { refreshAgentAwarenessRegistration } from "./remoteRegistration";
14+
15+vi.mock("../../lib/storage", () => ({
16+ savePreferencesPatch: vi.fn(() => Promise.resolve()),
17+}));
18+
19+vi.mock("../cloud/linkEnvironment", () => ({
20+ linkEnvironmentToCloud: vi.fn(() => Effect.void),
21+}));
22+
23+vi.mock("./liveActivityController", () => ({
24+ endAllAgentLiveActivities: vi.fn(() => Effect.void),
25+}));
26+
27+vi.mock("./remoteRegistration", () => ({
28+ refreshAgentAwarenessRegistration: vi.fn(() => Effect.void),
29+}));
30+
31+const 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+
41+const 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+
54+describe("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 @@
1+import * as Effect from "effect/Effect";
2+import { HttpClient } from "effect/unstable/http";
3+import { ManagedRelayClient } from "@t3tools/client-runtime";
4+
5+import type { SavedRemoteConnection } from "../../lib/connection";
6+import { savePreferencesPatch } from "../../lib/storage";
7+import { linkEnvironmentToCloud } from "../cloud/linkEnvironment";
8+import { endAllAgentLiveActivities } from "./liveActivityController";
9+import { refreshAgentAwarenessRegistration } from "./remoteRegistration";
10+
11+export 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 @@
1+import { describe, expect, it } from "vitest";
2+
3+import {
4+ extractAgentNotificationDeepLink,
5+ routeAgentNotificationResponseOnce,
6+} from "./notificationPayload";
7+
8+function 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+
21+describe("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+
84+describe("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 @@
1+import { useEffect, useRef } from "react";
2+import * as Notifications from "expo-notifications";
3+import { useRouter } from "expo-router";
4+
5+import { routeAgentNotificationResponseOnce } from "./notificationPayload";
6+
7+export 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 @@
1+function 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+
21+function 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+
37+function 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+
47+function 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+
72+export 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+
90+export 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 @@
1+import * as Notifications from "expo-notifications";
2+import * as Effect from "effect/Effect";
3+import { Platform } from "react-native";
4+
5+export type NotificationPermissionResult =
6+ | { readonly type: "unsupported" }
7+ | { readonly type: "granted" }
8+ | { readonly type: "denied"; readonly canAskAgain: boolean };
9+
10+export 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 @@
1+import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
2+
3+import type { MobilePreferences } from "../../lib/storage";
4+
5+export 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+
3+import * as NodeCrypto from "node:crypto";
4+
5+import { beforeEach, vi } from "vitest";
6+import { describe, expect, it } from "@effect/vitest";
7+import Constants from "expo-constants";
8+import * as Effect from "effect/Effect";
9+import * as Layer from "effect/Layer";
10+import { FetchHttpClient } from "effect/unstable/http";
11+import type { ManagedRelayClient } from "@t3tools/client-runtime";
12+
13+import type { EnvironmentId } from "@t3tools/contracts";
14+import { verifyDpopProof } from "@t3tools/shared/dpop";
15+import type { SavedRemoteConnection } from "../../lib/connection";
16+import { mobileCryptoLayer } from "../cloud/dpop";
17+import { mobileManagedRelayClientLayer } from "../cloud/managedRelayLayer";
18+import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
19+import {
20+ __resetAgentAwarenessRemoteRegistrationForTest,
21+ refreshAgentAwarenessRegistration,
22+ normalizeAgentAwarenessRelayBaseUrl,
23+ registerAgentAwarenessConnection,
24+ registerLiveActivityPushToken,
25+ setAgentAwarenessRelayTokenProvider,
26+ shouldRegisterAgentAwarenessDeviceForProvider,
27+ unregisterAgentAwarenessConnection,
28+} from "./remoteRegistration";
29+import * as Notifications from "expo-notifications";
30+
31+const secureStore = vi.hoisted(() => new Map<string, string>());
32+
33+vi.mock("expo-constants", () => ({
34+ default: {
35+ expoConfig: {
36+ version: "1.0.0",
37+ extra: {},
38+ },
39+ },
40+}));
41+
42+vi.mock("expo-widgets", () => ({
43+ addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })),
44+}));
45+
46+vi.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+
52+vi.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+
72+vi.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+
80+vi.mock("react-native", () => ({
81+ Platform: {
82+ OS: "ios",
83+ Version: "18.0",
84+ },
85+}));
86+
87+vi.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+
102+vi.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+
108+function 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+
119+function 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+
131+const 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+
142+async 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+
154+describe("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 @@
1+import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets";
2+import Constants from "expo-constants";
3+import * as Notifications from "expo-notifications";
4+import * as Effect from "effect/Effect";
5+import { Platform } from "react-native";
6+import type { EnvironmentId } from "@t3tools/contracts";
7+import {
8+ type RelayDeviceRegistrationRequest,
9+ type RelayLiveActivityRegistrationRequest,
10+} from "@t3tools/contracts/relay";
11+import { ManagedRelayClient } from "@t3tools/client-runtime";
12+
13+import type { SavedRemoteConnection } from "../../lib/connection";
14+import { mobileRuntime } from "../../lib/runtime";
15+import {
16+ loadAgentAwarenessDeviceId,
17+ loadOrCreateAgentAwarenessDeviceId,
18+ loadPreferences,
19+} from "../../lib/storage";
20+import type { AgentActivityProps } from "../../widgets/AgentActivity";
21+import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
22+
23+const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>();
24+const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>();
25+let pushToStartSubscription: { remove: () => void } | null = null;
26+let pushTokenSubscription: { remove: () => void } | null = null;
27+let relayTokenProvider: (() => Promise<string | null>) | null = null;
28+let relayTokenProviderIdentity: string | null = null;
29+
30+export 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+
40+function 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+
53+function canRegisterRemoteLiveActivities(): boolean {
54+ return Platform.OS === "ios";
55+}
56+
57+export function shouldRegisterAgentAwarenessDeviceForProvider(
58+ previousIdentity: string | null,
59+ identity: string | undefined,
60+): boolean {
61+ return identity === undefined || identity !== previousIdentity;
62+}
63+
64+export 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+
88+function 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+
97+function 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+
131+const 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+
142+function 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+
161+function 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+
184+function 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+
204+function 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+
214+function logRegistrationDebug(context: string, details?: unknown): void {
215+ if (!__DEV__) {
216+ return;
217+ }
218+ console.log(`[agent-awareness] ${context}`, details ?? "");
219+}
220+
221+function 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+
230+function 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+
264+function registerDeviceForCurrentUser(
265+ pushToStartToken?: string,
266+): Effect.Effect<void, unknown, ManagedRelayClient> {
267+ return registerDevice(pushToStartToken ? { pushToStartToken } : undefined);
268+}
269+
270+function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void {
271+ runRegistrationInBackground(
272+ registerDeviceForCurrentUser(pushToStartToken),
273+ "push-to-start token registration failed",
274+ );
275+}
276+
277+function 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+
290+function 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+
305+export 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+
316+function removeAgentAwarenessConnection(environmentId: EnvironmentId): void {
317+ environmentConnections.delete(environmentId);
318+}
319+
320+export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId): void {
321+ removeAgentAwarenessConnection(environmentId);
322+}
323+
324+export function unregisterAllAgentAwarenessConnections(): void {
325+ environmentConnections.clear();
326+ pushToStartSubscription?.remove();
327+ pushToStartSubscription = null;
328+ pushTokenSubscription?.remove();
329+ pushTokenSubscription = null;
330+}
331+
332+export 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+
346+export 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+
356+export 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+
377+export 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+
429+function 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 @@
1+import { shellSnapshotStateAtom } from "@t3tools/client-runtime";
2+import type { EnvironmentId } from "@t3tools/contracts";
3+
4+import type { SavedRemoteConnection } from "../../lib/connection";
5+import { mobileRuntime } from "../../lib/runtime";
6+import { appAtomRegistry } from "../../state/atom-registry";
7+import {
8+ endAllAgentLiveActivities,
9+ endEnvironmentLiveActivities,
10+ syncAgentLiveActivitiesForSnapshot,
11+} from "./liveActivityController";
12+import {
13+ registerAgentAwarenessConnection,
14+ unregisterAgentAwarenessConnection,
15+ unregisterAllAgentAwarenessConnections,
16+} from "./remoteRegistration";
17+
18+const environmentUnsubscribers = new Map<EnvironmentId, () => void>();
19+
20+export 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+
45+export 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+
52+export 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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { formatAgentActivityUpdatedAtLabel } from "./updatedAtLabel";
4+
5+describe("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 @@
1+const ISO_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T(?<hours>\d{2}):(?<minutes>\d{2}):/;
2+
3+export 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 @@
1+import { ClerkProvider, useAuth } from "@clerk/expo";
2+import { tokenCache } from "@clerk/expo/token-cache";
3+import Constants from "expo-constants";
4+import { type ReactNode, useEffect, useRef } from "react";
5+import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
6+
7+import { mobileRuntime } from "../../lib/runtime";
8+import {
9+ setAgentAwarenessRelayTokenProvider,
10+ unregisterAgentAwarenessDeviceForCurrentUser,
11+} from "../agent-awareness/remoteRegistration";
12+import { refreshActiveLiveActivityRemoteRegistration } from "../agent-awareness/liveActivityController";
13+
14+function 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+
21+function 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+
71+export 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 @@
1+import { useWaitlist } from "@clerk/expo";
2+import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
3+import { useState } from "react";
4+
5+import { useThemeColor } from "../../lib/useThemeColor";
6+
7+export 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+
116+function 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+
128+function 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+
141+const 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+
3+import * as NodeCrypto from "node:crypto";
4+
5+import { vi } from "vitest";
6+import { describe, expect, it } from "@effect/vitest";
7+import * as Crypto from "effect/Crypto";
8+import * as Effect from "effect/Effect";
9+import { verifyDpopProof } from "@t3tools/shared/dpop";
10+
11+import {
12+ createDpopProof,
13+ generateDpopProofKeyPair,
14+ loadOrCreateDpopProofKeyPair,
15+ mobileCryptoLayer,
16+} from "./dpop";
17+
18+vi.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+
38+const secureStore = new Map<string, string>();
39+vi.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+
47+function 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+
58+function 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+
69+describe("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 @@
1+import * as Clock from "effect/Clock";
2+import * as Crypto from "effect/Crypto";
3+import * as Data from "effect/Data";
4+import * as Effect from "effect/Effect";
5+import * as Encoding from "effect/Encoding";
6+import * as Result from "effect/Result";
7+import * as Schema from "effect/Schema";
8+import * as ExpoCrypto from "expo-crypto";
9+import * as SecureStore from "expo-secure-store";
10+import { p256 } from "@noble/curves/nist";
11+import {
12+ computeDpopAccessTokenHash,
13+ computeDpopJwkThumbprint,
14+ DpopPublicJwk,
15+ normalizeDpopHtu,
16+} from "@t3tools/shared/dpop";
17+import * as Layer from "effect/Layer";
18+
19+export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
20+ readonly message: string;
21+ readonly cause?: unknown;
22+}> {}
23+
24+function cloudDpopError(message: string) {
25+ return (cause: unknown) => new CloudDpopError({ message, cause });
26+}
27+
28+const DpopPrivateJwkSchema = Schema.Struct({
29+ ...DpopPublicJwk.fields,
30+ d: Schema.String,
31+});
32+
33+const DpopPrivateJwkJson = Schema.fromJsonString(DpopPrivateJwkSchema);
34+const decodeDpopPrivateJwkJson = Schema.decodeUnknownEffect(DpopPrivateJwkJson);
35+const encodeDpopPrivateJwkJson = Schema.encodeEffect(DpopPrivateJwkJson);
36+
37+const DpopJwtHeaderJson = Schema.fromJsonString(
38+ Schema.Struct({
39+ typ: Schema.Literal("dpop+jwt"),
40+ alg: Schema.Literal("ES256"),
41+ jwk: DpopPublicJwk,
42+ }),
43+);
44+
45+const 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+
55+const encodeDpopJwtHeaderJson = Schema.encodeEffect(DpopJwtHeaderJson);
56+const encodeDpopJwtPayloadJson = Schema.encodeEffect(DpopJwtPayloadJson);
57+
58+function 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+
73+export 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+
86+type DpopPrivateJwk = typeof DpopPrivateJwkSchema.Type;
87+
88+export interface DpopProofKeyPair {
89+ readonly privateJwk: DpopPrivateJwk;
90+ readonly publicJwk: DpopPublicJwk;
91+ readonly thumbprint: string;
92+}
93+
94+const DPOP_PROOF_KEY_STORAGE_KEY = "t3code.cloud.dpop-proof-key";
95+
96+function base64UrlToBytes(value: string): Uint8Array {
97+ return Result.getOrThrow(Encoding.decodeBase64Url(value));
98+}
99+
100+function 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+
110+function 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+
120+function 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+
132+function privateJwkFromPrivateKey(
133+ privateKey: Uint8Array,
134+ publicJwk: DpopPublicJwk,
135+): DpopPrivateJwk {
136+ return { ...publicJwk, d: Encoding.encodeBase64Url(privateKey) };
137+}
138+
139+export 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+
165+export 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+
209+function 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+
216+export 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 @@
1+import { beforeEach, vi } from "vitest";
2+import { describe, expect, it } from "@effect/vitest";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import { EnvironmentId } from "@t3tools/contracts";
6+import { RelayMobileClientId } from "@t3tools/contracts/relay";
7+import {
8+ managedRelayClientLayer,
9+ ManagedRelayClient,
10+ ManagedRelayDpopSigner,
11+ remoteHttpClientLayer,
12+} from "@t3tools/client-runtime";
13+import { HttpClient } from "effect/unstable/http";
14+
15+import {
16+ cloudEnvironmentsPendingStatus,
17+ linkEnvironmentToCloud,
18+ connectCloudEnvironment,
19+ listCloudEnvironments,
20+ listCloudEnvironmentsWithStatus,
21+ normalizeRelayBaseUrl,
22+} from "./linkEnvironment";
23+
24+vi.mock("expo-constants", () => ({
25+ default: {
26+ expoConfig: {
27+ extra: {
28+ relay: {
29+ url: "https://relay.example.test",
30+ },
31+ },
32+ },
33+ },
34+}));
35+
36+vi.mock("react-native", () => ({
37+ Platform: {
38+ OS: "ios",
39+ },
40+}));
41+
42+vi.mock("../../lib/storage", () => ({
43+ loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
44+ loadPreferences: vi.fn(() => Promise.resolve({})),
45+}));
46+
47+const 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+
57+const createProofMock = vi.fn(
58+ (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
59+ Effect.succeed(`dpop:${input.method}:${input.url}`),
60+);
61+const testDpopSignerLayer = Layer.succeed(
62+ ManagedRelayDpopSigner,
63+ ManagedRelayDpopSigner.of({
64+ thumbprint: Effect.succeed("client-proof-key-thumbprint"),
65+ createProof: (input) => createProofMock(input),
66+ }),
67+);
68+
69+function 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+
80+const withCloudServices = <A, E>(
81+ effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>,
82+) => effect.pipe(Effect.provide(cloudClientLayer()));
83+
84+function validLinkProof() {
85+ return "signed-environment-link-jwt";
86+}
87+
88+function 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+
108+function validLinkChallengeResponse() {
109+ return {
110+ challenge: "link-challenge",
111+ expiresAt: "2026-05-25T00:05:00.000Z",
112+ };
113+}
114+
115+function requestBodyText(body: BodyInit | null | undefined): string {
116+ return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
117+}
118+
119+function 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+
129+function 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+
142+describe("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 @@
1+import Constants from "expo-constants";
2+import * as Data from "effect/Data";
3+import * as Effect from "effect/Effect";
4+import * as Schema from "effect/Schema";
5+import { HttpClient } from "effect/unstable/http";
6+import {
7+ EnvironmentCloudEndpointUnavailableError,
8+ EnvironmentHttpBadRequestError,
9+ EnvironmentHttpConflictError,
10+ EnvironmentHttpForbiddenError,
11+ EnvironmentHttpInternalServerError,
12+ EnvironmentHttpUnauthorizedError,
13+} from "@t3tools/contracts";
14+import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";
15+import {
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";
27+import {
28+ exchangeRemoteDpopAccessToken,
29+ fetchRemoteEnvironmentDescriptor,
30+ makeEnvironmentHttpApiClient,
31+ ManagedRelayClient,
32+ ManagedRelayDpopSigner,
33+} from "@t3tools/client-runtime";
34+
35+import { mobileAuthClientMetadata } from "../../lib/authClientMetadata";
36+import type { SavedRemoteConnection } from "../../lib/connection";
37+import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage";
38+
39+const RELAY_STATUS_AND_CONNECT_SCOPES = [
40+ RelayEnvironmentStatusScope,
41+ RelayEnvironmentConnectScope,
42+] satisfies ReadonlyArray<RelayDpopAccessTokenScope>;
43+
44+export 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+
52+function 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+
59+export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
60+ readonly message: string;
61+ readonly cause?: unknown;
62+}> {}
63+
64+export interface CloudEnvironmentRecordWithStatus {
65+ readonly environment: RelayClientEnvironmentRecord;
66+ readonly status: RelayEnvironmentStatusResponseType | null;
67+ readonly statusError: string | null;
68+}
69+
70+const isRelayProtectedError = Schema.is(RelayProtectedError);
71+const isEnvironmentCloudApiError = Schema.is(
72+ Schema.Union([
73+ EnvironmentHttpBadRequestError,
74+ EnvironmentHttpUnauthorizedError,
75+ EnvironmentHttpForbiddenError,
76+ EnvironmentHttpConflictError,
77+ EnvironmentHttpInternalServerError,
78+ EnvironmentCloudEndpointUnavailableError,
79+ ]),
80+);
81+
82+const MANAGED_ENDPOINT_PROVIDER_KIND =
83+ "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
84+
85+function 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+
97+function isDevRuntime(): boolean {
98+ return typeof __DEV__ !== "undefined" && __DEV__;
99+}
100+
101+function 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+
115+function 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+
123+function 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+
158+function 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+
169+function 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+
179+function 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+
189+function 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+
196+function 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+
204+function 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+
222+function 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+
233+function 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+
258+function 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+
270+export 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+
360+export 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+
379+export 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+
409+export 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+
419+export 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+
452+export 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+
468+export 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 @@
1+import {
2+ managedRelayClientLayer,
3+ ManagedRelayDpopSigner,
4+ ManagedRelayDpopSignerError,
5+} from "@t3tools/client-runtime";
6+import { RelayMobileClientId } from "@t3tools/contracts/relay";
7+import * as Crypto from "effect/Crypto";
8+import * as Effect from "effect/Effect";
9+import * as Layer from "effect/Layer";
10+
11+import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";
12+
13+const 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+
39+export 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 @@
1+import { getClerkInstance } from "@clerk/expo";
2+import { tokenCache } from "@clerk/expo/token-cache";
3+import * as Data from "effect/Data";
4+import { useCallback, useRef } from "react";
5+import type { TurboModule } from "react-native";
6+import { TurboModuleRegistry } from "react-native";
7+
8+const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt";
9+
10+interface 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+
18+interface NativeAuthResult {
19+ readonly cancelled?: boolean;
20+ readonly session?: {
21+ readonly id?: string;
22+ };
23+ readonly sessionId?: string;
24+}
25+
26+interface ClerkWithNativeSync {
27+ readonly __internal_reloadInitialResources?: () => Promise<void>;
28+ readonly setActive?: (params: { readonly session: string }) => Promise<void>;
29+}
30+
31+const NativeClerk = TurboModuleRegistry.get<NativeClerkModule>("ClerkExpo");
32+
33+class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{
34+ readonly message: string;
35+ readonly cause?: unknown;
36+}> {}
37+
38+async 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+
91+export 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 @@
1+import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
2+import { Platform } from "react-native";
3+
4+export 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
33 import { mobileAuthClientMetadata, redactPairingCredential } from "./connection";
44
55 vi.mock("./runtime", () => ({
6− mobileRemoteHttpRuntime: {
6+ mobileRuntime: {
77 runPromise: vi.fn(),
88 },
99 }));
apps/mobile/src/lib/connection.ts+24−24
@@ -1,11 +1,14 @@
1−import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts";
1+import { EnvironmentId } from "@t3tools/contracts";
22 import {
33 bootstrapRemoteBearerSession,
44 fetchRemoteEnvironmentDescriptor,
55 } from "@t3tools/client-runtime";
66 import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";
7−import { Platform } from "react-native";
8−import { mobileRemoteHttpRuntime } from "./runtime";
7+import * as Effect from "effect/Effect";
8+import { mobileAuthClientMetadata } from "./authClientMetadata";
9+import { mobileRuntime } from "./runtime";
10+
11+export { mobileAuthClientMetadata } from "./authClientMetadata";
912
1013 export interface RemoteConnectionInput {
1114 readonly pairingUrl: string;
@@ -18,7 +21,9 @@ export interface SavedRemoteConnection { · 6 unmodified lines
1821 readonly displayUrl: string;
1922 readonly httpBaseUrl: string;
2023 readonly wsBaseUrl: string;
21− readonly bearerToken: string;
24+ readonly bearerToken: string | null;
25+ readonly authenticationMethod?: "bearer" | "dpop";
26+ readonly dpopAccessToken?: string;
2227 }
2328
2429 export type RemoteClientConnectionState =
@@ -37,14 +42,6 @@ export function redactPairingCredential(pairingUrl: string): string { · 12 unmodified lines
3742 }
3843 }
3944
40−export 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−
4845 export async function bootstrapRemoteConnection(
4946 input: RemoteConnectionInput,
5047 ): Promise<SavedRemoteConnection> {
@@ -52,18 +49,20 @@ export async function bootstrapRemoteConnection( · 1 unmodified line
5249 pairingUrl: input.pairingUrl,
5350 });
5451
55− const descriptor = await mobileRemoteHttpRuntime.runPromise(
56− fetchRemoteEnvironmentDescriptor({
57− httpBaseUrl: target.httpBaseUrl,
58− }),
59− );
60−
61− const bootstrap = await mobileRemoteHttpRuntime.runPromise(
62− bootstrapRemoteBearerSession({
63− httpBaseUrl: target.httpBaseUrl,
64− credential: target.credential,
65− clientMetadata: mobileAuthClientMetadata(),
66− }),
52+ const { descriptor, bootstrap } = await mobileRuntime.runPromise(
53+ Effect.all(
54+ {
55+ descriptor: fetchRemoteEnvironmentDescriptor({
56+ httpBaseUrl: target.httpBaseUrl,
57+ }),
58+ bootstrap: bootstrapRemoteBearerSession({
59+ httpBaseUrl: target.httpBaseUrl,
60+ credential: target.credential,
61+ clientMetadata: mobileAuthClientMetadata(),
62+ }),
63+ },
64+ { concurrency: "unbounded" },
65+ ),
6766 );
6867
6968 return {
@@ -74,5 +73,6 @@ export async function bootstrapRemoteConnection( · 4 unmodified lines
7473 httpBaseUrl: target.httpBaseUrl,
7574 wsBaseUrl: target.wsBaseUrl,
7675 bearerToken: bootstrap.access_token,
76+ authenticationMethod: "bearer",
7777 };
7878 }
apps/mobile/src/lib/runtime.ts+22−1
@@ -1,5 +1,26 @@
1+import Constants from "expo-constants";
2+import * as Layer from "effect/Layer";
13 import * as ManagedRuntime from "effect/ManagedRuntime";
24
35 import { remoteHttpClientLayer } from "@t3tools/client-runtime";
46
5−export const mobileRemoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(fetch));
7+import { mobileCryptoLayer } from "../features/cloud/dpop";
8+import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer";
9+
10+function 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+
16+const mobileHttpClientLayer = remoteHttpClientLayer(fetch);
17+
18+export 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
99
1010 const CONNECTIONS_KEY = "t3code.connections";
1111 const PREFERENCES_KEY = "t3code.preferences";
12+const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
1213 const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
1314 const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
1415
@@ -20,6 +21,7 @@ export interface CachedShellSnapshot { · 5 unmodified lines
2021 }
2122
2223 export interface MobilePreferences {
24+ readonly liveActivitiesEnabled?: boolean;
2325 readonly terminalFontSize?: number;
2426 }
2527
@@ -133,7 +135,12 @@ export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteC · 107 unmodified lines
133135
134136 return pipe(
135137 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+ ),
137144 );
138145 }
139146
@@ -164,11 +171,19 @@ export async function loadPreferences(): Promise<MobilePreferences> { · 24 unmodified lines
164171 return {};
165172 }
166173
174+ const preferences: {
175+ liveActivitiesEnabled?: boolean;
176+ terminalFontSize?: number;
177+ } = {};
178+
179+ if (typeof parsed.liveActivitiesEnabled === "boolean") {
180+ preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
181+ }
167182 if (typeof parsed.terminalFontSize === "number") {
168− return { terminalFontSize: parsed.terminalFontSize };
183+ preferences.terminalFontSize = parsed.terminalFontSize;
169184 }
170185
171− return {};
186+ return preferences;
172187 }
173188
174189 export async function savePreferencesPatch(
@@ -182,3 +197,20 @@ export async function savePreferencesPatch( · 7 unmodified lines
182197 await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next));
183198 return next;
184199 }
200+
201+export 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+
213+export 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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { EnvironmentId } from "@t3tools/contracts";
3+import { ManagedRelayDpopSigner } from "@t3tools/client-runtime";
4+import * as Effect from "effect/Effect";
5+import { beforeEach, vi } from "vitest";
6+
7+const 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+
54+vi.mock("react-native", () => ({
55+ Alert: {
56+ alert: vi.fn(),
57+ },
58+}));
59+
60+vi.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+
74+vi.mock("../lib/connection", () => ({
75+ bootstrapRemoteConnection: mocks.bootstrapRemoteConnection,
76+}));
77+
78+vi.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+
87+vi.mock("../lib/runtime", () => ({
88+ mobileRuntime: {
89+ runPromise: mocks.mobileRunPromise,
90+ },
91+}));
92+
93+vi.mock("./environment-session-registry", () => ({
94+ drainEnvironmentSessions: vi.fn(() => []),
95+ notifyEnvironmentConnectionListeners: mocks.notifyEnvironmentConnectionListeners,
96+ removeEnvironmentSession: mocks.removeEnvironmentSession,
97+ setEnvironmentSession: mocks.setEnvironmentSession,
98+}));
99+
100+vi.mock("../features/agent-awareness/shellLiveActivitySync", () => ({
101+ startAgentAwarenessForEnvironment: mocks.startAgentAwarenessForEnvironment,
102+ stopAgentAwarenessForEnvironment: mocks.stopAgentAwarenessForEnvironment,
103+ stopAllAgentAwareness: vi.fn(),
104+}));
105+
106+vi.mock("../features/terminal/terminalDebugLog", () => ({
107+ terminalDebugLog: mocks.terminalDebugLog,
108+}));
109+
110+vi.mock("./use-environment-runtime", () => ({
111+ environmentRuntimeManager: {
112+ invalidate: mocks.environmentRuntimeInvalidate,
113+ patch: mocks.environmentRuntimePatch,
114+ },
115+ useEnvironmentRuntimeStates: vi.fn(() => ({})),
116+}));
117+
118+vi.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+
130+vi.mock("./use-source-control-discovery", () => ({
131+ invalidateSourceControlDiscoveryForEnvironment:
132+ mocks.invalidateSourceControlDiscoveryForEnvironment,
133+ resetSourceControlDiscoveryState: vi.fn(),
134+}));
135+
136+vi.mock("./use-terminal-session", () => ({
137+ subscribeTerminalMetadata: mocks.subscribeTerminalMetadata,
138+ terminalSessionManager: {
139+ invalidate: vi.fn(),
140+ invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment,
141+ },
142+}));
143+
144+import { connectSavedEnvironment, disconnectEnvironment } from "./use-remote-environment-registry";
145+
146+const environmentId = EnvironmentId.make("env-mobile-test");
147+
148+const 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+
158+describe("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
99 createKnownEnvironment,
1010 createWsRpcClient,
1111 EnvironmentConnectionState,
12+ ManagedRelayDpopSigner,
1213 WsTransport,
14+ remoteEndpointUrl,
15+ resolveRemoteDpopWebSocketConnectionUrl,
1316 resolveRemoteWebSocketConnectionUrl,
1417 } from "@t3tools/client-runtime";
1518 import type { EnvironmentId } from "@t3tools/contracts";
1619 import * as Arr from "effect/Array";
20+import * as Duration from "effect/Duration";
21+import * as Effect from "effect/Effect";
1722 import * as Order from "effect/Order";
1823 import * as Option from "effect/Option";
1924 import { pipe } from "effect/Function";
@@ -29,7 +34,7 @@ import { · 9 unmodified lines
2934 saveConnection,
3035 } from "../lib/storage";
3136 import { appAtomRegistry } from "./atom-registry";
32−import { mobileRemoteHttpRuntime } from "../lib/runtime";
37+import { mobileRuntime } from "../lib/runtime";
3338 import {
3439 drainEnvironmentSessions,
3540 notifyEnvironmentConnectionListeners,
@@ -41,6 +46,11 @@ import { · 5 unmodified lines
4146 invalidateSourceControlDiscoveryForEnvironment,
4247 resetSourceControlDiscoveryState,
4348 } from "./use-source-control-discovery";
49+import {
50+ startAgentAwarenessForEnvironment,
51+ stopAgentAwarenessForEnvironment,
52+ stopAllAgentAwareness,
53+} from "../features/agent-awareness/shellLiveActivitySync";
4454 import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";
4555 import {
4656 clearCachedShellSnapshotMetadata,
@@ -153,223 +163,265 @@ function setEnvironmentConnectionStatus( · 106 unmodified lines
153163 }));
154164 }
155165
156−function withTimeout<T>(
157− promise: Promise<T>,
158− timeoutMs: number,
159− timeoutMessage: string,
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− );
166+function fromPromise<T>(tryPromise: () => Promise<T>): Effect.Effect<T, unknown> {
167+ return Effect.tryPromise({
168+ try: tryPromise,
169+ catch: (cause) => cause,
176170 });
177171 }
178172
179−export async function disconnectEnvironment(
173+export function disconnectEnvironment(
180174 environmentId: EnvironmentId,
181175 options?: {
182176 readonly preserveShellSnapshot?: boolean;
183177 readonly removeSaved?: boolean;
184178 readonly preserveConnectionAttempt?: boolean;
185179 },
186−) {
187− if (!options?.preserveConnectionAttempt) {
188− environmentConnectionAttempts.cancel(environmentId);
189− }
190−
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−
211−export async function connectSavedEnvironment(
212− connection: SavedRemoteConnection,
213− options?: { readonly persist?: boolean },
214−) {
215− const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId);
216− const isCurrentAttempt = connectionAttempt.isCurrent;
180+): Effect.Effect<void, unknown> {
181+ return Effect.gen(function* () {
182+ if (!options?.preserveConnectionAttempt) {
183+ environmentConnectionAttempts.cancel(environmentId);
184+ }
217185
218− await disconnectEnvironment(connection.environmentId, {
219− preserveShellSnapshot: true,
220− preserveConnectionAttempt: true,
186+ const session = removeEnvironmentSession(environmentId);
187+ notifyEnvironmentConnectionListeners();
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+ }
221212 });
222− if (!isCurrentAttempt()) {
223− return;
224− }
213+}
225214
226− if (options?.persist !== false) {
227− await saveConnection(connection);
215+export function connectSavedEnvironment(
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+ });
228227 if (!isCurrentAttempt()) {
229228 return;
230229 }
231− }
232230
233− upsertSavedConnection(connection);
234− setEnvironmentConnectionStatus(connection.environmentId, "connecting", null);
235− shellSnapshotManager.markPending({ environmentId: connection.environmentId });
231+ if (options?.persist !== false) {
232+ yield* fromPromise(() => saveConnection(connection));
233+ if (!isCurrentAttempt()) {
234+ return;
235+ }
236+ }
236237
237− const transport = new WsTransport(
238− () =>
239− mobileRemoteHttpRuntime.runPromise(
240− resolveRemoteWebSocketConnectionUrl({
241− wsBaseUrl: connection.wsBaseUrl,
242− httpBaseUrl: connection.httpBaseUrl,
243− bearerToken: connection.bearerToken,
244− }),
245− ),
246− {
247− onAttempt: () => {
248− if (!isCurrentAttempt()) {
249− return;
250− }
238+ upsertSavedConnection(connection);
239+ setEnvironmentConnectionStatus(connection.environmentId, "connecting", null);
240+ shellSnapshotManager.markPending({ environmentId: connection.environmentId });
241+
242+ const dpopAccessToken =
243+ connection.authenticationMethod === "dpop" ? connection.dpopAccessToken : undefined;
244+ const transport = new WsTransport(
245+ () =>
246+ mobileRuntime.runPromise(
247+ dpopAccessToken
248+ ? Effect.gen(function* () {
249+ const signer = yield* ManagedRelayDpopSigner;
250+ const dpop = yield* signer.createProof({
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+ }
251300
252− environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => {
253− const nextState =
254− previous.connectionState === "ready" || previous.connectionState === "reconnecting"
255− ? "reconnecting"
256− : "connecting";
257− const keepSettledFailure =
258− previous.connectionState === "disconnected" && previous.connectionError !== null;
259− return {
260− ...previous,
261− connectionState: keepSettledFailure ? "disconnected" : nextState,
262− connectionError: keepSettledFailure ? previous.connectionError : null,
263− };
264− });
301+ const reason =
302+ details.reason.trim().length > 0
303+ ? details.reason
304+ : details.code === 1000
305+ ? null
306+ : `Remote connection closed (${details.code}).`;
307+ setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason);
308+ },
265309 },
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) => {
267329 if (isCurrentAttempt()) {
268− setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message);
330+ shellSnapshotManager.applyEvent({ environmentId }, event);
269331 }
270332 },
271− onClose: (details) => {
333+ syncShellSnapshot: (snapshot, environmentId) => {
272334 if (!isCurrentAttempt()) {
273335 return;
274336 }
275337
276− const reason =
277− details.reason.trim().length > 0
278− ? details.reason
279− : details.code === 1000
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) => ({
338+ shellSnapshotManager.syncSnapshot({ environmentId }, snapshot);
339+ markShellSnapshotLive(environmentId);
340+ void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined);
341+ environmentRuntimeManager.patch({ environmentId }, (runtime) => ({
330342 ...runtime,
331− serverConfig,
343+ connectionState: "ready",
344+ connectionError: null,
332345 }));
333− }
334− },
335− });
346+ },
347+ onShellResubscribe: (environmentId) => {
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+ });
336364
337− if (!isCurrentAttempt()) {
338− await environmentConnection.dispose();
339− return;
340− }
365+ if (!isCurrentAttempt()) {
366+ yield* fromPromise(() => environmentConnection.dispose());
367+ return;
368+ }
341369
342− setEnvironmentSession(connection.environmentId, {
343− client,
344− connection: environmentConnection,
345− });
346− terminalMetadataUnsubscribers.set(
347− connection.environmentId,
348− subscribeTerminalMetadata({
349− environmentId: connection.environmentId,
370+ setEnvironmentSession(connection.environmentId, {
350371 client,
351− }),
352− );
353− terminalDebugLog("registry:terminal-metadata-subscribed", {
354− environmentId: connection.environmentId,
355− });
356− notifyEnvironmentConnectionListeners();
357−
358− try {
359− await withTimeout(
360− environmentConnection.ensureBootstrapped(),
361− SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS,
362− "Environment did not respond before the connection timeout.",
372+ connection: environmentConnection,
373+ });
374+
375+ const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe(
376+ Effect.timeoutOption(Duration.millis(SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS)),
377+ Effect.flatMap((result) =>
378+ Option.match(result, {
379+ onNone: () =>
380+ Effect.fail(new Error("Environment did not respond before the connection timeout.")),
381+ onSome: Effect.succeed,
382+ }),
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+ ),
363400 );
364− } catch (error) {
365− if (isCurrentAttempt()) {
366− setEnvironmentConnectionStatus(
367− connection.environmentId,
368− "disconnected",
369− error instanceof Error ? error.message : "Failed to bootstrap remote connection.",
370− );
401+ const bootstrapped = yield* options?.suppressBootstrapError
402+ ? bootstrap.pipe(
403+ Effect.as(true),
404+ Effect.catch(() => Effect.succeed(false)),
405+ )
406+ : bootstrap.pipe(Effect.as(true));
407+
408+ if (!bootstrapped || !isCurrentAttempt()) {
409+ return;
371410 }
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+ });
373425 }
374426
375427 const environmentsSortOrder = Order.mapInput(
@@ -404,8 +456,9 @@ export function useRemoteEnvironmentBootstrap() { · 28 unmodified lines
404456 useEffect(() => {
405457 let cancelled = false;
406458
407− void loadSavedConnections()
408− .then((connections) => {
459+ void (async () => {
460+ try {
461+ const connections = await loadSavedConnections();
409462 if (cancelled) {
410463 return;
411464 }
@@ -418,34 +471,36 @@ export function useRemoteEnvironmentBootstrap() { · 6 unmodified lines
418471
419472 setIsLoadingSavedConnection(false);
420473
421− void (async () => {
422− await Promise.all(
423− connections.map(async (connection) => {
424− const cached = await loadCachedShellSnapshot(connection.environmentId);
425− if (!cancelled && cached) {
426− hydrateCachedShellSnapshot(cached);
427− }
428− }),
429− );
474+ await Promise.all(
475+ connections.map(async (connection) => {
476+ const cached = await loadCachedShellSnapshot(connection.environmentId);
477+ if (!cancelled && cached) {
478+ hydrateCachedShellSnapshot(cached);
479+ }
480+ }),
481+ );
430482
431− if (cancelled) {
432− return;
433− }
483+ if (cancelled) {
484+ return;
485+ }
434486
435− await Promise.all(
487+ await mobileRuntime.runPromise(
488+ Effect.all(
436489 connections.map((connection) =>
437490 connectSavedEnvironment(connection, {
438491 persist: false,
492+ suppressBootstrapError: true,
439493 }),
440494 ),
441− );
442− })();
443− })
444− .catch(() => {
495+ { concurrency: "unbounded" },
496+ ),
497+ );
498+ } catch {
445499 if (!cancelled) {
446500 setIsLoadingSavedConnection(false);
447501 }
448− });
502+ }
503+ })();
449504
450505 return () => {
451506 cancelled = true;
@@ -457,6 +512,7 @@ export function useRemoteEnvironmentBootstrap() { · 5 unmodified lines
457512 }
458513 terminalMetadataUnsubscribers.clear();
459514 environmentConnectionAttempts.clear();
515+ stopAllAgentAwareness();
460516 environmentRuntimeManager.invalidate();
461517 shellSnapshotManager.invalidate();
462518 resetSourceControlDiscoveryState();
@@ -538,7 +594,7 @@ export function useRemoteConnections() { · 75 unmodified lines
538594 const nextPairingUrl = pairingUrl ?? connectionPairingUrl;
539595 const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl });
540596 clearPendingConnectionError();
541− await connectSavedEnvironment(connection);
597+ await mobileRuntime.runPromise(connectSavedEnvironment(connection));
542598 clearConnectionPairingUrl();
543599 } catch (error) {
544600 setPendingConnectionError(
@@ -577,7 +633,14 @@ export function useRemoteConnections() { · 32 unmodified lines
577633 if (!connection) {
578634 return;
579635 }
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);
581644 }, []);
582645
583646 const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => {
@@ -595,7 +658,9 @@ export function useRemoteConnections() { · 11 unmodified lines
595658 text: "Remove",
596659 style: "destructive",
597660 onPress: () => {
598− void disconnectEnvironment(environmentId, { removeSaved: true });
661+ void mobileRuntime
662+ .runPromise(disconnectEnvironment(environmentId, { removeSaved: true }))
663+ .catch(() => undefined);
599664 },
600665 },
601666 ],
apps/mobile/src/widgets/AgentActivity.tsx+313−0
@@ -0,0 +1,313 @@
1+import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui";
2+import { font, foregroundStyle, lineLimit, padding } from "@expo/ui/swift-ui/modifiers";
3+import {
4+ createLiveActivity,
5+ type LiveActivityComponent,
6+ type LiveActivityLayout,
7+} from "expo-widgets";
8+import { formatAgentActivityUpdatedAtLabel } from "../features/agent-awareness/updatedAtLabel";
9+
10+type LiveActivityEnvironment = Parameters<LiveActivityComponent<AgentActivityProps>>[1];
11+
12+export type AgentActivityPhase =
13+ | "starting"
14+ | "running"
15+ | "waiting_for_approval"
16+ | "waiting_for_input"
17+ | "completed"
18+ | "failed"
19+ | "stale";
20+
21+export 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+
33+export 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+
41+function 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+
313+export 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
7777 import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
7878 import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";
7979 import * as VcsProcess from "../src/vcs/VcsProcess.ts";
80+import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts";
8081
8182 const decodeCodexSettings = Schema.decodeEffect(CodexSettings);
8283
@@ -364,6 +365,12 @@ export const makeOrchestrationIntegrationHarness = ( · 281 unmodified lines
364365 drain: Effect.void,
365366 }),
366367 ),
368+ Layer.provideMerge(
369+ Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
370+ publishThread: () => Effect.void,
371+ start: () => Effect.void,
372+ }),
373+ ),
367374 );
368375 const layer = Layer.empty.pipe(
369376 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/auth/EnvironmentAuth.ts+65−6
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 } from "@t3tools/contracts";
2020 import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
2121 import * as Context from "effect/Context";
22+import * as Crypto from "effect/Crypto";
2223 import * as Data from "effect/Data";
2324 import * as DateTime from "effect/DateTime";
2425 import * as Duration from "effect/Duration";
@@ -31,6 +32,7 @@ import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; · 6 unmodified lines
3132 import * as PairingGrantStore from "./PairingGrantStore.ts";
3233 import * as ServerSecretStore from "./ServerSecretStore.ts";
3334 import * as SessionStore from "./SessionStore.ts";
35+import { verifyRequestDpopProof } from "./dpop.ts";
3436 import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
3537
3638 export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
@@ -61,6 +63,7 @@ export interface AuthenticatedSession { · 24 unmodified lines
6163 readonly subject: string;
6264 readonly method: ServerAuthSessionMethod;
6365 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
66+ readonly proofKeyThumbprint?: string;
6467 readonly expiresAt?: DateTime.DateTime;
6568 }
6669
@@ -107,6 +110,9 @@ export interface EnvironmentAuthShape { · 40 unmodified lines
107110 credential: string,
108111 requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,
109112 requestMetadata: AuthClientMetadata,
113+ input?: {
114+ readonly proofKeyThumbprint?: string;
115+ },
110116 ) => Effect.Effect<
111117 AuthAccessTokenResult,
112118 ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError
@@ -116,6 +122,7 @@ export interface EnvironmentAuthShape { · 3 unmodified lines
116122 readonly label?: string;
117123 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
118124 readonly subject?: string;
125+ readonly proofKeyThumbprint?: string;
119126 }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
120127 readonly issuePairingCredential: (
121128 input?: AuthCreatePairingCredentialInput,
@@ -184,6 +191,7 @@ type BootstrapExchangeResult = { · 62 unmodified lines
184191 };
185192
186193 const AUTHORIZATION_PREFIX = "Bearer ";
194+const DPOP_AUTHORIZATION_PREFIX = "DPoP ";
187195 const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket";
188196
189197 const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
@@ -245,10 +253,21 @@ function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string · 55 unmodified lines
245253 return token.length > 0 ? token : null;
246254 }
247255
256+function 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+
248265 export const make = Effect.fn("makeEnvironmentAuth")(function* () {
249266 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
250267 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
251268 const sessions = yield* SessionStore.SessionStore;
269+ const secretStore = yield* ServerSecretStore.ServerSecretStore;
270+ const crypto = yield* Crypto.Crypto;
252271 const descriptor = yield* policy.getDescriptor();
253272
254273 const authenticateToken = (
@@ -270,6 +289,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 15 unmodified lines
270289 subject: session.subject,
271290 method: session.method,
272291 scopes: session.scopes,
292+ ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}),
273293 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
274294 })),
275295 mapSessionVerificationErrors,
@@ -278,11 +298,43 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 2 unmodified lines
278298 const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
279299 const cookieToken = request.cookies[sessions.cookieName];
280300 const bearerToken = parseBearerToken(request);
281− const credential = cookieToken ?? bearerToken;
301+ const dpopToken = parseDpopToken(request);
302+ const credential = cookieToken ?? bearerToken ?? dpopToken;
282303 if (!credential) {
283304 return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" }));
284305 }
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+ );
286338 };
287339
288340 const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) =>
@@ -349,8 +401,8 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 60 unmodified lines
349401 );
350402
351403 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] =
352− (credential, requestedScopes, requestMetadata) =>
353− bootstrapCredentials.consume(credential).pipe(
404+ (credential, requestedScopes, requestMetadata, input) =>
405+ bootstrapCredentials.consume(credential, input).pipe(
354406 Effect.mapError(toBootstrapExchangeError),
355407 Effect.flatMap((grant) =>
356408 Effect.gen(function* () {
@@ -362,9 +414,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 5 unmodified lines
362414 }
363415 return yield* sessions
364416 .issue({
365− method: "bearer-access-token",
417+ method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",
366418 subject: grant.subject,
367419 scopes: grantedScopes,
420+ ...(input?.proofKeyThumbprint
421+ ? {
422+ proofKeyThumbprint: input.proofKeyThumbprint,
423+ ttl: Duration.hours(1),
424+ }
425+ : {}),
368426 client: {
369427 ...requestMetadata,
370428 ...(grant.label ? { label: grant.label } : {}),
@@ -388,7 +446,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 17 unmodified lines
388446 ({
389447 access_token: session.token,
390448 issued_token_type: AuthAccessTokenType,
391− token_type: "Bearer",
449+ token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer",
392450 expires_in: Math.max(
393451 0,
394452 Math.floor(
@@ -434,6 +492,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 39 unmodified lines
434492 subject: input?.subject ?? "one-time-token",
435493 ...(input?.ttl ? { ttl: input.ttl } : {}),
436494 ...(input?.label ? { label: input.label } : {}),
495+ ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
437496 });
438497 return {
439498 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
3939 const descriptor: ServerAuthDescriptor = {
4040 policy,
4141 bootstrapMethods,
42− sessionMethods: ["browser-session-cookie", "bearer-access-token"],
42+ sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"],
4343 sessionCookieName: resolveSessionCookieName({
4444 mode: config.mode,
4545 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
9292 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
9393 );
9494
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+
95118 it.effect("seeds the desktop bootstrap credential as a one-time grant", () =>
96119 Effect.gen(function* () {
97120 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
apps/server/src/auth/PairingGrantStore.ts+37−2
@@ -26,6 +26,7 @@ export interface BootstrapGrant { · 25 unmodified lines
2626 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
2727 readonly subject: string;
2828 readonly label?: string;
29+ readonly proofKeyThumbprint?: string;
2930 readonly expiresAt: DateTime.DateTime;
3031 }
3132
@@ -50,6 +51,7 @@ export interface IssuedBootstrapCredential { · 18 unmodified lines
5051 readonly id: string;
5152 readonly credential: string;
5253 readonly label?: string;
54+ readonly proofKeyThumbprint?: string;
5355 readonly expiresAt: DateTime.Utc;
5456 }
5557
@@ -69,6 +71,7 @@ export interface PairingGrantStoreShape { · 13 unmodified lines
6971 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
7072 readonly subject?: string;
7173 readonly label?: string;
74+ readonly proofKeyThumbprint?: string;
7275 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
7376 readonly listActive: () => Effect.Effect<
7477 ReadonlyArray<AuthPairingLink>,
@@ -76,7 +79,12 @@ export interface PairingGrantStoreShape { · 1 unmodified line
7679 >;
7780 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
7881 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>;
8088 }
8189
8290 export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()(
@@ -232,6 +240,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 149 unmodified lines
232240 id,
233241 credential,
234242 ...(input?.label ? { label: input.label } : {}),
243+ ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
235244 expiresAt,
236245 };
237246 yield* pairingLinks.create({
@@ -241,6 +250,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 3 unmodified lines
241250 scopes: input?.scopes ?? AuthStandardClientScopes,
242251 subject: input?.subject ?? "one-time-token",
243252 label: input?.label ?? null,
253+ proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
244254 createdAt: now,
245255 expiresAt: expiresAt,
246256 });
@@ -259,7 +269,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 12 unmodified lines
259269 );
260270
261271 const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")(
262− function* (credential) {
272+ function* (credential, input) {
263273 const now = yield* DateTime.now;
264274 const seededResult: ConsumeResult = yield* Ref.modify(
265275 seededGrantsRef,
@@ -289,6 +299,17 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 23 unmodified lines
289299 ];
290300 }
291301
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+
292313 const remainingUses = grant.remainingUses;
293314 if (typeof remainingUses === "number") {
294315 if (remainingUses <= 1) {
@@ -309,6 +330,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 14 unmodified lines
309330 scopes: grant.scopes,
310331 subject: grant.subject,
311332 ...(grant.label ? { label: grant.label } : {}),
333+ ...(grant.proofKeyThumbprint
334+ ? { proofKeyThumbprint: grant.proofKeyThumbprint }
335+ : {}),
312336 expiresAt: grant.expiresAt,
313337 } satisfies BootstrapGrant,
314338 },
@@ -326,6 +350,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 11 unmodified lines
326350
327351 const consumed = yield* pairingLinks.consumeAvailable({
328352 credential,
353+ proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
329354 consumedAt: now,
330355 now,
331356 });
@@ -337,6 +362,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 5 unmodified lines
337362 scopes: consumed.value.scopes,
338363 subject: consumed.value.subject,
339364 ...(consumed.value.label ? { label: consumed.value.label } : {}),
365+ ...(consumed.value.proofKeyThumbprint
366+ ? { proofKeyThumbprint: consumed.value.proofKeyThumbprint }
367+ : {}),
340368 expiresAt: consumed.value.expiresAt,
341369 } satisfies BootstrapGrant;
342370 }
@@ -360,6 +388,13 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 17 unmodified lines
360388 return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
361389 }
362390
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+
363398 return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
364399 },
365400 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
1515 readonly cause?: unknown;
1616 }> {}
1717
18+const isPlatformError = (value: unknown): value is PlatformError.PlatformError =>
19+ Predicate.isTagged(value, "PlatformError");
20+
21+export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean =>
22+ isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists";
23+
1824 export interface ServerSecretStoreShape {
1925 readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>;
2026 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
27+ readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
2128 readonly getOrCreateRandom: (
2229 name: string,
2330 bytes: number,
@@ -48,9 +55,6 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 24 unmodified lines
4855
4956 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
5057
51− const isPlatformError = (u: unknown): u is PlatformError.PlatformError =>
52− Predicate.isTagged(u, "PlatformError");
53−
5458 const get: ServerSecretStoreShape["get"] = (name) =>
5559 fileSystem.readFile(resolveSecretPath(name)).pipe(
5660 Effect.map((bytes) => Uint8Array.from(bytes)),
@@ -104,7 +108,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 47 unmodified lines
104108 );
105109 };
106110
107− const create: ServerSecretStoreShape["set"] = (name, value) => {
111+ const create: ServerSecretStoreShape["create"] = (name, value) => {
108112 const secretPath = resolveSecretPath(name);
109113 return Effect.scoped(
110114 Effect.gen(function* () {
@@ -146,7 +150,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 35 unmodified lines
146150 create(name, generated).pipe(
147151 Effect.as(Uint8Array.from(generated)),
148152 Effect.catchTag("SecretStoreError", (error) =>
149− isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"
153+ isSecretAlreadyExistsError(error)
150154 ? get(name).pipe(
151155 Effect.flatMap((created) =>
152156 created !== null
@@ -185,6 +189,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 32 unmodified lines
185189 return {
186190 get,
187191 set,
192+ create,
188193 getOrCreateRandom,
189194 remove,
190195 } satisfies ServerSecretStoreShape;
apps/server/src/auth/SessionStore.ts+8−1
@@ -40,6 +40,7 @@ export interface IssuedSession { · 39 unmodified lines
4040 readonly client: AuthClientMetadata;
4141 readonly expiresAt: DateTime.DateTime;
4242 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
43+ readonly proofKeyThumbprint?: string;
4344 }
4445
4546 export interface VerifiedSession {
@@ -50,6 +51,7 @@ export interface VerifiedSession { · 4 unmodified lines
5051 readonly expiresAt?: DateTime.DateTime;
5152 readonly subject: string;
5253 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
54+ readonly proofKeyThumbprint?: string;
5355 }
5456
5557 export type SessionCredentialChange =
@@ -86,6 +88,7 @@ export interface SessionStoreShape { · 30 unmodified lines
8688 readonly method?: ServerAuthSessionMethod;
8789 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
8890 readonly client?: AuthClientMetadata;
91+ readonly proofKeyThumbprint?: string;
8992 }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
9093 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
9194 readonly issueWebSocketToken: (
@@ -132,7 +135,8 @@ const SessionClaims = Schema.Struct({ · 40 unmodified lines
132135 sid: AuthSessionId,
133136 sub: Schema.String,
134137 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),
136140 iat: Schema.Number,
137141 exp: Schema.Number,
138142 });
@@ -310,6 +314,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 171 unmodified lines
310314 sub: input?.subject ?? "browser",
311315 scopes: input?.scopes ?? AuthStandardClientScopes,
312316 method: input?.method ?? "browser-session-cookie",
317+ ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}),
313318 iat: issuedAt.epochMilliseconds,
314319 exp: expiresAt.epochMilliseconds,
315320 };
@@ -360,6 +365,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 44 unmodified lines
360365 client,
361366 expiresAt: expiresAt,
362367 scopes: claims.scopes,
368+ ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
363369 } satisfies IssuedSession;
364370 },
365371 Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")),
@@ -425,6 +431,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 59 unmodified lines
425431 expiresAt: expiresAt.value,
426432 subject: claims.sub,
427433 scopes: claims.scopes,
434+ ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
428435 } satisfies VerifiedSession;
429436 },
430437 Effect.mapError((cause) =>
apps/server/src/auth/dpop.test.ts+33−0
@@ -0,0 +1,33 @@
1+import { describe, expect, it } from "vitest";
2+import * as PlatformError from "effect/PlatformError";
3+
4+import * as ServerSecretStore from "./ServerSecretStore.ts";
5+import { mapDpopReplayStoreError } from "./dpop.ts";
6+
7+const 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+
18+describe("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 @@
1+import { verifyDpopProof } from "@t3tools/shared/dpop";
2+import * as Crypto from "effect/Crypto";
3+import * as DateTime from "effect/DateTime";
4+import * as Effect from "effect/Effect";
5+import * as Encoding from "effect/Encoding";
6+import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
7+
8+import * as EnvironmentAuth from "./EnvironmentAuth.ts";
9+import * as ServerSecretStore from "./ServerSecretStore.ts";
10+
11+function firstHeaderValue(value: string | undefined): string | undefined {
12+ const first = value?.split(",")[0]?.trim();
13+ return first && first.length > 0 ? first : undefined;
14+}
15+
16+export 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+
27+export 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+
40+export 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
3434 import * as EnvironmentAuth from "./EnvironmentAuth.ts";
3535 import * as SessionStore from "./SessionStore.ts";
3636 import { deriveAuthClientMetadata } from "./utils.ts";
37+import { verifyRequestDpopProof } from "./dpop.ts";
38+
39+const CREDENTIAL_RESPONSE_HEADERS = {
40+ "cache-control": "no-store",
41+ pragma: "no-cache",
42+} as const;
43+
44+const appendCredentialResponseHeaders = HttpEffect.appendPreResponseHandler((_request, response) =>
45+ Effect.succeed(HttpServerResponse.setHeaders(response, CREDENTIAL_RESPONSE_HEADERS)),
46+);
47+
48+const appendDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) =>
49+ Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", "DPoP")),
50+);
51+
52+const 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+ });
3763
3864 export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe(
3965 Effect.map((span) => span.traceId),
@@ -152,7 +178,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( · 112 unmodified lines
152178 scopes: new Set(session.scopes),
153179 }),
154180 );
155− });
181+ }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized));
156182 }),
157183 );
158184
@@ -199,6 +225,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 40 unmodified lines
199225 yield* HttpEffect.appendPreResponseHandler((_request, response) =>
200226 Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)),
201227 );
228+ yield* appendCredentialResponseHeaders;
202229 return result.response;
203230 },
204231 Effect.catchTags({
@@ -230,8 +257,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 25 unmodified lines
230257 AuthRelayWriteScope,
231258 ]),
232259 });
233− if (requestedScopes === null)
260+ if (requestedScopes === null) {
234261 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;
235276 return yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
236277 args.payload.subject_token,
237278 requestedScopes,
@@ -245,6 +286,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 7 unmodified lines
245286 ...(args.payload.client_os ? { os: args.payload.client_os } : {}),
246287 },
247288 }),
289+ proofKeyThumbprint ? { proofKeyThumbprint } : undefined,
248290 );
249291 },
250292 Effect.catchTags({
@@ -261,6 +303,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 10 unmodified lines
261303 function* (args) {
262304 yield* annotateEnvironmentRequest(args.endpoint.name);
263305 const session = yield* EnvironmentAuthenticatedPrincipal;
306+ yield* appendCredentialResponseHeaders;
264307 return yield* serverAuth.issueWebSocketTicket(session);
265308 },
266309 Effect.catchTag("ServerAuthInternalError", (error) =>
apps/server/src/auth/utils.ts+6−3
@@ -5,6 +5,8 @@ import type { · 4 unmodified lines
55 } from "@t3tools/contracts";
66 import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
77 import * as Crypto from "node:crypto";
8+import * as Encoding from "effect/Encoding";
9+import * as Result from "effect/Result";
810
911 const SESSION_COOKIE_NAME = "t3_session";
1012
@@ -20,12 +22,13 @@ export function resolveSessionCookieName(input: { · 9 unmodified lines
2022 }
2123
2224 export function base64UrlEncode(input: string | Uint8Array): string {
23− const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input);
24− return buffer.toString("base64url");
25+ return typeof input === "string"
26+ ? Encoding.encodeBase64Url(new TextEncoder().encode(input))
27+ : Encoding.encodeBase64Url(input);
2528 }
2629
2730 export function base64UrlDecodeUtf8(input: string): string {
28− return Buffer.from(input, "base64url").toString("utf8");
31+ return Result.getOrThrow(Encoding.decodeBase64UrlString(input));
2932 }
3033
3134 export function signPayload(payload: string, secret: Uint8Array): string {
apps/server/src/cloud/ManagedEndpointRuntime.test.ts+244−0
@@ -0,0 +1,244 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import * as Deferred from "effect/Deferred";
3+import * as Effect from "effect/Effect";
4+import * as Layer from "effect/Layer";
5+import * as PlatformError from "effect/PlatformError";
6+import * as Sink from "effect/Sink";
7+import * as Stream from "effect/Stream";
8+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
9+
10+import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts";
11+
12+function 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+
36+describe("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 @@
1+import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2+import * as Context from "effect/Context";
3+import * as Effect from "effect/Effect";
4+import * as Exit from "effect/Exit";
5+import * as Layer from "effect/Layer";
6+import * as Option from "effect/Option";
7+import * as Ref from "effect/Ref";
8+import * as Scope from "effect/Scope";
9+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
10+
11+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12+import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts";
13+
14+function bytesToString(bytes: Uint8Array): string {
15+ return new TextDecoder().decode(bytes);
16+}
17+
18+const 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+
27+export interface CloudManagedEndpointRuntimeShape {
28+ readonly applyConfig: (
29+ config: RelayManagedEndpointRuntimeConfig | null,
30+ ) => Effect.Effect<CloudManagedEndpointRuntimeStatus>;
31+}
32+
33+export class CloudManagedEndpointRuntime extends Context.Service<
34+ CloudManagedEndpointRuntime,
35+ CloudManagedEndpointRuntimeShape
36+>()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {}
37+
38+export 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+
61+interface ActiveConnector {
62+ readonly child: ChildProcessSpawner.ChildProcessHandle;
63+ readonly scope: Scope.Closeable;
64+ readonly configKey: string;
65+ readonly config: RelayManagedEndpointRuntimeConfig;
66+}
67+
68+function 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+
77+const 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+
89+export 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+
218+export 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 @@
1+import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2+import * as Schema from "effect/Schema";
3+
4+export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";
5+export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";
6+export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id";
7+export const RELAY_URL_SECRET = "cloud-relay-url";
8+export const RELAY_ISSUER_SECRET = "cloud-relay-issuer";
9+export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential";
10+
11+export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect(
12+ Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
13+);
14+
15+export const decodeRuntimeConfig = Schema.decodeUnknownOption(
16+ Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
17+);
apps/server/src/cloud/http.test.ts+60−0
@@ -0,0 +1,60 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
3+import * as PlatformError from "effect/PlatformError";
4+
5+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
6+import { consumeCloudReplayGuards } from "./http.ts";
7+
8+const 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+
19+const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation");
20+
21+function 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+
33+describe("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 @@
1+import * as NodeCrypto from "node:crypto";
2+import {
3+ AuthRelayManageScope,
4+ AuthStandardClientScopes,
5+ EnvironmentCloudEndpointUnavailableError,
6+ EnvironmentCloudLinkStateResult,
7+ EnvironmentCloudRelayConfigResult,
8+ EnvironmentHttpApi,
9+ EnvironmentHttpBadRequestError,
10+ EnvironmentHttpConflictError,
11+ EnvironmentHttpInternalServerError,
12+ EnvironmentHttpUnauthorizedError,
13+} from "@t3tools/contracts";
14+import {
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";
29+import {
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";
39+import * as DateTime from "effect/DateTime";
40+import * as Crypto from "effect/Crypto";
41+import * as Duration from "effect/Duration";
42+import * as Effect from "effect/Effect";
43+import * as Option from "effect/Option";
44+import * as Schema from "effect/Schema";
45+import * as HttpEffect from "effect/unstable/http/HttpEffect";
46+import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
47+import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
48+
49+import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
50+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
51+import { requireEnvironmentScope } from "../auth/http.ts";
52+import {
53+ ServerEnvironment,
54+ type ServerEnvironmentShape,
55+} from "../environment/Services/ServerEnvironment.ts";
56+import {
57+ CloudManagedEndpointRuntime,
58+ type CloudManagedEndpointRuntimeShape,
59+} from "./ManagedEndpointRuntime.ts";
60+import {
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+
70+const CLOUD_LINK_PRIVATE_KEY = "cloud-link-ed25519-private-key";
71+const CLOUD_LINK_PUBLIC_KEY = "cloud-link-ed25519-public-key";
72+const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-";
73+const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-";
74+const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-";
75+const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-";
76+const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60;
77+const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60;
78+const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
79+const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
80+ "cache-control": "no-store",
81+ pragma: "no-cache",
82+} as const;
83+
84+const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler(
85+ (_request, response) =>
86+ Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)),
87+);
88+
89+const 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+
96+function bytesToString(bytes: Uint8Array): string {
97+ return new TextDecoder().decode(bytes);
98+}
99+
100+function stringToBytes(value: string): Uint8Array {
101+ return new TextEncoder().encode(value);
102+}
103+
104+export 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+
124+function normalizePemForSignedPayload(value: string): string {
125+ return value.trim();
126+}
127+
128+function normalizeHostname(hostname: string): string {
129+ return hostname
130+ .trim()
131+ .toLowerCase()
132+ .replace(/^\[(.*)\]$/, "$1");
133+}
134+
135+function 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+
157+function 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+
171+function 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+
205+function 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+
234+function 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+
257+function isLoopbackHostname(hostname: string): boolean {
258+ return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname));
259+}
260+
261+function firstForwardedHeaderValue(value: string | undefined): string | undefined {
262+ const first = value?.split(",")[0]?.trim();
263+ return first && first.length > 0 ? first : undefined;
264+}
265+
266+function 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+
279+function 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+
286+function endpointRequestPort(url: URL): number {
287+ return Number(url.port || (url.protocol === "https:" ? 443 : 80));
288+}
289+
290+function 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+
306+function providerKindMatchesRequestedLinkScopes(request: RelayLinkProofRequest): boolean {
307+ return request.endpoint.providerKind === "cloudflare_tunnel";
308+}
309+
310+function 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+
317+function 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+
329+const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentHealthProofPayload);
330+const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload);
331+
332+export 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+
356+interface CloudHttpDependencies {
357+ readonly secrets: ServerSecretStore.ServerSecretStoreShape;
358+ readonly environment: ServerEnvironmentShape;
359+ readonly endpointRuntime: CloudManagedEndpointRuntimeShape;
360+ readonly environmentAuth: EnvironmentAuth.EnvironmentAuthShape;
361+}
362+
363+const 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+
428+const 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+
487+const 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+
512+const 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+
535+const 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+
652+const 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+
776+export 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
44 "b3",
55 "traceparent",
66 "content-type",
7+ "dpop",
78 ] as const;
89
910 export const browserApiCorsHeaders = {
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+11−0
@@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge · 10 unmodified lines
1111 import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
1212 import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
1313 import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
14+import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1415
1516 describe("OrchestrationReactor", () => {
1617 let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null;
@@ -63,6 +64,15 @@ describe("OrchestrationReactor", () => { · 46 unmodified lines
6364 drain: Effect.void,
6465 }),
6566 ),
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+ ),
6676 ),
6777 );
6878
@@ -75,6 +85,7 @@ describe("OrchestrationReactor", () => { · 6 unmodified lines
7585 "provider-command-reactor",
7686 "checkpoint-reactor",
7787 "thread-deletion-reactor",
88+ "agent-awareness-relay",
7889 ]);
7990
8091 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
99 import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
1010 import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1111 import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12+import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1213
1314 export const makeOrchestrationReactor = Effect.gen(function* () {
1415 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;
1516 const providerCommandReactor = yield* ProviderCommandReactor;
1617 const checkpointReactor = yield* CheckpointReactor;
1718 const threadDeletionReactor = yield* ThreadDeletionReactor;
19+ const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
1820
1921 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
2022 yield* providerRuntimeIngestion.start();
2123 yield* providerCommandReactor.start();
2224 yield* checkpointReactor.start();
2325 yield* threadDeletionReactor.start();
26+ yield* agentAwarenessRelay.start();
2427 });
2528
2629 return {
apps/server/src/persistence/Layers/AuthPairingLinks.ts+10−1
@@ -41,6 +41,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 40 unmodified lines
4141 scopes,
4242 subject,
4343 label,
44+ proof_key_thumbprint,
4445 created_at,
4546 expires_at,
4647 consumed_at,
@@ -53,6 +54,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 6 unmodified lines
5354 ${JSON.stringify(input.scopes)},
5455 ${input.subject},
5556 ${input.label},
57+ ${input.proofKeyThumbprint},
5658 ${input.createdAt},
5759 ${input.expiresAt},
5860 NULL,
@@ -64,7 +66,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 5 unmodified lines
6466 const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({
6567 Request: ConsumeAuthPairingLinkInput,
6668 Result: AuthPairingLinkRecord,
67− execute: ({ credential, consumedAt, now }) =>
69+ execute: ({ credential, proofKeyThumbprint, consumedAt, now }) =>
6870 sql`
6971 UPDATE auth_pairing_links
7072 SET consumed_at = ${consumedAt}
@@ -72,6 +74,10 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 1 unmodified line
7274 AND revoked_at IS NULL
7375 AND consumed_at IS NULL
7476 AND expires_at > ${now}
77+ AND (
78+ proof_key_thumbprint IS NULL
79+ OR proof_key_thumbprint = ${proofKeyThumbprint}
80+ )
7581 RETURNING
7682 id AS "id",
7783 credential AS "credential",
@@ -79,6 +85,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 1 unmodified line
7985 scopes AS "scopes",
8086 subject AS "subject",
8187 label AS "label",
88+ proof_key_thumbprint AS "proofKeyThumbprint",
8289 created_at AS "createdAt",
8390 expires_at AS "expiresAt",
8491 consumed_at AS "consumedAt",
@@ -98,6 +105,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 13 unmodified lines
98105 scopes AS "scopes",
99106 subject AS "subject",
100107 label AS "label",
108+ proof_key_thumbprint AS "proofKeyThumbprint",
101109 created_at AS "createdAt",
102110 expires_at AS "expiresAt",
103111 consumed_at AS "consumedAt",
@@ -136,6 +144,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 32 unmodified lines
136144 scopes AS "scopes",
137145 subject AS "subject",
138146 label AS "label",
147+ proof_key_thumbprint AS "proofKeyThumbprint",
139148 created_at AS "createdAt",
140149 expires_at AS "expiresAt",
141150 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
4444 import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";
4545 import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";
4646 import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
47+import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
4748
4849 /**
4950 * Migration loader with all migrations defined inline.
@@ -87,6 +88,7 @@ export const migrationEntries = [ · 37 unmodified lines
8788 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],
8889 [30, "ProjectionThreadShellArchiveIndexes", Migration0030],
8990 [31, "AuthAuthorizationScopes", Migration0031],
91+ [32, "AuthPairingProofKeyThumbprint", Migration0032],
9092 ] as const;
9193
9294 export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/032_AuthPairingProofKeyThumbprint.ts+16−0
@@ -0,0 +1,16 @@
1+import * as Effect from "effect/Effect";
2+import * as SqlClient from "effect/unstable/sql/SqlClient";
3+
4+export default Effect.gen(function* () {
5+ const sql = yield* SqlClient.SqlClient;
6+
7+ const pairingLinkColumns = yield* sql<{ readonly name: string }>`
8+ PRAGMA table_info(auth_pairing_links)
9+ `;
10+ if (!pairingLinkColumns.some((column) => column.name === "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
1313 scopes: Schema.fromJsonString(AuthEnvironmentScopes),
1414 subject: Schema.String,
1515 label: Schema.NullOr(Schema.String),
16+ proofKeyThumbprint: Schema.NullOr(Schema.String),
1617 createdAt: Schema.DateTimeUtcFromString,
1718 expiresAt: Schema.DateTimeUtcFromString,
1819 consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
@@ -27,6 +28,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ · 8 unmodified lines
2728 scopes: AuthEnvironmentScopes,
2829 subject: Schema.String,
2930 label: Schema.NullOr(Schema.String),
31+ proofKeyThumbprint: Schema.NullOr(Schema.String),
3032 createdAt: Schema.DateTimeUtcFromString,
3133 expiresAt: Schema.DateTimeUtcFromString,
3234 });
@@ -34,6 +36,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; · 1 unmodified line
3436
3537 export const ConsumeAuthPairingLinkInput = Schema.Struct({
3638 credential: Schema.String,
39+ proofKeyThumbprint: Schema.NullOr(Schema.String),
3740 consumedAt: Schema.DateTimeUtcFromString,
3841 now: Schema.DateTimeUtcFromString,
3942 });
apps/server/src/relay/AgentAwarenessRelay.test.ts+634−0
@@ -0,0 +1,634 @@
1+import * as NodeCrypto from "node:crypto";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+
4+import type {
5+ EnvironmentId,
6+ ExecutionEnvironmentDescriptor,
7+ OrchestrationEvent,
8+ OrchestrationProjectShell,
9+ OrchestrationShellSnapshot,
10+ OrchestrationThreadShell,
11+ ProjectId,
12+ ThreadId,
13+ TurnId,
14+} from "@t3tools/contracts";
15+import type {
16+ RelayAgentActivityPublishProofPayload,
17+ RelayAgentActivityState,
18+} from "@t3tools/contracts/relay";
19+import { CommandId, ProviderInstanceId } from "@t3tools/contracts";
20+import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";
21+import { describe, expect, it } from "@effect/vitest";
22+import * as Deferred from "effect/Deferred";
23+import * as Effect from "effect/Effect";
24+import * as Layer from "effect/Layer";
25+import * as Option from "effect/Option";
26+import * as Queue from "effect/Queue";
27+import * as Stream from "effect/Stream";
28+
29+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
30+import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
31+import {
32+ OrchestrationEngineService,
33+ type OrchestrationEngineShape,
34+} from "../orchestration/Services/OrchestrationEngine.ts";
35+import {
36+ ProjectionSnapshotQuery,
37+ type ProjectionSnapshotQueryShape,
38+} from "../orchestration/Services/ProjectionSnapshotQuery.ts";
39+import {
40+ RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
41+ RELAY_ISSUER_SECRET,
42+ RELAY_URL_SECRET,
43+} from "../cloud/config.ts";
44+import * as AgentAwarenessRelay from "./AgentAwarenessRelay.ts";
45+
46+const 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+
58+const encodeSecret = (value: string): Uint8Array => new TextEncoder().encode(value);
59+
60+function 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+
96+describe.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 @@
1+import {
2+ RelayApi,
3+ type RelayAgentActivityPublishProofPayload,
4+ type RelayAgentActivityState,
5+} from "@t3tools/contracts/relay";
6+import type {
7+ EnvironmentId,
8+ OrchestrationEvent,
9+ OrchestrationProjectShell,
10+ OrchestrationThreadShell,
11+ ThreadId,
12+} from "@t3tools/contracts";
13+import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
14+import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
15+import {
16+ RELAY_ACTIVITY_PUBLISH_TYP,
17+ signRelayJwt,
18+ normalizeRelayIssuer,
19+} from "@t3tools/shared/relayJwt";
20+import * as Cause from "effect/Cause";
21+import * as Context from "effect/Context";
22+import * as Crypto from "effect/Crypto";
23+import * as DateTime from "effect/DateTime";
24+import * as Effect from "effect/Effect";
25+import * as Layer from "effect/Layer";
26+import * as Option from "effect/Option";
27+import * as Ref from "effect/Ref";
28+import type * as Scope from "effect/Scope";
29+import * as Stream from "effect/Stream";
30+import { FetchHttpClient } from "effect/unstable/http";
31+import * as HttpClient from "effect/unstable/http/HttpClient";
32+import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
33+import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
34+
35+import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
36+import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/http.ts";
37+import {
38+ RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
39+ RELAY_ISSUER_SECRET,
40+ RELAY_URL_SECRET,
41+} from "../cloud/config.ts";
42+import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
43+import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
44+import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
45+
46+export interface AgentAwarenessRelayShape {
47+ readonly publishThread: (threadId: ThreadId) => Effect.Effect<void>;
48+ readonly start: () => Effect.Effect<void, never, Scope.Scope>;
49+}
50+
51+export class AgentAwarenessRelay extends Context.Service<
52+ AgentAwarenessRelay,
53+ AgentAwarenessRelayShape
54+>()("t3/relay/AgentAwarenessRelay") {}
55+
56+export 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+
67+export 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+
89+export 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+
97+function relayEnvironmentClient(token: string) {
98+ return HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", `Bearer ${token}`));
99+}
100+
101+function 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+
140+export 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+
151+const 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+
175+export 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+
210+export 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+
233+const 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+
459+export const layer = Layer.effect(AgentAwarenessRelay, make);
apps/server/src/server.test.ts+2009−265
@@ -1,6 +1,7 @@
11 import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
22 import * as NodeSocket from "@effect/platform-node/NodeSocket";
33 import * as NodeServices from "@effect/platform-node/NodeServices";
4+import * as NodeCrypto from "node:crypto";
45
56 import {
67 AuthAccessTokenType,
@@ -28,6 +29,12 @@ import { · 21 unmodified lines
2829 WsRpcGroup,
2930 EditorId,
3031 } from "@t3tools/contracts";
32+import {
33+ computeDpopAccessTokenHash,
34+ computeDpopJwkThumbprint,
35+ type DpopPublicJwk,
36+} from "@t3tools/shared/dpop";
37+import { RELAY_HEALTH_REQUEST_TYP, RELAY_MINT_REQUEST_TYP } from "@t3tools/shared/relayJwt";
3138 import { assert, it } from "@effect/vitest";
3239 import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
3340 import * as Clock from "effect/Clock";
@@ -43,13 +50,13 @@ import * as Path from "effect/Path"; · 9 unmodified lines
4350 import * as Stream from "effect/Stream";
4451 import { ChildProcessSpawner } from "effect/unstable/process";
4552 import {
46− Cookies,
4753 FetchHttpClient,
4854 HttpBody,
4955 HttpClient,
56+ HttpClientRequest,
57+ HttpClientResponse,
5058 HttpRouter,
5159 HttpServer,
52− UrlParams,
5360 } from "effect/unstable/http";
5461 import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
5562 import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
@@ -120,6 +127,10 @@ import * as ReviewService from "./review/ReviewService.ts"; · 64 unmodified lines
120127 import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
121128 import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
122129 import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
130+import {
131+ CloudManagedEndpointRuntime,
132+ type CloudManagedEndpointRuntimeShape,
133+} from "./cloud/ManagedEndpointRuntime.ts";
123134 import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
124135 import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
125136 import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
@@ -347,6 +358,7 @@ const buildAppUnderTest = (options?: { · 221 unmodified lines
347358 serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>;
348359 serverEnvironment?: Partial<ServerEnvironmentShape>;
349360 repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>;
361+ cloudManagedEndpointRuntime?: Partial<CloudManagedEndpointRuntimeShape>;
350362 };
351363 }) =>
352364 Effect.gen(function* () {
@@ -741,7 +753,17 @@ const buildAppUnderTest = (options?: { · 388 unmodified lines
741753 ...options?.layers?.repositoryIdentityResolver,
742754 }),
743755 ),
756+ Layer.provide(
757+ Layer.succeed(
758+ CloudManagedEndpointRuntime,
759+ CloudManagedEndpointRuntime.of({
760+ applyConfig: () => Effect.succeed({ status: "disabled" }),
761+ ...options?.layers?.cloudManagedEndpointRuntime,
762+ }),
763+ ),
764+ ),
744765 Layer.provideMerge(makeAuthTestLayer()),
766+ Layer.provideMerge(ServerSecretStore.layer),
745767 Layer.provide(workspaceAndProjectServicesLayer),
746768 Layer.provideMerge(FetchHttpClient.layer),
747769 Layer.provide(layerConfig),
@@ -799,6 +821,13 @@ const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => · 51 unmodified lines
799821 return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`;
800822 };
801823
824+const 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+
802831 const bootstrapBrowserSession = (
803832 credential = defaultDesktopBootstrapToken,
804833 options?: {
@@ -806,19 +835,26 @@ const bootstrapBrowserSession = ( · 1 unmodified line
806835 },
807836 ) =>
808837 Effect.gen(function* () {
809− const response = yield* HttpClient.post("/api/auth/browser-session", {
810− headers: options?.headers,
811− body: yield* HttpBody.json({ credential }),
838+ const bootstrapUrl = yield* getHttpServerUrl("/api/auth/browser-session");
839+ const response = yield* fetchEffect(bootstrapUrl, {
840+ method: "POST",
841+ headers: {
842+ "content-type": "application/json",
843+ ...options?.headers,
844+ },
845+ body: jsonRequestBody({
846+ credential,
847+ }),
812848 });
813− const body = (yield* response.json) as {
849+ const body = yield* responseJsonEffect<{
814850 readonly authenticated: boolean;
815851 readonly sessionMethod: string;
816852 readonly expiresAt: string;
817− };
853+ }>(response);
818854 return {
819855 response,
820856 body,
821− cookie: Cookies.toSetCookieHeaders(response.cookies)[0] ?? null,
857+ cookie: response.headers["set-cookie"],
822858 };
823859 });
824860
@@ -835,48 +871,227 @@ const exchangeAccessToken = ( · 10 unmodified lines
835871 },
836872 ) =>
837873 Effect.gen(function* () {
838− const response = yield* HttpClient.post("/oauth/token", {
839− headers: options?.headers,
840− body: HttpBody.urlParams(
841− UrlParams.fromInput({
842− grant_type: AuthTokenExchangeGrantType,
843− subject_token: credential,
844− subject_token_type: AuthEnvironmentBootstrapTokenType,
845− requested_token_type: AuthAccessTokenType,
846− scope:
847− options?.scope ??
848− "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
849− ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}),
850− ...(options?.clientMetadata?.deviceType
851− ? { client_device_type: options.clientMetadata.deviceType }
852− : {}),
853− ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}),
854− }),
855− ),
874+ const tokenUrl = yield* getHttpServerUrl("/oauth/token");
875+ const response = yield* fetchEffect(tokenUrl, {
876+ method: "POST",
877+ headers: {
878+ "content-type": "application/x-www-form-urlencoded",
879+ ...options?.headers,
880+ },
881+ body: new URLSearchParams({
882+ grant_type: AuthTokenExchangeGrantType,
883+ subject_token: credential,
884+ subject_token_type: AuthEnvironmentBootstrapTokenType,
885+ requested_token_type: AuthAccessTokenType,
886+ scope:
887+ options?.scope ??
888+ "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
889+ ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}),
890+ ...(options?.clientMetadata?.deviceType
891+ ? { client_device_type: options.clientMetadata.deviceType }
892+ : {}),
893+ ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}),
894+ }).toString(),
856895 });
857− const body = (yield* response.json) as {
896+ const body = yield* responseJsonEffect<{
858897 readonly access_token?: string;
859898 readonly issued_token_type?: string;
860899 readonly token_type?: string;
861900 readonly expires_in?: number;
862901 readonly scope?: string;
863902 readonly _tag?: string;
864− readonly message?: string;
865− };
903+ readonly code?: string;
904+ readonly reason?: string;
905+ readonly traceId?: string;
906+ }>(response);
866907 return {
867908 response,
868909 body,
869910 };
870911 });
871912
913+const 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+
959+const 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+
997+const 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+
1030+const 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+
8721038 class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{
8731039 readonly message: string;
8741040 }> {}
8751041
1042+class TestHttpRequestError extends Data.TaggedError("TestHttpRequestError")<{
1043+ readonly cause: unknown;
1044+}> {}
1045+
1046+const 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+
1055+const 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+
1078+const jsonRequestBody = (value: unknown): string => {
1079+ return JSON.stringify(value);
1080+};
1081+
1082+const 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+
1088+const responseOk = (response: HttpClientResponse.HttpClientResponse) =>
1089+ response.status >= 200 && response.status < 300;
1090+
8761091 const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) =>
8771092 Effect.gen(function* () {
8781093 const { response, cookie } = yield* bootstrapBrowserSession(credential);
879− if (response.status !== 200) {
1094+ if (!responseOk(response)) {
8801095 return yield* new AuthenticationGetterError({
8811096 message: `Expected bootstrap session response to succeed, got ${response.status}`,
8821097 });
@@ -894,7 +1109,7 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra · 11 unmodified lines
8941109 const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) =>
8951110 Effect.gen(function* () {
8961111 const { response, body } = yield* exchangeAccessToken(credential);
897− if (response.status !== 200) {
1112+ if (!responseOk(response)) {
8981113 return yield* new AuthenticationGetterError({
8991114 message: `Expected bearer bootstrap response to succeed, got ${response.status}`,
9001115 });
@@ -918,7 +1133,7 @@ const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { · 17 unmodified lines
9181133 return token;
9191134 };
9201135
921−const splitHeaderTokens = (value: string | null) =>
1136+const splitHeaderTokens = (value: string | null | undefined) =>
9221137 (value ?? "")
9231138 .split(",")
9241139 .map((token) => token.trim())
@@ -940,10 +1155,11 @@ const assertBrowserApiCorsPreflightHeaders = ( · 15 unmodified lines
9401155 "OPTIONS",
9411156 "POST",
9421157 ]);
943− assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"] ?? null), [
1158+ assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"]), [
9441159 "authorization",
9451160 "b3",
9461161 "content-type",
1162+ "dpop",
9471163 "traceparent",
9481164 ]);
9491165 };
@@ -989,9 +1205,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 39 unmodified lines
9891205 config: { devUrl: new URL("http://127.0.0.1:5173") },
9901206 });
9911207
992− const response = yield* HttpClient.get("/foo/bar?token=test-token").pipe(
993− Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
994− );
1208+ const url = yield* getHttpServerUrl("/foo/bar?token=test-token");
1209+ const response = yield* fetchEffect(url, { redirect: "manual" });
9951210
9961211 assert.equal(response.status, 302);
9971212 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
10571272 Effect.gen(function* () {
10581273 yield* buildAppUnderTest();
10591274
1060− const response = yield* HttpClient.get("/.well-known/t3/environment");
1061− const body = (yield* response.json) as typeof testEnvironmentDescriptor;
1275+ const url = yield* getHttpServerUrl("/.well-known/t3/environment");
1276+ const response = yield* fetchEffect(url);
1277+ const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response);
10621278
10631279 assert.equal(response.status, 200);
10641280 assert.deepEqual(body, testEnvironmentDescriptor);
@@ -1069,12 +1285,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 4 unmodified lines
10691285 Effect.gen(function* () {
10701286 yield* buildAppUnderTest();
10711287
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, {
10731290 headers: {
10741291 origin: crossOriginClientOrigin,
10751292 },
10761293 });
1077− const body = (yield* response.json) as typeof testEnvironmentDescriptor;
1294+ const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response);
10781295
10791296 assert.equal(response.status, 200);
10801297 assertBrowserApiCorsResponseHeaders(response.headers);
@@ -1086,8 +1303,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines
10861303 Effect.gen(function* () {
10871304 yield* buildAppUnderTest();
10881305
1089− const response = yield* HttpClient.get("/api/auth/session");
1090− const body = (yield* response.json) as {
1306+ const url = yield* getHttpServerUrl("/api/auth/session");
1307+ const response = yield* fetchEffect(url);
1308+ const body = yield* responseJsonEffect<{
10911309 readonly authenticated: boolean;
10921310 readonly auth: {
10931311 readonly policy: string;
@@ -1095,13 +1313,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 1 unmodified line
10951313 readonly sessionMethods: ReadonlyArray<string>;
10961314 readonly sessionCookieName: string;
10971315 };
1098− };
1316+ }>(response);
10991317
11001318 assert.equal(response.status, 200);
11011319 assert.equal(body.authenticated, false);
11021320 assert.equal(body.auth.policy, "desktop-managed-local");
11031321 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+ ]);
11051327 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));
11061328 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11071329 );
@@ -1122,15 +1344,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 14 unmodified lines
11221344 assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken);
11231345 assert.isDefined(setCookie);
11241346
1125− const sessionResponse = yield* HttpClient.get("/api/auth/session", {
1347+ const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
1348+ const sessionResponse = yield* fetchEffect(sessionUrl, {
11261349 headers: {
11271350 cookie: setCookie?.split(";")[0] ?? "",
11281351 },
11291352 });
1130− const sessionBody = (yield* sessionResponse.json) as {
1353+ const sessionBody = yield* responseJsonEffect<{
11311354 readonly authenticated: boolean;
11321355 readonly sessionMethod?: string;
1133− };
1356+ }>(sessionResponse);
11341357
11351358 assert.equal(sessionResponse.status, 200);
11361359 assert.equal(sessionBody.authenticated, true);
@@ -1142,9 +1365,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines
11421365 Effect.gen(function* () {
11431366 yield* buildAppUnderTest();
11441367
1145− const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken();
1368+ const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken();
11461369
1147− assert.equal(bootstrapResponse.status, 200);
1370+ assert.equal(tokenResponse.status, 200);
11481371 assert.equal(tokenBody.issued_token_type, AuthAccessTokenType);
11491372 assert.equal(tokenBody.token_type, "Bearer");
11501373 assert.equal(
@@ -1152,18 +1375,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 1 unmodified line
11521375 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
11531376 );
11541377 assert.equal(typeof tokenBody.access_token, "string");
1155− assert.isTrue((tokenBody.access_token?.length ?? 0) > 0);
11561378
1157− const sessionResponse = yield* HttpClient.get("/api/auth/session", {
1379+ const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
1380+ const sessionResponse = yield* fetchEffect(sessionUrl, {
11581381 headers: {
11591382 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
11601383 },
11611384 });
1162− const sessionBody = (yield* sessionResponse.json) as {
1385+ const sessionBody = yield* responseJsonEffect<{
11631386 readonly authenticated: boolean;
11641387 readonly sessionMethod?: string;
11651388 readonly scopes?: ReadonlyArray<string>;
1166− };
1389+ }>(sessionResponse);
11671390
11681391 assert.equal(sessionResponse.status, 200);
11691392 assert.equal(sessionBody.authenticated, true);
@@ -1181,187 +1404,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 11 unmodified lines
11811404 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11821405 );
11831406
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−
13651407 it.effect("persists token exchange client display metadata for authorized-client listings", () =>
13661408 Effect.gen(function* () {
13671409 yield* buildAppUnderTest({
@@ -1385,7 +1427,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 17 unmodified lines
13851427 headers: {
13861428 "user-agent": "undici",
13871429 },
1388− scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
1430+ scope: "orchestration:read orchestration:operate terminal:operate review:write",
13891431 clientMetadata: {
13901432 label: "T3 Code Mobile",
13911433 deviceType: "mobile",
@@ -1423,23 +1465,1711 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 31 unmodified lines
14231465 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
14241466 );
14251467
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", () =>
14271545 Effect.gen(function* () {
14281546 yield* buildAppUnderTest();
14291547
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", {
14311550 headers: {
1432− cookie: yield* getAuthenticatedSessionCookieHeader(),
1551+ cookie: ownerCookie,
14331552 },
14341553 body: yield* HttpBody.json({}),
14351554 });
1436− const body = (yield* response.json) as {
1555+ const firstCredential = (yield* firstCredentialResponse.json) as {
14371556 readonly credential: string;
1438− readonly expiresAt: string;
14391557 };
1440−
1441− assert.equal(response.status, 200);
1442− assert.equal(typeof body.credential, "string");
1558+ const secondCredentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1559+ headers: {
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");
14433173 assert.isTrue(body.credential.length > 0);
14443174 assert.equal(typeof body.expiresAt, "string");
14453175
@@ -1538,8 +3268,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 92 unmodified lines
15383268 const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", {
15393269 headers: {
15403270 cookie: ownerCookie,
3271+ "content-type": "application/json",
15413272 },
1542− body: yield* HttpBody.json({ id: createdBody.id }),
3273+ body: HttpBody.text(jsonRequestBody({ id: createdBody.id }), "application/json"),
15433274 });
15443275 const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential);
15453276
@@ -1601,16 +3332,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 55 unmodified lines
16013332 });
16023333
16033334 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",
16053338 headers: {
16063339 cookie: ownerCookie,
3340+ "content-type": "application/json",
16073341 },
1608− body: yield* HttpBody.json({ label: "Julius iPhone" }),
3342+ body: jsonRequestBody({
3343+ label: "Julius iPhone",
3344+ }),
16093345 });
1610− const ownerPairingBody = (yield* ownerPairingResponse.json) as {
3346+ const ownerPairingBody = yield* responseJsonEffect<{
16113347 readonly credential: string;
16123348 readonly label?: string;
1613− };
3349+ }>(ownerPairingResponse);
16143350 assert.equal(ownerPairingResponse.status, 200);
16153351 const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, {
16163352 headers: {
@@ -1794,8 +3530,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 177 unmodified lines
17943530 const revokeResponse = yield* HttpClient.post("/api/auth/clients/revoke", {
17953531 headers: {
17963532 cookie: ownerCookie,
3533+ "content-type": "application/json",
17973534 },
1798− body: yield* HttpBody.json({ sessionId: pairedSessionId }),
3535+ body: HttpBody.text(jsonRequestBody({ sessionId: pairedSessionId }), "application/json"),
17993536 });
18003537 const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
18013538 headers: {
@@ -1890,20 +3627,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 88 unmodified lines
18903627 );
18913628
18923629 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",
18943631 () =>
18953632 Effect.gen(function* () {
18963633 yield* buildAppUnderTest();
18973634
18983635 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",
19003639 headers: {
19013640 authorization: `Bearer ${bearerToken}`,
19023641 },
19033642 });
1904− const wsTicketBody = (yield* wsTicketResponse.json) as {
3643+ const wsTicketBody = yield* responseJsonEffect<{
19053644 readonly ticket: string;
1906− };
3645+ }>(wsTicketResponse);
19073646 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
19083647
19093648 const response = yield* Effect.scoped(
@@ -2103,7 +3842,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 193 unmodified lines
21033842 "content-type": "application/json",
21043843 origin: "http://localhost:5733",
21053844 },
2106− body: yield* HttpBody.json(payload),
3845+ // @effect-diagnostics-next-line preferSchemaOverJson:off
3846+ body: HttpBody.text(JSON.stringify(payload), "application/json"),
21073847 });
21083848
21093849 assert.equal(response.status, 204);
@@ -2148,8 +3888,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 38 unmodified lines
21483888 ]);
21493889 assert.deepEqual(upstreamRequests, [
21503890 {
2151− // @effect-diagnostics-next-line preferSchemaOverJson:off
2152− body: JSON.stringify(payload),
3891+ body: jsonRequestBody(payload),
21533892 contentType: "application/json",
21543893 },
21553894 ]);
@@ -2170,14 +3909,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 14 unmodified lines
21703909
21713910 assert.equal(response.status, 204);
21723911 assert.equal(response.headers["access-control-allow-origin"], "*");
2173− assert.deepEqual(
2174− splitHeaderTokens(response.headers["access-control-allow-methods"] ?? null),
2175− ["GET", "OPTIONS", "POST"],
2176− );
2177− assert.deepEqual(
2178− splitHeaderTokens(response.headers["access-control-allow-headers"] ?? null),
2179− ["authorization", "b3", "content-type", "traceparent"],
2180− );
3912+ assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-methods"]), [
3913+ "GET",
3914+ "OPTIONS",
3915+ "POST",
3916+ ]);
3917+ assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-headers"]), [
3918+ "authorization",
3919+ "b3",
3920+ "content-type",
3921+ "dpop",
3922+ "traceparent",
3923+ ]);
21813924 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
21823925 );
21833926
@@ -2214,7 +3957,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 30 unmodified lines
22143957 cookie: yield* getAuthenticatedSessionCookieHeader(),
22153958 "content-type": "application/json",
22163959 },
2217− body: yield* HttpBody.json(payload),
3960+ // @effect-diagnostics-next-line preferSchemaOverJson:off
3961+ body: HttpBody.text(JSON.stringify(payload), "application/json"),
22183962 });
22193963
22203964 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
4444 import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
4545 import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
4646 import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
47+import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
4748 import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
4849 import { ServerSettingsLive } from "./serverSettings.ts";
4950 import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
@@ -67,6 +68,8 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts · 17 unmodified lines
6768 import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
6869 import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
6970 import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
71+import { cloudHttpApiLayer } from "./cloud/http.ts";
72+import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
7073 import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
7174 import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
7275 import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
@@ -134,6 +137,7 @@ const ReactorLayerLive = Layer.empty.pipe( · 61 unmodified lines
134137 Layer.provideMerge(ProviderCommandReactorLive),
135138 Layer.provideMerge(CheckpointReactorLive),
136139 Layer.provideMerge(ThreadDeletionReactorLive),
140+ Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
137141 Layer.provideMerge(RuntimeReceiptBusLive),
138142 );
139143
@@ -271,6 +275,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 131 unmodified lines
271275 Layer.provideMerge(RepositoryIdentityResolverLive),
272276 Layer.provideMerge(ServerEnvironmentLive),
273277 Layer.provideMerge(AuthLayerLive),
278+ Layer.provideMerge(ServerSecretStore.layer),
279+ Layer.provideMerge(
280+ CloudManagedEndpointRuntime.layer.pipe(Layer.provide(ServerSecretStore.layer)),
281+ ),
274282 );
275283
276284 const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
@@ -291,6 +299,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( · 14 unmodified lines
291299 export const makeRoutesLayer = Layer.mergeAll(
292300 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
293301 Layer.provide(authHttpApiLayer),
302+ Layer.provide(cloudHttpApiLayer),
294303 Layer.provide(orchestrationHttpApiLayer),
295304 Layer.provide(serverEnvironmentHttpApiLayer),
296305 Layer.provide(environmentAuthenticatedAuthLayer),
apps/server/src/serverRuntimeStartup.ts+5−2
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 ThreadId,
99 } from "@t3tools/contracts";
1010 import * as Data from "effect/Data";
11+import * as Crypto from "effect/Crypto";
1112 import * as Deferred from "effect/Deferred";
1213 import * as Effect from "effect/Effect";
1314 import * as Exit from "effect/Exit";
@@ -19,7 +20,6 @@ import * as Ref from "effect/Ref"; · 5 unmodified lines
1920 import * as Scope from "effect/Scope";
2021 import * as Context from "effect/Context";
2122 import * as Console from "effect/Console";
22−import * as Crypto from "effect/Crypto";
2323 import * as DateTime from "effect/DateTime";
2424
2525 import { ServerConfig } from "./config.ts";
@@ -289,6 +289,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { · 263 unmodified lines
289289 const lifecycleEvents = yield* ServerLifecycleEvents;
290290 const serverSettings = yield* ServerSettingsService;
291291 const serverEnvironment = yield* ServerEnvironment;
292+ const crypto = yield* Crypto.Crypto;
292293
293294 const commandGate = yield* makeCommandGate;
294295 const httpListening = yield* Deferred.make<void>();
@@ -361,7 +362,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { · 66 unmodified lines
361362 runStartupPhase(
362363 "welcome.autobootstrap",
363364 Effect.gen(function* () {
364− const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets;
365+ const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe(
366+ Effect.provideService(Crypto.Crypto, crypto),
367+ );
365368 if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) {
366369 return;
367370 }
apps/web/package.json+4−0
@@ -14,6 +14,8 @@ · 13 unmodified lines
1414 },
1515 "dependencies": {
1616 "@base-ui/react": "^1.4.1",
17+ "@clerk/clerk-js": "^6.13.0",
18+ "@clerk/react": "^6.7.2",
1719 "@dnd-kit/core": "^6.3.1",
1820 "@dnd-kit/modifiers": "^9.0.0",
1921 "@dnd-kit/sortable": "^10.0.0",
@@ -33,6 +35,7 @@ · 13 unmodified lines
3335 "@xterm/xterm": "^6.0.0",
3436 "class-variance-authority": "^0.7.1",
3537 "effect": "catalog:",
38+ "jose": "catalog:",
3639 "lexical": "^0.41.0",
3740 "lucide-react": "^0.564.0",
3841 "react": "19.2.6",
@@ -44,6 +47,7 @@ · 5 unmodified lines
4447 },
4548 "devDependencies": {
4649 "@effect/platform-node": "catalog:",
50+ "@effect/vitest": "catalog:",
4751 "@rolldown/plugin-babel": "^0.2.0",
4852 "@tailwindcss/vite": "^4.0.0",
4953 "@tanstack/router-plugin": "^1.161.0",
apps/web/src/clientPersistenceStorage.ts+14−2
@@ -27,6 +27,7 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ · 26 unmodified lines
2727 port: Schema.NullOr(Schema.Number),
2828 }),
2929 ),
30+ relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })),
3031 bearerToken: Schema.optionalKey(Schema.String),
3132 });
3233 type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type;
@@ -53,7 +54,11 @@ function toPersistedSavedEnvironmentRecord( · 20 unmodified lines
5354 createdAt: record.createdAt,
5455 lastConnectedAt: record.lastConnectedAt,
5556 };
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+ };
5762 }
5863
5964 export function readBrowserClientSettings(): ClientSettings | null {
@@ -145,6 +150,7 @@ export function writeBrowserSavedEnvironmentRegistry( · 85 unmodified lines
145150 createdAt: record.createdAt,
146151 lastConnectedAt: record.lastConnectedAt,
147152 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
153+ ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
148154 bearerToken,
149155 }
150156 : toPersistedSavedEnvironmentRecord(record);
@@ -171,6 +177,8 @@ export function writeBrowserSavedEnvironmentSecret( · 20 unmodified lines
171177 let found = false;
172178 writeBrowserSavedEnvironmentRegistryDocument({
173179 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
174182 records: records.map((record) => {
175183 if (record.environmentId !== environmentId) {
176184 return record;
@@ -185,7 +193,11 @@ export function writeBrowserSavedEnvironmentSecret( · 8 unmodified lines
185193 lastConnectedAt: record.lastConnectedAt,
186194 bearerToken: secret,
187195 };
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+ };
189201 }),
190202 });
191203 return found;
apps/web/src/cloud/desktopAuth.test.ts+24−0
@@ -0,0 +1,24 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { resolveDesktopCloudAuthOAuthOptions } from "./desktopAuth";
4+
5+describe("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 @@
1+export type DesktopCloudAuthOAuthStrategy = `oauth_${string}`;
2+
3+export interface DesktopCloudAuthOAuthOption {
4+ readonly strategy: DesktopCloudAuthOAuthStrategy;
5+ readonly label: string;
6+}
7+
8+interface ClerkOAuthProviderSetting {
9+ readonly enabled?: unknown;
10+ readonly authenticatable?: unknown;
11+ readonly strategy?: unknown;
12+ readonly name?: unknown;
13+}
14+
15+interface ClerkUserSettingsLike {
16+ readonly authenticatableSocialStrategies?: unknown;
17+ readonly social?: unknown;
18+}
19+
20+interface ClerkEnvironmentLike {
21+ readonly userSettings?: ClerkUserSettingsLike;
22+}
23+
24+interface ClerkLike {
25+ readonly __internal_environment?: ClerkEnvironmentLike;
26+ readonly environment?: ClerkEnvironmentLike;
27+}
28+
29+const isClerkOAuthProviderSetting = (value: unknown): value is ClerkOAuthProviderSetting =>
30+ typeof value === "object" && value !== null;
31+
32+const 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+
44+export function isDesktopCloudAuthOAuthStrategy(
45+ value: unknown,
46+): value is DesktopCloudAuthOAuthStrategy {
47+ return typeof value === "string" && value.startsWith("oauth_");
48+}
49+
50+export 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+
64+export 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+
107+function 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 @@
1+import { Clerk } from "@clerk/clerk-js";
2+import {
3+ buildClerkUIScriptAttributes,
4+ clerkUIScriptUrl,
5+ InternalClerkProvider,
6+} from "@clerk/react/internal";
7+import type { ClerkProviderProps } from "@clerk/react";
8+import React, { useEffect, useState } from "react";
9+
10+type DesktopClerkUiCtor = NonNullable<Window["__internal_ClerkUICtor"]>;
11+
12+interface ClerkFrontendApiRequest {
13+ credentials?: RequestCredentials;
14+ headers?: Headers;
15+ url?: URL;
16+}
17+
18+interface ClerkFrontendApiResponse {
19+ headers: Headers;
20+ payload?: {
21+ errors?: readonly {
22+ code?: string;
23+ }[];
24+ };
25+}
26+
27+interface 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+
49+interface DesktopClerkProviderProps {
50+ readonly children: React.ReactNode;
51+ readonly publishableKey: string;
52+}
53+
54+let desktopClerk: Clerk | null = null;
55+let desktopClerkFetchInstalled = false;
56+let desktopClerkUiLoad: Promise<DesktopClerkUiCtor> | null = null;
57+
58+const 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+
74+const getStoredClientJwt = (): Promise<string | null> =>
75+ window.desktopBridge?.getCloudAuthToken() ?? Promise.resolve(null);
76+
77+const setStoredClientJwt = (token: string): Promise<boolean> =>
78+ window.desktopBridge?.setCloudAuthToken(token) ?? Promise.resolve(false);
79+
80+const clearStoredClientJwt = (): Promise<void> =>
81+ window.desktopBridge?.clearCloudAuthToken() ?? Promise.resolve();
82+
83+const isClerkFrontendApiUrl = (url: URL): boolean =>
84+ url.protocol === "https:" &&
85+ (url.hostname.endsWith(".clerk.accounts.dev") || url.hostname.endsWith(".clerk.accounts.com"));
86+
87+const 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+
95+function 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+
128+function 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+
189+function 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+
242+export 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 @@
1+import { verifyDpopProof } from "@t3tools/shared/dpop";
2+import * as Effect from "effect/Effect";
3+import { describe, expect, it, vi } from "vitest";
4+
5+import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop";
6+
7+describe("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 @@
1+import {
2+ computeDpopAccessTokenHash,
3+ computeDpopJwkThumbprint,
4+ DpopPublicJwk,
5+} from "@t3tools/shared/dpop";
6+import * as Crypto from "effect/Crypto";
7+import * as Data from "effect/Data";
8+import * as Effect from "effect/Effect";
9+import * as Layer from "effect/Layer";
10+import * as Schema from "effect/Schema";
11+import { importJWK, SignJWT, type JWK } from "jose";
12+
13+export interface BrowserDpopKey {
14+ readonly privateKey: CryptoKey;
15+ readonly publicJwk: DpopPublicJwk;
16+ readonly thumbprint: string;
17+}
18+
19+export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{
20+ readonly message: string;
21+ readonly cause?: unknown;
22+}> {}
23+
24+const DPOP_DATABASE_NAME = "t3code:cloud-auth";
25+const DPOP_DATABASE_VERSION = 1;
26+const DPOP_KEY_STORE_NAME = "keys";
27+const DPOP_KEY_ID = "relay-dpop-proof-key";
28+const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk);
29+
30+export 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+
43+function dpopError(message: string, cause?: unknown) {
44+ return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) });
45+}
46+
47+function 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+
64+export 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+
87+export 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+
110+export 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+
148+export 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 @@
1+import { EnvironmentId } from "@t3tools/contracts";
2+import { RelayWebClientId } from "@t3tools/contracts/relay";
3+import { beforeEach, vi } from "vitest";
4+import { describe, expect, it } from "@effect/vitest";
5+import * as Effect from "effect/Effect";
6+import * as Layer from "effect/Layer";
7+import { HttpClient } from "effect/unstable/http";
8+import {
9+ managedRelayClientLayer,
10+ ManagedRelayClient,
11+ ManagedRelayDpopSigner,
12+ remoteHttpClientLayer,
13+} from "@t3tools/client-runtime";
14+
15+import type { SavedEnvironmentRecord } from "../environments/runtime";
16+import {
17+ connectManagedCloudEnvironment,
18+ linkEnvironmentToCloud,
19+ linkPrimaryEnvironmentToCloud,
20+ listManagedCloudEnvironments,
21+ normalizeRelayBaseUrl,
22+ readPrimaryCloudLinkState,
23+ unlinkPrimaryEnvironmentFromCloud,
24+} from "./linkEnvironment";
25+import {
26+ readPrimaryEnvironmentDescriptor,
27+ readPrimaryEnvironmentTarget,
28+ resolvePrimaryEnvironmentHttpUrl,
29+} from "../environments/primary";
30+
31+const getSavedEnvironmentSecretMock = vi.fn();
32+
33+const createProofMock = vi.fn(
34+ (_input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
35+ Effect.succeed("web-dpop-proof"),
36+);
37+const testDpopSignerLayer = Layer.succeed(
38+ ManagedRelayDpopSigner,
39+ ManagedRelayDpopSigner.of({
40+ thumbprint: Effect.succeed("web-thumbprint"),
41+ createProof: (input) => createProofMock(input),
42+ }),
43+);
44+
45+function 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+
56+const withCloudServices = <A, E>(
57+ effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>,
58+) => effect.pipe(Effect.provide(cloudClientLayer()));
59+
60+vi.mock("../localApi", () => ({
61+ ensureLocalApi: () => ({
62+ persistence: {
63+ getSavedEnvironmentSecret: getSavedEnvironmentSecretMock,
64+ },
65+ }),
66+}));
67+
68+vi.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+
74+const 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+
83+function validProof() {
84+ return "signed-environment-link-jwt";
85+}
86+
87+function validChallenge() {
88+ return {
89+ challenge: "link-challenge",
90+ expiresAt: "2026-05-25T00:05:00.000Z",
91+ };
92+}
93+
94+function requestBodyText(body: BodyInit | null | undefined): string {
95+ return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
96+}
97+
98+describe("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 @@
1+import * as Data from "effect/Data";
2+import * as Effect from "effect/Effect";
3+import * as Schema from "effect/Schema";
4+import { FetchHttpClient, HttpClient } from "effect/unstable/http";
5+import {
6+ EnvironmentCloudEndpointUnavailableError,
7+ type EnvironmentCloudLinkStateResult,
8+ EnvironmentHttpBadRequestError,
9+ EnvironmentHttpConflictError,
10+ EnvironmentHttpForbiddenError,
11+ EnvironmentHttpInternalServerError,
12+ EnvironmentHttpUnauthorizedError,
13+ EnvironmentId,
14+} from "@t3tools/contracts";
15+import {
16+ RelayEnvironmentConnectScope,
17+ type RelayEnvironmentLinkResponse,
18+ RelayProtectedError,
19+ type RelayClientEnvironmentRecord,
20+ type RelayProtectedError as RelayProtectedErrorType,
21+ type RelayManagedEndpointProviderKind,
22+} from "@t3tools/contracts/relay";
23+import {
24+ exchangeRemoteDpopAccessToken,
25+ fetchRemoteEnvironmentDescriptor,
26+ makeEnvironmentHttpApiClient,
27+ ManagedRelayClient,
28+ ManagedRelayDpopSigner,
29+} from "@t3tools/client-runtime";
30+
31+import { ensureLocalApi } from "../localApi";
32+import type { SavedEnvironmentRecord } from "../environments/runtime";
33+import {
34+ readPrimaryEnvironmentDescriptor,
35+ readPrimaryEnvironmentTarget,
36+ resolvePrimaryEnvironmentHttpUrl,
37+} from "../environments/primary";
38+
39+export 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+
47+function relayUrl(): string | null {
48+ return normalizeRelayBaseUrl(import.meta.env.VITE_T3_RELAY_URL as string | undefined);
49+}
50+
51+export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
52+ readonly message: string;
53+ readonly cause?: unknown;
54+}> {}
55+
56+const isRelayProtectedError = Schema.is(RelayProtectedError);
57+const isEnvironmentCloudApiError = Schema.is(
58+ Schema.Union([
59+ EnvironmentHttpBadRequestError,
60+ EnvironmentHttpUnauthorizedError,
61+ EnvironmentHttpForbiddenError,
62+ EnvironmentHttpConflictError,
63+ EnvironmentHttpInternalServerError,
64+ EnvironmentCloudEndpointUnavailableError,
65+ ]),
66+);
67+
68+function 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+
103+function 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+
114+function 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+
124+function 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+
134+const 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+
144+const withPrimaryEnvironmentCookies = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
145+ effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" }));
146+
147+function 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+
155+const MANAGED_ENDPOINT_PROVIDER_KIND =
156+ "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
157+
158+function 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+
176+export interface CloudLinkTarget {
177+ readonly environmentId: string;
178+ readonly label: string;
179+ readonly httpBaseUrl: string;
180+ readonly wsBaseUrl: string;
181+}
182+
183+export type CloudLinkState = EnvironmentCloudLinkStateResult;
184+
185+export 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+
194+export 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+
210+export 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+
224+export 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+
255+export 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+
362+export 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+
381+export 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+
418+export 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+
514+export 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 @@
1+import { useAuth } from "@clerk/react";
2+import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
3+import { useEffect, type ReactNode } from "react";
4+
5+let relayTokenProvider: (() => Promise<string | null>) | null = null;
6+
7+export async function readManagedRelayClerkToken(): Promise<string | null> {
8+ return relayTokenProvider?.() ?? null;
9+}
10+
11+export 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 @@
1+import {
2+ managedRelayClientLayer,
3+ ManagedRelayDpopSigner,
4+ ManagedRelayDpopSignerError,
5+} from "@t3tools/client-runtime";
6+import { RelayWebClientId } from "@t3tools/contracts/relay";
7+import * as Crypto from "effect/Crypto";
8+import * as Effect from "effect/Effect";
9+import * as Layer from "effect/Layer";
10+import * as Semaphore from "effect/Semaphore";
11+
12+import {
13+ createBrowserDpopProof,
14+ generateBrowserDpopKey,
15+ readStoredBrowserDpopKey,
16+ writeStoredBrowserDpopKey,
17+ type BrowserDpopKey,
18+} from "./dpop";
19+
20+export 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+
59+export 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 @@
1+import { UserButton, Waitlist, useAuth, useClerk } from "@clerk/react";
2+import { useSignIn, useSignUp } from "@clerk/react/legacy";
3+import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth";
4+import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
5+import * as Effect from "effect/Effect";
6+import { CloudIcon } from "lucide-react";
7+import { useCallback, useEffect, useRef, useState } from "react";
8+
9+import {
10+ type DesktopCloudAuthOAuthStrategy,
11+ resolveDesktopCloudAuthOAuthOptions,
12+} from "../../cloud/desktopAuth";
13+import {
14+ collectCloudLinkTargets,
15+ connectManagedCloudEnvironment,
16+ linkEnvironmentToCloud,
17+ linkPrimaryEnvironmentToCloud,
18+ listManagedCloudEnvironments,
19+ readPrimaryCloudLinkState,
20+ readPrimaryCloudLinkTarget,
21+ type CloudLinkState,
22+ unlinkPrimaryEnvironmentFromCloud,
23+} from "../../cloud/linkEnvironment";
24+import { isElectron } from "../../env";
25+import { usePrimaryEnvironmentId } from "../../environments/primary";
26+import {
27+ addManagedRelayEnvironment,
28+ listSavedEnvironmentRecords,
29+ useSavedEnvironmentRegistryStore,
30+} from "../../environments/runtime";
31+import { webRuntime } from "../../lib/runtime";
32+import { Button } from "../ui/button";
33+import { toastManager } from "../ui/toast";
34+import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
35+
36+function hasClerkConfig(): boolean {
37+ return Boolean(import.meta.env.VITE_CLERK_PUBLISHABLE_KEY);
38+}
39+
40+class 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+
50+async 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+
58+function 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+
69+export 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+
86+function ConfiguredCloudSettingsPanel() {
87+ const { isLoaded, isSignedIn } = useAuth();
88+
89+ if (!isLoaded) {
90+ return null;
91+ }
92+
93+ return isSignedIn ? <CloudSettingsPanelInner /> : <CloudWaitlistPanel />;
94+}
95+
96+function 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+
110+function 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+
388+function 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
450450 setTheme: vi.fn().mockResolvedValue(undefined),
451451 showContextMenu: vi.fn().mockResolvedValue(null),
452452 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: () => () => {},
453465 onMenuAction: () => () => {},
454466 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),
455467 setUpdateChannel:
apps/web/src/components/settings/SettingsSidebarNav.tsx+3−0
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
33 ArchiveIcon,
44 ArrowLeftIcon,
55 BotIcon,
6+ CloudIcon,
67 GitBranchIcon,
78 KeyboardIcon,
89 Link2Icon,
@@ -26,6 +27,7 @@ export type SettingsSectionPath = · 17 unmodified lines
2627 | "/settings/keybindings"
2728 | "/settings/providers"
2829 | "/settings/source-control"
30+ | "/settings/cloud"
2931 | "/settings/connections"
3032 | "/settings/archived";
3133
@@ -38,6 +40,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ · 6 unmodified lines
3840 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },
3941 { label: "Providers", to: "/settings/providers", icon: BotIcon },
4042 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
43+ { label: "T3 Cloud", to: "/settings/cloud", icon: CloudIcon },
4144 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
4245 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
4346 ];
apps/web/src/environments/primary/auth.ts+2−1
@@ -3,6 +3,7 @@ import type { · 2 unmodified lines
33 AuthClientMetadata,
44 AuthEnvironmentScope,
55 AuthPairingCredentialResult,
6+ ServerAuthSessionMethod,
67 AuthSessionId,
78 AuthSessionState,
89 } from "@t3tools/contracts";
@@ -45,7 +46,7 @@ export interface ServerClientSessionRecord { · 36 unmodified lines
4546 readonly sessionId: AuthSessionId;
4647 readonly subject: string;
4748 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
48− readonly method: "browser-session-cookie" | "bearer-access-token";
49+ readonly method: ServerAuthSessionMethod;
4950 readonly client: AuthClientMetadata;
5051 readonly issuedAt: string;
5152 readonly expiresAt: string;
apps/web/src/environments/primary/index.ts+5−1
@@ -34,4 +34,8 @@ export { · 33 unmodified lines
3434
3535 export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState";
3636
37−export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target";
37+export {
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
66 import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
77
88 import {
9+ readSavedEnvironmentCredential,
910 resetSavedEnvironmentRegistryStoreForTests,
1011 resetSavedEnvironmentRuntimeStoreForTests,
1112 useSavedEnvironmentRegistryStore,
1213 useSavedEnvironmentRuntimeStore,
1314 waitForSavedEnvironmentRegistryHydration,
15+ writeSavedEnvironmentCredential,
1416 } from "./catalog";
1517
1618 describe("environment runtime catalog stores", () => {
@@ -74,6 +76,47 @@ describe("environment runtime catalog stores", () => { · 57 unmodified lines
7476 expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({});
7577 });
7678
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+
77120 it("does not throw when local api lookup fails during registry persistence", async () => {
78121 vi.unstubAllGlobals();
79122 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
66 PersistedSavedEnvironmentRecord,
77 ServerConfig,
88 } from "@t3tools/contracts";
9+import * as Option from "effect/Option";
10+import * as Schema from "effect/Schema";
911 import { create } from "zustand";
1012
1113 import { ensureLocalApi } from "../../localApi";
@@ -19,8 +21,29 @@ export interface SavedEnvironmentRecord { · 7 unmodified lines
1921 readonly createdAt: string;
2022 readonly lastConnectedAt: string | null;
2123 readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"];
24+ readonly relayManaged?: PersistedSavedEnvironmentRecord["relayManaged"];
2225 }
2326
27+export 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+]);
39+export type SavedEnvironmentCredential = typeof SavedEnvironmentCredential.Type;
40+
41+const SavedEnvironmentCredentialJson = Schema.fromJsonString(SavedEnvironmentCredential);
42+const decodeSavedEnvironmentCredentialJson = Schema.decodeUnknownOption(
43+ SavedEnvironmentCredentialJson,
44+);
45+const encodeSavedEnvironmentCredentialJson = Schema.encodeSync(SavedEnvironmentCredentialJson);
46+
2447 interface SavedEnvironmentRegistryState {
2548 readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>;
2649 }
@@ -47,6 +70,7 @@ export function toPersistedSavedEnvironmentRecord( · 20 unmodified lines
4770 createdAt: record.createdAt,
4871 lastConnectedAt: record.lastConnectedAt,
4972 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
73+ ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
5074 };
5175 }
5276
@@ -250,6 +274,31 @@ export async function readSavedEnvironmentBearerToken( · 197 unmodified lines
250274 return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId);
251275 }
252276
277+export 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+
292+export 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+
253302 export async function writeSavedEnvironmentBearerToken(
254303 environmentId: EnvironmentId,
255304 bearerToken: string,
apps/web/src/environments/runtime/index.ts+1−0
@@ -16,6 +16,7 @@ export { · 15 unmodified lines
1616
1717 export {
1818 addSavedEnvironment,
19+ addManagedRelayEnvironment,
1920 connectDesktopSshEnvironment,
2021 disconnectSavedEnvironment,
2122 ensureEnvironmentConnectionBootstrapped,
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+159−19
@@ -1,4 +1,5 @@
11 import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";
2+import * as Effect from "effect/Effect";
23 import * as Schema from "effect/Schema";
34 import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
45
@@ -10,17 +11,33 @@ const mockResolveRemotePairingTarget = vi.fn(); · 5 unmodified lines
1011 const mockFetchRemoteEnvironmentDescriptor = vi.fn();
1112 const mockBootstrapRemoteBearerSession = vi.fn();
1213 const mockFetchRemoteSessionState = vi.fn();
14+const mockFetchRemoteDpopSessionState = vi.fn();
1315 const mockResolveRemoteWebSocketConnectionUrl = vi.fn();
14−const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);
16+let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner;
17+const 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+);
1530 const mockBootstrapSshBearerSession = vi.fn();
1631 const mockFetchSshSessionState = vi.fn();
1732 const mockPersistSavedEnvironmentRecord = vi.fn();
1833 const mockWriteSavedEnvironmentBearerToken = vi.fn();
34+const mockWriteSavedEnvironmentCredential = vi.fn();
1935 const mockSetSavedEnvironmentRegistry = vi.fn();
2036 const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => {
2137 return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null;
2238 });
2339 const mockReadSavedEnvironmentBearerToken = vi.fn();
40+const mockReadSavedEnvironmentCredential = vi.fn();
2441 const mockRemoveSavedEnvironmentBearerToken = vi.fn();
2542 const mockPatchRuntime = vi.fn();
2643 const mockClearRuntime = vi.fn();
@@ -58,6 +75,8 @@ const mockClientGetConfig = vi.fn(async () => ({ · 31 unmodified lines
5875 label: "Remote environment",
5976 },
6077 }));
78+const mockConnectManagedCloudEnvironment = vi.fn();
79+const mockReadManagedRelayClerkToken = vi.fn();
6180
6281 vi.mock("@t3tools/shared/remote", async (importOriginal) => ({
6382 ...(await importOriginal<typeof import("@t3tools/shared/remote")>()),
@@ -65,11 +84,19 @@ vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ · 1 unmodified line
6584 }));
6685
6786 vi.mock("../../lib/runtime", () => ({
68− remoteHttpRuntime: {
87+ webRuntime: {
6988 runPromise: mockRemoteHttpRunPromise,
7089 },
7190 }));
7291
92+vi.mock("../../cloud/linkEnvironment", () => ({
93+ connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment,
94+}));
95+
96+vi.mock("../../cloud/managedAuth", () => ({
97+ readManagedRelayClerkToken: mockReadManagedRelayClerkToken,
98+}));
99+
73100 vi.mock("~/localApi", () => ({
74101 ensureLocalApi: () => ({
75102 persistence: {
@@ -84,6 +111,7 @@ vi.mock("./catalog", () => ({ · 8 unmodified lines
84111 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
85112 persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord,
86113 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
114+ readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential,
87115 removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken,
88116 toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord,
89117 useSavedEnvironmentRegistryStore: {
@@ -105,6 +133,7 @@ vi.mock("./catalog", () => ({ · 15 unmodified lines
105133 },
106134 waitForSavedEnvironmentRegistryHydration: vi.fn(),
107135 writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken,
136+ writeSavedEnvironmentCredential: mockWriteSavedEnvironmentCredential,
108137 }));
109138
110139 vi.mock("./connection", async (importOriginal) => ({
@@ -114,6 +143,7 @@ vi.mock("./connection", async (importOriginal) => ({ · 3 unmodified lines
114143
115144 vi.mock("@t3tools/client-runtime", async (importOriginal) => {
116145 const actual = await importOriginal<typeof import("@t3tools/client-runtime")>();
146+ managedRelayDpopSigner = actual.ManagedRelayDpopSigner;
117147 return {
118148 ...actual,
119149 bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession,
@@ -130,6 +160,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { · 10 unmodified lines
130160 })),
131161 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
132162 fetchRemoteSessionState: mockFetchRemoteSessionState,
163+ fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState,
133164 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
134165 };
135166 });
@@ -172,20 +203,36 @@ describe("addSavedEnvironment", () => { · 36 unmodified lines
172203 credential: input.pairingCode ?? "pairing-code",
173204 }),
174205 );
175− mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({
176− environmentId: EnvironmentId.make("environment-1"),
177− label: "Remote environment",
178− });
179− mockBootstrapRemoteBearerSession.mockResolvedValue({
180− access_token: "bearer-token",
181− scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
206+ mockReadSavedEnvironmentCredential.mockImplementation(async () => {
207+ const token = await mockReadSavedEnvironmentBearerToken();
208+ return token ? { version: 1, method: "bearer", token } : null;
182209 });
183− mockFetchRemoteSessionState.mockResolvedValue({
184− authenticated: true,
185− scopes: ["orchestration:read", "access:write"],
186− });
187− mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue(
188− "wss://remote.example.com/?wsTicket=remote-token",
210+ mockFetchRemoteEnvironmentDescriptor.mockReturnValue(
211+ Effect.succeed({
212+ environmentId: EnvironmentId.make("environment-1"),
213+ label: "Remote environment",
214+ }),
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"),
189236 );
190237 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
191238 environmentId: EnvironmentId.make("environment-1"),
@@ -197,6 +244,8 @@ describe("addSavedEnvironment", () => { · 5 unmodified lines
197244 });
198245 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);
199246 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);
247+ mockWriteSavedEnvironmentCredential.mockResolvedValue(true);
248+ mockReadManagedRelayClerkToken.mockResolvedValue(null);
200249 mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined);
201250 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
202251 mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined);
@@ -323,6 +372,94 @@ describe("addSavedEnvironment", () => { · 120 unmodified lines
323372 await resetEnvironmentServiceForTests();
324373 });
325374
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+
326463 it("removes an older ssh record when the same target returns a new environment id", async () => {
327464 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
328465 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
@@ -424,7 +561,7 @@ describe("addSavedEnvironment", () => { · 95 unmodified lines
424561 reason: "invalid_credential",
425562 traceId: "trace-auth-test",
426563 });
427− mockFetchRemoteSessionState.mockRejectedValueOnce(authError);
564+ mockFetchRemoteSessionState.mockReturnValueOnce(Effect.fail(authError));
428565
429566 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
430567
@@ -747,9 +884,12 @@ describe("addSavedEnvironment", () => { · 316 unmodified lines
747884 readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">;
748885 }) => void;
749886 mockFetchRemoteSessionState.mockReturnValue(
750− new Promise((resolve) => {
751− resolveSessionState = resolve;
752− }),
887+ Effect.promise(
888+ () =>
889+ new Promise((resolve) => {
890+ resolveSessionState = resolve;
891+ }),
892+ ),
753893 );
754894
755895 const {
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+8−1
@@ -11,6 +11,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); · 10 unmodified lines
1111 const mockListSavedEnvironmentRecords = vi.fn();
1212 const mockSavedEnvironmentRegistrySubscribe = vi.fn();
1313 const mockReadSavedEnvironmentBearerToken = vi.fn();
14+const mockReadSavedEnvironmentCredential = vi.fn();
1415 const mockGetSavedEnvironmentRecord = vi.fn();
1516
1617 function MockWsTransport() {
@@ -31,7 +32,7 @@ vi.mock("../primary", () => ({ · 14 unmodified lines
3132 }));
3233
3334 vi.mock("../../lib/runtime", () => ({
34− remoteHttpRuntime: {
35+ webRuntime: {
3536 runPromise: mockRemoteHttpRunPromise,
3637 },
3738 }));
@@ -42,6 +43,7 @@ vi.mock("./catalog", () => ({ · 4 unmodified lines
4243 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
4344 persistSavedEnvironmentRecord: vi.fn(),
4445 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
46+ readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential,
4547 removeSavedEnvironmentBearerToken: vi.fn(),
4648 useSavedEnvironmentRegistryStore: {
4749 subscribe: mockSavedEnvironmentRegistrySubscribe,
@@ -61,6 +63,7 @@ vi.mock("./catalog", () => ({ · 13 unmodified lines
6163 },
6264 waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,
6365 writeSavedEnvironmentBearerToken: vi.fn(),
66+ writeSavedEnvironmentCredential: vi.fn(),
6467 }));
6568
6669 vi.mock("./connection", async (importOriginal) => ({
@@ -231,6 +234,10 @@ describe("saved environment startup", () => { · 164 unmodified lines
231234 mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined);
232235 mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined);
233236 mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token");
237+ mockReadSavedEnvironmentCredential.mockImplementation(async () => {
238+ const token = await mockReadSavedEnvironmentBearerToken();
239+ return token ? { version: 1, method: "bearer", token } : null;
240+ });
234241 mockCreateWsRpcClient.mockImplementation(() => createClient());
235242 mockCreateEnvironmentConnection.mockImplementation((input) => {
236243 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
1818 const mockListSavedEnvironmentRecords = vi.fn();
1919 const mockGetSavedEnvironmentRecord = vi.fn();
2020 const mockReadSavedEnvironmentBearerToken = vi.fn();
21+const mockReadSavedEnvironmentCredential = vi.fn();
2122 const mockSavedEnvironmentRegistrySubscribe = vi.fn();
2223 const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn());
2324 const mockFetchRemoteSessionState = vi.fn();
@@ -35,7 +36,7 @@ vi.mock("../primary", () => ({ · 11 unmodified lines
3536 }));
3637
3738 vi.mock("../../lib/runtime", () => ({
38− remoteHttpRuntime: {
39+ webRuntime: {
3940 runPromise: mockRemoteHttpRunPromise,
4041 },
4142 }));
@@ -46,6 +47,7 @@ vi.mock("./catalog", () => ({ · 4 unmodified lines
4647 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
4748 persistSavedEnvironmentRecord: vi.fn(),
4849 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
50+ readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential,
4951 removeSavedEnvironmentBearerToken: vi.fn(),
5052 useSavedEnvironmentRegistryStore: {
5153 subscribe: mockSavedEnvironmentRegistrySubscribe,
@@ -65,6 +67,7 @@ vi.mock("./catalog", () => ({ · 13 unmodified lines
6567 },
6668 waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,
6769 writeSavedEnvironmentBearerToken: vi.fn(),
70+ writeSavedEnvironmentCredential: vi.fn(),
6871 }));
6972
7073 vi.mock("./connection", async (importOriginal) => ({
@@ -301,6 +304,10 @@ describe("retainThreadDetailSubscription", () => { · 230 unmodified lines
301304 mockListSavedEnvironmentRecords.mockReturnValue([]);
302305 mockGetSavedEnvironmentRecord.mockReturnValue(null);
303306 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
307+ mockReadSavedEnvironmentCredential.mockImplementation(async () => {
308+ const token = await mockReadSavedEnvironmentBearerToken();
309+ return token ? { version: 1, method: "bearer", token } : null;
310+ });
304311 mockFetchRemoteSessionState.mockResolvedValue({
305312 authenticated: true,
306313 scopes: ["orchestration:read"],
apps/web/src/environments/runtime/service.ts+231−42
@@ -1,6 +1,5 @@
11 import {
22 AuthEnvironmentScope,
3− AuthStandardClientScopes,
43 type DesktopSshEnvironmentBootstrap,
54 type DesktopSshEnvironmentTarget,
65 type EnvironmentId,
@@ -16,12 +15,17 @@ import { · 9 unmodified lines
1615 type WsRpcClient,
1716 bootstrapRemoteBearerSession,
1817 fetchRemoteEnvironmentDescriptor,
18+ fetchRemoteDpopSessionState,
1919 fetchRemoteSessionState,
20+ type ManagedRelayDpopProofInput,
21+ ManagedRelayDpopSigner,
22+ resolveRemoteDpopWebSocketConnectionUrl,
2023 resolveRemoteWebSocketConnectionUrl,
2124 } from "@t3tools/client-runtime";
2225
2326 import { type QueryClient } from "@tanstack/react-query";
2427 import { Throttler } from "@tanstack/react-pacer";
28+import * as Effect from "effect/Effect";
2529 import * as Schema from "effect/Schema";
2630 import {
2731 createKnownEnvironment,
@@ -40,21 +44,25 @@ import { ensureLocalApi } from "~/localApi"; · 12 unmodified lines
4044 import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";
4145 import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
4246 import { getPrimaryKnownEnvironment } from "../primary";
43−import { remoteHttpRuntime } from "../../lib/runtime";
47+import { webRuntime } from "../../lib/runtime";
48+import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment";
49+import { readManagedRelayClerkToken } from "../../cloud/managedAuth";
4450
4551 import {
4652 getSavedEnvironmentRecord,
4753 hasSavedEnvironmentRegistryHydrated,
4854 listSavedEnvironmentRecords,
4955 persistSavedEnvironmentRecord,
50− readSavedEnvironmentBearerToken,
56+ readSavedEnvironmentCredential,
5157 removeSavedEnvironmentBearerToken,
5258 type SavedEnvironmentRecord,
59+ type SavedEnvironmentCredential,
5360 toPersistedSavedEnvironmentRecord,
5461 useSavedEnvironmentRegistryStore,
5562 useSavedEnvironmentRuntimeStore,
5663 waitForSavedEnvironmentRegistryHydration,
5764 writeSavedEnvironmentBearerToken,
65+ writeSavedEnvironmentCredential,
5866 } from "./catalog";
5967 import {
6068 createEnvironmentConnection,
@@ -159,6 +167,12 @@ const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; · 98 unmodified lines
159167 const NOOP = () => undefined;
160168 const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;
161169
170+const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) =>
171+ Effect.gen(function* () {
172+ const signer = yield* ManagedRelayDpopSigner;
173+ return yield* signer.createProof(input);
174+ });
175+
162176 function createDeferredPromise<T>() {
163177 let resolve: ((value: T) => void) | null = null;
164178 const promise = new Promise<T>((nextResolve) => {
@@ -1156,7 +1170,7 @@ function createPrimaryEnvironmentClient( · 991 unmodified lines
11561170
11571171 function createSavedEnvironmentClient(
11581172 environmentId: EnvironmentId,
1159− bearerToken: string,
1173+ credentialRef: { current: SavedEnvironmentCredential },
11601174 ): WsRpcClient {
11611175 useSavedEnvironmentRuntimeStore.getState().ensure(environmentId);
11621176
@@ -1167,19 +1181,70 @@ function createSavedEnvironmentClient( · 4 unmodified lines
11671181 if (!record) {
11681182 throw new Error(`Saved environment ${environmentId} not found.`);
11691183 }
1170− return record.desktopSsh
1171− ? await resolveDesktopSshWebSocketConnectionUrl(
1172− record.wsBaseUrl,
1173− record.httpBaseUrl,
1174− bearerToken,
1175− )
1176− : await remoteHttpRuntime.runPromise(
1177− resolveRemoteWebSocketConnectionUrl({
1178− wsBaseUrl: record.wsBaseUrl,
1179− httpBaseUrl: record.httpBaseUrl,
1180− bearerToken,
1181− }),
1184+ const credential = credentialRef.current;
1185+ if (record.desktopSsh) {
1186+ if (credential.method !== "bearer") {
1187+ throw new Error("SSH environments require bearer credentials.");
1188+ }
1189+ return await resolveDesktopSshWebSocketConnectionUrl(
1190+ record.wsBaseUrl,
1191+ record.httpBaseUrl,
1192+ credential.token,
1193+ );
1194+ }
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+ ),
11821238 );
1239+ }
1240+ }
1241+ return await webRuntime.runPromise(
1242+ resolveRemoteWebSocketConnectionUrl({
1243+ wsBaseUrl: record.wsBaseUrl,
1244+ httpBaseUrl: record.httpBaseUrl,
1245+ bearerToken: credential.token,
1246+ }),
1247+ );
11831248 },
11841249 {
11851250 getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null,
@@ -1221,7 +1286,7 @@ function createSavedEnvironmentClient( · 35 unmodified lines
12211286
12221287 async function refreshSavedEnvironmentMetadata(
12231288 environmentId: EnvironmentId,
1224− bearerToken: string,
1289+ credential: SavedEnvironmentCredential,
12251290 client: WsRpcClient,
12261291 scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null,
12271292 configHint?: ServerConfig | null,
@@ -1234,13 +1299,31 @@ async function refreshSavedEnvironmentMetadata( · 6 unmodified lines
12341299 const [serverConfig, sessionState] = await Promise.all([
12351300 configHint ? Promise.resolve(configHint) : client.server.getConfig(),
12361301 record.desktopSsh
1237− ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken)
1238− : remoteHttpRuntime.runPromise(
1239− fetchRemoteSessionState({
1240− httpBaseUrl: record.httpBaseUrl,
1241− bearerToken,
1242− }),
1243− ),
1302+ ? credential.method === "bearer"
1303+ ? fetchDesktopSshSessionState(record.httpBaseUrl, credential.token)
1304+ : Promise.reject(new Error("SSH environments require bearer credentials."))
1305+ : credential.method === "dpop"
1306+ ? webRuntime.runPromise(
1307+ createManagedRelayDpopProof({
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+ ),
12441327 ]);
12451328
12461329 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
@@ -1254,6 +1337,52 @@ async function refreshSavedEnvironmentMetadata( · 7 unmodified lines
12541337 .rename(record.environmentId, serverConfig.environment.label);
12551338 }
12561339
1340+async 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+
12571386 function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {
12581387 const existing = environmentConnections.get(connection.environmentId);
12591388 if (existing && existing !== connection) {
@@ -1320,8 +1449,10 @@ async function ensureSavedEnvironmentConnection( · 60 unmodified lines
13201449 options?: {
13211450 readonly client?: WsRpcClient;
13221451 readonly bearerToken?: string;
1452+ readonly credential?: SavedEnvironmentCredential;
13231453 readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null;
13241454 readonly serverConfig?: ServerConfig | null;
1455+ readonly allowManagedRenewal?: boolean;
13251456 },
13261457 ): Promise<EnvironmentConnection> {
13271458 const existing = environmentConnections.get(record.environmentId);
@@ -1340,14 +1471,17 @@ async function ensureSavedEnvironmentConnection( · 12 unmodified lines
13401471 promise: Promise.resolve().then(async () => {
13411472 let activeRecord = record;
13421473 let scopeHint = options?.scopes ?? null;
1343− let bearerToken =
1344− options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));
1345− if (!bearerToken) {
1474+ let credential =
1475+ options?.credential ??
1476+ (options?.bearerToken
1477+ ? ({ version: 1, method: "bearer", token: options.bearerToken } as const)
1478+ : await readSavedEnvironmentCredential(record.environmentId));
1479+ if (!credential) {
13461480 if (record.desktopSsh) {
13471481 const issued = await issueDesktopSshBearerSession(record);
13481482 activeRecord = issued.record;
1349− bearerToken = issued.bearerToken;
1350− scopeHint = [...AuthStandardClientScopes];
1483+ credential = { version: 1, method: "bearer", token: issued.bearerToken };
1484+ scopeHint = issued.scopes;
13511485 } else {
13521486 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
13531487 authState: "requires-auth",
@@ -1363,10 +1497,10 @@ async function ensureSavedEnvironmentConnection( · 9 unmodified lines
13631497 activeRecord = prepared.record;
13641498 }
13651499
1366− const activeBearerToken = bearerToken;
1500+ const activeCredential = { current: credential };
13671501 const client =
13681502 options?.client ??
1369− createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken);
1503+ createSavedEnvironmentClient(activeRecord.environmentId, activeCredential);
13701504 const initialConfigSnapshot = createDeferredPromise<ServerConfig>();
13711505 const knownEnvironment = createKnownEnvironment({
13721506 id: activeRecord.environmentId,
@@ -1387,7 +1521,7 @@ async function ensureSavedEnvironmentConnection( · 14 unmodified lines
13871521 refreshMetadata: async () => {
13881522 await refreshSavedEnvironmentMetadata(
13891523 activeRecord.environmentId,
1390− activeBearerToken,
1524+ activeCredential.current,
13911525 client,
13921526 );
13931527 },
@@ -1416,7 +1550,7 @@ async function ensureSavedEnvironmentConnection( · 22 unmodified lines
14161550 ));
14171551 await refreshSavedEnvironmentMetadata(
14181552 activeRecord.environmentId,
1419− activeBearerToken,
1553+ activeCredential.current,
14201554 client,
14211555 scopeHint,
14221556 initialServerConfig,
@@ -1429,20 +1563,41 @@ async function ensureSavedEnvironmentConnection( · 6 unmodified lines
14291563 throw error;
14301564 }
14311565 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+ }
14321582 await removeSavedEnvironmentBearerToken(activeRecord.environmentId);
1433− throw new Error("Saved environment credential expired. Pair it again.", {
1434− cause: error,
1435− });
1583+ throw new Error(
1584+ activeCredential.current.method === "dpop"
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+ );
14361591 }
14371592
14381593 const issued = await issueDesktopSshBearerSession(activeRecord);
14391594 activeRecord = issued.record;
1440− bearerToken = issued.bearerToken;
1441− scopeHint = [...AuthStandardClientScopes];
1595+ credential = { version: 1, method: "bearer", token: issued.bearerToken };
1596+ scopeHint = issued.scopes;
14421597 await connection.dispose().catch(() => undefined);
14431598 pendingSavedEnvironmentConnections.delete(activeRecord.environmentId);
14441599 return await ensureSavedEnvironmentConnection(activeRecord, {
1445− bearerToken,
1600+ credential,
14461601 scopes: scopeHint,
14471602 serverConfig: options?.serverConfig ?? null,
14481603 });
@@ -1647,7 +1802,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P · 198 unmodified lines
16471802 await removeConnection(environmentId).catch(() => false);
16481803 await ensureSavedEnvironmentConnection(issued.record, {
16491804 bearerToken: issued.bearerToken,
1650− scopes: [...AuthStandardClientScopes],
1805+ scopes: issued.scopes,
16511806 });
16521807 return;
16531808 } catch (recoveryError) {
@@ -1686,7 +1841,7 @@ export async function addSavedEnvironment(input: { · 32 unmodified lines
16861841 });
16871842 const descriptor = input.desktopSsh
16881843 ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl)
1689− : await remoteHttpRuntime.runPromise(
1844+ : await webRuntime.runPromise(
16901845 fetchRemoteEnvironmentDescriptor({
16911846 httpBaseUrl: resolvedTarget.httpBaseUrl,
16921847 }),
@@ -1701,7 +1856,7 @@ export async function addSavedEnvironment(input: { · 8 unmodified lines
17011856
17021857 const bearerSession = input.desktopSsh
17031858 ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential)
1704− : await remoteHttpRuntime.runPromise(
1859+ : await webRuntime.runPromise(
17051860 bootstrapRemoteBearerSession({
17061861 httpBaseUrl: resolvedTarget.httpBaseUrl,
17071862 credential: resolvedTarget.credential,
@@ -1741,6 +1896,40 @@ export async function addSavedEnvironment(input: { · 33 unmodified lines
17411896 return record;
17421897 }
17431898
1899+export 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+
17441933 export async function connectDesktopSshEnvironment(
17451934 target: DesktopSshEnvironmentTarget,
17461935 options?: { label?: string },
apps/web/src/hooks/useTheme.ts+1−1
@@ -109,7 +109,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { · 108 unmodified lines
109109 function syncDesktopTheme(theme: Theme) {
110110 if (typeof window === "undefined") return;
111111 const bridge = window.desktopBridge;
112− if (!bridge || lastDesktopTheme === theme) {
112+ if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) {
113113 return;
114114 }
115115
apps/web/src/hostedPairing.ts+1−1
@@ -10,7 +10,7 @@ export interface HostedPairingRequest { · 9 unmodified lines
1010
1111 export type HostedAppChannel = "latest" | "nightly";
1212
13−function configuredHostedAppUrl(): string {
13+export function configuredHostedAppUrl(): string {
1414 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;
1515 }
1616
apps/web/src/lib/runtime.ts+21−1
@@ -9,7 +9,17 @@ import { · 8 unmodified lines
99 primaryEnvironmentHttpClientLive,
1010 } from "../environments/primary/httpClient";
1111
12−export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch));
12+import { browserCryptoLayer } from "../cloud/dpop";
13+import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer";
14+
15+function 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+
20+const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch);
21+
22+export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer);
1323
1424 const primaryHttpRuntime = ManagedRuntime.make(
1525 primaryEnvironmentHttpClientLive.pipe(
@@ -37,3 +47,13 @@ export const runPrimaryHttp = <A, E>(effect: Effect.Effect<A, E, PrimaryEnvironm · 21 unmodified lines
3747 export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void {
3848 primaryHttpRunner = runner ?? livePrimaryHttpRunner;
3949 }
50+
51+export 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
234234 setTheme: async () => undefined,
235235 showContextMenu: async () => null,
236236 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,
237249 onMenuAction: () => () => undefined,
238250 getUpdateState: async () => {
239251 throw new Error("getUpdateState not implemented in test");
apps/web/src/main.tsx+21−1
@@ -1,5 +1,6 @@
11 import React from "react";
22 import ReactDOM from "react-dom/client";
3+import { ClerkProvider } from "@clerk/react";
34 import { RouterProvider } from "@tanstack/react-router";
45 import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
56
@@ -7,6 +8,8 @@ import "@xterm/xterm/css/xterm.css"; · 1 unmodified line
78 import "./index.css";
89
910 import { isElectron } from "./env";
11+import { DesktopClerkProvider } from "./cloud/desktopClerk";
12+import { ManagedRelayAuthProvider } from "./cloud/managedAuth";
1013 import { getRouter } from "./router";
1114 import { APP_DISPLAY_NAME } from "./branding";
1215 import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
@@ -22,8 +25,25 @@ if (isElectron) { · 9 unmodified lines
2225
2326 document.title = APP_DISPLAY_NAME;
2427
28+const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
29+const cloudWaitlistUrl = "/settings/cloud";
30+
31+const app = <RouterProvider router={router} />;
32+
2533 ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
2634 <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+ )}
2848 </React.StrictMode>,
2949 );
apps/web/src/routeTree.gen.ts+21−0
@@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi · 18 unmodified lines
1919 import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
2020 import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
2121 import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
22+import { Route as SettingsCloudRouteImport } from './routes/settings.cloud'
2223 import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
2324 import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
2425 import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
@@ -72,6 +73,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ · 47 unmodified lines
7273 path: '/connections',
7374 getParentRoute: () => SettingsRoute,
7475 } as any)
76+const SettingsCloudRoute = SettingsCloudRouteImport.update({
77+ id: '/cloud',
78+ path: '/cloud',
79+ getParentRoute: () => SettingsRoute,
80+} as any)
7581 const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
7682 id: '/archived',
7783 path: '/archived',
@@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { · 16 unmodified lines
94100 '/pair': typeof PairRoute
95101 '/settings': typeof SettingsRouteWithChildren
96102 '/settings/archived': typeof SettingsArchivedRoute
103+ '/settings/cloud': typeof SettingsCloudRoute
97104 '/settings/connections': typeof SettingsConnectionsRoute
98105 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
99106 '/settings/general': typeof SettingsGeneralRoute
@@ -107,6 +114,7 @@ export interface FileRoutesByTo { · 7 unmodified lines
107114 '/pair': typeof PairRoute
108115 '/settings': typeof SettingsRouteWithChildren
109116 '/settings/archived': typeof SettingsArchivedRoute
117+ '/settings/cloud': typeof SettingsCloudRoute
110118 '/settings/connections': typeof SettingsConnectionsRoute
111119 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
112120 '/settings/general': typeof SettingsGeneralRoute
@@ -123,6 +131,7 @@ export interface FileRoutesById { · 10 unmodified lines
123131 '/pair': typeof PairRoute
124132 '/settings': typeof SettingsRouteWithChildren
125133 '/settings/archived': typeof SettingsArchivedRoute
134+ '/settings/cloud': typeof SettingsCloudRoute
126135 '/settings/connections': typeof SettingsConnectionsRoute
127136 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
128137 '/settings/general': typeof SettingsGeneralRoute
@@ -140,6 +149,7 @@ export interface FileRouteTypes { · 11 unmodified lines
140149 | '/pair'
141150 | '/settings'
142151 | '/settings/archived'
152+ | '/settings/cloud'
143153 | '/settings/connections'
144154 | '/settings/diagnostics'
145155 | '/settings/general'
@@ -153,6 +163,7 @@ export interface FileRouteTypes { · 7 unmodified lines
153163 | '/pair'
154164 | '/settings'
155165 | '/settings/archived'
166+ | '/settings/cloud'
156167 | '/settings/connections'
157168 | '/settings/diagnostics'
158169 | '/settings/general'
@@ -168,6 +179,7 @@ export interface FileRouteTypes { · 9 unmodified lines
168179 | '/pair'
169180 | '/settings'
170181 | '/settings/archived'
182+ | '/settings/cloud'
171183 | '/settings/connections'
172184 | '/settings/diagnostics'
173185 | '/settings/general'
@@ -257,6 +269,13 @@ declare module '@tanstack/react-router' { · 83 unmodified lines
257269 preLoaderRoute: typeof SettingsConnectionsRouteImport
258270 parentRoute: typeof SettingsRoute
259271 }
272+ '/settings/cloud': {
273+ id: '/settings/cloud'
274+ path: '/cloud'
275+ fullPath: '/settings/cloud'
276+ preLoaderRoute: typeof SettingsCloudRouteImport
277+ parentRoute: typeof SettingsRoute
278+ }
260279 '/settings/archived': {
261280 id: '/settings/archived'
262281 path: '/archived'
@@ -297,6 +316,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) · 34 unmodified lines
297316
298317 interface SettingsRouteChildren {
299318 SettingsArchivedRoute: typeof SettingsArchivedRoute
319+ SettingsCloudRoute: typeof SettingsCloudRoute
300320 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
301321 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
302322 SettingsGeneralRoute: typeof SettingsGeneralRoute
@@ -307,6 +327,7 @@ interface SettingsRouteChildren { · 4 unmodified lines
307327
308328 const SettingsRouteChildren: SettingsRouteChildren = {
309329 SettingsArchivedRoute: SettingsArchivedRoute,
330+ SettingsCloudRoute: SettingsCloudRoute,
310331 SettingsConnectionsRoute: SettingsConnectionsRoute,
311332 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
312333 SettingsGeneralRoute: SettingsGeneralRoute,
apps/web/src/routes/_chat.index.tsx+4−4
@@ -48,13 +48,13 @@ function HostedStaticOnboardingState() { · 47 unmodified lines
4848 Connect an environment to get started
4949 </EmptyTitle>
5050 <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 backend
52− manually. Your saved environments stay in this browser.
51+ Sign in to T3 Cloud to connect a linked environment through its managed tunnel, or
52+ add a reachable backend manually.
5353 </EmptyDescription>
5454 <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">
5656 <PlusIcon className="size-4" />
57− Add environment
57+ Open T3 Cloud
5858 </Button>
5959 </div>
6060 </EmptyHeader>
apps/web/src/routes/settings.cloud.tsx+7−0
@@ -0,0 +1,7 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { CloudSettingsPanel } from "../components/settings/CloudSettings";
4+
5+export 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
77 readonly VITE_WS_URL: string;
88 readonly VITE_HOSTED_APP_URL: string;
99 readonly VITE_HOSTED_APP_CHANNEL: string;
10+ readonly VITE_CLERK_PUBLISHABLE_KEY: string;
1011 readonly APP_VERSION: string;
1112 }
1213
docs/relay-observability.md+40−0
@@ -0,0 +1,40 @@
1+# Relay observability
2+
3+The 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+
10+Deploy from `infra/relay` with the normal Alchemy workflow:
11+
12+```sh
13+bun run deploy
14+```
15+
16+Alchemy resolves Axiom deployment credentials through its provider. At runtime, the Worker
17+receives only the scoped ingest token; it does not receive the diagnostics query token.
18+
19+The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans.
20+Effect's OpenTelemetry exporter stores semantic HTTP attributes below the `attributes.` prefix.
21+For 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+
34+Endpoint 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+
38+Agents should prefer the provisioned view or APL queries for completed incidents instead of
39+tailing the Cloudflare Worker. Use the read-only query token when scripted access is needed;
40+keep the ingest token reserved for the Worker.
docs/t3-cloud-clerk.md+81−0
@@ -0,0 +1,81 @@
1+# T3 Cloud Clerk Setup
2+
3+T3 Cloud uses one Clerk application for web, desktop, and mobile authentication. The relay accepts
4+Clerk JWTs only when they are generated from the `t3-relay` template with the relay URL as the
5+audience.
6+
7+## Application Keys
8+
9+Use 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+
17+Never put `CLERK_SECRET_KEY` in a client application environment.
18+
19+## JWT Template
20+
21+In **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+
28+The `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+
33+The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme.
34+In **Clerk Dashboard > Native applications**, enable native application support and add these
35+entries under the mobile SSO redirect allowlist:
36+
37+```text
38+t3code-dev://auth/callback
39+t3code://auth/callback
40+```
41+
42+The first entry is for local desktop development. The second is for packaged desktop builds.
43+The app also adds a request-scoped `t3_state` query parameter and validates it on callback.
44+
45+The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth
46+flow uses a custom redirect URI, add that exact URI to the same allowlist.
47+
48+## Enable Waitlist Access
49+
50+For a private beta where people should request access, use **Clerk Dashboard > Waitlist**:
51+
52+1. Toggle on **Enable waitlist** and save.
53+2. Review requests on the same page and select **Invite** or **Deny**.
54+
55+Signed-out web and desktop users see Clerk's waitlist enrollment as the T3 Cloud page content,
56+while approved signed-in users see cloud settings. The browser app also uses `/settings/cloud` as
57+its Clerk waitlist URL.
58+
59+On mobile, signed-out users open **Settings > T3 Account** to reach `/settings/waitlist` within the
60+Settings 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
62+screen.
63+
64+## Alternative: Known-User Allowlist
65+
66+For a closed beta where all permitted users are known in advance, use an allowlist instead of a
67+request-and-approval waitlist:
68+
69+To restrict the beta to permitted email addresses or domains:
70+
71+1. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email
72+ domain.
73+2. Enable the allowlist and save.
74+3. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or
75+ manually created without a waitlist request flow.
76+
77+Do not enable an empty allowlist: it blocks all new sign-ups.
78+
79+Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud
80+access. To remove an already-created user's access, ban that user in Clerk so their active
81+sessions 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