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