Unified · Split
| @@ -5,6 +5,7 @@ node_modules · 4 unmodified lines | |||
| 5 | *.log | 5 | *.log |
| 6 | *.tsbuildinfo | 6 | *.tsbuildinfo |
| 7 | apps/*/dist | 7 | apps/*/dist |
| 8 | infra/*/dist | ||
| 8 | .astro | 9 | .astro |
| 9 | packages/*/dist | 10 | packages/*/dist |
| 10 | .env | 11 | .env |
| @@ -26,3 +27,8 @@ squashfs-root/ · 15 unmodified lines | |||
| 26 | .gstack/ | 27 | .gstack/ |
| 27 | dist-electron/ | 28 | dist-electron/ |
| 28 | .electron-runtime/ | 29 | .electron-runtime/ |
| 30 | node_modules/ | ||
| 31 | .alchemy/ | ||
| 32 | *.log | ||
| 33 | .env* | ||
| 34 | !.env.example | ||
| @@ -29,22 +29,8 @@ Long term maintainability is a core priority. If you add new functionality, firs · 28 unmodified lines | |||
| 29 | - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. | 29 | - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. |
| 30 | - `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. | 30 | - `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. |
| 31 | - `packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. Keep this package schema-only — no runtime logic. | 31 | - `packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. Keep this package schema-only — no runtime logic. |
| 32 | - `packages/shared`: Shared runtime utilities consumed by both server and web. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. | 32 | - `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. |
| 33 | 33 | - `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile. | |
| 34 | ## Codex App Server (Important) | ||
| 35 | |||
| 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 | ||
| 48 | 34 | ||
| 49 | ## Reference Repos | 35 | ## Reference Repos |
| 50 | 36 | ||
| @@ -48,6 +48,10 @@ We are not accepting contributions yet. · 47 unmodified lines | |||
| 48 | 48 | ||
| 49 | Observability guide: [docs/observability.md](./docs/observability.md) | 49 | Observability guide: [docs/observability.md](./docs/observability.md) |
| 50 | 50 | ||
| 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 | |||
| 51 | ## If you REALLY want to contribute still.... read this first | 55 | ## If you REALLY want to contribute still.... read this first |
| 52 | 56 | ||
| 53 | Before local development, prepare the environment and install dependencies: | 57 | Before local development, prepare the environment and install dependencies: |
| @@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process"; · 1 unmodified line | |||
| 2 | import { watch } from "node:fs"; | 2 | import { watch } from "node:fs"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; | 5 | import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs"; |
| 6 | import { waitForResources } from "./wait-for-resources.mjs"; | 6 | import { waitForResources } from "./wait-for-resources.mjs"; |
| 7 | 7 | ||
| 8 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); | 8 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); |
| @@ -28,6 +28,7 @@ const watchedDirectories = [ · 19 unmodified lines | |||
| 28 | const forcedShutdownTimeoutMs = 1_500; | 28 | const forcedShutdownTimeoutMs = 1_500; |
| 29 | const restartDebounceMs = 120; | 29 | const restartDebounceMs = 120; |
| 30 | const childTreeGracePeriodMs = 1_200; | 30 | const childTreeGracePeriodMs = 1_200; |
| 31 | const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); | ||
| 31 | 32 | ||
| 32 | await waitForResources({ | 33 | await waitForResources({ |
| 33 | baseDir: desktopDir, | 34 | baseDir: desktopDir, |
| @@ -38,6 +39,11 @@ await waitForResources({ · 4 unmodified lines | |||
| 38 | 39 | ||
| 39 | const childEnv = { ...process.env }; | 40 | const childEnv = { ...process.env }; |
| 40 | delete childEnv.ELECTRON_RUN_AS_NODE; | 41 | 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 | } | ||
| 41 | 47 | ||
| 42 | let shuttingDown = false; | 48 | let shuttingDown = false; |
| 43 | let restartTimer = null; | 49 | let restartTimer = null; |
| @@ -67,15 +73,17 @@ function startApp() { · 23 unmodified lines | |||
| 67 | return; | 73 | return; |
| 68 | } | 74 | } |
| 69 | 75 | ||
| 70 | const app = spawn( | 76 | const electronArgs = remoteDebuggingPort |
| 71 | resolveElectronPath(), | 77 | ? [`--remote-debugging-port=${remoteDebuggingPort}`] |
| 72 | [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"], | 78 | : []; |
| 73 | { | 79 | const launchArgs = devProtocolClient |
| 74 | cwd: desktopDir, | 80 | ? electronArgs |
| 75 | env: childEnv, | 81 | : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; |
| 76 | stdio: "inherit", | 82 | const app = spawn(resolveElectronPath(), launchArgs, { |
| 77 | }, | 83 | cwd: desktopDir, |
| 78 | ); | 84 | env: childEnv, |
| 85 | stdio: "inherit", | ||
| 86 | }); | ||
| 79 | 87 | ||
| 80 | currentApp = app; | 88 | currentApp = app; |
| 81 | 89 | ||
| @@ -125,6 +133,7 @@ async function stopApp() { · 43 unmodified lines | |||
| 125 | app.once("exit", finish); | 133 | app.once("exit", finish); |
| 126 | app.kill("SIGTERM"); | 134 | app.kill("SIGTERM"); |
| 127 | killChildTreeByPid(app.pid, "TERM"); | 135 | killChildTreeByPid(app.pid, "TERM"); |
| 136 | cleanupStaleDevApps(); | ||
| 128 | 137 | ||
| 129 | setTimeout(() => { | 138 | setTimeout(() => { |
| 130 | if (settled) { | 139 | if (settled) { |
| @@ -133,6 +142,7 @@ async function stopApp() { · 2 unmodified lines | |||
| 133 | 142 | ||
| 134 | app.kill("SIGKILL"); | 143 | app.kill("SIGKILL"); |
| 135 | killChildTreeByPid(app.pid, "KILL"); | 144 | killChildTreeByPid(app.pid, "KILL"); |
| 145 | cleanupStaleDevApps(); | ||
| 136 | finish(); | 146 | finish(); |
| 137 | }, forcedShutdownTimeoutMs).unref(); | 147 | }, forcedShutdownTimeoutMs).unref(); |
| 138 | }); | 148 | }); |
| @@ -3,6 +3,7 @@ · 2 unmodified lines | |||
| 3 | import { spawnSync } from "node:child_process"; | 3 | import { spawnSync } from "node:child_process"; |
| 4 | import { | 4 | import { |
| 5 | copyFileSync, | 5 | copyFileSync, |
| 6 | chmodSync, | ||
| 6 | cpSync, | 7 | cpSync, |
| 7 | existsSync, | 8 | existsSync, |
| 8 | mkdirSync, | 9 | mkdirSync, |
| @@ -13,21 +14,34 @@ import { · 4 unmodified lines | |||
| 13 | writeFileSync, | 14 | writeFileSync, |
| 14 | } from "node:fs"; | 15 | } from "node:fs"; |
| 15 | import { createRequire } from "node:module"; | 16 | import { createRequire } from "node:module"; |
| 16 | import { dirname, join, resolve } from "node:path"; | 17 | import { basename, dirname, join, resolve } from "node:path"; |
| 17 | import { fileURLToPath } from "node:url"; | 18 | import { fileURLToPath } from "node:url"; |
| 18 | import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; | 19 | import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; |
| 19 | 20 | ||
| 20 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | 21 | 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 | |||
| 25 | const __dirname = dirname(fileURLToPath(import.meta.url)); | 22 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 26 | export const desktopDir = resolve(__dirname, ".."); | 23 | export const desktopDir = resolve(__dirname, ".."); |
| 27 | const repoRoot = resolve(desktopDir, "..", ".."); | 24 | 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; | ||
| 28 | const defaultIconPath = join(desktopDir, "resources", "icon.icns"); | 34 | const defaultIconPath = join(desktopDir, "resources", "icon.icns"); |
| 29 | const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); | 35 | const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); |
| 30 | 36 | ||
| 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 | |||
| 31 | function setPlistString(plistPath, key, value) { | 45 | function setPlistString(plistPath, key, value) { |
| 32 | const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { | 46 | const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { |
| 33 | encoding: "utf8", | 47 | encoding: "utf8", |
| @@ -47,6 +61,26 @@ function setPlistString(plistPath, key, value) { · 13 unmodified lines | |||
| 47 | throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); | 61 | throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); |
| 48 | } | 62 | } |
| 49 | 63 | ||
| 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 | |||
| 50 | function runChecked(command, args) { | 84 | function runChecked(command, args) { |
| 51 | const result = spawnSync(command, args, { encoding: "utf8" }); | 85 | const result = spawnSync(command, args, { encoding: "utf8" }); |
| 52 | if (result.status === 0) { | 86 | if (result.status === 0) { |
| @@ -57,6 +91,71 @@ function runChecked(command, args) { · 4 unmodified lines | |||
| 57 | throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); | 91 | throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); |
| 58 | } | 92 | } |
| 59 | 93 | ||
| 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 | |||
| 60 | function ensureDevelopmentIconIcns(runtimeDir) { | 159 | function ensureDevelopmentIconIcns(runtimeDir) { |
| 61 | const generatedIconPath = join(runtimeDir, "icon-dev.icns"); | 160 | const generatedIconPath = join(runtimeDir, "icon-dev.icns"); |
| 62 | mkdirSync(runtimeDir, { recursive: true }); | 161 | mkdirSync(runtimeDir, { recursive: true }); |
| @@ -115,12 +214,49 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { · 52 unmodified lines | |||
| 115 | setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); | 214 | setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); |
| 116 | setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); | 215 | setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); |
| 117 | setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); | 216 | setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); |
| 217 | setPlistJson(infoPlistPath, "CFBundleURLTypes", [ | ||
| 218 | { | ||
| 219 | CFBundleURLName: APP_BUNDLE_ID, | ||
| 220 | CFBundleURLSchemes: APP_PROTOCOL_SCHEMES, | ||
| 221 | }, | ||
| 222 | ]); | ||
| 118 | 223 | ||
| 119 | const resourcesDir = join(appBundlePath, "Contents", "Resources"); | 224 | const resourcesDir = join(appBundlePath, "Contents", "Resources"); |
| 120 | copyFileSync(iconPath, join(resourcesDir, "icon.icns")); | 225 | copyFileSync(iconPath, join(resourcesDir, "icon.icns")); |
| 121 | copyFileSync(iconPath, join(resourcesDir, "electron.icns")); | 226 | copyFileSync(iconPath, join(resourcesDir, "electron.icns")); |
| 122 | } | 227 | } |
| 123 | 228 | ||
| 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 | |||
| 124 | function readJson(path) { | 260 | function readJson(path) { |
| 125 | try { | 261 | try { |
| 126 | return JSON.parse(readFileSync(path, "utf8")); | 262 | return JSON.parse(readFileSync(path, "utf8")); |
| @@ -144,6 +280,8 @@ function buildMacLauncher(electronBinaryPath) { · 17 unmodified lines | |||
| 144 | sourceAppBundlePath, | 280 | sourceAppBundlePath, |
| 145 | sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, | 281 | sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, |
| 146 | iconMtimeMs: statSync(iconPath).mtimeMs, | 282 | iconMtimeMs: statSync(iconPath).mtimeMs, |
| 283 | appBundleId: APP_BUNDLE_ID, | ||
| 284 | appProtocolSchemes: APP_PROTOCOL_SCHEMES, | ||
| 147 | }; | 285 | }; |
| 148 | 286 | ||
| 149 | const currentMetadata = readJson(metadataPath); | 287 | const currentMetadata = readJson(metadataPath); |
| @@ -152,13 +290,19 @@ function buildMacLauncher(electronBinaryPath) { · 2 unmodified lines | |||
| 152 | currentMetadata && | 290 | currentMetadata && |
| 153 | JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) | 291 | JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) |
| 154 | ) { | 292 | ) { |
| 293 | registerMacLauncherBundle(targetAppBundlePath); | ||
| 155 | return targetBinaryPath; | 294 | return targetBinaryPath; |
| 156 | } | 295 | } |
| 157 | 296 | ||
| 158 | rmSync(targetAppBundlePath, { recursive: true, force: true }); | 297 | rmSync(targetAppBundlePath, { recursive: true, force: true }); |
| 159 | cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); | 298 | cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); |
| 160 | patchMainBundleInfoPlist(targetAppBundlePath, iconPath); | 299 | patchMainBundleInfoPlist(targetAppBundlePath, iconPath); |
| 300 | patchHelperBundleInfoPlists(targetAppBundlePath); | ||
| 301 | if (isDevelopment) { | ||
| 302 | writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); | ||
| 303 | } | ||
| 161 | writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); | 304 | writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); |
| 305 | registerMacLauncherBundle(targetAppBundlePath); | ||
| 162 | 306 | ||
| 163 | return targetBinaryPath; | 307 | return targetBinaryPath; |
| 164 | } | 308 | } |
| @@ -173,11 +317,19 @@ export function resolveElectronPath() { · 8 unmodified lines | |||
| 173 | return electronBinaryPath; | 317 | return electronBinaryPath; |
| 174 | } | 318 | } |
| 175 | 319 | ||
| 176 | // Dev launches do not need a renamed app bundle badly enough to risk breaking | 320 | return buildMacLauncher(electronBinaryPath); |
| 177 | // Electron helper resource lookup on macOS. | 321 | } |
| 178 | if (isDevelopment) { | 322 | |
| 179 | return electronBinaryPath; | 323 | export function resolveDevProtocolClient() { |
| 324 | if (process.platform !== "darwin" || !isDevelopment) { | ||
| 325 | return null; | ||
| 180 | } | 326 | } |
| 181 | 327 | ||
| 182 | return buildMacLauncher(electronBinaryPath); | 328 | const require = createRequire(import.meta.url); |
| 329 | const electronBinaryPath = require("electron"); | ||
| 330 | const launcherBinaryPath = buildMacLauncher(electronBinaryPath); | ||
| 331 | return { | ||
| 332 | appBundlePath: resolve(launcherBinaryPath, "..", "..", ".."), | ||
| 333 | appBundleId: APP_BUNDLE_ID, | ||
| 334 | }; | ||
| 183 | } | 335 | } |
| @@ -1,16 +1,17 @@ | |||
| 1 | import * as Cause from "effect/Cause"; | 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Crypto from "effect/Crypto"; | ||
| 3 | import * as Data from "effect/Data"; | 2 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 5 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 6 | import * as Ref from "effect/Ref"; | 5 | import * as Ref from "effect/Ref"; |
| 7 | 6 | ||
| 8 | import * as NetService from "@t3tools/shared/Net"; | 7 | import * as NetService from "@t3tools/shared/Net"; |
| 8 | import * as Crypto from "effect/Crypto"; | ||
| 9 | import * as ElectronApp from "../electron/ElectronApp.ts"; | 9 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 10 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; | 10 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 11 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; | 11 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; |
| 12 | import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; | 12 | import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; |
| 13 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; | 13 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; |
| 14 | import * as DesktopCloudAuth from "./DesktopCloudAuth.ts"; | ||
| 14 | import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; | 15 | import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; |
| 15 | import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; | 16 | import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; |
| 16 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 17 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| @@ -190,6 +191,7 @@ const startup = Effect.gen(function* () { · 173 unmodified lines | |||
| 190 | const electronApp = yield* ElectronApp.ElectronApp; | 191 | const electronApp = yield* ElectronApp.ElectronApp; |
| 191 | const electronProtocol = yield* ElectronProtocol.ElectronProtocol; | 192 | const electronProtocol = yield* ElectronProtocol.ElectronProtocol; |
| 192 | const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; | 193 | const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; |
| 194 | const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; | ||
| 193 | const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; | 195 | const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; |
| 194 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | 196 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 195 | const updates = yield* DesktopUpdates.DesktopUpdates; | 197 | const updates = yield* DesktopUpdates.DesktopUpdates; |
| @@ -207,6 +209,7 @@ const startup = Effect.gen(function* () { · 11 unmodified lines | |||
| 207 | 209 | ||
| 208 | yield* appIdentity.configure; | 210 | yield* appIdentity.configure; |
| 209 | yield* lifecycle.register; | 211 | yield* lifecycle.register; |
| 212 | yield* cloudAuth.configure; | ||
| 210 | 213 | ||
| 211 | yield* electronApp.whenReady.pipe( | 214 | yield* electronApp.whenReady.pipe( |
| 212 | Effect.withSpan("desktop.electron.whenReady"), | 215 | Effect.withSpan("desktop.electron.whenReady"), |
| @@ -53,6 +53,9 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => · 52 unmodified lines | |||
| 53 | calls.setAboutPanelOptions.push(options); | 53 | calls.setAboutPanelOptions.push(options); |
| 54 | }), | 54 | }), |
| 55 | setAppUserModelId: () => Effect.void, | 55 | setAppUserModelId: () => Effect.void, |
| 56 | requestSingleInstanceLock: Effect.succeed(true), | ||
| 57 | isDefaultProtocolClient: () => Effect.succeed(false), | ||
| 58 | setAsDefaultProtocolClient: () => Effect.succeed(true), | ||
| 56 | setDesktopName: () => Effect.void, | 59 | setDesktopName: () => Effect.void, |
| 57 | setDockIcon: (iconPath) => | 60 | setDockIcon: (iconPath) => |
| 58 | Effect.sync(() => { | 61 | Effect.sync(() => { |
| @@ -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 | }); | ||
| @@ -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); | ||
| @@ -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 | }); | ||
| @@ -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 | ); | ||
| @@ -37,6 +37,7 @@ export const DesktopConfig = Config.all({ · 36 unmodified lines | |||
| 37 | xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), | 37 | xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), |
| 38 | t3Home: trimmedString("T3CODE_HOME"), | 38 | t3Home: trimmedString("T3CODE_HOME"), |
| 39 | devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), | 39 | devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), |
| 40 | appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"), | ||
| 40 | devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), | 41 | devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), |
| 41 | configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option), | 42 | configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option), |
| 42 | commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), | 43 | commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), |
| @@ -93,6 +93,20 @@ describe("DesktopEnvironment", () => { · 92 unmodified lines | |||
| 93 | }), | 93 | }), |
| 94 | ); | 94 | ); |
| 95 | 95 | ||
| 96 | it.effect("uses a configured app user model id override", () => | ||
| 97 | Effect.gen(function* () { | ||
| 98 | const environment = yield* makeEnvironment( | ||
| 99 | {}, | ||
| 100 | { | ||
| 101 | T3CODE_DESKTOP_APP_USER_MODEL_ID: " com.t3tools.t3code.dev.local ", | ||
| 102 | VITE_DEV_SERVER_URL: "http://localhost:5173", | ||
| 103 | }, | ||
| 104 | ); | ||
| 105 | |||
| 106 | assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev.local"); | ||
| 107 | }), | ||
| 108 | ); | ||
| 109 | |||
| 96 | it.effect("resolves picker defaults without nullish sentinels", () => | 110 | it.effect("resolves picker defaults without nullish sentinels", () => |
| 97 | Effect.gen(function* () { | 111 | Effect.gen(function* () { |
| 98 | const environment = yield* makeEnvironment(); | 112 | const environment = yield* makeEnvironment(); |
| @@ -199,7 +199,9 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( · 198 unmodified lines | |||
| 199 | otlpExportIntervalMs: config.otlpExportIntervalMs, | 199 | otlpExportIntervalMs: config.otlpExportIntervalMs, |
| 200 | branding, | 200 | branding, |
| 201 | displayName, | 201 | displayName, |
| 202 | appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", | 202 | appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => |
| 203 | isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", | ||
| 204 | ), | ||
| 203 | linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", | 205 | linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", |
| 204 | linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", | 206 | linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", |
| 205 | userDataDirName, | 207 | userDataDirName, |
| @@ -7,12 +7,15 @@ const { · 6 unmodified lines | |||
| 7 | exitMock, | 7 | exitMock, |
| 8 | getAppPathMock, | 8 | getAppPathMock, |
| 9 | getVersionMock, | 9 | getVersionMock, |
| 10 | isDefaultProtocolClientMock, | ||
| 10 | onMock, | 11 | onMock, |
| 11 | quitMock, | 12 | quitMock, |
| 12 | relaunchMock, | 13 | relaunchMock, |
| 13 | removeListenerMock, | 14 | removeListenerMock, |
| 15 | requestSingleInstanceLockMock, | ||
| 14 | setAboutPanelOptionsMock, | 16 | setAboutPanelOptionsMock, |
| 15 | setAppUserModelIdMock, | 17 | setAppUserModelIdMock, |
| 18 | setAsDefaultProtocolClientMock, | ||
| 16 | setDesktopNameMock, | 19 | setDesktopNameMock, |
| 17 | setDockIconMock, | 20 | setDockIconMock, |
| 18 | setNameMock, | 21 | setNameMock, |
| @@ -23,12 +26,15 @@ const { · 4 unmodified lines | |||
| 23 | exitMock: vi.fn(), | 26 | exitMock: vi.fn(), |
| 24 | getAppPathMock: vi.fn(() => "/app"), | 27 | getAppPathMock: vi.fn(() => "/app"), |
| 25 | getVersionMock: vi.fn(() => "1.2.3"), | 28 | getVersionMock: vi.fn(() => "1.2.3"), |
| 29 | isDefaultProtocolClientMock: vi.fn(() => false), | ||
| 26 | onMock: vi.fn(), | 30 | onMock: vi.fn(), |
| 27 | quitMock: vi.fn(), | 31 | quitMock: vi.fn(), |
| 28 | relaunchMock: vi.fn(), | 32 | relaunchMock: vi.fn(), |
| 29 | removeListenerMock: vi.fn(), | 33 | removeListenerMock: vi.fn(), |
| 34 | requestSingleInstanceLockMock: vi.fn(() => true), | ||
| 30 | setAboutPanelOptionsMock: vi.fn(), | 35 | setAboutPanelOptionsMock: vi.fn(), |
| 31 | setAppUserModelIdMock: vi.fn(), | 36 | setAppUserModelIdMock: vi.fn(), |
| 37 | setAsDefaultProtocolClientMock: vi.fn(() => true), | ||
| 32 | setDesktopNameMock: vi.fn(), | 38 | setDesktopNameMock: vi.fn(), |
| 33 | setDockIconMock: vi.fn(), | 39 | setDockIconMock: vi.fn(), |
| 34 | setNameMock: vi.fn(), | 40 | setNameMock: vi.fn(), |
| @@ -46,14 +52,17 @@ vi.mock("electron", () => ({ · 11 unmodified lines | |||
| 46 | }, | 52 | }, |
| 47 | getAppPath: getAppPathMock, | 53 | getAppPath: getAppPathMock, |
| 48 | getVersion: getVersionMock, | 54 | getVersion: getVersionMock, |
| 55 | isDefaultProtocolClient: isDefaultProtocolClientMock, | ||
| 49 | isPackaged: true, | 56 | isPackaged: true, |
| 50 | name: "T3 Code", | 57 | name: "T3 Code", |
| 51 | on: onMock, | 58 | on: onMock, |
| 52 | quit: quitMock, | 59 | quit: quitMock, |
| 53 | relaunch: relaunchMock, | 60 | relaunch: relaunchMock, |
| 54 | removeListener: removeListenerMock, | 61 | removeListener: removeListenerMock, |
| 62 | requestSingleInstanceLock: requestSingleInstanceLockMock, | ||
| 55 | runningUnderARM64Translation: false, | 63 | runningUnderARM64Translation: false, |
| 56 | setAboutPanelOptions: setAboutPanelOptionsMock, | 64 | setAboutPanelOptions: setAboutPanelOptionsMock, |
| 65 | setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, | ||
| 57 | setAppUserModelId: setAppUserModelIdMock, | 66 | setAppUserModelId: setAppUserModelIdMock, |
| 58 | setDesktopName: setDesktopNameMock, | 67 | setDesktopName: setDesktopNameMock, |
| 59 | setName: setNameMock, | 68 | setName: setNameMock, |
| @@ -29,6 +29,13 @@ export interface ElectronAppShape { · 28 unmodified lines | |||
| 29 | options: Electron.AboutPanelOptionsOptions, | 29 | options: Electron.AboutPanelOptionsOptions, |
| 30 | ) => Effect.Effect<void>; | 30 | ) => Effect.Effect<void>; |
| 31 | readonly setAppUserModelId: (id: string) => Effect.Effect<void>; | 31 | readonly setAppUserModelId: (id: string) => Effect.Effect<void>; |
| 32 | readonly requestSingleInstanceLock: Effect.Effect<boolean>; | ||
| 33 | readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>; | ||
| 34 | readonly setAsDefaultProtocolClient: ( | ||
| 35 | protocol: string, | ||
| 36 | path?: string, | ||
| 37 | args?: readonly string[], | ||
| 38 | ) => Effect.Effect<boolean>; | ||
| 32 | readonly setDesktopName: (desktopName: string) => Effect.Effect<void>; | 39 | readonly setDesktopName: (desktopName: string) => Effect.Effect<void>; |
| 33 | readonly setDockIcon: (iconPath: string) => Effect.Effect<void>; | 40 | readonly setDockIcon: (iconPath: string) => Effect.Effect<void>; |
| 34 | readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>; | 41 | readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>; |
| @@ -93,6 +100,16 @@ const make = ElectronApp.of({ · 58 unmodified lines | |||
| 93 | Effect.sync(() => { | 100 | Effect.sync(() => { |
| 94 | Electron.app.setAppUserModelId(id); | 101 | Electron.app.setAppUserModelId(id); |
| 95 | }), | 102 | }), |
| 103 | requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), | ||
| 104 | isDefaultProtocolClient: (protocol) => | ||
| 105 | Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), | ||
| 106 | setAsDefaultProtocolClient: (protocol, path, args) => | ||
| 107 | Effect.sync(() => { | ||
| 108 | if (path === undefined) { | ||
| 109 | return Electron.app.setAsDefaultProtocolClient(protocol); | ||
| 110 | } | ||
| 111 | return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); | ||
| 112 | }), | ||
| 96 | setDesktopName: (desktopName) => | 113 | setDesktopName: (desktopName) => |
| 97 | Effect.sync(() => { | 114 | Effect.sync(() => { |
| 98 | const linuxApp = Electron.app as Electron.App & { | 115 | const linuxApp = Electron.app as Electron.App & { |
| @@ -1,6 +1,13 @@ | |||
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | ||
| 3 | import * as DesktopIpc from "./DesktopIpc.ts"; | 3 | import * as DesktopIpc from "./DesktopIpc.ts"; |
| 4 | import { | ||
| 5 | clearCloudAuthToken, | ||
| 6 | createCloudAuthRequest, | ||
| 7 | fetchCloudAuth, | ||
| 8 | getCloudAuthToken, | ||
| 9 | setCloudAuthToken, | ||
| 10 | } from "./methods/cloudAuth.ts"; | ||
| 4 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; | 11 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; |
| 5 | import { | 12 | import { |
| 6 | getSavedEnvironmentRegistry, | 13 | getSavedEnvironmentRegistry, |
| @@ -75,6 +82,11 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { · 68 unmodified lines | |||
| 75 | yield* ipc.handle(setTheme); | 82 | yield* ipc.handle(setTheme); |
| 76 | yield* ipc.handle(showContextMenu); | 83 | yield* ipc.handle(showContextMenu); |
| 77 | yield* ipc.handle(openExternal); | 84 | yield* ipc.handle(openExternal); |
| 85 | yield* ipc.handle(createCloudAuthRequest); | ||
| 86 | yield* ipc.handle(getCloudAuthToken); | ||
| 87 | yield* ipc.handle(setCloudAuthToken); | ||
| 88 | yield* ipc.handle(clearCloudAuthToken); | ||
| 89 | yield* ipc.handle(fetchCloudAuth); | ||
| 78 | 90 | ||
| 79 | yield* ipc.handle(getUpdateState); | 91 | yield* ipc.handle(getUpdateState); |
| 80 | yield* ipc.handle(setUpdateChannel); | 92 | yield* ipc.handle(setUpdateChannel); |
| @@ -3,6 +3,12 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; · 2 unmodified lines | |||
| 3 | export const SET_THEME_CHANNEL = "desktop:set-theme"; | 3 | export const SET_THEME_CHANNEL = "desktop:set-theme"; |
| 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | 5 | 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"; | ||
| 6 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 12 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 7 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; | 13 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 8 | export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | 14 | export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| @@ -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 | }); | ||
| @@ -26,6 +26,8 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; · 25 unmodified lines | |||
| 26 | import * as ElectronWindow from "./electron/ElectronWindow.ts"; | 26 | import * as ElectronWindow from "./electron/ElectronWindow.ts"; |
| 27 | import * as DesktopApp from "./app/DesktopApp.ts"; | 27 | import * as DesktopApp from "./app/DesktopApp.ts"; |
| 28 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; | 28 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; |
| 29 | import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; | ||
| 30 | import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; | ||
| 29 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; | 31 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; |
| 30 | import * as DesktopAssets from "./app/DesktopAssets.ts"; | 32 | import * as DesktopAssets from "./app/DesktopAssets.ts"; |
| 31 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; | 33 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; |
| @@ -111,6 +113,7 @@ const desktopFoundationLayer = Layer.mergeAll( · 79 unmodified lines | |||
| 111 | DesktopAppSettings.layer, | 113 | DesktopAppSettings.layer, |
| 112 | DesktopClientSettings.layer, | 114 | DesktopClientSettings.layer, |
| 113 | DesktopSavedEnvironments.layer, | 115 | DesktopSavedEnvironments.layer, |
| 116 | DesktopCloudAuthTokenStore.layer, | ||
| 114 | DesktopAssets.layer, | 117 | DesktopAssets.layer, |
| 115 | DesktopObservability.layer, | 118 | DesktopObservability.layer, |
| 116 | ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); | 119 | ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); |
| @@ -135,6 +138,7 @@ const desktopBackendLayer = DesktopBackendManager.layer.pipe( · 18 unmodified lines | |||
| 135 | const desktopApplicationLayer = Layer.mergeAll( | 138 | const desktopApplicationLayer = Layer.mergeAll( |
| 136 | DesktopLifecycle.layer, | 139 | DesktopLifecycle.layer, |
| 137 | DesktopApplicationMenu.layer, | 140 | DesktopApplicationMenu.layer, |
| 141 | DesktopCloudAuth.layer, | ||
| 138 | DesktopShellEnvironment.layer, | 142 | DesktopShellEnvironment.layer, |
| 139 | desktopSshLayer, | 143 | desktopSshLayer, |
| 140 | ).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); | 144 | ).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); |
| @@ -96,6 +96,23 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 95 unmodified lines | |||
| 96 | ...(position === undefined ? {} : { position }), | 96 | ...(position === undefined ? {} : { position }), |
| 97 | }), | 97 | }), |
| 98 | openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), | 98 | openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), |
| 99 | createCloudAuthRequest: () => ipcRenderer.invoke(IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL), | ||
| 100 | getCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL), | ||
| 101 | setCloudAuthToken: (token: string) => | ||
| 102 | ipcRenderer.invoke(IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, token), | ||
| 103 | clearCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL), | ||
| 104 | fetchCloudAuth: (input) => ipcRenderer.invoke(IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, input), | ||
| 105 | onCloudAuthCallback: (listener) => { | ||
| 106 | const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => { | ||
| 107 | if (typeof rawUrl !== "string") return; | ||
| 108 | listener(rawUrl); | ||
| 109 | }; | ||
| 110 | |||
| 111 | ipcRenderer.on(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener); | ||
| 112 | return () => { | ||
| 113 | ipcRenderer.removeListener(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener); | ||
| 114 | }; | ||
| 115 | }, | ||
| 99 | onMenuAction: (listener) => { | 116 | onMenuAction: (listener) => { |
| 100 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { | 117 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { |
| 101 | if (typeof action !== "string") return; | 118 | if (typeof action !== "string") return; |
| @@ -53,6 +53,7 @@ const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ · 52 unmodified lines | |||
| 53 | createdAt: Schema.String, | 53 | createdAt: Schema.String, |
| 54 | lastConnectedAt: Schema.NullOr(Schema.String), | 54 | lastConnectedAt: Schema.NullOr(Schema.String), |
| 55 | desktopSsh: Schema.optionalKey(DesktopSshTargetSchema), | 55 | desktopSsh: Schema.optionalKey(DesktopSshTargetSchema), |
| 56 | relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })), | ||
| 56 | encryptedBearerToken: Schema.optionalKey(Schema.String), | 57 | encryptedBearerToken: Schema.optionalKey(Schema.String), |
| 57 | }); | 58 | }); |
| 58 | 59 | ||
| @@ -134,7 +135,11 @@ function toPersistedSavedEnvironmentRecord( · 75 unmodified lines | |||
| 134 | createdAt: record.createdAt, | 135 | createdAt: record.createdAt, |
| 135 | lastConnectedAt: record.lastConnectedAt, | 136 | lastConnectedAt: record.lastConnectedAt, |
| 136 | }; | 137 | }; |
| 137 | return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | 138 | return { |
| 139 | ...nextRecord, | ||
| 140 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 141 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 142 | }; | ||
| 138 | } | 143 | } |
| 139 | 144 | ||
| 140 | function toSavedEnvironmentStorageRecord( | 145 | function toSavedEnvironmentStorageRecord( |
| @@ -149,20 +154,13 @@ function toSavedEnvironmentStorageRecord( · 8 unmodified lines | |||
| 149 | createdAt: record.createdAt, | 154 | createdAt: record.createdAt, |
| 150 | lastConnectedAt: record.lastConnectedAt, | 155 | lastConnectedAt: record.lastConnectedAt, |
| 151 | }; | 156 | }; |
| 152 | const desktopSsh = record.desktopSsh; | 157 | const metadata = { |
| 153 | if (desktopSsh) { | 158 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), |
| 154 | return Option.match(encryptedBearerToken, { | 159 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), |
| 155 | onNone: () => ({ ...nextRecord, desktopSsh }), | 160 | }; |
| 156 | onSome: (value) => ({ | ||
| 157 | ...nextRecord, | ||
| 158 | desktopSsh, | ||
| 159 | encryptedBearerToken: value, | ||
| 160 | }), | ||
| 161 | }); | ||
| 162 | } | ||
| 163 | return Option.match(encryptedBearerToken, { | 161 | return Option.match(encryptedBearerToken, { |
| 164 | onNone: () => nextRecord, | 162 | onNone: () => ({ ...nextRecord, ...metadata }), |
| 165 | onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }), | 163 | onSome: (value) => ({ ...nextRecord, ...metadata, encryptedBearerToken: value }), |
| 166 | }); | 164 | }); |
| 167 | } | 165 | } |
| 168 | 166 | ||
| @@ -39,6 +39,9 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { · 38 unmodified lines | |||
| 39 | setName: () => Effect.void, | 39 | setName: () => Effect.void, |
| 40 | setAboutPanelOptions: () => Effect.void, | 40 | setAboutPanelOptions: () => Effect.void, |
| 41 | setAppUserModelId: () => Effect.void, | 41 | setAppUserModelId: () => Effect.void, |
| 42 | requestSingleInstanceLock: Effect.succeed(true), | ||
| 43 | isDefaultProtocolClient: () => Effect.succeed(false), | ||
| 44 | setAsDefaultProtocolClient: () => Effect.succeed(true), | ||
| 42 | setDesktopName: () => Effect.void, | 45 | setDesktopName: () => Effect.void, |
| 43 | setDockIcon: () => Effect.void, | 46 | setDockIcon: () => Effect.void, |
| 44 | appendCommandLineSwitch: () => Effect.void, | 47 | appendCommandLineSwitch: () => Effect.void, |
| @@ -96,6 +96,11 @@ const config: ExpoConfig = { · 95 unmodified lines | |||
| 96 | favicon: "./assets/favicon.png", | 96 | favicon: "./assets/favicon.png", |
| 97 | }, | 97 | }, |
| 98 | plugins: [ | 98 | plugins: [ |
| 99 | "expo-router", | ||
| 100 | "expo-font", | ||
| 101 | "expo-secure-store", | ||
| 102 | ["@clerk/expo", { theme: "./clerk-theme.json" }], | ||
| 103 | "expo-web-browser", | ||
| 99 | [ | 104 | [ |
| 100 | "expo-camera", | 105 | "expo-camera", |
| 101 | { | 106 | { |
| @@ -120,17 +125,36 @@ const config: ExpoConfig = { · 18 unmodified lines | |||
| 120 | "expo-build-properties", | 125 | "expo-build-properties", |
| 121 | { | 126 | { |
| 122 | ios: { | 127 | ios: { |
| 123 | deploymentTarget: "16.4", | 128 | deploymentTarget: "18.0", |
| 124 | }, | 129 | }, |
| 125 | }, | 130 | }, |
| 126 | ], | 131 | ], |
| 127 | "expo-secure-store", | 132 | [ |
| 128 | "expo-router", | 133 | "expo-widgets", |
| 129 | "expo-font", | 134 | { |
| 135 | bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, | ||
| 136 | groupIdentifier: `group.${variant.iosBundleIdentifier}`, | ||
| 137 | enablePushNotifications: true, | ||
| 138 | widgets: [ | ||
| 139 | { | ||
| 140 | name: "AgentActivity", | ||
| 141 | displayName: "Agent Activity", | ||
| 142 | description: "Shows the current state of active T3 Code agents.", | ||
| 143 | supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], | ||
| 144 | }, | ||
| 145 | ], | ||
| 146 | }, | ||
| 147 | ], | ||
| 130 | "./plugins/withAndroidCleartextTraffic.cjs", | 148 | "./plugins/withAndroidCleartextTraffic.cjs", |
| 131 | ], | 149 | ], |
| 132 | extra: { | 150 | extra: { |
| 133 | appVariant: APP_VARIANT, | 151 | appVariant: APP_VARIANT, |
| 152 | relay: { | ||
| 153 | url: process.env.T3_RELAY_URL ?? null, | ||
| 154 | }, | ||
| 155 | clerk: { | ||
| 156 | publishableKey: process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, | ||
| 157 | }, | ||
| 134 | eas: { | 158 | eas: { |
| 135 | projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", | 159 | projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", |
| 136 | }, | 160 | }, |
| @@ -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 | } | ||
| @@ -38,6 +38,7 @@ · 37 unmodified lines | |||
| 38 | --color-secondary: #ffffff; | 38 | --color-secondary: #ffffff; |
| 39 | --color-secondary-foreground: #262626; | 39 | --color-secondary-foreground: #262626; |
| 40 | --color-secondary-border: rgba(0, 0, 0, 0.08); | 40 | --color-secondary-border: rgba(0, 0, 0, 0.08); |
| 41 | --color-switch-active: #34c759; | ||
| 41 | 42 | ||
| 42 | /* Danger */ | 43 | /* Danger */ |
| 43 | --color-danger: #fef2f2; | 44 | --color-danger: #fef2f2; |
| @@ -125,6 +126,7 @@ · 81 unmodified lines | |||
| 125 | --color-secondary: rgba(255, 255, 255, 0.04); | 126 | --color-secondary: rgba(255, 255, 255, 0.04); |
| 126 | --color-secondary-foreground: #f5f5f5; | 127 | --color-secondary-foreground: #f5f5f5; |
| 127 | --color-secondary-border: rgba(255, 255, 255, 0.06); | 128 | --color-secondary-border: rgba(255, 255, 255, 0.06); |
| 129 | --color-switch-active: #30d158; | ||
| 128 | 130 | ||
| 129 | /* Danger */ | 131 | /* Danger */ |
| 130 | --color-danger: rgba(239, 68, 68, 0.14); | 132 | --color-danger: rgba(239, 68, 68, 0.14); |
| @@ -39,10 +39,13 @@ · 38 unmodified lines | |||
| 39 | }, | 39 | }, |
| 40 | "dependencies": { | 40 | "dependencies": { |
| 41 | "@callstack/liquid-glass": "^0.7.1", | 41 | "@callstack/liquid-glass": "^0.7.1", |
| 42 | "@clerk/expo": "^3.3.0", | ||
| 42 | "@effect/atom-react": "catalog:", | 43 | "@effect/atom-react": "catalog:", |
| 43 | "@expo-google-fonts/dm-sans": "^0.4.2", | 44 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 44 | "@expo/ui": "~56.0.8", | 45 | "@expo/ui": "~56.0.8", |
| 45 | "@legendapp/list": "3.0.0-beta.44", | 46 | "@legendapp/list": "3.0.0-beta.44", |
| 47 | "@noble/curves": "catalog:", | ||
| 48 | "@noble/hashes": "catalog:", | ||
| 46 | "@pierre/diffs": "catalog:", | 49 | "@pierre/diffs": "catalog:", |
| 47 | "@react-native-menu/menu": "^2.0.0", | 50 | "@react-native-menu/menu": "^2.0.0", |
| 48 | "@shikijs/core": "3.23.0", | 51 | "@shikijs/core": "3.23.0", |
| @@ -58,6 +61,7 @@ · 9 unmodified lines | |||
| 58 | "diff": "8.0.3", | 61 | "diff": "8.0.3", |
| 59 | "effect": "catalog:", | 62 | "effect": "catalog:", |
| 60 | "expo": "^56.0.0", | 63 | "expo": "^56.0.0", |
| 64 | "expo-auth-session": "~56.0.12", | ||
| 61 | "expo-build-properties": "~56.0.15", | 65 | "expo-build-properties": "~56.0.15", |
| 62 | "expo-camera": "~56.0.7", | 66 | "expo-camera": "~56.0.7", |
| 63 | "expo-clipboard": "~56.0.3", | 67 | "expo-clipboard": "~56.0.3", |
| @@ -70,12 +74,15 @@ · 6 unmodified lines | |||
| 70 | "expo-haptics": "~56.0.3", | 74 | "expo-haptics": "~56.0.3", |
| 71 | "expo-image-picker": "~56.0.14", | 75 | "expo-image-picker": "~56.0.14", |
| 72 | "expo-linking": "~56.0.12", | 76 | "expo-linking": "~56.0.12", |
| 77 | "expo-notifications": "~56.0.14", | ||
| 73 | "expo-paste-input": "^0.1.15", | 78 | "expo-paste-input": "^0.1.15", |
| 74 | "expo-router": "~56.2.7", | 79 | "expo-router": "~56.2.7", |
| 75 | "expo-secure-store": "~56.0.4", | 80 | "expo-secure-store": "~56.0.4", |
| 76 | "expo-splash-screen": "~56.0.10", | 81 | "expo-splash-screen": "~56.0.10", |
| 77 | "expo-symbols": "~56.0.5", | 82 | "expo-symbols": "~56.0.5", |
| 78 | "expo-updates": "~56.0.17", | 83 | "expo-updates": "~56.0.17", |
| 84 | "expo-web-browser": "~56.0.5", | ||
| 85 | "expo-widgets": "~56.0.15", | ||
| 79 | "punycode": "^2.3.1", | 86 | "punycode": "^2.3.1", |
| 80 | "react": "19.2.3", | 87 | "react": "19.2.3", |
| 81 | "react-dom": "19.2.3", | 88 | "react-dom": "19.2.3", |
| @@ -96,6 +103,7 @@ · 14 unmodified lines | |||
| 96 | "uniwind": "^1.6.2" | 103 | "uniwind": "^1.6.2" |
| 97 | }, | 104 | }, |
| 98 | "devDependencies": { | 105 | "devDependencies": { |
| 106 | "@effect/vitest": "catalog:", | ||
| 99 | "@types/react": "~19.2.0", | 107 | "@types/react": "~19.2.0", |
| 100 | "babel-preset-expo": "~56.0.0", | 108 | "babel-preset-expo": "~56.0.0", |
| 101 | "tailwindcss": "^4.0.0", | 109 | "tailwindcss": "^4.0.0", |
| @@ -20,12 +20,15 @@ import { · 19 unmodified lines | |||
| 20 | } from "../state/use-remote-environment-registry"; | 20 | } from "../state/use-remote-environment-registry"; |
| 21 | import { RegistryContext } from "@effect/atom-react"; | 21 | import { RegistryContext } from "@effect/atom-react"; |
| 22 | import { appAtomRegistry } from "../state/atom-registry"; | 22 | import { appAtomRegistry } from "../state/atom-registry"; |
| 23 | import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; | ||
| 24 | import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; | ||
| 23 | 25 | ||
| 24 | function AppNavigator() { | 26 | function AppNavigator() { |
| 25 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); | 27 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); |
| 26 | const colorScheme = useColorScheme(); | 28 | const colorScheme = useColorScheme(); |
| 27 | const statusBarBg = useCSSVariable("--color-status-bar"); | 29 | const statusBarBg = useCSSVariable("--color-status-bar"); |
| 28 | const sheetStyle = useResolveClassNames("bg-sheet"); | 30 | const sheetStyle = useResolveClassNames("bg-sheet"); |
| 31 | useAgentNotificationNavigation(); | ||
| 29 | 32 | ||
| 30 | const newTaskScreenOptions = { | 33 | const newTaskScreenOptions = { |
| 31 | contentStyle: sheetStyle, | 34 | contentStyle: sheetStyle, |
| @@ -45,6 +48,11 @@ function AppNavigator() { · 13 unmodified lines | |||
| 45 | sheetGrabberVisible: true, | 48 | sheetGrabberVisible: true, |
| 46 | }; | 49 | }; |
| 47 | 50 | ||
| 51 | const settingsSheetScreenOptions = { | ||
| 52 | ...connectionSheetScreenOptions, | ||
| 53 | sheetAllowedDetents: [0.7], | ||
| 54 | }; | ||
| 55 | |||
| 48 | if (isLoadingSavedConnection) { | 56 | if (isLoadingSavedConnection) { |
| 49 | return <LoadingScreen message="Loading remote workspace…" />; | 57 | return <LoadingScreen message="Loading remote workspace…" />; |
| 50 | } | 58 | } |
| @@ -66,6 +74,7 @@ function AppNavigator() { · 15 unmodified lines | |||
| 66 | headerShadowVisible: false, | 74 | headerShadowVisible: false, |
| 67 | }} | 75 | }} |
| 68 | /> | 76 | /> |
| 77 | <Stack.Screen name="settings" options={settingsSheetScreenOptions} /> | ||
| 69 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> | 78 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> |
| 70 | <Stack.Screen name="new" options={newTaskScreenOptions} /> | 79 | <Stack.Screen name="new" options={newTaskScreenOptions} /> |
| 71 | <Stack.Screen | 80 | <Stack.Screen |
| @@ -91,14 +100,20 @@ export default function RootLayout() { · 19 unmodified lines | |||
| 91 | useRemoteEnvironmentBootstrap(); | 100 | useRemoteEnvironmentBootstrap(); |
| 92 | 101 | ||
| 93 | return ( | 102 | return ( |
| 94 | <RegistryContext value={appAtomRegistry}> | 103 | <RegistryContext.Provider value={appAtomRegistry}> |
| 95 | <GestureHandlerRootView style={{ flex: 1 }}> | 104 | <CloudAuthProvider> |
| 96 | <KeyboardProvider statusBarTranslucent> | 105 | <GestureHandlerRootView style={{ flex: 1 }}> |
| 97 | <SafeAreaProvider> | 106 | <KeyboardProvider statusBarTranslucent> |
| 98 | {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />} | 107 | <SafeAreaProvider> |
| 99 | </SafeAreaProvider> | 108 | {fontsLoaded ? ( |
| 100 | </KeyboardProvider> | 109 | <AppNavigator /> |
| 101 | </GestureHandlerRootView> | 110 | ) : ( |
| 102 | </RegistryContext> | 111 | <LoadingScreen message="Loading remote workspace…" /> |
| 112 | )} | ||
| 113 | </SafeAreaProvider> | ||
| 114 | </KeyboardProvider> | ||
| 115 | </GestureHandlerRootView> | ||
| 116 | </CloudAuthProvider> | ||
| 117 | </RegistryContext.Provider> | ||
| 103 | ); | 118 | ); |
| 104 | } | 119 | } |
| @@ -22,8 +22,8 @@ export default function ConnectionsRouteScreen() { · 21 unmodified lines | |||
| 22 | const hasEnvironments = connectedEnvironments.length > 0; | 22 | const hasEnvironments = connectedEnvironments.length > 0; |
| 23 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | 23 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); |
| 24 | 24 | ||
| 25 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 26 | const accentColor = useThemeColor("--color-icon-muted"); | 25 | const accentColor = useThemeColor("--color-icon-muted"); |
| 26 | const iconColor = useThemeColor("--color-icon"); | ||
| 27 | 27 | ||
| 28 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | 28 | const handleToggle = useCallback((environmentId: EnvironmentId) => { |
| 29 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | 29 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); |
| @@ -36,11 +36,15 @@ export default function ConnectionsRouteScreen() { · 6 unmodified lines | |||
| 36 | title: "Environments", | 36 | title: "Environments", |
| 37 | headerRight: () => ( | 37 | headerRight: () => ( |
| 38 | <Link href="/connections/new" asChild> | 38 | <Link href="/connections/new" asChild> |
| 39 | <Pressable className="h-10 w-10 items-center justify-center rounded-full bg-primary active:opacity-70"> | 39 | <Pressable |
| 40 | accessibilityLabel="Add environment" | ||
| 41 | accessibilityRole="button" | ||
| 42 | className="h-10 w-10 items-center justify-center active:opacity-70" | ||
| 43 | > | ||
| 40 | <SymbolView | 44 | <SymbolView |
| 41 | name="plus" | 45 | name="plus" |
| 42 | size={18} | 46 | size={18} |
| 43 | tintColor={primaryFg} | 47 | tintColor={iconColor} |
| 44 | type="monochrome" | 48 | type="monochrome" |
| 45 | weight="semibold" | 49 | weight="semibold" |
| 46 | /> | 50 | /> |
| @@ -130,7 +130,9 @@ export default function ConnectionsNewRouteScreen() { · 129 unmodified lines | |||
| 130 | title: showScanner ? "Scan QR Code" : "Add Environment", | 130 | title: showScanner ? "Scan QR Code" : "Add Environment", |
| 131 | headerRight: () => ( | 131 | headerRight: () => ( |
| 132 | <Pressable | 132 | <Pressable |
| 133 | className="h-10 w-10 items-center justify-center rounded-full border border-border bg-secondary" | 133 | accessibilityLabel={showScanner ? "Close QR scanner" : "Scan pairing QR code"} |
| 134 | accessibilityRole="button" | ||
| 135 | className="h-10 w-10 items-center justify-center active:opacity-70" | ||
| 134 | onPress={() => { | 136 | onPress={() => { |
| 135 | if (showScanner) { | 137 | if (showScanner) { |
| 136 | closeScanner(); | 138 | closeScanner(); |
| @@ -78,16 +78,11 @@ export default function HomeRouteScreen() { · 77 unmodified lines | |||
| 78 | </Stack.Toolbar> | 78 | </Stack.Toolbar> |
| 79 | 79 | ||
| 80 | <Stack.Toolbar placement="right"> | 80 | <Stack.Toolbar placement="right"> |
| 81 | <Stack.Toolbar.Menu icon="ellipsis.circle" separateBackground> | 81 | <Stack.Toolbar.Button |
| 82 | <Stack.Toolbar.Label>More</Stack.Toolbar.Label> | 82 | icon="gearshape" |
| 83 | <Stack.Toolbar.MenuAction | 83 | onPress={() => router.push("/settings")} |
| 84 | icon="desktopcomputer" | 84 | separateBackground |
| 85 | onPress={() => router.push("/connections")} | 85 | /> |
| 86 | subtitle="Manage connected hosts" | ||
| 87 | > | ||
| 88 | <Stack.Toolbar.Label>Environments</Stack.Toolbar.Label> | ||
| 89 | </Stack.Toolbar.MenuAction> | ||
| 90 | </Stack.Toolbar.Menu> | ||
| 91 | </Stack.Toolbar> | 86 | </Stack.Toolbar> |
| 92 | 87 | ||
| 93 | {/* Bottom toolbar: search + compose, visually split like iMessage */} | 88 | {/* Bottom toolbar: search + compose, visually split like iMessage */} |
| @@ -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 | } | ||
| @@ -0,0 +1 @@ | |||
| 1 | export { default } from "../connections/new"; | ||
| @@ -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 | } | ||
| @@ -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 | } | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | ); | ||
| @@ -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 | } | ||
| @@ -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 | } | ||
| @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; · 2 unmodified lines | |||
| 3 | import { mobileAuthClientMetadata, redactPairingCredential } from "./connection"; | 3 | import { mobileAuthClientMetadata, redactPairingCredential } from "./connection"; |
| 4 | 4 | ||
| 5 | vi.mock("./runtime", () => ({ | 5 | vi.mock("./runtime", () => ({ |
| 6 | mobileRemoteHttpRuntime: { | 6 | mobileRuntime: { |
| 7 | runPromise: vi.fn(), | 7 | runPromise: vi.fn(), |
| 8 | }, | 8 | }, |
| 9 | })); | 9 | })); |
| @@ -1,11 +1,14 @@ | |||
| 1 | import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { | 2 | import { |
| 3 | bootstrapRemoteBearerSession, | 3 | bootstrapRemoteBearerSession, |
| 4 | fetchRemoteEnvironmentDescriptor, | 4 | fetchRemoteEnvironmentDescriptor, |
| 5 | } from "@t3tools/client-runtime"; | 5 | } from "@t3tools/client-runtime"; |
| 6 | import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; | 6 | import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; |
| 7 | import { Platform } from "react-native"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import { mobileRemoteHttpRuntime } from "./runtime"; | 8 | import { mobileAuthClientMetadata } from "./authClientMetadata"; |
| 9 | import { mobileRuntime } from "./runtime"; | ||
| 10 | |||
| 11 | export { mobileAuthClientMetadata } from "./authClientMetadata"; | ||
| 9 | 12 | ||
| 10 | export interface RemoteConnectionInput { | 13 | export interface RemoteConnectionInput { |
| 11 | readonly pairingUrl: string; | 14 | readonly pairingUrl: string; |
| @@ -18,7 +21,9 @@ export interface SavedRemoteConnection { · 6 unmodified lines | |||
| 18 | readonly displayUrl: string; | 21 | readonly displayUrl: string; |
| 19 | readonly httpBaseUrl: string; | 22 | readonly httpBaseUrl: string; |
| 20 | readonly wsBaseUrl: string; | 23 | readonly wsBaseUrl: string; |
| 21 | readonly bearerToken: string; | 24 | readonly bearerToken: string | null; |
| 25 | readonly authenticationMethod?: "bearer" | "dpop"; | ||
| 26 | readonly dpopAccessToken?: string; | ||
| 22 | } | 27 | } |
| 23 | 28 | ||
| 24 | export type RemoteClientConnectionState = | 29 | export type RemoteClientConnectionState = |
| @@ -37,14 +42,6 @@ export function redactPairingCredential(pairingUrl: string): string { · 12 unmodified lines | |||
| 37 | } | 42 | } |
| 38 | } | 43 | } |
| 39 | 44 | ||
| 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 | |||
| 48 | export async function bootstrapRemoteConnection( | 45 | export async function bootstrapRemoteConnection( |
| 49 | input: RemoteConnectionInput, | 46 | input: RemoteConnectionInput, |
| 50 | ): Promise<SavedRemoteConnection> { | 47 | ): Promise<SavedRemoteConnection> { |
| @@ -52,18 +49,20 @@ export async function bootstrapRemoteConnection( · 1 unmodified line | |||
| 52 | pairingUrl: input.pairingUrl, | 49 | pairingUrl: input.pairingUrl, |
| 53 | }); | 50 | }); |
| 54 | 51 | ||
| 55 | const descriptor = await mobileRemoteHttpRuntime.runPromise( | 52 | const { descriptor, bootstrap } = await mobileRuntime.runPromise( |
| 56 | fetchRemoteEnvironmentDescriptor({ | 53 | Effect.all( |
| 57 | httpBaseUrl: target.httpBaseUrl, | 54 | { |
| 58 | }), | 55 | descriptor: fetchRemoteEnvironmentDescriptor({ |
| 59 | ); | 56 | httpBaseUrl: target.httpBaseUrl, |
| 60 | 57 | }), | |
| 61 | const bootstrap = await mobileRemoteHttpRuntime.runPromise( | 58 | bootstrap: bootstrapRemoteBearerSession({ |
| 62 | bootstrapRemoteBearerSession({ | 59 | httpBaseUrl: target.httpBaseUrl, |
| 63 | httpBaseUrl: target.httpBaseUrl, | 60 | credential: target.credential, |
| 64 | credential: target.credential, | 61 | clientMetadata: mobileAuthClientMetadata(), |
| 65 | clientMetadata: mobileAuthClientMetadata(), | 62 | }), |
| 66 | }), | 63 | }, |
| 64 | { concurrency: "unbounded" }, | ||
| 65 | ), | ||
| 67 | ); | 66 | ); |
| 68 | 67 | ||
| 69 | return { | 68 | return { |
| @@ -74,5 +73,6 @@ export async function bootstrapRemoteConnection( · 4 unmodified lines | |||
| 74 | httpBaseUrl: target.httpBaseUrl, | 73 | httpBaseUrl: target.httpBaseUrl, |
| 75 | wsBaseUrl: target.wsBaseUrl, | 74 | wsBaseUrl: target.wsBaseUrl, |
| 76 | bearerToken: bootstrap.access_token, | 75 | bearerToken: bootstrap.access_token, |
| 76 | authenticationMethod: "bearer", | ||
| 77 | }; | 77 | }; |
| 78 | } | 78 | } |
| @@ -1,5 +1,26 @@ | |||
| 1 | import Constants from "expo-constants"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 1 | import * as ManagedRuntime from "effect/ManagedRuntime"; | 3 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 2 | 4 | ||
| 3 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | 5 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; |
| 4 | 6 | ||
| 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 | ); | ||
| @@ -9,6 +9,7 @@ import type { SavedRemoteConnection } from "./connection"; · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | const CONNECTIONS_KEY = "t3code.connections"; | 10 | const CONNECTIONS_KEY = "t3code.connections"; |
| 11 | const PREFERENCES_KEY = "t3code.preferences"; | 11 | const PREFERENCES_KEY = "t3code.preferences"; |
| 12 | const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; | ||
| 12 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | 13 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; |
| 13 | const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; | 14 | const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; |
| 14 | 15 | ||
| @@ -20,6 +21,7 @@ export interface CachedShellSnapshot { · 5 unmodified lines | |||
| 20 | } | 21 | } |
| 21 | 22 | ||
| 22 | export interface MobilePreferences { | 23 | export interface MobilePreferences { |
| 24 | readonly liveActivitiesEnabled?: boolean; | ||
| 23 | readonly terminalFontSize?: number; | 25 | readonly terminalFontSize?: number; |
| 24 | } | 26 | } |
| 25 | 27 | ||
| @@ -133,7 +135,12 @@ export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteC · 107 unmodified lines | |||
| 133 | 135 | ||
| 134 | return pipe( | 136 | return pipe( |
| 135 | parsed.connections ?? [], | 137 | parsed.connections ?? [], |
| 136 | Arr.filter((c) => !!c.environmentId && !!c.bearerToken?.trim()), | 138 | Arr.filter( |
| 139 | (c) => | ||
| 140 | !!c.environmentId && | ||
| 141 | (!!c.bearerToken?.trim() || | ||
| 142 | (c.authenticationMethod === "dpop" && !!c.dpopAccessToken?.trim())), | ||
| 143 | ), | ||
| 137 | ); | 144 | ); |
| 138 | } | 145 | } |
| 139 | 146 | ||
| @@ -164,11 +171,19 @@ export async function loadPreferences(): Promise<MobilePreferences> { · 24 unmodified lines | |||
| 164 | return {}; | 171 | return {}; |
| 165 | } | 172 | } |
| 166 | 173 | ||
| 174 | const preferences: { | ||
| 175 | liveActivitiesEnabled?: boolean; | ||
| 176 | terminalFontSize?: number; | ||
| 177 | } = {}; | ||
| 178 | |||
| 179 | if (typeof parsed.liveActivitiesEnabled === "boolean") { | ||
| 180 | preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; | ||
| 181 | } | ||
| 167 | if (typeof parsed.terminalFontSize === "number") { | 182 | if (typeof parsed.terminalFontSize === "number") { |
| 168 | return { terminalFontSize: parsed.terminalFontSize }; | 183 | preferences.terminalFontSize = parsed.terminalFontSize; |
| 169 | } | 184 | } |
| 170 | 185 | ||
| 171 | return {}; | 186 | return preferences; |
| 172 | } | 187 | } |
| 173 | 188 | ||
| 174 | export async function savePreferencesPatch( | 189 | export async function savePreferencesPatch( |
| @@ -182,3 +197,20 @@ export async function savePreferencesPatch( · 7 unmodified lines | |||
| 182 | await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); | 197 | await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); |
| 183 | return next; | 198 | return next; |
| 184 | } | 199 | } |
| 200 | |||
| 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 | } | ||
| @@ -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 | }); | ||
| @@ -9,11 +9,16 @@ import { · 8 unmodified lines | |||
| 9 | createKnownEnvironment, | 9 | createKnownEnvironment, |
| 10 | createWsRpcClient, | 10 | createWsRpcClient, |
| 11 | EnvironmentConnectionState, | 11 | EnvironmentConnectionState, |
| 12 | ManagedRelayDpopSigner, | ||
| 12 | WsTransport, | 13 | WsTransport, |
| 14 | remoteEndpointUrl, | ||
| 15 | resolveRemoteDpopWebSocketConnectionUrl, | ||
| 13 | resolveRemoteWebSocketConnectionUrl, | 16 | resolveRemoteWebSocketConnectionUrl, |
| 14 | } from "@t3tools/client-runtime"; | 17 | } from "@t3tools/client-runtime"; |
| 15 | import type { EnvironmentId } from "@t3tools/contracts"; | 18 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 16 | import * as Arr from "effect/Array"; | 19 | import * as Arr from "effect/Array"; |
| 20 | import * as Duration from "effect/Duration"; | ||
| 21 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Order from "effect/Order"; | 22 | import * as Order from "effect/Order"; |
| 18 | import * as Option from "effect/Option"; | 23 | import * as Option from "effect/Option"; |
| 19 | import { pipe } from "effect/Function"; | 24 | import { pipe } from "effect/Function"; |
| @@ -29,7 +34,7 @@ import { · 9 unmodified lines | |||
| 29 | saveConnection, | 34 | saveConnection, |
| 30 | } from "../lib/storage"; | 35 | } from "../lib/storage"; |
| 31 | import { appAtomRegistry } from "./atom-registry"; | 36 | import { appAtomRegistry } from "./atom-registry"; |
| 32 | import { mobileRemoteHttpRuntime } from "../lib/runtime"; | 37 | import { mobileRuntime } from "../lib/runtime"; |
| 33 | import { | 38 | import { |
| 34 | drainEnvironmentSessions, | 39 | drainEnvironmentSessions, |
| 35 | notifyEnvironmentConnectionListeners, | 40 | notifyEnvironmentConnectionListeners, |
| @@ -41,6 +46,11 @@ import { · 5 unmodified lines | |||
| 41 | invalidateSourceControlDiscoveryForEnvironment, | 46 | invalidateSourceControlDiscoveryForEnvironment, |
| 42 | resetSourceControlDiscoveryState, | 47 | resetSourceControlDiscoveryState, |
| 43 | } from "./use-source-control-discovery"; | 48 | } from "./use-source-control-discovery"; |
| 49 | import { | ||
| 50 | startAgentAwarenessForEnvironment, | ||
| 51 | stopAgentAwarenessForEnvironment, | ||
| 52 | stopAllAgentAwareness, | ||
| 53 | } from "../features/agent-awareness/shellLiveActivitySync"; | ||
| 44 | import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; | 54 | import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; |
| 45 | import { | 55 | import { |
| 46 | clearCachedShellSnapshotMetadata, | 56 | clearCachedShellSnapshotMetadata, |
| @@ -153,223 +163,265 @@ function setEnvironmentConnectionStatus( · 106 unmodified lines | |||
| 153 | })); | 163 | })); |
| 154 | } | 164 | } |
| 155 | 165 | ||
| 156 | function withTimeout<T>( | 166 | function fromPromise<T>(tryPromise: () => Promise<T>): Effect.Effect<T, unknown> { |
| 157 | promise: Promise<T>, | 167 | return Effect.tryPromise({ |
| 158 | timeoutMs: number, | 168 | try: tryPromise, |
| 159 | timeoutMessage: string, | 169 | catch: (cause) => cause, |
| 160 | ): Promise<T> { | ||
| 161 | return new Promise<T>((resolve, reject) => { | ||
| 162 | const timeoutId = setTimeout(() => { | ||
| 163 | reject(new Error(timeoutMessage)); | ||
| 164 | }, timeoutMs); | ||
| 165 | |||
| 166 | promise.then( | ||
| 167 | (value) => { | ||
| 168 | clearTimeout(timeoutId); | ||
| 169 | resolve(value); | ||
| 170 | }, | ||
| 171 | (error: unknown) => { | ||
| 172 | clearTimeout(timeoutId); | ||
| 173 | reject(error); | ||
| 174 | }, | ||
| 175 | ); | ||
| 176 | }); | 170 | }); |
| 177 | } | 171 | } |
| 178 | 172 | ||
| 179 | export async function disconnectEnvironment( | 173 | export function disconnectEnvironment( |
| 180 | environmentId: EnvironmentId, | 174 | environmentId: EnvironmentId, |
| 181 | options?: { | 175 | options?: { |
| 182 | readonly preserveShellSnapshot?: boolean; | 176 | readonly preserveShellSnapshot?: boolean; |
| 183 | readonly removeSaved?: boolean; | 177 | readonly removeSaved?: boolean; |
| 184 | readonly preserveConnectionAttempt?: boolean; | 178 | readonly preserveConnectionAttempt?: boolean; |
| 185 | }, | 179 | }, |
| 186 | ) { | 180 | ): Effect.Effect<void, unknown> { |
| 187 | if (!options?.preserveConnectionAttempt) { | 181 | return Effect.gen(function* () { |
| 188 | environmentConnectionAttempts.cancel(environmentId); | 182 | if (!options?.preserveConnectionAttempt) { |
| 189 | } | 183 | environmentConnectionAttempts.cancel(environmentId); |
| 190 | 184 | } | |
| 191 | const session = removeEnvironmentSession(environmentId); | ||
| 192 | notifyEnvironmentConnectionListeners(); | ||
| 193 | await session?.connection.dispose(); | ||
| 194 | terminalMetadataUnsubscribers.get(environmentId)?.(); | ||
| 195 | terminalMetadataUnsubscribers.delete(environmentId); | ||
| 196 | if (!options?.preserveShellSnapshot) { | ||
| 197 | shellSnapshotManager.invalidate({ environmentId }); | ||
| 198 | } | ||
| 199 | invalidateSourceControlDiscoveryForEnvironment(environmentId); | ||
| 200 | terminalSessionManager.invalidateEnvironment(environmentId); | ||
| 201 | environmentRuntimeManager.invalidate({ environmentId }); | ||
| 202 | |||
| 203 | if (options?.removeSaved) { | ||
| 204 | await clearSavedConnection(environmentId); | ||
| 205 | await clearCachedShellSnapshot(environmentId); | ||
| 206 | clearCachedShellSnapshotMetadata(environmentId); | ||
| 207 | removeSavedConnection(environmentId); | ||
| 208 | } | ||
| 209 | } | ||
| 210 | |||
| 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; | ||
| 217 | 185 | ||
| 218 | await disconnectEnvironment(connection.environmentId, { | 186 | const session = removeEnvironmentSession(environmentId); |
| 219 | preserveShellSnapshot: true, | 187 | notifyEnvironmentConnectionListeners(); |
| 220 | preserveConnectionAttempt: true, | 188 | if (session) { |
| 189 | yield* fromPromise(() => session.connection.dispose()); | ||
| 190 | } | ||
| 191 | terminalMetadataUnsubscribers.get(environmentId)?.(); | ||
| 192 | terminalMetadataUnsubscribers.delete(environmentId); | ||
| 193 | stopAgentAwarenessForEnvironment(environmentId); | ||
| 194 | if (!options?.preserveShellSnapshot) { | ||
| 195 | shellSnapshotManager.invalidate({ environmentId }); | ||
| 196 | } | ||
| 197 | invalidateSourceControlDiscoveryForEnvironment(environmentId); | ||
| 198 | terminalSessionManager.invalidateEnvironment(environmentId); | ||
| 199 | environmentRuntimeManager.invalidate({ environmentId }); | ||
| 200 | |||
| 201 | if (options?.removeSaved) { | ||
| 202 | yield* Effect.all( | ||
| 203 | [ | ||
| 204 | fromPromise(() => clearSavedConnection(environmentId)), | ||
| 205 | fromPromise(() => clearCachedShellSnapshot(environmentId)), | ||
| 206 | ], | ||
| 207 | { concurrency: 2 }, | ||
| 208 | ); | ||
| 209 | clearCachedShellSnapshotMetadata(environmentId); | ||
| 210 | removeSavedConnection(environmentId); | ||
| 211 | } | ||
| 221 | }); | 212 | }); |
| 222 | if (!isCurrentAttempt()) { | 213 | } |
| 223 | return; | ||
| 224 | } | ||
| 225 | 214 | ||
| 226 | if (options?.persist !== false) { | 215 | export function connectSavedEnvironment( |
| 227 | await saveConnection(connection); | 216 | connection: SavedRemoteConnection, |
| 217 | options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean }, | ||
| 218 | ): Effect.Effect<void, unknown> { | ||
| 219 | return Effect.gen(function* () { | ||
| 220 | const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); | ||
| 221 | const isCurrentAttempt = connectionAttempt.isCurrent; | ||
| 222 | |||
| 223 | yield* disconnectEnvironment(connection.environmentId, { | ||
| 224 | preserveShellSnapshot: true, | ||
| 225 | preserveConnectionAttempt: true, | ||
| 226 | }); | ||
| 228 | if (!isCurrentAttempt()) { | 227 | if (!isCurrentAttempt()) { |
| 229 | return; | 228 | return; |
| 230 | } | 229 | } |
| 231 | } | ||
| 232 | 230 | ||
| 233 | upsertSavedConnection(connection); | 231 | if (options?.persist !== false) { |
| 234 | setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); | 232 | yield* fromPromise(() => saveConnection(connection)); |
| 235 | shellSnapshotManager.markPending({ environmentId: connection.environmentId }); | 233 | if (!isCurrentAttempt()) { |
| 234 | return; | ||
| 235 | } | ||
| 236 | } | ||
| 236 | 237 | ||
| 237 | const transport = new WsTransport( | 238 | upsertSavedConnection(connection); |
| 238 | () => | 239 | setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); |
| 239 | mobileRemoteHttpRuntime.runPromise( | 240 | shellSnapshotManager.markPending({ environmentId: connection.environmentId }); |
| 240 | resolveRemoteWebSocketConnectionUrl({ | 241 | |
| 241 | wsBaseUrl: connection.wsBaseUrl, | 242 | const dpopAccessToken = |
| 242 | httpBaseUrl: connection.httpBaseUrl, | 243 | connection.authenticationMethod === "dpop" ? connection.dpopAccessToken : undefined; |
| 243 | bearerToken: connection.bearerToken, | 244 | const transport = new WsTransport( |
| 244 | }), | 245 | () => |
| 245 | ), | 246 | mobileRuntime.runPromise( |
| 246 | { | 247 | dpopAccessToken |
| 247 | onAttempt: () => { | 248 | ? Effect.gen(function* () { |
| 248 | if (!isCurrentAttempt()) { | 249 | const signer = yield* ManagedRelayDpopSigner; |
| 249 | return; | 250 | const dpop = yield* signer.createProof({ |
| 250 | } | 251 | method: "POST", |
| 252 | url: remoteEndpointUrl(connection.httpBaseUrl, "/api/auth/websocket-ticket"), | ||
| 253 | accessToken: dpopAccessToken, | ||
| 254 | }); | ||
| 255 | return yield* resolveRemoteDpopWebSocketConnectionUrl({ | ||
| 256 | wsBaseUrl: connection.wsBaseUrl, | ||
| 257 | httpBaseUrl: connection.httpBaseUrl, | ||
| 258 | accessToken: dpopAccessToken, | ||
| 259 | dpopProof: dpop, | ||
| 260 | }); | ||
| 261 | }) | ||
| 262 | : resolveRemoteWebSocketConnectionUrl({ | ||
| 263 | wsBaseUrl: connection.wsBaseUrl, | ||
| 264 | httpBaseUrl: connection.httpBaseUrl, | ||
| 265 | bearerToken: connection.bearerToken ?? "", | ||
| 266 | }), | ||
| 267 | ), | ||
| 268 | { | ||
| 269 | onAttempt: () => { | ||
| 270 | if (!isCurrentAttempt()) { | ||
| 271 | return; | ||
| 272 | } | ||
| 273 | |||
| 274 | environmentRuntimeManager.patch( | ||
| 275 | { environmentId: connection.environmentId }, | ||
| 276 | (previous) => { | ||
| 277 | const nextState = | ||
| 278 | previous.connectionState === "ready" || previous.connectionState === "reconnecting" | ||
| 279 | ? "reconnecting" | ||
| 280 | : "connecting"; | ||
| 281 | const keepSettledFailure = | ||
| 282 | previous.connectionState === "disconnected" && previous.connectionError !== null; | ||
| 283 | return { | ||
| 284 | ...previous, | ||
| 285 | connectionState: keepSettledFailure ? "disconnected" : nextState, | ||
| 286 | connectionError: keepSettledFailure ? previous.connectionError : null, | ||
| 287 | }; | ||
| 288 | }, | ||
| 289 | ); | ||
| 290 | }, | ||
| 291 | onError: (message) => { | ||
| 292 | if (isCurrentAttempt()) { | ||
| 293 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); | ||
| 294 | } | ||
| 295 | }, | ||
| 296 | onClose: (details) => { | ||
| 297 | if (!isCurrentAttempt()) { | ||
| 298 | return; | ||
| 299 | } | ||
| 251 | 300 | ||
| 252 | environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => { | 301 | const reason = |
| 253 | const nextState = | 302 | details.reason.trim().length > 0 |
| 254 | previous.connectionState === "ready" || previous.connectionState === "reconnecting" | 303 | ? details.reason |
| 255 | ? "reconnecting" | 304 | : details.code === 1000 |
| 256 | : "connecting"; | 305 | ? null |
| 257 | const keepSettledFailure = | 306 | : `Remote connection closed (${details.code}).`; |
| 258 | previous.connectionState === "disconnected" && previous.connectionError !== null; | 307 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); |
| 259 | return { | 308 | }, |
| 260 | ...previous, | ||
| 261 | connectionState: keepSettledFailure ? "disconnected" : nextState, | ||
| 262 | connectionError: keepSettledFailure ? previous.connectionError : null, | ||
| 263 | }; | ||
| 264 | }); | ||
| 265 | }, | 309 | }, |
| 266 | onError: (message) => { | 310 | ); |
| 311 | |||
| 312 | const client = createWsRpcClient(transport); | ||
| 313 | const environmentConnection = createEnvironmentConnection({ | ||
| 314 | kind: "saved", | ||
| 315 | knownEnvironment: { | ||
| 316 | ...createKnownEnvironment({ | ||
| 317 | id: connection.environmentId, | ||
| 318 | label: connection.environmentLabel, | ||
| 319 | source: "manual", | ||
| 320 | target: { | ||
| 321 | httpBaseUrl: connection.httpBaseUrl, | ||
| 322 | wsBaseUrl: connection.wsBaseUrl, | ||
| 323 | }, | ||
| 324 | }), | ||
| 325 | environmentId: connection.environmentId, | ||
| 326 | }, | ||
| 327 | client, | ||
| 328 | applyShellEvent: (event, environmentId) => { | ||
| 267 | if (isCurrentAttempt()) { | 329 | if (isCurrentAttempt()) { |
| 268 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); | 330 | shellSnapshotManager.applyEvent({ environmentId }, event); |
| 269 | } | 331 | } |
| 270 | }, | 332 | }, |
| 271 | onClose: (details) => { | 333 | syncShellSnapshot: (snapshot, environmentId) => { |
| 272 | if (!isCurrentAttempt()) { | 334 | if (!isCurrentAttempt()) { |
| 273 | return; | 335 | return; |
| 274 | } | 336 | } |
| 275 | 337 | ||
| 276 | const reason = | 338 | shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); |
| 277 | details.reason.trim().length > 0 | 339 | markShellSnapshotLive(environmentId); |
| 278 | ? details.reason | 340 | void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined); |
| 279 | : details.code === 1000 | 341 | environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ |
| 280 | ? null | ||
| 281 | : `Remote connection closed (${details.code}).`; | ||
| 282 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); | ||
| 283 | }, | ||
| 284 | }, | ||
| 285 | ); | ||
| 286 | |||
| 287 | const client = createWsRpcClient(transport); | ||
| 288 | const environmentConnection = createEnvironmentConnection({ | ||
| 289 | kind: "saved", | ||
| 290 | knownEnvironment: { | ||
| 291 | ...createKnownEnvironment({ | ||
| 292 | id: connection.environmentId, | ||
| 293 | label: connection.environmentLabel, | ||
| 294 | source: "manual", | ||
| 295 | target: { | ||
| 296 | httpBaseUrl: connection.httpBaseUrl, | ||
| 297 | wsBaseUrl: connection.wsBaseUrl, | ||
| 298 | }, | ||
| 299 | }), | ||
| 300 | environmentId: connection.environmentId, | ||
| 301 | }, | ||
| 302 | client, | ||
| 303 | applyShellEvent: (event, environmentId) => { | ||
| 304 | if (isCurrentAttempt()) { | ||
| 305 | shellSnapshotManager.applyEvent({ environmentId }, event); | ||
| 306 | } | ||
| 307 | }, | ||
| 308 | syncShellSnapshot: (snapshot, environmentId) => { | ||
| 309 | if (!isCurrentAttempt()) { | ||
| 310 | return; | ||
| 311 | } | ||
| 312 | |||
| 313 | shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); | ||
| 314 | markShellSnapshotLive(environmentId); | ||
| 315 | void saveCachedShellSnapshot(environmentId, snapshot); | ||
| 316 | environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ | ||
| 317 | ...runtime, | ||
| 318 | connectionState: "ready", | ||
| 319 | connectionError: null, | ||
| 320 | })); | ||
| 321 | }, | ||
| 322 | onShellResubscribe: (environmentId) => { | ||
| 323 | if (isCurrentAttempt()) { | ||
| 324 | shellSnapshotManager.markPending({ environmentId }); | ||
| 325 | } | ||
| 326 | }, | ||
| 327 | onConfigSnapshot: (serverConfig) => { | ||
| 328 | if (isCurrentAttempt()) { | ||
| 329 | environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (runtime) => ({ | ||
| 330 | ...runtime, | 342 | ...runtime, |
| 331 | serverConfig, | 343 | connectionState: "ready", |
| 344 | connectionError: null, | ||
| 332 | })); | 345 | })); |
| 333 | } | 346 | }, |
| 334 | }, | 347 | onShellResubscribe: (environmentId) => { |
| 335 | }); | 348 | if (isCurrentAttempt()) { |
| 349 | shellSnapshotManager.markPending({ environmentId }); | ||
| 350 | } | ||
| 351 | }, | ||
| 352 | onConfigSnapshot: (serverConfig) => { | ||
| 353 | if (isCurrentAttempt()) { | ||
| 354 | environmentRuntimeManager.patch( | ||
| 355 | { environmentId: connection.environmentId }, | ||
| 356 | (runtime) => ({ | ||
| 357 | ...runtime, | ||
| 358 | serverConfig, | ||
| 359 | }), | ||
| 360 | ); | ||
| 361 | } | ||
| 362 | }, | ||
| 363 | }); | ||
| 336 | 364 | ||
| 337 | if (!isCurrentAttempt()) { | 365 | if (!isCurrentAttempt()) { |
| 338 | await environmentConnection.dispose(); | 366 | yield* fromPromise(() => environmentConnection.dispose()); |
| 339 | return; | 367 | return; |
| 340 | } | 368 | } |
| 341 | 369 | ||
| 342 | setEnvironmentSession(connection.environmentId, { | 370 | setEnvironmentSession(connection.environmentId, { |
| 343 | client, | ||
| 344 | connection: environmentConnection, | ||
| 345 | }); | ||
| 346 | terminalMetadataUnsubscribers.set( | ||
| 347 | connection.environmentId, | ||
| 348 | subscribeTerminalMetadata({ | ||
| 349 | environmentId: connection.environmentId, | ||
| 350 | client, | 371 | client, |
| 351 | }), | 372 | connection: environmentConnection, |
| 352 | ); | 373 | }); |
| 353 | terminalDebugLog("registry:terminal-metadata-subscribed", { | 374 | |
| 354 | environmentId: connection.environmentId, | 375 | const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe( |
| 355 | }); | 376 | Effect.timeoutOption(Duration.millis(SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS)), |
| 356 | notifyEnvironmentConnectionListeners(); | 377 | Effect.flatMap((result) => |
| 357 | 378 | Option.match(result, { | |
| 358 | try { | 379 | onNone: () => |
| 359 | await withTimeout( | 380 | Effect.fail(new Error("Environment did not respond before the connection timeout.")), |
| 360 | environmentConnection.ensureBootstrapped(), | 381 | onSome: Effect.succeed, |
| 361 | SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS, | 382 | }), |
| 362 | "Environment did not respond before the connection timeout.", | 383 | ), |
| 384 | Effect.tapError((error: unknown) => | ||
| 385 | isCurrentAttempt() | ||
| 386 | ? Effect.gen(function* () { | ||
| 387 | setEnvironmentConnectionStatus( | ||
| 388 | connection.environmentId, | ||
| 389 | "disconnected", | ||
| 390 | error instanceof Error ? error.message : "Failed to bootstrap remote connection.", | ||
| 391 | ); | ||
| 392 | const pendingSession = removeEnvironmentSession(connection.environmentId); | ||
| 393 | notifyEnvironmentConnectionListeners(); | ||
| 394 | if (pendingSession) { | ||
| 395 | yield* fromPromise(() => pendingSession.connection.dispose()); | ||
| 396 | } | ||
| 397 | }) | ||
| 398 | : Effect.void, | ||
| 399 | ), | ||
| 363 | ); | 400 | ); |
| 364 | } catch (error) { | 401 | const bootstrapped = yield* options?.suppressBootstrapError |
| 365 | if (isCurrentAttempt()) { | 402 | ? bootstrap.pipe( |
| 366 | setEnvironmentConnectionStatus( | 403 | Effect.as(true), |
| 367 | connection.environmentId, | 404 | Effect.catch(() => Effect.succeed(false)), |
| 368 | "disconnected", | 405 | ) |
| 369 | error instanceof Error ? error.message : "Failed to bootstrap remote connection.", | 406 | : bootstrap.pipe(Effect.as(true)); |
| 370 | ); | 407 | |
| 408 | if (!bootstrapped || !isCurrentAttempt()) { | ||
| 409 | return; | ||
| 371 | } | 410 | } |
| 372 | } | 411 | |
| 412 | terminalMetadataUnsubscribers.set( | ||
| 413 | connection.environmentId, | ||
| 414 | subscribeTerminalMetadata({ | ||
| 415 | environmentId: connection.environmentId, | ||
| 416 | client, | ||
| 417 | }), | ||
| 418 | ); | ||
| 419 | terminalDebugLog("registry:terminal-metadata-subscribed", { | ||
| 420 | environmentId: connection.environmentId, | ||
| 421 | }); | ||
| 422 | startAgentAwarenessForEnvironment(connection); | ||
| 423 | notifyEnvironmentConnectionListeners(); | ||
| 424 | }); | ||
| 373 | } | 425 | } |
| 374 | 426 | ||
| 375 | const environmentsSortOrder = Order.mapInput( | 427 | const environmentsSortOrder = Order.mapInput( |
| @@ -404,8 +456,9 @@ export function useRemoteEnvironmentBootstrap() { · 28 unmodified lines | |||
| 404 | useEffect(() => { | 456 | useEffect(() => { |
| 405 | let cancelled = false; | 457 | let cancelled = false; |
| 406 | 458 | ||
| 407 | void loadSavedConnections() | 459 | void (async () => { |
| 408 | .then((connections) => { | 460 | try { |
| 461 | const connections = await loadSavedConnections(); | ||
| 409 | if (cancelled) { | 462 | if (cancelled) { |
| 410 | return; | 463 | return; |
| 411 | } | 464 | } |
| @@ -418,34 +471,36 @@ export function useRemoteEnvironmentBootstrap() { · 6 unmodified lines | |||
| 418 | 471 | ||
| 419 | setIsLoadingSavedConnection(false); | 472 | setIsLoadingSavedConnection(false); |
| 420 | 473 | ||
| 421 | void (async () => { | 474 | await Promise.all( |
| 422 | await Promise.all( | 475 | connections.map(async (connection) => { |
| 423 | connections.map(async (connection) => { | 476 | const cached = await loadCachedShellSnapshot(connection.environmentId); |
| 424 | const cached = await loadCachedShellSnapshot(connection.environmentId); | 477 | if (!cancelled && cached) { |
| 425 | if (!cancelled && cached) { | 478 | hydrateCachedShellSnapshot(cached); |
| 426 | hydrateCachedShellSnapshot(cached); | 479 | } |
| 427 | } | 480 | }), |
| 428 | }), | 481 | ); |
| 429 | ); | ||
| 430 | 482 | ||
| 431 | if (cancelled) { | 483 | if (cancelled) { |
| 432 | return; | 484 | return; |
| 433 | } | 485 | } |
| 434 | 486 | ||
| 435 | await Promise.all( | 487 | await mobileRuntime.runPromise( |
| 488 | Effect.all( | ||
| 436 | connections.map((connection) => | 489 | connections.map((connection) => |
| 437 | connectSavedEnvironment(connection, { | 490 | connectSavedEnvironment(connection, { |
| 438 | persist: false, | 491 | persist: false, |
| 492 | suppressBootstrapError: true, | ||
| 439 | }), | 493 | }), |
| 440 | ), | 494 | ), |
| 441 | ); | 495 | { concurrency: "unbounded" }, |
| 442 | })(); | 496 | ), |
| 443 | }) | 497 | ); |
| 444 | .catch(() => { | 498 | } catch { |
| 445 | if (!cancelled) { | 499 | if (!cancelled) { |
| 446 | setIsLoadingSavedConnection(false); | 500 | setIsLoadingSavedConnection(false); |
| 447 | } | 501 | } |
| 448 | }); | 502 | } |
| 503 | })(); | ||
| 449 | 504 | ||
| 450 | return () => { | 505 | return () => { |
| 451 | cancelled = true; | 506 | cancelled = true; |
| @@ -457,6 +512,7 @@ export function useRemoteEnvironmentBootstrap() { · 5 unmodified lines | |||
| 457 | } | 512 | } |
| 458 | terminalMetadataUnsubscribers.clear(); | 513 | terminalMetadataUnsubscribers.clear(); |
| 459 | environmentConnectionAttempts.clear(); | 514 | environmentConnectionAttempts.clear(); |
| 515 | stopAllAgentAwareness(); | ||
| 460 | environmentRuntimeManager.invalidate(); | 516 | environmentRuntimeManager.invalidate(); |
| 461 | shellSnapshotManager.invalidate(); | 517 | shellSnapshotManager.invalidate(); |
| 462 | resetSourceControlDiscoveryState(); | 518 | resetSourceControlDiscoveryState(); |
| @@ -538,7 +594,7 @@ export function useRemoteConnections() { · 75 unmodified lines | |||
| 538 | const nextPairingUrl = pairingUrl ?? connectionPairingUrl; | 594 | const nextPairingUrl = pairingUrl ?? connectionPairingUrl; |
| 539 | const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); | 595 | const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); |
| 540 | clearPendingConnectionError(); | 596 | clearPendingConnectionError(); |
| 541 | await connectSavedEnvironment(connection); | 597 | await mobileRuntime.runPromise(connectSavedEnvironment(connection)); |
| 542 | clearConnectionPairingUrl(); | 598 | clearConnectionPairingUrl(); |
| 543 | } catch (error) { | 599 | } catch (error) { |
| 544 | setPendingConnectionError( | 600 | setPendingConnectionError( |
| @@ -577,7 +633,14 @@ export function useRemoteConnections() { · 32 unmodified lines | |||
| 577 | if (!connection) { | 633 | if (!connection) { |
| 578 | return; | 634 | return; |
| 579 | } | 635 | } |
| 580 | void connectSavedEnvironment(connection, { persist: false }); | 636 | void mobileRuntime |
| 637 | .runPromise( | ||
| 638 | connectSavedEnvironment(connection, { | ||
| 639 | persist: false, | ||
| 640 | suppressBootstrapError: true, | ||
| 641 | }), | ||
| 642 | ) | ||
| 643 | .catch(() => undefined); | ||
| 581 | }, []); | 644 | }, []); |
| 582 | 645 | ||
| 583 | const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { | 646 | const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { |
| @@ -595,7 +658,9 @@ export function useRemoteConnections() { · 11 unmodified lines | |||
| 595 | text: "Remove", | 658 | text: "Remove", |
| 596 | style: "destructive", | 659 | style: "destructive", |
| 597 | onPress: () => { | 660 | onPress: () => { |
| 598 | void disconnectEnvironment(environmentId, { removeSaved: true }); | 661 | void mobileRuntime |
| 662 | .runPromise(disconnectEnvironment(environmentId, { removeSaved: true })) | ||
| 663 | .catch(() => undefined); | ||
| 599 | }, | 664 | }, |
| 600 | }, | 665 | }, |
| 601 | ], | 666 | ], |
| @@ -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); | ||
| @@ -77,6 +77,7 @@ import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; · 76 unmodified lines | |||
| 77 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; | 77 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; |
| 78 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; | 78 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; |
| 79 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; | 79 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| 80 | import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; | ||
| 80 | 81 | ||
| 81 | const decodeCodexSettings = Schema.decodeEffect(CodexSettings); | 82 | const decodeCodexSettings = Schema.decodeEffect(CodexSettings); |
| 82 | 83 | ||
| @@ -364,6 +365,12 @@ export const makeOrchestrationIntegrationHarness = ( · 281 unmodified lines | |||
| 364 | drain: Effect.void, | 365 | drain: Effect.void, |
| 365 | }), | 366 | }), |
| 366 | ), | 367 | ), |
| 368 | Layer.provideMerge( | ||
| 369 | Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { | ||
| 370 | publishThread: () => Effect.void, | ||
| 371 | start: () => Effect.void, | ||
| 372 | }), | ||
| 373 | ), | ||
| 367 | ); | 374 | ); |
| 368 | const layer = Layer.empty.pipe( | 375 | const layer = Layer.empty.pipe( |
| 369 | Layer.provideMerge(runtimeServicesLayer), | 376 | Layer.provideMerge(runtimeServicesLayer), |
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | |||
| 19 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 20 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | 20 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 21 | import * as Context from "effect/Context"; | 21 | import * as Context from "effect/Context"; |
| 22 | import * as Crypto from "effect/Crypto"; | ||
| 22 | import * as Data from "effect/Data"; | 23 | import * as Data from "effect/Data"; |
| 23 | import * as DateTime from "effect/DateTime"; | 24 | import * as DateTime from "effect/DateTime"; |
| 24 | import * as Duration from "effect/Duration"; | 25 | import * as Duration from "effect/Duration"; |
| @@ -31,6 +32,7 @@ import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; · 6 unmodified lines | |||
| 31 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | 32 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 32 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 33 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 33 | import * as SessionStore from "./SessionStore.ts"; | 34 | import * as SessionStore from "./SessionStore.ts"; |
| 35 | import { verifyRequestDpopProof } from "./dpop.ts"; | ||
| 34 | import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; | 36 | import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; |
| 35 | 37 | ||
| 36 | export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; | 38 | export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; |
| @@ -61,6 +63,7 @@ export interface AuthenticatedSession { · 24 unmodified lines | |||
| 61 | readonly subject: string; | 63 | readonly subject: string; |
| 62 | readonly method: ServerAuthSessionMethod; | 64 | readonly method: ServerAuthSessionMethod; |
| 63 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 65 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 66 | readonly proofKeyThumbprint?: string; | ||
| 64 | readonly expiresAt?: DateTime.DateTime; | 67 | readonly expiresAt?: DateTime.DateTime; |
| 65 | } | 68 | } |
| 66 | 69 | ||
| @@ -107,6 +110,9 @@ export interface EnvironmentAuthShape { · 40 unmodified lines | |||
| 107 | credential: string, | 110 | credential: string, |
| 108 | requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined, | 111 | requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined, |
| 109 | requestMetadata: AuthClientMetadata, | 112 | requestMetadata: AuthClientMetadata, |
| 113 | input?: { | ||
| 114 | readonly proofKeyThumbprint?: string; | ||
| 115 | }, | ||
| 110 | ) => Effect.Effect< | 116 | ) => Effect.Effect< |
| 111 | AuthAccessTokenResult, | 117 | AuthAccessTokenResult, |
| 112 | ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError | 118 | ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError |
| @@ -116,6 +122,7 @@ export interface EnvironmentAuthShape { · 3 unmodified lines | |||
| 116 | readonly label?: string; | 122 | readonly label?: string; |
| 117 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | 123 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 118 | readonly subject?: string; | 124 | readonly subject?: string; |
| 125 | readonly proofKeyThumbprint?: string; | ||
| 119 | }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>; | 126 | }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>; |
| 120 | readonly issuePairingCredential: ( | 127 | readonly issuePairingCredential: ( |
| 121 | input?: AuthCreatePairingCredentialInput, | 128 | input?: AuthCreatePairingCredentialInput, |
| @@ -184,6 +191,7 @@ type BootstrapExchangeResult = { · 62 unmodified lines | |||
| 184 | }; | 191 | }; |
| 185 | 192 | ||
| 186 | const AUTHORIZATION_PREFIX = "Bearer "; | 193 | const AUTHORIZATION_PREFIX = "Bearer "; |
| 194 | const DPOP_AUTHORIZATION_PREFIX = "DPoP "; | ||
| 187 | const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; | 195 | const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; |
| 188 | 196 | ||
| 189 | const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { | 197 | const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { |
| @@ -245,10 +253,21 @@ function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string · 55 unmodified lines | |||
| 245 | return token.length > 0 ? token : null; | 253 | return token.length > 0 ? token : null; |
| 246 | } | 254 | } |
| 247 | 255 | ||
| 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 | |||
| 248 | export const make = Effect.fn("makeEnvironmentAuth")(function* () { | 265 | export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 249 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | 266 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; |
| 250 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | 267 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 251 | const sessions = yield* SessionStore.SessionStore; | 268 | const sessions = yield* SessionStore.SessionStore; |
| 269 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | ||
| 270 | const crypto = yield* Crypto.Crypto; | ||
| 252 | const descriptor = yield* policy.getDescriptor(); | 271 | const descriptor = yield* policy.getDescriptor(); |
| 253 | 272 | ||
| 254 | const authenticateToken = ( | 273 | const authenticateToken = ( |
| @@ -270,6 +289,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 15 unmodified lines | |||
| 270 | subject: session.subject, | 289 | subject: session.subject, |
| 271 | method: session.method, | 290 | method: session.method, |
| 272 | scopes: session.scopes, | 291 | scopes: session.scopes, |
| 292 | ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), | ||
| 273 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | 293 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), |
| 274 | })), | 294 | })), |
| 275 | mapSessionVerificationErrors, | 295 | mapSessionVerificationErrors, |
| @@ -278,11 +298,43 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 2 unmodified lines | |||
| 278 | const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { | 298 | const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { |
| 279 | const cookieToken = request.cookies[sessions.cookieName]; | 299 | const cookieToken = request.cookies[sessions.cookieName]; |
| 280 | const bearerToken = parseBearerToken(request); | 300 | const bearerToken = parseBearerToken(request); |
| 281 | const credential = cookieToken ?? bearerToken; | 301 | const dpopToken = parseDpopToken(request); |
| 302 | const credential = cookieToken ?? bearerToken ?? dpopToken; | ||
| 282 | if (!credential) { | 303 | if (!credential) { |
| 283 | return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); | 304 | return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); |
| 284 | } | 305 | } |
| 285 | return authenticateToken(credential); | 306 | return authenticateToken(credential).pipe( |
| 307 | Effect.flatMap((session) => { | ||
| 308 | if (session.proofKeyThumbprint) { | ||
| 309 | if (!dpopToken || dpopToken !== credential) { | ||
| 310 | return Effect.fail( | ||
| 311 | new ServerAuthInvalidCredentialError({ | ||
| 312 | reason: "invalid_credential", | ||
| 313 | cause: "DPoP-bound access token requires DPoP authorization.", | ||
| 314 | }), | ||
| 315 | ); | ||
| 316 | } | ||
| 317 | return verifyRequestDpopProof({ | ||
| 318 | request, | ||
| 319 | expectedThumbprint: session.proofKeyThumbprint, | ||
| 320 | expectedAccessToken: dpopToken, | ||
| 321 | }).pipe( | ||
| 322 | Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), | ||
| 323 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 324 | Effect.as(session), | ||
| 325 | ); | ||
| 326 | } | ||
| 327 | if (dpopToken) { | ||
| 328 | return Effect.fail( | ||
| 329 | new ServerAuthInvalidCredentialError({ | ||
| 330 | reason: "invalid_credential", | ||
| 331 | cause: "DPoP authorization requires a proof-bound access token.", | ||
| 332 | }), | ||
| 333 | ); | ||
| 334 | } | ||
| 335 | return Effect.succeed(session); | ||
| 336 | }), | ||
| 337 | ); | ||
| 286 | }; | 338 | }; |
| 287 | 339 | ||
| 288 | const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => | 340 | const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => |
| @@ -349,8 +401,8 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 60 unmodified lines | |||
| 349 | ); | 401 | ); |
| 350 | 402 | ||
| 351 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = | 403 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = |
| 352 | (credential, requestedScopes, requestMetadata) => | 404 | (credential, requestedScopes, requestMetadata, input) => |
| 353 | bootstrapCredentials.consume(credential).pipe( | 405 | bootstrapCredentials.consume(credential, input).pipe( |
| 354 | Effect.mapError(toBootstrapExchangeError), | 406 | Effect.mapError(toBootstrapExchangeError), |
| 355 | Effect.flatMap((grant) => | 407 | Effect.flatMap((grant) => |
| 356 | Effect.gen(function* () { | 408 | Effect.gen(function* () { |
| @@ -362,9 +414,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 5 unmodified lines | |||
| 362 | } | 414 | } |
| 363 | return yield* sessions | 415 | return yield* sessions |
| 364 | .issue({ | 416 | .issue({ |
| 365 | method: "bearer-access-token", | 417 | method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", |
| 366 | subject: grant.subject, | 418 | subject: grant.subject, |
| 367 | scopes: grantedScopes, | 419 | scopes: grantedScopes, |
| 420 | ...(input?.proofKeyThumbprint | ||
| 421 | ? { | ||
| 422 | proofKeyThumbprint: input.proofKeyThumbprint, | ||
| 423 | ttl: Duration.hours(1), | ||
| 424 | } | ||
| 425 | : {}), | ||
| 368 | client: { | 426 | client: { |
| 369 | ...requestMetadata, | 427 | ...requestMetadata, |
| 370 | ...(grant.label ? { label: grant.label } : {}), | 428 | ...(grant.label ? { label: grant.label } : {}), |
| @@ -388,7 +446,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 17 unmodified lines | |||
| 388 | ({ | 446 | ({ |
| 389 | access_token: session.token, | 447 | access_token: session.token, |
| 390 | issued_token_type: AuthAccessTokenType, | 448 | issued_token_type: AuthAccessTokenType, |
| 391 | token_type: "Bearer", | 449 | token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", |
| 392 | expires_in: Math.max( | 450 | expires_in: Math.max( |
| 393 | 0, | 451 | 0, |
| 394 | Math.floor( | 452 | Math.floor( |
| @@ -434,6 +492,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { · 39 unmodified lines | |||
| 434 | subject: input?.subject ?? "one-time-token", | 492 | subject: input?.subject ?? "one-time-token", |
| 435 | ...(input?.ttl ? { ttl: input.ttl } : {}), | 493 | ...(input?.ttl ? { ttl: input.ttl } : {}), |
| 436 | ...(input?.label ? { label: input.label } : {}), | 494 | ...(input?.label ? { label: input.label } : {}), |
| 495 | ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), | ||
| 437 | }); | 496 | }); |
| 438 | return { | 497 | return { |
| 439 | id: issued.id, | 498 | id: issued.id, |
| @@ -39,7 +39,7 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { · 38 unmodified lines | |||
| 39 | const descriptor: ServerAuthDescriptor = { | 39 | const descriptor: ServerAuthDescriptor = { |
| 40 | policy, | 40 | policy, |
| 41 | bootstrapMethods, | 41 | bootstrapMethods, |
| 42 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | 42 | sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"], |
| 43 | sessionCookieName: resolveSessionCookieName({ | 43 | sessionCookieName: resolveSessionCookieName({ |
| 44 | mode: config.mode, | 44 | mode: config.mode, |
| 45 | port: config.port, | 45 | port: config.port, |
| @@ -92,6 +92,29 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { · 91 unmodified lines | |||
| 92 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | 92 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), |
| 93 | ); | 93 | ); |
| 94 | 94 | ||
| 95 | it.effect("requires the bound proof key thumbprint when present", () => | ||
| 96 | Effect.gen(function* () { | ||
| 97 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | ||
| 98 | const token = yield* bootstrapCredentials.issueOneTimeToken({ | ||
| 99 | proofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 100 | }); | ||
| 101 | |||
| 102 | const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential)); | ||
| 103 | const wrong = yield* Effect.flip( | ||
| 104 | bootstrapCredentials.consume(token.credential, { | ||
| 105 | proofKeyThumbprint: "other-proof-key-thumbprint", | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | const consumed = yield* bootstrapCredentials.consume(token.credential, { | ||
| 109 | proofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 110 | }); | ||
| 111 | |||
| 112 | expect(missing.message).toContain("proof key mismatch"); | ||
| 113 | expect(wrong.message).toContain("proof key mismatch"); | ||
| 114 | expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint"); | ||
| 115 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | ||
| 116 | ); | ||
| 117 | |||
| 95 | it.effect("seeds the desktop bootstrap credential as a one-time grant", () => | 118 | it.effect("seeds the desktop bootstrap credential as a one-time grant", () => |
| 96 | Effect.gen(function* () { | 119 | Effect.gen(function* () { |
| 97 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | 120 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| @@ -26,6 +26,7 @@ export interface BootstrapGrant { · 25 unmodified lines | |||
| 26 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 26 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 27 | readonly subject: string; | 27 | readonly subject: string; |
| 28 | readonly label?: string; | 28 | readonly label?: string; |
| 29 | readonly proofKeyThumbprint?: string; | ||
| 29 | readonly expiresAt: DateTime.DateTime; | 30 | readonly expiresAt: DateTime.DateTime; |
| 30 | } | 31 | } |
| 31 | 32 | ||
| @@ -50,6 +51,7 @@ export interface IssuedBootstrapCredential { · 18 unmodified lines | |||
| 50 | readonly id: string; | 51 | readonly id: string; |
| 51 | readonly credential: string; | 52 | readonly credential: string; |
| 52 | readonly label?: string; | 53 | readonly label?: string; |
| 54 | readonly proofKeyThumbprint?: string; | ||
| 53 | readonly expiresAt: DateTime.Utc; | 55 | readonly expiresAt: DateTime.Utc; |
| 54 | } | 56 | } |
| 55 | 57 | ||
| @@ -69,6 +71,7 @@ export interface PairingGrantStoreShape { · 13 unmodified lines | |||
| 69 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | 71 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 70 | readonly subject?: string; | 72 | readonly subject?: string; |
| 71 | readonly label?: string; | 73 | readonly label?: string; |
| 74 | readonly proofKeyThumbprint?: string; | ||
| 72 | }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>; | 75 | }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>; |
| 73 | readonly listActive: () => Effect.Effect< | 76 | readonly listActive: () => Effect.Effect< |
| 74 | ReadonlyArray<AuthPairingLink>, | 77 | ReadonlyArray<AuthPairingLink>, |
| @@ -76,7 +79,12 @@ export interface PairingGrantStoreShape { · 1 unmodified line | |||
| 76 | >; | 79 | >; |
| 77 | readonly streamChanges: Stream.Stream<BootstrapCredentialChange>; | 80 | readonly streamChanges: Stream.Stream<BootstrapCredentialChange>; |
| 78 | readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>; | 81 | readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>; |
| 79 | readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | 82 | readonly consume: ( |
| 83 | credential: string, | ||
| 84 | input?: { | ||
| 85 | readonly proofKeyThumbprint?: string; | ||
| 86 | }, | ||
| 87 | ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | ||
| 80 | } | 88 | } |
| 81 | 89 | ||
| 82 | export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()( | 90 | export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()( |
| @@ -232,6 +240,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 149 unmodified lines | |||
| 232 | id, | 240 | id, |
| 233 | credential, | 241 | credential, |
| 234 | ...(input?.label ? { label: input.label } : {}), | 242 | ...(input?.label ? { label: input.label } : {}), |
| 243 | ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), | ||
| 235 | expiresAt, | 244 | expiresAt, |
| 236 | }; | 245 | }; |
| 237 | yield* pairingLinks.create({ | 246 | yield* pairingLinks.create({ |
| @@ -241,6 +250,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 3 unmodified lines | |||
| 241 | scopes: input?.scopes ?? AuthStandardClientScopes, | 250 | scopes: input?.scopes ?? AuthStandardClientScopes, |
| 242 | subject: input?.subject ?? "one-time-token", | 251 | subject: input?.subject ?? "one-time-token", |
| 243 | label: input?.label ?? null, | 252 | label: input?.label ?? null, |
| 253 | proofKeyThumbprint: input?.proofKeyThumbprint ?? null, | ||
| 244 | createdAt: now, | 254 | createdAt: now, |
| 245 | expiresAt: expiresAt, | 255 | expiresAt: expiresAt, |
| 246 | }); | 256 | }); |
| @@ -259,7 +269,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 12 unmodified lines | |||
| 259 | ); | 269 | ); |
| 260 | 270 | ||
| 261 | const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")( | 271 | const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")( |
| 262 | function* (credential) { | 272 | function* (credential, input) { |
| 263 | const now = yield* DateTime.now; | 273 | const now = yield* DateTime.now; |
| 264 | const seededResult: ConsumeResult = yield* Ref.modify( | 274 | const seededResult: ConsumeResult = yield* Ref.modify( |
| 265 | seededGrantsRef, | 275 | seededGrantsRef, |
| @@ -289,6 +299,17 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 23 unmodified lines | |||
| 289 | ]; | 299 | ]; |
| 290 | } | 300 | } |
| 291 | 301 | ||
| 302 | if (grant.proofKeyThumbprint && grant.proofKeyThumbprint !== input?.proofKeyThumbprint) { | ||
| 303 | return [ | ||
| 304 | { | ||
| 305 | _tag: "error", | ||
| 306 | reason: "not-found", | ||
| 307 | error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."), | ||
| 308 | }, | ||
| 309 | next, | ||
| 310 | ]; | ||
| 311 | } | ||
| 312 | |||
| 292 | const remainingUses = grant.remainingUses; | 313 | const remainingUses = grant.remainingUses; |
| 293 | if (typeof remainingUses === "number") { | 314 | if (typeof remainingUses === "number") { |
| 294 | if (remainingUses <= 1) { | 315 | if (remainingUses <= 1) { |
| @@ -309,6 +330,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 14 unmodified lines | |||
| 309 | scopes: grant.scopes, | 330 | scopes: grant.scopes, |
| 310 | subject: grant.subject, | 331 | subject: grant.subject, |
| 311 | ...(grant.label ? { label: grant.label } : {}), | 332 | ...(grant.label ? { label: grant.label } : {}), |
| 333 | ...(grant.proofKeyThumbprint | ||
| 334 | ? { proofKeyThumbprint: grant.proofKeyThumbprint } | ||
| 335 | : {}), | ||
| 312 | expiresAt: grant.expiresAt, | 336 | expiresAt: grant.expiresAt, |
| 313 | } satisfies BootstrapGrant, | 337 | } satisfies BootstrapGrant, |
| 314 | }, | 338 | }, |
| @@ -326,6 +350,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 11 unmodified lines | |||
| 326 | 350 | ||
| 327 | const consumed = yield* pairingLinks.consumeAvailable({ | 351 | const consumed = yield* pairingLinks.consumeAvailable({ |
| 328 | credential, | 352 | credential, |
| 353 | proofKeyThumbprint: input?.proofKeyThumbprint ?? null, | ||
| 329 | consumedAt: now, | 354 | consumedAt: now, |
| 330 | now, | 355 | now, |
| 331 | }); | 356 | }); |
| @@ -337,6 +362,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 5 unmodified lines | |||
| 337 | scopes: consumed.value.scopes, | 362 | scopes: consumed.value.scopes, |
| 338 | subject: consumed.value.subject, | 363 | subject: consumed.value.subject, |
| 339 | ...(consumed.value.label ? { label: consumed.value.label } : {}), | 364 | ...(consumed.value.label ? { label: consumed.value.label } : {}), |
| 365 | ...(consumed.value.proofKeyThumbprint | ||
| 366 | ? { proofKeyThumbprint: consumed.value.proofKeyThumbprint } | ||
| 367 | : {}), | ||
| 340 | expiresAt: consumed.value.expiresAt, | 368 | expiresAt: consumed.value.expiresAt, |
| 341 | } satisfies BootstrapGrant; | 369 | } satisfies BootstrapGrant; |
| 342 | } | 370 | } |
| @@ -360,6 +388,13 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { · 17 unmodified lines | |||
| 360 | return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); | 388 | return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); |
| 361 | } | 389 | } |
| 362 | 390 | ||
| 391 | if ( | ||
| 392 | matching.value.proofKeyThumbprint !== null && | ||
| 393 | matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint | ||
| 394 | ) { | ||
| 395 | return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."); | ||
| 396 | } | ||
| 397 | |||
| 363 | return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); | 398 | return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); |
| 364 | }, | 399 | }, |
| 365 | Effect.mapError((cause) => | 400 | Effect.mapError((cause) => |
| @@ -15,9 +15,16 @@ export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ · 14 unmodified lines | |||
| 15 | readonly cause?: unknown; | 15 | readonly cause?: unknown; |
| 16 | }> {} | 16 | }> {} |
| 17 | 17 | ||
| 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 | |||
| 18 | export interface ServerSecretStoreShape { | 24 | export interface ServerSecretStoreShape { |
| 19 | readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>; | 25 | readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>; |
| 20 | readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | 26 | readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; |
| 27 | readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | ||
| 21 | readonly getOrCreateRandom: ( | 28 | readonly getOrCreateRandom: ( |
| 22 | name: string, | 29 | name: string, |
| 23 | bytes: number, | 30 | bytes: number, |
| @@ -48,9 +55,6 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 24 unmodified lines | |||
| 48 | 55 | ||
| 49 | const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); | 56 | const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); |
| 50 | 57 | ||
| 51 | const isPlatformError = (u: unknown): u is PlatformError.PlatformError => | ||
| 52 | Predicate.isTagged(u, "PlatformError"); | ||
| 53 | |||
| 54 | const get: ServerSecretStoreShape["get"] = (name) => | 58 | const get: ServerSecretStoreShape["get"] = (name) => |
| 55 | fileSystem.readFile(resolveSecretPath(name)).pipe( | 59 | fileSystem.readFile(resolveSecretPath(name)).pipe( |
| 56 | Effect.map((bytes) => Uint8Array.from(bytes)), | 60 | Effect.map((bytes) => Uint8Array.from(bytes)), |
| @@ -104,7 +108,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 47 unmodified lines | |||
| 104 | ); | 108 | ); |
| 105 | }; | 109 | }; |
| 106 | 110 | ||
| 107 | const create: ServerSecretStoreShape["set"] = (name, value) => { | 111 | const create: ServerSecretStoreShape["create"] = (name, value) => { |
| 108 | const secretPath = resolveSecretPath(name); | 112 | const secretPath = resolveSecretPath(name); |
| 109 | return Effect.scoped( | 113 | return Effect.scoped( |
| 110 | Effect.gen(function* () { | 114 | Effect.gen(function* () { |
| @@ -146,7 +150,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 35 unmodified lines | |||
| 146 | create(name, generated).pipe( | 150 | create(name, generated).pipe( |
| 147 | Effect.as(Uint8Array.from(generated)), | 151 | Effect.as(Uint8Array.from(generated)), |
| 148 | Effect.catchTag("SecretStoreError", (error) => | 152 | Effect.catchTag("SecretStoreError", (error) => |
| 149 | isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists" | 153 | isSecretAlreadyExistsError(error) |
| 150 | ? get(name).pipe( | 154 | ? get(name).pipe( |
| 151 | Effect.flatMap((created) => | 155 | Effect.flatMap((created) => |
| 152 | created !== null | 156 | created !== null |
| @@ -185,6 +189,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { · 32 unmodified lines | |||
| 185 | return { | 189 | return { |
| 186 | get, | 190 | get, |
| 187 | set, | 191 | set, |
| 192 | create, | ||
| 188 | getOrCreateRandom, | 193 | getOrCreateRandom, |
| 189 | remove, | 194 | remove, |
| 190 | } satisfies ServerSecretStoreShape; | 195 | } satisfies ServerSecretStoreShape; |
| @@ -40,6 +40,7 @@ export interface IssuedSession { · 39 unmodified lines | |||
| 40 | readonly client: AuthClientMetadata; | 40 | readonly client: AuthClientMetadata; |
| 41 | readonly expiresAt: DateTime.DateTime; | 41 | readonly expiresAt: DateTime.DateTime; |
| 42 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 42 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 43 | readonly proofKeyThumbprint?: string; | ||
| 43 | } | 44 | } |
| 44 | 45 | ||
| 45 | export interface VerifiedSession { | 46 | export interface VerifiedSession { |
| @@ -50,6 +51,7 @@ export interface VerifiedSession { · 4 unmodified lines | |||
| 50 | readonly expiresAt?: DateTime.DateTime; | 51 | readonly expiresAt?: DateTime.DateTime; |
| 51 | readonly subject: string; | 52 | readonly subject: string; |
| 52 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 53 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 54 | readonly proofKeyThumbprint?: string; | ||
| 53 | } | 55 | } |
| 54 | 56 | ||
| 55 | export type SessionCredentialChange = | 57 | export type SessionCredentialChange = |
| @@ -86,6 +88,7 @@ export interface SessionStoreShape { · 30 unmodified lines | |||
| 86 | readonly method?: ServerAuthSessionMethod; | 88 | readonly method?: ServerAuthSessionMethod; |
| 87 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | 89 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 88 | readonly client?: AuthClientMetadata; | 90 | readonly client?: AuthClientMetadata; |
| 91 | readonly proofKeyThumbprint?: string; | ||
| 89 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; | 92 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; |
| 90 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | 93 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; |
| 91 | readonly issueWebSocketToken: ( | 94 | readonly issueWebSocketToken: ( |
| @@ -132,7 +135,8 @@ const SessionClaims = Schema.Struct({ · 40 unmodified lines | |||
| 132 | sid: AuthSessionId, | 135 | sid: AuthSessionId, |
| 133 | sub: Schema.String, | 136 | sub: Schema.String, |
| 134 | scopes: AuthEnvironmentScopes, | 137 | scopes: AuthEnvironmentScopes, |
| 135 | method: Schema.Literals(["browser-session-cookie", "bearer-access-token"]), | 138 | method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), |
| 139 | jkt: Schema.optionalKey(Schema.String), | ||
| 136 | iat: Schema.Number, | 140 | iat: Schema.Number, |
| 137 | exp: Schema.Number, | 141 | exp: Schema.Number, |
| 138 | }); | 142 | }); |
| @@ -310,6 +314,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 171 unmodified lines | |||
| 310 | sub: input?.subject ?? "browser", | 314 | sub: input?.subject ?? "browser", |
| 311 | scopes: input?.scopes ?? AuthStandardClientScopes, | 315 | scopes: input?.scopes ?? AuthStandardClientScopes, |
| 312 | method: input?.method ?? "browser-session-cookie", | 316 | method: input?.method ?? "browser-session-cookie", |
| 317 | ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), | ||
| 313 | iat: issuedAt.epochMilliseconds, | 318 | iat: issuedAt.epochMilliseconds, |
| 314 | exp: expiresAt.epochMilliseconds, | 319 | exp: expiresAt.epochMilliseconds, |
| 315 | }; | 320 | }; |
| @@ -360,6 +365,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 44 unmodified lines | |||
| 360 | client, | 365 | client, |
| 361 | expiresAt: expiresAt, | 366 | expiresAt: expiresAt, |
| 362 | scopes: claims.scopes, | 367 | scopes: claims.scopes, |
| 368 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), | ||
| 363 | } satisfies IssuedSession; | 369 | } satisfies IssuedSession; |
| 364 | }, | 370 | }, |
| 365 | Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), | 371 | Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), |
| @@ -425,6 +431,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { · 59 unmodified lines | |||
| 425 | expiresAt: expiresAt.value, | 431 | expiresAt: expiresAt.value, |
| 426 | subject: claims.sub, | 432 | subject: claims.sub, |
| 427 | scopes: claims.scopes, | 433 | scopes: claims.scopes, |
| 434 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), | ||
| 428 | } satisfies VerifiedSession; | 435 | } satisfies VerifiedSession; |
| 429 | }, | 436 | }, |
| 430 | Effect.mapError((cause) => | 437 | Effect.mapError((cause) => |
| @@ -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 | }); | ||
| @@ -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 | }); | ||
| @@ -34,6 +34,32 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; · 33 unmodified lines | |||
| 34 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 34 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 35 | import * as SessionStore from "./SessionStore.ts"; | 35 | import * as SessionStore from "./SessionStore.ts"; |
| 36 | import { deriveAuthClientMetadata } from "./utils.ts"; | 36 | 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 | }); | ||
| 37 | 63 | ||
| 38 | export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( | 64 | export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( |
| 39 | Effect.map((span) => span.traceId), | 65 | Effect.map((span) => span.traceId), |
| @@ -152,7 +178,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( · 112 unmodified lines | |||
| 152 | scopes: new Set(session.scopes), | 178 | scopes: new Set(session.scopes), |
| 153 | }), | 179 | }), |
| 154 | ); | 180 | ); |
| 155 | }); | 181 | }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); |
| 156 | }), | 182 | }), |
| 157 | ); | 183 | ); |
| 158 | 184 | ||
| @@ -199,6 +225,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 40 unmodified lines | |||
| 199 | yield* HttpEffect.appendPreResponseHandler((_request, response) => | 225 | yield* HttpEffect.appendPreResponseHandler((_request, response) => |
| 200 | Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), | 226 | Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), |
| 201 | ); | 227 | ); |
| 228 | yield* appendCredentialResponseHeaders; | ||
| 202 | return result.response; | 229 | return result.response; |
| 203 | }, | 230 | }, |
| 204 | Effect.catchTags({ | 231 | Effect.catchTags({ |
| @@ -230,8 +257,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 25 unmodified lines | |||
| 230 | AuthRelayWriteScope, | 257 | AuthRelayWriteScope, |
| 231 | ]), | 258 | ]), |
| 232 | }); | 259 | }); |
| 233 | if (requestedScopes === null) | 260 | if (requestedScopes === null) { |
| 234 | return yield* failEnvironmentInvalidRequest("invalid_scope"); | 261 | return yield* failEnvironmentInvalidRequest("invalid_scope"); |
| 262 | } | ||
| 263 | const proofKeyThumbprint = args.headers.dpop | ||
| 264 | ? yield* verifyRequestDpopProof({ request }).pipe( | ||
| 265 | Effect.catchTags({ | ||
| 266 | ServerAuthInvalidCredentialError: () => | ||
| 267 | appendDpopChallengeHeader.pipe( | ||
| 268 | Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), | ||
| 269 | ), | ||
| 270 | ServerAuthInternalError: (error) => | ||
| 271 | failEnvironmentInternal("access_token_issuance_failed", error), | ||
| 272 | }), | ||
| 273 | ) | ||
| 274 | : undefined; | ||
| 275 | yield* appendCredentialResponseHeaders; | ||
| 235 | return yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | 276 | return yield* serverAuth.exchangeBootstrapCredentialForAccessToken( |
| 236 | args.payload.subject_token, | 277 | args.payload.subject_token, |
| 237 | requestedScopes, | 278 | requestedScopes, |
| @@ -245,6 +286,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 7 unmodified lines | |||
| 245 | ...(args.payload.client_os ? { os: args.payload.client_os } : {}), | 286 | ...(args.payload.client_os ? { os: args.payload.client_os } : {}), |
| 246 | }, | 287 | }, |
| 247 | }), | 288 | }), |
| 289 | proofKeyThumbprint ? { proofKeyThumbprint } : undefined, | ||
| 248 | ); | 290 | ); |
| 249 | }, | 291 | }, |
| 250 | Effect.catchTags({ | 292 | Effect.catchTags({ |
| @@ -261,6 +303,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 10 unmodified lines | |||
| 261 | function* (args) { | 303 | function* (args) { |
| 262 | yield* annotateEnvironmentRequest(args.endpoint.name); | 304 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 263 | const session = yield* EnvironmentAuthenticatedPrincipal; | 305 | const session = yield* EnvironmentAuthenticatedPrincipal; |
| 306 | yield* appendCredentialResponseHeaders; | ||
| 264 | return yield* serverAuth.issueWebSocketTicket(session); | 307 | return yield* serverAuth.issueWebSocketTicket(session); |
| 265 | }, | 308 | }, |
| 266 | Effect.catchTag("ServerAuthInternalError", (error) => | 309 | Effect.catchTag("ServerAuthInternalError", (error) => |
| @@ -5,6 +5,8 @@ import type { · 4 unmodified lines | |||
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | 6 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 7 | import * as Crypto from "node:crypto"; | 7 | import * as Crypto from "node:crypto"; |
| 8 | import * as Encoding from "effect/Encoding"; | ||
| 9 | import * as Result from "effect/Result"; | ||
| 8 | 10 | ||
| 9 | const SESSION_COOKIE_NAME = "t3_session"; | 11 | const SESSION_COOKIE_NAME = "t3_session"; |
| 10 | 12 | ||
| @@ -20,12 +22,13 @@ export function resolveSessionCookieName(input: { · 9 unmodified lines | |||
| 20 | } | 22 | } |
| 21 | 23 | ||
| 22 | export function base64UrlEncode(input: string | Uint8Array): string { | 24 | export function base64UrlEncode(input: string | Uint8Array): string { |
| 23 | const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); | 25 | return typeof input === "string" |
| 24 | return buffer.toString("base64url"); | 26 | ? Encoding.encodeBase64Url(new TextEncoder().encode(input)) |
| 27 | : Encoding.encodeBase64Url(input); | ||
| 25 | } | 28 | } |
| 26 | 29 | ||
| 27 | export function base64UrlDecodeUtf8(input: string): string { | 30 | export function base64UrlDecodeUtf8(input: string): string { |
| 28 | return Buffer.from(input, "base64url").toString("utf8"); | 31 | return Result.getOrThrow(Encoding.decodeBase64UrlString(input)); |
| 29 | } | 32 | } |
| 30 | 33 | ||
| 31 | export function signPayload(payload: string, secret: Uint8Array): string { | 34 | export function signPayload(payload: string, secret: Uint8Array): string { |
| @@ -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 | }); | ||
| @@ -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 | ); | ||
| @@ -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 | ); | ||
| @@ -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 | }); | ||
| @@ -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 | ); | ||
| @@ -4,6 +4,7 @@ export const browserApiCorsAllowedHeaders = [ · 3 unmodified lines | |||
| 4 | "b3", | 4 | "b3", |
| 5 | "traceparent", | 5 | "traceparent", |
| 6 | "content-type", | 6 | "content-type", |
| 7 | "dpop", | ||
| 7 | ] as const; | 8 | ] as const; |
| 8 | 9 | ||
| 9 | export const browserApiCorsHeaders = { | 10 | export const browserApiCorsHeaders = { |
| @@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge · 10 unmodified lines | |||
| 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; | 12 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; |
| 13 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; | 13 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; |
| 14 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; | ||
| 14 | 15 | ||
| 15 | describe("OrchestrationReactor", () => { | 16 | describe("OrchestrationReactor", () => { |
| 16 | let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null; | 17 | let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null; |
| @@ -63,6 +64,15 @@ describe("OrchestrationReactor", () => { · 46 unmodified lines | |||
| 63 | drain: Effect.void, | 64 | drain: Effect.void, |
| 64 | }), | 65 | }), |
| 65 | ), | 66 | ), |
| 67 | Layer.provideMerge( | ||
| 68 | Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { | ||
| 69 | publishThread: () => Effect.void, | ||
| 70 | start: () => { | ||
| 71 | started.push("agent-awareness-relay"); | ||
| 72 | return Effect.void; | ||
| 73 | }, | ||
| 74 | }), | ||
| 75 | ), | ||
| 66 | ), | 76 | ), |
| 67 | ); | 77 | ); |
| 68 | 78 | ||
| @@ -75,6 +85,7 @@ describe("OrchestrationReactor", () => { · 6 unmodified lines | |||
| 75 | "provider-command-reactor", | 85 | "provider-command-reactor", |
| 76 | "checkpoint-reactor", | 86 | "checkpoint-reactor", |
| 77 | "thread-deletion-reactor", | 87 | "thread-deletion-reactor", |
| 88 | "agent-awareness-relay", | ||
| 78 | ]); | 89 | ]); |
| 79 | 90 | ||
| 80 | await Effect.runPromise(Scope.close(scope, Exit.void)); | 91 | await Effect.runPromise(Scope.close(scope, Exit.void)); |
| @@ -9,18 +9,21 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; · 8 unmodified lines | |||
| 9 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | 9 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; | ||
| 12 | 13 | ||
| 13 | export const makeOrchestrationReactor = Effect.gen(function* () { | 14 | export const makeOrchestrationReactor = Effect.gen(function* () { |
| 14 | const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; | 15 | const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; |
| 15 | const providerCommandReactor = yield* ProviderCommandReactor; | 16 | const providerCommandReactor = yield* ProviderCommandReactor; |
| 16 | const checkpointReactor = yield* CheckpointReactor; | 17 | const checkpointReactor = yield* CheckpointReactor; |
| 17 | const threadDeletionReactor = yield* ThreadDeletionReactor; | 18 | const threadDeletionReactor = yield* ThreadDeletionReactor; |
| 19 | const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; | ||
| 18 | 20 | ||
| 19 | const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { | 21 | const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { |
| 20 | yield* providerRuntimeIngestion.start(); | 22 | yield* providerRuntimeIngestion.start(); |
| 21 | yield* providerCommandReactor.start(); | 23 | yield* providerCommandReactor.start(); |
| 22 | yield* checkpointReactor.start(); | 24 | yield* checkpointReactor.start(); |
| 23 | yield* threadDeletionReactor.start(); | 25 | yield* threadDeletionReactor.start(); |
| 26 | yield* agentAwarenessRelay.start(); | ||
| 24 | }); | 27 | }); |
| 25 | 28 | ||
| 26 | return { | 29 | return { |
| @@ -41,6 +41,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 40 unmodified lines | |||
| 41 | scopes, | 41 | scopes, |
| 42 | subject, | 42 | subject, |
| 43 | label, | 43 | label, |
| 44 | proof_key_thumbprint, | ||
| 44 | created_at, | 45 | created_at, |
| 45 | expires_at, | 46 | expires_at, |
| 46 | consumed_at, | 47 | consumed_at, |
| @@ -53,6 +54,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 6 unmodified lines | |||
| 53 | ${JSON.stringify(input.scopes)}, | 54 | ${JSON.stringify(input.scopes)}, |
| 54 | ${input.subject}, | 55 | ${input.subject}, |
| 55 | ${input.label}, | 56 | ${input.label}, |
| 57 | ${input.proofKeyThumbprint}, | ||
| 56 | ${input.createdAt}, | 58 | ${input.createdAt}, |
| 57 | ${input.expiresAt}, | 59 | ${input.expiresAt}, |
| 58 | NULL, | 60 | NULL, |
| @@ -64,7 +66,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 5 unmodified lines | |||
| 64 | const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ | 66 | const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ |
| 65 | Request: ConsumeAuthPairingLinkInput, | 67 | Request: ConsumeAuthPairingLinkInput, |
| 66 | Result: AuthPairingLinkRecord, | 68 | Result: AuthPairingLinkRecord, |
| 67 | execute: ({ credential, consumedAt, now }) => | 69 | execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => |
| 68 | sql` | 70 | sql` |
| 69 | UPDATE auth_pairing_links | 71 | UPDATE auth_pairing_links |
| 70 | SET consumed_at = ${consumedAt} | 72 | SET consumed_at = ${consumedAt} |
| @@ -72,6 +74,10 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 1 unmodified line | |||
| 72 | AND revoked_at IS NULL | 74 | AND revoked_at IS NULL |
| 73 | AND consumed_at IS NULL | 75 | AND consumed_at IS NULL |
| 74 | AND expires_at > ${now} | 76 | AND expires_at > ${now} |
| 77 | AND ( | ||
| 78 | proof_key_thumbprint IS NULL | ||
| 79 | OR proof_key_thumbprint = ${proofKeyThumbprint} | ||
| 80 | ) | ||
| 75 | RETURNING | 81 | RETURNING |
| 76 | id AS "id", | 82 | id AS "id", |
| 77 | credential AS "credential", | 83 | credential AS "credential", |
| @@ -79,6 +85,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 1 unmodified line | |||
| 79 | scopes AS "scopes", | 85 | scopes AS "scopes", |
| 80 | subject AS "subject", | 86 | subject AS "subject", |
| 81 | label AS "label", | 87 | label AS "label", |
| 88 | proof_key_thumbprint AS "proofKeyThumbprint", | ||
| 82 | created_at AS "createdAt", | 89 | created_at AS "createdAt", |
| 83 | expires_at AS "expiresAt", | 90 | expires_at AS "expiresAt", |
| 84 | consumed_at AS "consumedAt", | 91 | consumed_at AS "consumedAt", |
| @@ -98,6 +105,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 13 unmodified lines | |||
| 98 | scopes AS "scopes", | 105 | scopes AS "scopes", |
| 99 | subject AS "subject", | 106 | subject AS "subject", |
| 100 | label AS "label", | 107 | label AS "label", |
| 108 | proof_key_thumbprint AS "proofKeyThumbprint", | ||
| 101 | created_at AS "createdAt", | 109 | created_at AS "createdAt", |
| 102 | expires_at AS "expiresAt", | 110 | expires_at AS "expiresAt", |
| 103 | consumed_at AS "consumedAt", | 111 | consumed_at AS "consumedAt", |
| @@ -136,6 +144,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { · 32 unmodified lines | |||
| 136 | scopes AS "scopes", | 144 | scopes AS "scopes", |
| 137 | subject AS "subject", | 145 | subject AS "subject", |
| 138 | label AS "label", | 146 | label AS "label", |
| 147 | proof_key_thumbprint AS "proofKeyThumbprint", | ||
| 139 | created_at AS "createdAt", | 148 | created_at AS "createdAt", |
| 140 | expires_at AS "expiresAt", | 149 | expires_at AS "expiresAt", |
| 141 | consumed_at AS "consumedAt", | 150 | consumed_at AS "consumedAt", |
| @@ -44,6 +44,7 @@ import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts · 43 unmodified lines | |||
| 44 | import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; | 44 | import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; |
| 45 | import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; | 45 | import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; |
| 46 | import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; | 46 | import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; |
| 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; | ||
| 47 | 48 | ||
| 48 | /** | 49 | /** |
| 49 | * Migration loader with all migrations defined inline. | 50 | * Migration loader with all migrations defined inline. |
| @@ -87,6 +88,7 @@ export const migrationEntries = [ · 37 unmodified lines | |||
| 87 | [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], | 88 | [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], |
| 88 | [30, "ProjectionThreadShellArchiveIndexes", Migration0030], | 89 | [30, "ProjectionThreadShellArchiveIndexes", Migration0030], |
| 89 | [31, "AuthAuthorizationScopes", Migration0031], | 90 | [31, "AuthAuthorizationScopes", Migration0031], |
| 91 | [32, "AuthPairingProofKeyThumbprint", Migration0032], | ||
| 90 | ] as const; | 92 | ] as const; |
| 91 | 93 | ||
| 92 | export const makeMigrationLoader = (throughId?: number) => | 94 | export const makeMigrationLoader = (throughId?: number) => |
| @@ -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 | }); | ||
| @@ -13,6 +13,7 @@ export const AuthPairingLinkRecord = Schema.Struct({ · 12 unmodified lines | |||
| 13 | scopes: Schema.fromJsonString(AuthEnvironmentScopes), | 13 | scopes: Schema.fromJsonString(AuthEnvironmentScopes), |
| 14 | subject: Schema.String, | 14 | subject: Schema.String, |
| 15 | label: Schema.NullOr(Schema.String), | 15 | label: Schema.NullOr(Schema.String), |
| 16 | proofKeyThumbprint: Schema.NullOr(Schema.String), | ||
| 16 | createdAt: Schema.DateTimeUtcFromString, | 17 | createdAt: Schema.DateTimeUtcFromString, |
| 17 | expiresAt: Schema.DateTimeUtcFromString, | 18 | expiresAt: Schema.DateTimeUtcFromString, |
| 18 | consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | 19 | consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), |
| @@ -27,6 +28,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ · 8 unmodified lines | |||
| 27 | scopes: AuthEnvironmentScopes, | 28 | scopes: AuthEnvironmentScopes, |
| 28 | subject: Schema.String, | 29 | subject: Schema.String, |
| 29 | label: Schema.NullOr(Schema.String), | 30 | label: Schema.NullOr(Schema.String), |
| 31 | proofKeyThumbprint: Schema.NullOr(Schema.String), | ||
| 30 | createdAt: Schema.DateTimeUtcFromString, | 32 | createdAt: Schema.DateTimeUtcFromString, |
| 31 | expiresAt: Schema.DateTimeUtcFromString, | 33 | expiresAt: Schema.DateTimeUtcFromString, |
| 32 | }); | 34 | }); |
| @@ -34,6 +36,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; · 1 unmodified line | |||
| 34 | 36 | ||
| 35 | export const ConsumeAuthPairingLinkInput = Schema.Struct({ | 37 | export const ConsumeAuthPairingLinkInput = Schema.Struct({ |
| 36 | credential: Schema.String, | 38 | credential: Schema.String, |
| 39 | proofKeyThumbprint: Schema.NullOr(Schema.String), | ||
| 37 | consumedAt: Schema.DateTimeUtcFromString, | 40 | consumedAt: Schema.DateTimeUtcFromString, |
| 38 | now: Schema.DateTimeUtcFromString, | 41 | now: Schema.DateTimeUtcFromString, |
| 39 | }); | 42 | }); |
| @@ -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 | }); | ||
| @@ -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); | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; | 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import * as NodeCrypto from "node:crypto"; | ||
| 4 | 5 | ||
| 5 | import { | 6 | import { |
| 6 | AuthAccessTokenType, | 7 | AuthAccessTokenType, |
| @@ -28,6 +29,12 @@ import { · 21 unmodified lines | |||
| 28 | WsRpcGroup, | 29 | WsRpcGroup, |
| 29 | EditorId, | 30 | EditorId, |
| 30 | } from "@t3tools/contracts"; | 31 | } from "@t3tools/contracts"; |
| 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"; | ||
| 31 | import { assert, it } from "@effect/vitest"; | 38 | import { assert, it } from "@effect/vitest"; |
| 32 | import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; | 39 | import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; |
| 33 | import * as Clock from "effect/Clock"; | 40 | import * as Clock from "effect/Clock"; |
| @@ -43,13 +50,13 @@ import * as Path from "effect/Path"; · 9 unmodified lines | |||
| 43 | import * as Stream from "effect/Stream"; | 50 | import * as Stream from "effect/Stream"; |
| 44 | import { ChildProcessSpawner } from "effect/unstable/process"; | 51 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 45 | import { | 52 | import { |
| 46 | Cookies, | ||
| 47 | FetchHttpClient, | 53 | FetchHttpClient, |
| 48 | HttpBody, | 54 | HttpBody, |
| 49 | HttpClient, | 55 | HttpClient, |
| 56 | HttpClientRequest, | ||
| 57 | HttpClientResponse, | ||
| 50 | HttpRouter, | 58 | HttpRouter, |
| 51 | HttpServer, | 59 | HttpServer, |
| 52 | UrlParams, | ||
| 53 | } from "effect/unstable/http"; | 60 | } from "effect/unstable/http"; |
| 54 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | 61 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 55 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | 62 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; |
| @@ -120,6 +127,10 @@ import * as ReviewService from "./review/ReviewService.ts"; · 64 unmodified lines | |||
| 120 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 127 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 121 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 128 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 122 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 129 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 130 | import { | ||
| 131 | CloudManagedEndpointRuntime, | ||
| 132 | type CloudManagedEndpointRuntimeShape, | ||
| 133 | } from "./cloud/ManagedEndpointRuntime.ts"; | ||
| 123 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 134 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 124 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 135 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 125 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 136 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| @@ -347,6 +358,7 @@ const buildAppUnderTest = (options?: { · 221 unmodified lines | |||
| 347 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; | 358 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; |
| 348 | serverEnvironment?: Partial<ServerEnvironmentShape>; | 359 | serverEnvironment?: Partial<ServerEnvironmentShape>; |
| 349 | repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>; | 360 | repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>; |
| 361 | cloudManagedEndpointRuntime?: Partial<CloudManagedEndpointRuntimeShape>; | ||
| 350 | }; | 362 | }; |
| 351 | }) => | 363 | }) => |
| 352 | Effect.gen(function* () { | 364 | Effect.gen(function* () { |
| @@ -741,7 +753,17 @@ const buildAppUnderTest = (options?: { · 388 unmodified lines | |||
| 741 | ...options?.layers?.repositoryIdentityResolver, | 753 | ...options?.layers?.repositoryIdentityResolver, |
| 742 | }), | 754 | }), |
| 743 | ), | 755 | ), |
| 756 | Layer.provide( | ||
| 757 | Layer.succeed( | ||
| 758 | CloudManagedEndpointRuntime, | ||
| 759 | CloudManagedEndpointRuntime.of({ | ||
| 760 | applyConfig: () => Effect.succeed({ status: "disabled" }), | ||
| 761 | ...options?.layers?.cloudManagedEndpointRuntime, | ||
| 762 | }), | ||
| 763 | ), | ||
| 764 | ), | ||
| 744 | Layer.provideMerge(makeAuthTestLayer()), | 765 | Layer.provideMerge(makeAuthTestLayer()), |
| 766 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 745 | Layer.provide(workspaceAndProjectServicesLayer), | 767 | Layer.provide(workspaceAndProjectServicesLayer), |
| 746 | Layer.provideMerge(FetchHttpClient.layer), | 768 | Layer.provideMerge(FetchHttpClient.layer), |
| 747 | Layer.provide(layerConfig), | 769 | Layer.provide(layerConfig), |
| @@ -799,6 +821,13 @@ const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => · 51 unmodified lines | |||
| 799 | return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; | 821 | return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; |
| 800 | }; | 822 | }; |
| 801 | 823 | ||
| 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 | |||
| 802 | const bootstrapBrowserSession = ( | 831 | const bootstrapBrowserSession = ( |
| 803 | credential = defaultDesktopBootstrapToken, | 832 | credential = defaultDesktopBootstrapToken, |
| 804 | options?: { | 833 | options?: { |
| @@ -806,19 +835,26 @@ const bootstrapBrowserSession = ( · 1 unmodified line | |||
| 806 | }, | 835 | }, |
| 807 | ) => | 836 | ) => |
| 808 | Effect.gen(function* () { | 837 | Effect.gen(function* () { |
| 809 | const response = yield* HttpClient.post("/api/auth/browser-session", { | 838 | const bootstrapUrl = yield* getHttpServerUrl("/api/auth/browser-session"); |
| 810 | headers: options?.headers, | 839 | const response = yield* fetchEffect(bootstrapUrl, { |
| 811 | body: yield* HttpBody.json({ credential }), | 840 | method: "POST", |
| 841 | headers: { | ||
| 842 | "content-type": "application/json", | ||
| 843 | ...options?.headers, | ||
| 844 | }, | ||
| 845 | body: jsonRequestBody({ | ||
| 846 | credential, | ||
| 847 | }), | ||
| 812 | }); | 848 | }); |
| 813 | const body = (yield* response.json) as { | 849 | const body = yield* responseJsonEffect<{ |
| 814 | readonly authenticated: boolean; | 850 | readonly authenticated: boolean; |
| 815 | readonly sessionMethod: string; | 851 | readonly sessionMethod: string; |
| 816 | readonly expiresAt: string; | 852 | readonly expiresAt: string; |
| 817 | }; | 853 | }>(response); |
| 818 | return { | 854 | return { |
| 819 | response, | 855 | response, |
| 820 | body, | 856 | body, |
| 821 | cookie: Cookies.toSetCookieHeaders(response.cookies)[0] ?? null, | 857 | cookie: response.headers["set-cookie"], |
| 822 | }; | 858 | }; |
| 823 | }); | 859 | }); |
| 824 | 860 | ||
| @@ -835,48 +871,227 @@ const exchangeAccessToken = ( · 10 unmodified lines | |||
| 835 | }, | 871 | }, |
| 836 | ) => | 872 | ) => |
| 837 | Effect.gen(function* () { | 873 | Effect.gen(function* () { |
| 838 | const response = yield* HttpClient.post("/oauth/token", { | 874 | const tokenUrl = yield* getHttpServerUrl("/oauth/token"); |
| 839 | headers: options?.headers, | 875 | const response = yield* fetchEffect(tokenUrl, { |
| 840 | body: HttpBody.urlParams( | 876 | method: "POST", |
| 841 | UrlParams.fromInput({ | 877 | headers: { |
| 842 | grant_type: AuthTokenExchangeGrantType, | 878 | "content-type": "application/x-www-form-urlencoded", |
| 843 | subject_token: credential, | 879 | ...options?.headers, |
| 844 | subject_token_type: AuthEnvironmentBootstrapTokenType, | 880 | }, |
| 845 | requested_token_type: AuthAccessTokenType, | 881 | body: new URLSearchParams({ |
| 846 | scope: | 882 | grant_type: AuthTokenExchangeGrantType, |
| 847 | options?.scope ?? | 883 | subject_token: credential, |
| 848 | "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", | 884 | subject_token_type: AuthEnvironmentBootstrapTokenType, |
| 849 | ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), | 885 | requested_token_type: AuthAccessTokenType, |
| 850 | ...(options?.clientMetadata?.deviceType | 886 | scope: |
| 851 | ? { client_device_type: options.clientMetadata.deviceType } | 887 | options?.scope ?? |
| 852 | : {}), | 888 | "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", |
| 853 | ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}), | 889 | ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), |
| 854 | }), | 890 | ...(options?.clientMetadata?.deviceType |
| 855 | ), | 891 | ? { client_device_type: options.clientMetadata.deviceType } |
| 892 | : {}), | ||
| 893 | ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}), | ||
| 894 | }).toString(), | ||
| 856 | }); | 895 | }); |
| 857 | const body = (yield* response.json) as { | 896 | const body = yield* responseJsonEffect<{ |
| 858 | readonly access_token?: string; | 897 | readonly access_token?: string; |
| 859 | readonly issued_token_type?: string; | 898 | readonly issued_token_type?: string; |
| 860 | readonly token_type?: string; | 899 | readonly token_type?: string; |
| 861 | readonly expires_in?: number; | 900 | readonly expires_in?: number; |
| 862 | readonly scope?: string; | 901 | readonly scope?: string; |
| 863 | readonly _tag?: string; | 902 | readonly _tag?: string; |
| 864 | readonly message?: string; | 903 | readonly code?: string; |
| 865 | }; | 904 | readonly reason?: string; |
| 905 | readonly traceId?: string; | ||
| 906 | }>(response); | ||
| 866 | return { | 907 | return { |
| 867 | response, | 908 | response, |
| 868 | body, | 909 | body, |
| 869 | }; | 910 | }; |
| 870 | }); | 911 | }); |
| 871 | 912 | ||
| 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 | |||
| 872 | class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{ | 1038 | class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{ |
| 873 | readonly message: string; | 1039 | readonly message: string; |
| 874 | }> {} | 1040 | }> {} |
| 875 | 1041 | ||
| 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 | |||
| 876 | const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => | 1091 | const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => |
| 877 | Effect.gen(function* () { | 1092 | Effect.gen(function* () { |
| 878 | const { response, cookie } = yield* bootstrapBrowserSession(credential); | 1093 | const { response, cookie } = yield* bootstrapBrowserSession(credential); |
| 879 | if (response.status !== 200) { | 1094 | if (!responseOk(response)) { |
| 880 | return yield* new AuthenticationGetterError({ | 1095 | return yield* new AuthenticationGetterError({ |
| 881 | message: `Expected bootstrap session response to succeed, got ${response.status}`, | 1096 | message: `Expected bootstrap session response to succeed, got ${response.status}`, |
| 882 | }); | 1097 | }); |
| @@ -894,7 +1109,7 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra · 11 unmodified lines | |||
| 894 | const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => | 1109 | const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => |
| 895 | Effect.gen(function* () { | 1110 | Effect.gen(function* () { |
| 896 | const { response, body } = yield* exchangeAccessToken(credential); | 1111 | const { response, body } = yield* exchangeAccessToken(credential); |
| 897 | if (response.status !== 200) { | 1112 | if (!responseOk(response)) { |
| 898 | return yield* new AuthenticationGetterError({ | 1113 | return yield* new AuthenticationGetterError({ |
| 899 | message: `Expected bearer bootstrap response to succeed, got ${response.status}`, | 1114 | message: `Expected bearer bootstrap response to succeed, got ${response.status}`, |
| 900 | }); | 1115 | }); |
| @@ -918,7 +1133,7 @@ const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { · 17 unmodified lines | |||
| 918 | return token; | 1133 | return token; |
| 919 | }; | 1134 | }; |
| 920 | 1135 | ||
| 921 | const splitHeaderTokens = (value: string | null) => | 1136 | const splitHeaderTokens = (value: string | null | undefined) => |
| 922 | (value ?? "") | 1137 | (value ?? "") |
| 923 | .split(",") | 1138 | .split(",") |
| 924 | .map((token) => token.trim()) | 1139 | .map((token) => token.trim()) |
| @@ -940,10 +1155,11 @@ const assertBrowserApiCorsPreflightHeaders = ( · 15 unmodified lines | |||
| 940 | "OPTIONS", | 1155 | "OPTIONS", |
| 941 | "POST", | 1156 | "POST", |
| 942 | ]); | 1157 | ]); |
| 943 | assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"] ?? null), [ | 1158 | assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"]), [ |
| 944 | "authorization", | 1159 | "authorization", |
| 945 | "b3", | 1160 | "b3", |
| 946 | "content-type", | 1161 | "content-type", |
| 1162 | "dpop", | ||
| 947 | "traceparent", | 1163 | "traceparent", |
| 948 | ]); | 1164 | ]); |
| 949 | }; | 1165 | }; |
| @@ -989,9 +1205,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 39 unmodified lines | |||
| 989 | config: { devUrl: new URL("http://127.0.0.1:5173") }, | 1205 | config: { devUrl: new URL("http://127.0.0.1:5173") }, |
| 990 | }); | 1206 | }); |
| 991 | 1207 | ||
| 992 | const response = yield* HttpClient.get("/foo/bar?token=test-token").pipe( | 1208 | const url = yield* getHttpServerUrl("/foo/bar?token=test-token"); |
| 993 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), | 1209 | const response = yield* fetchEffect(url, { redirect: "manual" }); |
| 994 | ); | ||
| 995 | 1210 | ||
| 996 | assert.equal(response.status, 302); | 1211 | assert.equal(response.status, 302); |
| 997 | assert.equal(response.headers.location, "http://127.0.0.1:5173/foo/bar?token=test-token"); | 1212 | assert.equal(response.headers.location, "http://127.0.0.1:5173/foo/bar?token=test-token"); |
| @@ -1057,8 +1272,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 59 unmodified lines | |||
| 1057 | Effect.gen(function* () { | 1272 | Effect.gen(function* () { |
| 1058 | yield* buildAppUnderTest(); | 1273 | yield* buildAppUnderTest(); |
| 1059 | 1274 | ||
| 1060 | const response = yield* HttpClient.get("/.well-known/t3/environment"); | 1275 | const url = yield* getHttpServerUrl("/.well-known/t3/environment"); |
| 1061 | const body = (yield* response.json) as typeof testEnvironmentDescriptor; | 1276 | const response = yield* fetchEffect(url); |
| 1277 | const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response); | ||
| 1062 | 1278 | ||
| 1063 | assert.equal(response.status, 200); | 1279 | assert.equal(response.status, 200); |
| 1064 | assert.deepEqual(body, testEnvironmentDescriptor); | 1280 | assert.deepEqual(body, testEnvironmentDescriptor); |
| @@ -1069,12 +1285,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 4 unmodified lines | |||
| 1069 | Effect.gen(function* () { | 1285 | Effect.gen(function* () { |
| 1070 | yield* buildAppUnderTest(); | 1286 | yield* buildAppUnderTest(); |
| 1071 | 1287 | ||
| 1072 | const response = yield* HttpClient.get("/.well-known/t3/environment", { | 1288 | const url = yield* getHttpServerUrl("/.well-known/t3/environment"); |
| 1289 | const response = yield* fetchEffect(url, { | ||
| 1073 | headers: { | 1290 | headers: { |
| 1074 | origin: crossOriginClientOrigin, | 1291 | origin: crossOriginClientOrigin, |
| 1075 | }, | 1292 | }, |
| 1076 | }); | 1293 | }); |
| 1077 | const body = (yield* response.json) as typeof testEnvironmentDescriptor; | 1294 | const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response); |
| 1078 | 1295 | ||
| 1079 | assert.equal(response.status, 200); | 1296 | assert.equal(response.status, 200); |
| 1080 | assertBrowserApiCorsResponseHeaders(response.headers); | 1297 | assertBrowserApiCorsResponseHeaders(response.headers); |
| @@ -1086,8 +1303,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines | |||
| 1086 | Effect.gen(function* () { | 1303 | Effect.gen(function* () { |
| 1087 | yield* buildAppUnderTest(); | 1304 | yield* buildAppUnderTest(); |
| 1088 | 1305 | ||
| 1089 | const response = yield* HttpClient.get("/api/auth/session"); | 1306 | const url = yield* getHttpServerUrl("/api/auth/session"); |
| 1090 | const body = (yield* response.json) as { | 1307 | const response = yield* fetchEffect(url); |
| 1308 | const body = yield* responseJsonEffect<{ | ||
| 1091 | readonly authenticated: boolean; | 1309 | readonly authenticated: boolean; |
| 1092 | readonly auth: { | 1310 | readonly auth: { |
| 1093 | readonly policy: string; | 1311 | readonly policy: string; |
| @@ -1095,13 +1313,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 1 unmodified line | |||
| 1095 | readonly sessionMethods: ReadonlyArray<string>; | 1313 | readonly sessionMethods: ReadonlyArray<string>; |
| 1096 | readonly sessionCookieName: string; | 1314 | readonly sessionCookieName: string; |
| 1097 | }; | 1315 | }; |
| 1098 | }; | 1316 | }>(response); |
| 1099 | 1317 | ||
| 1100 | assert.equal(response.status, 200); | 1318 | assert.equal(response.status, 200); |
| 1101 | assert.equal(body.authenticated, false); | 1319 | assert.equal(body.authenticated, false); |
| 1102 | assert.equal(body.auth.policy, "desktop-managed-local"); | 1320 | assert.equal(body.auth.policy, "desktop-managed-local"); |
| 1103 | assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); | 1321 | assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); |
| 1104 | assert.deepEqual(body.auth.sessionMethods, ["browser-session-cookie", "bearer-access-token"]); | 1322 | assert.deepEqual(body.auth.sessionMethods, [ |
| 1323 | "browser-session-cookie", | ||
| 1324 | "bearer-access-token", | ||
| 1325 | "dpop-access-token", | ||
| 1326 | ]); | ||
| 1105 | assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); | 1327 | assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); |
| 1106 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1328 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1107 | ); | 1329 | ); |
| @@ -1122,15 +1344,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 14 unmodified lines | |||
| 1122 | assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken); | 1344 | assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken); |
| 1123 | assert.isDefined(setCookie); | 1345 | assert.isDefined(setCookie); |
| 1124 | 1346 | ||
| 1125 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { | 1347 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); |
| 1348 | const sessionResponse = yield* fetchEffect(sessionUrl, { | ||
| 1126 | headers: { | 1349 | headers: { |
| 1127 | cookie: setCookie?.split(";")[0] ?? "", | 1350 | cookie: setCookie?.split(";")[0] ?? "", |
| 1128 | }, | 1351 | }, |
| 1129 | }); | 1352 | }); |
| 1130 | const sessionBody = (yield* sessionResponse.json) as { | 1353 | const sessionBody = yield* responseJsonEffect<{ |
| 1131 | readonly authenticated: boolean; | 1354 | readonly authenticated: boolean; |
| 1132 | readonly sessionMethod?: string; | 1355 | readonly sessionMethod?: string; |
| 1133 | }; | 1356 | }>(sessionResponse); |
| 1134 | 1357 | ||
| 1135 | assert.equal(sessionResponse.status, 200); | 1358 | assert.equal(sessionResponse.status, 200); |
| 1136 | assert.equal(sessionBody.authenticated, true); | 1359 | assert.equal(sessionBody.authenticated, true); |
| @@ -1142,9 +1365,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 5 unmodified lines | |||
| 1142 | Effect.gen(function* () { | 1365 | Effect.gen(function* () { |
| 1143 | yield* buildAppUnderTest(); | 1366 | yield* buildAppUnderTest(); |
| 1144 | 1367 | ||
| 1145 | const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken(); | 1368 | const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken(); |
| 1146 | 1369 | ||
| 1147 | assert.equal(bootstrapResponse.status, 200); | 1370 | assert.equal(tokenResponse.status, 200); |
| 1148 | assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); | 1371 | assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); |
| 1149 | assert.equal(tokenBody.token_type, "Bearer"); | 1372 | assert.equal(tokenBody.token_type, "Bearer"); |
| 1150 | assert.equal( | 1373 | assert.equal( |
| @@ -1152,18 +1375,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 1 unmodified line | |||
| 1152 | "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", | 1375 | "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", |
| 1153 | ); | 1376 | ); |
| 1154 | assert.equal(typeof tokenBody.access_token, "string"); | 1377 | assert.equal(typeof tokenBody.access_token, "string"); |
| 1155 | assert.isTrue((tokenBody.access_token?.length ?? 0) > 0); | ||
| 1156 | 1378 | ||
| 1157 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { | 1379 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); |
| 1380 | const sessionResponse = yield* fetchEffect(sessionUrl, { | ||
| 1158 | headers: { | 1381 | headers: { |
| 1159 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | 1382 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, |
| 1160 | }, | 1383 | }, |
| 1161 | }); | 1384 | }); |
| 1162 | const sessionBody = (yield* sessionResponse.json) as { | 1385 | const sessionBody = yield* responseJsonEffect<{ |
| 1163 | readonly authenticated: boolean; | 1386 | readonly authenticated: boolean; |
| 1164 | readonly sessionMethod?: string; | 1387 | readonly sessionMethod?: string; |
| 1165 | readonly scopes?: ReadonlyArray<string>; | 1388 | readonly scopes?: ReadonlyArray<string>; |
| 1166 | }; | 1389 | }>(sessionResponse); |
| 1167 | 1390 | ||
| 1168 | assert.equal(sessionResponse.status, 200); | 1391 | assert.equal(sessionResponse.status, 200); |
| 1169 | assert.equal(sessionBody.authenticated, true); | 1392 | assert.equal(sessionBody.authenticated, true); |
| @@ -1181,187 +1404,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 11 unmodified lines | |||
| 1181 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1404 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1182 | ); | 1405 | ); |
| 1183 | 1406 | ||
| 1184 | it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => | ||
| 1185 | Effect.gen(function* () { | ||
| 1186 | yield* buildAppUnderTest(); | ||
| 1187 | |||
| 1188 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | ||
| 1189 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | ||
| 1190 | headers: { | ||
| 1191 | authorization: `Bearer ${bearerToken}`, | ||
| 1192 | }, | ||
| 1193 | }); | ||
| 1194 | const wsTicketBody = (yield* wsTicketResponse.json) as { | ||
| 1195 | readonly ticket: string; | ||
| 1196 | readonly expiresAt: string; | ||
| 1197 | }; | ||
| 1198 | |||
| 1199 | assert.equal(wsTicketResponse.status, 200); | ||
| 1200 | assert.equal(typeof wsTicketBody.ticket, "string"); | ||
| 1201 | assert.isTrue(wsTicketBody.ticket.length > 0); | ||
| 1202 | assert.equal(typeof wsTicketBody.expiresAt, "string"); | ||
| 1203 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1204 | ); | ||
| 1205 | |||
| 1206 | it.effect("does not allow management-only access tokens to operate the environment", () => | ||
| 1207 | Effect.gen(function* () { | ||
| 1208 | yield* buildAppUnderTest(); | ||
| 1209 | |||
| 1210 | const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken( | ||
| 1211 | defaultDesktopBootstrapToken, | ||
| 1212 | { scope: "access:write" }, | ||
| 1213 | ); | ||
| 1214 | assert.equal(exchangeResponse.status, 200); | ||
| 1215 | assert.equal(tokenBody.scope, "access:write"); | ||
| 1216 | assert.isDefined(tokenBody.access_token); | ||
| 1217 | |||
| 1218 | const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1219 | headers: { | ||
| 1220 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1221 | }, | ||
| 1222 | body: yield* HttpBody.json({}), | ||
| 1223 | }); | ||
| 1224 | const overbroadPairingBody = (yield* overbroadPairingResponse.json) as { | ||
| 1225 | readonly requiredScope: string; | ||
| 1226 | }; | ||
| 1227 | const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1228 | headers: { | ||
| 1229 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1230 | }, | ||
| 1231 | body: yield* HttpBody.json({ scopes: ["access:write"] }), | ||
| 1232 | }); | ||
| 1233 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | ||
| 1234 | headers: { | ||
| 1235 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1236 | }, | ||
| 1237 | }); | ||
| 1238 | const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; | ||
| 1239 | const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { | ||
| 1240 | headers: { | ||
| 1241 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1242 | }, | ||
| 1243 | }); | ||
| 1244 | const faviconBody = (yield* faviconResponse.json) as { | ||
| 1245 | readonly _tag: string; | ||
| 1246 | readonly code: string; | ||
| 1247 | readonly requiredScope: string; | ||
| 1248 | readonly traceId: string; | ||
| 1249 | }; | ||
| 1250 | |||
| 1251 | assert.equal(overbroadPairingResponse.status, 403); | ||
| 1252 | assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); | ||
| 1253 | assert.equal(pairingResponse.status, 200); | ||
| 1254 | assert.equal(wsTicketResponse.status, 200); | ||
| 1255 | assert.equal(faviconResponse.status, 403); | ||
| 1256 | assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); | ||
| 1257 | assert.equal(faviconBody.code, "insufficient_scope"); | ||
| 1258 | assert.equal(faviconBody.requiredScope, "orchestration:read"); | ||
| 1259 | assert.equal(typeof faviconBody.traceId, "string"); | ||
| 1260 | |||
| 1261 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; | ||
| 1262 | const rpcError = yield* Effect.flip( | ||
| 1263 | Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), | ||
| 1264 | ); | ||
| 1265 | assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); | ||
| 1266 | if (rpcError._tag === "EnvironmentAuthorizationError") { | ||
| 1267 | assert.equal(rpcError.requiredScope, "orchestration:read"); | ||
| 1268 | } | ||
| 1269 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1270 | ); | ||
| 1271 | |||
| 1272 | it.effect("includes CORS headers on remote auth success responses", () => | ||
| 1273 | Effect.gen(function* () { | ||
| 1274 | yield* buildAppUnderTest(); | ||
| 1275 | |||
| 1276 | const origin = crossOriginClientOrigin; | ||
| 1277 | const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken( | ||
| 1278 | defaultDesktopBootstrapToken, | ||
| 1279 | { | ||
| 1280 | headers: { origin }, | ||
| 1281 | }, | ||
| 1282 | ); | ||
| 1283 | |||
| 1284 | assert.equal(bootstrapResponse.status, 200); | ||
| 1285 | assertBrowserApiCorsResponseHeaders(bootstrapResponse.headers); | ||
| 1286 | assert.equal(tokenBody.token_type, "Bearer"); | ||
| 1287 | assert.equal(typeof tokenBody.access_token, "string"); | ||
| 1288 | |||
| 1289 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { | ||
| 1290 | headers: { | ||
| 1291 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1292 | origin, | ||
| 1293 | }, | ||
| 1294 | }); | ||
| 1295 | const sessionBody = (yield* sessionResponse.json) as { | ||
| 1296 | readonly authenticated: boolean; | ||
| 1297 | readonly sessionMethod?: string; | ||
| 1298 | }; | ||
| 1299 | |||
| 1300 | assert.equal(sessionResponse.status, 200); | ||
| 1301 | assertBrowserApiCorsResponseHeaders(sessionResponse.headers); | ||
| 1302 | assert.equal(sessionBody.authenticated, true); | ||
| 1303 | assert.equal(sessionBody.sessionMethod, "bearer-access-token"); | ||
| 1304 | |||
| 1305 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | ||
| 1306 | headers: { | ||
| 1307 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1308 | origin, | ||
| 1309 | }, | ||
| 1310 | }); | ||
| 1311 | const wsTicketBody = (yield* wsTicketResponse.json) as { | ||
| 1312 | readonly ticket: string; | ||
| 1313 | }; | ||
| 1314 | |||
| 1315 | assert.equal(wsTicketResponse.status, 200); | ||
| 1316 | assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers); | ||
| 1317 | assert.equal(typeof wsTicketBody.ticket, "string"); | ||
| 1318 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1319 | ); | ||
| 1320 | |||
| 1321 | it.effect( | ||
| 1322 | "responds to remote auth websocket-ticket preflight requests with authorization CORS headers", | ||
| 1323 | () => | ||
| 1324 | Effect.gen(function* () { | ||
| 1325 | yield* buildAppUnderTest(); | ||
| 1326 | |||
| 1327 | const response = yield* HttpClient.options("/api/auth/websocket-ticket", { | ||
| 1328 | headers: { | ||
| 1329 | origin: crossOriginClientOrigin, | ||
| 1330 | "access-control-request-method": "POST", | ||
| 1331 | "access-control-request-headers": "authorization", | ||
| 1332 | }, | ||
| 1333 | }); | ||
| 1334 | |||
| 1335 | assert.equal(response.status, 204); | ||
| 1336 | assertBrowserApiCorsPreflightHeaders(response.headers); | ||
| 1337 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1338 | ); | ||
| 1339 | |||
| 1340 | it.effect("includes CORS headers on remote websocket-ticket auth failures", () => | ||
| 1341 | Effect.gen(function* () { | ||
| 1342 | yield* buildAppUnderTest(); | ||
| 1343 | |||
| 1344 | const response = yield* HttpClient.post("/api/auth/websocket-ticket", { | ||
| 1345 | headers: { | ||
| 1346 | origin: crossOriginClientOrigin, | ||
| 1347 | }, | ||
| 1348 | }); | ||
| 1349 | const body = (yield* response.json) as { | ||
| 1350 | readonly _tag?: string; | ||
| 1351 | readonly code?: string; | ||
| 1352 | readonly reason?: string; | ||
| 1353 | readonly traceId?: string; | ||
| 1354 | }; | ||
| 1355 | |||
| 1356 | assert.equal(response.status, 401); | ||
| 1357 | assertBrowserApiCorsResponseHeaders(response.headers); | ||
| 1358 | assert.equal(body._tag, "EnvironmentAuthInvalidError"); | ||
| 1359 | assert.equal(body.code, "auth_invalid"); | ||
| 1360 | assert.equal(body.reason, "missing_credential"); | ||
| 1361 | assert.equal(typeof body.traceId, "string"); | ||
| 1362 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1363 | ); | ||
| 1364 | |||
| 1365 | it.effect("persists token exchange client display metadata for authorized-client listings", () => | 1407 | it.effect("persists token exchange client display metadata for authorized-client listings", () => |
| 1366 | Effect.gen(function* () { | 1408 | Effect.gen(function* () { |
| 1367 | yield* buildAppUnderTest({ | 1409 | yield* buildAppUnderTest({ |
| @@ -1385,7 +1427,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 17 unmodified lines | |||
| 1385 | headers: { | 1427 | headers: { |
| 1386 | "user-agent": "undici", | 1428 | "user-agent": "undici", |
| 1387 | }, | 1429 | }, |
| 1388 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | 1430 | scope: "orchestration:read orchestration:operate terminal:operate review:write", |
| 1389 | clientMetadata: { | 1431 | clientMetadata: { |
| 1390 | label: "T3 Code Mobile", | 1432 | label: "T3 Code Mobile", |
| 1391 | deviceType: "mobile", | 1433 | deviceType: "mobile", |
| @@ -1423,23 +1465,1711 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 31 unmodified lines | |||
| 1423 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1465 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1424 | ); | 1466 | ); |
| 1425 | 1467 | ||
| 1426 | it.effect("issues authenticated one-time pairing credentials for additional clients", () => | 1468 | it.effect( |
| 1469 | "exchanges a bootstrap credential for a DPoP-bound access token without bearer downgrade", | ||
| 1470 | () => | ||
| 1471 | Effect.gen(function* () { | ||
| 1472 | yield* buildAppUnderTest(); | ||
| 1473 | |||
| 1474 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1475 | const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1476 | headers: { cookie: ownerCookie }, | ||
| 1477 | body: yield* HttpBody.json({}), | ||
| 1478 | }); | ||
| 1479 | const credential = (yield* credentialResponse.json) as { readonly credential: string }; | ||
| 1480 | const tokenUrl = yield* getHttpServerUrl("/oauth/token"); | ||
| 1481 | const now = yield* DateTime.now; | ||
| 1482 | const tokenProof = makeDpopProof({ | ||
| 1483 | method: "POST", | ||
| 1484 | url: tokenUrl, | ||
| 1485 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 1486 | jti: "token-exchange-proof", | ||
| 1487 | }); | ||
| 1488 | const tokenResponse = yield* fetchEffect(tokenUrl, { | ||
| 1489 | method: "POST", | ||
| 1490 | headers: { | ||
| 1491 | "content-type": "application/x-www-form-urlencoded", | ||
| 1492 | dpop: tokenProof.proof, | ||
| 1493 | }, | ||
| 1494 | body: new URLSearchParams({ | ||
| 1495 | grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", | ||
| 1496 | subject_token: credential.credential, | ||
| 1497 | subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap", | ||
| 1498 | requested_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 1499 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 1500 | }).toString(), | ||
| 1501 | }); | ||
| 1502 | const token = yield* responseJsonEffect<{ | ||
| 1503 | readonly access_token: string; | ||
| 1504 | readonly token_type: string; | ||
| 1505 | }>(tokenResponse); | ||
| 1506 | |||
| 1507 | assert.equal(tokenResponse.status, 200); | ||
| 1508 | assert.equal(tokenResponse.headers["cache-control"], "no-store"); | ||
| 1509 | assert.equal(token.token_type, "DPoP"); | ||
| 1510 | |||
| 1511 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); | ||
| 1512 | const bearerResponse = yield* fetchEffect(sessionUrl, { | ||
| 1513 | headers: { authorization: `Bearer ${token.access_token}` }, | ||
| 1514 | }); | ||
| 1515 | const bearerState = yield* responseJsonEffect<{ readonly authenticated: boolean }>( | ||
| 1516 | bearerResponse, | ||
| 1517 | ); | ||
| 1518 | assert.equal(bearerState.authenticated, false); | ||
| 1519 | |||
| 1520 | const sessionProof = makeDpopProof({ | ||
| 1521 | method: "GET", | ||
| 1522 | url: sessionUrl, | ||
| 1523 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 1524 | jti: "session-proof", | ||
| 1525 | accessToken: token.access_token, | ||
| 1526 | privateKey: tokenProof.privateKey, | ||
| 1527 | publicJwk: tokenProof.publicJwk, | ||
| 1528 | }); | ||
| 1529 | const dpopResponse = yield* fetchEffect(sessionUrl, { | ||
| 1530 | headers: { | ||
| 1531 | authorization: `DPoP ${token.access_token}`, | ||
| 1532 | dpop: sessionProof.proof, | ||
| 1533 | }, | ||
| 1534 | }); | ||
| 1535 | const dpopState = yield* responseJsonEffect<{ | ||
| 1536 | readonly authenticated: boolean; | ||
| 1537 | readonly sessionMethod?: string; | ||
| 1538 | }>(dpopResponse); | ||
| 1539 | assert.equal(dpopState.authenticated, true); | ||
| 1540 | assert.equal(dpopState.sessionMethod, "dpop-access-token"); | ||
| 1541 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1542 | ); | ||
| 1543 | |||
| 1544 | it.effect("rejects replayed DPoP proofs across token exchanges", () => | ||
| 1427 | Effect.gen(function* () { | 1545 | Effect.gen(function* () { |
| 1428 | yield* buildAppUnderTest(); | 1546 | yield* buildAppUnderTest(); |
| 1429 | 1547 | ||
| 1430 | const response = yield* HttpClient.post("/api/auth/pairing-token", { | 1548 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); |
| 1549 | const firstCredentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1431 | headers: { | 1550 | headers: { |
| 1432 | cookie: yield* getAuthenticatedSessionCookieHeader(), | 1551 | cookie: ownerCookie, |
| 1433 | }, | 1552 | }, |
| 1434 | body: yield* HttpBody.json({}), | 1553 | body: yield* HttpBody.json({}), |
| 1435 | }); | 1554 | }); |
| 1436 | const body = (yield* response.json) as { | 1555 | const firstCredential = (yield* firstCredentialResponse.json) as { |
| 1437 | readonly credential: string; | 1556 | readonly credential: string; |
| 1438 | readonly expiresAt: string; | ||
| 1439 | }; | 1557 | }; |
| 1440 | 1558 | const secondCredentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | |
| 1441 | assert.equal(response.status, 200); | 1559 | headers: { |
| 1442 | assert.equal(typeof body.credential, "string"); | 1560 | cookie: ownerCookie, |
| 1561 | }, | ||
| 1562 | body: yield* HttpBody.json({}), | ||
| 1563 | }); | ||
| 1564 | const secondCredential = (yield* secondCredentialResponse.json) as { | ||
| 1565 | readonly credential: string; | ||
| 1566 | }; | ||
| 1567 | const tokenUrl = yield* getHttpServerUrl("/oauth/token"); | ||
| 1568 | const now = yield* DateTime.now; | ||
| 1569 | const dpop = makeDpopProof({ | ||
| 1570 | method: "POST", | ||
| 1571 | url: tokenUrl, | ||
| 1572 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 1573 | }); | ||
| 1574 | |||
| 1575 | const firstBootstrap = yield* exchangeAccessToken(firstCredential.credential, { | ||
| 1576 | headers: { | ||
| 1577 | dpop: dpop.proof, | ||
| 1578 | }, | ||
| 1579 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 1580 | }); | ||
| 1581 | const replayBootstrap = yield* exchangeAccessToken(secondCredential.credential, { | ||
| 1582 | headers: { | ||
| 1583 | dpop: dpop.proof, | ||
| 1584 | }, | ||
| 1585 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 1586 | }); | ||
| 1587 | |||
| 1588 | assert.equal(firstBootstrap.response.status, 200); | ||
| 1589 | assert.equal(replayBootstrap.response.status, 401); | ||
| 1590 | assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError"); | ||
| 1591 | assert.equal(replayBootstrap.body.code, "auth_invalid"); | ||
| 1592 | assert.equal(replayBootstrap.body.reason, "invalid_credential"); | ||
| 1593 | assert.equal(typeof replayBootstrap.body.traceId, "string"); | ||
| 1594 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1595 | ); | ||
| 1596 | |||
| 1597 | it.effect("ignores forwarded host headers when validating token exchange DPoP URLs", () => | ||
| 1598 | Effect.gen(function* () { | ||
| 1599 | yield* buildAppUnderTest(); | ||
| 1600 | |||
| 1601 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1602 | const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1603 | headers: { | ||
| 1604 | cookie: ownerCookie, | ||
| 1605 | }, | ||
| 1606 | body: yield* HttpBody.json({}), | ||
| 1607 | }); | ||
| 1608 | const credential = (yield* credentialResponse.json) as { | ||
| 1609 | readonly credential: string; | ||
| 1610 | }; | ||
| 1611 | const tokenUrl = yield* getHttpServerUrl("/oauth/token"); | ||
| 1612 | const now = yield* DateTime.now; | ||
| 1613 | const dpop = makeDpopProof({ | ||
| 1614 | method: "POST", | ||
| 1615 | url: tokenUrl, | ||
| 1616 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 1617 | }); | ||
| 1618 | |||
| 1619 | const bootstrap = yield* exchangeAccessToken(credential.credential, { | ||
| 1620 | headers: { | ||
| 1621 | dpop: dpop.proof, | ||
| 1622 | "x-forwarded-host": "environment.example.test", | ||
| 1623 | }, | ||
| 1624 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 1625 | }); | ||
| 1626 | |||
| 1627 | assert.equal(bootstrap.response.status, 200); | ||
| 1628 | assert.equal(bootstrap.body.token_type, "DPoP"); | ||
| 1629 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1630 | ); | ||
| 1631 | |||
| 1632 | it.effect("rejects token exchange DPoP proofs bound to spoofed forwarded hosts", () => | ||
| 1633 | Effect.gen(function* () { | ||
| 1634 | yield* buildAppUnderTest(); | ||
| 1635 | |||
| 1636 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1637 | const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1638 | headers: { | ||
| 1639 | cookie: ownerCookie, | ||
| 1640 | }, | ||
| 1641 | body: yield* HttpBody.json({}), | ||
| 1642 | }); | ||
| 1643 | const credential = (yield* credentialResponse.json) as { | ||
| 1644 | readonly credential: string; | ||
| 1645 | }; | ||
| 1646 | const tokenUrl = yield* getHttpServerUrl("/oauth/token"); | ||
| 1647 | const spoofedUrl = new URL(tokenUrl); | ||
| 1648 | spoofedUrl.hostname = "environment.example.test"; | ||
| 1649 | const now = yield* DateTime.now; | ||
| 1650 | const dpop = makeDpopProof({ | ||
| 1651 | method: "POST", | ||
| 1652 | url: spoofedUrl.href, | ||
| 1653 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 1654 | }); | ||
| 1655 | |||
| 1656 | const bootstrap = yield* exchangeAccessToken(credential.credential, { | ||
| 1657 | headers: { | ||
| 1658 | dpop: dpop.proof, | ||
| 1659 | "x-forwarded-host": spoofedUrl.host, | ||
| 1660 | }, | ||
| 1661 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 1662 | }); | ||
| 1663 | |||
| 1664 | assert.equal(bootstrap.response.status, 401); | ||
| 1665 | assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError"); | ||
| 1666 | assert.equal(bootstrap.body.code, "auth_invalid"); | ||
| 1667 | assert.equal(bootstrap.body.reason, "invalid_credential"); | ||
| 1668 | assert.equal(typeof bootstrap.body.traceId, "string"); | ||
| 1669 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1670 | ); | ||
| 1671 | |||
| 1672 | it.effect("rejects cloud link proofs for non-loopback managed endpoint origins", () => | ||
| 1673 | Effect.gen(function* () { | ||
| 1674 | yield* buildAppUnderTest(); | ||
| 1675 | |||
| 1676 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1677 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1678 | const linkProofResponse = yield* fetchEffect(linkProofUrl, { | ||
| 1679 | method: "POST", | ||
| 1680 | headers: { | ||
| 1681 | cookie: ownerCookie, | ||
| 1682 | "content-type": "application/json", | ||
| 1683 | }, | ||
| 1684 | body: jsonRequestBody({ | ||
| 1685 | challenge: "relay-link-challenge", | ||
| 1686 | relayIssuer: "https://relay.example.test", | ||
| 1687 | endpoint: { | ||
| 1688 | httpBaseUrl: "https://environment.example.test/", | ||
| 1689 | wsBaseUrl: "wss://environment.example.test/ws", | ||
| 1690 | providerKind: "manual", | ||
| 1691 | }, | ||
| 1692 | origin: { | ||
| 1693 | localHttpHost: "192.168.1.42", | ||
| 1694 | localHttpPort: 3773, | ||
| 1695 | }, | ||
| 1696 | }), | ||
| 1697 | }); | ||
| 1698 | const body = yield* responseJsonEffect<{ | ||
| 1699 | readonly _tag?: string; | ||
| 1700 | readonly message?: string; | ||
| 1701 | }>(linkProofResponse); | ||
| 1702 | |||
| 1703 | assert.equal(linkProofResponse.status, 400); | ||
| 1704 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1705 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1706 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1707 | ); | ||
| 1708 | |||
| 1709 | it.effect("rejects managed cloud link proofs for manual endpoint providers", () => | ||
| 1710 | Effect.gen(function* () { | ||
| 1711 | yield* buildAppUnderTest(); | ||
| 1712 | |||
| 1713 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1714 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1715 | const serverPort = Number(new URL(linkProofUrl).port); | ||
| 1716 | const linkProofResponse = yield* fetchEffect(linkProofUrl, { | ||
| 1717 | method: "POST", | ||
| 1718 | headers: { | ||
| 1719 | cookie: ownerCookie, | ||
| 1720 | "content-type": "application/json", | ||
| 1721 | }, | ||
| 1722 | body: jsonRequestBody({ | ||
| 1723 | challenge: "relay-link-challenge", | ||
| 1724 | relayIssuer: "https://relay.example.test", | ||
| 1725 | endpoint: { | ||
| 1726 | httpBaseUrl: linkProofUrl.replace("/api/cloud/link-proof", ""), | ||
| 1727 | wsBaseUrl: linkProofUrl | ||
| 1728 | .replace("http://", "ws://") | ||
| 1729 | .replace("/api/cloud/link-proof", "/ws"), | ||
| 1730 | providerKind: "manual", | ||
| 1731 | }, | ||
| 1732 | origin: { | ||
| 1733 | localHttpHost: "127.0.0.1", | ||
| 1734 | localHttpPort: serverPort, | ||
| 1735 | }, | ||
| 1736 | }), | ||
| 1737 | }); | ||
| 1738 | const body = yield* responseJsonEffect<{ | ||
| 1739 | readonly _tag?: string; | ||
| 1740 | readonly message?: string; | ||
| 1741 | }>(linkProofResponse); | ||
| 1742 | |||
| 1743 | assert.equal(linkProofResponse.status, 400); | ||
| 1744 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1745 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1746 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1747 | ); | ||
| 1748 | |||
| 1749 | it.effect("rejects cloud link proofs requested through a public managed endpoint", () => | ||
| 1750 | Effect.gen(function* () { | ||
| 1751 | yield* buildAppUnderTest(); | ||
| 1752 | |||
| 1753 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1754 | const serverPort = Number(new URL(linkProofUrl).port); | ||
| 1755 | const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { | ||
| 1756 | headers: { | ||
| 1757 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 1758 | "content-type": "application/json", | ||
| 1759 | host: "environment.example.test", | ||
| 1760 | "x-forwarded-host": "environment.example.test", | ||
| 1761 | "x-forwarded-proto": "https", | ||
| 1762 | }, | ||
| 1763 | body: HttpBody.text( | ||
| 1764 | jsonRequestBody({ | ||
| 1765 | challenge: "relay-link-challenge", | ||
| 1766 | relayIssuer: "https://relay.example.test", | ||
| 1767 | endpoint: { | ||
| 1768 | httpBaseUrl: "https://environment.example.test/", | ||
| 1769 | wsBaseUrl: "wss://environment.example.test/ws", | ||
| 1770 | providerKind: "manual", | ||
| 1771 | }, | ||
| 1772 | origin: { | ||
| 1773 | localHttpHost: "127.0.0.1", | ||
| 1774 | localHttpPort: serverPort, | ||
| 1775 | }, | ||
| 1776 | }), | ||
| 1777 | "application/json", | ||
| 1778 | ), | ||
| 1779 | }); | ||
| 1780 | const body = (yield* linkProofResponse.json) as { | ||
| 1781 | readonly _tag?: string; | ||
| 1782 | readonly message?: string; | ||
| 1783 | }; | ||
| 1784 | |||
| 1785 | assert.equal(linkProofResponse.status, 400); | ||
| 1786 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1787 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1788 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1789 | ); | ||
| 1790 | |||
| 1791 | it.effect( | ||
| 1792 | "rejects cloud link proofs when a public request spoofs loopback forwarded headers", | ||
| 1793 | () => | ||
| 1794 | Effect.gen(function* () { | ||
| 1795 | yield* buildAppUnderTest(); | ||
| 1796 | |||
| 1797 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1798 | const serverPort = Number(new URL(linkProofUrl).port); | ||
| 1799 | const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { | ||
| 1800 | headers: { | ||
| 1801 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 1802 | "content-type": "application/json", | ||
| 1803 | host: "environment.example.test", | ||
| 1804 | "x-forwarded-host": `127.0.0.1:${serverPort}`, | ||
| 1805 | "x-forwarded-proto": "http", | ||
| 1806 | }, | ||
| 1807 | body: HttpBody.text( | ||
| 1808 | jsonRequestBody({ | ||
| 1809 | challenge: "relay-link-challenge", | ||
| 1810 | relayIssuer: "https://relay.example.test", | ||
| 1811 | endpoint: { | ||
| 1812 | httpBaseUrl: "https://environment.example.test/", | ||
| 1813 | wsBaseUrl: "wss://environment.example.test/ws", | ||
| 1814 | providerKind: "manual", | ||
| 1815 | }, | ||
| 1816 | origin: { | ||
| 1817 | localHttpHost: "127.0.0.1", | ||
| 1818 | localHttpPort: serverPort, | ||
| 1819 | }, | ||
| 1820 | }), | ||
| 1821 | "application/json", | ||
| 1822 | ), | ||
| 1823 | }); | ||
| 1824 | const body = (yield* linkProofResponse.json) as { | ||
| 1825 | readonly _tag?: string; | ||
| 1826 | readonly message?: string; | ||
| 1827 | }; | ||
| 1828 | |||
| 1829 | assert.equal(linkProofResponse.status, 400); | ||
| 1830 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1831 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1832 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1833 | ); | ||
| 1834 | |||
| 1835 | it.effect("rejects cloud link proofs with malformed forwarded request hosts", () => | ||
| 1836 | Effect.gen(function* () { | ||
| 1837 | yield* buildAppUnderTest(); | ||
| 1838 | |||
| 1839 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1840 | const serverPort = Number(new URL(linkProofUrl).port); | ||
| 1841 | const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { | ||
| 1842 | headers: { | ||
| 1843 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 1844 | "content-type": "application/json", | ||
| 1845 | host: "bad host", | ||
| 1846 | "x-forwarded-host": "bad host", | ||
| 1847 | "x-forwarded-proto": "https", | ||
| 1848 | }, | ||
| 1849 | body: HttpBody.text( | ||
| 1850 | jsonRequestBody({ | ||
| 1851 | challenge: "relay-link-challenge", | ||
| 1852 | relayIssuer: "https://relay.example.test", | ||
| 1853 | endpoint: { | ||
| 1854 | httpBaseUrl: "https://environment.example.test/", | ||
| 1855 | wsBaseUrl: "wss://environment.example.test/ws", | ||
| 1856 | providerKind: "manual", | ||
| 1857 | }, | ||
| 1858 | origin: { | ||
| 1859 | localHttpHost: "127.0.0.1", | ||
| 1860 | localHttpPort: serverPort, | ||
| 1861 | }, | ||
| 1862 | }), | ||
| 1863 | "application/json", | ||
| 1864 | ), | ||
| 1865 | }); | ||
| 1866 | const body = (yield* linkProofResponse.json) as { | ||
| 1867 | readonly _tag?: string; | ||
| 1868 | readonly message?: string; | ||
| 1869 | }; | ||
| 1870 | |||
| 1871 | assert.equal(linkProofResponse.status, 400); | ||
| 1872 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1873 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1874 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1875 | ); | ||
| 1876 | |||
| 1877 | it.effect("rejects local cloud link proofs for a different loopback port", () => | ||
| 1878 | Effect.gen(function* () { | ||
| 1879 | yield* buildAppUnderTest(); | ||
| 1880 | |||
| 1881 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1882 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1883 | const serverPort = Number(new URL(linkProofUrl).port); | ||
| 1884 | const linkProofResponse = yield* fetchEffect(linkProofUrl, { | ||
| 1885 | method: "POST", | ||
| 1886 | headers: { | ||
| 1887 | cookie: ownerCookie, | ||
| 1888 | "content-type": "application/json", | ||
| 1889 | }, | ||
| 1890 | body: jsonRequestBody({ | ||
| 1891 | challenge: "relay-link-challenge", | ||
| 1892 | relayIssuer: "https://relay.example.test", | ||
| 1893 | endpoint: { | ||
| 1894 | httpBaseUrl: "https://environment.example.test/", | ||
| 1895 | wsBaseUrl: "wss://environment.example.test/ws", | ||
| 1896 | providerKind: "manual", | ||
| 1897 | }, | ||
| 1898 | origin: { | ||
| 1899 | localHttpHost: "127.0.0.1", | ||
| 1900 | localHttpPort: serverPort === 65_535 ? serverPort - 1 : serverPort + 1, | ||
| 1901 | }, | ||
| 1902 | }), | ||
| 1903 | }); | ||
| 1904 | const body = yield* responseJsonEffect<{ | ||
| 1905 | readonly _tag?: string; | ||
| 1906 | readonly message?: string; | ||
| 1907 | }>(linkProofResponse); | ||
| 1908 | |||
| 1909 | assert.equal(linkProofResponse.status, 400); | ||
| 1910 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1911 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1912 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1913 | ); | ||
| 1914 | |||
| 1915 | it.effect("rejects managed relay configuration reads without relay management scope", () => | ||
| 1916 | Effect.gen(function* () { | ||
| 1917 | yield* buildAppUnderTest(); | ||
| 1918 | |||
| 1919 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1920 | const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1921 | headers: { cookie: ownerCookie }, | ||
| 1922 | body: yield* HttpBody.json({}), | ||
| 1923 | }); | ||
| 1924 | const credential = (yield* credentialResponse.json) as { readonly credential: string }; | ||
| 1925 | const pairedCookie = yield* getAuthenticatedSessionCookieHeader(credential.credential); | ||
| 1926 | const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); | ||
| 1927 | const response = yield* fetchEffect(linkStateUrl, { | ||
| 1928 | headers: { cookie: pairedCookie }, | ||
| 1929 | }); | ||
| 1930 | const body = yield* responseJsonEffect<{ | ||
| 1931 | readonly _tag?: string; | ||
| 1932 | readonly code?: string; | ||
| 1933 | readonly requiredScope?: string; | ||
| 1934 | readonly traceId?: string; | ||
| 1935 | }>(response); | ||
| 1936 | |||
| 1937 | assert.equal(response.status, 403); | ||
| 1938 | assert.equal(body._tag, "EnvironmentScopeRequiredError"); | ||
| 1939 | assert.equal(body.code, "insufficient_scope"); | ||
| 1940 | assert.equal(body.requiredScope, "relay:manage"); | ||
| 1941 | assert.equal(typeof body.traceId, "string"); | ||
| 1942 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1943 | ); | ||
| 1944 | |||
| 1945 | it.effect("rejects relay config with an invalid cloud mint public key", () => | ||
| 1946 | Effect.gen(function* () { | ||
| 1947 | yield* buildAppUnderTest(); | ||
| 1948 | |||
| 1949 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1950 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 1951 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 1952 | method: "POST", | ||
| 1953 | headers: { | ||
| 1954 | cookie: ownerCookie, | ||
| 1955 | "content-type": "application/json", | ||
| 1956 | }, | ||
| 1957 | body: jsonRequestBody({ | ||
| 1958 | relayUrl: "https://relay.example.test", | ||
| 1959 | cloudUserId: "user_123", | ||
| 1960 | environmentCredential: "t3env_test_credential", | ||
| 1961 | cloudMintPublicKey: "not-a-public-key", | ||
| 1962 | endpointRuntime: null, | ||
| 1963 | }), | ||
| 1964 | }); | ||
| 1965 | const body = yield* responseJsonEffect<{ | ||
| 1966 | readonly _tag?: string; | ||
| 1967 | readonly message?: string; | ||
| 1968 | }>(relayConfigResponse); | ||
| 1969 | |||
| 1970 | assert.equal(relayConfigResponse.status, 400); | ||
| 1971 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1972 | assert.equal(body.message, "Cloud mint public key must be a valid Ed25519 public key."); | ||
| 1973 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1974 | ); | ||
| 1975 | |||
| 1976 | it.effect("rejects relay config with insecure relay metadata or empty credentials", () => | ||
| 1977 | Effect.gen(function* () { | ||
| 1978 | yield* buildAppUnderTest(); | ||
| 1979 | |||
| 1980 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 1981 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 1982 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 1983 | }); | ||
| 1984 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1985 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 1986 | const postRelayConfig = (body: { | ||
| 1987 | readonly relayUrl: string; | ||
| 1988 | readonly relayIssuer?: string; | ||
| 1989 | readonly cloudUserId: string; | ||
| 1990 | readonly environmentCredential: string; | ||
| 1991 | }) => | ||
| 1992 | fetchEffect(relayConfigUrl, { | ||
| 1993 | method: "POST", | ||
| 1994 | headers: { | ||
| 1995 | cookie: ownerCookie, | ||
| 1996 | "content-type": "application/json", | ||
| 1997 | }, | ||
| 1998 | body: jsonRequestBody({ | ||
| 1999 | ...body, | ||
| 2000 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2001 | endpointRuntime: null, | ||
| 2002 | }), | ||
| 2003 | }); | ||
| 2004 | |||
| 2005 | const insecureRelayUrl = yield* postRelayConfig({ | ||
| 2006 | relayUrl: "http://relay.example.test", | ||
| 2007 | cloudUserId: "user_123", | ||
| 2008 | environmentCredential: "t3env_test_credential", | ||
| 2009 | }); | ||
| 2010 | const insecureRelayIssuer = yield* postRelayConfig({ | ||
| 2011 | relayUrl: "https://relay.example.test", | ||
| 2012 | cloudUserId: "user_123", | ||
| 2013 | relayIssuer: "http://relay.example.test", | ||
| 2014 | environmentCredential: "t3env_test_credential", | ||
| 2015 | }); | ||
| 2016 | const emptyCredential = yield* postRelayConfig({ | ||
| 2017 | relayUrl: "https://relay.example.test", | ||
| 2018 | cloudUserId: "user_123", | ||
| 2019 | environmentCredential: " ", | ||
| 2020 | }); | ||
| 2021 | const insecureRelayUrlBody = yield* responseJsonEffect<{ readonly message?: string }>( | ||
| 2022 | insecureRelayUrl, | ||
| 2023 | ); | ||
| 2024 | const insecureRelayIssuerBody = yield* responseJsonEffect<{ readonly message?: string }>( | ||
| 2025 | insecureRelayIssuer, | ||
| 2026 | ); | ||
| 2027 | const emptyCredentialBody = yield* responseJsonEffect<{ readonly message?: string }>( | ||
| 2028 | emptyCredential, | ||
| 2029 | ); | ||
| 2030 | |||
| 2031 | assert.equal(insecureRelayUrl.status, 400); | ||
| 2032 | assert.equal(insecureRelayUrlBody.message, "Relay URL must be a secure absolute HTTPS URL."); | ||
| 2033 | assert.equal(insecureRelayIssuer.status, 400); | ||
| 2034 | assert.equal( | ||
| 2035 | insecureRelayIssuerBody.message, | ||
| 2036 | "Relay issuer must be a secure absolute HTTPS URL.", | ||
| 2037 | ); | ||
| 2038 | assert.equal(emptyCredential.status, 400); | ||
| 2039 | assert.equal(emptyCredentialBody.message, "Relay environment credential is required."); | ||
| 2040 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2041 | ); | ||
| 2042 | |||
| 2043 | it.effect("rejects relay config replacement from a different cloud account", () => | ||
| 2044 | Effect.gen(function* () { | ||
| 2045 | yield* buildAppUnderTest(); | ||
| 2046 | |||
| 2047 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2048 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2049 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2050 | }); | ||
| 2051 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2052 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2053 | const postRelayConfig = (cloudUserId: string, environmentCredential: string) => | ||
| 2054 | fetchEffect(relayConfigUrl, { | ||
| 2055 | method: "POST", | ||
| 2056 | headers: { | ||
| 2057 | cookie: ownerCookie, | ||
| 2058 | "content-type": "application/json", | ||
| 2059 | }, | ||
| 2060 | body: jsonRequestBody({ | ||
| 2061 | relayUrl: "https://relay.example.test", | ||
| 2062 | cloudUserId, | ||
| 2063 | environmentCredential, | ||
| 2064 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2065 | endpointRuntime: null, | ||
| 2066 | }), | ||
| 2067 | }); | ||
| 2068 | |||
| 2069 | const firstResponse = yield* postRelayConfig("user_123", "t3env_first_credential"); | ||
| 2070 | const replacementResponse = yield* postRelayConfig("user_456", "t3env_second_credential"); | ||
| 2071 | const replacementBody = yield* responseJsonEffect<{ | ||
| 2072 | readonly _tag?: string; | ||
| 2073 | readonly message?: string; | ||
| 2074 | }>(replacementResponse); | ||
| 2075 | |||
| 2076 | assert.equal(firstResponse.status, 200); | ||
| 2077 | assert.equal(replacementResponse.status, 409); | ||
| 2078 | assert.equal(replacementBody._tag, "EnvironmentHttpConflictError"); | ||
| 2079 | assert.equal( | ||
| 2080 | replacementBody.message, | ||
| 2081 | "This environment is already linked to a different cloud account. Unlink it before switching accounts.", | ||
| 2082 | ); | ||
| 2083 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2084 | ); | ||
| 2085 | |||
| 2086 | it.effect("reports local cloud link state from persisted relay config", () => | ||
| 2087 | Effect.gen(function* () { | ||
| 2088 | yield* buildAppUnderTest(); | ||
| 2089 | |||
| 2090 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2091 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2092 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2093 | }); | ||
| 2094 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2095 | const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); | ||
| 2096 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2097 | |||
| 2098 | const initialResponse = yield* fetchEffect(linkStateUrl, { | ||
| 2099 | headers: { | ||
| 2100 | cookie: ownerCookie, | ||
| 2101 | }, | ||
| 2102 | }); | ||
| 2103 | const initialBody = yield* responseJsonEffect<{ | ||
| 2104 | readonly linked?: boolean; | ||
| 2105 | readonly cloudUserId?: string | null; | ||
| 2106 | }>(initialResponse); | ||
| 2107 | assert.equal(initialResponse.status, 200); | ||
| 2108 | assert.equal(initialBody.linked, false); | ||
| 2109 | assert.equal(initialBody.cloudUserId, null); | ||
| 2110 | |||
| 2111 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2112 | method: "POST", | ||
| 2113 | headers: { | ||
| 2114 | cookie: ownerCookie, | ||
| 2115 | "content-type": "application/json", | ||
| 2116 | }, | ||
| 2117 | body: jsonRequestBody({ | ||
| 2118 | relayUrl: "https://transport.example.test", | ||
| 2119 | relayIssuer: "https://relay.example.test", | ||
| 2120 | cloudUserId: "user_123", | ||
| 2121 | environmentCredential: "t3env_test_credential", | ||
| 2122 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2123 | endpointRuntime: null, | ||
| 2124 | }), | ||
| 2125 | }); | ||
| 2126 | assert.equal(relayConfigResponse.status, 200); | ||
| 2127 | |||
| 2128 | const linkedResponse = yield* fetchEffect(linkStateUrl, { | ||
| 2129 | headers: { | ||
| 2130 | cookie: ownerCookie, | ||
| 2131 | }, | ||
| 2132 | }); | ||
| 2133 | const linkedBody = yield* responseJsonEffect<{ | ||
| 2134 | readonly linked?: boolean; | ||
| 2135 | readonly cloudUserId?: string | null; | ||
| 2136 | readonly relayUrl?: string | null; | ||
| 2137 | readonly relayIssuer?: string | null; | ||
| 2138 | }>(linkedResponse); | ||
| 2139 | |||
| 2140 | assert.equal(linkedResponse.status, 200); | ||
| 2141 | assert.equal(linkedBody.linked, true); | ||
| 2142 | assert.equal(linkedBody.cloudUserId, "user_123"); | ||
| 2143 | assert.equal(linkedBody.relayUrl, "https://transport.example.test"); | ||
| 2144 | assert.equal(linkedBody.relayIssuer, "https://relay.example.test"); | ||
| 2145 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2146 | ); | ||
| 2147 | |||
| 2148 | it.effect("unlinks local cloud state and disables the managed endpoint runtime", () => | ||
| 2149 | Effect.gen(function* () { | ||
| 2150 | const appliedRuntimeConfigs: Array<unknown> = []; | ||
| 2151 | yield* buildAppUnderTest({ | ||
| 2152 | layers: { | ||
| 2153 | cloudManagedEndpointRuntime: { | ||
| 2154 | applyConfig: (config) => { | ||
| 2155 | appliedRuntimeConfigs.push(config); | ||
| 2156 | if (!config) { | ||
| 2157 | return Effect.succeed({ status: "disabled" }); | ||
| 2158 | } | ||
| 2159 | return Effect.succeed({ | ||
| 2160 | status: "running", | ||
| 2161 | providerKind: "cloudflare_tunnel", | ||
| 2162 | pid: 123, | ||
| 2163 | ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), | ||
| 2164 | ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), | ||
| 2165 | }); | ||
| 2166 | }, | ||
| 2167 | }, | ||
| 2168 | }, | ||
| 2169 | }); | ||
| 2170 | |||
| 2171 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2172 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2173 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2174 | }); | ||
| 2175 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2176 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2177 | const unlinkUrl = yield* getHttpServerUrl("/api/cloud/unlink"); | ||
| 2178 | const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); | ||
| 2179 | |||
| 2180 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2181 | method: "POST", | ||
| 2182 | headers: { | ||
| 2183 | cookie: ownerCookie, | ||
| 2184 | "content-type": "application/json", | ||
| 2185 | }, | ||
| 2186 | body: jsonRequestBody({ | ||
| 2187 | relayUrl: "https://transport.example.test", | ||
| 2188 | relayIssuer: "https://relay.example.test", | ||
| 2189 | cloudUserId: "user_123", | ||
| 2190 | environmentCredential: "t3env_test_credential", | ||
| 2191 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2192 | endpointRuntime: { | ||
| 2193 | providerKind: "cloudflare_tunnel", | ||
| 2194 | connectorToken: "connector-token", | ||
| 2195 | tunnelId: "tunnel-id", | ||
| 2196 | tunnelName: "tunnel-name", | ||
| 2197 | }, | ||
| 2198 | }), | ||
| 2199 | }); | ||
| 2200 | assert.equal(relayConfigResponse.status, 200); | ||
| 2201 | |||
| 2202 | const unlinkResponse = yield* fetchEffect(unlinkUrl, { | ||
| 2203 | method: "POST", | ||
| 2204 | headers: { | ||
| 2205 | cookie: ownerCookie, | ||
| 2206 | }, | ||
| 2207 | }); | ||
| 2208 | const unlinkBody = yield* responseJsonEffect<{ | ||
| 2209 | readonly ok?: boolean; | ||
| 2210 | readonly endpointRuntimeStatus?: { readonly status?: string }; | ||
| 2211 | }>(unlinkResponse); | ||
| 2212 | assert.equal(unlinkResponse.status, 200); | ||
| 2213 | assert.equal(unlinkBody.ok, true); | ||
| 2214 | assert.equal(unlinkBody.endpointRuntimeStatus?.status, "disabled"); | ||
| 2215 | |||
| 2216 | const linkStateResponse = yield* fetchEffect(linkStateUrl, { | ||
| 2217 | headers: { | ||
| 2218 | cookie: ownerCookie, | ||
| 2219 | }, | ||
| 2220 | }); | ||
| 2221 | const linkStateBody = yield* responseJsonEffect<{ | ||
| 2222 | readonly linked?: boolean; | ||
| 2223 | readonly cloudUserId?: string | null; | ||
| 2224 | readonly relayUrl?: string | null; | ||
| 2225 | readonly relayIssuer?: string | null; | ||
| 2226 | }>(linkStateResponse); | ||
| 2227 | assert.equal(linkStateResponse.status, 200); | ||
| 2228 | assert.equal(linkStateBody.linked, false); | ||
| 2229 | assert.equal(linkStateBody.cloudUserId, null); | ||
| 2230 | assert.equal(linkStateBody.relayUrl, null); | ||
| 2231 | assert.equal(linkStateBody.relayIssuer, null); | ||
| 2232 | assert.deepEqual(appliedRuntimeConfigs, [ | ||
| 2233 | { | ||
| 2234 | providerKind: "cloudflare_tunnel", | ||
| 2235 | connectorToken: "connector-token", | ||
| 2236 | tunnelId: "tunnel-id", | ||
| 2237 | tunnelName: "tunnel-name", | ||
| 2238 | }, | ||
| 2239 | null, | ||
| 2240 | ]); | ||
| 2241 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2242 | ); | ||
| 2243 | |||
| 2244 | it.effect("rejects replayed cloud mint requests atomically", () => | ||
| 2245 | Effect.gen(function* () { | ||
| 2246 | yield* buildAppUnderTest(); | ||
| 2247 | |||
| 2248 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2249 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2250 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2251 | }); | ||
| 2252 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2253 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2254 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2255 | method: "POST", | ||
| 2256 | headers: { | ||
| 2257 | cookie: ownerCookie, | ||
| 2258 | "content-type": "application/json", | ||
| 2259 | }, | ||
| 2260 | body: jsonRequestBody({ | ||
| 2261 | relayUrl: "https://relay.example.test", | ||
| 2262 | cloudUserId: "user_123", | ||
| 2263 | environmentCredential: "t3env_test_credential", | ||
| 2264 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2265 | endpointRuntime: null, | ||
| 2266 | }), | ||
| 2267 | }); | ||
| 2268 | assert.equal(relayConfigResponse.status, 200); | ||
| 2269 | |||
| 2270 | const now = yield* DateTime.now; | ||
| 2271 | const request = makeCloudMintCredentialRequest({ | ||
| 2272 | privateKey: cloudKeyPair.privateKey, | ||
| 2273 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2274 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2275 | nonce: "cloud-mint-nonce-1", | ||
| 2276 | issuedAt: DateTime.formatIso(now), | ||
| 2277 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2278 | }); | ||
| 2279 | const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential"); | ||
| 2280 | const postMint = () => | ||
| 2281 | fetchEffect(mintUrl, { | ||
| 2282 | method: "POST", | ||
| 2283 | headers: { | ||
| 2284 | "content-type": "application/json", | ||
| 2285 | }, | ||
| 2286 | body: jsonRequestBody(request), | ||
| 2287 | }); | ||
| 2288 | |||
| 2289 | const firstResponse = yield* postMint(); | ||
| 2290 | const replayResponse = yield* postMint(); | ||
| 2291 | const replayBody = yield* responseJsonEffect<{ | ||
| 2292 | readonly _tag?: string; | ||
| 2293 | readonly message?: string; | ||
| 2294 | }>(replayResponse); | ||
| 2295 | |||
| 2296 | assert.equal(firstResponse.status, 200); | ||
| 2297 | assert.equal(replayResponse.status, 409); | ||
| 2298 | assert.equal(replayBody._tag, "EnvironmentHttpConflictError"); | ||
| 2299 | assert.equal(replayBody.message, "Cloud mint request was already consumed."); | ||
| 2300 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2301 | ); | ||
| 2302 | |||
| 2303 | it.effect("serves the documented T3 Cloud mint credential endpoint", () => | ||
| 2304 | Effect.gen(function* () { | ||
| 2305 | yield* buildAppUnderTest(); | ||
| 2306 | |||
| 2307 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2308 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2309 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2310 | }); | ||
| 2311 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2312 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2313 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2314 | method: "POST", | ||
| 2315 | headers: { | ||
| 2316 | cookie: ownerCookie, | ||
| 2317 | "content-type": "application/json", | ||
| 2318 | }, | ||
| 2319 | body: jsonRequestBody({ | ||
| 2320 | relayUrl: "https://relay.example.test", | ||
| 2321 | cloudUserId: "user_123", | ||
| 2322 | environmentCredential: "t3env_test_credential", | ||
| 2323 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2324 | endpointRuntime: null, | ||
| 2325 | }), | ||
| 2326 | }); | ||
| 2327 | assert.equal(relayConfigResponse.status, 200); | ||
| 2328 | |||
| 2329 | const now = yield* DateTime.now; | ||
| 2330 | const request = makeCloudMintCredentialRequest({ | ||
| 2331 | privateKey: cloudKeyPair.privateKey, | ||
| 2332 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2333 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2334 | jti: "cloud-mint-jti-documented-endpoint", | ||
| 2335 | nonce: "cloud-mint-nonce-documented-endpoint", | ||
| 2336 | issuedAt: DateTime.formatIso(now), | ||
| 2337 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2338 | }); | ||
| 2339 | const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); | ||
| 2340 | const response = yield* fetchEffect(mintUrl, { | ||
| 2341 | method: "POST", | ||
| 2342 | headers: { | ||
| 2343 | "content-type": "application/json", | ||
| 2344 | }, | ||
| 2345 | body: jsonRequestBody(request), | ||
| 2346 | }); | ||
| 2347 | |||
| 2348 | assert.equal(response.status, 200); | ||
| 2349 | const body = yield* responseJsonEffect<{ | ||
| 2350 | readonly credential?: string; | ||
| 2351 | readonly proof?: string; | ||
| 2352 | }>(response); | ||
| 2353 | assert.equal(typeof body.credential, "string"); | ||
| 2354 | assert.equal(typeof body.proof, "string"); | ||
| 2355 | assert.equal( | ||
| 2356 | decodeCompactJwtPayload<{ readonly requestNonce?: string }>(body.proof!).requestNonce, | ||
| 2357 | "cloud-mint-nonce-documented-endpoint", | ||
| 2358 | ); | ||
| 2359 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2360 | ); | ||
| 2361 | |||
| 2362 | it.effect("serves signed T3 Cloud environment health checks", () => | ||
| 2363 | Effect.gen(function* () { | ||
| 2364 | yield* buildAppUnderTest(); | ||
| 2365 | |||
| 2366 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2367 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2368 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2369 | }); | ||
| 2370 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2371 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2372 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2373 | method: "POST", | ||
| 2374 | headers: { | ||
| 2375 | cookie: ownerCookie, | ||
| 2376 | "content-type": "application/json", | ||
| 2377 | }, | ||
| 2378 | body: jsonRequestBody({ | ||
| 2379 | relayUrl: "https://relay.example.test", | ||
| 2380 | cloudUserId: "user_123", | ||
| 2381 | environmentCredential: "t3env_test_credential", | ||
| 2382 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2383 | endpointRuntime: null, | ||
| 2384 | }), | ||
| 2385 | }); | ||
| 2386 | assert.equal(relayConfigResponse.status, 200); | ||
| 2387 | |||
| 2388 | const now = yield* DateTime.now; | ||
| 2389 | const request = makeCloudEnvironmentHealthRequest({ | ||
| 2390 | privateKey: cloudKeyPair.privateKey, | ||
| 2391 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2392 | jti: "cloud-health-jti-documented-endpoint", | ||
| 2393 | nonce: "cloud-health-nonce-documented-endpoint", | ||
| 2394 | issuedAt: DateTime.formatIso(now), | ||
| 2395 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2396 | }); | ||
| 2397 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 2398 | const response = yield* fetchEffect(healthUrl, { | ||
| 2399 | method: "POST", | ||
| 2400 | headers: { | ||
| 2401 | "content-type": "application/json", | ||
| 2402 | }, | ||
| 2403 | body: jsonRequestBody(request), | ||
| 2404 | }); | ||
| 2405 | |||
| 2406 | assert.equal(response.status, 200); | ||
| 2407 | const body = yield* responseJsonEffect<{ | ||
| 2408 | readonly status?: string; | ||
| 2409 | readonly descriptor?: { readonly environmentId?: string }; | ||
| 2410 | readonly proof?: string; | ||
| 2411 | }>(response); | ||
| 2412 | assert.equal(body.status, "online"); | ||
| 2413 | assert.equal(body.descriptor?.environmentId, testEnvironmentDescriptor.environmentId); | ||
| 2414 | assert.equal(typeof body.proof, "string"); | ||
| 2415 | assert.equal( | ||
| 2416 | decodeCompactJwtPayload<{ readonly requestNonce?: string }>(body.proof!).requestNonce, | ||
| 2417 | "cloud-health-nonce-documented-endpoint", | ||
| 2418 | ); | ||
| 2419 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2420 | ); | ||
| 2421 | |||
| 2422 | it.effect("rejects replayed cloud health requests atomically", () => | ||
| 2423 | Effect.gen(function* () { | ||
| 2424 | yield* buildAppUnderTest(); | ||
| 2425 | |||
| 2426 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2427 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2428 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2429 | }); | ||
| 2430 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2431 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2432 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2433 | method: "POST", | ||
| 2434 | headers: { | ||
| 2435 | cookie: ownerCookie, | ||
| 2436 | "content-type": "application/json", | ||
| 2437 | }, | ||
| 2438 | body: jsonRequestBody({ | ||
| 2439 | relayUrl: "https://relay.example.test", | ||
| 2440 | cloudUserId: "user_123", | ||
| 2441 | environmentCredential: "t3env_test_credential", | ||
| 2442 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2443 | endpointRuntime: null, | ||
| 2444 | }), | ||
| 2445 | }); | ||
| 2446 | assert.equal(relayConfigResponse.status, 200); | ||
| 2447 | |||
| 2448 | const now = yield* DateTime.now; | ||
| 2449 | const request = makeCloudEnvironmentHealthRequest({ | ||
| 2450 | privateKey: cloudKeyPair.privateKey, | ||
| 2451 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2452 | jti: "cloud-health-jti-replay", | ||
| 2453 | nonce: "cloud-health-nonce-replay", | ||
| 2454 | issuedAt: DateTime.formatIso(now), | ||
| 2455 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2456 | }); | ||
| 2457 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 2458 | const postHealth = () => | ||
| 2459 | fetchEffect(healthUrl, { | ||
| 2460 | method: "POST", | ||
| 2461 | headers: { | ||
| 2462 | "content-type": "application/json", | ||
| 2463 | }, | ||
| 2464 | body: jsonRequestBody(request), | ||
| 2465 | }); | ||
| 2466 | |||
| 2467 | const firstResponse = yield* postHealth(); | ||
| 2468 | const replayResponse = yield* postHealth(); | ||
| 2469 | const replayBody = yield* responseJsonEffect<{ | ||
| 2470 | readonly _tag?: string; | ||
| 2471 | readonly message?: string; | ||
| 2472 | }>(replayResponse); | ||
| 2473 | |||
| 2474 | assert.equal(firstResponse.status, 200); | ||
| 2475 | assert.equal(replayResponse.status, 409); | ||
| 2476 | assert.equal(replayBody._tag, "EnvironmentHttpConflictError"); | ||
| 2477 | assert.equal(replayBody.message, "Cloud health request was already consumed."); | ||
| 2478 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2479 | ); | ||
| 2480 | |||
| 2481 | it.effect( | ||
| 2482 | "validates cloud proofs against the configured relay issuer, not the transport URL", | ||
| 2483 | () => | ||
| 2484 | Effect.gen(function* () { | ||
| 2485 | yield* buildAppUnderTest(); | ||
| 2486 | |||
| 2487 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2488 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2489 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2490 | }); | ||
| 2491 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2492 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2493 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2494 | method: "POST", | ||
| 2495 | headers: { | ||
| 2496 | cookie: ownerCookie, | ||
| 2497 | "content-type": "application/json", | ||
| 2498 | }, | ||
| 2499 | body: jsonRequestBody({ | ||
| 2500 | relayUrl: "https://transport.example.test", | ||
| 2501 | cloudUserId: "user_123", | ||
| 2502 | relayIssuer: "https://relay.example.test", | ||
| 2503 | environmentCredential: "t3env_test_credential", | ||
| 2504 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2505 | endpointRuntime: null, | ||
| 2506 | }), | ||
| 2507 | }); | ||
| 2508 | assert.equal(relayConfigResponse.status, 200); | ||
| 2509 | |||
| 2510 | const now = yield* DateTime.now; | ||
| 2511 | const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); | ||
| 2512 | const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) => | ||
| 2513 | fetchEffect(mintUrl, { | ||
| 2514 | method: "POST", | ||
| 2515 | headers: { | ||
| 2516 | "content-type": "application/json", | ||
| 2517 | }, | ||
| 2518 | body: jsonRequestBody(request), | ||
| 2519 | }); | ||
| 2520 | |||
| 2521 | const acceptedResponse = yield* postMint( | ||
| 2522 | makeCloudMintCredentialRequest({ | ||
| 2523 | privateKey: cloudKeyPair.privateKey, | ||
| 2524 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2525 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2526 | issuer: "https://relay.example.test", | ||
| 2527 | jti: "cloud-mint-jti-explicit-relay-issuer", | ||
| 2528 | nonce: "cloud-mint-nonce-explicit-relay-issuer", | ||
| 2529 | issuedAt: DateTime.formatIso(now), | ||
| 2530 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2531 | }), | ||
| 2532 | ); | ||
| 2533 | const rejectedResponse = yield* postMint( | ||
| 2534 | makeCloudMintCredentialRequest({ | ||
| 2535 | privateKey: cloudKeyPair.privateKey, | ||
| 2536 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2537 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2538 | issuer: "https://transport.example.test", | ||
| 2539 | jti: "cloud-mint-jti-transport-url", | ||
| 2540 | nonce: "cloud-mint-nonce-transport-url", | ||
| 2541 | issuedAt: DateTime.formatIso(now), | ||
| 2542 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2543 | }), | ||
| 2544 | ); | ||
| 2545 | |||
| 2546 | assert.equal(acceptedResponse.status, 200); | ||
| 2547 | assert.equal(rejectedResponse.status, 401); | ||
| 2548 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2549 | ); | ||
| 2550 | |||
| 2551 | it.effect("fails relay config when the managed endpoint connector cannot start", () => | ||
| 2552 | Effect.gen(function* () { | ||
| 2553 | yield* buildAppUnderTest({ | ||
| 2554 | layers: { | ||
| 2555 | cloudManagedEndpointRuntime: { | ||
| 2556 | applyConfig: () => | ||
| 2557 | Effect.succeed({ | ||
| 2558 | status: "failed", | ||
| 2559 | providerKind: "cloudflare_tunnel", | ||
| 2560 | reason: "cloudflared missing", | ||
| 2561 | tunnelId: "tunnel-1", | ||
| 2562 | }), | ||
| 2563 | }, | ||
| 2564 | }, | ||
| 2565 | }); | ||
| 2566 | |||
| 2567 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2568 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2569 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2570 | }); | ||
| 2571 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2572 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2573 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2574 | method: "POST", | ||
| 2575 | headers: { | ||
| 2576 | cookie: ownerCookie, | ||
| 2577 | "content-type": "application/json", | ||
| 2578 | }, | ||
| 2579 | body: jsonRequestBody({ | ||
| 2580 | relayUrl: "https://relay.example.test", | ||
| 2581 | cloudUserId: "user_123", | ||
| 2582 | environmentCredential: "t3env_test_credential", | ||
| 2583 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2584 | endpointRuntime: { | ||
| 2585 | providerKind: "cloudflare_tunnel", | ||
| 2586 | connectorToken: "connector-token", | ||
| 2587 | tunnelId: "tunnel-1", | ||
| 2588 | }, | ||
| 2589 | }), | ||
| 2590 | }); | ||
| 2591 | |||
| 2592 | assert.equal(relayConfigResponse.status, 503); | ||
| 2593 | const relayConfigBody = yield* responseJsonEffect<{ | ||
| 2594 | _tag?: string; | ||
| 2595 | message?: string; | ||
| 2596 | endpointRuntimeStatus?: { status?: string; reason?: string }; | ||
| 2597 | }>(relayConfigResponse); | ||
| 2598 | assert.equal(relayConfigBody._tag, "EnvironmentCloudEndpointUnavailableError"); | ||
| 2599 | assert.equal(relayConfigBody.message, "Managed endpoint runtime could not be started."); | ||
| 2600 | assert.equal(relayConfigBody.endpointRuntimeStatus?.status, "failed"); | ||
| 2601 | assert.equal(relayConfigBody.endpointRuntimeStatus?.reason, "cloudflared missing"); | ||
| 2602 | |||
| 2603 | const now = yield* DateTime.now; | ||
| 2604 | const healthRequest = makeCloudEnvironmentHealthRequest({ | ||
| 2605 | privateKey: cloudKeyPair.privateKey, | ||
| 2606 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2607 | nonce: "cloud-health-after-failed-runtime", | ||
| 2608 | issuedAt: DateTime.formatIso(now), | ||
| 2609 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2610 | }); | ||
| 2611 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 2612 | const healthResponse = yield* fetchEffect(healthUrl, { | ||
| 2613 | method: "POST", | ||
| 2614 | headers: { | ||
| 2615 | "content-type": "application/json", | ||
| 2616 | }, | ||
| 2617 | body: jsonRequestBody(healthRequest), | ||
| 2618 | }); | ||
| 2619 | const healthBody = yield* responseJsonEffect<{ | ||
| 2620 | _tag?: string; | ||
| 2621 | message?: string; | ||
| 2622 | }>(healthResponse); | ||
| 2623 | assert.equal(healthResponse.status, 500); | ||
| 2624 | assert.equal(healthBody._tag, "EnvironmentHttpInternalServerError"); | ||
| 2625 | assert.equal( | ||
| 2626 | healthBody.message, | ||
| 2627 | "Cloud mint public key is not installed for this environment.", | ||
| 2628 | ); | ||
| 2629 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2630 | ); | ||
| 2631 | |||
| 2632 | it.effect("rejects cloud mint requests with the wrong issuer or audience", () => | ||
| 2633 | Effect.gen(function* () { | ||
| 2634 | yield* buildAppUnderTest(); | ||
| 2635 | |||
| 2636 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2637 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2638 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2639 | }); | ||
| 2640 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2641 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2642 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2643 | method: "POST", | ||
| 2644 | headers: { | ||
| 2645 | cookie: ownerCookie, | ||
| 2646 | "content-type": "application/json", | ||
| 2647 | }, | ||
| 2648 | body: jsonRequestBody({ | ||
| 2649 | relayUrl: "https://relay.example.test/", | ||
| 2650 | cloudUserId: "user_123", | ||
| 2651 | environmentCredential: "t3env_test_credential", | ||
| 2652 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2653 | endpointRuntime: null, | ||
| 2654 | }), | ||
| 2655 | }); | ||
| 2656 | assert.equal(relayConfigResponse.status, 200); | ||
| 2657 | |||
| 2658 | const now = yield* DateTime.now; | ||
| 2659 | const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential"); | ||
| 2660 | const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) => | ||
| 2661 | fetchEffect(mintUrl, { | ||
| 2662 | method: "POST", | ||
| 2663 | headers: { | ||
| 2664 | "content-type": "application/json", | ||
| 2665 | }, | ||
| 2666 | body: jsonRequestBody(request), | ||
| 2667 | }); | ||
| 2668 | |||
| 2669 | const wrongIssuer = yield* postMint( | ||
| 2670 | makeCloudMintCredentialRequest({ | ||
| 2671 | privateKey: cloudKeyPair.privateKey, | ||
| 2672 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2673 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2674 | issuer: "https://attacker.example.test", | ||
| 2675 | jti: "cloud-mint-jti-wrong-issuer", | ||
| 2676 | nonce: "cloud-mint-nonce-wrong-issuer", | ||
| 2677 | issuedAt: DateTime.formatIso(now), | ||
| 2678 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2679 | }), | ||
| 2680 | ); | ||
| 2681 | const wrongAudience = yield* postMint( | ||
| 2682 | makeCloudMintCredentialRequest({ | ||
| 2683 | privateKey: cloudKeyPair.privateKey, | ||
| 2684 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2685 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2686 | audience: "t3-env:other-environment", | ||
| 2687 | jti: "cloud-mint-jti-wrong-audience", | ||
| 2688 | nonce: "cloud-mint-nonce-wrong-audience", | ||
| 2689 | issuedAt: DateTime.formatIso(now), | ||
| 2690 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2691 | }), | ||
| 2692 | ); | ||
| 2693 | |||
| 2694 | assert.equal(wrongIssuer.status, 401); | ||
| 2695 | assert.equal(wrongAudience.status, 401); | ||
| 2696 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2697 | ); | ||
| 2698 | |||
| 2699 | it.effect("rejects cloud mint requests for a cloud subject other than the linked user", () => | ||
| 2700 | Effect.gen(function* () { | ||
| 2701 | yield* buildAppUnderTest(); | ||
| 2702 | |||
| 2703 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2704 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2705 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2706 | }); | ||
| 2707 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2708 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2709 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2710 | method: "POST", | ||
| 2711 | headers: { | ||
| 2712 | cookie: ownerCookie, | ||
| 2713 | "content-type": "application/json", | ||
| 2714 | }, | ||
| 2715 | body: jsonRequestBody({ | ||
| 2716 | relayUrl: "https://relay.example.test/", | ||
| 2717 | cloudUserId: "user_123", | ||
| 2718 | environmentCredential: "t3env_test_credential", | ||
| 2719 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2720 | endpointRuntime: null, | ||
| 2721 | }), | ||
| 2722 | }); | ||
| 2723 | assert.equal(relayConfigResponse.status, 200); | ||
| 2724 | |||
| 2725 | const now = yield* DateTime.now; | ||
| 2726 | const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); | ||
| 2727 | const response = yield* fetchEffect(mintUrl, { | ||
| 2728 | method: "POST", | ||
| 2729 | headers: { | ||
| 2730 | "content-type": "application/json", | ||
| 2731 | }, | ||
| 2732 | body: jsonRequestBody( | ||
| 2733 | makeCloudMintCredentialRequest({ | ||
| 2734 | privateKey: cloudKeyPair.privateKey, | ||
| 2735 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2736 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2737 | subject: "user_other", | ||
| 2738 | jti: "cloud-mint-jti-wrong-subject", | ||
| 2739 | nonce: "cloud-mint-nonce-wrong-subject", | ||
| 2740 | issuedAt: DateTime.formatIso(now), | ||
| 2741 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2742 | }), | ||
| 2743 | ), | ||
| 2744 | }); | ||
| 2745 | |||
| 2746 | assert.equal(response.status, 401); | ||
| 2747 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2748 | ); | ||
| 2749 | |||
| 2750 | it.effect("rejects cloud mint requests without the exact connect scope", () => | ||
| 2751 | Effect.gen(function* () { | ||
| 2752 | yield* buildAppUnderTest(); | ||
| 2753 | |||
| 2754 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2755 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2756 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2757 | }); | ||
| 2758 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2759 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2760 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2761 | method: "POST", | ||
| 2762 | headers: { | ||
| 2763 | cookie: ownerCookie, | ||
| 2764 | "content-type": "application/json", | ||
| 2765 | }, | ||
| 2766 | body: jsonRequestBody({ | ||
| 2767 | relayUrl: "https://relay.example.test/", | ||
| 2768 | cloudUserId: "user_123", | ||
| 2769 | environmentCredential: "t3env_test_credential", | ||
| 2770 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2771 | endpointRuntime: null, | ||
| 2772 | }), | ||
| 2773 | }); | ||
| 2774 | assert.equal(relayConfigResponse.status, 200); | ||
| 2775 | |||
| 2776 | const now = yield* DateTime.now; | ||
| 2777 | const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); | ||
| 2778 | const response = yield* fetchEffect(mintUrl, { | ||
| 2779 | method: "POST", | ||
| 2780 | headers: { | ||
| 2781 | "content-type": "application/json", | ||
| 2782 | }, | ||
| 2783 | body: jsonRequestBody( | ||
| 2784 | makeCloudMintCredentialRequest({ | ||
| 2785 | privateKey: cloudKeyPair.privateKey, | ||
| 2786 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2787 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2788 | jti: "cloud-mint-jti-duplicate-scope", | ||
| 2789 | nonce: "cloud-mint-nonce-duplicate-scope", | ||
| 2790 | issuedAt: DateTime.formatIso(now), | ||
| 2791 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2792 | scope: ["environment:connect", "environment:connect"], | ||
| 2793 | }), | ||
| 2794 | ), | ||
| 2795 | }); | ||
| 2796 | |||
| 2797 | assert.equal(response.status, 401); | ||
| 2798 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2799 | ); | ||
| 2800 | |||
| 2801 | it.effect("rejects cloud health requests with the wrong issuer or audience", () => | ||
| 2802 | Effect.gen(function* () { | ||
| 2803 | yield* buildAppUnderTest(); | ||
| 2804 | |||
| 2805 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2806 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2807 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2808 | }); | ||
| 2809 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2810 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2811 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2812 | method: "POST", | ||
| 2813 | headers: { | ||
| 2814 | cookie: ownerCookie, | ||
| 2815 | "content-type": "application/json", | ||
| 2816 | }, | ||
| 2817 | body: jsonRequestBody({ | ||
| 2818 | relayUrl: "https://relay.example.test/", | ||
| 2819 | cloudUserId: "user_123", | ||
| 2820 | environmentCredential: "t3env_test_credential", | ||
| 2821 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2822 | endpointRuntime: null, | ||
| 2823 | }), | ||
| 2824 | }); | ||
| 2825 | assert.equal(relayConfigResponse.status, 200); | ||
| 2826 | |||
| 2827 | const now = yield* DateTime.now; | ||
| 2828 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 2829 | const postHealth = (request: ReturnType<typeof makeCloudEnvironmentHealthRequest>) => | ||
| 2830 | fetchEffect(healthUrl, { | ||
| 2831 | method: "POST", | ||
| 2832 | headers: { | ||
| 2833 | "content-type": "application/json", | ||
| 2834 | }, | ||
| 2835 | body: jsonRequestBody(request), | ||
| 2836 | }); | ||
| 2837 | |||
| 2838 | const wrongIssuer = yield* postHealth( | ||
| 2839 | makeCloudEnvironmentHealthRequest({ | ||
| 2840 | privateKey: cloudKeyPair.privateKey, | ||
| 2841 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2842 | issuer: "https://attacker.example.test", | ||
| 2843 | jti: "cloud-health-jti-wrong-issuer", | ||
| 2844 | nonce: "cloud-health-nonce-wrong-issuer", | ||
| 2845 | issuedAt: DateTime.formatIso(now), | ||
| 2846 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2847 | }), | ||
| 2848 | ); | ||
| 2849 | const wrongAudience = yield* postHealth( | ||
| 2850 | makeCloudEnvironmentHealthRequest({ | ||
| 2851 | privateKey: cloudKeyPair.privateKey, | ||
| 2852 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2853 | audience: "t3-env:other-environment", | ||
| 2854 | jti: "cloud-health-jti-wrong-audience", | ||
| 2855 | nonce: "cloud-health-nonce-wrong-audience", | ||
| 2856 | issuedAt: DateTime.formatIso(now), | ||
| 2857 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2858 | }), | ||
| 2859 | ); | ||
| 2860 | |||
| 2861 | assert.equal(wrongIssuer.status, 401); | ||
| 2862 | assert.equal(wrongAudience.status, 401); | ||
| 2863 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2864 | ); | ||
| 2865 | |||
| 2866 | it.effect("rejects cloud health requests for a cloud subject other than the linked user", () => | ||
| 2867 | Effect.gen(function* () { | ||
| 2868 | yield* buildAppUnderTest(); | ||
| 2869 | |||
| 2870 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2871 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2872 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2873 | }); | ||
| 2874 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2875 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2876 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2877 | method: "POST", | ||
| 2878 | headers: { | ||
| 2879 | cookie: ownerCookie, | ||
| 2880 | "content-type": "application/json", | ||
| 2881 | }, | ||
| 2882 | body: jsonRequestBody({ | ||
| 2883 | relayUrl: "https://relay.example.test/", | ||
| 2884 | cloudUserId: "user_123", | ||
| 2885 | environmentCredential: "t3env_test_credential", | ||
| 2886 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2887 | endpointRuntime: null, | ||
| 2888 | }), | ||
| 2889 | }); | ||
| 2890 | assert.equal(relayConfigResponse.status, 200); | ||
| 2891 | |||
| 2892 | const now = yield* DateTime.now; | ||
| 2893 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 2894 | const response = yield* fetchEffect(healthUrl, { | ||
| 2895 | method: "POST", | ||
| 2896 | headers: { | ||
| 2897 | "content-type": "application/json", | ||
| 2898 | }, | ||
| 2899 | body: jsonRequestBody( | ||
| 2900 | makeCloudEnvironmentHealthRequest({ | ||
| 2901 | privateKey: cloudKeyPair.privateKey, | ||
| 2902 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2903 | subject: "user_other", | ||
| 2904 | jti: "cloud-health-jti-wrong-subject", | ||
| 2905 | nonce: "cloud-health-nonce-wrong-subject", | ||
| 2906 | issuedAt: DateTime.formatIso(now), | ||
| 2907 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2908 | }), | ||
| 2909 | ), | ||
| 2910 | }); | ||
| 2911 | |||
| 2912 | assert.equal(response.status, 401); | ||
| 2913 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2914 | ); | ||
| 2915 | |||
| 2916 | it.effect("rejects cloud health requests without the exact status scope", () => | ||
| 2917 | Effect.gen(function* () { | ||
| 2918 | yield* buildAppUnderTest(); | ||
| 2919 | |||
| 2920 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2921 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2922 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2923 | }); | ||
| 2924 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2925 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2926 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2927 | method: "POST", | ||
| 2928 | headers: { | ||
| 2929 | cookie: ownerCookie, | ||
| 2930 | "content-type": "application/json", | ||
| 2931 | }, | ||
| 2932 | body: jsonRequestBody({ | ||
| 2933 | relayUrl: "https://relay.example.test/", | ||
| 2934 | cloudUserId: "user_123", | ||
| 2935 | environmentCredential: "t3env_test_credential", | ||
| 2936 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2937 | endpointRuntime: null, | ||
| 2938 | }), | ||
| 2939 | }); | ||
| 2940 | assert.equal(relayConfigResponse.status, 200); | ||
| 2941 | |||
| 2942 | const now = yield* DateTime.now; | ||
| 2943 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 2944 | const response = yield* fetchEffect(healthUrl, { | ||
| 2945 | method: "POST", | ||
| 2946 | headers: { | ||
| 2947 | "content-type": "application/json", | ||
| 2948 | }, | ||
| 2949 | body: jsonRequestBody( | ||
| 2950 | makeCloudEnvironmentHealthRequest({ | ||
| 2951 | privateKey: cloudKeyPair.privateKey, | ||
| 2952 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2953 | jti: "cloud-health-jti-duplicate-scope", | ||
| 2954 | nonce: "cloud-health-nonce-duplicate-scope", | ||
| 2955 | issuedAt: DateTime.formatIso(now), | ||
| 2956 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2957 | scope: ["environment:status", "environment:status"], | ||
| 2958 | }), | ||
| 2959 | ), | ||
| 2960 | }); | ||
| 2961 | |||
| 2962 | assert.equal(response.status, 401); | ||
| 2963 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2964 | ); | ||
| 2965 | |||
| 2966 | it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => | ||
| 2967 | Effect.gen(function* () { | ||
| 2968 | yield* buildAppUnderTest(); | ||
| 2969 | |||
| 2970 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | ||
| 2971 | const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); | ||
| 2972 | const wsTicketResponse = yield* fetchEffect(wsTicketUrl, { | ||
| 2973 | method: "POST", | ||
| 2974 | headers: { | ||
| 2975 | authorization: `Bearer ${bearerToken}`, | ||
| 2976 | }, | ||
| 2977 | }); | ||
| 2978 | const wsTicketBody = yield* responseJsonEffect<{ | ||
| 2979 | readonly ticket: string; | ||
| 2980 | readonly expiresAt: string; | ||
| 2981 | }>(wsTicketResponse); | ||
| 2982 | |||
| 2983 | assert.equal(wsTicketResponse.status, 200); | ||
| 2984 | assert.equal(typeof wsTicketBody.ticket, "string"); | ||
| 2985 | assert.isTrue(wsTicketBody.ticket.length > 0); | ||
| 2986 | assert.equal(typeof wsTicketBody.expiresAt, "string"); | ||
| 2987 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2988 | ); | ||
| 2989 | |||
| 2990 | it.effect("does not allow management-only access tokens to operate the environment", () => | ||
| 2991 | Effect.gen(function* () { | ||
| 2992 | yield* buildAppUnderTest(); | ||
| 2993 | |||
| 2994 | const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken( | ||
| 2995 | defaultDesktopBootstrapToken, | ||
| 2996 | { scope: "access:write" }, | ||
| 2997 | ); | ||
| 2998 | assert.equal(exchangeResponse.status, 200); | ||
| 2999 | assert.equal(tokenBody.scope, "access:write"); | ||
| 3000 | assert.isDefined(tokenBody.access_token); | ||
| 3001 | |||
| 3002 | const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 3003 | headers: { | ||
| 3004 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3005 | }, | ||
| 3006 | body: yield* HttpBody.json({}), | ||
| 3007 | }); | ||
| 3008 | const overbroadPairingBody = (yield* overbroadPairingResponse.json) as { | ||
| 3009 | readonly requiredScope: string; | ||
| 3010 | }; | ||
| 3011 | const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 3012 | headers: { | ||
| 3013 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3014 | }, | ||
| 3015 | body: yield* HttpBody.json({ scopes: ["access:write"] }), | ||
| 3016 | }); | ||
| 3017 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | ||
| 3018 | headers: { | ||
| 3019 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3020 | }, | ||
| 3021 | }); | ||
| 3022 | const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; | ||
| 3023 | const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { | ||
| 3024 | headers: { | ||
| 3025 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3026 | }, | ||
| 3027 | }); | ||
| 3028 | const faviconBody = (yield* faviconResponse.json) as { | ||
| 3029 | readonly _tag: string; | ||
| 3030 | readonly code: string; | ||
| 3031 | readonly requiredScope: string; | ||
| 3032 | readonly traceId: string; | ||
| 3033 | }; | ||
| 3034 | |||
| 3035 | assert.equal(overbroadPairingResponse.status, 403); | ||
| 3036 | assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); | ||
| 3037 | assert.equal(pairingResponse.status, 200); | ||
| 3038 | assert.equal(wsTicketResponse.status, 200); | ||
| 3039 | assert.equal(faviconResponse.status, 403); | ||
| 3040 | assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); | ||
| 3041 | assert.equal(faviconBody.code, "insufficient_scope"); | ||
| 3042 | assert.equal(faviconBody.requiredScope, "orchestration:read"); | ||
| 3043 | assert.equal(typeof faviconBody.traceId, "string"); | ||
| 3044 | |||
| 3045 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; | ||
| 3046 | const rpcError = yield* Effect.flip( | ||
| 3047 | Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), | ||
| 3048 | ); | ||
| 3049 | assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); | ||
| 3050 | if (rpcError._tag === "EnvironmentAuthorizationError") { | ||
| 3051 | assert.equal(rpcError.requiredScope, "orchestration:read"); | ||
| 3052 | } | ||
| 3053 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3054 | ); | ||
| 3055 | |||
| 3056 | it.effect("includes CORS headers on remote auth success responses", () => | ||
| 3057 | Effect.gen(function* () { | ||
| 3058 | yield* buildAppUnderTest(); | ||
| 3059 | |||
| 3060 | const origin = crossOriginClientOrigin; | ||
| 3061 | const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken( | ||
| 3062 | defaultDesktopBootstrapToken, | ||
| 3063 | { | ||
| 3064 | headers: { origin }, | ||
| 3065 | }, | ||
| 3066 | ); | ||
| 3067 | |||
| 3068 | assert.equal(tokenResponse.status, 200); | ||
| 3069 | assertBrowserApiCorsResponseHeaders(tokenResponse.headers); | ||
| 3070 | assert.equal(tokenBody.token_type, "Bearer"); | ||
| 3071 | assert.equal(typeof tokenBody.access_token, "string"); | ||
| 3072 | |||
| 3073 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); | ||
| 3074 | const sessionResponse = yield* fetchEffect(sessionUrl, { | ||
| 3075 | headers: { | ||
| 3076 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3077 | origin, | ||
| 3078 | }, | ||
| 3079 | }); | ||
| 3080 | const sessionBody = yield* responseJsonEffect<{ | ||
| 3081 | readonly authenticated: boolean; | ||
| 3082 | readonly sessionMethod?: string; | ||
| 3083 | }>(sessionResponse); | ||
| 3084 | |||
| 3085 | assert.equal(sessionResponse.status, 200); | ||
| 3086 | assertBrowserApiCorsResponseHeaders(sessionResponse.headers); | ||
| 3087 | assert.equal(sessionBody.authenticated, true); | ||
| 3088 | assert.equal(sessionBody.sessionMethod, "bearer-access-token"); | ||
| 3089 | |||
| 3090 | const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); | ||
| 3091 | const wsTicketResponse = yield* fetchEffect(wsTicketUrl, { | ||
| 3092 | method: "POST", | ||
| 3093 | headers: { | ||
| 3094 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3095 | origin, | ||
| 3096 | }, | ||
| 3097 | }); | ||
| 3098 | const wsTicketBody = yield* responseJsonEffect<{ | ||
| 3099 | readonly ticket: string; | ||
| 3100 | }>(wsTicketResponse); | ||
| 3101 | |||
| 3102 | assert.equal(wsTicketResponse.status, 200); | ||
| 3103 | assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers); | ||
| 3104 | assert.equal(typeof wsTicketBody.ticket, "string"); | ||
| 3105 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3106 | ); | ||
| 3107 | |||
| 3108 | it.effect( | ||
| 3109 | "responds to remote auth websocket-ticket preflight requests with authorization CORS headers", | ||
| 3110 | () => | ||
| 3111 | Effect.gen(function* () { | ||
| 3112 | yield* buildAppUnderTest(); | ||
| 3113 | |||
| 3114 | const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); | ||
| 3115 | const response = yield* fetchEffect(wsTicketUrl, { | ||
| 3116 | method: "OPTIONS", | ||
| 3117 | headers: { | ||
| 3118 | origin: crossOriginClientOrigin, | ||
| 3119 | "access-control-request-method": "POST", | ||
| 3120 | "access-control-request-headers": "authorization", | ||
| 3121 | }, | ||
| 3122 | }); | ||
| 3123 | |||
| 3124 | assert.equal(response.status, 204); | ||
| 3125 | assertBrowserApiCorsPreflightHeaders(response.headers); | ||
| 3126 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3127 | ); | ||
| 3128 | |||
| 3129 | it.effect("includes CORS headers on remote websocket-ticket auth failures", () => | ||
| 3130 | Effect.gen(function* () { | ||
| 3131 | yield* buildAppUnderTest(); | ||
| 3132 | |||
| 3133 | const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); | ||
| 3134 | const response = yield* fetchEffect(wsTicketUrl, { | ||
| 3135 | method: "POST", | ||
| 3136 | headers: { | ||
| 3137 | origin: crossOriginClientOrigin, | ||
| 3138 | }, | ||
| 3139 | }); | ||
| 3140 | const body = yield* responseJsonEffect<{ | ||
| 3141 | readonly _tag?: string; | ||
| 3142 | readonly code?: string; | ||
| 3143 | readonly reason?: string; | ||
| 3144 | readonly traceId?: string; | ||
| 3145 | }>(response); | ||
| 3146 | |||
| 3147 | assert.equal(response.status, 401); | ||
| 3148 | assertBrowserApiCorsResponseHeaders(response.headers); | ||
| 3149 | assert.equal(body._tag, "EnvironmentAuthInvalidError"); | ||
| 3150 | assert.equal(body.code, "auth_invalid"); | ||
| 3151 | assert.equal(body.reason, "missing_credential"); | ||
| 3152 | assert.equal(typeof body.traceId, "string"); | ||
| 3153 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3154 | ); | ||
| 3155 | |||
| 3156 | it.effect("issues authenticated one-time pairing credentials for additional clients", () => | ||
| 3157 | Effect.gen(function* () { | ||
| 3158 | yield* buildAppUnderTest(); | ||
| 3159 | |||
| 3160 | const response = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 3161 | headers: { | ||
| 3162 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 3163 | }, | ||
| 3164 | body: yield* HttpBody.json({}), | ||
| 3165 | }); | ||
| 3166 | const body = (yield* response.json) as { | ||
| 3167 | readonly credential: string; | ||
| 3168 | readonly expiresAt: string; | ||
| 3169 | }; | ||
| 3170 | |||
| 3171 | assert.equal(response.status, 200); | ||
| 3172 | assert.equal(typeof body.credential, "string"); | ||
| 1443 | assert.isTrue(body.credential.length > 0); | 3173 | assert.isTrue(body.credential.length > 0); |
| 1444 | assert.equal(typeof body.expiresAt, "string"); | 3174 | assert.equal(typeof body.expiresAt, "string"); |
| 1445 | 3175 | ||
| @@ -1538,8 +3268,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 92 unmodified lines | |||
| 1538 | const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", { | 3268 | const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", { |
| 1539 | headers: { | 3269 | headers: { |
| 1540 | cookie: ownerCookie, | 3270 | cookie: ownerCookie, |
| 3271 | "content-type": "application/json", | ||
| 1541 | }, | 3272 | }, |
| 1542 | body: yield* HttpBody.json({ id: createdBody.id }), | 3273 | body: HttpBody.text(jsonRequestBody({ id: createdBody.id }), "application/json"), |
| 1543 | }); | 3274 | }); |
| 1544 | const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential); | 3275 | const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential); |
| 1545 | 3276 | ||
| @@ -1601,16 +3332,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 55 unmodified lines | |||
| 1601 | }); | 3332 | }); |
| 1602 | 3333 | ||
| 1603 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | 3334 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); |
| 1604 | const ownerPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | 3335 | const pairingTokenUrl = yield* getHttpServerUrl("/api/auth/pairing-token"); |
| 3336 | const ownerPairingResponse = yield* fetchEffect(pairingTokenUrl, { | ||
| 3337 | method: "POST", | ||
| 1605 | headers: { | 3338 | headers: { |
| 1606 | cookie: ownerCookie, | 3339 | cookie: ownerCookie, |
| 3340 | "content-type": "application/json", | ||
| 1607 | }, | 3341 | }, |
| 1608 | body: yield* HttpBody.json({ label: "Julius iPhone" }), | 3342 | body: jsonRequestBody({ |
| 3343 | label: "Julius iPhone", | ||
| 3344 | }), | ||
| 1609 | }); | 3345 | }); |
| 1610 | const ownerPairingBody = (yield* ownerPairingResponse.json) as { | 3346 | const ownerPairingBody = yield* responseJsonEffect<{ |
| 1611 | readonly credential: string; | 3347 | readonly credential: string; |
| 1612 | readonly label?: string; | 3348 | readonly label?: string; |
| 1613 | }; | 3349 | }>(ownerPairingResponse); |
| 1614 | assert.equal(ownerPairingResponse.status, 200); | 3350 | assert.equal(ownerPairingResponse.status, 200); |
| 1615 | const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, { | 3351 | const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, { |
| 1616 | headers: { | 3352 | headers: { |
| @@ -1794,8 +3530,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 177 unmodified lines | |||
| 1794 | const revokeResponse = yield* HttpClient.post("/api/auth/clients/revoke", { | 3530 | const revokeResponse = yield* HttpClient.post("/api/auth/clients/revoke", { |
| 1795 | headers: { | 3531 | headers: { |
| 1796 | cookie: ownerCookie, | 3532 | cookie: ownerCookie, |
| 3533 | "content-type": "application/json", | ||
| 1797 | }, | 3534 | }, |
| 1798 | body: yield* HttpBody.json({ sessionId: pairedSessionId }), | 3535 | body: HttpBody.text(jsonRequestBody({ sessionId: pairedSessionId }), "application/json"), |
| 1799 | }); | 3536 | }); |
| 1800 | const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | 3537 | const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { |
| 1801 | headers: { | 3538 | headers: { |
| @@ -1890,20 +3627,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 88 unmodified lines | |||
| 1890 | ); | 3627 | ); |
| 1891 | 3628 | ||
| 1892 | it.effect( | 3629 | it.effect( |
| 1893 | "accepts websocket rpc handshake with a dedicated websocket token in the query string", | 3630 | "accepts websocket rpc handshake with a dedicated websocket ticket in the query string", |
| 1894 | () => | 3631 | () => |
| 1895 | Effect.gen(function* () { | 3632 | Effect.gen(function* () { |
| 1896 | yield* buildAppUnderTest(); | 3633 | yield* buildAppUnderTest(); |
| 1897 | 3634 | ||
| 1898 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | 3635 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); |
| 1899 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | 3636 | const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); |
| 3637 | const wsTicketResponse = yield* fetchEffect(wsTicketUrl, { | ||
| 3638 | method: "POST", | ||
| 1900 | headers: { | 3639 | headers: { |
| 1901 | authorization: `Bearer ${bearerToken}`, | 3640 | authorization: `Bearer ${bearerToken}`, |
| 1902 | }, | 3641 | }, |
| 1903 | }); | 3642 | }); |
| 1904 | const wsTicketBody = (yield* wsTicketResponse.json) as { | 3643 | const wsTicketBody = yield* responseJsonEffect<{ |
| 1905 | readonly ticket: string; | 3644 | readonly ticket: string; |
| 1906 | }; | 3645 | }>(wsTicketResponse); |
| 1907 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; | 3646 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; |
| 1908 | 3647 | ||
| 1909 | const response = yield* Effect.scoped( | 3648 | const response = yield* Effect.scoped( |
| @@ -2103,7 +3842,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 193 unmodified lines | |||
| 2103 | "content-type": "application/json", | 3842 | "content-type": "application/json", |
| 2104 | origin: "http://localhost:5733", | 3843 | origin: "http://localhost:5733", |
| 2105 | }, | 3844 | }, |
| 2106 | body: yield* HttpBody.json(payload), | 3845 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| 3846 | body: HttpBody.text(JSON.stringify(payload), "application/json"), | ||
| 2107 | }); | 3847 | }); |
| 2108 | 3848 | ||
| 2109 | assert.equal(response.status, 204); | 3849 | assert.equal(response.status, 204); |
| @@ -2148,8 +3888,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 38 unmodified lines | |||
| 2148 | ]); | 3888 | ]); |
| 2149 | assert.deepEqual(upstreamRequests, [ | 3889 | assert.deepEqual(upstreamRequests, [ |
| 2150 | { | 3890 | { |
| 2151 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 3891 | body: jsonRequestBody(payload), |
| 2152 | body: JSON.stringify(payload), | ||
| 2153 | contentType: "application/json", | 3892 | contentType: "application/json", |
| 2154 | }, | 3893 | }, |
| 2155 | ]); | 3894 | ]); |
| @@ -2170,14 +3909,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 14 unmodified lines | |||
| 2170 | 3909 | ||
| 2171 | assert.equal(response.status, 204); | 3910 | assert.equal(response.status, 204); |
| 2172 | assert.equal(response.headers["access-control-allow-origin"], "*"); | 3911 | assert.equal(response.headers["access-control-allow-origin"], "*"); |
| 2173 | assert.deepEqual( | 3912 | assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-methods"]), [ |
| 2174 | splitHeaderTokens(response.headers["access-control-allow-methods"] ?? null), | 3913 | "GET", |
| 2175 | ["GET", "OPTIONS", "POST"], | 3914 | "OPTIONS", |
| 2176 | ); | 3915 | "POST", |
| 2177 | assert.deepEqual( | 3916 | ]); |
| 2178 | splitHeaderTokens(response.headers["access-control-allow-headers"] ?? null), | 3917 | assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-headers"]), [ |
| 2179 | ["authorization", "b3", "content-type", "traceparent"], | 3918 | "authorization", |
| 2180 | ); | 3919 | "b3", |
| 3920 | "content-type", | ||
| 3921 | "dpop", | ||
| 3922 | "traceparent", | ||
| 3923 | ]); | ||
| 2181 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 3924 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 2182 | ); | 3925 | ); |
| 2183 | 3926 | ||
| @@ -2214,7 +3957,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 30 unmodified lines | |||
| 2214 | cookie: yield* getAuthenticatedSessionCookieHeader(), | 3957 | cookie: yield* getAuthenticatedSessionCookieHeader(), |
| 2215 | "content-type": "application/json", | 3958 | "content-type": "application/json", |
| 2216 | }, | 3959 | }, |
| 2217 | body: yield* HttpBody.json(payload), | 3960 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| 3961 | body: HttpBody.text(JSON.stringify(payload), "application/json"), | ||
| 2218 | }); | 3962 | }); |
| 2219 | 3963 | ||
| 2220 | assert.equal(response.status, 204); | 3964 | assert.equal(response.status, 204); |
| @@ -44,6 +44,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun · 43 unmodified lines | |||
| 44 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; | 44 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; |
| 45 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; | 45 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; |
| 46 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; | 46 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; |
| 47 | import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; | ||
| 47 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; | 48 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| 48 | import { ServerSettingsLive } from "./serverSettings.ts"; | 49 | import { ServerSettingsLive } from "./serverSettings.ts"; |
| 49 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | 50 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; |
| @@ -67,6 +68,8 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts · 17 unmodified lines | |||
| 67 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | 68 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 68 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 69 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 69 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 70 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 71 | import { cloudHttpApiLayer } from "./cloud/http.ts"; | ||
| 72 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; | ||
| 70 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 73 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 71 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 74 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 72 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 75 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| @@ -134,6 +137,7 @@ const ReactorLayerLive = Layer.empty.pipe( · 61 unmodified lines | |||
| 134 | Layer.provideMerge(ProviderCommandReactorLive), | 137 | Layer.provideMerge(ProviderCommandReactorLive), |
| 135 | Layer.provideMerge(CheckpointReactorLive), | 138 | Layer.provideMerge(CheckpointReactorLive), |
| 136 | Layer.provideMerge(ThreadDeletionReactorLive), | 139 | Layer.provideMerge(ThreadDeletionReactorLive), |
| 140 | Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), | ||
| 137 | Layer.provideMerge(RuntimeReceiptBusLive), | 141 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 138 | ); | 142 | ); |
| 139 | 143 | ||
| @@ -271,6 +275,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 131 unmodified lines | |||
| 271 | Layer.provideMerge(RepositoryIdentityResolverLive), | 275 | Layer.provideMerge(RepositoryIdentityResolverLive), |
| 272 | Layer.provideMerge(ServerEnvironmentLive), | 276 | Layer.provideMerge(ServerEnvironmentLive), |
| 273 | Layer.provideMerge(AuthLayerLive), | 277 | Layer.provideMerge(AuthLayerLive), |
| 278 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 279 | Layer.provideMerge( | ||
| 280 | CloudManagedEndpointRuntime.layer.pipe(Layer.provide(ServerSecretStore.layer)), | ||
| 281 | ), | ||
| 274 | ); | 282 | ); |
| 275 | 283 | ||
| 276 | const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( | 284 | const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( |
| @@ -291,6 +299,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( · 14 unmodified lines | |||
| 291 | export const makeRoutesLayer = Layer.mergeAll( | 299 | export const makeRoutesLayer = Layer.mergeAll( |
| 292 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( | 300 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( |
| 293 | Layer.provide(authHttpApiLayer), | 301 | Layer.provide(authHttpApiLayer), |
| 302 | Layer.provide(cloudHttpApiLayer), | ||
| 294 | Layer.provide(orchestrationHttpApiLayer), | 303 | Layer.provide(orchestrationHttpApiLayer), |
| 295 | Layer.provide(serverEnvironmentHttpApiLayer), | 304 | Layer.provide(serverEnvironmentHttpApiLayer), |
| 296 | Layer.provide(environmentAuthenticatedAuthLayer), | 305 | Layer.provide(environmentAuthenticatedAuthLayer), |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | ThreadId, | 8 | ThreadId, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import * as Data from "effect/Data"; | 10 | import * as Data from "effect/Data"; |
| 11 | import * as Crypto from "effect/Crypto"; | ||
| 11 | import * as Deferred from "effect/Deferred"; | 12 | import * as Deferred from "effect/Deferred"; |
| 12 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | 14 | import * as Exit from "effect/Exit"; |
| @@ -19,7 +20,6 @@ import * as Ref from "effect/Ref"; · 5 unmodified lines | |||
| 19 | import * as Scope from "effect/Scope"; | 20 | import * as Scope from "effect/Scope"; |
| 20 | import * as Context from "effect/Context"; | 21 | import * as Context from "effect/Context"; |
| 21 | import * as Console from "effect/Console"; | 22 | import * as Console from "effect/Console"; |
| 22 | import * as Crypto from "effect/Crypto"; | ||
| 23 | import * as DateTime from "effect/DateTime"; | 23 | import * as DateTime from "effect/DateTime"; |
| 24 | 24 | ||
| 25 | import { ServerConfig } from "./config.ts"; | 25 | import { ServerConfig } from "./config.ts"; |
| @@ -289,6 +289,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { · 263 unmodified lines | |||
| 289 | const lifecycleEvents = yield* ServerLifecycleEvents; | 289 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 290 | const serverSettings = yield* ServerSettingsService; | 290 | const serverSettings = yield* ServerSettingsService; |
| 291 | const serverEnvironment = yield* ServerEnvironment; | 291 | const serverEnvironment = yield* ServerEnvironment; |
| 292 | const crypto = yield* Crypto.Crypto; | ||
| 292 | 293 | ||
| 293 | const commandGate = yield* makeCommandGate; | 294 | const commandGate = yield* makeCommandGate; |
| 294 | const httpListening = yield* Deferred.make<void>(); | 295 | const httpListening = yield* Deferred.make<void>(); |
| @@ -361,7 +362,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { · 66 unmodified lines | |||
| 361 | runStartupPhase( | 362 | runStartupPhase( |
| 362 | "welcome.autobootstrap", | 363 | "welcome.autobootstrap", |
| 363 | Effect.gen(function* () { | 364 | Effect.gen(function* () { |
| 364 | const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets; | 365 | const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( |
| 366 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 367 | ); | ||
| 365 | if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { | 368 | if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { |
| 366 | return; | 369 | return; |
| 367 | } | 370 | } |
| @@ -14,6 +14,8 @@ · 13 unmodified lines | |||
| 14 | }, | 14 | }, |
| 15 | "dependencies": { | 15 | "dependencies": { |
| 16 | "@base-ui/react": "^1.4.1", | 16 | "@base-ui/react": "^1.4.1", |
| 17 | "@clerk/clerk-js": "^6.13.0", | ||
| 18 | "@clerk/react": "^6.7.2", | ||
| 17 | "@dnd-kit/core": "^6.3.1", | 19 | "@dnd-kit/core": "^6.3.1", |
| 18 | "@dnd-kit/modifiers": "^9.0.0", | 20 | "@dnd-kit/modifiers": "^9.0.0", |
| 19 | "@dnd-kit/sortable": "^10.0.0", | 21 | "@dnd-kit/sortable": "^10.0.0", |
| @@ -33,6 +35,7 @@ · 13 unmodified lines | |||
| 33 | "@xterm/xterm": "^6.0.0", | 35 | "@xterm/xterm": "^6.0.0", |
| 34 | "class-variance-authority": "^0.7.1", | 36 | "class-variance-authority": "^0.7.1", |
| 35 | "effect": "catalog:", | 37 | "effect": "catalog:", |
| 38 | "jose": "catalog:", | ||
| 36 | "lexical": "^0.41.0", | 39 | "lexical": "^0.41.0", |
| 37 | "lucide-react": "^0.564.0", | 40 | "lucide-react": "^0.564.0", |
| 38 | "react": "19.2.6", | 41 | "react": "19.2.6", |
| @@ -44,6 +47,7 @@ · 5 unmodified lines | |||
| 44 | }, | 47 | }, |
| 45 | "devDependencies": { | 48 | "devDependencies": { |
| 46 | "@effect/platform-node": "catalog:", | 49 | "@effect/platform-node": "catalog:", |
| 50 | "@effect/vitest": "catalog:", | ||
| 47 | "@rolldown/plugin-babel": "^0.2.0", | 51 | "@rolldown/plugin-babel": "^0.2.0", |
| 48 | "@tailwindcss/vite": "^4.0.0", | 52 | "@tailwindcss/vite": "^4.0.0", |
| 49 | "@tanstack/router-plugin": "^1.161.0", | 53 | "@tanstack/router-plugin": "^1.161.0", |
| @@ -27,6 +27,7 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ · 26 unmodified lines | |||
| 27 | port: Schema.NullOr(Schema.Number), | 27 | port: Schema.NullOr(Schema.Number), |
| 28 | }), | 28 | }), |
| 29 | ), | 29 | ), |
| 30 | relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })), | ||
| 30 | bearerToken: Schema.optionalKey(Schema.String), | 31 | bearerToken: Schema.optionalKey(Schema.String), |
| 31 | }); | 32 | }); |
| 32 | type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; | 33 | type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; |
| @@ -53,7 +54,11 @@ function toPersistedSavedEnvironmentRecord( · 20 unmodified lines | |||
| 53 | createdAt: record.createdAt, | 54 | createdAt: record.createdAt, |
| 54 | lastConnectedAt: record.lastConnectedAt, | 55 | lastConnectedAt: record.lastConnectedAt, |
| 55 | }; | 56 | }; |
| 56 | return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | 57 | return { |
| 58 | ...nextRecord, | ||
| 59 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 60 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 61 | }; | ||
| 57 | } | 62 | } |
| 58 | 63 | ||
| 59 | export function readBrowserClientSettings(): ClientSettings | null { | 64 | export function readBrowserClientSettings(): ClientSettings | null { |
| @@ -145,6 +150,7 @@ export function writeBrowserSavedEnvironmentRegistry( · 85 unmodified lines | |||
| 145 | createdAt: record.createdAt, | 150 | createdAt: record.createdAt, |
| 146 | lastConnectedAt: record.lastConnectedAt, | 151 | lastConnectedAt: record.lastConnectedAt, |
| 147 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | 152 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), |
| 153 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 148 | bearerToken, | 154 | bearerToken, |
| 149 | } | 155 | } |
| 150 | : toPersistedSavedEnvironmentRecord(record); | 156 | : toPersistedSavedEnvironmentRecord(record); |
| @@ -171,6 +177,8 @@ export function writeBrowserSavedEnvironmentSecret( · 20 unmodified lines | |||
| 171 | let found = false; | 177 | let found = false; |
| 172 | writeBrowserSavedEnvironmentRegistryDocument({ | 178 | writeBrowserSavedEnvironmentRegistryDocument({ |
| 173 | version: document.version ?? 1, | 179 | version: document.version ?? 1, |
| 180 | // The persistence update is copy-on-write so storage subscribers observe a new document. | ||
| 181 | // oxlint-disable-next-line oxc/no-map-spread | ||
| 174 | records: records.map((record) => { | 182 | records: records.map((record) => { |
| 175 | if (record.environmentId !== environmentId) { | 183 | if (record.environmentId !== environmentId) { |
| 176 | return record; | 184 | return record; |
| @@ -185,7 +193,11 @@ export function writeBrowserSavedEnvironmentSecret( · 8 unmodified lines | |||
| 185 | lastConnectedAt: record.lastConnectedAt, | 193 | lastConnectedAt: record.lastConnectedAt, |
| 186 | bearerToken: secret, | 194 | bearerToken: secret, |
| 187 | }; | 195 | }; |
| 188 | return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | 196 | return { |
| 197 | ...nextRecord, | ||
| 198 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 199 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 200 | }; | ||
| 189 | }), | 201 | }), |
| 190 | }); | 202 | }); |
| 191 | return found; | 203 | return found; |
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | }); | ||
| @@ -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 | } | ||
| @@ -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 | } | ||
| @@ -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 | ); | ||
| @@ -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 | } | ||
| @@ -450,6 +450,18 @@ const createDesktopBridgeStub = (overrides?: { · 449 unmodified lines | |||
| 450 | setTheme: vi.fn().mockResolvedValue(undefined), | 450 | setTheme: vi.fn().mockResolvedValue(undefined), |
| 451 | showContextMenu: vi.fn().mockResolvedValue(null), | 451 | showContextMenu: vi.fn().mockResolvedValue(null), |
| 452 | openExternal: vi.fn().mockResolvedValue(true), | 452 | openExternal: vi.fn().mockResolvedValue(true), |
| 453 | createCloudAuthRequest: vi.fn().mockResolvedValue("t3code-dev://auth/callback?t3_state=test"), | ||
| 454 | getCloudAuthToken: vi.fn().mockResolvedValue(null), | ||
| 455 | setCloudAuthToken: vi.fn().mockResolvedValue(true), | ||
| 456 | clearCloudAuthToken: vi.fn().mockResolvedValue(undefined), | ||
| 457 | fetchCloudAuth: vi.fn().mockResolvedValue({ | ||
| 458 | ok: true, | ||
| 459 | status: 200, | ||
| 460 | statusText: "OK", | ||
| 461 | headers: {}, | ||
| 462 | body: "", | ||
| 463 | }), | ||
| 464 | onCloudAuthCallback: () => () => {}, | ||
| 453 | onMenuAction: () => () => {}, | 465 | onMenuAction: () => () => {}, |
| 454 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), | 466 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), |
| 455 | setUpdateChannel: | 467 | setUpdateChannel: |
| @@ -3,6 +3,7 @@ import { · 2 unmodified lines | |||
| 3 | ArchiveIcon, | 3 | ArchiveIcon, |
| 4 | ArrowLeftIcon, | 4 | ArrowLeftIcon, |
| 5 | BotIcon, | 5 | BotIcon, |
| 6 | CloudIcon, | ||
| 6 | GitBranchIcon, | 7 | GitBranchIcon, |
| 7 | KeyboardIcon, | 8 | KeyboardIcon, |
| 8 | Link2Icon, | 9 | Link2Icon, |
| @@ -26,6 +27,7 @@ export type SettingsSectionPath = · 17 unmodified lines | |||
| 26 | | "/settings/keybindings" | 27 | | "/settings/keybindings" |
| 27 | | "/settings/providers" | 28 | | "/settings/providers" |
| 28 | | "/settings/source-control" | 29 | | "/settings/source-control" |
| 30 | | "/settings/cloud" | ||
| 29 | | "/settings/connections" | 31 | | "/settings/connections" |
| 30 | | "/settings/archived"; | 32 | | "/settings/archived"; |
| 31 | 33 | ||
| @@ -38,6 +40,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ · 6 unmodified lines | |||
| 38 | { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, | 40 | { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, |
| 39 | { label: "Providers", to: "/settings/providers", icon: BotIcon }, | 41 | { label: "Providers", to: "/settings/providers", icon: BotIcon }, |
| 40 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, | 42 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, |
| 43 | { label: "T3 Cloud", to: "/settings/cloud", icon: CloudIcon }, | ||
| 41 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, | 44 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, |
| 42 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, | 45 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, |
| 43 | ]; | 46 | ]; |
| @@ -3,6 +3,7 @@ import type { · 2 unmodified lines | |||
| 3 | AuthClientMetadata, | 3 | AuthClientMetadata, |
| 4 | AuthEnvironmentScope, | 4 | AuthEnvironmentScope, |
| 5 | AuthPairingCredentialResult, | 5 | AuthPairingCredentialResult, |
| 6 | ServerAuthSessionMethod, | ||
| 6 | AuthSessionId, | 7 | AuthSessionId, |
| 7 | AuthSessionState, | 8 | AuthSessionState, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| @@ -45,7 +46,7 @@ export interface ServerClientSessionRecord { · 36 unmodified lines | |||
| 45 | readonly sessionId: AuthSessionId; | 46 | readonly sessionId: AuthSessionId; |
| 46 | readonly subject: string; | 47 | readonly subject: string; |
| 47 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 48 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 48 | readonly method: "browser-session-cookie" | "bearer-access-token"; | 49 | readonly method: ServerAuthSessionMethod; |
| 49 | readonly client: AuthClientMetadata; | 50 | readonly client: AuthClientMetadata; |
| 50 | readonly issuedAt: string; | 51 | readonly issuedAt: string; |
| 51 | readonly expiresAt: string; | 52 | readonly expiresAt: string; |
| @@ -34,4 +34,8 @@ export { · 33 unmodified lines | |||
| 34 | 34 | ||
| 35 | export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState"; | 35 | export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState"; |
| 36 | 36 | ||
| 37 | export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target"; | 37 | export { |
| 38 | readPrimaryEnvironmentTarget, | ||
| 39 | resolvePrimaryEnvironmentHttpUrl, | ||
| 40 | isLoopbackHostname, | ||
| 41 | } from "./target"; | ||
| @@ -6,11 +6,13 @@ import { · 5 unmodified lines | |||
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 9 | readSavedEnvironmentCredential, | ||
| 9 | resetSavedEnvironmentRegistryStoreForTests, | 10 | resetSavedEnvironmentRegistryStoreForTests, |
| 10 | resetSavedEnvironmentRuntimeStoreForTests, | 11 | resetSavedEnvironmentRuntimeStoreForTests, |
| 11 | useSavedEnvironmentRegistryStore, | 12 | useSavedEnvironmentRegistryStore, |
| 12 | useSavedEnvironmentRuntimeStore, | 13 | useSavedEnvironmentRuntimeStore, |
| 13 | waitForSavedEnvironmentRegistryHydration, | 14 | waitForSavedEnvironmentRegistryHydration, |
| 15 | writeSavedEnvironmentCredential, | ||
| 14 | } from "./catalog"; | 16 | } from "./catalog"; |
| 15 | 17 | ||
| 16 | describe("environment runtime catalog stores", () => { | 18 | describe("environment runtime catalog stores", () => { |
| @@ -74,6 +76,47 @@ describe("environment runtime catalog stores", () => { · 57 unmodified lines | |||
| 74 | expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); | 76 | expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); |
| 75 | }); | 77 | }); |
| 76 | 78 | ||
| 79 | it("decodes legacy bearer secrets and writes versioned DPoP credentials", async () => { | ||
| 80 | let storedSecret: string | null = "legacy-bearer-token"; | ||
| 81 | vi.stubGlobal("window", { | ||
| 82 | nativeApi: { | ||
| 83 | persistence: { | ||
| 84 | getClientSettings: async () => null, | ||
| 85 | setClientSettings: async () => undefined, | ||
| 86 | getSavedEnvironmentRegistry: async () => [], | ||
| 87 | setSavedEnvironmentRegistry: async () => undefined, | ||
| 88 | getSavedEnvironmentSecret: async () => storedSecret, | ||
| 89 | setSavedEnvironmentSecret: async (_environmentId, secret) => { | ||
| 90 | storedSecret = secret; | ||
| 91 | return true; | ||
| 92 | }, | ||
| 93 | removeSavedEnvironmentSecret: async () => undefined, | ||
| 94 | }, | ||
| 95 | } satisfies Pick<LocalApi, "persistence">, | ||
| 96 | }); | ||
| 97 | const { __resetLocalApiForTests } = await import("../../localApi"); | ||
| 98 | await __resetLocalApiForTests(); | ||
| 99 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 100 | |||
| 101 | await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ | ||
| 102 | version: 1, | ||
| 103 | method: "bearer", | ||
| 104 | token: "legacy-bearer-token", | ||
| 105 | }); | ||
| 106 | await expect( | ||
| 107 | writeSavedEnvironmentCredential(environmentId, { | ||
| 108 | version: 1, | ||
| 109 | method: "dpop", | ||
| 110 | accessToken: "managed-dpop-access-token", | ||
| 111 | }), | ||
| 112 | ).resolves.toBe(true); | ||
| 113 | await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ | ||
| 114 | version: 1, | ||
| 115 | method: "dpop", | ||
| 116 | accessToken: "managed-dpop-access-token", | ||
| 117 | }); | ||
| 118 | }); | ||
| 119 | |||
| 77 | it("does not throw when local api lookup fails during registry persistence", async () => { | 120 | it("does not throw when local api lookup fails during registry persistence", async () => { |
| 78 | vi.unstubAllGlobals(); | 121 | vi.unstubAllGlobals(); |
| 79 | const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); | 122 | const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); |
| @@ -6,6 +6,8 @@ import type { · 5 unmodified lines | |||
| 6 | PersistedSavedEnvironmentRecord, | 6 | PersistedSavedEnvironmentRecord, |
| 7 | ServerConfig, | 7 | ServerConfig, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 9 | import { create } from "zustand"; | 11 | import { create } from "zustand"; |
| 10 | 12 | ||
| 11 | import { ensureLocalApi } from "../../localApi"; | 13 | import { ensureLocalApi } from "../../localApi"; |
| @@ -19,8 +21,29 @@ export interface SavedEnvironmentRecord { · 7 unmodified lines | |||
| 19 | readonly createdAt: string; | 21 | readonly createdAt: string; |
| 20 | readonly lastConnectedAt: string | null; | 22 | readonly lastConnectedAt: string | null; |
| 21 | readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"]; | 23 | readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"]; |
| 24 | readonly relayManaged?: PersistedSavedEnvironmentRecord["relayManaged"]; | ||
| 22 | } | 25 | } |
| 23 | 26 | ||
| 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 | |||
| 24 | interface SavedEnvironmentRegistryState { | 47 | interface SavedEnvironmentRegistryState { |
| 25 | readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>; | 48 | readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>; |
| 26 | } | 49 | } |
| @@ -47,6 +70,7 @@ export function toPersistedSavedEnvironmentRecord( · 20 unmodified lines | |||
| 47 | createdAt: record.createdAt, | 70 | createdAt: record.createdAt, |
| 48 | lastConnectedAt: record.lastConnectedAt, | 71 | lastConnectedAt: record.lastConnectedAt, |
| 49 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | 72 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), |
| 73 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 50 | }; | 74 | }; |
| 51 | } | 75 | } |
| 52 | 76 | ||
| @@ -250,6 +274,31 @@ export async function readSavedEnvironmentBearerToken( · 197 unmodified lines | |||
| 250 | return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); | 274 | return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); |
| 251 | } | 275 | } |
| 252 | 276 | ||
| 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 | |||
| 253 | export async function writeSavedEnvironmentBearerToken( | 302 | export async function writeSavedEnvironmentBearerToken( |
| 254 | environmentId: EnvironmentId, | 303 | environmentId: EnvironmentId, |
| 255 | bearerToken: string, | 304 | bearerToken: string, |
| @@ -16,6 +16,7 @@ export { · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | export { | 17 | export { |
| 18 | addSavedEnvironment, | 18 | addSavedEnvironment, |
| 19 | addManagedRelayEnvironment, | ||
| 19 | connectDesktopSshEnvironment, | 20 | connectDesktopSshEnvironment, |
| 20 | disconnectSavedEnvironment, | 21 | disconnectSavedEnvironment, |
| 21 | ensureEnvironmentConnectionBootstrapped, | 22 | ensureEnvironmentConnectionBootstrapped, |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 5 | ||
| @@ -10,17 +11,33 @@ const mockResolveRemotePairingTarget = vi.fn(); · 5 unmodified lines | |||
| 10 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); | 11 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); |
| 11 | const mockBootstrapRemoteBearerSession = vi.fn(); | 12 | const mockBootstrapRemoteBearerSession = vi.fn(); |
| 12 | const mockFetchRemoteSessionState = vi.fn(); | 13 | const mockFetchRemoteSessionState = vi.fn(); |
| 14 | const mockFetchRemoteDpopSessionState = vi.fn(); | ||
| 13 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); | 15 | 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 | ); | ||
| 15 | const mockBootstrapSshBearerSession = vi.fn(); | 30 | const mockBootstrapSshBearerSession = vi.fn(); |
| 16 | const mockFetchSshSessionState = vi.fn(); | 31 | const mockFetchSshSessionState = vi.fn(); |
| 17 | const mockPersistSavedEnvironmentRecord = vi.fn(); | 32 | const mockPersistSavedEnvironmentRecord = vi.fn(); |
| 18 | const mockWriteSavedEnvironmentBearerToken = vi.fn(); | 33 | const mockWriteSavedEnvironmentBearerToken = vi.fn(); |
| 34 | const mockWriteSavedEnvironmentCredential = vi.fn(); | ||
| 19 | const mockSetSavedEnvironmentRegistry = vi.fn(); | 35 | const mockSetSavedEnvironmentRegistry = vi.fn(); |
| 20 | const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { | 36 | const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { |
| 21 | return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; | 37 | return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; |
| 22 | }); | 38 | }); |
| 23 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | 39 | const mockReadSavedEnvironmentBearerToken = vi.fn(); |
| 40 | const mockReadSavedEnvironmentCredential = vi.fn(); | ||
| 24 | const mockRemoveSavedEnvironmentBearerToken = vi.fn(); | 41 | const mockRemoveSavedEnvironmentBearerToken = vi.fn(); |
| 25 | const mockPatchRuntime = vi.fn(); | 42 | const mockPatchRuntime = vi.fn(); |
| 26 | const mockClearRuntime = vi.fn(); | 43 | const mockClearRuntime = vi.fn(); |
| @@ -58,6 +75,8 @@ const mockClientGetConfig = vi.fn(async () => ({ · 31 unmodified lines | |||
| 58 | label: "Remote environment", | 75 | label: "Remote environment", |
| 59 | }, | 76 | }, |
| 60 | })); | 77 | })); |
| 78 | const mockConnectManagedCloudEnvironment = vi.fn(); | ||
| 79 | const mockReadManagedRelayClerkToken = vi.fn(); | ||
| 61 | 80 | ||
| 62 | vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ | 81 | vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ |
| 63 | ...(await importOriginal<typeof import("@t3tools/shared/remote")>()), | 82 | ...(await importOriginal<typeof import("@t3tools/shared/remote")>()), |
| @@ -65,11 +84,19 @@ vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ · 1 unmodified line | |||
| 65 | })); | 84 | })); |
| 66 | 85 | ||
| 67 | vi.mock("../../lib/runtime", () => ({ | 86 | vi.mock("../../lib/runtime", () => ({ |
| 68 | remoteHttpRuntime: { | 87 | webRuntime: { |
| 69 | runPromise: mockRemoteHttpRunPromise, | 88 | runPromise: mockRemoteHttpRunPromise, |
| 70 | }, | 89 | }, |
| 71 | })); | 90 | })); |
| 72 | 91 | ||
| 92 | vi.mock("../../cloud/linkEnvironment", () => ({ | ||
| 93 | connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment, | ||
| 94 | })); | ||
| 95 | |||
| 96 | vi.mock("../../cloud/managedAuth", () => ({ | ||
| 97 | readManagedRelayClerkToken: mockReadManagedRelayClerkToken, | ||
| 98 | })); | ||
| 99 | |||
| 73 | vi.mock("~/localApi", () => ({ | 100 | vi.mock("~/localApi", () => ({ |
| 74 | ensureLocalApi: () => ({ | 101 | ensureLocalApi: () => ({ |
| 75 | persistence: { | 102 | persistence: { |
| @@ -84,6 +111,7 @@ vi.mock("./catalog", () => ({ · 8 unmodified lines | |||
| 84 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | 111 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, |
| 85 | persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, | 112 | persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, |
| 86 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, | 113 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, |
| 114 | readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, | ||
| 87 | removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, | 115 | removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, |
| 88 | toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, | 116 | toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, |
| 89 | useSavedEnvironmentRegistryStore: { | 117 | useSavedEnvironmentRegistryStore: { |
| @@ -105,6 +133,7 @@ vi.mock("./catalog", () => ({ · 15 unmodified lines | |||
| 105 | }, | 133 | }, |
| 106 | waitForSavedEnvironmentRegistryHydration: vi.fn(), | 134 | waitForSavedEnvironmentRegistryHydration: vi.fn(), |
| 107 | writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, | 135 | writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, |
| 136 | writeSavedEnvironmentCredential: mockWriteSavedEnvironmentCredential, | ||
| 108 | })); | 137 | })); |
| 109 | 138 | ||
| 110 | vi.mock("./connection", async (importOriginal) => ({ | 139 | vi.mock("./connection", async (importOriginal) => ({ |
| @@ -114,6 +143,7 @@ vi.mock("./connection", async (importOriginal) => ({ · 3 unmodified lines | |||
| 114 | 143 | ||
| 115 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { | 144 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 116 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); | 145 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); |
| 146 | managedRelayDpopSigner = actual.ManagedRelayDpopSigner; | ||
| 117 | return { | 147 | return { |
| 118 | ...actual, | 148 | ...actual, |
| 119 | bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, | 149 | bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, |
| @@ -130,6 +160,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { · 10 unmodified lines | |||
| 130 | })), | 160 | })), |
| 131 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, | 161 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, |
| 132 | fetchRemoteSessionState: mockFetchRemoteSessionState, | 162 | fetchRemoteSessionState: mockFetchRemoteSessionState, |
| 163 | fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState, | ||
| 133 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | 164 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, |
| 134 | }; | 165 | }; |
| 135 | }); | 166 | }); |
| @@ -172,20 +203,36 @@ describe("addSavedEnvironment", () => { · 36 unmodified lines | |||
| 172 | credential: input.pairingCode ?? "pairing-code", | 203 | credential: input.pairingCode ?? "pairing-code", |
| 173 | }), | 204 | }), |
| 174 | ); | 205 | ); |
| 175 | mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({ | 206 | mockReadSavedEnvironmentCredential.mockImplementation(async () => { |
| 176 | environmentId: EnvironmentId.make("environment-1"), | 207 | const token = await mockReadSavedEnvironmentBearerToken(); |
| 177 | label: "Remote environment", | 208 | return token ? { version: 1, method: "bearer", token } : null; |
| 178 | }); | ||
| 179 | mockBootstrapRemoteBearerSession.mockResolvedValue({ | ||
| 180 | access_token: "bearer-token", | ||
| 181 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 182 | }); | 209 | }); |
| 183 | mockFetchRemoteSessionState.mockResolvedValue({ | 210 | mockFetchRemoteEnvironmentDescriptor.mockReturnValue( |
| 184 | authenticated: true, | 211 | Effect.succeed({ |
| 185 | scopes: ["orchestration:read", "access:write"], | 212 | environmentId: EnvironmentId.make("environment-1"), |
| 186 | }); | 213 | label: "Remote environment", |
| 187 | mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( | 214 | }), |
| 188 | "wss://remote.example.com/?wsTicket=remote-token", | 215 | ); |
| 216 | mockBootstrapRemoteBearerSession.mockReturnValue( | ||
| 217 | Effect.succeed({ | ||
| 218 | access_token: "bearer-token", | ||
| 219 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 220 | }), | ||
| 221 | ); | ||
| 222 | mockFetchRemoteSessionState.mockReturnValue( | ||
| 223 | Effect.succeed({ | ||
| 224 | authenticated: true, | ||
| 225 | scopes: ["orchestration:read", "access:write"], | ||
| 226 | }), | ||
| 227 | ); | ||
| 228 | mockFetchRemoteDpopSessionState.mockReturnValue( | ||
| 229 | Effect.succeed({ | ||
| 230 | authenticated: true, | ||
| 231 | scopes: ["orchestration:read", "access:write"], | ||
| 232 | }), | ||
| 233 | ); | ||
| 234 | mockResolveRemoteWebSocketConnectionUrl.mockReturnValue( | ||
| 235 | Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"), | ||
| 189 | ); | 236 | ); |
| 190 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ | 237 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ |
| 191 | environmentId: EnvironmentId.make("environment-1"), | 238 | environmentId: EnvironmentId.make("environment-1"), |
| @@ -197,6 +244,8 @@ describe("addSavedEnvironment", () => { · 5 unmodified lines | |||
| 197 | }); | 244 | }); |
| 198 | mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); | 245 | mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); |
| 199 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); | 246 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); |
| 247 | mockWriteSavedEnvironmentCredential.mockResolvedValue(true); | ||
| 248 | mockReadManagedRelayClerkToken.mockResolvedValue(null); | ||
| 200 | mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); | 249 | mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); |
| 201 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | 250 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); |
| 202 | mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); | 251 | mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); |
| @@ -323,6 +372,94 @@ describe("addSavedEnvironment", () => { · 120 unmodified lines | |||
| 323 | await resetEnvironmentServiceForTests(); | 372 | await resetEnvironmentServiceForTests(); |
| 324 | }); | 373 | }); |
| 325 | 374 | ||
| 375 | it("installs relay-managed environments with versioned DPoP credentials", async () => { | ||
| 376 | const { addManagedRelayEnvironment, resetEnvironmentServiceForTests } = | ||
| 377 | await import("./service"); | ||
| 378 | |||
| 379 | await addManagedRelayEnvironment({ | ||
| 380 | environmentId: EnvironmentId.make("environment-1"), | ||
| 381 | label: "Managed remote", | ||
| 382 | httpBaseUrl: "https://managed.example.com/", | ||
| 383 | wsBaseUrl: "wss://managed.example.com/", | ||
| 384 | relayUrl: "https://relay.example.com", | ||
| 385 | accessToken: "managed-access-token", | ||
| 386 | }); | ||
| 387 | |||
| 388 | expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith( | ||
| 389 | EnvironmentId.make("environment-1"), | ||
| 390 | { | ||
| 391 | version: 1, | ||
| 392 | method: "dpop", | ||
| 393 | accessToken: "managed-access-token", | ||
| 394 | }, | ||
| 395 | ); | ||
| 396 | expect(mockFetchRemoteDpopSessionState).toHaveBeenCalledWith({ | ||
| 397 | httpBaseUrl: "https://managed.example.com/", | ||
| 398 | accessToken: "managed-access-token", | ||
| 399 | dpopProof: "dpop-proof", | ||
| 400 | }); | ||
| 401 | await resetEnvironmentServiceForTests(); | ||
| 402 | }); | ||
| 403 | |||
| 404 | it("renews expired managed DPoP credentials through the relay", async () => { | ||
| 405 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 406 | mockSavedRecords = [ | ||
| 407 | { | ||
| 408 | environmentId, | ||
| 409 | label: "Managed remote", | ||
| 410 | httpBaseUrl: "https://managed.example.com/", | ||
| 411 | wsBaseUrl: "wss://managed.example.com/", | ||
| 412 | createdAt: "2026-05-25T00:00:00.000Z", | ||
| 413 | lastConnectedAt: null, | ||
| 414 | relayManaged: { relayUrl: "https://relay.example.com" }, | ||
| 415 | }, | ||
| 416 | ]; | ||
| 417 | mockReadSavedEnvironmentCredential.mockResolvedValue({ | ||
| 418 | version: 1, | ||
| 419 | method: "dpop", | ||
| 420 | accessToken: "expired-access-token", | ||
| 421 | }); | ||
| 422 | mockFetchRemoteDpopSessionState | ||
| 423 | .mockReturnValueOnce( | ||
| 424 | Effect.fail( | ||
| 425 | decodeEnvironmentAuthInvalidError({ | ||
| 426 | _tag: "EnvironmentAuthInvalidError", | ||
| 427 | code: "auth_invalid", | ||
| 428 | reason: "invalid_credential", | ||
| 429 | traceId: "trace-auth-expired", | ||
| 430 | }), | ||
| 431 | ), | ||
| 432 | ) | ||
| 433 | .mockReturnValue(Effect.succeed({ authenticated: true, scopes: ["orchestration:read"] })); | ||
| 434 | mockReadManagedRelayClerkToken.mockResolvedValue("clerk-token"); | ||
| 435 | mockConnectManagedCloudEnvironment.mockReturnValue( | ||
| 436 | Effect.succeed({ | ||
| 437 | environmentId, | ||
| 438 | label: "Managed remote", | ||
| 439 | httpBaseUrl: "https://managed.example.com/", | ||
| 440 | wsBaseUrl: "wss://managed.example.com/", | ||
| 441 | relayUrl: "https://relay.example.com", | ||
| 442 | accessToken: "renewed-access-token", | ||
| 443 | }), | ||
| 444 | ); | ||
| 445 | |||
| 446 | const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 447 | await import("./service"); | ||
| 448 | await reconnectSavedEnvironment(environmentId); | ||
| 449 | |||
| 450 | expect(mockConnectManagedCloudEnvironment).toHaveBeenCalledWith({ | ||
| 451 | clerkToken: "clerk-token", | ||
| 452 | relayUrl: "https://relay.example.com", | ||
| 453 | environment: expect.objectContaining({ environmentId }), | ||
| 454 | }); | ||
| 455 | expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(environmentId, { | ||
| 456 | version: 1, | ||
| 457 | method: "dpop", | ||
| 458 | accessToken: "renewed-access-token", | ||
| 459 | }); | ||
| 460 | await resetEnvironmentServiceForTests(); | ||
| 461 | }); | ||
| 462 | |||
| 326 | it("removes an older ssh record when the same target returns a new environment id", async () => { | 463 | it("removes an older ssh record when the same target returns a new environment id", async () => { |
| 327 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | 464 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); |
| 328 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ | 465 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ |
| @@ -424,7 +561,7 @@ describe("addSavedEnvironment", () => { · 95 unmodified lines | |||
| 424 | reason: "invalid_credential", | 561 | reason: "invalid_credential", |
| 425 | traceId: "trace-auth-test", | 562 | traceId: "trace-auth-test", |
| 426 | }); | 563 | }); |
| 427 | mockFetchRemoteSessionState.mockRejectedValueOnce(authError); | 564 | mockFetchRemoteSessionState.mockReturnValueOnce(Effect.fail(authError)); |
| 428 | 565 | ||
| 429 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | 566 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); |
| 430 | 567 | ||
| @@ -747,9 +884,12 @@ describe("addSavedEnvironment", () => { · 316 unmodified lines | |||
| 747 | readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; | 884 | readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; |
| 748 | }) => void; | 885 | }) => void; |
| 749 | mockFetchRemoteSessionState.mockReturnValue( | 886 | mockFetchRemoteSessionState.mockReturnValue( |
| 750 | new Promise((resolve) => { | 887 | Effect.promise( |
| 751 | resolveSessionState = resolve; | 888 | () => |
| 752 | }), | 889 | new Promise((resolve) => { |
| 890 | resolveSessionState = resolve; | ||
| 891 | }), | ||
| 892 | ), | ||
| 753 | ); | 893 | ); |
| 754 | 894 | ||
| 755 | const { | 895 | const { |
| @@ -11,6 +11,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); · 10 unmodified lines | |||
| 11 | const mockListSavedEnvironmentRecords = vi.fn(); | 11 | const mockListSavedEnvironmentRecords = vi.fn(); |
| 12 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); | 12 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); |
| 13 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | 13 | const mockReadSavedEnvironmentBearerToken = vi.fn(); |
| 14 | const mockReadSavedEnvironmentCredential = vi.fn(); | ||
| 14 | const mockGetSavedEnvironmentRecord = vi.fn(); | 15 | const mockGetSavedEnvironmentRecord = vi.fn(); |
| 15 | 16 | ||
| 16 | function MockWsTransport() { | 17 | function MockWsTransport() { |
| @@ -31,7 +32,7 @@ vi.mock("../primary", () => ({ · 14 unmodified lines | |||
| 31 | })); | 32 | })); |
| 32 | 33 | ||
| 33 | vi.mock("../../lib/runtime", () => ({ | 34 | vi.mock("../../lib/runtime", () => ({ |
| 34 | remoteHttpRuntime: { | 35 | webRuntime: { |
| 35 | runPromise: mockRemoteHttpRunPromise, | 36 | runPromise: mockRemoteHttpRunPromise, |
| 36 | }, | 37 | }, |
| 37 | })); | 38 | })); |
| @@ -42,6 +43,7 @@ vi.mock("./catalog", () => ({ · 4 unmodified lines | |||
| 42 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | 43 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, |
| 43 | persistSavedEnvironmentRecord: vi.fn(), | 44 | persistSavedEnvironmentRecord: vi.fn(), |
| 44 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, | 45 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, |
| 46 | readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, | ||
| 45 | removeSavedEnvironmentBearerToken: vi.fn(), | 47 | removeSavedEnvironmentBearerToken: vi.fn(), |
| 46 | useSavedEnvironmentRegistryStore: { | 48 | useSavedEnvironmentRegistryStore: { |
| 47 | subscribe: mockSavedEnvironmentRegistrySubscribe, | 49 | subscribe: mockSavedEnvironmentRegistrySubscribe, |
| @@ -61,6 +63,7 @@ vi.mock("./catalog", () => ({ · 13 unmodified lines | |||
| 61 | }, | 63 | }, |
| 62 | waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, | 64 | waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, |
| 63 | writeSavedEnvironmentBearerToken: vi.fn(), | 65 | writeSavedEnvironmentBearerToken: vi.fn(), |
| 66 | writeSavedEnvironmentCredential: vi.fn(), | ||
| 64 | })); | 67 | })); |
| 65 | 68 | ||
| 66 | vi.mock("./connection", async (importOriginal) => ({ | 69 | vi.mock("./connection", async (importOriginal) => ({ |
| @@ -231,6 +234,10 @@ describe("saved environment startup", () => { · 164 unmodified lines | |||
| 231 | mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); | 234 | mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); |
| 232 | mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); | 235 | mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); |
| 233 | mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token"); | 236 | mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token"); |
| 237 | mockReadSavedEnvironmentCredential.mockImplementation(async () => { | ||
| 238 | const token = await mockReadSavedEnvironmentBearerToken(); | ||
| 239 | return token ? { version: 1, method: "bearer", token } : null; | ||
| 240 | }); | ||
| 234 | mockCreateWsRpcClient.mockImplementation(() => createClient()); | 241 | mockCreateWsRpcClient.mockImplementation(() => createClient()); |
| 235 | mockCreateEnvironmentConnection.mockImplementation((input) => { | 242 | mockCreateEnvironmentConnection.mockImplementation((input) => { |
| 236 | if (input.kind === "saved") { | 243 | if (input.kind === "saved") { |
| @@ -18,6 +18,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); · 17 unmodified lines | |||
| 18 | const mockListSavedEnvironmentRecords = vi.fn(); | 18 | const mockListSavedEnvironmentRecords = vi.fn(); |
| 19 | const mockGetSavedEnvironmentRecord = vi.fn(); | 19 | const mockGetSavedEnvironmentRecord = vi.fn(); |
| 20 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | 20 | const mockReadSavedEnvironmentBearerToken = vi.fn(); |
| 21 | const mockReadSavedEnvironmentCredential = vi.fn(); | ||
| 21 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); | 22 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); |
| 22 | const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); | 23 | const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); |
| 23 | const mockFetchRemoteSessionState = vi.fn(); | 24 | const mockFetchRemoteSessionState = vi.fn(); |
| @@ -35,7 +36,7 @@ vi.mock("../primary", () => ({ · 11 unmodified lines | |||
| 35 | })); | 36 | })); |
| 36 | 37 | ||
| 37 | vi.mock("../../lib/runtime", () => ({ | 38 | vi.mock("../../lib/runtime", () => ({ |
| 38 | remoteHttpRuntime: { | 39 | webRuntime: { |
| 39 | runPromise: mockRemoteHttpRunPromise, | 40 | runPromise: mockRemoteHttpRunPromise, |
| 40 | }, | 41 | }, |
| 41 | })); | 42 | })); |
| @@ -46,6 +47,7 @@ vi.mock("./catalog", () => ({ · 4 unmodified lines | |||
| 46 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | 47 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, |
| 47 | persistSavedEnvironmentRecord: vi.fn(), | 48 | persistSavedEnvironmentRecord: vi.fn(), |
| 48 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, | 49 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, |
| 50 | readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, | ||
| 49 | removeSavedEnvironmentBearerToken: vi.fn(), | 51 | removeSavedEnvironmentBearerToken: vi.fn(), |
| 50 | useSavedEnvironmentRegistryStore: { | 52 | useSavedEnvironmentRegistryStore: { |
| 51 | subscribe: mockSavedEnvironmentRegistrySubscribe, | 53 | subscribe: mockSavedEnvironmentRegistrySubscribe, |
| @@ -65,6 +67,7 @@ vi.mock("./catalog", () => ({ · 13 unmodified lines | |||
| 65 | }, | 67 | }, |
| 66 | waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, | 68 | waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, |
| 67 | writeSavedEnvironmentBearerToken: vi.fn(), | 69 | writeSavedEnvironmentBearerToken: vi.fn(), |
| 70 | writeSavedEnvironmentCredential: vi.fn(), | ||
| 68 | })); | 71 | })); |
| 69 | 72 | ||
| 70 | vi.mock("./connection", async (importOriginal) => ({ | 73 | vi.mock("./connection", async (importOriginal) => ({ |
| @@ -301,6 +304,10 @@ describe("retainThreadDetailSubscription", () => { · 230 unmodified lines | |||
| 301 | mockListSavedEnvironmentRecords.mockReturnValue([]); | 304 | mockListSavedEnvironmentRecords.mockReturnValue([]); |
| 302 | mockGetSavedEnvironmentRecord.mockReturnValue(null); | 305 | mockGetSavedEnvironmentRecord.mockReturnValue(null); |
| 303 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | 306 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); |
| 307 | mockReadSavedEnvironmentCredential.mockImplementation(async () => { | ||
| 308 | const token = await mockReadSavedEnvironmentBearerToken(); | ||
| 309 | return token ? { version: 1, method: "bearer", token } : null; | ||
| 310 | }); | ||
| 304 | mockFetchRemoteSessionState.mockResolvedValue({ | 311 | mockFetchRemoteSessionState.mockResolvedValue({ |
| 305 | authenticated: true, | 312 | authenticated: true, |
| 306 | scopes: ["orchestration:read"], | 313 | scopes: ["orchestration:read"], |
| @@ -1,6 +1,5 @@ | |||
| 1 | import { | 1 | import { |
| 2 | AuthEnvironmentScope, | 2 | AuthEnvironmentScope, |
| 3 | AuthStandardClientScopes, | ||
| 4 | type DesktopSshEnvironmentBootstrap, | 3 | type DesktopSshEnvironmentBootstrap, |
| 5 | type DesktopSshEnvironmentTarget, | 4 | type DesktopSshEnvironmentTarget, |
| 6 | type EnvironmentId, | 5 | type EnvironmentId, |
| @@ -16,12 +15,17 @@ import { · 9 unmodified lines | |||
| 16 | type WsRpcClient, | 15 | type WsRpcClient, |
| 17 | bootstrapRemoteBearerSession, | 16 | bootstrapRemoteBearerSession, |
| 18 | fetchRemoteEnvironmentDescriptor, | 17 | fetchRemoteEnvironmentDescriptor, |
| 18 | fetchRemoteDpopSessionState, | ||
| 19 | fetchRemoteSessionState, | 19 | fetchRemoteSessionState, |
| 20 | type ManagedRelayDpopProofInput, | ||
| 21 | ManagedRelayDpopSigner, | ||
| 22 | resolveRemoteDpopWebSocketConnectionUrl, | ||
| 20 | resolveRemoteWebSocketConnectionUrl, | 23 | resolveRemoteWebSocketConnectionUrl, |
| 21 | } from "@t3tools/client-runtime"; | 24 | } from "@t3tools/client-runtime"; |
| 22 | 25 | ||
| 23 | import { type QueryClient } from "@tanstack/react-query"; | 26 | import { type QueryClient } from "@tanstack/react-query"; |
| 24 | import { Throttler } from "@tanstack/react-pacer"; | 27 | import { Throttler } from "@tanstack/react-pacer"; |
| 28 | import * as Effect from "effect/Effect"; | ||
| 25 | import * as Schema from "effect/Schema"; | 29 | import * as Schema from "effect/Schema"; |
| 26 | import { | 30 | import { |
| 27 | createKnownEnvironment, | 31 | createKnownEnvironment, |
| @@ -40,21 +44,25 @@ import { ensureLocalApi } from "~/localApi"; · 12 unmodified lines | |||
| 40 | import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup"; | 44 | import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup"; |
| 41 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; | 45 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; |
| 42 | import { getPrimaryKnownEnvironment } from "../primary"; | 46 | 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"; | ||
| 44 | 50 | ||
| 45 | import { | 51 | import { |
| 46 | getSavedEnvironmentRecord, | 52 | getSavedEnvironmentRecord, |
| 47 | hasSavedEnvironmentRegistryHydrated, | 53 | hasSavedEnvironmentRegistryHydrated, |
| 48 | listSavedEnvironmentRecords, | 54 | listSavedEnvironmentRecords, |
| 49 | persistSavedEnvironmentRecord, | 55 | persistSavedEnvironmentRecord, |
| 50 | readSavedEnvironmentBearerToken, | 56 | readSavedEnvironmentCredential, |
| 51 | removeSavedEnvironmentBearerToken, | 57 | removeSavedEnvironmentBearerToken, |
| 52 | type SavedEnvironmentRecord, | 58 | type SavedEnvironmentRecord, |
| 59 | type SavedEnvironmentCredential, | ||
| 53 | toPersistedSavedEnvironmentRecord, | 60 | toPersistedSavedEnvironmentRecord, |
| 54 | useSavedEnvironmentRegistryStore, | 61 | useSavedEnvironmentRegistryStore, |
| 55 | useSavedEnvironmentRuntimeStore, | 62 | useSavedEnvironmentRuntimeStore, |
| 56 | waitForSavedEnvironmentRegistryHydration, | 63 | waitForSavedEnvironmentRegistryHydration, |
| 57 | writeSavedEnvironmentBearerToken, | 64 | writeSavedEnvironmentBearerToken, |
| 65 | writeSavedEnvironmentCredential, | ||
| 58 | } from "./catalog"; | 66 | } from "./catalog"; |
| 59 | import { | 67 | import { |
| 60 | createEnvironmentConnection, | 68 | createEnvironmentConnection, |
| @@ -159,6 +167,12 @@ const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; · 98 unmodified lines | |||
| 159 | const NOOP = () => undefined; | 167 | const NOOP = () => undefined; |
| 160 | const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; | 168 | const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; |
| 161 | 169 | ||
| 170 | const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) => | ||
| 171 | Effect.gen(function* () { | ||
| 172 | const signer = yield* ManagedRelayDpopSigner; | ||
| 173 | return yield* signer.createProof(input); | ||
| 174 | }); | ||
| 175 | |||
| 162 | function createDeferredPromise<T>() { | 176 | function createDeferredPromise<T>() { |
| 163 | let resolve: ((value: T) => void) | null = null; | 177 | let resolve: ((value: T) => void) | null = null; |
| 164 | const promise = new Promise<T>((nextResolve) => { | 178 | const promise = new Promise<T>((nextResolve) => { |
| @@ -1156,7 +1170,7 @@ function createPrimaryEnvironmentClient( · 991 unmodified lines | |||
| 1156 | 1170 | ||
| 1157 | function createSavedEnvironmentClient( | 1171 | function createSavedEnvironmentClient( |
| 1158 | environmentId: EnvironmentId, | 1172 | environmentId: EnvironmentId, |
| 1159 | bearerToken: string, | 1173 | credentialRef: { current: SavedEnvironmentCredential }, |
| 1160 | ): WsRpcClient { | 1174 | ): WsRpcClient { |
| 1161 | useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); | 1175 | useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); |
| 1162 | 1176 | ||
| @@ -1167,19 +1181,70 @@ function createSavedEnvironmentClient( · 4 unmodified lines | |||
| 1167 | if (!record) { | 1181 | if (!record) { |
| 1168 | throw new Error(`Saved environment ${environmentId} not found.`); | 1182 | throw new Error(`Saved environment ${environmentId} not found.`); |
| 1169 | } | 1183 | } |
| 1170 | return record.desktopSsh | 1184 | const credential = credentialRef.current; |
| 1171 | ? await resolveDesktopSshWebSocketConnectionUrl( | 1185 | if (record.desktopSsh) { |
| 1172 | record.wsBaseUrl, | 1186 | if (credential.method !== "bearer") { |
| 1173 | record.httpBaseUrl, | 1187 | throw new Error("SSH environments require bearer credentials."); |
| 1174 | bearerToken, | 1188 | } |
| 1175 | ) | 1189 | return await resolveDesktopSshWebSocketConnectionUrl( |
| 1176 | : await remoteHttpRuntime.runPromise( | 1190 | record.wsBaseUrl, |
| 1177 | resolveRemoteWebSocketConnectionUrl({ | 1191 | record.httpBaseUrl, |
| 1178 | wsBaseUrl: record.wsBaseUrl, | 1192 | credential.token, |
| 1179 | httpBaseUrl: record.httpBaseUrl, | 1193 | ); |
| 1180 | bearerToken, | 1194 | } |
| 1181 | }), | 1195 | if (credential.method === "dpop") { |
| 1196 | try { | ||
| 1197 | return await webRuntime.runPromise( | ||
| 1198 | createManagedRelayDpopProof({ | ||
| 1199 | method: "POST", | ||
| 1200 | url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(), | ||
| 1201 | accessToken: credential.accessToken, | ||
| 1202 | }).pipe( | ||
| 1203 | Effect.flatMap((proof) => | ||
| 1204 | resolveRemoteDpopWebSocketConnectionUrl({ | ||
| 1205 | wsBaseUrl: record.wsBaseUrl, | ||
| 1206 | httpBaseUrl: record.httpBaseUrl, | ||
| 1207 | accessToken: credential.accessToken, | ||
| 1208 | dpopProof: proof, | ||
| 1209 | }), | ||
| 1210 | ), | ||
| 1211 | ), | ||
| 1212 | ); | ||
| 1213 | } catch (error) { | ||
| 1214 | if (!isEnvironmentAuthInvalidError(error)) { | ||
| 1215 | throw error; | ||
| 1216 | } | ||
| 1217 | const renewed = await renewManagedRelayCredential(record); | ||
| 1218 | if (!renewed || renewed.credential.method !== "dpop") { | ||
| 1219 | throw error; | ||
| 1220 | } | ||
| 1221 | const renewedCredential = renewed.credential; | ||
| 1222 | credentialRef.current = renewedCredential; | ||
| 1223 | return await webRuntime.runPromise( | ||
| 1224 | createManagedRelayDpopProof({ | ||
| 1225 | method: "POST", | ||
| 1226 | url: new URL("/api/auth/websocket-ticket", renewed.record.httpBaseUrl).toString(), | ||
| 1227 | accessToken: renewedCredential.accessToken, | ||
| 1228 | }).pipe( | ||
| 1229 | Effect.flatMap((proof) => | ||
| 1230 | resolveRemoteDpopWebSocketConnectionUrl({ | ||
| 1231 | wsBaseUrl: renewed.record.wsBaseUrl, | ||
| 1232 | httpBaseUrl: renewed.record.httpBaseUrl, | ||
| 1233 | accessToken: renewedCredential.accessToken, | ||
| 1234 | dpopProof: proof, | ||
| 1235 | }), | ||
| 1236 | ), | ||
| 1237 | ), | ||
| 1182 | ); | 1238 | ); |
| 1239 | } | ||
| 1240 | } | ||
| 1241 | return await webRuntime.runPromise( | ||
| 1242 | resolveRemoteWebSocketConnectionUrl({ | ||
| 1243 | wsBaseUrl: record.wsBaseUrl, | ||
| 1244 | httpBaseUrl: record.httpBaseUrl, | ||
| 1245 | bearerToken: credential.token, | ||
| 1246 | }), | ||
| 1247 | ); | ||
| 1183 | }, | 1248 | }, |
| 1184 | { | 1249 | { |
| 1185 | getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, | 1250 | getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, |
| @@ -1221,7 +1286,7 @@ function createSavedEnvironmentClient( · 35 unmodified lines | |||
| 1221 | 1286 | ||
| 1222 | async function refreshSavedEnvironmentMetadata( | 1287 | async function refreshSavedEnvironmentMetadata( |
| 1223 | environmentId: EnvironmentId, | 1288 | environmentId: EnvironmentId, |
| 1224 | bearerToken: string, | 1289 | credential: SavedEnvironmentCredential, |
| 1225 | client: WsRpcClient, | 1290 | client: WsRpcClient, |
| 1226 | scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null, | 1291 | scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null, |
| 1227 | configHint?: ServerConfig | null, | 1292 | configHint?: ServerConfig | null, |
| @@ -1234,13 +1299,31 @@ async function refreshSavedEnvironmentMetadata( · 6 unmodified lines | |||
| 1234 | const [serverConfig, sessionState] = await Promise.all([ | 1299 | const [serverConfig, sessionState] = await Promise.all([ |
| 1235 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), | 1300 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), |
| 1236 | record.desktopSsh | 1301 | record.desktopSsh |
| 1237 | ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken) | 1302 | ? credential.method === "bearer" |
| 1238 | : remoteHttpRuntime.runPromise( | 1303 | ? fetchDesktopSshSessionState(record.httpBaseUrl, credential.token) |
| 1239 | fetchRemoteSessionState({ | 1304 | : Promise.reject(new Error("SSH environments require bearer credentials.")) |
| 1240 | httpBaseUrl: record.httpBaseUrl, | 1305 | : credential.method === "dpop" |
| 1241 | bearerToken, | 1306 | ? webRuntime.runPromise( |
| 1242 | }), | 1307 | createManagedRelayDpopProof({ |
| 1243 | ), | 1308 | method: "GET", |
| 1309 | url: new URL("/api/auth/session", record.httpBaseUrl).toString(), | ||
| 1310 | accessToken: credential.accessToken, | ||
| 1311 | }).pipe( | ||
| 1312 | Effect.flatMap((proof) => | ||
| 1313 | fetchRemoteDpopSessionState({ | ||
| 1314 | httpBaseUrl: record.httpBaseUrl, | ||
| 1315 | accessToken: credential.accessToken, | ||
| 1316 | dpopProof: proof, | ||
| 1317 | }), | ||
| 1318 | ), | ||
| 1319 | ), | ||
| 1320 | ) | ||
| 1321 | : webRuntime.runPromise( | ||
| 1322 | fetchRemoteSessionState({ | ||
| 1323 | httpBaseUrl: record.httpBaseUrl, | ||
| 1324 | bearerToken: credential.token, | ||
| 1325 | }), | ||
| 1326 | ), | ||
| 1244 | ]); | 1327 | ]); |
| 1245 | 1328 | ||
| 1246 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 1329 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| @@ -1254,6 +1337,52 @@ async function refreshSavedEnvironmentMetadata( · 7 unmodified lines | |||
| 1254 | .rename(record.environmentId, serverConfig.environment.label); | 1337 | .rename(record.environmentId, serverConfig.environment.label); |
| 1255 | } | 1338 | } |
| 1256 | 1339 | ||
| 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 | |||
| 1257 | function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { | 1386 | function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { |
| 1258 | const existing = environmentConnections.get(connection.environmentId); | 1387 | const existing = environmentConnections.get(connection.environmentId); |
| 1259 | if (existing && existing !== connection) { | 1388 | if (existing && existing !== connection) { |
| @@ -1320,8 +1449,10 @@ async function ensureSavedEnvironmentConnection( · 60 unmodified lines | |||
| 1320 | options?: { | 1449 | options?: { |
| 1321 | readonly client?: WsRpcClient; | 1450 | readonly client?: WsRpcClient; |
| 1322 | readonly bearerToken?: string; | 1451 | readonly bearerToken?: string; |
| 1452 | readonly credential?: SavedEnvironmentCredential; | ||
| 1323 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null; | 1453 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null; |
| 1324 | readonly serverConfig?: ServerConfig | null; | 1454 | readonly serverConfig?: ServerConfig | null; |
| 1455 | readonly allowManagedRenewal?: boolean; | ||
| 1325 | }, | 1456 | }, |
| 1326 | ): Promise<EnvironmentConnection> { | 1457 | ): Promise<EnvironmentConnection> { |
| 1327 | const existing = environmentConnections.get(record.environmentId); | 1458 | const existing = environmentConnections.get(record.environmentId); |
| @@ -1340,14 +1471,17 @@ async function ensureSavedEnvironmentConnection( · 12 unmodified lines | |||
| 1340 | promise: Promise.resolve().then(async () => { | 1471 | promise: Promise.resolve().then(async () => { |
| 1341 | let activeRecord = record; | 1472 | let activeRecord = record; |
| 1342 | let scopeHint = options?.scopes ?? null; | 1473 | let scopeHint = options?.scopes ?? null; |
| 1343 | let bearerToken = | 1474 | let credential = |
| 1344 | options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); | 1475 | options?.credential ?? |
| 1345 | if (!bearerToken) { | 1476 | (options?.bearerToken |
| 1477 | ? ({ version: 1, method: "bearer", token: options.bearerToken } as const) | ||
| 1478 | : await readSavedEnvironmentCredential(record.environmentId)); | ||
| 1479 | if (!credential) { | ||
| 1346 | if (record.desktopSsh) { | 1480 | if (record.desktopSsh) { |
| 1347 | const issued = await issueDesktopSshBearerSession(record); | 1481 | const issued = await issueDesktopSshBearerSession(record); |
| 1348 | activeRecord = issued.record; | 1482 | activeRecord = issued.record; |
| 1349 | bearerToken = issued.bearerToken; | 1483 | credential = { version: 1, method: "bearer", token: issued.bearerToken }; |
| 1350 | scopeHint = [...AuthStandardClientScopes]; | 1484 | scopeHint = issued.scopes; |
| 1351 | } else { | 1485 | } else { |
| 1352 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 1486 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| 1353 | authState: "requires-auth", | 1487 | authState: "requires-auth", |
| @@ -1363,10 +1497,10 @@ async function ensureSavedEnvironmentConnection( · 9 unmodified lines | |||
| 1363 | activeRecord = prepared.record; | 1497 | activeRecord = prepared.record; |
| 1364 | } | 1498 | } |
| 1365 | 1499 | ||
| 1366 | const activeBearerToken = bearerToken; | 1500 | const activeCredential = { current: credential }; |
| 1367 | const client = | 1501 | const client = |
| 1368 | options?.client ?? | 1502 | options?.client ?? |
| 1369 | createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken); | 1503 | createSavedEnvironmentClient(activeRecord.environmentId, activeCredential); |
| 1370 | const initialConfigSnapshot = createDeferredPromise<ServerConfig>(); | 1504 | const initialConfigSnapshot = createDeferredPromise<ServerConfig>(); |
| 1371 | const knownEnvironment = createKnownEnvironment({ | 1505 | const knownEnvironment = createKnownEnvironment({ |
| 1372 | id: activeRecord.environmentId, | 1506 | id: activeRecord.environmentId, |
| @@ -1387,7 +1521,7 @@ async function ensureSavedEnvironmentConnection( · 14 unmodified lines | |||
| 1387 | refreshMetadata: async () => { | 1521 | refreshMetadata: async () => { |
| 1388 | await refreshSavedEnvironmentMetadata( | 1522 | await refreshSavedEnvironmentMetadata( |
| 1389 | activeRecord.environmentId, | 1523 | activeRecord.environmentId, |
| 1390 | activeBearerToken, | 1524 | activeCredential.current, |
| 1391 | client, | 1525 | client, |
| 1392 | ); | 1526 | ); |
| 1393 | }, | 1527 | }, |
| @@ -1416,7 +1550,7 @@ async function ensureSavedEnvironmentConnection( · 22 unmodified lines | |||
| 1416 | )); | 1550 | )); |
| 1417 | await refreshSavedEnvironmentMetadata( | 1551 | await refreshSavedEnvironmentMetadata( |
| 1418 | activeRecord.environmentId, | 1552 | activeRecord.environmentId, |
| 1419 | activeBearerToken, | 1553 | activeCredential.current, |
| 1420 | client, | 1554 | client, |
| 1421 | scopeHint, | 1555 | scopeHint, |
| 1422 | initialServerConfig, | 1556 | initialServerConfig, |
| @@ -1429,20 +1563,41 @@ async function ensureSavedEnvironmentConnection( · 6 unmodified lines | |||
| 1429 | throw error; | 1563 | throw error; |
| 1430 | } | 1564 | } |
| 1431 | if (!activeRecord.desktopSsh) { | 1565 | if (!activeRecord.desktopSsh) { |
| 1566 | if ( | ||
| 1567 | activeCredential.current.method === "dpop" && | ||
| 1568 | options?.allowManagedRenewal !== false | ||
| 1569 | ) { | ||
| 1570 | const renewed = await renewManagedRelayCredential(activeRecord); | ||
| 1571 | if (renewed) { | ||
| 1572 | await connection.dispose().catch(() => undefined); | ||
| 1573 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); | ||
| 1574 | return await ensureSavedEnvironmentConnection(renewed.record, { | ||
| 1575 | credential: renewed.credential, | ||
| 1576 | scopes: scopeHint, | ||
| 1577 | serverConfig: options?.serverConfig ?? null, | ||
| 1578 | allowManagedRenewal: false, | ||
| 1579 | }); | ||
| 1580 | } | ||
| 1581 | } | ||
| 1432 | await removeSavedEnvironmentBearerToken(activeRecord.environmentId); | 1582 | await removeSavedEnvironmentBearerToken(activeRecord.environmentId); |
| 1433 | throw new Error("Saved environment credential expired. Pair it again.", { | 1583 | throw new Error( |
| 1434 | cause: error, | 1584 | activeCredential.current.method === "dpop" |
| 1435 | }); | 1585 | ? "Managed tunnel credential expired. Connect it again from T3 Cloud." |
| 1586 | : "Saved environment credential expired. Pair it again.", | ||
| 1587 | { | ||
| 1588 | cause: error, | ||
| 1589 | }, | ||
| 1590 | ); | ||
| 1436 | } | 1591 | } |
| 1437 | 1592 | ||
| 1438 | const issued = await issueDesktopSshBearerSession(activeRecord); | 1593 | const issued = await issueDesktopSshBearerSession(activeRecord); |
| 1439 | activeRecord = issued.record; | 1594 | activeRecord = issued.record; |
| 1440 | bearerToken = issued.bearerToken; | 1595 | credential = { version: 1, method: "bearer", token: issued.bearerToken }; |
| 1441 | scopeHint = [...AuthStandardClientScopes]; | 1596 | scopeHint = issued.scopes; |
| 1442 | await connection.dispose().catch(() => undefined); | 1597 | await connection.dispose().catch(() => undefined); |
| 1443 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); | 1598 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); |
| 1444 | return await ensureSavedEnvironmentConnection(activeRecord, { | 1599 | return await ensureSavedEnvironmentConnection(activeRecord, { |
| 1445 | bearerToken, | 1600 | credential, |
| 1446 | scopes: scopeHint, | 1601 | scopes: scopeHint, |
| 1447 | serverConfig: options?.serverConfig ?? null, | 1602 | serverConfig: options?.serverConfig ?? null, |
| 1448 | }); | 1603 | }); |
| @@ -1647,7 +1802,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P · 198 unmodified lines | |||
| 1647 | await removeConnection(environmentId).catch(() => false); | 1802 | await removeConnection(environmentId).catch(() => false); |
| 1648 | await ensureSavedEnvironmentConnection(issued.record, { | 1803 | await ensureSavedEnvironmentConnection(issued.record, { |
| 1649 | bearerToken: issued.bearerToken, | 1804 | bearerToken: issued.bearerToken, |
| 1650 | scopes: [...AuthStandardClientScopes], | 1805 | scopes: issued.scopes, |
| 1651 | }); | 1806 | }); |
| 1652 | return; | 1807 | return; |
| 1653 | } catch (recoveryError) { | 1808 | } catch (recoveryError) { |
| @@ -1686,7 +1841,7 @@ export async function addSavedEnvironment(input: { · 32 unmodified lines | |||
| 1686 | }); | 1841 | }); |
| 1687 | const descriptor = input.desktopSsh | 1842 | const descriptor = input.desktopSsh |
| 1688 | ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) | 1843 | ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) |
| 1689 | : await remoteHttpRuntime.runPromise( | 1844 | : await webRuntime.runPromise( |
| 1690 | fetchRemoteEnvironmentDescriptor({ | 1845 | fetchRemoteEnvironmentDescriptor({ |
| 1691 | httpBaseUrl: resolvedTarget.httpBaseUrl, | 1846 | httpBaseUrl: resolvedTarget.httpBaseUrl, |
| 1692 | }), | 1847 | }), |
| @@ -1701,7 +1856,7 @@ export async function addSavedEnvironment(input: { · 8 unmodified lines | |||
| 1701 | 1856 | ||
| 1702 | const bearerSession = input.desktopSsh | 1857 | const bearerSession = input.desktopSsh |
| 1703 | ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) | 1858 | ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) |
| 1704 | : await remoteHttpRuntime.runPromise( | 1859 | : await webRuntime.runPromise( |
| 1705 | bootstrapRemoteBearerSession({ | 1860 | bootstrapRemoteBearerSession({ |
| 1706 | httpBaseUrl: resolvedTarget.httpBaseUrl, | 1861 | httpBaseUrl: resolvedTarget.httpBaseUrl, |
| 1707 | credential: resolvedTarget.credential, | 1862 | credential: resolvedTarget.credential, |
| @@ -1741,6 +1896,40 @@ export async function addSavedEnvironment(input: { · 33 unmodified lines | |||
| 1741 | return record; | 1896 | return record; |
| 1742 | } | 1897 | } |
| 1743 | 1898 | ||
| 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 | |||
| 1744 | export async function connectDesktopSshEnvironment( | 1933 | export async function connectDesktopSshEnvironment( |
| 1745 | target: DesktopSshEnvironmentTarget, | 1934 | target: DesktopSshEnvironmentTarget, |
| 1746 | options?: { label?: string }, | 1935 | options?: { label?: string }, |
| @@ -109,7 +109,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { · 108 unmodified lines | |||
| 109 | function syncDesktopTheme(theme: Theme) { | 109 | function syncDesktopTheme(theme: Theme) { |
| 110 | if (typeof window === "undefined") return; | 110 | if (typeof window === "undefined") return; |
| 111 | const bridge = window.desktopBridge; | 111 | const bridge = window.desktopBridge; |
| 112 | if (!bridge || lastDesktopTheme === theme) { | 112 | if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { |
| 113 | return; | 113 | return; |
| 114 | } | 114 | } |
| 115 | 115 | ||
| @@ -10,7 +10,7 @@ export interface HostedPairingRequest { · 9 unmodified lines | |||
| 10 | 10 | ||
| 11 | export type HostedAppChannel = "latest" | "nightly"; | 11 | export type HostedAppChannel = "latest" | "nightly"; |
| 12 | 12 | ||
| 13 | function configuredHostedAppUrl(): string { | 13 | export function configuredHostedAppUrl(): string { |
| 14 | return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; | 14 | return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; |
| 15 | } | 15 | } |
| 16 | 16 | ||
| @@ -9,7 +9,17 @@ import { · 8 unmodified lines | |||
| 9 | primaryEnvironmentHttpClientLive, | 9 | primaryEnvironmentHttpClientLive, |
| 10 | } from "../environments/primary/httpClient"; | 10 | } from "../environments/primary/httpClient"; |
| 11 | 11 | ||
| 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); | ||
| 13 | 23 | ||
| 14 | const primaryHttpRuntime = ManagedRuntime.make( | 24 | const primaryHttpRuntime = ManagedRuntime.make( |
| 15 | primaryEnvironmentHttpClientLive.pipe( | 25 | primaryEnvironmentHttpClientLive.pipe( |
| @@ -37,3 +47,13 @@ export const runPrimaryHttp = <A, E>(effect: Effect.Effect<A, E, PrimaryEnvironm · 21 unmodified lines | |||
| 37 | export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void { | 47 | export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void { |
| 38 | primaryHttpRunner = runner ?? livePrimaryHttpRunner; | 48 | primaryHttpRunner = runner ?? livePrimaryHttpRunner; |
| 39 | } | 49 | } |
| 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 | ); | ||
| @@ -234,6 +234,18 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg · 233 unmodified lines | |||
| 234 | setTheme: async () => undefined, | 234 | setTheme: async () => undefined, |
| 235 | showContextMenu: async () => null, | 235 | showContextMenu: async () => null, |
| 236 | openExternal: async () => true, | 236 | openExternal: async () => true, |
| 237 | createCloudAuthRequest: async () => "t3code-dev://auth/callback?t3_state=test", | ||
| 238 | getCloudAuthToken: async () => null, | ||
| 239 | setCloudAuthToken: async () => true, | ||
| 240 | clearCloudAuthToken: async () => undefined, | ||
| 241 | fetchCloudAuth: async () => ({ | ||
| 242 | ok: true, | ||
| 243 | status: 200, | ||
| 244 | statusText: "OK", | ||
| 245 | headers: {}, | ||
| 246 | body: "", | ||
| 247 | }), | ||
| 248 | onCloudAuthCallback: () => () => undefined, | ||
| 237 | onMenuAction: () => () => undefined, | 249 | onMenuAction: () => () => undefined, |
| 238 | getUpdateState: async () => { | 250 | getUpdateState: async () => { |
| 239 | throw new Error("getUpdateState not implemented in test"); | 251 | throw new Error("getUpdateState not implemented in test"); |
| @@ -1,5 +1,6 @@ | |||
| 1 | import React from "react"; | 1 | import React from "react"; |
| 2 | import ReactDOM from "react-dom/client"; | 2 | import ReactDOM from "react-dom/client"; |
| 3 | import { ClerkProvider } from "@clerk/react"; | ||
| 3 | import { RouterProvider } from "@tanstack/react-router"; | 4 | import { RouterProvider } from "@tanstack/react-router"; |
| 4 | import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; | 5 | import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; |
| 5 | 6 | ||
| @@ -7,6 +8,8 @@ import "@xterm/xterm/css/xterm.css"; · 1 unmodified line | |||
| 7 | import "./index.css"; | 8 | import "./index.css"; |
| 8 | 9 | ||
| 9 | import { isElectron } from "./env"; | 10 | import { isElectron } from "./env"; |
| 11 | import { DesktopClerkProvider } from "./cloud/desktopClerk"; | ||
| 12 | import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; | ||
| 10 | import { getRouter } from "./router"; | 13 | import { getRouter } from "./router"; |
| 11 | import { APP_DISPLAY_NAME } from "./branding"; | 14 | import { APP_DISPLAY_NAME } from "./branding"; |
| 12 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; | 15 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; |
| @@ -22,8 +25,25 @@ if (isElectron) { · 9 unmodified lines | |||
| 22 | 25 | ||
| 23 | document.title = APP_DISPLAY_NAME; | 26 | document.title = APP_DISPLAY_NAME; |
| 24 | 27 | ||
| 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 | |||
| 25 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | 33 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( |
| 26 | <React.StrictMode> | 34 | <React.StrictMode> |
| 27 | <RouterProvider router={router} /> | 35 | {clerkPublishableKey ? ( |
| 36 | isElectron ? ( | ||
| 37 | <DesktopClerkProvider publishableKey={clerkPublishableKey}> | ||
| 38 | <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider> | ||
| 39 | </DesktopClerkProvider> | ||
| 40 | ) : ( | ||
| 41 | <ClerkProvider publishableKey={clerkPublishableKey} waitlistUrl={cloudWaitlistUrl}> | ||
| 42 | <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider> | ||
| 43 | </ClerkProvider> | ||
| 44 | ) | ||
| 45 | ) : ( | ||
| 46 | app | ||
| 47 | )} | ||
| 28 | </React.StrictMode>, | 48 | </React.StrictMode>, |
| 29 | ); | 49 | ); |
| @@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi · 18 unmodified lines | |||
| 19 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' | 19 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 20 | import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' | 20 | import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' |
| 21 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' | 21 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' |
| 22 | import { Route as SettingsCloudRouteImport } from './routes/settings.cloud' | ||
| 22 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' | 23 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| 23 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' | 24 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' |
| 24 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' | 25 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' |
| @@ -72,6 +73,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ · 47 unmodified lines | |||
| 72 | path: '/connections', | 73 | path: '/connections', |
| 73 | getParentRoute: () => SettingsRoute, | 74 | getParentRoute: () => SettingsRoute, |
| 74 | } as any) | 75 | } as any) |
| 76 | const SettingsCloudRoute = SettingsCloudRouteImport.update({ | ||
| 77 | id: '/cloud', | ||
| 78 | path: '/cloud', | ||
| 79 | getParentRoute: () => SettingsRoute, | ||
| 80 | } as any) | ||
| 75 | const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ | 81 | const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ |
| 76 | id: '/archived', | 82 | id: '/archived', |
| 77 | path: '/archived', | 83 | path: '/archived', |
| @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { · 16 unmodified lines | |||
| 94 | '/pair': typeof PairRoute | 100 | '/pair': typeof PairRoute |
| 95 | '/settings': typeof SettingsRouteWithChildren | 101 | '/settings': typeof SettingsRouteWithChildren |
| 96 | '/settings/archived': typeof SettingsArchivedRoute | 102 | '/settings/archived': typeof SettingsArchivedRoute |
| 103 | '/settings/cloud': typeof SettingsCloudRoute | ||
| 97 | '/settings/connections': typeof SettingsConnectionsRoute | 104 | '/settings/connections': typeof SettingsConnectionsRoute |
| 98 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute | 105 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 99 | '/settings/general': typeof SettingsGeneralRoute | 106 | '/settings/general': typeof SettingsGeneralRoute |
| @@ -107,6 +114,7 @@ export interface FileRoutesByTo { · 7 unmodified lines | |||
| 107 | '/pair': typeof PairRoute | 114 | '/pair': typeof PairRoute |
| 108 | '/settings': typeof SettingsRouteWithChildren | 115 | '/settings': typeof SettingsRouteWithChildren |
| 109 | '/settings/archived': typeof SettingsArchivedRoute | 116 | '/settings/archived': typeof SettingsArchivedRoute |
| 117 | '/settings/cloud': typeof SettingsCloudRoute | ||
| 110 | '/settings/connections': typeof SettingsConnectionsRoute | 118 | '/settings/connections': typeof SettingsConnectionsRoute |
| 111 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute | 119 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 112 | '/settings/general': typeof SettingsGeneralRoute | 120 | '/settings/general': typeof SettingsGeneralRoute |
| @@ -123,6 +131,7 @@ export interface FileRoutesById { · 10 unmodified lines | |||
| 123 | '/pair': typeof PairRoute | 131 | '/pair': typeof PairRoute |
| 124 | '/settings': typeof SettingsRouteWithChildren | 132 | '/settings': typeof SettingsRouteWithChildren |
| 125 | '/settings/archived': typeof SettingsArchivedRoute | 133 | '/settings/archived': typeof SettingsArchivedRoute |
| 134 | '/settings/cloud': typeof SettingsCloudRoute | ||
| 126 | '/settings/connections': typeof SettingsConnectionsRoute | 135 | '/settings/connections': typeof SettingsConnectionsRoute |
| 127 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute | 136 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 128 | '/settings/general': typeof SettingsGeneralRoute | 137 | '/settings/general': typeof SettingsGeneralRoute |
| @@ -140,6 +149,7 @@ export interface FileRouteTypes { · 11 unmodified lines | |||
| 140 | | '/pair' | 149 | | '/pair' |
| 141 | | '/settings' | 150 | | '/settings' |
| 142 | | '/settings/archived' | 151 | | '/settings/archived' |
| 152 | | '/settings/cloud' | ||
| 143 | | '/settings/connections' | 153 | | '/settings/connections' |
| 144 | | '/settings/diagnostics' | 154 | | '/settings/diagnostics' |
| 145 | | '/settings/general' | 155 | | '/settings/general' |
| @@ -153,6 +163,7 @@ export interface FileRouteTypes { · 7 unmodified lines | |||
| 153 | | '/pair' | 163 | | '/pair' |
| 154 | | '/settings' | 164 | | '/settings' |
| 155 | | '/settings/archived' | 165 | | '/settings/archived' |
| 166 | | '/settings/cloud' | ||
| 156 | | '/settings/connections' | 167 | | '/settings/connections' |
| 157 | | '/settings/diagnostics' | 168 | | '/settings/diagnostics' |
| 158 | | '/settings/general' | 169 | | '/settings/general' |
| @@ -168,6 +179,7 @@ export interface FileRouteTypes { · 9 unmodified lines | |||
| 168 | | '/pair' | 179 | | '/pair' |
| 169 | | '/settings' | 180 | | '/settings' |
| 170 | | '/settings/archived' | 181 | | '/settings/archived' |
| 182 | | '/settings/cloud' | ||
| 171 | | '/settings/connections' | 183 | | '/settings/connections' |
| 172 | | '/settings/diagnostics' | 184 | | '/settings/diagnostics' |
| 173 | | '/settings/general' | 185 | | '/settings/general' |
| @@ -257,6 +269,13 @@ declare module '@tanstack/react-router' { · 83 unmodified lines | |||
| 257 | preLoaderRoute: typeof SettingsConnectionsRouteImport | 269 | preLoaderRoute: typeof SettingsConnectionsRouteImport |
| 258 | parentRoute: typeof SettingsRoute | 270 | parentRoute: typeof SettingsRoute |
| 259 | } | 271 | } |
| 272 | '/settings/cloud': { | ||
| 273 | id: '/settings/cloud' | ||
| 274 | path: '/cloud' | ||
| 275 | fullPath: '/settings/cloud' | ||
| 276 | preLoaderRoute: typeof SettingsCloudRouteImport | ||
| 277 | parentRoute: typeof SettingsRoute | ||
| 278 | } | ||
| 260 | '/settings/archived': { | 279 | '/settings/archived': { |
| 261 | id: '/settings/archived' | 280 | id: '/settings/archived' |
| 262 | path: '/archived' | 281 | path: '/archived' |
| @@ -297,6 +316,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) · 34 unmodified lines | |||
| 297 | 316 | ||
| 298 | interface SettingsRouteChildren { | 317 | interface SettingsRouteChildren { |
| 299 | SettingsArchivedRoute: typeof SettingsArchivedRoute | 318 | SettingsArchivedRoute: typeof SettingsArchivedRoute |
| 319 | SettingsCloudRoute: typeof SettingsCloudRoute | ||
| 300 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute | 320 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute |
| 301 | SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute | 321 | SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute |
| 302 | SettingsGeneralRoute: typeof SettingsGeneralRoute | 322 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| @@ -307,6 +327,7 @@ interface SettingsRouteChildren { · 4 unmodified lines | |||
| 307 | 327 | ||
| 308 | const SettingsRouteChildren: SettingsRouteChildren = { | 328 | const SettingsRouteChildren: SettingsRouteChildren = { |
| 309 | SettingsArchivedRoute: SettingsArchivedRoute, | 329 | SettingsArchivedRoute: SettingsArchivedRoute, |
| 330 | SettingsCloudRoute: SettingsCloudRoute, | ||
| 310 | SettingsConnectionsRoute: SettingsConnectionsRoute, | 331 | SettingsConnectionsRoute: SettingsConnectionsRoute, |
| 311 | SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, | 332 | SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, |
| 312 | SettingsGeneralRoute: SettingsGeneralRoute, | 333 | SettingsGeneralRoute: SettingsGeneralRoute, |
| @@ -48,13 +48,13 @@ function HostedStaticOnboardingState() { · 47 unmodified lines | |||
| 48 | Connect an environment to get started | 48 | Connect an environment to get started |
| 49 | </EmptyTitle> | 49 | </EmptyTitle> |
| 50 | <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78"> | 50 | <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78"> |
| 51 | Open a pairing link from your T3 Code desktop app or add a reachable backend | 51 | Sign in to T3 Cloud to connect a linked environment through its managed tunnel, or |
| 52 | manually. Your saved environments stay in this browser. | 52 | add a reachable backend manually. |
| 53 | </EmptyDescription> | 53 | </EmptyDescription> |
| 54 | <div className="mt-6 flex justify-center"> | 54 | <div className="mt-6 flex justify-center"> |
| 55 | <Button render={<a href="/settings/connections" />} size="sm"> | 55 | <Button render={<a href="/settings/cloud" />} size="sm"> |
| 56 | <PlusIcon className="size-4" /> | 56 | <PlusIcon className="size-4" /> |
| 57 | Add environment | 57 | Open T3 Cloud |
| 58 | </Button> | 58 | </Button> |
| 59 | </div> | 59 | </div> |
| 60 | </EmptyHeader> | 60 | </EmptyHeader> |
| @@ -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 | }); | ||
| @@ -7,6 +7,7 @@ interface ImportMetaEnv { · 6 unmodified lines | |||
| 7 | readonly VITE_WS_URL: string; | 7 | readonly VITE_WS_URL: string; |
| 8 | readonly VITE_HOSTED_APP_URL: string; | 8 | readonly VITE_HOSTED_APP_URL: string; |
| 9 | readonly VITE_HOSTED_APP_CHANNEL: string; | 9 | readonly VITE_HOSTED_APP_CHANNEL: string; |
| 10 | readonly VITE_CLERK_PUBLISHABLE_KEY: string; | ||
| 10 | readonly APP_VERSION: string; | 11 | readonly APP_VERSION: string; |
| 11 | } | 12 | } |
| 12 | 13 | ||
| @@ -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. | ||
| @@ -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. | ||
| @@ -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.
This diff is large: its 39 lines aren’t shown here.
This diff is large: its 105 lines aren’t shown here.
This diff is large: its 1268 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 64 lines aren’t shown here.
This diff is large: its 158 lines aren’t shown here.
This diff is large: its 1017 lines aren’t shown here.
This diff is large: its 117 lines aren’t shown here.
This diff is large: its 275 lines aren’t shown here.
This diff is large: its 222 lines aren’t shown here.
This diff is large: its 197 lines aren’t shown here.
This diff is large: its 183 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 204 lines aren’t shown here.
This diff is large: its 62 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 83 lines aren’t shown here.
This diff is large: its 59 lines aren’t shown here.
This diff is large: its 156 lines aren’t shown here.
This diff is large: its 324 lines aren’t shown here.
This diff is large: its 206 lines aren’t shown here.
This diff is large: its 161 lines aren’t shown here.
This diff is large: its 135 lines aren’t shown here.
This diff is large: its 96 lines aren’t shown here.
This diff is large: its 69 lines aren’t shown here.
This diff is large: its 149 lines aren’t shown here.
This diff is large: its 177 lines aren’t shown here.
This diff is large: its 79 lines aren’t shown here.
This diff is large: its 350 lines aren’t shown here.
This diff is large: its 197 lines aren’t shown here.
This diff is large: its 376 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 197 lines aren’t shown here.
This diff is large: its 187 lines aren’t shown here.
This diff is large: its 164 lines aren’t shown here.
This diff is large: its 628 lines aren’t shown here.
This diff is large: its 235 lines aren’t shown here.
This diff is large: its 1127 lines aren’t shown here.
This diff is large: its 794 lines aren’t shown here.
This diff is large: its 145 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 584 lines aren’t shown here.
This diff is large: its 398 lines aren’t shown here.
This diff is large: its 204 lines aren’t shown here.
This diff is large: its 271 lines aren’t shown here.
This diff is large: its 161 lines aren’t shown here.
This diff is large: its 177 lines aren’t shown here.
This diff is large: its 447 lines aren’t shown here.
This diff is large: its 344 lines aren’t shown here.
This diff is large: its 462 lines aren’t shown here.
This diff is large: its 102 lines aren’t shown here.
This diff is large: its 32 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 296 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 5 lines aren’t shown here.
This diff is large: its 115 lines aren’t shown here.
This diff is large: its 471 lines aren’t shown here.
This diff is large: its 185 lines aren’t shown here.
This diff is large: its 179 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 215 lines aren’t shown here.
This diff is large: its 49 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 869 lines aren’t shown here.
This diff is large: its 50 lines aren’t shown here.
This diff is large: its 129 lines aren’t shown here.
This diff is large: its 143 lines aren’t shown here.
This diff is large: its 206 lines aren’t shown here.
This diff is large: its 201 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 70 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.