diff --git a/.env.example b/.env.example new file mode 100644 index 00000000000..20fc3186b8f --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Optional: T3 Cloud source builds +# Leave these unset to disable optional T3 Cloud features in local source builds. +# Release builds inject their public values at build time. Do not add server-side +# secrets to this file. + +# Get these from the Clerk Dashboard under API keys, JWT templates, and OAuth applications. +# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... +# T3CODE_CLERK_JWT_TEMPLATE=t3-relay +# T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_... + +# Get this from your relay deployment. `infra/relay` deploys update it automatically. +# T3CODE_RELAY_URL=https://relay.example.com diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml new file mode 100644 index 00000000000..dd27fd2a79f --- /dev/null +++ b/.github/workflows/deploy-relay.yml @@ -0,0 +1,58 @@ +name: Deploy T3 Cloud relay + +on: + push: + branches: + - main + +permissions: + contents: read + id-token: none + +concurrency: + group: relay-production + cancel-in-progress: false + +jobs: + deploy_relay: + name: Deploy production relay + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 15 + environment: + name: production + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} + AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} + RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} + RELAY_ZONE_NAME: ${{ vars.RELAY_ZONE_NAME }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} + APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} + APNS_TEAM_ID: ${{ vars.APNS_TEAM_ID }} + APNS_KEY_ID: ${{ vars.APNS_KEY_ID }} + APNS_BUNDLE_ID: ${{ vars.APNS_BUNDLE_ID }} + ALCHEMY_TELEMETRY_DISABLED: "1" + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: false + + - name: Install dependencies + run: vp install --frozen-lockfile + + - name: Deploy production relay stage + run: vp run --filter t3code-relay deploy -- --stage prod --yes + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} + PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} + AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 807c4cdd65d..ea4b379ebbb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,12 +169,64 @@ jobs: --current-tag "${{ steps.release_meta.outputs.tag }}" \ --github-output - build: - name: Build ${{ matrix.label }} + relay_public_config: + name: Resolve T3 Cloud public config needs: preflight if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 5 + environment: + name: production + outputs: + clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} + clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} + relay_url: ${{ steps.public_config.outputs.relay_url }} + env: + RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} + CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} + CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} + steps: + - id: public_config + name: Resolve production relay public config + shell: bash + run: | + set -euo pipefail + + required=( + RELAY_DOMAIN + CLERK_PUBLISHABLE_KEY + CLERK_JWT_TEMPLATE + CLERK_CLI_OAUTH_CLIENT_ID + ) + missing=() + for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + missing+=("$name") + fi + done + if (( ${#missing[@]} > 0 )); then + printf 'Missing required relay deployment configuration: %s\n' "${missing[*]}" >&2 + exit 1 + fi + + echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" + echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" + echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" + echo "relay_url=https://$RELAY_DOMAIN" >> "$GITHUB_OUTPUT" + + build: + name: Build ${{ matrix.label }} + needs: [preflight, relay_public_config] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 + env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} strategy: fail-fast: false matrix: @@ -428,13 +480,18 @@ jobs: publish_cli: name: Publish CLI to npm - needs: [preflight, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' }} + needs: [preflight, relay_public_config, build] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 permissions: contents: read id-token: write + env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} + T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout uses: actions/checkout@v6 @@ -588,11 +645,14 @@ jobs: deploy_web: name: Deploy hosted web app - needs: [preflight, release] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + needs: [preflight, relay_public_config, release] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} + T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} + T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} @@ -659,6 +719,9 @@ jobs: --token "$VERCEL_TOKEN" \ "${vercel_scope_args[@]}" \ --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ + --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ + --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ + --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" @@ -755,10 +818,11 @@ jobs: if: | always() && !cancelled() && needs.preflight.result == 'success' && + needs.relay_public_config.result == 'success' && needs.release.result == 'success' && needs.deploy_web.result == 'success' && (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') - needs: [preflight, release, deploy_web, finalize] + needs: [preflight, relay_public_config, release, deploy_web, finalize] runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: diff --git a/.gitignore b/.gitignore index 5e941c7b9f0..ef6067824f2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules *.log *.tsbuildinfo apps/*/dist +infra/*/dist .astro packages/*/dist .env @@ -26,3 +27,8 @@ squashfs-root/ .gstack/ dist-electron/ .electron-runtime/ +node_modules/ +.alchemy/ +*.log +.env* +!.env.example diff --git a/.vscode/settings.json b/.vscode/settings.json index 564120b4aaf..3c426dce591 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ }, "oxc.unusedDisableDirectives": "warn", "js/ts.tsdk.path": "node_modules/typescript/lib", + "typescript.native-preview.tsdk": "node_modules/@typescript/native-preview", "typescript.preferences.autoImportFileExcludePatterns": [".repos/**"], "javascript.preferences.autoImportFileExcludePatterns": [".repos/**"], "files.watcherExclude": { diff --git a/AGENTS.md b/AGENTS.md index 7a8075e29b5..380a9202683 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,22 +29,8 @@ Long term maintainability is a core priority. If you add new functionality, firs - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. - `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. - `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. -- `packages/shared`: Shared runtime utilities consumed by both server and web. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. - -## Codex App Server (Important) - -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. - -How we use it in this codebase: - -- Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`. -- Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`. -- WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`. -- Web app consumes orchestration domain events via WebSocket push on channel `orchestration.domainEvent` (provider runtime activity is projected into orchestration events server-side). - -Docs: - -- Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server +- `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. +- `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile. ## Reference Repos diff --git a/README.md b/README.md index e9b6da70ea9..5856f7e1b29 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ We are not accepting contributions yet. Observability guide: [docs/observability.md](./docs/observability.md) +Relay observability: [docs/relay-observability.md](./docs/relay-observability.md) + +T3 Cloud Clerk setup: [docs/t3-cloud-clerk.md](./docs/t3-cloud-clerk.md) + ## If you REALLY want to contribute still.... read this first Before local development, prepare the environment and install dependencies: @@ -58,6 +62,10 @@ mise install vp install ``` +T3 Cloud is optional and disabled in a fresh clone. To enable it for web, desktop, and mobile source +builds, copy [`.env.example`](./.env.example) to `.env` at the repository root and set the canonical +public configuration there. + Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 9a7e68dfbbb..2a2e52449be 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process"; import { watch } from "node:fs"; import { join } from "node:path"; -import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; +import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); @@ -28,6 +28,7 @@ const watchedDirectories = [ const forcedShutdownTimeoutMs = 1_500; const restartDebounceMs = 120; const childTreeGracePeriodMs = 1_200; +const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); await waitForResources({ baseDir: desktopDir, @@ -38,6 +39,11 @@ await waitForResources({ const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; +const devProtocolClient = resolveDevProtocolClient(); +if (devProtocolClient) { + childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId; + childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1"; +} let shuttingDown = false; let restartTimer = null; @@ -67,15 +73,17 @@ function startApp() { return; } - const app = spawn( - resolveElectronPath(), - [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"], - { - cwd: desktopDir, - env: childEnv, - stdio: "inherit", - }, - ); + const electronArgs = remoteDebuggingPort + ? [`--remote-debugging-port=${remoteDebuggingPort}`] + : []; + const launchArgs = devProtocolClient + ? electronArgs + : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; + const app = spawn(resolveElectronPath(), launchArgs, { + cwd: desktopDir, + env: childEnv, + stdio: "inherit", + }); currentApp = app; @@ -125,6 +133,7 @@ async function stopApp() { app.once("exit", finish); app.kill("SIGTERM"); killChildTreeByPid(app.pid, "TERM"); + cleanupStaleDevApps(); setTimeout(() => { if (settled) { @@ -133,6 +142,7 @@ async function stopApp() { app.kill("SIGKILL"); killChildTreeByPid(app.pid, "KILL"); + cleanupStaleDevApps(); finish(); }, forcedShutdownTimeoutMs).unref(); }); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 113dd82f58a..8f20001bbb0 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import { copyFileSync, + chmodSync, cpSync, existsSync, mkdirSync, @@ -13,21 +14,34 @@ import { writeFileSync, } from "node:fs"; import { createRequire } from "node:module"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; -const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; -const LAUNCHER_VERSION = 2; - const __dirname = dirname(fileURLToPath(import.meta.url)); export const desktopDir = resolve(__dirname, ".."); const repoRoot = resolve(desktopDir, "..", ".."); +const devBundleIdSuffix = basename(repoRoot) + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, ""); +export const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +export const APP_BUNDLE_ID = isDevelopment + ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` + : "com.t3tools.t3code"; +const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; +const LAUNCHER_VERSION = 10; const defaultIconPath = join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); +function resolveDevelopmentProtocolCallbackPort() { + const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); + if (Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort < 65535) { + return configuredPort + 1; + } + return 13774; +} + function setPlistString(plistPath, key, value) { const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { encoding: "utf8", @@ -47,6 +61,26 @@ function setPlistString(plistPath, key, value) { throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); } +function setPlistJson(plistPath, key, value) { + const serialized = JSON.stringify(value); + const replaceResult = spawnSync("plutil", ["-replace", key, "-json", serialized, plistPath], { + encoding: "utf8", + }); + if (replaceResult.status === 0) { + return; + } + + const insertResult = spawnSync("plutil", ["-insert", key, "-json", serialized, plistPath], { + encoding: "utf8", + }); + if (insertResult.status === 0) { + return; + } + + const details = [replaceResult.stderr, insertResult.stderr].filter(Boolean).join("\n"); + throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); +} + function runChecked(command, args) { const result = spawnSync(command, args, { encoding: "utf8" }); if (result.status === 0) { @@ -57,6 +91,71 @@ function runChecked(command, args) { throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); } +function shellSingleQuote(value) { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { + const mainEntryPath = join(desktopDir, "dist-electron", "main.cjs"); + const protocolCallbackUrl = `http://127.0.0.1:${resolveDevelopmentProtocolCallbackPort()}/auth/callback`; + const envEntries = [ + ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL], + ["T3CODE_PORT", process.env.T3CODE_PORT], + ["T3CODE_HOME", process.env.T3CODE_HOME], + ["T3CODE_COMMIT_HASH", process.env.T3CODE_COMMIT_HASH], + ["T3CODE_OTLP_TRACES_URL", process.env.T3CODE_OTLP_TRACES_URL], + ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS], + ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], + ["T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED", "1"], + ["T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL", protocolCallbackUrl], + ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); + writeFileSync( + targetBinaryPath, + [ + "#!/bin/sh", + ...envEntries.map(([name, value]) => `export ${name}=${shellSingleQuote(value)}`), + 'for arg in "$@"; do', + ' case "$arg" in', + " t3code-dev://auth/callback*)", + ' if [ -n "$T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL" ]; then', + ' /usr/bin/curl -fsS --max-time 2 -X POST --data-binary "$arg" "$T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL" >/dev/null 2>&1 && exit 0', + " fi", + " ;;", + " esac", + "done", + `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopDir)} ${shellSingleQuote(mainEntryPath)} "$@"`, + "", + ].join("\n"), + ); + chmodSync(targetBinaryPath, 0o755); +} + +function registerMacLauncherBundle(appBundlePath) { + runChecked( + "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", + ["-f", appBundlePath], + ); + + if (!isDevelopment) { + return; + } + + for (const scheme of APP_PROTOCOL_SCHEMES) { + runChecked("osascript", [ + "-l", + "JavaScript", + "-e", + [ + 'ObjC.import("CoreServices");', + `const scheme = $.NSString.alloc.initWithUTF8String(${JSON.stringify(scheme)});`, + `const bundle = $.NSString.alloc.initWithUTF8String(${JSON.stringify(APP_BUNDLE_ID)});`, + "const status = $.LSSetDefaultHandlerForURLScheme(scheme, bundle);", + "if (status !== 0) throw new Error(`LSSetDefaultHandlerForURLScheme failed: ${status}`);", + ].join(" "), + ]); + } +} + function ensureDevelopmentIconIcns(runtimeDir) { const generatedIconPath = join(runtimeDir, "icon-dev.icns"); mkdirSync(runtimeDir, { recursive: true }); @@ -115,12 +214,49 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); + setPlistJson(infoPlistPath, "CFBundleURLTypes", [ + { + CFBundleURLName: APP_BUNDLE_ID, + CFBundleURLSchemes: APP_PROTOCOL_SCHEMES, + }, + ]); const resourcesDir = join(appBundlePath, "Contents", "Resources"); copyFileSync(iconPath, join(resourcesDir, "icon.icns")); copyFileSync(iconPath, join(resourcesDir, "electron.icns")); } +function patchHelperBundleInfoPlists(appBundlePath) { + const helperBundleNames = [ + ["Electron Helper.app", "helper", `${APP_DISPLAY_NAME} Helper`], + ["Electron Helper (GPU).app", "helper.gpu", `${APP_DISPLAY_NAME} Helper (GPU)`], + ["Electron Helper (Plugin).app", "helper.plugin", `${APP_DISPLAY_NAME} Helper (Plugin)`], + ["Electron Helper (Renderer).app", "helper.renderer", `${APP_DISPLAY_NAME} Helper (Renderer)`], + ]; + + for (const [bundleName, bundleIdentifierSuffix, bundleDisplayName] of helperBundleNames) { + const infoPlistPath = join( + appBundlePath, + "Contents", + "Frameworks", + bundleName, + "Contents", + "Info.plist", + ); + if (!existsSync(infoPlistPath)) { + continue; + } + + setPlistString(infoPlistPath, "CFBundleDisplayName", bundleDisplayName); + setPlistString(infoPlistPath, "CFBundleName", bundleDisplayName); + setPlistString( + infoPlistPath, + "CFBundleIdentifier", + `${APP_BUNDLE_ID}.${bundleIdentifierSuffix}`, + ); + } +} + function readJson(path) { try { return JSON.parse(readFileSync(path, "utf8")); @@ -144,6 +280,8 @@ function buildMacLauncher(electronBinaryPath) { sourceAppBundlePath, sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, iconMtimeMs: statSync(iconPath).mtimeMs, + appBundleId: APP_BUNDLE_ID, + appProtocolSchemes: APP_PROTOCOL_SCHEMES, }; const currentMetadata = readJson(metadataPath); @@ -152,13 +290,19 @@ function buildMacLauncher(electronBinaryPath) { currentMetadata && JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { + registerMacLauncherBundle(targetAppBundlePath); return targetBinaryPath; } rmSync(targetAppBundlePath, { recursive: true, force: true }); cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); patchMainBundleInfoPlist(targetAppBundlePath, iconPath); + patchHelperBundleInfoPlists(targetAppBundlePath); + if (isDevelopment) { + writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + } writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); + registerMacLauncherBundle(targetAppBundlePath); return targetBinaryPath; } @@ -173,11 +317,19 @@ export function resolveElectronPath() { return electronBinaryPath; } - // Dev launches do not need a renamed app bundle badly enough to risk breaking - // Electron helper resource lookup on macOS. - if (isDevelopment) { - return electronBinaryPath; + return buildMacLauncher(electronBinaryPath); +} + +export function resolveDevProtocolClient() { + if (process.platform !== "darwin" || !isDevelopment) { + return null; } - return buildMacLauncher(electronBinaryPath); + const require = createRequire(import.meta.url); + const electronBinaryPath = require("electron"); + const launcherBinaryPath = buildMacLauncher(electronBinaryPath); + return { + appBundlePath: resolve(launcherBinaryPath, "..", "..", ".."), + appBundleId: APP_BUNDLE_ID, + }; } diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 85b8aed4a42..052a25e4b97 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,16 +1,17 @@ import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as NetService from "@t3tools/shared/Net"; +import * as Crypto from "effect/Crypto"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopCloudAuth from "./DesktopCloudAuth.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -190,6 +191,7 @@ const startup = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronProtocol = yield* ElectronProtocol.ElectronProtocol; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const updates = yield* DesktopUpdates.DesktopUpdates; @@ -207,6 +209,7 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* lifecycle.register; + yield* cloudAuth.configure; yield* electronApp.whenReady.pipe( Effect.withSpan("desktop.electron.whenReady"), diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index f95fd1bef71..eafdbf056dc 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -53,6 +53,9 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => calls.setAboutPanelOptions.push(options); }), setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, setDockIcon: (iconPath) => Effect.sync(() => { diff --git a/apps/desktop/src/app/DesktopCloudAuth.test.ts b/apps/desktop/src/app/DesktopCloudAuth.test.ts new file mode 100644 index 00000000000..002fd86b0a4 --- /dev/null +++ b/apps/desktop/src/app/DesktopCloudAuth.test.ts @@ -0,0 +1,302 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopCloudAuth from "./DesktopCloudAuth.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +interface CloudAuthHarness { + readonly app: ElectronApp.ElectronAppShape; + readonly window: ElectronWindow.ElectronWindowShape; + readonly listeners: Map void)[]>; + readonly protocolRegistrations: { + readonly protocol: string; + readonly path?: string; + readonly args?: readonly string[]; + }[]; + readonly sends: { readonly channel: string; readonly args: readonly unknown[] }[]; + readonly reveals: unknown[]; + readonly layer: Layer.Layer< + | DesktopCloudAuth.DesktopCloudAuth + | DesktopEnvironment.DesktopEnvironment + | ElectronApp.ElectronApp + | ElectronWindow.ElectronWindow + >; +} + +function makeHarness(input: { readonly isDevelopment: boolean }): CloudAuthHarness { + const listeners = new Map void)[]>(); + const protocolRegistrations: CloudAuthHarness["protocolRegistrations"] = []; + const sends: CloudAuthHarness["sends"] = []; + const reveals: unknown[] = []; + const mainWindow = { id: "main-window" }; + + const app = ElectronApp.ElectronApp.of({ + metadata: Effect.succeed({ + appVersion: "0.0.0-test", + appPath: "/tmp/t3-code-test", + isPackaged: !input.isDevelopment, + resourcesPath: "/tmp/t3-code-test/resources", + runningUnderArm64Translation: false, + }), + name: Effect.succeed("T3 Code"), + whenReady: Effect.void, + quit: Effect.void, + exit: () => Effect.void, + relaunch: () => Effect.void, + setPath: () => Effect.void, + setName: () => Effect.void, + setAboutPanelOptions: () => Effect.void, + setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: (protocol, path, args) => + Effect.sync(() => { + protocolRegistrations.push({ + protocol, + ...(path === undefined ? {} : { path }), + ...(args === undefined ? {} : { args }), + }); + return true; + }), + setDesktopName: () => Effect.void, + setDockIcon: () => Effect.void, + appendCommandLineSwitch: () => Effect.void, + on: (eventName, listener) => + Effect.sync(() => { + const erasedListener = listener as (...args: readonly unknown[]) => void; + listeners.set(eventName, [...(listeners.get(eventName) ?? []), erasedListener]); + }), + }); + + const window = ElectronWindow.ElectronWindow.of({ + create: () => Effect.die("not used"), + main: Effect.succeed(Option.some(mainWindow as never)), + currentMainOrFirst: Effect.succeed(Option.some(mainWindow as never)), + focusedMainOrFirst: Effect.succeed(Option.some(mainWindow as never)), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: (target) => + Effect.sync(() => { + reveals.push(target); + }), + sendAll: (channel, ...args) => + Effect.sync(() => { + sends.push({ channel, args }); + }), + destroyAll: Effect.void, + syncAllAppearance: () => Effect.void, + }); + + const environment = DesktopEnvironment.DesktopEnvironment.of({ + isDevelopment: input.isDevelopment, + } as DesktopEnvironment.DesktopEnvironmentShape); + const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment); + + return { + app, + window, + listeners, + protocolRegistrations, + sends, + reveals, + layer: Layer.mergeAll( + DesktopCloudAuth.layer.pipe( + Layer.provideMerge(environmentLayer), + Layer.provide(NodeServices.layer), + ), + Layer.succeed(ElectronApp.ElectronApp, app), + Layer.succeed(ElectronWindow.ElectronWindow, window), + ), + }; +} + +function emitAppEvent( + harness: CloudAuthHarness, + eventName: string, + ...args: readonly unknown[] +): void { + for (const listener of harness.listeners.get(eventName) ?? []) { + listener(...args); + } +} + +const flushCloudAuthDispatch = Effect.promise(() => Promise.resolve()); + +describe("DesktopCloudAuth", () => { + it("uses separate callback schemes for packaged and development builds", () => { + assert.equal( + DesktopCloudAuth.resolveCloudAuthCallbackScheme({ isDevelopment: false }), + "t3code", + ); + assert.equal( + DesktopCloudAuth.resolveCloudAuthCallbackScheme({ isDevelopment: true }), + "t3code-dev", + ); + }); + + it("builds a native callback URL with request state", () => { + assert.equal( + DesktopCloudAuth.buildCloudAuthCallbackUrl({ + scheme: "t3code", + state: "state-1", + }), + "t3code://auth/callback?t3_state=state-1", + ); + }); + + it("accepts only the expected scheme, host, path, and state", () => { + assert.isNotNull( + DesktopCloudAuth.parseCloudAuthCallbackUrl({ + rawUrl: "t3code://auth/callback?rotating_token_nonce=nonce&t3_state=state-1", + scheme: "t3code", + state: "state-1", + }), + ); + assert.isNull( + DesktopCloudAuth.parseCloudAuthCallbackUrl({ + rawUrl: "t3code://auth/callback?rotating_token_nonce=nonce&t3_state=wrong", + scheme: "t3code", + state: "state-1", + }), + ); + assert.isNull( + DesktopCloudAuth.parseCloudAuthCallbackUrl({ + rawUrl: "https://example.com/callback?rotating_token_nonce=nonce&t3_state=state-1", + scheme: "t3code", + state: "state-1", + }), + ); + }); + + it("builds a native development callback URL with request state", () => { + assert.equal( + DesktopCloudAuth.buildCloudAuthCallbackUrl({ + scheme: "t3code-dev", + state: "state-1", + }), + "t3code-dev://auth/callback?t3_state=state-1", + ); + }); + + it.effect("registers the development protocol client and dispatches matching callbacks", () => { + const harness = makeHarness({ isDevelopment: true }); + + return Effect.gen(function* () { + const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; + yield* cloudAuth.configure; + const redirectUrl = yield* cloudAuth.createRequest; + const callbackUrl = new URL(redirectUrl); + callbackUrl.searchParams.set("rotating_token_nonce", "nonce-1"); + + let prevented = false; + emitAppEvent( + harness, + "open-url", + { preventDefault: () => (prevented = true) }, + callbackUrl.toString(), + ); + yield* flushCloudAuthDispatch; + + assert.isTrue(prevented); + assert.deepEqual( + harness.protocolRegistrations.map((registration) => registration.protocol), + ["t3code-dev"], + ); + assert.isString(harness.protocolRegistrations[0]?.path); + assert.isArray(harness.protocolRegistrations[0]?.args); + assert.deepEqual(harness.sends, [ + { + channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, + args: [callbackUrl.toString()], + }, + ]); + assert.lengthOf(harness.reveals, 1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }); + + it.effect("rejects mismatched callback state and only consumes the pending request once", () => { + const harness = makeHarness({ isDevelopment: false }); + + return Effect.gen(function* () { + const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; + yield* cloudAuth.configure; + const redirectUrl = yield* cloudAuth.createRequest; + const validCallback = new URL(redirectUrl); + validCallback.searchParams.set("rotating_token_nonce", "nonce-1"); + const invalidCallback = new URL(validCallback); + invalidCallback.searchParams.set(DesktopCloudAuth.CLOUD_AUTH_CALLBACK_STATE_PARAM, "wrong"); + + emitAppEvent( + harness, + "open-url", + { preventDefault: () => undefined }, + invalidCallback.toString(), + ); + yield* flushCloudAuthDispatch; + assert.deepEqual(harness.sends, []); + + emitAppEvent( + harness, + "open-url", + { preventDefault: () => undefined }, + validCallback.toString(), + ); + yield* flushCloudAuthDispatch; + emitAppEvent( + harness, + "open-url", + { preventDefault: () => undefined }, + validCallback.toString(), + ); + yield* flushCloudAuthDispatch; + + assert.deepEqual( + harness.protocolRegistrations.map((registration) => registration.protocol), + ["t3code"], + ); + assert.deepEqual(harness.sends, [ + { + channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, + args: [validCallback.toString()], + }, + ]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }); + + it.effect( + "routes second-instance callbacks and reveals the window for non-callback launches", + () => { + const harness = makeHarness({ isDevelopment: true }); + + return Effect.gen(function* () { + const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; + yield* cloudAuth.configure; + const redirectUrl = yield* cloudAuth.createRequest; + const callbackUrl = new URL(redirectUrl); + callbackUrl.searchParams.set("rotating_token_nonce", "nonce-1"); + + emitAppEvent(harness, "second-instance", {}, ["electron", callbackUrl.toString()]); + yield* flushCloudAuthDispatch; + + const revealCountAfterCallback = harness.reveals.length; + emitAppEvent(harness, "second-instance", {}, ["electron", "--opened-from-dock"]); + yield* flushCloudAuthDispatch; + + assert.deepEqual(harness.sends, [ + { + channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, + args: [callbackUrl.toString()], + }, + ]); + assert.equal(revealCountAfterCallback, 1); + assert.equal(harness.reveals.length, 2); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }, + ); +}); diff --git a/apps/desktop/src/app/DesktopCloudAuth.ts b/apps/desktop/src/app/DesktopCloudAuth.ts new file mode 100644 index 00000000000..732de27b9ab --- /dev/null +++ b/apps/desktop/src/app/DesktopCloudAuth.ts @@ -0,0 +1,330 @@ +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as IpcChannels from "../ipc/channels.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import type * as Electron from "electron"; + +export const CLOUD_AUTH_CALLBACK_HOST = "auth"; +export const CLOUD_AUTH_CALLBACK_PATHNAME = "/callback"; +export const CLOUD_AUTH_CALLBACK_STATE_PARAM = "t3_state"; +export const CLOUD_AUTH_CALLBACK_SCHEME = "t3code"; +export const DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME = "t3code-dev"; + +const CLOUD_AUTH_REQUEST_TIMEOUT_MS = 5 * 60 * 1000; + +export class DesktopCloudAuthCallbackServerError extends Data.TaggedError( + "DesktopCloudAuthCallbackServerError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Failed to start the desktop cloud auth callback server."; + } +} + +interface PendingCloudAuthRequest { + readonly state: string; + readonly redirectUrl: string; + readonly close: () => void; +} + +export interface DesktopCloudAuthShape { + readonly createRequest: Effect.Effect; + readonly configure: Effect.Effect< + void, + never, + ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope + >; +} + +export class DesktopCloudAuth extends Context.Service()( + "@t3tools/desktop/app/DesktopCloudAuth", +) {} + +export function resolveCloudAuthCallbackScheme(input: { readonly isDevelopment: boolean }): string { + return input.isDevelopment ? DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME : CLOUD_AUTH_CALLBACK_SCHEME; +} + +export function buildCloudAuthCallbackUrl(input: { + readonly scheme: string; + readonly state: string; +}): string { + const url = new URL( + `${input.scheme}://${CLOUD_AUTH_CALLBACK_HOST}${CLOUD_AUTH_CALLBACK_PATHNAME}`, + ); + url.searchParams.set(CLOUD_AUTH_CALLBACK_STATE_PARAM, input.state); + return url.toString(); +} + +export function parseCloudAuthCallbackUrl(input: { + readonly rawUrl: unknown; + readonly scheme: string; + readonly state: string; +}): URL | null { + if (typeof input.rawUrl !== "string") { + return null; + } + + try { + const url = new URL(input.rawUrl); + if (url.protocol !== `${input.scheme}:`) return null; + if (url.hostname !== CLOUD_AUTH_CALLBACK_HOST) return null; + if (url.pathname !== CLOUD_AUTH_CALLBACK_PATHNAME) return null; + if (url.searchParams.get(CLOUD_AUTH_CALLBACK_STATE_PARAM) !== input.state) return null; + return url; + } catch { + return null; + } +} + +export function findCloudAuthCallbackUrl(input: { + readonly values: readonly unknown[]; + readonly scheme: string; + readonly state: string; +}): URL | null { + for (const value of input.values) { + const url = parseCloudAuthCallbackUrl({ + rawUrl: value, + scheme: input.scheme, + state: input.state, + }); + if (url) return url; + } + return null; +} + +export function resolveProtocolClientLaunchArgs(input: { + readonly argv: readonly string[]; +}): readonly string[] { + return input.argv.slice(1); +} + +function resolveConfiguredProtocolClient(): { + readonly path: string; + readonly args: readonly string[]; +} | null { + const path = process.env.T3CODE_DESKTOP_PROTOCOL_CLIENT_PATH?.trim(); + if (!path) return null; + + return { + path, + args: (process.env.T3CODE_DESKTOP_PROTOCOL_CLIENT_ARGS ?? "") + .split("\n") + .map((arg) => arg.trim()) + .filter((arg) => arg.length > 0), + }; +} + +function isProtocolRegistrationManagedExternally(): boolean { + return process.env.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED?.trim() === "1"; +} + +function resolveProtocolCallbackForwardUrl(): URL | null { + const rawUrl = process.env.T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL?.trim(); + if (!rawUrl) return null; + + try { + const url = new URL(rawUrl); + if (url.protocol !== "http:") return null; + if (url.hostname !== "127.0.0.1") return null; + if (url.pathname !== "/auth/callback") return null; + if (!url.port) return null; + return url; + } catch { + return null; + } +} + +const closeCloudAuthRequest = (request: PendingCloudAuthRequest | null): null => { + request?.close(); + return null; +}; + +function createCloudAuthRequestTimeout(onExpire: () => void): ReturnType { + // @effect-diagnostics-next-line globalTimers:off - Auth request expiry is tied to an Electron callback server, not fiber scheduling. + return setTimeout(onExpire, CLOUD_AUTH_REQUEST_TIMEOUT_MS); +} + +function ignoreCloudAuthCallback(_rawUrl: string) {} + +function startProtocolCallbackForwardServer( + callbackUrl: URL, + dispatch: (rawUrl: string) => void, +): Effect.Effect { + const port = Number.parseInt(callbackUrl.port, 10); + const routesLayer = HttpRouter.add( + "POST", + "/auth/callback", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const rawUrl = yield* request.text; + yield* Effect.sync(() => { + dispatch(rawUrl); + }); + return HttpServerResponse.empty({ status: 204 }); + }), + ); + + return Effect.gen(function* () { + const NodeHttp = yield* Effect.promise(() => import("node:http")); + const serverLayer = NodeHttpServer.layer(NodeHttp.createServer, { + host: callbackUrl.hostname, + port, + }); + yield* Layer.launch(HttpRouter.serve(routesLayer).pipe(Layer.provideMerge(serverLayer))).pipe( + Effect.forkScoped, + ); + }); +} + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + let pendingAuthRequest: PendingCloudAuthRequest | null = null; + let dispatchCloudAuthCallback: (rawUrl: string) => void = ignoreCloudAuthCallback; + const makeCloudAuthRequestState = Effect.gen(function* () { + const [left, right] = yield* Effect.all([crypto.randomUUIDv4, crypto.randomUUIDv4]); + return `${left}${right}`.replaceAll("-", ""); + }); + + return DesktopCloudAuth.of({ + createRequest: Effect.gen(function* () { + const scheme = resolveCloudAuthCallbackScheme({ + isDevelopment: environment.isDevelopment, + }); + const state = yield* makeCloudAuthRequestState.pipe( + Effect.mapError((cause) => new DesktopCloudAuthCallbackServerError({ cause })), + ); + + pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); + + const redirectUrl = buildCloudAuthCallbackUrl({ scheme, state }); + const timeout = createCloudAuthRequestTimeout(() => { + pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); + }); + pendingAuthRequest = { + state, + redirectUrl, + close: () => clearTimeout(timeout), + }; + return redirectUrl; + }), + configure: Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const scope = yield* Scope.Scope; + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + const scheme = resolveCloudAuthCallbackScheme({ + isDevelopment: environment.isDevelopment, + }); + + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); + }), + ); + + if (isProtocolRegistrationManagedExternally()) { + // Development macOS launchers set the default URL handler before the stock Electron + // process starts so LaunchServices binds the scheme to the worktree-specific app bundle. + } else if (environment.isDevelopment) { + const configuredClient = resolveConfiguredProtocolClient(); + if (configuredClient) { + yield* electronApp.setAsDefaultProtocolClient( + scheme, + configuredClient.path, + configuredClient.args, + ); + } else { + yield* electronApp.setAsDefaultProtocolClient( + scheme, + process.execPath, + resolveProtocolClientLaunchArgs({ argv: process.argv }), + ); + } + } else { + yield* electronApp.setAsDefaultProtocolClient(scheme); + } + + dispatchCloudAuthCallback = (rawUrl: string) => { + const pending = pendingAuthRequest; + const callbackUrl = pending + ? parseCloudAuthCallbackUrl({ rawUrl, scheme, state: pending.state }) + : null; + if (!callbackUrl) { + return; + } + + pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); + void runPromise( + Effect.gen(function* () { + yield* electronWindow.sendAll( + IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, + callbackUrl.toString(), + ); + const mainWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(mainWindow)) { + yield* electronWindow.reveal(mainWindow.value); + } + }), + ); + }; + + const protocolCallbackForwardUrl = resolveProtocolCallbackForwardUrl(); + if (environment.isDevelopment && protocolCallbackForwardUrl) { + yield* startProtocolCallbackForwardServer( + protocolCallbackForwardUrl, + dispatchCloudAuthCallback, + ); + } + + const hasInstanceLock = yield* electronApp.requestSingleInstanceLock; + if (!hasInstanceLock) { + return yield* electronApp.quit; + } + + yield* electronApp.on<[Electron.Event, string]>("open-url", (event, rawUrl) => { + event.preventDefault?.(); + dispatchCloudAuthCallback(rawUrl); + }); + + yield* electronApp.on<[Electron.Event, readonly string[]]>( + "second-instance", + (_event, argv) => { + const values = resolveProtocolClientLaunchArgs({ argv }); + const pending = pendingAuthRequest; + const callbackUrl = pending + ? findCloudAuthCallbackUrl({ values, scheme, state: pending.state }) + : null; + if (callbackUrl) { + dispatchCloudAuthCallback(callbackUrl.toString()); + return; + } + + void runPromise( + Effect.gen(function* () { + const mainWindow = yield* electronWindow.currentMainOrFirst; + if (Option.isSome(mainWindow)) { + yield* electronWindow.reveal(mainWindow.value); + } + }), + ); + }, + ); + }).pipe(Effect.withSpan("desktop.cloudAuth.configure")), + }); +}); + +export const layer = Layer.effect(DesktopCloudAuth, make); diff --git a/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts b/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts new file mode 100644 index 00000000000..3257edca885 --- /dev/null +++ b/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts @@ -0,0 +1,96 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopCloudAuthTokenStore from "./DesktopCloudAuthTokenStore.ts"; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +function makeSafeStorageLayer(input: { readonly available: boolean }) { + return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { + isEncryptionAvailable: Effect.succeed(input.available), + encryptString: (value) => Effect.succeed(textEncoder.encode(`enc:${value}`)), + decryptString: (value) => { + const decoded = textDecoder.decode(value); + if (!decoded.startsWith("enc:")) { + return Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: new Error("invalid encrypted token"), + }), + ); + } + return Effect.succeed(decoded.slice("enc:".length)); + }, + } satisfies ElectronSafeStorage.ElectronSafeStorageShape); +} + +function makeLayer(baseDir: string, input?: { readonly encryptionAvailable?: boolean }) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopCloudAuthTokenStore.layer.pipe( + Layer.provideMerge(environmentLayer), + Layer.provideMerge(makeSafeStorageLayer({ available: input?.encryptionAvailable ?? true })), + Layer.provideMerge(NodeServices.layer), + ); +} + +const withTokenStore = ( + effect: Effect.Effect, + input?: { readonly encryptionAvailable?: boolean }, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-cloud-auth-token-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer(baseDir, input))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopCloudAuthTokenStore", () => { + it.effect("persists, reads, and clears the encrypted Clerk client JWT", () => + withTokenStore( + Effect.gen(function* () { + const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; + + assert.isTrue(yield* tokenStore.set("__client=test.jwt")); + assert.deepStrictEqual(yield* tokenStore.get, Option.some("__client=test.jwt")); + + yield* tokenStore.clear; + assert.deepStrictEqual(yield* tokenStore.get, Option.none()); + }), + ), + ); + + it.effect("does not persist a token when Electron safe storage is unavailable", () => + withTokenStore( + Effect.gen(function* () { + const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; + + assert.isFalse(yield* tokenStore.set("__client=test.jwt")); + assert.deepStrictEqual(yield* tokenStore.get, Option.none()); + }), + { encryptionAvailable: false }, + ), + ); +}); diff --git a/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts b/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts new file mode 100644 index 00000000000..652072c1f5d --- /dev/null +++ b/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts @@ -0,0 +1,155 @@ +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +interface CloudAuthTokenDocument { + readonly version: number; + readonly encryptedClientJwt: string; +} + +const CloudAuthTokenDocumentSchema = Schema.Struct({ + version: Schema.Number, + encryptedClientJwt: Schema.String, +}); + +const CloudAuthTokenDocumentJson = fromLenientJson(CloudAuthTokenDocumentSchema); +const decodeCloudAuthTokenDocumentJson = Schema.decodeEffect(CloudAuthTokenDocumentJson); +const encodeCloudAuthTokenDocumentJson = Schema.encodeEffect(CloudAuthTokenDocumentJson); + +export class DesktopCloudAuthTokenStoreWriteError extends Data.TaggedError( + "DesktopCloudAuthTokenStoreWriteError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop cloud auth token: ${this.cause.message}`; + } +} + +export class DesktopCloudAuthTokenStoreDecodeError extends Data.TaggedError( + "DesktopCloudAuthTokenStoreDecodeError", +)<{ + readonly cause: Encoding.EncodingError; +}> { + override get message() { + return "Failed to decode desktop cloud auth token."; + } +} + +export interface DesktopCloudAuthTokenStoreShape { + readonly get: Effect.Effect< + Option.Option, + | DesktopCloudAuthTokenStoreDecodeError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageDecryptError + >; + readonly set: ( + token: string, + ) => Effect.Effect< + boolean, + | DesktopCloudAuthTokenStoreWriteError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageEncryptError + >; + readonly clear: Effect.Effect; +} + +export class DesktopCloudAuthTokenStore extends Context.Service< + DesktopCloudAuthTokenStore, + DesktopCloudAuthTokenStoreShape +>()("@t3tools/desktop/app/DesktopCloudAuthTokenStore") {} + +function decodeSecretBytes( + encoded: string, +): Effect.Effect { + return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( + Effect.mapError((cause) => new DesktopCloudAuthTokenStoreDecodeError({ cause })), + ); +} + +const readDocument = ( + fileSystem: FileSystem.FileSystem, + tokenPath: string, +): Effect.Effect> => + fileSystem.readFileString(tokenPath).pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (raw) => decodeCloudAuthTokenDocumentJson(raw).pipe(Effect.option), + }), + ), + ); + +const writeDocument = Effect.fn("desktop.cloudAuthTokenStore.writeDocument")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly tokenPath: string; + readonly document: CloudAuthTokenDocument; + readonly suffix: string; +}): Effect.fn.Return { + const directory = input.path.dirname(input.tokenPath); + const tempPath = `${input.tokenPath}.${process.pid}.${input.suffix}.tmp`; + const encoded = yield* encodeCloudAuthTokenDocumentJson(input.document); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.tokenPath); +}); + +export const layer = Layer.effect( + DesktopCloudAuthTokenStore, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const crypto = yield* Crypto.Crypto; + const tokenPath = path.join(environment.stateDir, "cloud-auth-token.json"); + + return DesktopCloudAuthTokenStore.of({ + get: Effect.gen(function* () { + const document = yield* readDocument(fileSystem, tokenPath); + if (Option.isNone(document) || !(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + + const secretBytes = yield* decodeSecretBytes(document.value.encryptedClientJwt); + return Option.some(yield* safeStorage.decryptString(secretBytes)); + }).pipe(Effect.withSpan("desktop.cloudAuthTokenStore.get")), + set: Effect.fn("desktop.cloudAuthTokenStore.set")(function* (token) { + if (!(yield* safeStorage.isEncryptionAvailable)) { + return false; + } + + const encryptedClientJwt = Encoding.encodeBase64(yield* safeStorage.encryptString(token)); + const suffix = (yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new DesktopCloudAuthTokenStoreWriteError({ cause })), + )).replace(/-/g, ""); + yield* writeDocument({ + fileSystem, + path, + tokenPath, + document: { version: 1, encryptedClientJwt }, + suffix, + }).pipe(Effect.mapError((cause) => new DesktopCloudAuthTokenStoreWriteError({ cause }))); + return true; + }), + clear: fileSystem.remove(tokenPath, { force: true }).pipe( + Effect.catch(() => Effect.void), + Effect.withSpan("desktop.cloudAuthTokenStore.clear"), + ), + }); + }), +); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index a9218314018..4bf6b513306 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -37,6 +37,7 @@ export const DesktopConfig = Config.all({ xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), t3Home: trimmedString("T3CODE_HOME"), devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), + appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"), devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option), commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 11ea03f6816..ee732bf830c 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -93,6 +93,20 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("uses a configured app user model id override", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment( + {}, + { + T3CODE_DESKTOP_APP_USER_MODEL_ID: " com.t3tools.t3code.dev.local ", + VITE_DEV_SERVER_URL: "http://localhost:5173", + }, + ); + + assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev.local"); + }), + ); + it.effect("resolves picker defaults without nullish sentinels", () => Effect.gen(function* () { const environment = yield* makeEnvironment(); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 2c5db8a16d8..431e0d34d81 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -199,7 +199,9 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( otlpExportIntervalMs: config.otlpExportIntervalMs, branding, displayName, - appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => + isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", + ), linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", userDataDirName, diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 9eab65ade91..f6ed5cb1df7 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -7,12 +7,15 @@ const { exitMock, getAppPathMock, getVersionMock, + isDefaultProtocolClientMock, onMock, quitMock, relaunchMock, removeListenerMock, + requestSingleInstanceLockMock, setAboutPanelOptionsMock, setAppUserModelIdMock, + setAsDefaultProtocolClientMock, setDesktopNameMock, setDockIconMock, setNameMock, @@ -23,12 +26,15 @@ const { exitMock: vi.fn(), getAppPathMock: vi.fn(() => "/app"), getVersionMock: vi.fn(() => "1.2.3"), + isDefaultProtocolClientMock: vi.fn(() => false), onMock: vi.fn(), quitMock: vi.fn(), relaunchMock: vi.fn(), removeListenerMock: vi.fn(), + requestSingleInstanceLockMock: vi.fn(() => true), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), + setAsDefaultProtocolClientMock: vi.fn(() => true), setDesktopNameMock: vi.fn(), setDockIconMock: vi.fn(), setNameMock: vi.fn(), @@ -46,14 +52,17 @@ vi.mock("electron", () => ({ }, getAppPath: getAppPathMock, getVersion: getVersionMock, + isDefaultProtocolClient: isDefaultProtocolClientMock, isPackaged: true, name: "T3 Code", on: onMock, quit: quitMock, relaunch: relaunchMock, removeListener: removeListenerMock, + requestSingleInstanceLock: requestSingleInstanceLockMock, runningUnderARM64Translation: false, setAboutPanelOptions: setAboutPanelOptionsMock, + setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, setAppUserModelId: setAppUserModelIdMock, setDesktopName: setDesktopNameMock, setName: setNameMock, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index bd7a5ec8476..49b432fd5dd 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -29,6 +29,13 @@ export interface ElectronAppShape { options: Electron.AboutPanelOptionsOptions, ) => Effect.Effect; readonly setAppUserModelId: (id: string) => Effect.Effect; + readonly requestSingleInstanceLock: Effect.Effect; + readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; + readonly setAsDefaultProtocolClient: ( + protocol: string, + path?: string, + args?: readonly string[], + ) => Effect.Effect; readonly setDesktopName: (desktopName: string) => Effect.Effect; readonly setDockIcon: (iconPath: string) => Effect.Effect; readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect; @@ -93,6 +100,16 @@ const make = ElectronApp.of({ Effect.sync(() => { Electron.app.setAppUserModelId(id); }), + requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), + isDefaultProtocolClient: (protocol) => + Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), + setAsDefaultProtocolClient: (protocol, path, args) => + Effect.sync(() => { + if (path === undefined) { + return Electron.app.setAsDefaultProtocolClient(protocol); + } + return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); + }), setDesktopName: (desktopName) => Effect.sync(() => { const linuxApp = Electron.app as Electron.App & { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index d0b08c8dac7..40f84054878 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,6 +1,13 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; +import { + clearCloudAuthToken, + createCloudAuthRequest, + fetchCloudAuth, + getCloudAuthToken, + setCloudAuthToken, +} from "./methods/cloudAuth.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { getSavedEnvironmentRegistry, @@ -75,7 +82,11 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); - + yield* ipc.handle(createCloudAuthRequest); + yield* ipc.handle(getCloudAuthToken); + yield* ipc.handle(setCloudAuthToken); + yield* ipc.handle(clearCloudAuthToken); + yield* ipc.handle(fetchCloudAuth); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 2715b20cb36..1ded238c663 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,12 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const CREATE_CLOUD_AUTH_REQUEST_CHANNEL = "desktop:create-cloud-auth-request"; +export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token"; +export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token"; +export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token"; +export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth"; +export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; diff --git a/apps/desktop/src/ipc/methods/cloudAuth.test.ts b/apps/desktop/src/ipc/methods/cloudAuth.test.ts new file mode 100644 index 00000000000..74187715730 --- /dev/null +++ b/apps/desktop/src/ipc/methods/cloudAuth.test.ts @@ -0,0 +1,105 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { afterEach } from "vitest"; + +import { fetchCloudAuth, validateClerkFrontendApiUrl } from "./cloudAuth.ts"; + +const originalClerkPublishableKey = process.env.T3CODE_CLERK_PUBLISHABLE_KEY; + +const clerkPublishableKey = (hostname: string): string => + `pk_test_${Buffer.from(`${hostname}$`).toString("base64")}`; + +function makeHttpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +describe("Desktop cloud auth IPC", () => { + afterEach(() => { + if (originalClerkPublishableKey === undefined) { + delete process.env.T3CODE_CLERK_PUBLISHABLE_KEY; + } else { + process.env.T3CODE_CLERK_PUBLISHABLE_KEY = originalClerkPublishableKey; + } + }); + + it.effect("preserves Clerk's URL-encoded OAuth form content type", () => { + const body = "strategy=oauth_google&redirect_url=t3code%3A%2F%2Fauth%2Fcallback"; + let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null; + const layer = makeHttpClientLayer((request) => + Effect.sync(() => { + forwardedRequest = request; + return HttpClientResponse.fromWeb( + request, + Response.json({ response: { object: "sign_in_attempt" } }), + ); + }), + ); + + return Effect.gen(function* () { + yield* fetchCloudAuth.handler({ + url: "https://example.clerk.accounts.dev/v1/client/sign_ins", + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "x-mobile": "1", + }, + body, + }); + + assert(forwardedRequest !== null); + assert.equal( + forwardedRequest.headers["content-type"], + "application/x-www-form-urlencoded;charset=UTF-8", + ); + assert.equal(forwardedRequest.body._tag, "Uint8Array"); + if (forwardedRequest.body._tag === "Uint8Array") { + assert.equal(new TextDecoder().decode(forwardedRequest.body.body), body); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "allows the custom Clerk Frontend API host encoded by the configured publishable key", + () => { + process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes"); + let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null; + const layer = makeHttpClientLayer((request) => + Effect.sync(() => { + forwardedRequest = request; + return HttpClientResponse.fromWeb( + request, + Response.json({ response: { object: "client" } }), + ); + }), + ); + + return Effect.gen(function* () { + yield* fetchCloudAuth.handler({ + url: "https://clerk.t3.codes/v1/client", + method: "GET", + headers: {}, + }); + + assert(forwardedRequest !== null); + assert.equal(forwardedRequest.url.toString(), "https://clerk.t3.codes/v1/client"); + }).pipe(Effect.provide(layer)); + }, + ); + + it("rejects arbitrary HTTPS hosts that are not configured Clerk Frontend API hosts", () => { + process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes"); + assert.throws( + () => validateClerkFrontendApiUrl("https://attacker.example/v1/client"), + /restricted to Clerk Frontend API HTTPS hosts/u, + ); + }); +}); diff --git a/apps/desktop/src/ipc/methods/cloudAuth.ts b/apps/desktop/src/ipc/methods/cloudAuth.ts new file mode 100644 index 00000000000..3c9ee264fc5 --- /dev/null +++ b/apps/desktop/src/ipc/methods/cloudAuth.ts @@ -0,0 +1,154 @@ +import { + DesktopCloudAuthFetchInputSchema, + DesktopCloudAuthFetchResultSchema, +} from "@t3tools/contracts"; +import { + clerkFrontendApiHostnameFromPublishableKey, + isAllowedClerkFrontendApiHostname, +} from "@t3tools/shared/relayAuth"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts"; +import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; + +export class DesktopCloudAuthFetchError extends Data.TaggedError("DesktopCloudAuthFetchError")<{ + readonly reason: string; + readonly cause?: unknown; +}> { + override get message() { + return this.reason; + } +} + +function configuredClerkFrontendApiHostname(): string | null { + const publishableKey = + process.env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() || + (typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" + ? "" + : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__.trim()); + if (!publishableKey) return null; + + return clerkFrontendApiHostnameFromPublishableKey(publishableKey); +} + +const allowedClerkFrontendApiHosts = (hostname: string): boolean => + isAllowedClerkFrontendApiHostname(hostname, configuredClerkFrontendApiHostname()); + +export function validateClerkFrontendApiUrl(rawUrl: string): URL { + const url = new URL(rawUrl); + if (url.protocol !== "https:" || !allowedClerkFrontendApiHosts(url.hostname)) { + throw new DesktopCloudAuthFetchError({ + reason: "Desktop cloud auth fetch is restricted to Clerk Frontend API HTTPS hosts.", + }); + } + return url; +} + +export const createCloudAuthRequest = makeIpcMethod({ + channel: IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL, + payload: Schema.Void, + result: Schema.String, + handler: Effect.fn("desktop.ipc.cloudAuth.createRequest")(function* () { + const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; + return yield* cloudAuth.createRequest; + }), +}); + +export const getCloudAuthToken = makeIpcMethod({ + channel: IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.cloudAuth.getToken")(function* () { + const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; + return Option.getOrNull(yield* tokenStore.get); + }), +}); + +export const setCloudAuthToken = makeIpcMethod({ + channel: IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.cloudAuth.setToken")(function* (token) { + const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; + return yield* tokenStore.set(token); + }), +}); + +export const clearCloudAuthToken = makeIpcMethod({ + channel: IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.cloudAuth.clearToken")(function* () { + const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; + yield* tokenStore.clear; + }), +}); + +export const fetchCloudAuth = makeIpcMethod({ + channel: IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, + payload: DesktopCloudAuthFetchInputSchema, + result: DesktopCloudAuthFetchResultSchema, + handler: Effect.fn("desktop.ipc.cloudAuth.fetch")(function* (input) { + const url = yield* Effect.try({ + try: () => validateClerkFrontendApiUrl(input.url), + catch: (cause) => + cause instanceof DesktopCloudAuthFetchError + ? cause + : new DesktopCloudAuthFetchError({ + reason: "Desktop cloud auth fetch received an invalid URL.", + cause, + }), + }); + + const requestWithoutBody = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")( + url, + { + headers: input.headers, + }, + ); + const request = + input.body === undefined + ? requestWithoutBody + : HttpClientRequest.bodyText( + requestWithoutBody, + input.body, + Option.getOrUndefined(Headers.get(requestWithoutBody.headers, "content-type")), + ); + + const response = yield* HttpClient.execute(request).pipe( + Effect.mapError( + (cause) => + new DesktopCloudAuthFetchError({ + reason: "Desktop cloud auth fetch failed.", + cause, + }), + ), + ); + + const body = yield* response.text.pipe( + Effect.mapError( + (cause) => + new DesktopCloudAuthFetchError({ + reason: "Desktop cloud auth fetch response could not be read.", + cause, + }), + ), + ); + + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: "", + headers: response.headers, + body, + }; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 8c458db0122..9356eef441b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -26,6 +26,8 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; +import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; +import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -111,6 +113,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopSavedEnvironments.layer, + DesktopCloudAuthTokenStore.layer, DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); @@ -135,6 +138,7 @@ const desktopBackendLayer = DesktopBackendManager.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, + DesktopCloudAuth.layer, DesktopShellEnvironment.layer, desktopSshLayer, ).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 737567990de..84f7580cb07 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -96,6 +96,23 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + createCloudAuthRequest: () => ipcRenderer.invoke(IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL), + getCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL), + setCloudAuthToken: (token: string) => + ipcRenderer.invoke(IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, token), + clearCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL), + fetchCloudAuth: (input) => ipcRenderer.invoke(IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, input), + onCloudAuthCallback: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => { + if (typeof rawUrl !== "string") return; + listener(rawUrl); + }; + + ipcRenderer.on(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener); + }; + }, onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 494e5c3253f..bb571a9ad3b 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -53,6 +53,7 @@ const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ createdAt: Schema.String, lastConnectedAt: Schema.NullOr(Schema.String), desktopSsh: Schema.optionalKey(DesktopSshTargetSchema), + relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })), encryptedBearerToken: Schema.optionalKey(Schema.String), }); @@ -134,7 +135,11 @@ function toPersistedSavedEnvironmentRecord( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, }; - return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; + return { + ...nextRecord, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), + ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), + }; } function toSavedEnvironmentStorageRecord( @@ -149,20 +154,13 @@ function toSavedEnvironmentStorageRecord( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, }; - const desktopSsh = record.desktopSsh; - if (desktopSsh) { - return Option.match(encryptedBearerToken, { - onNone: () => ({ ...nextRecord, desktopSsh }), - onSome: (value) => ({ - ...nextRecord, - desktopSsh, - encryptedBearerToken: value, - }), - }); - } + const metadata = { + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), + ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), + }; return Option.match(encryptedBearerToken, { - onNone: () => nextRecord, - onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }), + onNone: () => ({ ...nextRecord, ...metadata }), + onSome: (value) => ({ ...nextRecord, ...metadata, encryptedBearerToken: value }), }); } diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index fc589b3e39b..62d619fe18b 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -39,6 +39,9 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, + requestSingleInstanceLock: Effect.succeed(true), + isDefaultProtocolClient: () => Effect.succeed(false), + setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, appendCommandLineSwitch: () => Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index a4d83afc406..271b35095e4 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -32,10 +32,13 @@ const environmentInput = { } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; function makeFakeBrowserWindow() { + const webContentsListeners = new Map void>(); const webContents = { copyImageAt: vi.fn(), isLoadingMainFrame: vi.fn(() => false), - on: vi.fn(), + on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { + webContentsListeners.set(eventName, listener); + }), once: vi.fn(), openDevTools: vi.fn(), replaceMisspelling: vi.fn(), @@ -63,6 +66,7 @@ function makeFakeBrowserWindow() { window: window as unknown as Electron.BrowserWindow, loadURL: window.loadURL, openDevTools: webContents.openDevTools, + webContentsListeners, }; } @@ -96,11 +100,6 @@ const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { showContextMenu: () => Effect.succeed(Option.none()), } satisfies ElectronMenu.ElectronMenuShape); -const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, { - openExternal: () => Effect.succeed(true), - copyText: () => Effect.void, -} satisfies ElectronShell.ElectronShellShape); - const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { shouldUseDarkColors: Effect.succeed(false), setSource: () => Effect.void, @@ -123,6 +122,7 @@ function makeTestLayer(input: { readonly window: Electron.BrowserWindow; readonly createCount: Ref.Ref; readonly mainWindow: Ref.Ref>; + readonly openedExternalUrls?: unknown[]; }) { const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), @@ -145,7 +145,14 @@ function makeTestLayer(input: { desktopServerExposureLayer, DesktopState.layer, electronMenuLayer, - electronShellLayer, + Layer.succeed(ElectronShell.ElectronShell, { + openExternal: (url) => + Effect.sync(() => { + input.openedExternalUrls?.push(url); + return true; + }), + copyText: () => Effect.void, + } satisfies ElectronShell.ElectronShellShape), electronThemeLayer, electronWindowLayer, ), @@ -154,6 +161,27 @@ function makeTestLayer(input: { } describe("DesktopWindow", () => { + it("recognizes only same-origin renderer navigations", () => { + assert.isTrue( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "http://127.0.0.1:3773/", + navigationUrl: "http://127.0.0.1:3773/settings/cloud", + }), + ); + assert.isFalse( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "http://127.0.0.1:3773/", + navigationUrl: "https://accounts.microsoft.com/oauth", + }), + ); + assert.isFalse( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "http://127.0.0.1:3773/", + navigationUrl: "not a url", + }), + ); + }); + it.effect("does not open a development window until the backend is ready", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -177,4 +205,42 @@ describe("DesktopWindow", () => { }).pipe(Effect.provide(layer)); }), ); + + it.effect("opens safe off-origin renderer navigations in the system browser", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const openedExternalUrls: unknown[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + openedExternalUrls, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady; + + const willNavigate = fakeWindow.webContentsListeners.get("will-navigate"); + if (!willNavigate) { + return yield* Effect.die("will-navigate listener was not registered"); + } + let prevented = false; + willNavigate( + { + preventDefault: () => { + prevented = true; + }, + }, + "https://accounts.microsoft.com/oauth", + ); + yield* Effect.promise(() => Promise.resolve()); + + assert.isTrue(prevented); + assert.deepEqual(openedExternalUrls, ["https://accounts.microsoft.com/oauth"]); + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index e88c52d42ec..35145cc1d53 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -92,6 +92,17 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } +export function isSameOriginRendererNavigation(input: { + readonly applicationUrl: string; + readonly navigationUrl: string; +}): boolean { + try { + return new URL(input.applicationUrl).origin === new URL(input.navigationUrl).origin; + } catch { + return false; + } +} + function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { if (process.platform === "darwin") { return { @@ -159,6 +170,9 @@ const make = Effect.gen(function* () { const createWindow = Effect.fn("desktop.window.createWindow")(function* ( backendHttpUrl: URL, ): Effect.fn.Return { + const applicationUrl = environment.isDevelopment + ? yield* resolveDesktopDevServerUrl(environment) + : backendHttpUrl.href; const iconPaths = yield* assets.iconPaths; const iconOption = getIconOption(iconPaths); const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; @@ -235,6 +249,21 @@ const make = Effect.gen(function* () { } return { action: "deny" }; }); + window.webContents.on("will-navigate", (event, url) => { + if ( + isSameOriginRendererNavigation({ + applicationUrl, + navigationUrl: url, + }) + ) { + return; + } + + event.preventDefault(); + if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { + void runPromise(electronShell.openExternal(url)); + } + }); window.on("page-title-updated", (event) => { event.preventDefault(); @@ -276,11 +305,10 @@ const make = Effect.gen(function* () { }); if (environment.isDevelopment) { - const devServerUrl = yield* resolveDesktopDevServerUrl(environment); - void window.loadURL(devServerUrl); + void window.loadURL(applicationUrl); window.webContents.openDevTools({ mode: "detach" }); } else { - void window.loadURL(backendHttpUrl.href); + void window.loadURL(applicationUrl); } window.on("closed", () => { diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 67fb736c6d4..396ea7aa548 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -5,5 +5,5 @@ "types": ["node", "electron"], "lib": ["ESNext", "DOM", "esnext.disposable"] }, - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts", "../../scripts/lib"] } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 46ae1640000..066cd349095 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,6 +1,14 @@ import { defineConfig } from "vite-plus"; +import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; + +const repoEnv = loadRepoEnv(); const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1"; +const publicConfigDefine = { + __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( + repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "", + ), +}; export default defineConfig({ run: { @@ -27,6 +35,7 @@ export default defineConfig({ outDir: "dist-electron", sourcemap: true, outExtensions: () => ({ js: ".cjs" }), + define: publicConfigDefine, entry: ["src/main.ts"], clean: true, deps: { @@ -39,6 +48,7 @@ export default defineConfig({ outDir: "dist-electron", sourcemap: true, outExtensions: () => ({ js: ".cjs" }), + define: publicConfigDefine, entry: ["src/preload.ts"], }, ], diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 44639e74ccf..34d1ef15dcb 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -16,6 +16,10 @@ This app has three variants: Run commands from `apps/mobile`. +T3 Cloud is optional and disabled in a fresh clone. Public configuration belongs in the +repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See +[`../../.env.example`](../../.env.example). + ## Development Start Metro for the dev client: @@ -61,6 +65,10 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. ## EAS Builds +For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY`, +`T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL` +as EAS environment variables. Expo config maps the canonical values into the mobile build. + Create a cloud dev-client build: ```bash diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 5c46d01bfd8..f74ca0cd25d 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,8 +1,13 @@ import type { ExpoConfig } from "expo/config"; +import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; + type AppVariant = "development" | "preview" | "production"; -const APP_VARIANT = resolveAppVariant(process.env.APP_VARIANT); +const repoEnv = loadRepoEnv(); +Object.assign(process.env, repoEnv); + +const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT); const VARIANT_CONFIG: Record< AppVariant, @@ -95,6 +100,11 @@ const config: ExpoConfig = { favicon: "./assets/favicon.png", }, plugins: [ + "expo-router", + "expo-font", + "expo-secure-store", + ["@clerk/expo", { theme: "./clerk-theme.json" }], + "expo-web-browser", [ "expo-camera", { @@ -119,17 +129,37 @@ const config: ExpoConfig = { "expo-build-properties", { ios: { - deploymentTarget: "16.4", + deploymentTarget: "18.0", }, }, ], - "expo-secure-store", - "expo-router", - "expo-font", + [ + "expo-widgets", + { + bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, + groupIdentifier: `group.${variant.iosBundleIdentifier}`, + enablePushNotifications: true, + widgets: [ + { + name: "AgentActivity", + displayName: "Agent Activity", + description: "Shows the current state of active T3 Code agents.", + supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], + }, + ], + }, + ], "./plugins/withAndroidCleartextTraffic.cjs", ], extra: { appVariant: APP_VARIANT, + relay: { + url: repoEnv.T3CODE_RELAY_URL ?? null, + }, + clerk: { + publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, + jwtTemplate: repoEnv.EXPO_PUBLIC_CLERK_JWT_TEMPLATE ?? null, + }, eas: { projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", }, diff --git a/apps/mobile/clerk-theme.json b/apps/mobile/clerk-theme.json new file mode 100644 index 00000000000..52941785f3e --- /dev/null +++ b/apps/mobile/clerk-theme.json @@ -0,0 +1,39 @@ +{ + "colors": { + "primary": "#262626", + "background": "#F2F2F7", + "input": "#FFFFFF", + "danger": "#DC2626", + "success": "#059669", + "warning": "#D97706", + "foreground": "#262626", + "mutedForeground": "#737373", + "primaryForeground": "#FFFFFF", + "inputForeground": "#262626", + "neutral": "#F5F5F5", + "border": "#E5E5EA", + "ring": "#A3A3A3", + "muted": "#F5F5F5", + "shadow": "#000000" + }, + "darkColors": { + "primary": "#F5F5F5", + "background": "#0E0E0E", + "input": "#171717", + "danger": "#FCA5A5", + "success": "#34D399", + "warning": "#FBBF24", + "foreground": "#F5F5F5", + "mutedForeground": "#A3A3A3", + "primaryForeground": "#0A0A0A", + "inputForeground": "#F5F5F5", + "neutral": "#1C1C1C", + "border": "#2A2A2A", + "ring": "#525252", + "muted": "#1C1C1C", + "shadow": "#000000" + }, + "design": { + "borderRadius": 18 + } +} diff --git a/apps/mobile/global.css b/apps/mobile/global.css index bbd1cb0be0a..4642879451a 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -38,6 +38,7 @@ --color-secondary: #ffffff; --color-secondary-foreground: #262626; --color-secondary-border: rgba(0, 0, 0, 0.08); + --color-switch-active: #34c759; /* Danger */ --color-danger: #fef2f2; @@ -125,6 +126,7 @@ --color-secondary: rgba(255, 255, 255, 0.04); --color-secondary-foreground: #f5f5f5; --color-secondary-border: rgba(255, 255, 255, 0.06); + --color-switch-active: #30d158; /* Danger */ --color-danger: rgba(239, 68, 68, 0.14); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d2dec20e521..a9b4b45c92b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -36,10 +36,13 @@ }, "dependencies": { "@callstack/liquid-glass": "^0.7.1", + "@clerk/expo": "^3.3.0", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/ui": "~56.0.8", "@legendapp/list": "3.0.0-beta.44", + "@noble/curves": "catalog:", + "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", "@react-native-menu/menu": "^2.0.0", "@shikijs/core": "3.23.0", @@ -55,6 +58,7 @@ "diff": "8.0.3", "effect": "catalog:", "expo": "^56.0.0", + "expo-auth-session": "~56.0.12", "expo-build-properties": "~56.0.15", "expo-camera": "~56.0.7", "expo-clipboard": "~56.0.3", @@ -67,12 +71,15 @@ "expo-haptics": "~56.0.3", "expo-image-picker": "~56.0.14", "expo-linking": "~56.0.12", + "expo-notifications": "~56.0.14", "expo-paste-input": "^0.1.15", "expo-router": "~56.2.7", "expo-secure-store": "~56.0.4", "expo-splash-screen": "~56.0.10", "expo-symbols": "~56.0.5", "expo-updates": "~56.0.17", + "expo-web-browser": "~56.0.5", + "expo-widgets": "~56.0.15", "punycode": "^2.3.1", "react": "19.2.3", "react-dom": "19.2.3", @@ -93,6 +100,7 @@ "uniwind": "^1.6.2" }, "devDependencies": { + "@effect/vitest": "catalog:", "@types/react": "~19.2.0", "babel-preset-expo": "~56.0.0", "tailwindcss": "^4.0.0", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 15ff31339fa..70fd861aaac 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -20,12 +20,15 @@ import { } from "../state/use-remote-environment-registry"; import { RegistryContext } from "@effect/atom-react"; import { appAtomRegistry } from "../state/atom-registry"; +import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; +import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; function AppNavigator() { const { isLoadingSavedConnection } = useRemoteEnvironmentState(); const colorScheme = useColorScheme(); const statusBarBg = useCSSVariable("--color-status-bar"); const sheetStyle = useResolveClassNames("bg-sheet"); + useAgentNotificationNavigation(); const newTaskScreenOptions = { contentStyle: sheetStyle, @@ -45,6 +48,11 @@ function AppNavigator() { sheetGrabberVisible: true, }; + const settingsSheetScreenOptions = { + ...connectionSheetScreenOptions, + sheetAllowedDetents: [0.7], + }; + if (isLoadingSavedConnection) { return ; } @@ -66,6 +74,7 @@ function AppNavigator() { headerShadowVisible: false, }} /> + - - - - {fontsLoaded ? : } - - - - + + + + + + {fontsLoaded ? ( + + ) : ( + + )} + + + + + ); } diff --git a/apps/mobile/src/app/connections/index.tsx b/apps/mobile/src/app/connections/index.tsx index 6b5717094b0..1f9a94a2b92 100644 --- a/apps/mobile/src/app/connections/index.tsx +++ b/apps/mobile/src/app/connections/index.tsx @@ -22,8 +22,8 @@ export default function ConnectionsRouteScreen() { const hasEnvironments = connectedEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); - const primaryFg = useThemeColor("--color-primary-foreground"); const accentColor = useThemeColor("--color-icon-muted"); + const iconColor = useThemeColor("--color-icon"); const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); @@ -36,11 +36,15 @@ export default function ConnectionsRouteScreen() { title: "Environments", headerRight: () => ( - + diff --git a/apps/mobile/src/app/connections/new.tsx b/apps/mobile/src/app/connections/new.tsx index 66010900593..b817b446ec8 100644 --- a/apps/mobile/src/app/connections/new.tsx +++ b/apps/mobile/src/app/connections/new.tsx @@ -130,7 +130,9 @@ export default function ConnectionsNewRouteScreen() { title: showScanner ? "Scan QR Code" : "Add Environment", headerRight: () => ( { if (showScanner) { closeScanner(); diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 666a7967364..1ae0ea36b21 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -78,16 +78,11 @@ export default function HomeRouteScreen() { - - More - router.push("/connections")} - subtitle="Manage connected hosts" - > - Environments - - + router.push("/settings")} + separateBackground + /> {/* Bottom toolbar: search + compose, visually split like iMessage */} diff --git a/apps/mobile/src/app/settings/_layout.tsx b/apps/mobile/src/app/settings/_layout.tsx new file mode 100644 index 00000000000..0110d765a6f --- /dev/null +++ b/apps/mobile/src/app/settings/_layout.tsx @@ -0,0 +1,41 @@ +import Stack from "expo-router/stack"; +import { useResolveClassNames } from "uniwind"; + +import { useThemeColor } from "../../lib/useThemeColor"; + +export const unstable_settings = { + anchor: "index", +}; + +export default function SettingsLayout() { + const contentStyle = useResolveClassNames("bg-sheet"); + const sheetBg = String(useThemeColor("--color-sheet")); + const headerTint = String(useThemeColor("--color-icon")); + + return ( + + + + + + + ); +} diff --git a/apps/mobile/src/app/settings/environment-new.tsx b/apps/mobile/src/app/settings/environment-new.tsx new file mode 100644 index 00000000000..3fb3e8d3a04 --- /dev/null +++ b/apps/mobile/src/app/settings/environment-new.tsx @@ -0,0 +1 @@ +export { default } from "../connections/new"; diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx new file mode 100644 index 00000000000..0a8e8969724 --- /dev/null +++ b/apps/mobile/src/app/settings/environments.tsx @@ -0,0 +1,285 @@ +import { useAuth } from "@clerk/expo"; +import { Link, Stack } from "expo-router"; +import { SymbolView } from "expo-symbols"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import * as Effect from "effect/Effect"; +import { useCallback, useMemo, useState } from "react"; +import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment"; +import { + hasCloudPublicConfig, + resolveRelayClerkTokenOptions, +} from "../../features/cloud/publicConfig"; +import { + useManagedRelayEnvironments, + useManagedRelayEnvironmentStatus, +} from "../../features/cloud/managedRelayState"; +import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; +import { cn } from "../../lib/cn"; +import { mobileRuntime } from "../../lib/runtime"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + connectSavedEnvironment, + useRemoteConnections, + useRemoteEnvironmentState, +} from "../../state/use-remote-environment-registry"; + +export default function SettingsEnvironmentsRouteScreen() { + const { + connectedEnvironments, + onReconnectEnvironment, + onRemoveEnvironmentPress, + onUpdateEnvironment, + } = useRemoteConnections(); + const insets = useSafeAreaInsets(); + const hasEnvironments = connectedEnvironments.length > 0; + const [expandedId, setExpandedId] = useState(null); + const accentColor = useThemeColor("--color-icon-muted"); + const iconColor = useThemeColor("--color-icon"); + + const handleToggle = useCallback((environmentId: EnvironmentId) => { + setExpandedId((prev) => (prev === environmentId ? null : environmentId)); + }, []); + + return ( + + ( + + + + + + ), + }} + /> + + {hasEnvironments ? ( + + {connectedEnvironments.map((environment, index) => ( + + handleToggle(environment.environmentId)} + onReconnect={onReconnectEnvironment} + onRemove={onRemoveEnvironmentPress} + onUpdate={onUpdateEnvironment} + /> + + ))} + + ) : ( + + + + + + No environments connected yet.{"\n"}Tap{" "} + + to add one. + + + )} + + {hasCloudPublicConfig() ? : null} + + + ); +} + +function ConfiguredCloudEnvironmentRows() { + const { getToken, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { savedConnectionsById } = useRemoteEnvironmentState(); + const cloudEnvironmentsState = useManagedRelayEnvironments(); + const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState( + null, + ); + const iconColor = useThemeColor("--color-icon"); + const availableCloudEnvironments = useMemo( + () => + (cloudEnvironmentsState.data ?? []).filter( + (environment) => savedConnectionsById[environment.environmentId] === undefined, + ), + [cloudEnvironmentsState.data, savedConnectionsById], + ); + + const handleConnectCloudEnvironment = useCallback( + async (environment: RelayClientEnvironmentRecord) => { + setConnectingCloudEnvironmentId(environment.environmentId); + try { + const token = await getToken(resolveRelayClerkTokenOptions()); + if (!token) { + throw new Error("Sign in to T3 Cloud before connecting."); + } + await mobileRuntime.runPromise( + connectCloudEnvironment({ + clerkToken: token, + environment, + }).pipe(Effect.flatMap(connectSavedEnvironment)), + ); + } catch (error) { + Alert.alert( + "Connect failed", + error instanceof Error ? error.message : "Could not connect to this environment.", + ); + } finally { + setConnectingCloudEnvironmentId(null); + } + }, + [getToken], + ); + + if (!isSignedIn) return null; + + return ( + + + T3 Cloud + + {cloudEnvironmentsState.isPending ? ( + + ) : ( + + )} + + + + {availableCloudEnvironments.length > 0 ? ( + + {availableCloudEnvironments.map((environment, index) => ( + handleConnectCloudEnvironment(environment)} + /> + ))} + + ) : cloudEnvironmentsState.data === null ? ( + + + + Loading linked cloud environments. + + + ) : cloudEnvironmentsState.error ? ( + + + Could not load T3 Cloud environments + + + {cloudEnvironmentsState.error} + + + ) : ( + + + No additional linked cloud environments. + + + )} + + ); +} + +function CloudEnvironmentRow(props: { + readonly environment: RelayClientEnvironmentRecord; + readonly borderTop: boolean; + readonly isConnecting: boolean; + readonly onConnect: () => void; +}) { + const mutedColor = useThemeColor("--color-icon-muted"); + const statusState = useManagedRelayEnvironmentStatus(props.environment); + const status = statusState.data; + const disabled = props.isConnecting; + const statusText = + status === null + ? (statusState.error ?? (statusState.isPending ? "Checking status..." : "Status unavailable")) + : status.status === "online" + ? "Online" + : (status.error ?? "Offline"); + + return ( + + + + + + + {props.environment.label} + + + {props.environment.endpoint.httpBaseUrl} + + + {statusText} + + + + + {props.isConnecting ? "Connecting" : "Connect"} + + + + ); +} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx new file mode 100644 index 00000000000..b25de626867 --- /dev/null +++ b/apps/mobile/src/app/settings/index.tsx @@ -0,0 +1,451 @@ +import { useAuth, useUser, useUserProfileModal } from "@clerk/expo"; +import * as Notifications from "expo-notifications"; +import { Link, Stack, useRouter } from "expo-router"; +import { SymbolView } from "expo-symbols"; +import * as Effect from "effect/Effect"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences"; +import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; +import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; +import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; +import { + hasCloudPublicConfig, + resolveRelayClerkTokenOptions, +} from "../../features/cloud/publicConfig"; +import { mobileRuntime } from "../../lib/runtime"; +import { loadPreferences } from "../../lib/storage"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; + +type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; +type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; + +export default function SettingsRouteScreen() { + return hasCloudPublicConfig() ? : ; +} + +function LocalSettingsRouteScreen() { + const insets = useSafeAreaInsets(); + const { savedConnectionsById } = useRemoteEnvironmentState(); + const environmentCount = Object.keys(savedConnectionsById).length; + + return ( + + + + + + + + + + + ); +} + +function ConfiguredSettingsRouteScreen() { + const insets = useSafeAreaInsets(); + const { push } = useRouter(); + const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { user } = useUser(); + const { isAvailable: isUserProfileModalAvailable, presentUserProfile } = useUserProfileModal(); + const { savedConnectionsById } = useRemoteEnvironmentState(); + const [notificationStatus, setNotificationStatus] = useState("checking"); + const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); + + const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); + const environmentCount = connections.length; + const accountLabel = useMemo(() => { + if (!isLoaded) return "Checking"; + if (!isSignedIn) return "Request access"; + return user?.primaryEmailAddress?.emailAddress ?? "Signed in"; + }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); + + const refreshNotifications = useCallback(async () => { + if (process.env.EXPO_OS !== "ios") { + setNotificationStatus("unsupported"); + return; + } + const permission = await Notifications.getPermissionsAsync(); + setNotificationStatus(permission.granted ? "enabled" : "disabled"); + }, []); + + useEffect(() => { + void refreshNotifications(); + }, [refreshNotifications]); + + useEffect(() => { + if (!isLoaded) { + setLiveActivityStatus("checking"); + return; + } + if (!isSignedIn) { + setLiveActivityStatus("signed-out"); + return; + } + void loadPreferences().then( + (preferences) => { + setLiveActivityStatus(preferences.liveActivitiesEnabled === false ? "disabled" : "enabled"); + }, + () => { + setLiveActivityStatus("enabled"); + }, + ); + }, [isLoaded, isSignedIn]); + + const requestNotifications = useCallback(async () => { + try { + const result = await mobileRuntime.runPromise( + requestAgentNotificationPermission.pipe( + Effect.tap((permission) => + permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, + ), + ), + ); + if (result.type === "granted") { + setNotificationStatus("enabled"); + Alert.alert( + "Notifications enabled", + "Live Activity notifications are enabled for this device.", + ); + return; + } + if (result.type === "unsupported") { + setNotificationStatus("unsupported"); + Alert.alert( + "Notifications unavailable", + "Live Activity notifications are only available on iOS.", + ); + return; + } + setNotificationStatus("disabled"); + if (result.canAskAgain) { + Alert.alert("Notifications disabled", "Notifications were not enabled."); + return; + } + Alert.alert( + "Notifications disabled", + "Notifications were denied for this app. Open Settings to enable them.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); + } catch (error) { + Alert.alert( + "Notifications unavailable", + error instanceof Error ? error.message : "Could not request notification permission.", + ); + } + }, []); + + const promptSignIn = useCallback(() => { + Alert.alert( + "Request T3 Cloud access", + "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Continue", onPress: () => push("/settings/waitlist") }, + ], + ); + }, [push]); + + const linkEnvironments = useCallback(async () => { + if (!isSignedIn) { + promptSignIn(); + return; + } + + setLiveActivityStatus("linking"); + try { + const token = await getToken(resolveRelayClerkTokenOptions()); + if (!token) { + promptSignIn(); + setLiveActivityStatus("signed-out"); + return; + } + + await mobileRuntime.runPromise( + setLiveActivityUpdatesEnabled({ + enabled: true, + clerkToken: token, + connections, + }), + ); + refreshManagedRelayEnvironments(); + setLiveActivityStatus("enabled"); + Alert.alert( + "Live Activities enabled", + environmentCount > 0 + ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` + : "Live Activity updates are enabled. Add an environment to start receiving updates.", + ); + } catch (error) { + setLiveActivityStatus("disabled"); + Alert.alert( + "Live Activities unavailable", + error instanceof Error ? error.message : "Could not enable Live Activity updates.", + ); + } + }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); + + const handleDeviceNotificationsChange = useCallback( + (enabled: boolean) => { + if (enabled) { + void requestNotifications(); + return; + } + + Alert.alert( + "Disable notifications", + "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], + ); + }, + [requestNotifications], + ); + + const handleLiveActivitiesChange = useCallback( + (enabled: boolean) => { + if (!enabled) { + setLiveActivityStatus("disabled"); + void (async () => { + try { + const token = isSignedIn ? await getToken(resolveRelayClerkTokenOptions()) : null; + await mobileRuntime.runPromise( + setLiveActivityUpdatesEnabled({ + enabled: false, + clerkToken: token, + connections, + }), + ); + refreshManagedRelayEnvironments(); + } catch { + // The switch is optimistic; a future refresh reconciles relay state. + } + })(); + return; + } + + if (!isSignedIn) { + promptSignIn(); + return; + } + + void linkEnvironments(); + }, + [connections, getToken, isSignedIn, linkEnvironments, promptSignIn], + ); + + const openAccount = useCallback(() => { + if (!isLoaded) return; + if (!isSignedIn) { + push("/settings/waitlist"); + return; + } + if (isUserProfileModalAvailable) { + void presentUserProfile(); + return; + } + Alert.alert( + "T3 Cloud unavailable", + "Native T3 Cloud account management is not available in this build.", + ); + }, [isLoaded, isSignedIn, isUserProfileModalAvailable, presentUserProfile, push]); + + return ( + + + + + + + + + T3 Code works locally without signing in. Cloud features are optional. + + + + + + + + + + + + + ); +} + +type SymbolName = ComponentProps["name"]; + +function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { + return ( + + {props.title} + + {props.children} + + + ); +} + +function AppSettingsSection() { + const icon = useThemeColor("--color-icon"); + + return ( + + + + Version + Alpha + + + ); +} + +function SettingsRow(props: { + readonly disabled?: boolean; + readonly icon: SymbolName; + readonly label: string; + readonly value?: string; + readonly href?: "/settings/environments"; + readonly onPress?: () => void; +}) { + const icon = useThemeColor("--color-icon"); + const chevron = useThemeColor("--color-chevron"); + const content = ( + + + {props.label} + {props.value ? ( + + {props.value} + + ) : null} + + + ); + + if (props.href) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} + +function SettingsSwitchRow(props: { + readonly disabled?: boolean; + readonly icon: SymbolName; + readonly label: string; + readonly value: boolean; + readonly onValueChange: (value: boolean) => void; +}) { + const icon = useThemeColor("--color-icon"); + const activeTrack = String(useThemeColor("--color-switch-active")); + const track = String(useThemeColor("--color-secondary-border")); + + return ( + + + {props.label} + + + ); +} diff --git a/apps/mobile/src/app/settings/waitlist.tsx b/apps/mobile/src/app/settings/waitlist.tsx new file mode 100644 index 00000000000..891a7f4ce28 --- /dev/null +++ b/apps/mobile/src/app/settings/waitlist.tsx @@ -0,0 +1,38 @@ +import { Redirect, Stack } from "expo-router"; +import { ScrollView } from "react-native"; + +import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment"; +import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; +import { useNativeClerkAuthModal } from "../../features/cloud/useNativeClerkAuthModal"; + +export default function SettingsWaitlistRouteScreen() { + return hasCloudPublicConfig() ? ( + + ) : ( + + ); +} + +function ConfiguredSettingsWaitlistRouteScreen() { + const { presentAuth } = useNativeClerkAuthModal(); + + return ( + <> + + + void presentAuth()} /> + + + ); +} diff --git a/apps/mobile/src/features/agent-awareness/liveActivityController.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityController.test.ts new file mode 100644 index 00000000000..69d002577e2 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/liveActivityController.test.ts @@ -0,0 +1,256 @@ +import { afterEach, beforeEach, vi } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { ManagedRelayClient } from "@t3tools/client-runtime"; + +import type { EnvironmentId, OrchestrationShellSnapshot, ThreadId } from "@t3tools/contracts"; +import { + __resetAgentLiveActivitiesForTest, + refreshActiveLiveActivityRemoteRegistration, + syncAgentLiveActivitiesForSnapshot, +} from "./liveActivityController"; +import { registerLiveActivityPushToken } from "./remoteRegistration"; + +const mocks = vi.hoisted(() => { + const existingActivity = { + update: vi.fn(() => Promise.resolve()), + end: vi.fn((_dismissal: unknown, _props?: unknown, _endedAt?: unknown) => Promise.resolve()), + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + return { + existingActivity, + getInstances: vi.fn(() => [existingActivity]), + start: vi.fn(() => existingActivity), + registerLiveActivityPushToken: vi.fn( + (): Effect.Effect => Effect.succeed(true), + ), + }; +}); + +vi.mock("react-native", () => ({ + Platform: { + OS: "ios", + }, +})); + +vi.mock("expo-linking", () => ({ + default: { + createURL: vi.fn((path: string) => `t3code://${path}`), + }, + createURL: vi.fn((path: string) => `t3code://${path}`), +})); + +vi.mock("expo-widgets", () => ({ + after: (date: Date) => ({ after: date }), +})); + +vi.mock("../../lib/runtime", () => ({ + mobileRuntime: { + runPromise: (operation: Effect.Effect) => Effect.runPromise(operation), + }, +})); + +vi.mock("../../widgets/AgentActivity", () => ({ + default: { + getInstances: mocks.getInstances, + start: mocks.start, + }, +})); + +vi.mock("./remoteRegistration", () => ({ + registerLiveActivityPushToken: mocks.registerLiveActivityPushToken, +})); + +const runManagedRelayEffect = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provideService(ManagedRelayClient, null as never))); + +function runningSnapshot(): OrchestrationShellSnapshot { + return { + projects: [ + { + id: "project-1", + title: "Project", + }, + ], + threads: [ + { + id: "thread-1" as ThreadId, + projectId: "project-1", + title: "Thread", + modelSelection: { + model: "gpt-5", + }, + session: { + status: "running", + providerName: "Codex", + }, + latestTurn: { + state: "running", + }, + updatedAt: "2026-05-25T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + }, + ], + } as unknown as OrchestrationShellSnapshot; +} + +function completedSnapshot(): OrchestrationShellSnapshot { + return { + projects: [ + { + id: "project-1", + title: "Project", + }, + ], + threads: [ + { + id: "thread-1" as ThreadId, + projectId: "project-1", + title: "Thread", + modelSelection: { + model: "gpt-5", + }, + session: null, + latestTurn: { + state: "completed", + }, + updatedAt: "2026-05-25T00:01:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + }, + ], + } as unknown as OrchestrationShellSnapshot; +} + +describe("liveActivityController", () => { + beforeEach(() => { + vi.stubGlobal("__DEV__", false); + __resetAgentLiveActivitiesForTest(); + mocks.existingActivity.update.mockClear(); + mocks.existingActivity.end.mockClear(); + mocks.existingActivity.getPushToken.mockClear(); + mocks.existingActivity.addPushTokenListener.mockClear(); + mocks.getInstances.mockClear(); + mocks.getInstances.mockReturnValue([mocks.existingActivity]); + mocks.start.mockClear(); + mocks.registerLiveActivityPushToken.mockClear(); + mocks.registerLiveActivityPushToken.mockReturnValue(Effect.succeed(true)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("adopts an existing remote-started Live Activity instead of creating a duplicate", async () => { + await Effect.runPromise( + syncAgentLiveActivitiesForSnapshot({ + environmentId: "env-1" as EnvironmentId, + snapshot: runningSnapshot(), + }), + ); + + expect(mocks.getInstances).toHaveBeenCalledTimes(1); + expect(mocks.existingActivity.update).toHaveBeenCalledTimes(1); + expect(mocks.start).not.toHaveBeenCalled(); + expect(registerLiveActivityPushToken).toHaveBeenCalledWith({ + activity: mocks.existingActivity, + }); + }); + + it("keeps skipped remote token registrations retryable after cloud sign-in", async () => { + mocks.registerLiveActivityPushToken + .mockReturnValueOnce(Effect.succeed(false)) + .mockReturnValueOnce(Effect.succeed(true)); + + await Effect.runPromise( + syncAgentLiveActivitiesForSnapshot({ + environmentId: "env-1" as EnvironmentId, + snapshot: runningSnapshot(), + }), + ); + await runManagedRelayEffect(refreshActiveLiveActivityRemoteRegistration()); + + expect(registerLiveActivityPushToken).toHaveBeenCalledTimes(2); + expect(registerLiveActivityPushToken).toHaveBeenNthCalledWith(2, { + activity: mocks.existingActivity, + }); + }); + + it("retries cloud sign-in Live Activity token refresh failures", async () => { + vi.useFakeTimers(); + mocks.registerLiveActivityPushToken + .mockReturnValueOnce(Effect.succeed(true)) + .mockReturnValueOnce(Effect.fail(new Error("relay unavailable"))) + .mockReturnValueOnce(Effect.succeed(true)); + + await Effect.runPromise( + syncAgentLiveActivitiesForSnapshot({ + environmentId: "env-1" as EnvironmentId, + snapshot: runningSnapshot(), + }), + ); + await runManagedRelayEffect(refreshActiveLiveActivityRemoteRegistration()); + + expect(registerLiveActivityPushToken).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(15_000); + + expect(registerLiveActivityPushToken).toHaveBeenCalledTimes(3); + expect(registerLiveActivityPushToken).toHaveBeenNthCalledWith(3, { + activity: mocks.existingActivity, + }); + }); + + it("retries adopted remote-start Live Activity token registration when the token is initially unavailable", async () => { + vi.useFakeTimers(); + mocks.registerLiveActivityPushToken + .mockReturnValueOnce(Effect.succeed(false)) + .mockReturnValueOnce(Effect.succeed(true)); + + await Effect.runPromise( + syncAgentLiveActivitiesForSnapshot({ + environmentId: "env-1" as EnvironmentId, + snapshot: runningSnapshot(), + }), + ); + + expect(registerLiveActivityPushToken).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(15_000); + + expect(registerLiveActivityPushToken).toHaveBeenCalledTimes(2); + expect(registerLiveActivityPushToken).toHaveBeenNthCalledWith(2, { + activity: mocks.existingActivity, + }); + }); + + it("ends active Live Activities with the terminal thread content state", async () => { + await Effect.runPromise( + syncAgentLiveActivitiesForSnapshot({ + environmentId: "env-1" as EnvironmentId, + snapshot: runningSnapshot(), + }), + ); + await Effect.runPromise( + syncAgentLiveActivitiesForSnapshot({ + environmentId: "env-1" as EnvironmentId, + snapshot: completedSnapshot(), + }), + ); + + expect(mocks.existingActivity.end).toHaveBeenCalledTimes(1); + expect(mocks.existingActivity.end.mock.calls[0]?.[1]).toMatchObject({ + activeCount: 1, + activities: [ + { + environmentId: "env-1", + threadId: "thread-1", + phase: "completed", + status: "Done", + }, + ], + }); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityController.ts b/apps/mobile/src/features/agent-awareness/liveActivityController.ts new file mode 100644 index 00000000000..ed10e1e4ec7 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/liveActivityController.ts @@ -0,0 +1,605 @@ +import { + isTerminalAgentAwarenessPhase, + projectThreadAwareness, + type AgentAwarenessState, +} from "@t3tools/shared/agentAwareness"; +import type { + EnvironmentId, + OrchestrationProjectShell, + OrchestrationShellSnapshot, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Semaphore from "effect/Semaphore"; +import { after, type LiveActivity } from "expo-widgets"; +import * as Linking from "expo-linking"; +import { Platform } from "react-native"; +import { ManagedRelayClient } from "@t3tools/client-runtime"; + +import { mobileRuntime } from "../../lib/runtime"; +import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; +import { registerLiveActivityPushToken } from "./remoteRegistration"; + +const MAX_LOCAL_LIVE_ACTIVITIES = 3; +const FINAL_ACTIVITY_DISMISSAL_MS = 5 * 60 * 1_000; +const REMOTE_TOKEN_REGISTRATION_RETRY_MS = 15_000; +const MISSING_STATE_GRACE_MS = 30_000; + +interface ActiveDeviceActivity { + readonly instance: LiveActivity; + readonly props: AgentActivityProps; + readonly lastRemoteTokenRegistrationAttemptAt: number | null; + readonly remoteTokenRegistered: boolean; + readonly remoteTokenRetryScheduled: boolean; + readonly missingSince: number | null; +} + +let activeActivity: ActiveDeviceActivity | null = null; +const environmentStates = new Map>(); +let localLiveActivitiesEnabled = true; +const liveActivitySyncSemaphore = Effect.runSync(Semaphore.make(1)); + +function canUseLocalLiveActivities(): boolean { + return localLiveActivitiesEnabled && Platform.OS === "ios"; +} + +function logLiveActivityDebug(context: string, details?: unknown): void { + if (!__DEV__) { + return; + } + console.log(`[agent-awareness] ${context}`, details ?? ""); +} + +function logLiveActivityWarning(context: string, error: unknown): void { + if (!__DEV__) { + return; + } + console.warn( + `[agent-awareness] ${context}`, + error instanceof Error ? error.message : String(error), + ); +} + +function statusForPhase(phase: AgentAwarenessState["phase"]): string { + switch (phase) { + case "waiting_for_approval": + return "Approval"; + case "waiting_for_input": + return "Input"; + case "completed": + return "Done"; + case "failed": + return "Failed"; + case "starting": + return "Starting"; + case "running": + return "Working"; + case "stale": + return "Waiting"; + } +} + +function epochMillis(iso: string): number { + return Option.match(DateTime.make(iso), { + onNone: () => Number.NaN, + onSome: (dt) => dt.epochMilliseconds, + }); +} + +function currentTimeMillis(): number { + return Date.now(); +} + +function currentDateTime(): DateTime.Utc { + return DateTime.nowUnsafe(); +} + +function toActivityProps(states: ReadonlyArray): AgentActivityProps { + const updatedAt = states.reduce((latest, state) => { + if (latest === null) { + return state.updatedAt; + } + return epochMillis(state.updatedAt) > epochMillis(latest) ? state.updatedAt : latest; + }, null); + + return { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: states.length, + updatedAt: updatedAt ?? DateTime.formatIso(currentDateTime()), + activities: states.slice(0, MAX_LOCAL_LIVE_ACTIVITIES).map((state) => ({ + environmentId: state.environmentId, + threadId: state.threadId, + projectTitle: state.projectTitle, + threadTitle: state.threadTitle, + modelTitle: state.modelTitle, + phase: state.phase, + status: statusForPhase(state.phase), + updatedAt: state.updatedAt, + deepLink: state.deepLink, + })), + }; +} + +function hasSameActivityContent(left: AgentActivityProps, right: AgentActivityProps): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function sortByUpdatedAtDesc(left: AgentAwarenessState, right: AgentAwarenessState): number { + return epochMillis(right.updatedAt) - epochMillis(left.updatedAt); +} + +function projectSnapshotAwareness(input: { + readonly environmentId: EnvironmentId; + readonly snapshot: OrchestrationShellSnapshot; +}): ReadonlyArray { + const projectsById = new Map( + input.snapshot.projects.map((project) => [project.id, project]), + ); + const states: AgentAwarenessState[] = []; + + for (const thread of input.snapshot.threads) { + const project = projectsById.get(thread.projectId); + if (!project) { + continue; + } + const state = projectThreadAwareness({ + environmentId: input.environmentId, + project, + thread, + }); + if (state) { + states.push(state); + } + } + + return states; +} + +export function syncAgentLiveActivitiesForSnapshot(input: { + readonly environmentId: EnvironmentId; + readonly snapshot: OrchestrationShellSnapshot | null; +}): Effect.Effect { + return enqueueLiveActivitySync( + Effect.gen(function* () { + if (!canUseLocalLiveActivities()) { + return; + } + + if (input.snapshot === null) { + return yield* endEnvironmentLiveActivitiesNow(input.environmentId); + } + + const states = projectSnapshotAwareness({ + environmentId: input.environmentId, + snapshot: input.snapshot, + }); + environmentStates.set(input.environmentId, states); + return yield* syncAgentLiveActivities(); + }), + ); +} + +export function endEnvironmentLiveActivities( + environmentId: EnvironmentId, +): Effect.Effect { + return enqueueLiveActivitySync(endEnvironmentLiveActivitiesNow(environmentId)); +} + +function endEnvironmentLiveActivitiesNow( + environmentId: EnvironmentId, +): Effect.Effect { + return Effect.gen(function* () { + environmentStates.delete(environmentId); + return yield* syncAgentLiveActivities(); + }); +} + +export function endAllAgentLiveActivities(): Effect.Effect { + return enqueueLiveActivitySync(endAllAgentLiveActivitiesNow()); +} + +function endAllAgentLiveActivitiesNow(): Effect.Effect { + return Effect.gen(function* () { + environmentStates.clear(); + return yield* endActiveActivity(null, "all-ended"); + }); +} + +function enqueueLiveActivitySync( + operation: Effect.Effect, +): Effect.Effect { + return liveActivitySyncSemaphore.withPermits(1)(operation); +} + +function syncAgentLiveActivities(): Effect.Effect { + const states = [...environmentStates.values()].flat().sort(sortByUpdatedAtDesc); + const activeNonTerminalStates = states.filter( + (state) => !isTerminalAgentAwarenessPhase(state.phase), + ); + const terminalState = states.find((state) => isTerminalAgentAwarenessPhase(state.phase)) ?? null; + + if (activeNonTerminalStates.length === 0) { + if (!activeActivity) { + return Effect.void; + } + if (terminalState) { + return endActiveActivity(terminalState, "terminal-state"); + } + const now = currentTimeMillis(); + if (activeActivity.missingSince === null) { + activeActivity = { ...activeActivity, missingSince: now }; + logLiveActivityDebug("live activity missing state; waiting before ending", { + graceMs: MISSING_STATE_GRACE_MS, + }); + return Effect.void; + } + if (now - activeActivity.missingSince >= MISSING_STATE_GRACE_MS) { + return endActiveActivity(null, "missing-state"); + } + return Effect.void; + } + + return startOrUpdateActivity(activeNonTerminalStates); +} + +function startOrUpdateActivity( + states: ReadonlyArray, +): Effect.Effect { + const props = toActivityProps(states); + const primaryState = states[0]; + if (activeActivity) { + const active = activeActivity; + if (!hasSameActivityContent(active.props, props)) { + logLiveActivityDebug("updating live activity", { + activeCount: props.activeCount, + primaryThreadId: primaryState?.threadId ?? null, + }); + return runLiveActivityOperation( + "update", + Effect.tryPromise({ + try: () => active.instance.update(props), + catch: (error) => error, + }), + ).pipe( + Effect.flatMap((updated) => + Effect.sync(() => { + if (!updated.ok) { + activeActivity = null; + return; + } + activeActivity = { ...active, props, missingSince: null }; + retryRemoteTokenRegistration(activeActivity.instance); + }), + ), + ); + } else if (activeActivity.missingSince !== null) { + activeActivity = { ...activeActivity, missingSince: null }; + } + retryRemoteTokenRegistration(activeActivity.instance); + return Effect.void; + } + + return adoptExistingActivity(props, primaryState).pipe( + Effect.flatMap((adopted) => { + if (adopted) { + return Effect.void; + } + + logLiveActivityDebug("starting live activity", { + activeCount: props.activeCount, + primaryThreadId: primaryState?.threadId ?? null, + }); + return runLiveActivityOperation( + "start", + Effect.try({ + try: () => AgentActivity.start(props, Linking.createURL(primaryState?.deepLink ?? "/")), + catch: (error) => error, + }), + ).pipe( + Effect.flatMap((started) => + Effect.sync(() => { + const instance = started.ok ? started.value : undefined; + if (instance) { + logLiveActivityDebug("live activity started", { + primaryThreadId: primaryState?.threadId ?? null, + localActivityCount: AgentActivity.getInstances().length, + rowCount: props.activeCount, + }); + activeActivity = { + instance, + props, + lastRemoteTokenRegistrationAttemptAt: null, + remoteTokenRegistered: false, + remoteTokenRetryScheduled: false, + missingSince: null, + }; + retryRemoteTokenRegistration(instance); + } + }), + ), + ); + }), + ); +} + +function adoptExistingActivity( + props: AgentActivityProps, + primaryState: AgentAwarenessState | undefined, +): Effect.Effect { + const existingActivity = AgentActivity.getInstances()[0]; + if (!existingActivity) { + return Effect.succeed(false); + } + + logLiveActivityDebug("adopting existing live activity", { + activeCount: props.activeCount, + primaryThreadId: primaryState?.threadId ?? null, + }); + return runLiveActivityOperation( + "update", + Effect.tryPromise({ + try: () => existingActivity.update(props), + catch: (error) => error, + }), + ).pipe( + Effect.flatMap((updated) => + Effect.sync(() => { + if (!updated.ok) { + return false; + } + + activeActivity = { + instance: existingActivity, + props, + lastRemoteTokenRegistrationAttemptAt: null, + remoteTokenRegistered: false, + remoteTokenRetryScheduled: false, + missingSince: null, + }; + retryRemoteTokenRegistration(existingActivity); + return true; + }), + ), + ); +} + +function retryRemoteTokenRegistration(activity: LiveActivity): void { + if (!activeActivity) { + return; + } + + if (activeActivity.remoteTokenRegistered) { + return; + } + const now = currentTimeMillis(); + if ( + activeActivity.lastRemoteTokenRegistrationAttemptAt !== null && + now - activeActivity.lastRemoteTokenRegistrationAttemptAt < REMOTE_TOKEN_REGISTRATION_RETRY_MS + ) { + return; + } + + activeActivity = { + ...activeActivity, + lastRemoteTokenRegistrationAttemptAt: now, + }; + void mobileRuntime + .runPromise( + registerRemoteActivityToken(activity).pipe( + Effect.flatMap((registered) => + Effect.sync(() => { + if (!registered) { + scheduleRemoteTokenRegistrationRetry(activity); + } + }), + ), + Effect.catch((error) => + Effect.sync(() => { + logLiveActivityWarning( + "live activity token registration failed; retry scheduled", + error, + ); + scheduleRemoteTokenRegistrationRetry(activity); + }), + ), + ), + ) + .catch((error: unknown) => { + logLiveActivityWarning("unexpected live activity token registration failure", error); + }); +} + +function registerRemoteActivityToken( + activity: LiveActivity, +): Effect.Effect { + return registerLiveActivityPushToken({ activity }).pipe( + Effect.flatMap((registered) => + Effect.sync(() => { + if (registered && activeActivity?.instance === activity) { + activeActivity = { + ...activeActivity, + remoteTokenRegistered: true, + remoteTokenRetryScheduled: false, + }; + } + return registered; + }), + ), + ); +} + +function scheduleRemoteTokenRegistrationRetry(activity: LiveActivity): void { + if ( + !activeActivity || + activeActivity.instance !== activity || + activeActivity.remoteTokenRegistered || + activeActivity.remoteTokenRetryScheduled + ) { + return; + } + + activeActivity = { + ...activeActivity, + remoteTokenRetryScheduled: true, + }; + + void mobileRuntime + .runPromise( + Effect.sleep(Duration.millis(REMOTE_TOKEN_REGISTRATION_RETRY_MS)).pipe( + Effect.flatMap(() => + enqueueLiveActivitySync( + Effect.sync(() => { + if (!activeActivity || activeActivity.instance !== activity) { + return; + } + activeActivity = { + ...activeActivity, + remoteTokenRetryScheduled: false, + lastRemoteTokenRegistrationAttemptAt: null, + }; + retryRemoteTokenRegistration(activity); + }), + ), + ), + Effect.catch((error) => + Effect.sync(() => { + logLiveActivityWarning("live activity token retry failed", error); + }), + ), + ), + ) + .catch((error: unknown) => { + logLiveActivityWarning("unexpected live activity token retry failure", error); + }); +} + +export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< + void, + unknown, + ManagedRelayClient +> { + return enqueueLiveActivitySync( + Effect.gen(function* () { + if (!activeActivity || !canUseLocalLiveActivities()) { + return; + } + const activity = activeActivity.instance; + activeActivity = { + ...activeActivity, + remoteTokenRegistered: false, + remoteTokenRetryScheduled: false, + lastRemoteTokenRegistrationAttemptAt: currentTimeMillis(), + }; + yield* registerRemoteActivityToken(activity).pipe( + Effect.flatMap((registered) => + Effect.sync(() => { + if (!registered) { + scheduleRemoteTokenRegistrationRetry(activity); + } + }), + ), + Effect.catch((error) => + Effect.sync(() => { + logLiveActivityWarning("live activity token refresh failed; retry scheduled", error); + scheduleRemoteTokenRegistrationRetry(activity); + }), + ), + ); + }), + ); +} + +function endActiveActivity( + finalState: AgentAwarenessState | null, + reason: string, +): Effect.Effect { + const active = activeActivity; + if (!active) { + return Effect.void; + } + + activeActivity = null; + logLiveActivityDebug("ending live activity", { + reason, + finalPhase: finalState?.phase ?? null, + }); + if (finalState) { + const now = currentDateTime(); + return runLiveActivityOperation( + "end", + Effect.tryPromise({ + try: () => + active.instance.end( + after( + DateTime.toDateUtc(DateTime.add(now, { milliseconds: FINAL_ACTIVITY_DISMISSAL_MS })), + ), + toActivityProps([finalState]), + DateTime.toDateUtc(now), + ), + catch: (error) => error, + }), + ).pipe(Effect.asVoid); + } + + return runLiveActivityOperation( + "end", + Effect.tryPromise({ + try: () => active.instance.end("immediate"), + catch: (error) => error, + }), + ).pipe(Effect.asVoid); +} + +type LiveActivityOperationResult = + | { + readonly ok: true; + readonly value: T; + } + | { + readonly ok: false; + }; + +function runLiveActivityOperation( + operationName: "start" | "update" | "end", + operation: Effect.Effect, +): Effect.Effect> { + return operation.pipe( + Effect.match({ + onSuccess: (value) => ({ ok: true, value }) as const, + onFailure: (error): LiveActivityOperationResult => { + if ( + (operationName === "end" || operationName === "update") && + isMissingNativeLiveActivityError(error) + ) { + logLiveActivityWarning( + `live activity ${operationName} skipped; native activity was already gone`, + error, + ); + return { ok: false }; + } + + logLiveActivityWarning( + "live activity operation failed; disabling local live activities", + error, + ); + localLiveActivitiesEnabled = false; + activeActivity = null; + environmentStates.clear(); + return { ok: false }; + }, + }), + ); +} + +function isMissingNativeLiveActivityError(error: unknown): boolean { + return error instanceof Error && error.message.includes("Can't find live activity with id:"); +} + +export function __resetAgentLiveActivitiesForTest(): void { + localLiveActivitiesEnabled = true; + activeActivity = null; + environmentStates.clear(); +} diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts new file mode 100644 index 00000000000..da5ce59107e --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, vi } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { HttpClient } from "effect/unstable/http"; + +import type { SavedRemoteConnection } from "../../lib/connection"; +import { savePreferencesPatch } from "../../lib/storage"; +import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; +import { endAllAgentLiveActivities } from "./liveActivityController"; +import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; +import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; + +vi.mock("../../lib/storage", () => ({ + savePreferencesPatch: vi.fn(() => Promise.resolve()), +})); + +vi.mock("../cloud/linkEnvironment", () => ({ + linkEnvironmentToCloud: vi.fn(() => Effect.void), +})); + +vi.mock("./liveActivityController", () => ({ + endAllAgentLiveActivities: vi.fn(() => Effect.void), +})); + +vi.mock("./remoteRegistration", () => ({ + refreshAgentAwarenessRegistration: vi.fn(() => Effect.void), +})); + +const connection: SavedRemoteConnection = { + environmentId: "env-1" as EnvironmentId, + environmentLabel: "Desktop", + pairingUrl: "https://desktop.example.test/", + displayUrl: "https://desktop.example.test/", + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + bearerToken: "local-bearer", +}; + +const runWithHttpClient = ( + effect: Effect.Effect, +): Promise => + Effect.runPromise( + effect.pipe( + Effect.provideService(ManagedRelayClient, null as never), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HTTP request")), + ), + ), + ); + +describe("liveActivityPreferences", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("pushes disabled Live Activity preferences to local and relay registrations", async () => { + await runWithHttpClient( + setLiveActivityUpdatesEnabled({ + enabled: false, + clerkToken: "clerk-token", + connections: [connection], + }), + ); + + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); + expect(endAllAgentLiveActivities).toHaveBeenCalledTimes(1); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }); + + it("pushes enabled Live Activity preferences without ending active local activities", async () => { + await runWithHttpClient( + setLiveActivityUpdatesEnabled({ + enabled: true, + clerkToken: "clerk-token", + connections: [connection], + }), + ); + + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); + expect(endAllAgentLiveActivities).not.toHaveBeenCalled(); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }); + + it("keeps local preferences refreshable when signed out", async () => { + await runWithHttpClient( + setLiveActivityUpdatesEnabled({ + enabled: false, + clerkToken: null, + connections: [connection], + }), + ); + + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); + }); + + it("does not try to re-link managed relay connections without bearer credentials", async () => { + const managedConnection: SavedRemoteConnection = { + ...connection, + bearerToken: null, + }; + + await runWithHttpClient( + setLiveActivityUpdatesEnabled({ + enabled: true, + clerkToken: "clerk-token", + connections: [connection, managedConnection], + }), + ); + + expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts new file mode 100644 index 00000000000..fe2a9117be8 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -0,0 +1,39 @@ +import * as Effect from "effect/Effect"; +import { HttpClient } from "effect/unstable/http"; +import { ManagedRelayClient } from "@t3tools/client-runtime"; + +import type { SavedRemoteConnection } from "../../lib/connection"; +import { savePreferencesPatch } from "../../lib/storage"; +import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; +import { endAllAgentLiveActivities } from "./liveActivityController"; +import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; + +export function setLiveActivityUpdatesEnabled(input: { + readonly enabled: boolean; + readonly clerkToken: string | null; + readonly connections: ReadonlyArray; +}): Effect.Effect { + return Effect.gen(function* () { + yield* Effect.tryPromise({ + try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), + catch: (error) => error, + }); + + if (!input.enabled) { + yield* endAllAgentLiveActivities(); + } + + yield* refreshAgentAwarenessRegistration(); + + const clerkToken = input.clerkToken; + if (!clerkToken) { + return; + } + + yield* Effect.forEach( + input.connections.filter((connection) => connection.bearerToken !== null), + (connection) => linkEnvironmentToCloud({ clerkToken, connection }), + { concurrency: "unbounded" }, + ); + }); +} diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts new file mode 100644 index 00000000000..361641557a3 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { + extractAgentNotificationDeepLink, + routeAgentNotificationResponseOnce, +} from "./notificationPayload"; + +function responseWithData(data: Record, identifier = "notification-1") { + return { + notification: { + request: { + identifier, + content: { + data, + }, + }, + }, + }; +} + +describe("extractAgentNotificationDeepLink", () => { + it("uses explicit deep links from APNs payload data", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/threads/env/thread", + environmentId: "ignored", + threadId: "ignored", + }), + ), + ).toBe("/threads/env/thread"); + }); + + it("normalizes explicit thread deep links from APNs payload data", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/threads/env%201/thread%2F2", + }), + ), + ).toBe("/threads/env%201/thread%2F2"); + }); + + it("falls back to the thread route from environment and thread ids", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + environmentId: "env 1", + threadId: "thread/2", + }), + ), + ).toBe("/threads/env%201/thread%2F2"); + }); + + it("falls back to ids when explicit deep link is not an agent thread route", () => { + expect( + extractAgentNotificationDeepLink( + responseWithData({ + deepLink: "/", + environmentId: "env", + threadId: "thread", + }), + ), + ).toBe("/threads/env/thread"); + }); + + it("ignores malformed or external links", () => { + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "https://example.com" })), + ).toBeNull(); + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "/settings" })), + ).toBeNull(); + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "//example.com" })), + ).toBeNull(); + expect( + extractAgentNotificationDeepLink(responseWithData({ deepLink: "/threads/env/thread?x=1" })), + ).toBeNull(); + expect(extractAgentNotificationDeepLink({})).toBeNull(); + }); +}); + +describe("routeAgentNotificationResponseOnce", () => { + it("does not navigate twice when the initial and listener responses refer to one notification", () => { + const handledResponseIds = new Set(); + const navigations: Array = []; + const response = responseWithData({ + environmentId: "env", + threadId: "thread", + }); + + routeAgentNotificationResponseOnce({ + handledResponseIds, + response, + navigate: (deepLink) => navigations.push(deepLink), + }); + routeAgentNotificationResponseOnce({ + handledResponseIds, + response, + navigate: (deepLink) => navigations.push(deepLink), + }); + + expect(navigations).toEqual(["/threads/env/thread"]); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts new file mode 100644 index 00000000000..a7027623653 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef } from "react"; +import * as Notifications from "expo-notifications"; +import { useRouter } from "expo-router"; + +import { routeAgentNotificationResponseOnce } from "./notificationPayload"; + +export function useAgentNotificationNavigation(): void { + const router = useRouter(); + const handledResponseIds = useRef(new Set()); + + useEffect(() => { + const handleResponse = (response: Notifications.NotificationResponse): void => { + routeAgentNotificationResponseOnce({ + handledResponseIds: handledResponseIds.current, + response, + navigate: (deepLink) => router.push(deepLink as never), + }); + }; + + const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); + void Notifications.getLastNotificationResponseAsync() + .then((response) => { + if (response) { + handleResponse(response); + return Notifications.clearLastNotificationResponseAsync(); + } + return undefined; + }) + .catch(() => undefined); + + return () => { + subscription.remove(); + }; + }, [router]); +} diff --git a/apps/mobile/src/features/agent-awareness/notificationPayload.ts b/apps/mobile/src/features/agent-awareness/notificationPayload.ts new file mode 100644 index 00000000000..dc72e3d1bd2 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationPayload.ts @@ -0,0 +1,106 @@ +function dataFromNotificationResponse(response: unknown): Record | null { + if (typeof response !== "object" || response === null) { + return null; + } + const notification = (response as { readonly notification?: unknown }).notification; + if (typeof notification !== "object" || notification === null) { + return null; + } + const request = (notification as { readonly request?: unknown }).request; + if (typeof request !== "object" || request === null) { + return null; + } + const content = (request as { readonly content?: unknown }).content; + if (typeof content !== "object" || content === null) { + return null; + } + const data = (content as { readonly data?: unknown }).data; + return typeof data === "object" && data !== null ? (data as Record) : null; +} + +function identifierFromNotificationResponse(response: unknown): string | null { + if (typeof response !== "object" || response === null) { + return null; + } + const notification = (response as { readonly notification?: unknown }).notification; + if (typeof notification !== "object" || notification === null) { + return null; + } + const request = (notification as { readonly request?: unknown }).request; + if (typeof request !== "object" || request === null) { + return null; + } + const identifier = (request as { readonly identifier?: unknown }).identifier; + return typeof identifier === "string" ? identifier : null; +} + +function encodeThreadDeepLink(input: { + readonly environmentId: string; + readonly threadId: string; +}): string | null { + if (input.environmentId.length === 0 || input.threadId.length === 0) { + return null; + } + return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; +} + +function normalizeThreadDeepLink(value: string): string | null { + if ( + value.trim() !== value || + value.startsWith("//") || + value.includes("?") || + value.includes("#") + ) { + return null; + } + + const parts = value.split("/"); + if (parts.length !== 4 || parts[0] !== "" || parts[1] !== "threads") { + return null; + } + + try { + return encodeThreadDeepLink({ + environmentId: decodeURIComponent(parts[2] ?? ""), + threadId: decodeURIComponent(parts[3] ?? ""), + }); + } catch { + return null; + } +} + +export function extractAgentNotificationDeepLink(response: unknown): string | null { + const data = dataFromNotificationResponse(response); + const deepLink = data?.deepLink; + if (typeof deepLink === "string") { + const normalizedDeepLink = normalizeThreadDeepLink(deepLink); + if (normalizedDeepLink) { + return normalizedDeepLink; + } + } + + const environmentId = data?.environmentId; + const threadId = data?.threadId; + if (typeof environmentId === "string" && typeof threadId === "string") { + return encodeThreadDeepLink({ environmentId, threadId }); + } + return null; +} + +export function routeAgentNotificationResponseOnce(input: { + readonly handledResponseIds: Set; + readonly response: unknown; + readonly navigate: (deepLink: string) => void; +}): void { + const responseId = identifierFromNotificationResponse(input.response); + if (responseId && input.handledResponseIds.has(responseId)) { + return; + } + if (responseId) { + input.handledResponseIds.add(responseId); + } + const deepLink = extractAgentNotificationDeepLink(input.response); + if (deepLink) { + input.navigate(deepLink); + } +} diff --git a/apps/mobile/src/features/agent-awareness/notificationPermissions.ts b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts new file mode 100644 index 00000000000..ce8dfddf3d2 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/notificationPermissions.ts @@ -0,0 +1,44 @@ +import * as Notifications from "expo-notifications"; +import * as Effect from "effect/Effect"; +import { Platform } from "react-native"; + +export type NotificationPermissionResult = + | { readonly type: "unsupported" } + | { readonly type: "granted" } + | { readonly type: "denied"; readonly canAskAgain: boolean }; + +export const requestAgentNotificationPermission: Effect.Effect< + NotificationPermissionResult, + unknown +> = Effect.gen(function* () { + if (Platform.OS !== "ios") { + return { type: "unsupported" }; + } + + const existing = yield* Effect.tryPromise({ + try: () => Notifications.getPermissionsAsync(), + catch: (error) => error, + }); + if (existing.granted) { + return { type: "granted" }; + } + + if (!existing.canAskAgain) { + return { type: "denied", canAskAgain: false }; + } + + const requested = yield* Effect.tryPromise({ + try: () => + Notifications.requestPermissionsAsync({ + ios: { + allowAlert: true, + allowBadge: true, + allowSound: true, + }, + }), + catch: (error) => error, + }); + return requested.granted + ? { type: "granted" } + : { type: "denied", canAskAgain: requested.canAskAgain }; +}); diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts new file mode 100644 index 00000000000..44ef38df0ef --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -0,0 +1,33 @@ +import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; + +import type { MobilePreferences } from "../../lib/storage"; + +export function makeRelayDeviceRegistrationRequest(input: { + readonly deviceId: string; + readonly label: string; + readonly iosMajorVersion: number; + readonly appVersion?: string; + readonly pushToken?: string; + readonly pushToStartToken?: string; + readonly notificationsEnabled: boolean; + readonly preferences: MobilePreferences; +}): RelayDeviceRegistrationRequest { + const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; + return { + deviceId: input.deviceId, + label: input.label, + platform: "ios", + iosMajorVersion: input.iosMajorVersion, + appVersion: input.appVersion, + ...(input.pushToken ? { pushToken: input.pushToken } : {}), + ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), + preferences: { + liveActivitiesEnabled, + notificationsEnabled: input.notificationsEnabled, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + }; +} diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts new file mode 100644 index 00000000000..369309bb502 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -0,0 +1,413 @@ +/// + +import * as NodeCrypto from "node:crypto"; + +import { beforeEach, vi } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import Constants from "expo-constants"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { FetchHttpClient } from "effect/unstable/http"; +import type { ManagedRelayClient } from "@t3tools/client-runtime"; + +import type { EnvironmentId } from "@t3tools/contracts"; +import { verifyDpopProof } from "@t3tools/shared/dpop"; +import type { SavedRemoteConnection } from "../../lib/connection"; +import { mobileCryptoLayer } from "../cloud/dpop"; +import { mobileManagedRelayClientLayer } from "../cloud/managedRelayLayer"; +import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; +import { + __resetAgentAwarenessRemoteRegistrationForTest, + refreshAgentAwarenessRegistration, + normalizeAgentAwarenessRelayBaseUrl, + registerAgentAwarenessConnection, + registerLiveActivityPushToken, + setAgentAwarenessRelayTokenProvider, + shouldRegisterAgentAwarenessDeviceForProvider, + unregisterAgentAwarenessConnection, +} from "./remoteRegistration"; +import * as Notifications from "expo-notifications"; + +const secureStore = vi.hoisted(() => new Map()); + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + version: "1.0.0", + extra: {}, + }, + }, +})); + +vi.mock("expo-widgets", () => ({ + addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })), +})); + +vi.mock("expo-notifications", () => ({ + addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })), + getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })), + getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true })), +})); + +vi.mock("expo-crypto", () => ({ + CryptoDigestAlgorithm: { + SHA1: "SHA-1", + SHA256: "SHA-256", + SHA384: "SHA-384", + SHA512: "SHA-512", + }, + getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)), + getRandomBytesAsync: (byteCount: number) => + Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))), + digest: (algorithm: string, data: unknown) => { + if (!(data instanceof Uint8Array)) { + return Promise.reject(new TypeError("expo-crypto digest data must be a typed array.")); + } + return Promise.resolve( + new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer, + ); + }, +})); + +vi.mock("expo-secure-store", () => ({ + getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null), + setItemAsync: (key: string, value: string) => { + secureStore.set(key, value); + return Promise.resolve(); + }, +})); + +vi.mock("react-native", () => ({ + Platform: { + OS: "ios", + Version: "18.0", + }, +})); + +vi.mock("../../lib/runtime", () => ({ + mobileRuntime: { + runPromise: (operation: Effect.Effect) => + Effect.runPromise( + operation.pipe( + Effect.provide( + mobileManagedRelayClientLayer("https://relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), + ), + ), + ), + ), + }, +})); + +vi.mock("../../lib/storage", () => ({ + loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), + loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), + loadPreferences: vi.fn(() => Promise.resolve({})), +})); + +function proofIat(proof: string): number { + const payload = proof.split(".")[1]; + if (!payload) { + throw new Error("Missing DPoP payload."); + } + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + readonly iat: number; + }; + return decoded.iat; +} + +function savedConnection(): SavedRemoteConnection { + return { + environmentId: "env-1" as EnvironmentId, + environmentLabel: "Desktop", + pairingUrl: "https://desktop.example/pair", + displayUrl: "https://desktop.example", + httpBaseUrl: "https://desktop.example", + wsBaseUrl: "wss://desktop.example/ws", + bearerToken: "bearer-token", + }; +} + +const runRegistrationEffect = (effect: Effect.Effect): Promise => + Effect.runPromise( + effect.pipe( + Effect.provide( + mobileManagedRelayClientLayer("https://relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), + ), + ), + ), + ); + +async function waitForFetchCalls( + fetchMock: ReturnType, + count: number, +): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (fetchMock.mock.calls.length >= count) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe("makeRelayDeviceRegistrationRequest", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.stubGlobal("__DEV__", false); + secureStore.clear(); + Constants.expoConfig!.extra = {}; + __resetAgentAwarenessRemoteRegistrationForTest(); + }); + + it("preserves disabled Live Activity preferences in relay registrations", () => { + expect( + makeRelayDeviceRegistrationRequest({ + deviceId: "device-1", + label: "Julius's iPhone", + iosMajorVersion: 18, + appVersion: "1.0.0", + pushToken: "apns-token", + pushToStartToken: "push-to-start-token", + notificationsEnabled: true, + preferences: { + liveActivitiesEnabled: false, + }, + }), + ).toEqual({ + deviceId: "device-1", + label: "Julius's iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0", + pushToken: "apns-token", + pushToStartToken: "push-to-start-token", + preferences: { + liveActivitiesEnabled: false, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + }); + }); + + it("marks notification delivery disabled when APNs permission is unavailable", () => { + expect( + makeRelayDeviceRegistrationRequest({ + deviceId: "device-1", + label: "Julius's iPhone", + iosMajorVersion: 18, + appVersion: "1.0.0", + pushToStartToken: "push-to-start-token", + notificationsEnabled: false, + preferences: { + liveActivitiesEnabled: true, + }, + }), + ).toEqual({ + deviceId: "device-1", + label: "Julius's iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0", + pushToStartToken: "push-to-start-token", + preferences: { + liveActivitiesEnabled: true, + notificationsEnabled: false, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + }); + }); + + it("normalizes relay base URLs for APNs registration requests", () => { + expect(normalizeAgentAwarenessRelayBaseUrl(" https://relay.example.test/// ")).toBe( + "https://relay.example.test", + ); + expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); + }); + + it("registers at most one listener while a Live Activity push token is pending", async () => { + registerAgentAwarenessConnection(savedConnection()); + const addPushTokenListener = vi.fn(); + const activity = { + getPushToken: vi.fn(() => Promise.resolve(null)), + addPushTokenListener, + }; + + await expect( + runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), + ).resolves.toBe(false); + await expect( + runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), + ).resolves.toBe(false); + + expect(activity.getPushToken).toHaveBeenCalledTimes(2); + expect(addPushTokenListener).toHaveBeenCalledTimes(1); + }); + + it("reports Live Activity token registration as skipped when relay auth is unavailable", async () => { + registerAgentAwarenessConnection(savedConnection()); + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; + + await expect( + runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), + ).resolves.toBe(false); + }); + + it("refreshes APNs registration for connected environments after settings changes", async () => { + registerAgentAwarenessConnection(savedConnection()); + await new Promise((resolve) => setTimeout(resolve, 0)); + vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); + + await runRegistrationEffect(refreshAgentAwarenessRegistration()); + + expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); + }); + + it.effect("registers the APNs device when cloud auth becomes available", () => { + const fetchMock = vi.fn((request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(request); + return Promise.resolve( + Response.json( + url.endsWith("/v1/client/dpop-token") + ? { + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + } + : { ok: true }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [request, init] = fetchMock.mock.calls[1] as unknown as [ + unknown, + RequestInit | undefined, + ]; + const url = request instanceof Request ? request.url : String(request); + const method = request instanceof Request ? request.method : init?.method; + const headers = request instanceof Request ? request.headers : new Headers(init?.headers); + const dpop = headers.get("dpop"); + expect(url).toBe("https://relay.example.test/v1/mobile/devices"); + expect(method).toBe("POST"); + expect(headers.get("authorization")).toBe("DPoP relay-dpop-token"); + expect(dpop).toEqual(expect.any(String)); + if (!dpop) { + throw new Error("Missing DPoP header."); + } + expect( + verifyDpopProof({ + proof: dpop, + method: "POST", + url: "https://relay.example.test/v1/mobile/devices", + expectedAccessToken: "relay-dpop-token", + nowEpochSeconds: proofIat(dpop), + }), + ).toMatchObject({ ok: true }); + }); + }); + + it("only registers again when the authenticated identity changes", () => { + expect(shouldRegisterAgentAwarenessDeviceForProvider(null, "user-a")).toBe(true); + expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", "user-a")).toBe(false); + expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", "user-b")).toBe(true); + expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", undefined)).toBe(true); + }); + + it("registers rotated APNs tokens without rereading the native token", async () => { + const fetchMock = vi.fn((request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(request); + return Promise.resolve( + Response.json( + url.endsWith("/v1/client/dpop-token") + ? { + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + } + : { ok: true }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + + vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0]; + expect(tokenListener).toBeDefined(); + tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); + }); + + it.effect( + "keeps the user-scoped relay APNs device when an environment connection is removed", + () => { + const fetchMock = vi.fn((request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(request); + return Promise.resolve( + Response.json( + url.endsWith("/v1/client/dpop-token") + ? { + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + } + : { ok: true }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + + registerAgentAwarenessConnection(savedConnection()); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + return Effect.gen(function* () { + yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); + fetchMock.mockClear(); + + unregisterAgentAwarenessConnection(savedConnection().environmentId); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + }, + ); +}); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts new file mode 100644 index 00000000000..558cc9ec7ac --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -0,0 +1,447 @@ +import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets"; +import Constants from "expo-constants"; +import * as Notifications from "expo-notifications"; +import * as Effect from "effect/Effect"; +import { Platform } from "react-native"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { + type RelayDeviceRegistrationRequest, + type RelayLiveActivityRegistrationRequest, +} from "@t3tools/contracts/relay"; +import { ManagedRelayClient } from "@t3tools/client-runtime"; + +import type { SavedRemoteConnection } from "../../lib/connection"; +import { mobileRuntime } from "../../lib/runtime"; +import { + loadAgentAwarenessDeviceId, + loadOrCreateAgentAwarenessDeviceId, + loadPreferences, +} from "../../lib/storage"; +import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import { resolveCloudPublicConfig } from "../cloud/publicConfig"; +import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; + +const environmentConnections = new Map(); +const activityPushTokenListeners = new WeakSet>(); +let pushToStartSubscription: { remove: () => void } | null = null; +let pushTokenSubscription: { remove: () => void } | null = null; +let relayTokenProvider: (() => Promise) | null = null; +let relayTokenProviderIdentity: string | null = null; + +export function normalizeAgentAwarenessRelayBaseUrl( + value: string | null | undefined, +): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + return trimmed.replace(/\/+$/g, ""); +} + +function readRelayConfig(): { readonly url: string } | null { + const relayUrl = resolveCloudPublicConfig().relayUrl; + if (!relayUrl) { + logRegistrationDebug("relay registration skipped; relay config missing"); + return null; + } + + return { url: relayUrl }; +} + +function canRegisterRemoteLiveActivities(): boolean { + return Platform.OS === "ios"; +} + +export function shouldRegisterAgentAwarenessDeviceForProvider( + previousIdentity: string | null, + identity: string | undefined, +): boolean { + return identity === undefined || identity !== previousIdentity; +} + +export function setAgentAwarenessRelayTokenProvider( + provider: (() => Promise) | null, + identity?: string, +): void { + const isExistingIdentity = + provider !== null && + !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity); + relayTokenProvider = provider; + relayTokenProviderIdentity = provider ? (identity ?? null) : null; + if (!provider) { + pushToStartSubscription?.remove(); + pushToStartSubscription = null; + pushTokenSubscription?.remove(); + pushTokenSubscription = null; + return; + } + ensurePushToStartListener(); + ensurePushTokenListener(); + if (isExistingIdentity) { + return; + } + runRegistrationInBackground(registerDevice(), "device registration after cloud sign-in failed"); +} + +function iosMajorVersion(): number { + const version = Platform.Version; + if (typeof version === "number") { + return Math.floor(version); + } + const major = Number.parseInt(version.split(".")[0] ?? "", 10); + return Number.isFinite(major) ? major : 18; +} + +function nativePushTokenRegistration(observedPushToken?: string) { + return Effect.gen(function* () { + if (!canRegisterRemoteLiveActivities()) { + return { notificationsEnabled: false, pushToken: null }; + } + if (observedPushToken) { + return { notificationsEnabled: true, pushToken: observedPushToken }; + } + const permissions = yield* Effect.tryPromise({ + try: () => Notifications.getPermissionsAsync(), + catch: (error) => error, + }); + if (!permissions.granted) { + return { notificationsEnabled: false, pushToken: null }; + } + const token = yield* Effect.tryPromise({ + try: () => Notifications.getDevicePushTokenAsync(), + catch: (error) => error, + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + logRegistrationError("native APNs token lookup failed", error); + }), + ), + Effect.orElseSucceed(() => null), + ); + const pushToken = + token?.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0 + ? token.data.trim() + : null; + return { notificationsEnabled: pushToken !== null, pushToken }; + }); +} + +const relayToken = Effect.gen(function* () { + const provider = relayTokenProvider; + if (!provider) { + return null; + } + return yield* Effect.tryPromise({ + try: provider, + catch: (error) => error, + }); +}); + +function registerDeviceWithRelay( + body: RelayDeviceRegistrationRequest, +): Effect.Effect { + return Effect.gen(function* () { + if (!readRelayConfig()) return; + const token = yield* relayToken; + if (!token) { + logRegistrationDebug("relay device registration skipped; user is not signed in"); + return; + } + + const client = yield* ManagedRelayClient; + yield* client.registerDevice({ + clerkToken: token, + payload: body, + }); + }); +} + +function unregisterDeviceWithRelay(input: { + readonly deviceId: string; + readonly tokenProvider: () => Promise; +}): Effect.Effect { + return Effect.gen(function* () { + if (!readRelayConfig()) return; + const token = yield* Effect.tryPromise({ + try: input.tokenProvider, + catch: (error) => error, + }); + if (!token) { + logRegistrationDebug("relay device unregistration skipped; user is not signed in"); + return; + } + + const client = yield* ManagedRelayClient; + yield* client.unregisterDevice({ + clerkToken: token, + deviceId: input.deviceId, + }); + }); +} + +function registerLiveActivityWithRelay( + body: RelayLiveActivityRegistrationRequest, +): Effect.Effect { + return Effect.gen(function* () { + if (!readRelayConfig()) return false; + const token = yield* relayToken; + if (!token) { + logRegistrationDebug("relay live activity registration skipped; user is not signed in"); + return false; + } + + const client = yield* ManagedRelayClient; + yield* client.registerLiveActivity({ + clerkToken: token, + payload: body, + }); + return true; + }); +} + +function logRegistrationError(context: string, error: unknown): void { + if (!__DEV__) { + return; + } + console.warn( + `[agent-awareness] ${context}`, + error instanceof Error ? error.message : String(error), + ); +} + +function logRegistrationDebug(context: string, details?: unknown): void { + if (!__DEV__) { + return; + } + console.log(`[agent-awareness] ${context}`, details ?? ""); +} + +function runRegistrationInBackground( + operation: Effect.Effect, + context: string, +): void { + void mobileRuntime.runPromise(operation).catch((error: unknown) => { + logRegistrationError(context, error); + }); +} + +function registerDevice(input?: { + readonly pushToStartToken?: string; + readonly observedPushToken?: string; +}): Effect.Effect { + return Effect.gen(function* () { + if (!canRegisterRemoteLiveActivities()) { + return; + } + + const [deviceId, preferences] = yield* Effect.all([ + Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (error) => error, + }), + Effect.tryPromise({ + try: () => loadPreferences(), + catch: (error) => error, + }), + ]); + const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); + yield* registerDeviceWithRelay( + makeRelayDeviceRegistrationRequest({ + deviceId, + label: Constants.deviceName?.trim() || "iOS device", + iosMajorVersion: iosMajorVersion(), + appVersion: Constants.expoConfig?.version, + ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), + ...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), + notificationsEnabled: pushTokenRegistration.notificationsEnabled, + preferences, + }), + ); + }); +} + +function registerDeviceForCurrentUser( + pushToStartToken?: string, +): Effect.Effect { + return registerDevice(pushToStartToken ? { pushToStartToken } : undefined); +} + +function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void { + runRegistrationInBackground( + registerDeviceForCurrentUser(pushToStartToken), + "push-to-start token registration failed", + ); +} + +function ensurePushToStartListener(): void { + if (pushToStartSubscription || !canRegisterRemoteLiveActivities()) { + return; + } + + pushToStartSubscription = addPushToStartTokenListener((event) => { + const token = event.activityPushToStartToken; + if (token) { + registerPushToStartTokenForCurrentUser(token); + } + }); +} + +function ensurePushTokenListener(): void { + if (pushTokenSubscription || !canRegisterRemoteLiveActivities()) { + return; + } + + pushTokenSubscription = Notifications.addPushTokenListener((token) => { + if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) { + runRegistrationInBackground( + registerDevice({ observedPushToken: token.data.trim() }), + "native APNs token rotation registration failed", + ); + } + }); +} + +export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void { + if (!canRegisterRemoteLiveActivities()) { + return; + } + + environmentConnections.set(connection.environmentId, connection); + ensurePushToStartListener(); + ensurePushTokenListener(); + runRegistrationInBackground(registerDevice(), "device registration failed"); +} + +function removeAgentAwarenessConnection(environmentId: EnvironmentId): void { + environmentConnections.delete(environmentId); +} + +export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId): void { + removeAgentAwarenessConnection(environmentId); +} + +export function unregisterAllAgentAwarenessConnections(): void { + environmentConnections.clear(); + pushToStartSubscription?.remove(); + pushToStartSubscription = null; + pushTokenSubscription?.remove(); + pushTokenSubscription = null; +} + +export function refreshAgentAwarenessRegistration(): Effect.Effect< + void, + never, + ManagedRelayClient +> { + return registerDeviceForCurrentUser().pipe( + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("device registration refresh failed", error); + }), + ), + ); +} + +export function __resetAgentAwarenessRemoteRegistrationForTest(): void { + environmentConnections.clear(); + pushToStartSubscription?.remove(); + pushToStartSubscription = null; + pushTokenSubscription?.remove(); + pushTokenSubscription = null; + relayTokenProvider = null; + relayTokenProviderIdentity = null; +} + +export function unregisterAgentAwarenessDeviceForCurrentUser( + tokenProvider: () => Promise, +): Effect.Effect { + return Effect.gen(function* () { + const deviceId = yield* Effect.tryPromise({ + try: () => loadAgentAwarenessDeviceId(), + catch: (error) => error, + }); + if (!deviceId) { + return; + } + yield* unregisterDeviceWithRelay({ deviceId, tokenProvider }); + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + logRegistrationError("device unregistration failed", error); + }), + ), + ); +} + +export function registerLiveActivityPushToken(input: { + readonly activity: LiveActivity; +}): Effect.Effect { + return Effect.gen(function* () { + if (!canRegisterRemoteLiveActivities()) { + return false; + } + + const activityPushToken = yield* Effect.tryPromise({ + try: () => input.activity.getPushToken(), + catch: (error) => error, + }); + if (!activityPushToken) { + if (activityPushTokenListeners.has(input.activity)) { + logRegistrationDebug( + "live activity push token not available yet; token listener already registered", + { + connectionCount: environmentConnections.size, + }, + ); + return false; + } + + logRegistrationDebug( + "live activity push token not available yet; listening for token event", + { + connectionCount: environmentConnections.size, + }, + ); + activityPushTokenListeners.add(input.activity); + input.activity.addPushTokenListener((event) => { + if (event.pushToken) { + logRegistrationDebug("live activity push token event received", { + tokenSuffix: event.pushToken.slice(-8), + }); + runRegistrationInBackground( + registerLiveActivityPushTokenValue({ + activityPushToken: event.pushToken, + }), + "live activity token listener registration failed", + ); + } + }); + return false; + } + + return yield* registerLiveActivityPushTokenValue({ + activityPushToken, + }); + }); +} + +function registerLiveActivityPushTokenValue(input: { + readonly activityPushToken: string; +}): Effect.Effect { + return Effect.gen(function* () { + const deviceId = yield* Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (error) => error, + }); + const registered = yield* registerLiveActivityWithRelay({ + deviceId, + activityPushToken: input.activityPushToken, + }); + if (registered) { + logRegistrationDebug("live activity push token registered", { + tokenSuffix: input.activityPushToken.slice(-8), + }); + } + return registered; + }); +} diff --git a/apps/mobile/src/features/agent-awareness/shellLiveActivitySync.ts b/apps/mobile/src/features/agent-awareness/shellLiveActivitySync.ts new file mode 100644 index 00000000000..44c720d0e77 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/shellLiveActivitySync.ts @@ -0,0 +1,59 @@ +import { shellSnapshotStateAtom } from "@t3tools/client-runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import type { SavedRemoteConnection } from "../../lib/connection"; +import { mobileRuntime } from "../../lib/runtime"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { + endAllAgentLiveActivities, + endEnvironmentLiveActivities, + syncAgentLiveActivitiesForSnapshot, +} from "./liveActivityController"; +import { + registerAgentAwarenessConnection, + unregisterAgentAwarenessConnection, + unregisterAllAgentAwarenessConnections, +} from "./remoteRegistration"; + +const environmentUnsubscribers = new Map void>(); + +export function startAgentAwarenessForEnvironment(connection: SavedRemoteConnection): void { + const { environmentId } = connection; + if (environmentUnsubscribers.has(environmentId)) { + return; + } + + registerAgentAwarenessConnection(connection); + + const sync = () => { + const state = appAtomRegistry.get(shellSnapshotStateAtom(environmentId)); + void mobileRuntime + .runPromise( + syncAgentLiveActivitiesForSnapshot({ + environmentId, + snapshot: state.data, + }), + ) + .catch(() => undefined); + }; + + const unsubscribe = appAtomRegistry.subscribe(shellSnapshotStateAtom(environmentId), sync); + environmentUnsubscribers.set(environmentId, unsubscribe); + sync(); +} + +export function stopAgentAwarenessForEnvironment(environmentId: EnvironmentId): void { + environmentUnsubscribers.get(environmentId)?.(); + environmentUnsubscribers.delete(environmentId); + unregisterAgentAwarenessConnection(environmentId); + void mobileRuntime.runPromise(endEnvironmentLiveActivities(environmentId)).catch(() => undefined); +} + +export function stopAllAgentAwareness(): void { + for (const unsubscribe of environmentUnsubscribers.values()) { + unsubscribe(); + } + environmentUnsubscribers.clear(); + unregisterAllAgentAwarenessConnections(); + void mobileRuntime.runPromise(endAllAgentLiveActivities()).catch(() => undefined); +} diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx new file mode 100644 index 00000000000..470bd130e90 --- /dev/null +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -0,0 +1,93 @@ +import { ClerkProvider, useAuth } from "@clerk/expo"; +import { tokenCache } from "@clerk/expo/token-cache"; +import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; +import { type ReactNode, useEffect, useRef } from "react"; + +import { mobileRuntime } from "../../lib/runtime"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { + setAgentAwarenessRelayTokenProvider, + unregisterAgentAwarenessDeviceForCurrentUser, +} from "../agent-awareness/remoteRegistration"; +import { refreshActiveLiveActivityRemoteRegistration } from "../agent-awareness/liveActivityController"; +import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; + +function CloudAuthBridge(props: { readonly children: ReactNode }) { + const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const previousTokenProviderRef = useRef<{ + readonly userId: string; + readonly provider: () => Promise; + } | null>(null); + + useEffect(() => { + if (!isLoaded) { + return; + } + if (!isSignedIn || !userId) { + const previous = previousTokenProviderRef.current; + previousTokenProviderRef.current = null; + if (previous) { + void mobileRuntime + .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) + .catch(() => undefined); + } + setAgentAwarenessRelayTokenProvider(null); + setManagedRelaySession(appAtomRegistry, null); + return; + } + + const previous = previousTokenProviderRef.current; + if (previous && previous.userId !== userId) { + void mobileRuntime + .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) + .catch(() => undefined); + } + const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); + previousTokenProviderRef.current = { userId, provider: tokenProvider }; + setAgentAwarenessRelayTokenProvider(tokenProvider, userId); + setManagedRelaySession( + appAtomRegistry, + createManagedRelaySession({ + accountId: userId, + readClerkToken: tokenProvider, + }), + ); + if (!previous || previous.userId !== userId) { + void mobileRuntime + .runPromise(refreshActiveLiveActivityRemoteRegistration()) + .catch(() => undefined); + } + }, [getToken, isLoaded, isSignedIn, userId]); + + useEffect( + () => () => { + previousTokenProviderRef.current = null; + setAgentAwarenessRelayTokenProvider(null); + setManagedRelaySession(appAtomRegistry, null); + }, + [], + ); + + return props.children; +} + +export function CloudAuthProvider(props: { readonly children: ReactNode }) { + const { clerkPublishableKey: publishableKey, relayUrl } = resolveCloudPublicConfig(); + + useEffect(() => { + if (!publishableKey || !relayUrl) { + setAgentAwarenessRelayTokenProvider(null); + setManagedRelaySession(appAtomRegistry, null); + } + }, [publishableKey, relayUrl]); + + if (!publishableKey || !relayUrl) { + return props.children; + } + + return ( + + {props.children} + + ); +} diff --git a/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx new file mode 100644 index 00000000000..1528a8fb97f --- /dev/null +++ b/apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx @@ -0,0 +1,208 @@ +import { useWaitlist } from "@clerk/expo"; +import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; +import { useState } from "react"; + +import { useThemeColor } from "../../lib/useThemeColor"; + +export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { + const { errors, fetchStatus, waitlist } = useWaitlist(); + const colors = useCloudWaitlistColors(); + const [emailAddress, setEmailAddress] = useState(""); + const [requestError, setRequestError] = useState(null); + const isSubmitting = fetchStatus === "fetching"; + const fieldError = errors.fields.emailAddress?.longMessage; + + const joinWaitlist = async () => { + const normalizedEmailAddress = emailAddress.trim(); + if (!normalizedEmailAddress || isSubmitting) { + return; + } + + setRequestError(null); + try { + const { error } = await waitlist.join({ emailAddress: normalizedEmailAddress }); + if (error) { + setRequestError("Could not join the waitlist. Check your email address and try again."); + } + } catch { + setRequestError("Could not join the waitlist. Check your connection and try again."); + } + }; + + if (waitlist.id) { + return ( + + You are on the waitlist + + We will email you when your T3 Cloud access is ready. + + + + ); + } + + return ( + + + Enter your email and we will let you know when access is ready. + + + + Email address + { + setEmailAddress(value); + setRequestError(null); + }} + onSubmitEditing={() => void joinWaitlist()} + placeholder="Enter your email address" + placeholderTextColor={colors.placeholder} + returnKeyType="join" + style={[ + styles.input, + { + backgroundColor: colors.input, + borderColor: + fieldError || requestError ? colors.dangerForeground : colors.inputBorder, + color: colors.foreground, + }, + ]} + textContentType="emailAddress" + value={emailAddress} + /> + {fieldError || requestError ? ( + + {fieldError ?? requestError} + + ) : null} + + + void joinWaitlist()} + style={[ + styles.primaryButton, + { + backgroundColor: colors.primary, + opacity: isSubmitting || emailAddress.trim().length === 0 ? 0.45 : 1, + }, + ]} + > + {isSubmitting ? : null} + + {isSubmitting ? "Joining" : "Join the waitlist"} + + + + + + ); +} + +function SignInAction(props: { readonly onPress: () => void }) { + const colors = useCloudWaitlistColors(); + return ( + + Already have access? + + Sign in + + + ); +} + +function useCloudWaitlistColors() { + return { + dangerForeground: String(useThemeColor("--color-danger-foreground")), + foreground: String(useThemeColor("--color-foreground")), + input: String(useThemeColor("--color-input")), + inputBorder: String(useThemeColor("--color-input-border")), + placeholder: String(useThemeColor("--color-placeholder")), + primary: String(useThemeColor("--color-primary")), + primaryForeground: String(useThemeColor("--color-primary-foreground")), + secondaryForeground: String(useThemeColor("--color-foreground-secondary")), + }; +} + +const styles = StyleSheet.create({ + body: { + fontFamily: "DMSans_400Regular", + fontSize: 15, + lineHeight: 21, + }, + buttonText: { + fontFamily: "DMSans_700Bold", + fontSize: 16, + }, + content: { + gap: 18, + }, + confirmationBody: { + textAlign: "center", + }, + error: { + fontFamily: "DMSans_400Regular", + fontSize: 13, + lineHeight: 18, + }, + field: { + gap: 8, + }, + input: { + borderCurve: "continuous", + borderRadius: 16, + borderWidth: 1, + fontFamily: "DMSans_400Regular", + fontSize: 17, + minHeight: 54, + paddingHorizontal: 16, + paddingVertical: 14, + }, + label: { + fontFamily: "DMSans_700Bold", + fontSize: 13, + lineHeight: 18, + }, + primaryButton: { + alignItems: "center", + borderRadius: 999, + flexDirection: "row", + gap: 8, + justifyContent: "center", + minHeight: 54, + paddingHorizontal: 20, + paddingVertical: 12, + }, + signInRow: { + alignItems: "center", + flexDirection: "row", + gap: 4, + justifyContent: "center", + paddingTop: 4, + }, + signInText: { + fontFamily: "DMSans_700Bold", + fontSize: 15, + lineHeight: 21, + }, + title: { + fontFamily: "DMSans_700Bold", + fontSize: 20, + lineHeight: 26, + textAlign: "center", + }, +}); diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts new file mode 100644 index 00000000000..ccab9ac047d --- /dev/null +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -0,0 +1,166 @@ +/// + +import * as NodeCrypto from "node:crypto"; + +import { vi } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import { verifyDpopProof } from "@t3tools/shared/dpop"; + +import { + createDpopProof, + generateDpopProofKeyPair, + loadOrCreateDpopProofKeyPair, + mobileCryptoLayer, +} from "./dpop"; + +vi.mock("expo-crypto", () => ({ + CryptoDigestAlgorithm: { + SHA1: "SHA-1", + SHA256: "SHA-256", + SHA384: "SHA-384", + SHA512: "SHA-512", + }, + getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)), + getRandomBytesAsync: (byteCount: number) => + Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))), + digest: (algorithm: string, data: unknown) => { + if (!(data instanceof Uint8Array)) { + return Promise.reject(new TypeError("expo-crypto digest data must be a typed array.")); + } + return Promise.resolve( + new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer, + ); + }, +})); + +const secureStore = new Map(); +vi.mock("expo-secure-store", () => ({ + getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null), + setItemAsync: (key: string, value: string) => { + secureStore.set(key, value); + return Promise.resolve(); + }, +})); + +function proofIat(proof: string): number { + const payload = proof.split(".")[1]; + if (!payload) { + throw new Error("Missing DPoP payload."); + } + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + readonly iat: number; + }; + return decoded.iat; +} + +function proofHtu(proof: string): string { + const payload = proof.split(".")[1]; + if (!payload) { + throw new Error("Missing DPoP payload."); + } + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + readonly htu: string; + }; + return decoded.htu; +} + +describe("mobile DPoP", () => { + it.effect("passes typed-array digest input through the Expo Crypto adapter", () => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode("typed-array")); + + expect(Buffer.from(digest).toString("hex")).toBe( + NodeCrypto.createHash("sha256").update("typed-array").digest("hex"), + ); + }).pipe(Effect.provide(mobileCryptoLayer)), + ); + + it.effect("persists and reuses the installation proof key", () => + Effect.gen(function* () { + secureStore.clear(); + const first = yield* loadOrCreateDpopProofKeyPair(); + const second = yield* loadOrCreateDpopProofKeyPair(); + + expect(second.thumbprint).toBe(first.thumbprint); + expect(second.privateJwk).toEqual(first.privateJwk); + }).pipe(Effect.provide(mobileCryptoLayer)), + ); + + it.effect("rejects malformed persisted proof keys", () => + Effect.gen(function* () { + secureStore.set("t3code.cloud.dpop-proof-key", `{"kty":"EC","crv":"P-256","d":42}`); + + const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); + + expect(error.message).toBe("Stored DPoP proof key is invalid."); + }).pipe(Effect.provide(mobileCryptoLayer)), + ); + + it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () => + Effect.gen(function* () { + const proofKey = yield* generateDpopProofKeyPair(); + const connect = yield* createDpopProof({ + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/connect", + accessToken: "clerk-token", + proofKey, + }); + const bootstrap = yield* createDpopProof({ + method: "POST", + url: "https://desktop.example.test/oauth/token", + proofKey, + }); + + expect(connect.thumbprint).toBe(proofKey.thumbprint); + expect(bootstrap.thumbprint).toBe(proofKey.thumbprint); + expect( + verifyDpopProof({ + proof: connect.proof, + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/connect", + expectedThumbprint: proofKey.thumbprint, + expectedAccessToken: "clerk-token", + nowEpochSeconds: proofIat(connect.proof), + }), + ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint }); + expect( + verifyDpopProof({ + proof: bootstrap.proof, + method: "POST", + url: "https://desktop.example.test/oauth/token", + expectedThumbprint: proofKey.thumbprint, + nowEpochSeconds: proofIat(bootstrap.proof), + }), + ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint }); + }).pipe(Effect.provide(mobileCryptoLayer)), + ); + + it.effect("signs DPoP proofs with RFC 9449 htu normalization", () => + Effect.gen(function* () { + const proofKey = yield* generateDpopProofKeyPair(); + const proof = yield* createDpopProof({ + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored", + accessToken: "clerk-token", + proofKey, + }); + + expect(proofHtu(proof.proof)).toBe( + "https://relay.example.test/v1/environments/env-1/connect", + ); + expect( + verifyDpopProof({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored", + expectedThumbprint: proofKey.thumbprint, + expectedAccessToken: "clerk-token", + nowEpochSeconds: proofIat(proof.proof), + }), + ).toMatchObject({ ok: true }); + }).pipe(Effect.provide(mobileCryptoLayer)), + ); +}); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts new file mode 100644 index 00000000000..0a3d7c2a5a7 --- /dev/null +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -0,0 +1,270 @@ +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as ExpoCrypto from "expo-crypto"; +import * as SecureStore from "expo-secure-store"; +import { p256 } from "@noble/curves/nist"; +import { + computeDpopAccessTokenHash, + computeDpopJwkThumbprint, + DpopPublicJwk, + normalizeDpopHtu, +} from "@t3tools/shared/dpop"; +import * as Layer from "effect/Layer"; + +export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +function cloudDpopError(message: string) { + return (cause: unknown) => new CloudDpopError({ message, cause }); +} + +const DpopPrivateJwkSchema = Schema.Struct({ + ...DpopPublicJwk.fields, + d: Schema.String, +}); + +const DpopPrivateJwkJson = Schema.fromJsonString(DpopPrivateJwkSchema); +const decodeDpopPrivateJwkJson = Schema.decodeUnknownEffect(DpopPrivateJwkJson); +const encodeDpopPrivateJwkJson = Schema.encodeEffect(DpopPrivateJwkJson); + +const DpopJwtHeaderJson = Schema.fromJsonString( + Schema.Struct({ + typ: Schema.Literal("dpop+jwt"), + alg: Schema.Literal("ES256"), + jwk: DpopPublicJwk, + }), +); + +const DpopJwtPayloadJson = Schema.fromJsonString( + Schema.Struct({ + htm: Schema.String, + htu: Schema.String, + jti: Schema.String, + iat: Schema.Int, + ath: Schema.optionalKey(Schema.String), + }), +); + +const encodeDpopJwtHeaderJson = Schema.encodeEffect(DpopJwtHeaderJson); +const encodeDpopJwtPayloadJson = Schema.encodeEffect(DpopJwtPayloadJson); + +function toExpoDigestAlgorithm( + algorithm: Crypto.DigestAlgorithm, +): ExpoCrypto.CryptoDigestAlgorithm { + switch (algorithm) { + case "SHA-1": + return ExpoCrypto.CryptoDigestAlgorithm.SHA1; + case "SHA-256": + return ExpoCrypto.CryptoDigestAlgorithm.SHA256; + case "SHA-384": + return ExpoCrypto.CryptoDigestAlgorithm.SHA384; + case "SHA-512": + return ExpoCrypto.CryptoDigestAlgorithm.SHA512; + } +} + +export const mobileCryptoLayer = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: ExpoCrypto.getRandomBytes, + digest: (algorithm, data) => + Effect.promise(async () => { + const input = new Uint8Array(data.length); + input.set(data); + return new Uint8Array(await ExpoCrypto.digest(toExpoDigestAlgorithm(algorithm), input)); + }), + }), +); + +type DpopPrivateJwk = typeof DpopPrivateJwkSchema.Type; + +export interface DpopProofKeyPair { + readonly privateJwk: DpopPrivateJwk; + readonly publicJwk: DpopPublicJwk; + readonly thumbprint: string; +} + +const DPOP_PROOF_KEY_STORAGE_KEY = "t3code.cloud.dpop-proof-key"; + +function base64UrlToBytes(value: string): Uint8Array { + return Result.getOrThrow(Encoding.decodeBase64Url(value)); +} + +function sha256Digest( + data: Uint8Array, + message: string, +): Effect.Effect { + return Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.digest("SHA-256", data)), + Effect.mapError(cloudDpopError(message)), + ); +} + +function secureRandomBytes( + byteCount: number, + message: string, +): Effect.Effect { + return Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomBytes(byteCount)), + Effect.mapError(cloudDpopError(message)), + ); +} + +function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { + if (publicKey.length !== 65 || publicKey[0] !== 0x04) { + throw new Error("Generated DPoP public key is not an uncompressed P-256 point."); + } + return { + kty: "EC", + crv: "P-256", + x: Encoding.encodeBase64Url(publicKey.slice(1, 33)), + y: Encoding.encodeBase64Url(publicKey.slice(33, 65)), + }; +} + +function privateJwkFromPrivateKey( + privateKey: Uint8Array, + publicJwk: DpopPublicJwk, +): DpopPrivateJwk { + return { ...publicJwk, d: Encoding.encodeBase64Url(privateKey) }; +} + +export function generateDpopProofKeyPair(): Effect.Effect< + DpopProofKeyPair, + CloudDpopError, + Crypto.Crypto +> { + return Effect.gen(function* () { + let privateKey: Uint8Array; + do { + privateKey = yield* secureRandomBytes( + p256.CURVE.nByteLength, + "Could not generate DPoP key pair randomness.", + ); + } while (!p256.utils.isValidPrivateKey(privateKey)); + const publicJwk = yield* Effect.try({ + try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), + catch: cloudDpopError("Generated DPoP public key is invalid."), + }); + const thumbprint = computeDpopJwkThumbprint(publicJwk); + return { + privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk), + publicJwk, + thumbprint, + }; + }); +} + +export function loadOrCreateDpopProofKeyPair(): Effect.Effect< + DpopProofKeyPair, + CloudDpopError, + Crypto.Crypto +> { + return Effect.gen(function* () { + const stored = yield* Effect.tryPromise({ + try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY), + catch: cloudDpopError("Could not read the DPoP proof key."), + }); + if (stored) { + const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe( + Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")), + ); + const restored = yield* Effect.try({ + try: () => { + const privateKey = base64UrlToBytes(storedPrivateJwk.d); + const publicJwk = publicJwkFromUncompressedPublicKey( + p256.getPublicKey(privateKey, false), + ); + if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) { + throw new Error("Stored DPoP key does not match its public key."); + } + return { privateJwk: storedPrivateJwk, publicJwk }; + }, + catch: cloudDpopError("Stored DPoP proof key is invalid."), + }); + return { + ...restored, + thumbprint: computeDpopJwkThumbprint(restored.publicJwk), + }; + } + const generated = yield* generateDpopProofKeyPair(); + const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe( + Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")), + ); + yield* Effect.tryPromise({ + try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk), + catch: cloudDpopError("Could not store the DPoP proof key."), + }); + return generated; + }); +} + +function normalizeHtu(url: string): Effect.Effect { + const normalized = normalizeDpopHtu(url); + return normalized + ? Effect.succeed(normalized) + : Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." })); +} + +export function createDpopProof(input: { + readonly method: string; + readonly url: string; + readonly accessToken?: string; + readonly proofKey?: DpopProofKeyPair; +}): Effect.Effect< + { readonly proof: string; readonly thumbprint: string }, + CloudDpopError, + Crypto.Crypto +> { + return Effect.gen(function* () { + const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair()); + const privateKey = yield* Effect.try({ + try: () => base64UrlToBytes(keyPair.privateJwk.d), + catch: cloudDpopError("Could not import DPoP private key."), + }); + const nowMs = yield* Clock.currentTimeMillis; + const jti = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4), + Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")), + ); + const htu = yield* normalizeHtu(input.url); + const header = yield* encodeDpopJwtHeaderJson({ + typ: "dpop+jwt", + alg: "ES256", + jwk: keyPair.publicJwk, + }).pipe( + Effect.map(Encoding.encodeBase64Url), + Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")), + ); + const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null; + const payload = yield* encodeDpopJwtPayloadJson({ + htm: input.method.toUpperCase(), + htu, + jti, + iat: Math.floor(nowMs / 1_000), + ...(ath ? { ath } : {}), + }).pipe( + Effect.map(Encoding.encodeBase64Url), + Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")), + ); + const signatureInputHash = yield* sha256Digest( + new TextEncoder().encode(`${header}.${payload}`), + "Could not hash DPoP signing input.", + ); + const signature = yield* Effect.try({ + try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(), + catch: cloudDpopError("Could not sign DPoP proof."), + }); + return { + proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`, + thumbprint: keyPair.thumbprint, + }; + }); +} diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts new file mode 100644 index 00000000000..f99a5addcce --- /dev/null +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -0,0 +1,1107 @@ +import { beforeEach, vi } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayMobileClientId } from "@t3tools/contracts/relay"; +import { + managedRelayClientLayer, + ManagedRelayClient, + ManagedRelayDpopSigner, + remoteHttpClientLayer, +} from "@t3tools/client-runtime"; +import { HttpClient } from "effect/unstable/http"; + +import { + cloudEnvironmentsPendingStatus, + linkEnvironmentToCloud, + connectCloudEnvironment, + listCloudEnvironments, + listCloudEnvironmentsWithStatus, + normalizeRelayBaseUrl, + refreshCloudEnvironmentConnection, +} from "./linkEnvironment"; + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + extra: { + relay: { + url: "https://relay.example.test", + }, + }, + }, + }, +})); + +vi.mock("react-native", () => ({ + Platform: { + OS: "ios", + }, +})); + +vi.mock("../../lib/storage", () => ({ + loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), + loadPreferences: vi.fn(() => Promise.resolve({})), +})); + +const savedConnection = { + environmentId: EnvironmentId.make("env-1"), + environmentLabel: "Desktop", + pairingUrl: "https://desktop.example.test/", + displayUrl: "https://desktop.example.test/", + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + bearerToken: "local-bearer", +}; + +const createProofMock = vi.fn( + (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => + Effect.succeed(`dpop:${input.method}:${input.url}`), +); +const testDpopSignerLayer = Layer.succeed( + ManagedRelayDpopSigner, + ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("client-proof-key-thumbprint"), + createProof: (input) => createProofMock(input), + }), +); + +function cloudClientLayer() { + const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); + return Layer.mergeAll( + httpClientLayer, + managedRelayClientLayer({ + relayUrl: "https://relay.example.test", + clientId: RelayMobileClientId, + }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)), + ); +} + +const withCloudServices = ( + effect: Effect.Effect, +) => effect.pipe(Effect.provide(cloudClientLayer())); + +function validLinkProof() { + return "signed-environment-link-jwt"; +} + +function validLinkResponse(environmentId = "env-1") { + return { + ok: true, + environmentId, + endpoint: { + httpBaseUrl: "https://managed.example.test/", + wsBaseUrl: "wss://managed.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + }, + relayIssuer: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "environment-credential", + cloudMintPublicKey: "cloud-mint-public-key", + }; +} + +function validLinkChallengeResponse() { + return { + challenge: "link-challenge", + expiresAt: "2026-05-25T00:05:00.000Z", + }; +} + +function requestBodyText(body: BodyInit | null | undefined): string { + return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); +} + +function validDpopAccessTokenResponse(scope = "environment:status environment:connect") { + return { + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope, + }; +} + +function listedEnvironment(environmentId: string) { + return { + environmentId: EnvironmentId.make(environmentId), + label: "Desktop", + endpoint: { + httpBaseUrl: `https://${environmentId}.example.test/`, + wsBaseUrl: `wss://${environmentId}.example.test/ws`, + providerKind: "cloudflare_tunnel" as const, + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }; +} + +describe("mobile cloud link environment client", () => { + beforeEach(() => { + vi.restoreAllMocks(); + createProofMock.mockClear(); + }); + + it("normalizes configured relay base URLs before building DPoP-bound requests", () => { + expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( + "https://relay.example.test", + ); + expect(normalizeRelayBaseUrl(" ")).toBeNull(); + }); + + it("makes linked environments visible while their status is still loading", () => { + expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([ + { + environment: { environmentId: "env-1", label: "Desktop" }, + status: null, + statusError: "Checking status...", + }, + ]); + }); + + it.effect("decodes relay environment list responses before returning records", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + Response.json({ + environments: [ + { + environmentId: "env-1", + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + ], + }), + ), + ), + ); + + const records = yield* withCloudServices( + listCloudEnvironments({ clerkToken: "clerk-token" }), + ); + expect(records).toEqual([ + { + environmentId: "env-1", + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + ]); + }), + ); + + it.effect("rejects malformed relay environment list responses", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + Response.json({ + environments: [ + { + environmentId: "env-1", + label: "Desktop", + endpoint: { + httpBaseUrl: "", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + ], + }), + ), + ), + ); + + const error = yield* withCloudServices( + listCloudEnvironments({ clerkToken: "clerk-token" }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "https://relay.example.test/v1/environments failed", + }); + }), + ); + + it.effect("loads signed status for each advertised cloud environment", () => + Effect.gen(function* () { + const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => { + if (String(url) === "https://relay.example.test/v1/environments") { + return Promise.resolve( + Response.json({ + environments: [ + { + environmentId: "env-1", + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + ], + }), + ); + } + if (String(url) === "https://relay.example.test/v1/client/dpop-token") { + return Promise.resolve(Response.json(validDpopAccessTokenResponse())); + } + expect(String(url)).toBe("https://relay.example.test/v1/environments/env-1/status"); + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-05-25T00:01:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const records = yield* withCloudServices( + listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }), + ); + expect(records).toMatchObject([ + { + environment: { + environmentId: "env-1", + label: "Desktop", + }, + status: { + environmentId: "env-1", + status: "online", + checkedAt: "2026-05-25T00:01:00.000Z", + }, + statusError: null, + }, + ]); + expect(String(fetchMock.mock.calls[2]?.[0])).toBe( + "https://relay.example.test/v1/environments/env-1/status", + ); + expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST"); + const statusHeaders = new Headers(fetchMock.mock.calls[2]?.[1]?.headers); + expect(statusHeaders.get("authorization")).toBe("DPoP relay-dpop-token"); + expect(statusHeaders.get("dpop")).toBe( + "dpop:POST:https://relay.example.test/v1/environments/env-1/status", + ); + expect(createProofMock).toHaveBeenCalledWith({ + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/status", + accessToken: "relay-dpop-token", + }); + }), + ); + + it.effect("reuses one valid DPoP access token while probing multiple environment statuses", () => + Effect.gen(function* () { + const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => { + if (String(url).endsWith("/v1/environments")) { + return Promise.resolve( + Response.json({ + environments: [listedEnvironment("env-1"), listedEnvironment("env-2")], + }), + ); + } + if (String(url).endsWith("/v1/client/dpop-token")) { + return Promise.resolve(Response.json(validDpopAccessTokenResponse())); + } + const environmentId = String(url).includes("/env-1/") ? "env-1" : "env-2"; + return Promise.resolve( + Response.json({ + environmentId, + endpoint: listedEnvironment(environmentId).endpoint, + status: "online", + checkedAt: "2026-05-25T00:01:00.000Z", + descriptor: { + environmentId, + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" })); + + expect( + fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), + ).toHaveLength(1); + }), + ); + + it.effect("reuses the status-and-connect token when connecting from the cloud list", () => + Effect.gen(function* () { + const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => { + if (String(url).endsWith("/v1/environments")) { + return Promise.resolve( + Response.json({ + environments: [listedEnvironment("env-1")], + }), + ); + } + if (String(url).endsWith("/v1/client/dpop-token")) { + return Promise.resolve(Response.json(validDpopAccessTokenResponse())); + } + if (String(url).endsWith("/status")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: listedEnvironment("env-1").endpoint, + status: "online", + checkedAt: "2026-05-25T00:01:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + } + if (String(url).endsWith("/connect")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: listedEnvironment("env-1").endpoint, + credential: "one-time-cloud-credential", + expiresAt: "2026-05-25T00:05:00.000Z", + }), + ); + } + if (String(url).endsWith("/.well-known/t3/environment")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }), + ); + } + return Promise.resolve( + Response.json({ + access_token: "environment-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 3600, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + Effect.gen(function* () { + const records = yield* listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }); + yield* connectCloudEnvironment({ + clerkToken: "clerk-token", + environment: records[0]!.environment, + }); + }), + ); + + expect( + fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), + ).toHaveLength(1); + const exchangeRequest = fetchMock.mock.calls.find(([url]) => + String(url).endsWith("/v1/client/dpop-token"), + )?.[1]; + expect(new URLSearchParams(requestBodyText(exchangeRequest?.body)).get("scope")).toBe( + "environment:status environment:connect", + ); + const environmentTokenRequest = fetchMock.mock.calls.find(([url]) => + String(url).endsWith("/oauth/token"), + )?.[1]; + const environmentTokenBody = new URLSearchParams( + requestBodyText(environmentTokenRequest?.body), + ); + expect(environmentTokenBody.get("client_label")).toBe("T3 Code Mobile"); + expect(environmentTokenBody.get("client_device_type")).toBe("mobile"); + expect(environmentTokenBody.get("client_os")).toBe("iOS"); + }), + ); + + it.effect("keeps advertised environments visible when status probing fails", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => { + if (String(url) === "https://relay.example.test/v1/environments") { + return Promise.resolve( + Response.json({ + environments: [ + { + environmentId: "env-1", + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + ], + }), + ); + } + if (String(url) === "https://relay.example.test/v1/client/dpop-token") { + return Promise.resolve(Response.json(validDpopAccessTokenResponse())); + } + return Promise.resolve(Response.json({ error: "offline" }, { status: 503 })); + }), + ); + + const records = yield* withCloudServices( + listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }), + ); + expect(records).toMatchObject([ + { + environment: { + environmentId: "env-1", + label: "Desktop", + }, + status: null, + statusError: "https://relay.example.test/v1/environments/env-1/status failed", + }, + ]); + }), + ); + + it.effect("rejects status responses for a different advertised environment", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => { + if (String(url) === "https://relay.example.test/v1/environments") { + return Promise.resolve( + Response.json({ + environments: [ + { + environmentId: "env-1", + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + ], + }), + ); + } + if (String(url) === "https://relay.example.test/v1/client/dpop-token") { + return Promise.resolve(Response.json(validDpopAccessTokenResponse())); + } + return Promise.resolve( + Response.json({ + environmentId: "env-other", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-05-25T00:01:00.000Z", + descriptor: { + environmentId: "env-other", + label: "Other Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }), + ); + + const records = yield* withCloudServices( + listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }), + ); + expect(records).toMatchObject([ + { + environment: { + environmentId: "env-1", + label: "Desktop", + }, + status: null, + statusError: "Relay returned status for a different environment.", + }, + ]); + }), + ); + + it.effect( + "rejects relay link credentials for a different environment before persisting relay config", + () => + Effect.gen(function* () { + const fetchMock = vi.fn((url: string | URL) => { + if (String(url).endsWith("/v1/client/environment-link-challenges")) { + return Promise.resolve(Response.json(validLinkChallengeResponse())); + } + if (String(url).endsWith("/api/cloud/link-proof")) { + return Promise.resolve(Response.json(validLinkProof())); + } + return Promise.resolve(Response.json(validLinkResponse("env-other"))); + }); + vi.stubGlobal("fetch", fetchMock); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + clerkToken: "clerk-token", + connection: savedConnection, + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "Relay returned credentials for a different environment.", + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }), + ); + + it.effect("preserves typed local environment failures while obtaining a link proof", () => + Effect.gen(function* () { + const fetchMock = vi.fn((url: string | URL) => { + if (String(url).endsWith("/v1/client/environment-link-challenges")) { + return Promise.resolve(Response.json(validLinkChallengeResponse())); + } + return Promise.resolve( + Response.json( + { + _tag: "EnvironmentHttpUnauthorizedError", + message: "Invalid environment bearer session.", + }, + { status: 401 }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + clerkToken: "clerk-token", + connection: savedConnection, + }), + ).pipe(Effect.flip); + expect(error._tag).toBe("CloudEnvironmentLinkError"); + expect(error.message).toBe( + "Could not obtain environment link proof: Invalid environment bearer session.", + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("preserves typed relay error bodies while linking environments", () => + Effect.gen(function* () { + const fetchMock = vi.fn((url: string | URL) => { + if (String(url).endsWith("/v1/client/environment-link-challenges")) { + return Promise.resolve(Response.json(validLinkChallengeResponse())); + } + if (String(url).endsWith("/api/cloud/link-proof")) { + return Promise.resolve(Response.json(validLinkProof())); + } + return Promise.resolve( + Response.json( + { + _tag: "RelayEnvironmentLinkProofInvalidError", + code: "environment_link_proof_invalid", + reason: "origin_not_allowed", + traceId: "trace-test", + }, + { status: 400 }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + clerkToken: "clerk-token", + connection: savedConnection, + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: + "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }), + ); + + it.effect("rejects relay link credentials for a different managed endpoint provider", () => + Effect.gen(function* () { + const fetchMock = vi.fn((url: string | URL) => { + if (String(url).endsWith("/v1/client/environment-link-challenges")) { + return Promise.resolve(Response.json(validLinkChallengeResponse())); + } + if (String(url).endsWith("/api/cloud/link-proof")) { + return Promise.resolve(Response.json(validLinkProof())); + } + return Promise.resolve( + Response.json({ + ...validLinkResponse(), + endpoint: { + ...validLinkResponse().endpoint, + providerKind: "manual", + }, + }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + clerkToken: "clerk-token", + connection: savedConnection, + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "Relay returned credentials for a different endpoint provider.", + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }), + ); + + it.effect("preserves disabled Live Activity preferences when linking an environment", () => + Effect.gen(function* () { + const storage = yield* Effect.promise(() => import("../../lib/storage")); + vi.mocked(storage.loadPreferences).mockResolvedValueOnce({ + liveActivitiesEnabled: false, + }); + const bodies: Array = []; + const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { + if (init?.body) { + // @effect-diagnostics-next-line preferSchemaOverJson:off + bodies.push(JSON.parse(requestBodyText(init.body))); + } + if (String(url).endsWith("/v1/client/environment-link-challenges")) { + return Promise.resolve(Response.json(validLinkChallengeResponse())); + } + if (String(url).endsWith("/api/cloud/link-proof")) { + return Promise.resolve(Response.json(validLinkProof())); + } + if (String(url).endsWith("/v1/client/environment-links")) { + return Promise.resolve(Response.json(validLinkResponse())); + } + return Promise.resolve( + Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + linkEnvironmentToCloud({ + clerkToken: "clerk-token", + connection: savedConnection, + }), + ); + + expect(bodies[1]).toMatchObject({ + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + origin: { + localHttpHost: "127.0.0.1", + localHttpPort: 443, + }, + }); + expect(bodies[2]).toMatchObject({ + deviceId: "device-1", + notificationsEnabled: true, + liveActivitiesEnabled: false, + managedTunnelsEnabled: true, + }); + expect(bodies[3]).toMatchObject({ + cloudUserId: "user_123", + environmentCredential: "environment-credential", + }); + }), + ); + + it.effect( + "does not persist cloud connect bootstrap credentials in saved connection records", + () => + Effect.gen(function* () { + let connectRequestBody = ""; + const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { + if (String(url).endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json(validDpopAccessTokenResponse("environment:connect")), + ); + } + if (String(url).endsWith("/.well-known/t3/environment")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }), + ); + } + if (String(url).endsWith("/oauth/token")) { + return Promise.resolve( + Response.json({ + access_token: "environment-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 3600, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }), + ); + } + connectRequestBody = requestBodyText(init?.body); + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + credential: "one-time-cloud-credential", + expiresAt: "2026-05-25T00:05:00.000Z", + }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const connection = yield* withCloudServices( + connectCloudEnvironment({ + clerkToken: "clerk-token", + environment: { + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + }), + ); + + expect(connection.pairingUrl).toBe("https://desktop.example.test/"); + expect(connection.pairingUrl).not.toContain("one-time-cloud-credential"); + expect(connection.bearerToken).toBeNull(); + expect(connection.authenticationMethod).toBe("dpop"); + expect(connection.dpopAccessToken).toBe("environment-dpop-token"); + expect(connection.relayManaged).toBe(true); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(connectRequestBody)).toMatchObject({ + deviceId: "device-1", + clientKeyThumbprint: "client-proof-key-thumbprint", + }); + expect(createProofMock).toHaveBeenCalledWith({ + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/connect", + accessToken: "relay-dpop-token", + }); + expect(createProofMock).toHaveBeenCalledWith({ + method: "POST", + url: "https://desktop.example.test/oauth/token", + }); + }), + ); + + it.effect("refreshes a saved environment against a rotated managed endpoint", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => { + if (String(url).endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json(validDpopAccessTokenResponse("environment:connect")), + ); + } + if (String(url).endsWith("/.well-known/t3/environment")) { + return Promise.resolve( + Response.json({ + environmentId: "env-1", + label: "Rotated Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }), + ); + } + if (String(url).endsWith("/oauth/token")) { + return Promise.resolve( + Response.json({ + access_token: "fresh-environment-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 3600, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://rotated-desktop.example.test/", + wsBaseUrl: "wss://rotated-desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + credential: "rotated-one-time-cloud-credential", + expiresAt: "2026-05-25T00:05:00.000Z", + }), + ); + }), + ); + + const connection = yield* withCloudServices( + refreshCloudEnvironmentConnection({ + clerkToken: "clerk-token", + connection: { + environmentId: EnvironmentId.make("env-1"), + environmentLabel: "Desktop", + pairingUrl: "https://desktop.example.test/", + displayUrl: "https://desktop.example.test/", + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + bearerToken: null, + authenticationMethod: "dpop", + relayManaged: true, + }, + }), + ); + + expect(connection).toMatchObject({ + environmentId: "env-1", + environmentLabel: "Rotated Desktop", + displayUrl: "https://rotated-desktop.example.test/", + httpBaseUrl: "https://rotated-desktop.example.test/", + wsBaseUrl: "wss://rotated-desktop.example.test/ws", + dpopAccessToken: "fresh-environment-dpop-token", + }); + }), + ); + + it.effect("rejects relay connect responses for a different environment", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => + Promise.resolve( + String(url).endsWith("/v1/client/dpop-token") + ? Response.json(validDpopAccessTokenResponse("environment:connect")) + : Response.json({ + environmentId: "env-other", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + credential: "one-time-cloud-credential", + expiresAt: "2026-05-25T00:05:00.000Z", + }), + ), + ), + ); + + const error = yield* withCloudServices( + connectCloudEnvironment({ + clerkToken: "clerk-token", + environment: { + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "Relay returned credentials for a different environment.", + }); + }), + ); + + it.effect("preserves relay DPoP auth failures while connecting environments", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => + Promise.resolve( + String(url).endsWith("/v1/client/dpop-token") + ? Response.json(validDpopAccessTokenResponse("environment:connect")) + : Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "invalid_dpop", + traceId: "trace-connect", + }, + { status: 401 }, + ), + ), + ), + ); + + const error = yield* withCloudServices( + connectCloudEnvironment({ + clerkToken: "clerk-token", + environment: { + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: + "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", + }); + }), + ); + + it.effect("rejects relay connect responses for a different endpoint", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => + Promise.resolve( + String(url).endsWith("/v1/client/dpop-token") + ? Response.json(validDpopAccessTokenResponse("environment:connect")) + : Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://other-desktop.example.test/", + wsBaseUrl: "wss://other-desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + credential: "one-time-cloud-credential", + expiresAt: "2026-05-25T00:05:00.000Z", + }), + ), + ), + ); + + const error = yield* withCloudServices( + connectCloudEnvironment({ + clerkToken: "clerk-token", + environment: { + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "Relay returned credentials for a different endpoint.", + }); + }), + ); + + it.effect( + "rejects managed endpoints whose descriptor does not match the selected environment", + () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn((url: string | URL) => + Promise.resolve( + String(url).endsWith("/v1/client/dpop-token") + ? Response.json(validDpopAccessTokenResponse("environment:connect")) + : String(url).endsWith("/.well-known/t3/environment") + ? Response.json({ + environmentId: "env-other", + label: "Other Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }) + : Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + credential: "one-time-cloud-credential", + expiresAt: "2026-05-25T00:05:00.000Z", + }), + ), + ), + ); + + const error = yield* withCloudServices( + connectCloudEnvironment({ + clerkToken: "clerk-token", + environment: { + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "Connected endpoint descriptor does not match the selected environment.", + }); + }), + ); +}); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts new file mode 100644 index 00000000000..61ebad26237 --- /dev/null +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -0,0 +1,582 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { + EnvironmentCloudEndpointUnavailableError, + EnvironmentHttpBadRequestError, + EnvironmentHttpConflictError, + EnvironmentHttpForbiddenError, + EnvironmentHttpInternalServerError, + EnvironmentHttpUnauthorizedError, +} from "@t3tools/contracts"; +import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; +import { + type RelayEnvironmentConnectResponse as RelayEnvironmentConnectResponseType, + type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, + RelayProtectedError, + type RelayDpopAccessTokenScope, + type RelayProtectedError as RelayProtectedErrorType, + type RelayClientEnvironmentRecord, + type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, + type RelayManagedEndpointProviderKind, +} from "@t3tools/contracts/relay"; +import { + exchangeRemoteDpopAccessToken, + fetchRemoteEnvironmentDescriptor, + makeEnvironmentHttpApiClient, + ManagedRelayClient, + ManagedRelayDpopSigner, +} from "@t3tools/client-runtime"; + +import { mobileAuthClientMetadata } from "../../lib/authClientMetadata"; +import type { SavedRemoteConnection } from "../../lib/connection"; +import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; +import { resolveCloudPublicConfig } from "./publicConfig"; + +const RELAY_STATUS_AND_CONNECT_SCOPES = [ + RelayEnvironmentStatusScope, + RelayEnvironmentConnectScope, +] satisfies ReadonlyArray; + +export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + return trimmed.replace(/\/+$/g, ""); +} + +function readRelayUrl(): string | null { + return resolveCloudPublicConfig().relayUrl; +} + +export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface CloudEnvironmentRecordWithStatus { + readonly environment: RelayClientEnvironmentRecord; + readonly status: RelayEnvironmentStatusResponseType | null; + readonly statusError: string | null; +} + +const isRelayProtectedError = Schema.is(RelayProtectedError); +const isEnvironmentCloudApiError = Schema.is( + Schema.Union([ + EnvironmentHttpBadRequestError, + EnvironmentHttpUnauthorizedError, + EnvironmentHttpForbiddenError, + EnvironmentHttpConflictError, + EnvironmentHttpInternalServerError, + EnvironmentCloudEndpointUnavailableError, + ]), +); + +const MANAGED_ENDPOINT_PROVIDER_KIND = + "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; + +function cloudEnvironmentLinkError(message: string) { + return (cause: unknown) => { + const environmentError = findEnvironmentCloudApiError(cause); + return new CloudEnvironmentLinkError({ + message: environmentError + ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` + : withDevCause(message, cause), + cause, + }); + }; +} + +function isDevRuntime(): boolean { + return typeof __DEV__ !== "undefined" && __DEV__; +} + +function causeMessage(cause: unknown): string | null { + if (cause instanceof Error && cause.message) { + return cause.message; + } + if (typeof cause === "object" && cause !== null) { + const record = cause as { readonly message?: unknown; readonly cause?: unknown }; + if (typeof record.message === "string" && record.message.length > 0) { + const nested = causeMessage(record.cause); + return nested ? `${record.message}: ${nested}` : record.message; + } + } + return null; +} + +function withDevCause(message: string, cause: unknown): string { + if (!isDevRuntime()) { + return message; + } + const detail = causeMessage(cause); + return detail ? `${message} (${detail})` : message; +} + +function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { + switch (error._tag) { + case "RelayAuthInvalidError": + switch (error.reason) { + case "missing_bearer": + case "invalid_bearer": + return "Relay rejected the cloud session token."; + case "invalid_dpop": + return "Relay rejected the DPoP proof."; + case "not_authorized": + return "Relay rejected the authenticated request."; + } + case "RelayEnvironmentLinkProofExpiredError": + return "Relay rejected an expired environment link proof."; + case "RelayEnvironmentLinkProofInvalidError": + return `Relay rejected the environment link proof (${error.reason}).`; + case "RelayEnvironmentConnectNotAuthorizedError": + return "Relay rejected the environment connection request."; + case "RelayEnvironmentEndpointUnavailableError": + return `Relay could not reach the environment endpoint (${error.reason}).`; + case "RelayEnvironmentEndpointTimedOutError": + return "Relay timed out while contacting the environment endpoint."; + case "RelayEnvironmentLinkFailedError": + return `Relay could not link the environment (${error.reason}).`; + case "RelayEnvironmentLinkUnavailableError": + return `Relay cannot provision the managed endpoint (${error.reason}).`; + case "RelayAgentActivityPublishProofExpiredError": + return "Relay rejected an expired agent activity publish proof."; + case "RelayAgentActivityPublishProofInvalidError": + return `Relay rejected the agent activity publish proof (${error.reason}).`; + case "RelayInternalError": + return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; + } +} + +function decodedRelayClientError(message: string) { + return (cause: unknown) => { + const relayError = findRelayProtectedError(cause); + const detail = relayError ? relayProtectedErrorMessage(relayError) : null; + return new CloudEnvironmentLinkError({ + message: detail ? `${message}: ${detail}` : message, + cause, + }); + }; +} + +function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { + if (isRelayProtectedError(cause)) { + return cause; + } + if (typeof cause !== "object" || cause === null) { + return null; + } + return "cause" in cause ? findRelayProtectedError(cause.cause) : null; +} + +function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { + if (isEnvironmentCloudApiError(cause)) { + return cause; + } + if (typeof cause !== "object" || cause === null) { + return null; + } + return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; +} + +function requireRelayUrl(): Effect.Effect { + const relayUrl = readRelayUrl(); + return relayUrl + ? Effect.succeed(relayUrl) + : Effect.fail(new CloudEnvironmentLinkError({ message: "Relay URL is not configured." })); +} + +function endpointOrigin(httpBaseUrl: string) { + const url = new URL(httpBaseUrl); + return { + localHttpHost: "127.0.0.1", + localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), + }; +} + +function ensureLinkedEnvironmentMatches(input: { + readonly expectedEnvironmentId: string; + readonly expectedProviderKind: RelayManagedEndpointProviderKind; + readonly link: RelayEnvironmentLinkResponseType; +}): Effect.Effect { + if (input.link.environmentId !== input.expectedEnvironmentId) { + return new CloudEnvironmentLinkError({ + message: "Relay returned credentials for a different environment.", + }); + } + if (input.link.endpoint.providerKind !== input.expectedProviderKind) { + return new CloudEnvironmentLinkError({ + message: "Relay returned credentials for a different endpoint provider.", + }); + } + return Effect.void; +} + +function endpointMatches( + left: RelayClientEnvironmentRecord["endpoint"], + right: RelayClientEnvironmentRecord["endpoint"], +): boolean { + return ( + left.httpBaseUrl === right.httpBaseUrl && + left.wsBaseUrl === right.wsBaseUrl && + left.providerKind === right.providerKind + ); +} + +function ensureStatusMatchesEnvironment(input: { + readonly environment: RelayClientEnvironmentRecord; + readonly status: RelayEnvironmentStatusResponseType; +}): Effect.Effect { + if (input.status.environmentId !== input.environment.environmentId) { + return new CloudEnvironmentLinkError({ + message: "Relay returned status for a different environment.", + }); + } + if (!endpointMatches(input.status.endpoint, input.environment.endpoint)) { + return new CloudEnvironmentLinkError({ + message: "Relay returned status for a different endpoint.", + }); + } + if ( + input.status.descriptor && + input.status.descriptor.environmentId !== input.environment.environmentId + ) { + return new CloudEnvironmentLinkError({ + message: "Relay returned status descriptor for a different environment.", + }); + } + return Effect.void; +} + +function ensureConnectEndpointMatchesEnvironment(input: { + readonly environment: RelayClientEnvironmentRecord; + readonly connect: RelayEnvironmentConnectResponseType; +}): Effect.Effect { + if (!endpointMatches(input.connect.endpoint, input.environment.endpoint)) { + return new CloudEnvironmentLinkError({ + message: "Relay returned credentials for a different endpoint.", + }); + } + return Effect.void; +} + +export function linkEnvironmentToCloud(input: { + readonly connection: SavedRemoteConnection; + readonly clerkToken: string; +}): Effect.Effect { + return Effect.gen(function* () { + if (!input.connection.bearerToken) { + return yield* new CloudEnvironmentLinkError({ + message: "Only a locally paired bearer connection can be linked to the cloud.", + }); + } + const localBearerToken = input.connection.bearerToken; + const relayUrl = yield* requireRelayUrl(); + const relayClient = yield* ManagedRelayClient; + const deviceId = yield* Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: cloudEnvironmentLinkError("Could not load the mobile device id."), + }); + const preferences = yield* Effect.tryPromise({ + try: () => loadPreferences(), + catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."), + }); + const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; + const challenge = yield* relayClient + .createEnvironmentLinkChallenge({ + clerkToken: input.clerkToken, + payload: { + notificationsEnabled: true, + liveActivitiesEnabled, + managedTunnelsEnabled: true, + }, + }) + .pipe( + Effect.mapError( + decodedRelayClientError(`${relayUrl}/v1/client/environment-link-challenges failed`), + ), + ); + const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); + const proof = yield* environmentClient.cloud + .linkProof({ + headers: { authorization: `Bearer ${localBearerToken}` }, + payload: { + challenge: challenge.challenge, + relayIssuer: relayUrl, + endpoint: { + httpBaseUrl: input.connection.httpBaseUrl, + wsBaseUrl: input.connection.wsBaseUrl, + providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, + }, + origin: endpointOrigin(input.connection.httpBaseUrl), + }, + }) + .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof."))); + const link = yield* relayClient + .linkEnvironment({ + clerkToken: input.clerkToken, + payload: { + deviceId, + proof, + notificationsEnabled: true, + liveActivitiesEnabled, + managedTunnelsEnabled: true, + }, + }) + .pipe( + Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/client/environment-links failed`)), + ); + yield* ensureLinkedEnvironmentMatches({ + expectedEnvironmentId: input.connection.environmentId, + expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, + link, + }); + + yield* environmentClient.cloud + .relayConfig({ + headers: { authorization: `Bearer ${localBearerToken}` }, + payload: { + relayUrl, + relayIssuer: link.relayIssuer, + cloudUserId: link.cloudUserId, + environmentCredential: link.environmentCredential, + cloudMintPublicKey: link.cloudMintPublicKey, + endpointRuntime: link.endpointRuntime, + }, + }) + .pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not configure environment relay access.")), + ); + }); +} + +export function listCloudEnvironments(input: { + readonly clerkToken: string; +}): Effect.Effect< + ReadonlyArray, + CloudEnvironmentLinkError, + ManagedRelayClient +> { + return Effect.gen(function* () { + const relayUrl = yield* requireRelayUrl(); + const relayClient = yield* ManagedRelayClient; + + return yield* relayClient + .listEnvironments({ + clerkToken: input.clerkToken, + }) + .pipe(Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/environments failed`))); + }); +} + +export function getCloudEnvironmentStatus(input: { + readonly clerkToken: string; + readonly environment: RelayClientEnvironmentRecord; + readonly relayScopes?: ReadonlyArray; +}): Effect.Effect< + RelayEnvironmentStatusResponseType, + CloudEnvironmentLinkError, + ManagedRelayClient +> { + return Effect.gen(function* () { + const relayUrl = yield* requireRelayUrl(); + const relayClient = yield* ManagedRelayClient; + const status = yield* relayClient + .getEnvironmentStatus({ + clerkToken: input.clerkToken, + scopes: input.relayScopes ?? [RelayEnvironmentStatusScope], + environmentId: input.environment.environmentId, + }) + .pipe( + Effect.mapError( + decodedRelayClientError( + `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/status failed`, + ), + ), + ); + yield* ensureStatusMatchesEnvironment({ environment: input.environment, status }); + return status; + }); +} + +export function cloudEnvironmentsPendingStatus( + environments: ReadonlyArray, +): ReadonlyArray { + return environments.map((environment) => ({ + environment, + status: null, + statusError: "Checking status...", + })); +} + +export function loadCloudEnvironmentStatuses(input: { + readonly clerkToken: string; + readonly environments: ReadonlyArray; +}): Effect.Effect< + ReadonlyArray, + CloudEnvironmentLinkError, + ManagedRelayClient +> { + return Effect.forEach( + input.environments, + (environment) => + getCloudEnvironmentStatus({ + clerkToken: input.clerkToken, + environment, + relayScopes: RELAY_STATUS_AND_CONNECT_SCOPES, + }).pipe( + Effect.match({ + onFailure: (error) => ({ + environment, + status: null, + statusError: error.message, + }), + onSuccess: (status) => ({ + environment, + status, + statusError: null, + }), + }), + ), + { concurrency: "unbounded" }, + ); +} + +export function listCloudEnvironmentsWithStatus(input: { + readonly clerkToken: string; +}): Effect.Effect< + ReadonlyArray, + CloudEnvironmentLinkError, + ManagedRelayClient +> { + return Effect.gen(function* () { + const environments = yield* listCloudEnvironments(input); + return yield* loadCloudEnvironmentStatuses({ + clerkToken: input.clerkToken, + environments, + }); + }); +} + +function connectRelayManagedEnvironment(input: { + readonly clerkToken: string; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly expectedEnvironment?: RelayClientEnvironmentRecord; +}): Effect.Effect< + SavedRemoteConnection, + CloudEnvironmentLinkError, + HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner +> { + return Effect.gen(function* () { + const relayUrl = yield* requireRelayUrl(); + const relayClient = yield* ManagedRelayClient; + + const deviceId = yield* Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: cloudEnvironmentLinkError("Could not load the mobile device id."), + }); + const connect = yield* relayClient + .connectEnvironment({ + clerkToken: input.clerkToken, + scopes: [RelayEnvironmentConnectScope], + environmentId: input.environmentId, + deviceId, + }) + .pipe( + Effect.mapError( + decodedRelayClientError( + `${relayUrl}/v1/environments/${encodeURIComponent(input.environmentId)}/connect failed`, + ), + ), + ); + if (connect.environmentId !== input.environmentId) { + return yield* new CloudEnvironmentLinkError({ + message: "Relay returned credentials for a different environment.", + }); + } + if (input.expectedEnvironment) { + yield* ensureConnectEndpointMatchesEnvironment({ + environment: input.expectedEnvironment, + connect, + }); + } + + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: connect.endpoint.httpBaseUrl, + }).pipe( + Effect.mapError( + cloudEnvironmentLinkError("Could not fetch the connected environment descriptor."), + ), + ); + if (descriptor.environmentId !== connect.environmentId) { + return yield* new CloudEnvironmentLinkError({ + message: "Connected endpoint descriptor does not match the selected environment.", + }); + } + const signer = yield* ManagedRelayDpopSigner; + const bootstrapDpop = yield* signer + .createProof({ + method: "POST", + url: new URL("/oauth/token", connect.endpoint.httpBaseUrl).toString(), + }) + .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not create bootstrap DPoP proof."))); + const bootstrap = yield* exchangeRemoteDpopAccessToken({ + httpBaseUrl: connect.endpoint.httpBaseUrl, + credential: connect.credential, + dpopProof: bootstrapDpop, + clientMetadata: mobileAuthClientMetadata(), + }).pipe( + Effect.mapError( + cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), + ), + ); + const pairingUrl = new URL(connect.endpoint.httpBaseUrl); + pairingUrl.hash = new URLSearchParams([["token", connect.credential]]).toString(); + + return { + environmentId: descriptor.environmentId, + environmentLabel: descriptor.label, + pairingUrl: stripPairingTokenFromUrl(pairingUrl).toString(), + displayUrl: connect.endpoint.httpBaseUrl, + httpBaseUrl: connect.endpoint.httpBaseUrl, + wsBaseUrl: connect.endpoint.wsBaseUrl, + bearerToken: null, + authenticationMethod: "dpop", + dpopAccessToken: bootstrap.access_token, + relayManaged: true, + }; + }); +} + +export function connectCloudEnvironment(input: { + readonly clerkToken: string; + readonly environment: RelayClientEnvironmentRecord; +}): Effect.Effect< + SavedRemoteConnection, + CloudEnvironmentLinkError, + HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner +> { + return connectRelayManagedEnvironment({ + clerkToken: input.clerkToken, + environmentId: input.environment.environmentId, + expectedEnvironment: input.environment, + }); +} + +export function refreshCloudEnvironmentConnection(input: { + readonly clerkToken: string; + readonly connection: SavedRemoteConnection; +}): Effect.Effect< + SavedRemoteConnection, + CloudEnvironmentLinkError, + HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner +> { + return connectRelayManagedEnvironment({ + clerkToken: input.clerkToken, + environmentId: input.connection.environmentId, + }); +} diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts new file mode 100644 index 00000000000..0de43d049c5 --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -0,0 +1,42 @@ +import { + managedRelayClientLayer, + ManagedRelayDpopSigner, + ManagedRelayDpopSignerError, +} from "@t3tools/client-runtime"; +import { RelayMobileClientId } from "@t3tools/contracts/relay"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; + +const mobileRelayDpopSignerLayer = Layer.effect( + ManagedRelayDpopSigner, + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return ManagedRelayDpopSigner.of({ + thumbprint: Effect.suspend(() => + loadOrCreateDpopProofKeyPair().pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((proofKey) => proofKey.thumbprint), + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + ), + ), + createProof: (input) => + Effect.gen(function* () { + const proofKey = yield* loadOrCreateDpopProofKeyPair().pipe( + Effect.provideService(Crypto.Crypto, crypto), + ); + return yield* createDpopProof({ ...input, proofKey }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((proof) => proof.proof), + ); + }).pipe(Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause }))), + }); + }), +); + +export const mobileManagedRelayClientLayer = (relayUrl: string) => + managedRelayClientLayer({ relayUrl, clientId: RelayMobileClientId }).pipe( + Layer.provideMerge(mobileRelayDpopSignerLayer), + ); diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts new file mode 100644 index 00000000000..a1ecc8b4ea9 --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -0,0 +1,88 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + createManagedRelayQueryManager, + ManagedRelayClient, + managedRelaySessionAtom, + readManagedRelaySnapshotState, +} from "@t3tools/client-runtime"; +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback } from "react"; + +import { mobileRuntime } from "../../lib/runtime"; +import { appAtomRegistry } from "../../state/atom-registry"; + +const managedRelayAtomRuntime = Atom.runtime( + Layer.effect( + ManagedRelayClient, + mobileRuntime.contextEffect.pipe( + Effect.map((context) => Context.get(context, ManagedRelayClient)), + ), + ), +); + +export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); + +const EMPTY_ENVIRONMENTS_ATOM = Atom.make( + AsyncResult.success>([]), +).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null")); + +const EMPTY_ENVIRONMENT_STATUS_ATOM = Atom.make( + AsyncResult.initial(false), +).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environment-status:null")); + +export function useManagedRelayEnvironments() { + const session = useAtomValue(managedRelaySessionAtom); + const accountId = session?.accountId ?? null; + const atom = accountId + ? managedRelayQueryManager.environmentsAtom(accountId) + : EMPTY_ENVIRONMENTS_ATOM; + const result = useAtomValue(atom); + const refresh = useCallback(() => { + if (accountId) { + managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); + } + }, [accountId]); + + return { + ...readManagedRelaySnapshotState(result), + accountId, + refresh, + }; +} + +export function useManagedRelayEnvironmentStatus(environment: RelayClientEnvironmentRecord) { + const session = useAtomValue(managedRelaySessionAtom); + const accountId = session?.accountId ?? null; + const atom = accountId + ? managedRelayQueryManager.environmentStatusAtom({ accountId, environment }) + : EMPTY_ENVIRONMENT_STATUS_ATOM; + const result = useAtomValue(atom); + const refresh = useCallback(() => { + if (accountId) { + managedRelayQueryManager.refreshEnvironmentStatus(appAtomRegistry, { + accountId, + environment, + }); + } + }, [accountId, environment]); + + return { + ...readManagedRelaySnapshotState(result), + accountId, + refresh, + }; +} + +export function refreshManagedRelayEnvironments(): void { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session) { + managedRelayQueryManager.refreshEnvironments(appAtomRegistry, session.accountId); + } +} diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts new file mode 100644 index 00000000000..df9aefdc43a --- /dev/null +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveCloudPublicConfig } from "./publicConfig"; + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + extra: {}, + }, + }, +})); + +describe("resolveCloudPublicConfig", () => { + it("returns no cloud configuration for an unconfigured build", () => { + expect(resolveCloudPublicConfig({})).toEqual({ + clerkPublishableKey: null, + clerkJwtTemplate: null, + relayUrl: null, + }); + }); + + it("normalizes statically injected cloud configuration", () => { + expect( + resolveCloudPublicConfig({ + clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " }, + relay: { url: " https://relay.example.test/// " }, + }), + ).toEqual({ + clerkPublishableKey: "pk_test_example", + clerkJwtTemplate: "t3-relay", + relayUrl: "https://relay.example.test", + }); + }); + + it("rejects an insecure relay URL", () => { + expect( + resolveCloudPublicConfig({ + clerk: { publishableKey: "pk_test_example", jwtTemplate: "t3-relay" }, + relay: { url: "http://relay.example.test" }, + }), + ).toEqual({ + clerkPublishableKey: "pk_test_example", + clerkJwtTemplate: "t3-relay", + relayUrl: null, + }); + }); +}); diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts new file mode 100644 index 00000000000..fcd0ceb2728 --- /dev/null +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -0,0 +1,41 @@ +import Constants from "expo-constants"; +import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; + +type ExpoExtra = Readonly> | undefined; + +export interface CloudPublicConfig { + readonly clerkPublishableKey: string | null; + readonly clerkJwtTemplate: string | null; + readonly relayUrl: string | null; +} + +function trimNonEmpty(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { + const clerk = extra?.clerk as + | { readonly publishableKey?: unknown; readonly jwtTemplate?: unknown } + | undefined; + const relay = extra?.relay as { readonly url?: unknown } | undefined; + + return { + clerkPublishableKey: trimNonEmpty(clerk?.publishableKey), + clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate), + relayUrl: normalizeSecureRelayUrl(trimNonEmpty(relay?.url) ?? ""), + } satisfies CloudPublicConfig; +} + +export function hasCloudPublicConfig(): boolean { + const config = resolveCloudPublicConfig(); + return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); +} + +export function resolveRelayClerkTokenOptions() { + const { clerkJwtTemplate } = resolveCloudPublicConfig(); + if (!clerkJwtTemplate) { + throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); + } + return relayClerkTokenOptions(clerkJwtTemplate); +} diff --git a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts b/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts new file mode 100644 index 00000000000..3356642776a --- /dev/null +++ b/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts @@ -0,0 +1,134 @@ +import { getClerkInstance } from "@clerk/expo"; +import { tokenCache } from "@clerk/expo/token-cache"; +import * as Data from "effect/Data"; +import { useCallback, useRef } from "react"; +import type { TurboModule } from "react-native"; +import { TurboModuleRegistry } from "react-native"; + +const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt"; + +interface NativeClerkModule extends TurboModule { + readonly getClientToken?: () => Promise; + readonly presentAuth?: (options: { + readonly dismissable: boolean; + readonly mode: "signInOrUp"; + }) => Promise; +} + +interface NativeAuthResult { + readonly cancelled?: boolean; + readonly session?: { + readonly id?: string; + }; + readonly sessionId?: string; +} + +interface ClerkWithNativeSync { + readonly __internal_reloadInitialResources?: () => Promise; + readonly setActive?: (params: { readonly session: string }) => Promise; +} + +const NativeClerk = TurboModuleRegistry.get("ClerkExpo"); + +class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +async function syncNativeSession(sessionId: string): Promise { + const getClientToken = NativeClerk?.getClientToken; + let nativeClientToken: string | null = null; + if (getClientToken) { + try { + nativeClientToken = await getClientToken(); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Could not read native Clerk client token.", + cause, + }); + } + } + if (nativeClientToken) { + const saveToken = tokenCache?.saveToken; + if (saveToken) { + try { + await saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Could not save native Clerk client token.", + cause, + }); + } + } + } + + const clerk = getClerkInstance(); + const clerkWithNativeSync = clerk as ClerkWithNativeSync; + const reloadInitialResources = clerkWithNativeSync.__internal_reloadInitialResources; + if (reloadInitialResources) { + try { + await reloadInitialResources(); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Could not reload Clerk resources after native auth.", + cause, + }); + } + } + const setActive = clerkWithNativeSync.setActive; + if (setActive) { + try { + await setActive({ session: sessionId }); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Could not activate native Clerk session.", + cause, + }); + } + } +} + +export function useNativeClerkAuthModal() { + const presentingRef = useRef(false); + + const presentAuth = useCallback(async (): Promise => { + if (presentingRef.current || !NativeClerk?.presentAuth) { + return; + } + + presentingRef.current = true; + const presentNativeAuth = NativeClerk.presentAuth; + try { + // Clerk's iOS AuthView is not inline. It presents this same native modal + // internally; call the presenter directly so Expo Router does not render + // an empty formSheet behind it. + let result: NativeAuthResult | null; + try { + result = await presentNativeAuth({ + dismissable: true, + mode: "signInOrUp", + }); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Native Clerk auth presentation failed.", + cause, + }); + } + const sessionId = result?.sessionId ?? result?.session?.id ?? null; + if (sessionId && !result?.cancelled) { + await syncNativeSession(sessionId); + } + } catch (error) { + if (__DEV__) { + console.error("[useNativeClerkAuthModal] presentAuth failed:", error); + } + } finally { + presentingRef.current = false; + } + }, []); + + return { + isAvailable: !!NativeClerk?.presentAuth, + presentAuth, + }; +} diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 1dc6db74cc6..dd26e2e6ffb 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -113,56 +113,66 @@ export function ConnectionEnvironmentRow(props: { exiting={FadeOut.duration(150)} className="gap-3 px-4 pb-4" > - - - Label + {props.environment.isRelayManaged ? ( + + Managed by T3 Cloud. Tunnel details update automatically. - - + ) : ( + <> + + + Label + + + - - - URL - - - + + + URL + + + + + )} - - - - + {props.environment.isRelayManaged ? null : ( + - Save - - + + + Save + + + )} Comment - - + + diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index b73e874169c..e58489ee1f5 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,14 +1,33 @@ import { useRouter } from "expo-router"; +import { SymbolView } from "expo-symbols"; import { TextInputWrapper } from "expo-paste-input"; -import { useCallback, useEffect, useMemo } from "react"; -import { View } from "react-native"; -import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller"; +import { + type ComponentProps, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + InteractionManager, + Pressable, + ScrollView, + View, + useColorScheme, + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + type TextInput as RNTextInput, +} from "react-native"; +import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; -import { AppTextInput as TextInput } from "../../components/AppText"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; @@ -22,6 +41,9 @@ import { NewTaskSheetHeader } from "./NewTaskSheetHeader"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { useProjectActions } from "./use-project-actions"; +const TOOLBAR_FADE_WIDTH = 18; +const TOOLBAR_SCROLL_EPSILON = 4; + function withModelSelectionOption( selection: ModelSelection, id: string, @@ -34,6 +56,61 @@ function withModelSelectionOption( }; } +function formatTitleCase(value: string): string { + return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} + +function formatWorkspaceLabel(input: { + readonly workspaceMode: string; + readonly currentBranchName: string | null; + readonly selectedBranchName: string | null; +}): string { + const branchName = input.selectedBranchName ?? input.currentBranchName; + if (input.workspaceMode === "worktree") { + return branchName ? `New worktree · ${branchName}` : "New worktree"; + } + return branchName ? `Current · ${branchName}` : "Current checkout"; +} + +function NewTaskToolbarTrigger(props: { + readonly icon?: ComponentProps["name"]; + readonly iconNode?: ReactNode; + readonly label?: string; + readonly accessibilityLabel?: string; + readonly onPress?: () => void; + readonly showChevron?: boolean; +}) { + const iconColor = useThemeColor("--color-icon"); + + return ( + + {props.iconNode ? ( + {props.iconNode} + ) : props.icon ? ( + + ) : null} + {props.label ? ( + + {props.label} + + ) : null} + {props.showChevron === false ? null : ( + + )} + + ); +} + export function NewTaskDraftScreen(props: { readonly initialProjectRef?: { readonly environmentId?: string; @@ -45,11 +122,20 @@ export function NewTaskDraftScreen(props: { const flow = useNewTaskFlow(); const router = useRouter(); const insets = useSafeAreaInsets(); + const colorScheme = useColorScheme(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; + const promptInputRef = useRef(null); + const [toolbarMetrics, setToolbarMetrics] = useState({ + contentWidth: 0, + offsetX: 0, + viewportWidth: 0, + }); const borderColor = useThemeColor("--color-border"); + const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; + const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; useEffect(() => { if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { @@ -93,6 +179,24 @@ export function NewTaskDraftScreen(props: { void flow.loadBranches(); }, [flow, selectedProject]); + useEffect(() => { + if (!selectedProject) { + return; + } + + let focusFrame: ReturnType | null = null; + const interaction = InteractionManager.runAfterInteractions(() => { + focusFrame = requestAnimationFrame(() => promptInputRef.current?.focus()); + }); + + return () => { + interaction.cancel(); + if (focusFrame !== null) { + cancelAnimationFrame(focusFrame); + } + }; + }, [selectedProject]); + const environmentMenuActions = useMemo( () => flow.environments.map((environment) => ({ @@ -231,10 +335,10 @@ export function NewTaskDraftScreen(props: { { id: "workspace:mode", title: "Mode", - subtitle: flow.workspaceMode === "local" ? "Local" : "Worktree", + subtitle: flow.workspaceMode === "local" ? "Current checkout" : "New worktree", subactions: (["local", "worktree"] as const).map((value) => ({ id: `workspace:mode:${value}`, - title: value === "local" ? "Local" : "Worktree", + title: value === "local" ? "Current checkout" : "New worktree", state: flow.workspaceMode === value ? ("on" as const) : undefined, })), }, @@ -253,6 +357,56 @@ export function NewTaskDraftScreen(props: { flow.workspaceMode, ]); + const selectedEnvironmentLabel = + flow.environments.find( + (environment) => environment.environmentId === flow.selectedEnvironmentId, + )?.environmentLabel ?? "Environment"; + const currentBranchName = + flow.availableBranches.find((branch) => branch.current)?.name ?? + flow.availableBranches.find((branch) => branch.isDefault)?.name ?? + null; + const configurationLabel = useMemo(() => { + const parts = [ + formatTitleCase(flow.effort), + flow.fastMode ? "Fast" : null, + flow.contextWindow !== "1M" ? flow.contextWindow : null, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(" · ") : "Configuration"; + }, [flow.contextWindow, flow.effort, flow.fastMode]); + const workspaceLabel = useMemo( + () => + formatWorkspaceLabel({ + currentBranchName, + selectedBranchName: flow.selectedBranchName, + workspaceMode: flow.workspaceMode, + }), + [currentBranchName, flow.selectedBranchName, flow.workspaceMode], + ); + const toolbarScrollEdges = useMemo(() => { + const maxOffset = Math.max(0, toolbarMetrics.contentWidth - toolbarMetrics.viewportWidth); + return { + showLeftFade: toolbarMetrics.offsetX > TOOLBAR_SCROLL_EPSILON, + showRightFade: toolbarMetrics.offsetX < maxOffset - TOOLBAR_SCROLL_EPSILON, + }; + }, [toolbarMetrics]); + const handleToolbarLayout = useCallback((event: LayoutChangeEvent) => { + const viewportWidth = event.nativeEvent.layout.width; + setToolbarMetrics((current) => + current.viewportWidth === viewportWidth ? current : { ...current, viewportWidth }, + ); + }, []); + const handleToolbarContentSizeChange = useCallback((contentWidth: number) => { + setToolbarMetrics((current) => + current.contentWidth === contentWidth ? current : { ...current, contentWidth }, + ); + }, []); + const handleToolbarScroll = useCallback((event: NativeSyntheticEvent) => { + const offsetX = event.nativeEvent.contentOffset.x; + setToolbarMetrics((current) => + Math.abs(current.offsetX - offsetX) < 1 ? current : { ...current, offsetX }, + ); + }, []); + function handleModelMenuAction(event: string) { if (!event.startsWith("model:")) { return; @@ -408,24 +562,27 @@ export function NewTaskDraftScreen(props: { } /> - - void handleNativePaste(payload)} - style={{ flex: 1 }} - > - - - + + + void handleNativePaste(payload)} + style={{ flex: 1, minHeight: 0 }} + > + + + - ) : null} - - void handlePickImages()} /> - handleModelMenuAction(nativeEvent.event)} - > - - } - /> - - handleOptionsMenuAction(nativeEvent.event)} - > - - - handleEnvironmentMenuAction(nativeEvent.event)} - > - - - handleWorkspaceMenuAction(nativeEvent.event)} - > - - + + + + void handlePickImages()} /> + handleModelMenuAction(nativeEvent.event)} + > + + } + label={flow.selectedModelOption?.label ?? "Model"} + /> + + handleOptionsMenuAction(nativeEvent.event)} + > + + + + handleEnvironmentMenuAction(nativeEvent.event) + } + > + + + handleWorkspaceMenuAction(nativeEvent.event)} + > + + + + {toolbarScrollEdges.showLeftFade ? ( + + ) : null} + {toolbarScrollEdges.showRightFade ? ( + + ) : null} + void handleStart()} variant="primary" disabled={ @@ -488,7 +705,7 @@ export function NewTaskDraftScreen(props: { /> - + ); } diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts new file mode 100644 index 00000000000..b341c7b6bd4 --- /dev/null +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -0,0 +1,10 @@ +import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; +import { Platform } from "react-native"; + +export function mobileAuthClientMetadata(): AuthClientPresentationMetadata { + return { + label: "T3 Code Mobile", + deviceType: "mobile", + ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), + }; +} diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index ddd3a40eab3..68813b0b3b1 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; -import { mobileAuthClientMetadata, redactPairingCredential } from "./connection"; +import { + isRelayManagedConnection, + mobileAuthClientMetadata, + redactPairingCredential, + toStableSavedRemoteConnection, +} from "./connection"; vi.mock("./runtime", () => ({ - mobileRemoteHttpRuntime: { + mobileRuntime: { runPromise: vi.fn(), }, })); @@ -39,4 +45,30 @@ describe("mobile remote connection records", () => { ), ).toBe("https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&label=Desktop"); }); + + it("recognizes explicitly managed relay connections", () => { + expect(isRelayManagedConnection({ relayManaged: true })).toBe(true); + }); + + it("keeps existing DPoP tunnel records read-only after upgrading", () => { + expect(isRelayManagedConnection({ authenticationMethod: "dpop" })).toBe(true); + expect(isRelayManagedConnection({ authenticationMethod: "bearer" })).toBe(false); + }); + + it("drops short-lived managed environment credentials from stable records", () => { + const connection = { + environmentId: EnvironmentId.make("environment-1"), + environmentLabel: "Desktop", + pairingUrl: "https://desktop.example/", + displayUrl: "https://desktop.example/", + httpBaseUrl: "https://desktop.example/", + wsBaseUrl: "wss://desktop.example/", + bearerToken: null, + authenticationMethod: "dpop", + dpopAccessToken: "short-lived-token", + relayManaged: true, + } as const; + + expect(toStableSavedRemoteConnection(connection)).not.toHaveProperty("dpopAccessToken"); + }); }); diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index 8d87fda09c2..aa92c6f5d58 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -1,11 +1,14 @@ -import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import { bootstrapRemoteBearerSession, fetchRemoteEnvironmentDescriptor, } from "@t3tools/client-runtime"; import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; -import { Platform } from "react-native"; -import { mobileRemoteHttpRuntime } from "./runtime"; +import * as Effect from "effect/Effect"; +import { mobileAuthClientMetadata } from "./authClientMetadata"; +import { mobileRuntime } from "./runtime"; + +export { mobileAuthClientMetadata } from "./authClientMetadata"; export interface RemoteConnectionInput { readonly pairingUrl: string; @@ -18,7 +21,10 @@ export interface SavedRemoteConnection { readonly displayUrl: string; readonly httpBaseUrl: string; readonly wsBaseUrl: string; - readonly bearerToken: string; + readonly bearerToken: string | null; + readonly authenticationMethod?: "bearer" | "dpop"; + readonly dpopAccessToken?: string; + readonly relayManaged?: true; } export type RemoteClientConnectionState = @@ -37,12 +43,21 @@ export function redactPairingCredential(pairingUrl: string): string { } } -export function mobileAuthClientMetadata(): AuthClientPresentationMetadata { - return { - label: "T3 Code Mobile", - deviceType: "mobile", - ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), - }; +export function isRelayManagedConnection( + connection: Pick, +): boolean { + return connection.relayManaged === true || connection.authenticationMethod === "dpop"; +} + +export function toStableSavedRemoteConnection( + connection: SavedRemoteConnection, +): SavedRemoteConnection { + if (!isRelayManagedConnection(connection) || !connection.dpopAccessToken) { + return connection; + } + + const { dpopAccessToken: _, ...stableConnection } = connection; + return stableConnection; } export async function bootstrapRemoteConnection( @@ -52,18 +67,20 @@ export async function bootstrapRemoteConnection( pairingUrl: input.pairingUrl, }); - const descriptor = await mobileRemoteHttpRuntime.runPromise( - fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: target.httpBaseUrl, - }), - ); - - const bootstrap = await mobileRemoteHttpRuntime.runPromise( - bootstrapRemoteBearerSession({ - httpBaseUrl: target.httpBaseUrl, - credential: target.credential, - clientMetadata: mobileAuthClientMetadata(), - }), + const { descriptor, bootstrap } = await mobileRuntime.runPromise( + Effect.all( + { + descriptor: fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: target.httpBaseUrl, + }), + bootstrap: bootstrapRemoteBearerSession({ + httpBaseUrl: target.httpBaseUrl, + credential: target.credential, + clientMetadata: mobileAuthClientMetadata(), + }), + }, + { concurrency: "unbounded" }, + ), ); return { @@ -74,5 +91,6 @@ export async function bootstrapRemoteConnection( httpBaseUrl: target.httpBaseUrl, wsBaseUrl: target.wsBaseUrl, bearerToken: bootstrap.access_token, + authenticationMethod: "bearer", }; } diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index f50ddf6df8b..80083869d75 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,5 +1,24 @@ +import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; import { remoteHttpClientLayer } from "@t3tools/client-runtime"; -export const mobileRemoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(fetch)); +import { mobileCryptoLayer } from "../features/cloud/dpop"; +import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer"; +import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; + +function configuredRelayUrl(): string { + return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; +} + +const mobileHttpClientLayer = remoteHttpClientLayer(fetch); + +export const mobileRuntime = ManagedRuntime.make( + Layer.mergeAll( + mobileHttpClientLayer, + mobileCryptoLayer, + mobileManagedRelayClientLayer(configuredRelayUrl()).pipe( + Layer.provide(Layer.mergeAll(mobileHttpClientLayer, mobileCryptoLayer)), + ), + ), +); diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts new file mode 100644 index 00000000000..3522caa1546 --- /dev/null +++ b/apps/mobile/src/lib/storage.test.ts @@ -0,0 +1,72 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const values = new Map(); + return { + clear: () => values.clear(), + getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)), + setItemAsync: vi.fn((key: string, value: string) => { + values.set(key, value); + return Promise.resolve(); + }), + }; +}); + +vi.mock("expo-secure-store", () => ({ + getItemAsync: mocks.getItemAsync, + setItemAsync: mocks.setItemAsync, +})); + +vi.mock("react-native", () => ({ + Platform: { + OS: "ios", + }, +})); + +vi.mock("./runtime", () => ({ + mobileRuntime: { + runPromise: vi.fn(), + }, +})); + +import { loadSavedConnections, saveConnection } from "./storage"; +import { toStableSavedRemoteConnection } from "./connection"; + +const managedConnection = { + environmentId: EnvironmentId.make("environment-1"), + environmentLabel: "Desktop", + pairingUrl: "https://desktop.example/", + displayUrl: "https://desktop.example/", + httpBaseUrl: "https://desktop.example/", + wsBaseUrl: "wss://desktop.example/", + bearerToken: null, + authenticationMethod: "dpop", + dpopAccessToken: "short-lived-token", + relayManaged: true, +} as const; + +describe("mobile connection storage", () => { + beforeEach(() => { + mocks.clear(); + vi.clearAllMocks(); + }); + + it("persists relay-managed connections without their ephemeral access token", async () => { + await saveConnection(managedConnection); + + const savedValue = mocks.setItemAsync.mock.calls[0]?.[1]; + expect(savedValue).toBeDefined(); + expect(JSON.parse(savedValue ?? "")).toEqual({ + connections: [toStableSavedRemoteConnection(managedConnection)], + }); + }); + + it("loads relay-managed connection metadata without a cached access token", async () => { + await saveConnection(managedConnection); + + await expect(loadSavedConnections()).resolves.toEqual([ + toStableSavedRemoteConnection(managedConnection), + ]); + }); +}); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index e2eb173fac6..2f9e4962c1a 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -5,10 +5,15 @@ import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; -import type { SavedRemoteConnection } from "./connection"; +import { + isRelayManagedConnection, + type SavedRemoteConnection, + toStableSavedRemoteConnection, +} from "./connection"; const CONNECTIONS_KEY = "t3code.connections"; const PREFERENCES_KEY = "t3code.preferences"; +const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; @@ -20,6 +25,7 @@ export interface CachedShellSnapshot { } export interface MobilePreferences { + readonly liveActivitiesEnabled?: boolean; readonly terminalFontSize?: number; } @@ -133,18 +139,23 @@ export async function loadSavedConnections(): Promise !!c.environmentId && !!c.bearerToken?.trim()), + Arr.filter( + (c) => !!c.environmentId && (!!c.bearerToken?.trim() || isRelayManagedConnection(c)), + ), ); } export async function saveConnection(connection: SavedRemoteConnection): Promise { const current = await loadSavedConnections(); + const stableConnection = toStableSavedRemoteConnection(connection); const next = current.some((entry) => entry.environmentId === connection.environmentId) ? pipe( current, - Arr.map((entry) => (entry.environmentId === connection.environmentId ? connection : entry)), + Arr.map((entry) => + entry.environmentId === connection.environmentId ? stableConnection : entry, + ), ) - : pipe(current, Arr.append(connection)); + : pipe(current, Arr.append(stableConnection)); await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); } @@ -164,11 +175,19 @@ export async function loadPreferences(): Promise { return {}; } + const preferences: { + liveActivitiesEnabled?: boolean; + terminalFontSize?: number; + } = {}; + + if (typeof parsed.liveActivitiesEnabled === "boolean") { + preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; + } if (typeof parsed.terminalFontSize === "number") { - return { terminalFontSize: parsed.terminalFontSize }; + preferences.terminalFontSize = parsed.terminalFontSize; } - return {}; + return preferences; } export async function savePreferencesPatch( @@ -182,3 +201,20 @@ export async function savePreferencesPatch( await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); return next; } + +export async function loadOrCreateAgentAwarenessDeviceId(): Promise { + const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); + if (existing?.trim()) { + return existing; + } + + const { uuidv4 } = await import("./uuid"); + const deviceId = uuidv4(); + await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); + return deviceId; +} + +export async function loadAgentAwarenessDeviceId(): Promise { + const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); + return existing?.trim() ? existing : null; +} diff --git a/apps/mobile/src/state/remote-runtime-types.ts b/apps/mobile/src/state/remote-runtime-types.ts index 74d2cc8d995..054203715bd 100644 --- a/apps/mobile/src/state/remote-runtime-types.ts +++ b/apps/mobile/src/state/remote-runtime-types.ts @@ -11,6 +11,7 @@ export interface ConnectedEnvironmentSummary { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly displayUrl: string; + readonly isRelayManaged: boolean; readonly connectionState: EnvironmentConnectionState; readonly connectionError: string | null; } diff --git a/apps/mobile/src/state/use-remote-environment-registry.test.ts b/apps/mobile/src/state/use-remote-environment-registry.test.ts new file mode 100644 index 00000000000..4bbf266ede1 --- /dev/null +++ b/apps/mobile/src/state/use-remote-environment-registry.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import { + createManagedRelaySession, + ManagedRelayDpopSigner, + setManagedRelaySession, +} from "@t3tools/client-runtime"; +import * as Effect from "effect/Effect"; +import { beforeEach, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const environmentConnection = { + ensureBootstrapped: vi.fn(() => Promise.resolve()), + dispose: vi.fn(() => Promise.resolve()), + }; + const sessionConnection = { + dispose: vi.fn(() => Promise.resolve()), + reconnect: vi.fn(() => Promise.resolve()), + }; + const sessionClient = { + isHeartbeatFresh: vi.fn(() => false), + }; + return { + environmentConnection, + sessionConnection, + sessionClient, + createEnvironmentConnection: vi.fn(() => environmentConnection), + createKnownEnvironment: vi.fn((input: unknown) => input), + createWsRpcClient: vi.fn(() => ({ rpc: true })), + wsTransportConstructor: vi.fn(), + resolveRemoteWebSocketConnectionUrl: vi.fn(() => ({ _tag: "remote-ws-url-effect" })), + resolveRemoteDpopWebSocketConnectionUrl: vi.fn(), + remoteEndpointUrl: vi.fn((baseUrl: string, path: string) => new URL(path, baseUrl).toString()), + createDpopProof: vi.fn(), + refreshCloudEnvironmentConnection: vi.fn(), + bootstrapRemoteConnection: vi.fn(), + clearCachedShellSnapshot: vi.fn(() => Promise.resolve()), + clearSavedConnection: vi.fn(() => Promise.resolve()), + saveConnection: vi.fn((_connection?: unknown) => Promise.resolve()), + saveCachedShellSnapshot: vi.fn(() => Promise.resolve()), + mobileRunPromise: vi.fn((_effect?: unknown) => + Promise.resolve("wss://desktop.example/ws?wsTicket=token"), + ), + removeEnvironmentSession: vi.fn(() => null), + getEnvironmentSession: vi.fn(() => null), + setEnvironmentSession: vi.fn(), + notifyEnvironmentConnectionListeners: vi.fn(), + stopAgentAwarenessForEnvironment: vi.fn(), + startAgentAwarenessForEnvironment: vi.fn(), + shellSnapshotInvalidate: vi.fn(), + shellSnapshotMarkPending: vi.fn(), + environmentRuntimeInvalidate: vi.fn(), + environmentRuntimePatch: vi.fn(), + clearCachedShellSnapshotMetadata: vi.fn(), + invalidateSourceControlDiscoveryForEnvironment: vi.fn(), + terminalSessionInvalidateEnvironment: vi.fn(), + subscribeTerminalMetadata: vi.fn(() => vi.fn()), + terminalDebugLog: vi.fn(), + WsTransport: function WsTransport(...args: ReadonlyArray) { + mocks.wsTransportConstructor(...args); + }, + }; +}); + +vi.mock("react-native", () => ({ + Alert: { + alert: vi.fn(), + }, + AppState: { + currentState: "active", + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, +})); + +vi.mock("@t3tools/client-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + WsTransport: mocks.WsTransport, + createEnvironmentConnection: mocks.createEnvironmentConnection, + createKnownEnvironment: mocks.createKnownEnvironment, + createWsRpcClient: mocks.createWsRpcClient, + remoteEndpointUrl: mocks.remoteEndpointUrl, + resolveRemoteDpopWebSocketConnectionUrl: mocks.resolveRemoteDpopWebSocketConnectionUrl, + resolveRemoteWebSocketConnectionUrl: mocks.resolveRemoteWebSocketConnectionUrl, + }; +}); + +vi.mock("../lib/connection", async (importOriginal) => ({ + ...(await importOriginal()), + bootstrapRemoteConnection: mocks.bootstrapRemoteConnection, +})); + +vi.mock("../features/cloud/linkEnvironment", () => ({ + refreshCloudEnvironmentConnection: mocks.refreshCloudEnvironmentConnection, +})); + +vi.mock("../lib/storage", () => ({ + clearCachedShellSnapshot: mocks.clearCachedShellSnapshot, + clearSavedConnection: mocks.clearSavedConnection, + loadCachedShellSnapshot: vi.fn(() => Promise.resolve(null)), + loadSavedConnections: vi.fn(() => Promise.resolve([])), + saveCachedShellSnapshot: mocks.saveCachedShellSnapshot, + saveConnection: mocks.saveConnection, +})); + +vi.mock("../lib/runtime", () => ({ + mobileRuntime: { + runPromise: mocks.mobileRunPromise, + }, +})); + +vi.mock("./environment-session-registry", () => ({ + drainEnvironmentSessions: vi.fn(() => []), + getEnvironmentSession: mocks.getEnvironmentSession, + notifyEnvironmentConnectionListeners: mocks.notifyEnvironmentConnectionListeners, + removeEnvironmentSession: mocks.removeEnvironmentSession, + setEnvironmentSession: mocks.setEnvironmentSession, +})); + +vi.mock("../features/agent-awareness/shellLiveActivitySync", () => ({ + startAgentAwarenessForEnvironment: mocks.startAgentAwarenessForEnvironment, + stopAgentAwarenessForEnvironment: mocks.stopAgentAwarenessForEnvironment, + stopAllAgentAwareness: vi.fn(), +})); + +vi.mock("../features/terminal/terminalDebugLog", () => ({ + terminalDebugLog: mocks.terminalDebugLog, +})); + +vi.mock("./use-environment-runtime", () => ({ + environmentRuntimeManager: { + invalidate: mocks.environmentRuntimeInvalidate, + patch: mocks.environmentRuntimePatch, + }, + useEnvironmentRuntimeStates: vi.fn(() => ({})), +})); + +vi.mock("./use-shell-snapshot", () => ({ + clearCachedShellSnapshotMetadata: mocks.clearCachedShellSnapshotMetadata, + hydrateCachedShellSnapshot: vi.fn(), + markShellSnapshotLive: vi.fn(), + shellSnapshotManager: { + applyEvent: vi.fn(), + invalidate: mocks.shellSnapshotInvalidate, + markPending: mocks.shellSnapshotMarkPending, + syncSnapshot: vi.fn(), + }, +})); + +vi.mock("./use-source-control-discovery", () => ({ + invalidateSourceControlDiscoveryForEnvironment: + mocks.invalidateSourceControlDiscoveryForEnvironment, + resetSourceControlDiscoveryState: vi.fn(), +})); + +vi.mock("./use-terminal-session", () => ({ + subscribeTerminalMetadata: mocks.subscribeTerminalMetadata, + terminalSessionManager: { + invalidate: vi.fn(), + invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment, + }, +})); + +import { + connectSavedEnvironment, + disconnectEnvironment, + reconnectEnvironmentConnectionsAfterAppResume, +} from "./use-remote-environment-registry"; +import { appAtomRegistry } from "./atom-registry"; + +const environmentId = EnvironmentId.make("env-mobile-test"); + +const connection = { + environmentId, + environmentLabel: "Mobile Test Desktop", + pairingUrl: "https://desktop.example/", + displayUrl: "https://desktop.example/", + httpBaseUrl: "https://desktop.example/", + wsBaseUrl: "wss://desktop.example/", + bearerToken: "remote-access-token", +} as const; + +describe("mobile remote environment registry effects", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.createEnvironmentConnection.mockReturnValue(mocks.environmentConnection); + mocks.environmentConnection.ensureBootstrapped.mockResolvedValue(undefined); + mocks.environmentConnection.dispose.mockResolvedValue(undefined); + mocks.sessionConnection.dispose.mockResolvedValue(undefined); + mocks.sessionConnection.reconnect.mockResolvedValue(undefined); + mocks.sessionClient.isHeartbeatFresh.mockReturnValue(false); + mocks.removeEnvironmentSession.mockReturnValue(null); + mocks.getEnvironmentSession.mockReturnValue(null); + mocks.mobileRunPromise.mockResolvedValue("wss://desktop.example/ws?wsTicket=token"); + mocks.createDpopProof.mockReturnValue(Effect.succeed("dpop-proof")); + mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.die("unexpected refresh")); + mocks.resolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( + Effect.succeed("wss://desktop.example/ws?wsTicket=dpop-token"), + ); + setManagedRelaySession(appAtomRegistry, null); + }); + + it.effect("connects a saved managed endpoint environment through Effect-wrapped APIs", () => + Effect.gen(function* () { + yield* connectSavedEnvironment(connection); + + expect(mocks.saveConnection).toHaveBeenCalledWith(connection); + expect(mocks.wsTransportConstructor).toHaveBeenCalledTimes(1); + expect(mocks.createEnvironmentConnection).toHaveBeenCalledTimes(1); + expect(mocks.setEnvironmentSession).toHaveBeenCalledWith( + connection.environmentId, + expect.objectContaining({ + connection: mocks.environmentConnection, + }), + ); + expect(mocks.subscribeTerminalMetadata).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: connection.environmentId }), + ); + expect(mocks.startAgentAwarenessForEnvironment).toHaveBeenCalledWith(connection); + expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); + }), + ); + + it.effect("uses DPoP-bound admission for a managed DPoP connection", () => + Effect.gen(function* () { + const dpopConnection = { + ...connection, + bearerToken: null, + authenticationMethod: "dpop", + dpopAccessToken: "environment-dpop-token", + } as const; + mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => + Effect.runPromise( + (effect as Effect.Effect).pipe( + Effect.provideService( + ManagedRelayDpopSigner, + ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("mobile-key-thumbprint"), + createProof: mocks.createDpopProof, + }), + ), + ), + ), + ); + + yield* connectSavedEnvironment(dpopConnection); + const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as + | (() => Promise) + | undefined; + expect(openSocket).toBeDefined(); + yield* Effect.promise(() => openSocket!()); + + expect(mocks.createDpopProof).toHaveBeenCalledWith({ + method: "POST", + url: "https://desktop.example/api/auth/websocket-ticket", + accessToken: "environment-dpop-token", + }); + expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ + wsBaseUrl: dpopConnection.wsBaseUrl, + httpBaseUrl: dpopConnection.httpBaseUrl, + accessToken: "environment-dpop-token", + dpopProof: "dpop-proof", + }); + expect(mocks.resolveRemoteWebSocketConnectionUrl).not.toHaveBeenCalled(); + }), + ); + + it.effect("refreshes a persisted managed connection before reconnecting", () => + Effect.gen(function* () { + const savedDpopConnection = { + ...connection, + bearerToken: null, + authenticationMethod: "dpop", + relayManaged: true, + } as const; + const refreshedConnection = { + ...savedDpopConnection, + displayUrl: "https://rotated-desktop.example/", + httpBaseUrl: "https://rotated-desktop.example/", + wsBaseUrl: "wss://rotated-desktop.example/", + dpopAccessToken: "fresh-environment-dpop-token", + } as const; + setManagedRelaySession( + appAtomRegistry, + createManagedRelaySession({ + accountId: "account-1", + readClerkToken: () => Promise.resolve("fresh-clerk-token"), + }), + ); + mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.succeed(refreshedConnection)); + mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => + Effect.runPromise( + (effect as Effect.Effect).pipe( + Effect.provideService( + ManagedRelayDpopSigner, + ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("mobile-key-thumbprint"), + createProof: mocks.createDpopProof, + }), + ), + ), + ), + ); + + yield* connectSavedEnvironment(savedDpopConnection, { persist: false }); + const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as + | (() => Promise) + | undefined; + expect(openSocket).toBeDefined(); + yield* Effect.promise(() => openSocket!()); + + expect(mocks.refreshCloudEnvironmentConnection).toHaveBeenCalledWith({ + clerkToken: "fresh-clerk-token", + connection: savedDpopConnection, + }); + const persistedConnection = mocks.saveConnection.mock.calls[0]?.[0]; + expect(persistedConnection).toMatchObject({ + ...savedDpopConnection, + displayUrl: refreshedConnection.displayUrl, + httpBaseUrl: refreshedConnection.httpBaseUrl, + wsBaseUrl: refreshedConnection.wsBaseUrl, + }); + expect(persistedConnection).not.toHaveProperty("dpopAccessToken"); + expect(mocks.createDpopProof).toHaveBeenCalledWith({ + method: "POST", + url: "https://rotated-desktop.example/api/auth/websocket-ticket", + accessToken: "fresh-environment-dpop-token", + }); + expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ + wsBaseUrl: refreshedConnection.wsBaseUrl, + httpBaseUrl: refreshedConnection.httpBaseUrl, + accessToken: "fresh-environment-dpop-token", + dpopProof: "dpop-proof", + }); + }), + ); + + it.effect("fails interactive connects when the managed endpoint bootstrap fails", () => + Effect.gen(function* () { + mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( + new Error("bootstrap failed"), + ); + mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ + connection: mocks.sessionConnection, + } as never); + + const result = yield* Effect.exit(connectSavedEnvironment(connection)); + + expect(result._tag).toBe("Failure"); + expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( + { environmentId: connection.environmentId }, + expect.any(Function), + ); + expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); + expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); + expect(mocks.startAgentAwarenessForEnvironment).not.toHaveBeenCalled(); + }), + ); + + it.effect("can suppress bootstrap failures during best-effort startup reconnect", () => + Effect.gen(function* () { + mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( + new Error("bootstrap failed"), + ); + mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ + connection: mocks.sessionConnection, + } as never); + + yield* connectSavedEnvironment(connection, { + persist: false, + suppressBootstrapError: true, + }); + + expect(mocks.saveConnection).not.toHaveBeenCalled(); + expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); + expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); + expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); + expect(mocks.startAgentAwarenessForEnvironment).not.toHaveBeenCalled(); + expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( + { environmentId: connection.environmentId }, + expect.any(Function), + ); + }), + ); + + it.effect("reconnects a stale saved environment session after app resume", () => + Effect.gen(function* () { + yield* connectSavedEnvironment(connection); + vi.clearAllMocks(); + mocks.getEnvironmentSession.mockReturnValue({ + client: mocks.sessionClient, + connection: mocks.sessionConnection, + } as never); + + reconnectEnvironmentConnectionsAfterAppResume("test"); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(mocks.sessionConnection.reconnect).toHaveBeenCalledTimes(1); + }), + ); + expect(mocks.shellSnapshotMarkPending).toHaveBeenCalledWith({ + environmentId: connection.environmentId, + }); + expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( + { environmentId: connection.environmentId }, + expect.any(Function), + ); + }), + ); + + it.effect("disconnects and removes persisted managed endpoint state when requested", () => + Effect.gen(function* () { + mocks.removeEnvironmentSession.mockReturnValue({ + connection: mocks.sessionConnection, + } as never); + + yield* disconnectEnvironment(connection.environmentId, { removeSaved: true }); + + expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); + expect(mocks.stopAgentAwarenessForEnvironment).toHaveBeenCalledWith(connection.environmentId); + expect(mocks.clearSavedConnection).toHaveBeenCalledWith(connection.environmentId); + expect(mocks.clearCachedShellSnapshot).toHaveBeenCalledWith(connection.environmentId); + expect(mocks.clearCachedShellSnapshotMetadata).toHaveBeenCalledWith(connection.environmentId); + }), + ); +}); diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 4e27cb27f8b..536a803d945 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; -import { Alert } from "react-native"; +import { Alert, AppState } from "react-native"; import { type EnvironmentRuntimeState, @@ -9,16 +9,28 @@ import { createKnownEnvironment, createWsRpcClient, EnvironmentConnectionState, + ManagedRelayDpopSigner, WsTransport, + remoteEndpointUrl, + resolveRemoteDpopWebSocketConnectionUrl, resolveRemoteWebSocketConnectionUrl, + waitForManagedRelayClerkToken, } from "@t3tools/client-runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Arr from "effect/Array"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; import * as Order from "effect/Order"; import * as Option from "effect/Option"; import { pipe } from "effect/Function"; import { Atom } from "effect/unstable/reactivity"; -import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection"; +import { + type SavedRemoteConnection, + bootstrapRemoteConnection, + isRelayManagedConnection, + toStableSavedRemoteConnection, +} from "../lib/connection"; +import { refreshCloudEnvironmentConnection } from "../features/cloud/linkEnvironment"; import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; import { clearCachedShellSnapshot, @@ -29,9 +41,10 @@ import { saveConnection, } from "../lib/storage"; import { appAtomRegistry } from "./atom-registry"; -import { mobileRemoteHttpRuntime } from "../lib/runtime"; +import { mobileRuntime } from "../lib/runtime"; import { drainEnvironmentSessions, + getEnvironmentSession, notifyEnvironmentConnectionListeners, removeEnvironmentSession, setEnvironmentSession, @@ -41,6 +54,11 @@ import { invalidateSourceControlDiscoveryForEnvironment, resetSourceControlDiscoveryState, } from "./use-source-control-discovery"; +import { + startAgentAwarenessForEnvironment, + stopAgentAwarenessForEnvironment, + stopAllAgentAwareness, +} from "../features/agent-awareness/shellLiveActivitySync"; import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; import { clearCachedShellSnapshotMetadata, @@ -53,6 +71,8 @@ import { subscribeTerminalMetadata, terminalSessionManager } from "./use-termina const terminalMetadataUnsubscribers = new Map void>(); const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000; +const APP_RESUME_RECONNECT_COOLDOWN_MS = 2_000; +let lastAppResumeReconnectAt = Number.NEGATIVE_INFINITY; interface RemoteEnvironmentLocalState { readonly isLoadingSavedConnection: boolean; @@ -153,225 +173,357 @@ function setEnvironmentConnectionStatus( })); } -function withTimeout( - promise: Promise, - timeoutMs: number, - timeoutMessage: string, -): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(timeoutMessage)); - }, timeoutMs); - - promise.then( - (value) => { - clearTimeout(timeoutId); - resolve(value); - }, - (error: unknown) => { - clearTimeout(timeoutId); - reject(error); - }, - ); +function fromPromise(tryPromise: () => Promise): Effect.Effect { + return Effect.tryPromise({ + try: tryPromise, + catch: (cause) => cause, }); } -export async function disconnectEnvironment( +export function disconnectEnvironment( environmentId: EnvironmentId, options?: { readonly preserveShellSnapshot?: boolean; readonly removeSaved?: boolean; readonly preserveConnectionAttempt?: boolean; }, -) { - if (!options?.preserveConnectionAttempt) { - environmentConnectionAttempts.cancel(environmentId); - } +): Effect.Effect { + return Effect.gen(function* () { + if (!options?.preserveConnectionAttempt) { + environmentConnectionAttempts.cancel(environmentId); + } - const session = removeEnvironmentSession(environmentId); - notifyEnvironmentConnectionListeners(); - await session?.connection.dispose(); - terminalMetadataUnsubscribers.get(environmentId)?.(); - terminalMetadataUnsubscribers.delete(environmentId); - if (!options?.preserveShellSnapshot) { - shellSnapshotManager.invalidate({ environmentId }); - } - invalidateSourceControlDiscoveryForEnvironment(environmentId); - terminalSessionManager.invalidateEnvironment(environmentId); - environmentRuntimeManager.invalidate({ environmentId }); - - if (options?.removeSaved) { - await clearSavedConnection(environmentId); - await clearCachedShellSnapshot(environmentId); - clearCachedShellSnapshotMetadata(environmentId); - removeSavedConnection(environmentId); - } + const session = removeEnvironmentSession(environmentId); + notifyEnvironmentConnectionListeners(); + if (session) { + yield* fromPromise(() => session.connection.dispose()); + } + terminalMetadataUnsubscribers.get(environmentId)?.(); + terminalMetadataUnsubscribers.delete(environmentId); + stopAgentAwarenessForEnvironment(environmentId); + if (!options?.preserveShellSnapshot) { + shellSnapshotManager.invalidate({ environmentId }); + } + invalidateSourceControlDiscoveryForEnvironment(environmentId); + terminalSessionManager.invalidateEnvironment(environmentId); + environmentRuntimeManager.invalidate({ environmentId }); + + if (options?.removeSaved) { + yield* Effect.all( + [ + fromPromise(() => clearSavedConnection(environmentId)), + fromPromise(() => clearCachedShellSnapshot(environmentId)), + ], + { concurrency: 2 }, + ); + clearCachedShellSnapshotMetadata(environmentId); + removeSavedConnection(environmentId); + } + }); } -export async function connectSavedEnvironment( +export function connectSavedEnvironment( connection: SavedRemoteConnection, - options?: { readonly persist?: boolean }, -) { - const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); - const isCurrentAttempt = connectionAttempt.isCurrent; - - await disconnectEnvironment(connection.environmentId, { - preserveShellSnapshot: true, - preserveConnectionAttempt: true, - }); - if (!isCurrentAttempt()) { - return; - } - - if (options?.persist !== false) { - await saveConnection(connection); + options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean }, +): Effect.Effect { + return Effect.gen(function* () { + const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); + const isCurrentAttempt = connectionAttempt.isCurrent; + let activeConnection = connection; + let initialDpopAccessToken = + options?.persist === false ? undefined : connection.dpopAccessToken; + + yield* disconnectEnvironment(connection.environmentId, { + preserveShellSnapshot: true, + preserveConnectionAttempt: true, + }); if (!isCurrentAttempt()) { return; } - } - upsertSavedConnection(connection); - setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); - shellSnapshotManager.markPending({ environmentId: connection.environmentId }); + if (options?.persist !== false) { + yield* fromPromise(() => saveConnection(toStableSavedRemoteConnection(connection))); + if (!isCurrentAttempt()) { + return; + } + } + + upsertSavedConnection(toStableSavedRemoteConnection(connection)); + setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); + shellSnapshotManager.markPending({ environmentId: connection.environmentId }); + + const transport = new WsTransport( + () => + mobileRuntime.runPromise( + isRelayManagedConnection(connection) + ? Effect.gen(function* () { + let dpopAccessToken = initialDpopAccessToken; + initialDpopAccessToken = undefined; + if (!dpopAccessToken) { + const clerkToken = yield* waitForManagedRelayClerkToken(appAtomRegistry); + const refreshedConnection = yield* refreshCloudEnvironmentConnection({ + clerkToken, + connection: activeConnection, + }); + const stableConnection = toStableSavedRemoteConnection(refreshedConnection); + activeConnection = refreshedConnection; + if (isCurrentAttempt()) { + yield* fromPromise(() => saveConnection(stableConnection)); + upsertSavedConnection(stableConnection); + } + dpopAccessToken = refreshedConnection.dpopAccessToken; + } + if (!dpopAccessToken) { + return yield* Effect.fail( + new Error("Managed environment connection did not return a DPoP access token."), + ); + } + const signer = yield* ManagedRelayDpopSigner; + const dpop = yield* signer.createProof({ + method: "POST", + url: remoteEndpointUrl( + activeConnection.httpBaseUrl, + "/api/auth/websocket-ticket", + ), + accessToken: dpopAccessToken, + }); + return yield* resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: activeConnection.wsBaseUrl, + httpBaseUrl: activeConnection.httpBaseUrl, + accessToken: dpopAccessToken, + dpopProof: dpop, + }); + }) + : resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: connection.wsBaseUrl, + httpBaseUrl: connection.httpBaseUrl, + bearerToken: connection.bearerToken ?? "", + }), + ), + { + onAttempt: () => { + if (!isCurrentAttempt()) { + return; + } - const transport = new WsTransport( - () => - mobileRemoteHttpRuntime.runPromise( - resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: connection.wsBaseUrl, - httpBaseUrl: connection.httpBaseUrl, - bearerToken: connection.bearerToken, + environmentRuntimeManager.patch( + { environmentId: connection.environmentId }, + (previous) => { + const nextState = + previous.connectionState === "ready" || previous.connectionState === "reconnecting" + ? "reconnecting" + : "connecting"; + const keepSettledFailure = + previous.connectionState === "disconnected" && previous.connectionError !== null; + return { + ...previous, + connectionState: keepSettledFailure ? "disconnected" : nextState, + connectionError: keepSettledFailure ? previous.connectionError : null, + }; + }, + ); + }, + onError: (message) => { + if (isCurrentAttempt()) { + setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); + } + }, + onClose: (details) => { + if (!isCurrentAttempt()) { + return; + } + + const reason = + details.reason.trim().length > 0 + ? details.reason + : details.code === 1000 + ? null + : `Remote connection closed (${details.code}).`; + setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); + }, + }, + ); + + const client = createWsRpcClient(transport); + const environmentConnection = createEnvironmentConnection({ + kind: "saved", + knownEnvironment: { + ...createKnownEnvironment({ + id: connection.environmentId, + label: connection.environmentLabel, + source: "manual", + target: { + httpBaseUrl: connection.httpBaseUrl, + wsBaseUrl: connection.wsBaseUrl, + }, }), - ), - { - onAttempt: () => { + environmentId: connection.environmentId, + }, + client, + applyShellEvent: (event, environmentId) => { + if (isCurrentAttempt()) { + shellSnapshotManager.applyEvent({ environmentId }, event); + } + }, + syncShellSnapshot: (snapshot, environmentId) => { if (!isCurrentAttempt()) { return; } - environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => { - const nextState = - previous.connectionState === "ready" || previous.connectionState === "reconnecting" - ? "reconnecting" - : "connecting"; - const keepSettledFailure = - previous.connectionState === "disconnected" && previous.connectionError !== null; - return { - ...previous, - connectionState: keepSettledFailure ? "disconnected" : nextState, - connectionError: keepSettledFailure ? previous.connectionError : null, - }; - }); + shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); + markShellSnapshotLive(environmentId); + void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined); + environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ + ...runtime, + connectionState: "ready", + connectionError: null, + })); }, - onError: (message) => { + onShellResubscribe: (environmentId) => { if (isCurrentAttempt()) { - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); + shellSnapshotManager.markPending({ environmentId }); } }, - onClose: (details) => { - if (!isCurrentAttempt()) { - return; + onConfigSnapshot: (serverConfig) => { + if (isCurrentAttempt()) { + environmentRuntimeManager.patch( + { environmentId: connection.environmentId }, + (runtime) => ({ + ...runtime, + serverConfig, + }), + ); } - - const reason = - details.reason.trim().length > 0 - ? details.reason - : details.code === 1000 - ? null - : `Remote connection closed (${details.code}).`; - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); }, - }, - ); + }); - const client = createWsRpcClient(transport); - const environmentConnection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - ...createKnownEnvironment({ - id: connection.environmentId, - label: connection.environmentLabel, - source: "manual", - target: { - httpBaseUrl: connection.httpBaseUrl, - wsBaseUrl: connection.wsBaseUrl, - }, + if (!isCurrentAttempt()) { + yield* fromPromise(() => environmentConnection.dispose()); + return; + } + + setEnvironmentSession(connection.environmentId, { + client, + connection: environmentConnection, + }); + + const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe( + Effect.timeoutOption(Duration.millis(SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS)), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail(new Error("Environment did not respond before the connection timeout.")), + onSome: Effect.succeed, + }), + ), + Effect.tapError((error: unknown) => + isCurrentAttempt() + ? Effect.gen(function* () { + setEnvironmentConnectionStatus( + connection.environmentId, + "disconnected", + error instanceof Error ? error.message : "Failed to bootstrap remote connection.", + ); + const pendingSession = removeEnvironmentSession(connection.environmentId); + notifyEnvironmentConnectionListeners(); + if (pendingSession) { + yield* fromPromise(() => pendingSession.connection.dispose()); + } + }) + : Effect.void, + ), + ); + const bootstrapped = yield* options?.suppressBootstrapError + ? bootstrap.pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ) + : bootstrap.pipe(Effect.as(true)); + + if (!bootstrapped || !isCurrentAttempt()) { + return; + } + + terminalMetadataUnsubscribers.set( + connection.environmentId, + subscribeTerminalMetadata({ + environmentId: connection.environmentId, + client, }), + ); + terminalDebugLog("registry:terminal-metadata-subscribed", { environmentId: connection.environmentId, - }, - client, - applyShellEvent: (event, environmentId) => { - if (isCurrentAttempt()) { - shellSnapshotManager.applyEvent({ environmentId }, event); - } - }, - syncShellSnapshot: (snapshot, environmentId) => { - if (!isCurrentAttempt()) { - return; - } - - shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); - markShellSnapshotLive(environmentId); - void saveCachedShellSnapshot(environmentId, snapshot); - environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ - ...runtime, - connectionState: "ready", - connectionError: null, - })); - }, - onShellResubscribe: (environmentId) => { - if (isCurrentAttempt()) { - shellSnapshotManager.markPending({ environmentId }); - } - }, - onConfigSnapshot: (serverConfig) => { - if (isCurrentAttempt()) { - environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (runtime) => ({ - ...runtime, - serverConfig, - })); - } - }, + }); + startAgentAwarenessForEnvironment(toStableSavedRemoteConnection(activeConnection)); + notifyEnvironmentConnectionListeners(); }); +} - if (!isCurrentAttempt()) { - await environmentConnection.dispose(); +export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void { + const now = Date.now(); + if (now - lastAppResumeReconnectAt < APP_RESUME_RECONNECT_COOLDOWN_MS) { return; } - setEnvironmentSession(connection.environmentId, { - client, - connection: environmentConnection, - }); - terminalMetadataUnsubscribers.set( - connection.environmentId, - subscribeTerminalMetadata({ - environmentId: connection.environmentId, - client, - }), - ); - terminalDebugLog("registry:terminal-metadata-subscribed", { - environmentId: connection.environmentId, - }); - notifyEnvironmentConnectionListeners(); + for (const connection of Object.values(getSavedConnectionsById())) { + const session = getEnvironmentSession(connection.environmentId); + if (session?.client.isHeartbeatFresh()) { + continue; + } - try { - await withTimeout( - environmentConnection.ensureBootstrapped(), - SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS, - "Environment did not respond before the connection timeout.", - ); - } catch (error) { - if (isCurrentAttempt()) { - setEnvironmentConnectionStatus( - connection.environmentId, - "disconnected", - error instanceof Error ? error.message : "Failed to bootstrap remote connection.", - ); + lastAppResumeReconnectAt = now; + terminalDebugLog("registry:app-resume-reconnect", { + environmentId: connection.environmentId, + reason, + hasSession: session !== null, + }); + + if (!session) { + void mobileRuntime + .runPromise( + connectSavedEnvironment(connection, { + persist: false, + suppressBootstrapError: true, + }), + ) + .catch((error: unknown) => { + terminalDebugLog("registry:app-resume-reconnect-failed", { + environmentId: connection.environmentId, + reason, + error: error instanceof Error ? error.message : String(error), + }); + }); + continue; } + + setEnvironmentConnectionStatus(connection.environmentId, "reconnecting", null); + shellSnapshotManager.markPending({ environmentId: connection.environmentId }); + void session.connection.reconnect().catch((error: unknown) => { + const message = + error instanceof Error ? error.message : "Failed to reconnect remote environment."; + setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); + terminalDebugLog("registry:app-resume-reconnect-failed", { + environmentId: connection.environmentId, + reason, + error: message, + }); + }); } } +function subscribeAppResumeReconnects(): () => void { + let previousAppState = AppState.currentState; + const subscription = AppState.addEventListener("change", (nextAppState) => { + const wasInactive = previousAppState !== "active"; + previousAppState = nextAppState; + if (nextAppState === "active" && wasInactive) { + reconnectEnvironmentConnectionsAfterAppResume("appstate"); + } + }); + + return () => subscription.remove(); +} + const environmentsSortOrder = Order.mapInput( Order.Struct({ environmentLabel: Order.String, @@ -392,6 +544,7 @@ function deriveConnectedEnvironments( environmentId: connection.environmentId, environmentLabel: connection.environmentLabel, displayUrl: connection.displayUrl, + isRelayManaged: isRelayManagedConnection(connection), connectionState: runtime?.connectionState ?? "idle", connectionError: runtime?.connectionError ?? null, }; @@ -403,9 +556,11 @@ function deriveConnectedEnvironments( export function useRemoteEnvironmentBootstrap() { useEffect(() => { let cancelled = false; + const unsubscribeAppResumeReconnects = subscribeAppResumeReconnects(); - void loadSavedConnections() - .then((connections) => { + void (async () => { + try { + const connections = await loadSavedConnections(); if (cancelled) { return; } @@ -418,37 +573,40 @@ export function useRemoteEnvironmentBootstrap() { setIsLoadingSavedConnection(false); - void (async () => { - await Promise.all( - connections.map(async (connection) => { - const cached = await loadCachedShellSnapshot(connection.environmentId); - if (!cancelled && cached) { - hydrateCachedShellSnapshot(cached); - } - }), - ); + await Promise.all( + connections.map(async (connection) => { + const cached = await loadCachedShellSnapshot(connection.environmentId); + if (!cancelled && cached) { + hydrateCachedShellSnapshot(cached); + } + }), + ); - if (cancelled) { - return; - } + if (cancelled) { + return; + } - await Promise.all( + await mobileRuntime.runPromise( + Effect.all( connections.map((connection) => connectSavedEnvironment(connection, { persist: false, + suppressBootstrapError: true, }), ), - ); - })(); - }) - .catch(() => { + { concurrency: "unbounded" }, + ), + ); + } catch { if (!cancelled) { setIsLoadingSavedConnection(false); } - }); + } + })(); return () => { cancelled = true; + unsubscribeAppResumeReconnects(); for (const session of drainEnvironmentSessions()) { void session.connection.dispose(); } @@ -457,6 +615,7 @@ export function useRemoteEnvironmentBootstrap() { } terminalMetadataUnsubscribers.clear(); environmentConnectionAttempts.clear(); + stopAllAgentAwareness(); environmentRuntimeManager.invalidate(); shellSnapshotManager.invalidate(); resetSourceControlDiscoveryState(); @@ -538,7 +697,7 @@ export function useRemoteConnections() { const nextPairingUrl = pairingUrl ?? connectionPairingUrl; const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); clearPendingConnectionError(); - await connectSavedEnvironment(connection); + await mobileRuntime.runPromise(connectSavedEnvironment(connection)); clearConnectionPairingUrl(); } catch (error) { setPendingConnectionError( @@ -556,7 +715,7 @@ export function useRemoteConnections() { updates: { readonly label: string; readonly displayUrl: string }, ) => { const connection = getSavedConnectionsById()[environmentId]; - if (!connection) { + if (!connection || isRelayManagedConnection(connection)) { return; } @@ -577,7 +736,14 @@ export function useRemoteConnections() { if (!connection) { return; } - void connectSavedEnvironment(connection, { persist: false }); + void mobileRuntime + .runPromise( + connectSavedEnvironment(connection, { + persist: false, + suppressBootstrapError: true, + }), + ) + .catch(() => undefined); }, []); const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { @@ -595,7 +761,9 @@ export function useRemoteConnections() { text: "Remove", style: "destructive", onPress: () => { - void disconnectEnvironment(environmentId, { removeSaved: true }); + void mobileRuntime + .runPromise(disconnectEnvironment(environmentId, { removeSaved: true })) + .catch(() => undefined); }, }, ], diff --git a/apps/mobile/src/widgets/AgentActivity.test.ts b/apps/mobile/src/widgets/AgentActivity.test.ts new file mode 100644 index 00000000000..03ba05283cb --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivity.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@expo/ui/swift-ui", () => ({ + HStack: "HStack", + Spacer: "Spacer", + Text: "Text", + VStack: "VStack", +})); + +vi.mock("@expo/ui/swift-ui/modifiers", () => ({ + font: (value: unknown) => value, + foregroundStyle: (value: unknown) => value, + lineLimit: (value: unknown) => value, + padding: (value: unknown) => value, +})); + +vi.mock("expo-widgets", () => ({ + createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })), +})); + +import { AgentActivity, type AgentActivityProps } from "./AgentActivity"; + +const props = { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: "2026-05-25T13:07:00.000Z", + activities: [], +} satisfies AgentActivityProps; + +const environment = { + colorScheme: "dark", + isLuminanceReduced: false, +} as const; + +describe("AgentActivity widget layout", () => { + it("formats its updated-at label without app-runtime helper references", () => { + expect(JSON.stringify(AgentActivity(props, environment as never))).toContain( + '"children":["Updated ","1:07"]', + ); + expect(AgentActivity.toString()).not.toContain("formatAgentActivityUpdatedAtLabel"); + }); + + it("uses now when the updated-at timestamp is malformed", () => { + expect( + JSON.stringify(AgentActivity({ ...props, updatedAt: "not-a-date" }, environment as never)), + ).toContain('"children":["Updated ","now"]'); + }); +}); diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx new file mode 100644 index 00000000000..5cbd6c442f5 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -0,0 +1,321 @@ +import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { font, foregroundStyle, lineLimit, padding } from "@expo/ui/swift-ui/modifiers"; +import { + createLiveActivity, + type LiveActivityComponent, + type LiveActivityLayout, +} from "expo-widgets"; + +type LiveActivityEnvironment = Parameters>[1]; + +export type AgentActivityPhase = + | "starting" + | "running" + | "waiting_for_approval" + | "waiting_for_input" + | "completed" + | "failed" + | "stale"; + +export interface AgentActivityRowProps { + readonly environmentId: string; + readonly threadId: string; + readonly projectTitle: string; + readonly threadTitle: string; + readonly modelTitle: string; + readonly phase: AgentActivityPhase; + readonly status: string; + readonly updatedAt: string; + readonly deepLink: string; +} + +export interface AgentActivityProps { + readonly title: string; + readonly subtitle: string; + readonly activeCount: number; + readonly updatedAt: string; + readonly activities: ReadonlyArray; +} + +export function AgentActivity( + props: AgentActivityProps, + environment: LiveActivityEnvironment, +): LiveActivityLayout { + "widget"; + + const row0 = props.activities[0]; + const row1 = props.activities[1]; + const row2 = props.activities[2]; + const updatedAtMatch = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2}):/.exec(props.updatedAt); + const updatedAtHours24 = Number(updatedAtMatch?.[1]); + const updatedAtMinutes = updatedAtMatch?.[2]; + const updatedAt = + Number.isInteger(updatedAtHours24) && + updatedAtHours24 >= 0 && + updatedAtHours24 <= 23 && + updatedAtMinutes + ? `${updatedAtHours24 % 12 || 12}:${updatedAtMinutes}` + : "now"; + const activeLabel = `${props.activeCount} active`; + const isLight = environment.colorScheme === "light"; + const primaryForeground = isLight ? "#0f172a" : "#ffffff"; + const secondaryForeground = isLight ? "#475569" : "#cbd5e1"; + const mutedForeground = isLight ? "#64748b" : "#94a3b8"; + const tint = environment.isLuminanceReduced + ? secondaryForeground + : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" + ? "#f97316" + : row0?.phase === "failed" + ? "#ef4444" + : "#14b8a6"; + + return { + banner: ( + + + + + {props.title} + + + {props.subtitle} + + + + + {activeLabel} + + + {row0 ? ( + + + + {row0.threadTitle} + + + {row0.projectTitle} - {row0.modelTitle} + + + + + {row0.status} + + + ) : null} + {row1 ? ( + + + + {row1.threadTitle} + + + {row1.projectTitle} - {row1.modelTitle} + + + + + {row1.status} + + + ) : null} + {row2 ? ( + + + + {row2.threadTitle} + + + {row2.projectTitle} - {row2.modelTitle} + + + + + {row2.status} + + + ) : null} + + Updated {updatedAt} + + + ), + bannerSmall: ( + + + + {props.title} + + + + {activeLabel} + + + {row0 ? ( + + + {row0.threadTitle} + + + {row0.projectTitle} - {row0.status} + + + ) : null} + + ), + compactLeading: ( + T3 + ), + compactTrailing: ( + + {activeLabel} + + ), + minimal: ( + T3 + ), + expandedLeading: ( + + + {activeLabel} + + + ), + expandedCenter: row0 ? ( + + + {row0.threadTitle} + + + {row0.projectTitle} - {row0.status} + + + ) : null, + expandedTrailing: ( + + Updated {updatedAt} + + ), + expandedBottom: ( + + {row0 ? ( + + + + {row0.threadTitle} + + + {row0.projectTitle} - {row0.modelTitle} + + + + + {row0.status} + + + ) : null} + {row1 ? ( + + + + {row1.threadTitle} + + + {row1.projectTitle} - {row1.modelTitle} + + + + + {row1.status} + + + ) : null} + {row2 ? ( + + + + {row2.threadTitle} + + + {row2.projectTitle} - {row2.modelTitle} + + + + + {row2.status} + + + ) : null} + + ), + }; +} + +export default createLiveActivity("AgentActivity", AgentActivity); diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 837c32fc4fd..4e3d483a9f4 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -77,6 +77,7 @@ import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; +import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; const decodeCodexSettings = Schema.decodeEffect(CodexSettings); @@ -364,6 +365,12 @@ export const makeOrchestrationIntegrationHarness = ( drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { + publishThread: () => Effect.void, + start: () => Effect.void, + }), + ), ); const layer = Layer.empty.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index abb1f7b792e..d8c0079089f 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -31,6 +32,7 @@ import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; +import { verifyRequestDpopProof } from "./dpop.ts"; import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; @@ -61,6 +63,7 @@ export interface AuthenticatedSession { readonly subject: string; readonly method: ServerAuthSessionMethod; readonly scopes: ReadonlyArray; + readonly proofKeyThumbprint?: string; readonly expiresAt?: DateTime.DateTime; } @@ -107,6 +110,9 @@ export interface EnvironmentAuthShape { credential: string, requestedScopes: ReadonlyArray | undefined, requestMetadata: AuthClientMetadata, + input?: { + readonly proofKeyThumbprint?: string; + }, ) => Effect.Effect< AuthAccessTokenResult, ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError @@ -116,6 +122,7 @@ export interface EnvironmentAuthShape { readonly label?: string; readonly scopes?: ReadonlyArray; readonly subject?: string; + readonly proofKeyThumbprint?: string; }) => Effect.Effect; readonly issuePairingCredential: ( input?: AuthCreatePairingCredentialInput, @@ -184,6 +191,7 @@ type BootstrapExchangeResult = { }; const AUTHORIZATION_PREFIX = "Bearer "; +const DPOP_AUTHORIZATION_PREFIX = "DPoP "; const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { @@ -245,10 +253,21 @@ function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string return token.length > 0 ? token : null; } +function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | null { + const header = request.headers["authorization"]; + if (typeof header !== "string" || !header.startsWith(DPOP_AUTHORIZATION_PREFIX)) { + return null; + } + const token = header.slice(DPOP_AUTHORIZATION_PREFIX.length).trim(); + return token.length > 0 ? token : null; +} + export const make = Effect.fn("makeEnvironmentAuth")(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const crypto = yield* Crypto.Crypto; const descriptor = yield* policy.getDescriptor(); const authenticateToken = ( @@ -270,6 +289,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { subject: session.subject, method: session.method, scopes: session.scopes, + ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), })), mapSessionVerificationErrors, @@ -278,11 +298,43 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { const cookieToken = request.cookies[sessions.cookieName]; const bearerToken = parseBearerToken(request); - const credential = cookieToken ?? bearerToken; + const dpopToken = parseDpopToken(request); + const credential = cookieToken ?? bearerToken ?? dpopToken; if (!credential) { return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); } - return authenticateToken(credential); + return authenticateToken(credential).pipe( + Effect.flatMap((session) => { + if (session.proofKeyThumbprint) { + if (!dpopToken || dpopToken !== credential) { + return Effect.fail( + new ServerAuthInvalidCredentialError({ + reason: "invalid_credential", + cause: "DPoP-bound access token requires DPoP authorization.", + }), + ); + } + return verifyRequestDpopProof({ + request, + expectedThumbprint: session.proofKeyThumbprint, + expectedAccessToken: dpopToken, + }).pipe( + Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), + Effect.provideService(Crypto.Crypto, crypto), + Effect.as(session), + ); + } + if (dpopToken) { + return Effect.fail( + new ServerAuthInvalidCredentialError({ + reason: "invalid_credential", + cause: "DPoP authorization requires a proof-bound access token.", + }), + ); + } + return Effect.succeed(session); + }), + ); }; const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => @@ -349,8 +401,8 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ); const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = - (credential, requestedScopes, requestMetadata) => - bootstrapCredentials.consume(credential).pipe( + (credential, requestedScopes, requestMetadata, input) => + bootstrapCredentials.consume(credential, input).pipe( Effect.mapError(toBootstrapExchangeError), Effect.flatMap((grant) => Effect.gen(function* () { @@ -362,9 +414,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { } return yield* sessions .issue({ - method: "bearer-access-token", + method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", subject: grant.subject, scopes: grantedScopes, + ...(input?.proofKeyThumbprint + ? { + proofKeyThumbprint: input.proofKeyThumbprint, + ttl: Duration.hours(1), + } + : {}), client: { ...requestMetadata, ...(grant.label ? { label: grant.label } : {}), @@ -388,7 +446,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ({ access_token: session.token, issued_token_type: AuthAccessTokenType, - token_type: "Bearer", + token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", expires_in: Math.max( 0, Math.floor( @@ -434,6 +492,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { subject: input?.subject ?? "one-time-token", ...(input?.ttl ? { ttl: input.ttl } : {}), ...(input?.label ? { label: input.label } : {}), + ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), }); return { id: issued.id, diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 3968ddb2531..205c85b0234 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -39,7 +39,7 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { const descriptor: ServerAuthDescriptor = { policy, bootstrapMethods, - sessionMethods: ["browser-session-cookie", "bearer-access-token"], + sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"], sessionCookieName: resolveSessionCookieName({ mode: config.mode, port: config.port, diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 0c33a753f7d..3861b4fc78f 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -92,6 +92,29 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); + it.effect("requires the bound proof key thumbprint when present", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const token = yield* bootstrapCredentials.issueOneTimeToken({ + proofKeyThumbprint: "client-proof-key-thumbprint", + }); + + const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential)); + const wrong = yield* Effect.flip( + bootstrapCredentials.consume(token.credential, { + proofKeyThumbprint: "other-proof-key-thumbprint", + }), + ); + const consumed = yield* bootstrapCredentials.consume(token.credential, { + proofKeyThumbprint: "client-proof-key-thumbprint", + }); + + expect(missing.message).toContain("proof key mismatch"); + expect(wrong.message).toContain("proof key mismatch"); + expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint"); + }).pipe(Effect.provide(makePairingGrantStoreLayer())), + ); + it.effect("seeds the desktop bootstrap credential as a one-time grant", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 4fcc133ebb4..e97696fbadd 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -26,6 +26,7 @@ export interface BootstrapGrant { readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; + readonly proofKeyThumbprint?: string; readonly expiresAt: DateTime.DateTime; } @@ -50,6 +51,7 @@ export interface IssuedBootstrapCredential { readonly id: string; readonly credential: string; readonly label?: string; + readonly proofKeyThumbprint?: string; readonly expiresAt: DateTime.Utc; } @@ -69,6 +71,7 @@ export interface PairingGrantStoreShape { readonly scopes?: ReadonlyArray; readonly subject?: string; readonly label?: string; + readonly proofKeyThumbprint?: string; }) => Effect.Effect; readonly listActive: () => Effect.Effect< ReadonlyArray, @@ -76,7 +79,12 @@ export interface PairingGrantStoreShape { >; readonly streamChanges: Stream.Stream; readonly revoke: (id: string) => Effect.Effect; - readonly consume: (credential: string) => Effect.Effect; + readonly consume: ( + credential: string, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect; } export class PairingGrantStore extends Context.Service()( @@ -232,6 +240,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { id, credential, ...(input?.label ? { label: input.label } : {}), + ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), expiresAt, }; yield* pairingLinks.create({ @@ -241,6 +250,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { scopes: input?.scopes ?? AuthStandardClientScopes, subject: input?.subject ?? "one-time-token", label: input?.label ?? null, + proofKeyThumbprint: input?.proofKeyThumbprint ?? null, createdAt: now, expiresAt: expiresAt, }); @@ -259,7 +269,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { ); const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")( - function* (credential) { + function* (credential, input) { const now = yield* DateTime.now; const seededResult: ConsumeResult = yield* Ref.modify( seededGrantsRef, @@ -289,6 +299,17 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { ]; } + if (grant.proofKeyThumbprint && grant.proofKeyThumbprint !== input?.proofKeyThumbprint) { + return [ + { + _tag: "error", + reason: "not-found", + error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."), + }, + next, + ]; + } + const remainingUses = grant.remainingUses; if (typeof remainingUses === "number") { if (remainingUses <= 1) { @@ -309,6 +330,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { scopes: grant.scopes, subject: grant.subject, ...(grant.label ? { label: grant.label } : {}), + ...(grant.proofKeyThumbprint + ? { proofKeyThumbprint: grant.proofKeyThumbprint } + : {}), expiresAt: grant.expiresAt, } satisfies BootstrapGrant, }, @@ -326,6 +350,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { const consumed = yield* pairingLinks.consumeAvailable({ credential, + proofKeyThumbprint: input?.proofKeyThumbprint ?? null, consumedAt: now, now, }); @@ -337,6 +362,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { scopes: consumed.value.scopes, subject: consumed.value.subject, ...(consumed.value.label ? { label: consumed.value.label } : {}), + ...(consumed.value.proofKeyThumbprint + ? { proofKeyThumbprint: consumed.value.proofKeyThumbprint } + : {}), expiresAt: consumed.value.expiresAt, } satisfies BootstrapGrant; } @@ -360,6 +388,13 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); } + if ( + matching.value.proofKeyThumbprint !== null && + matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint + ) { + return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."); + } + return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); }, Effect.mapError((cause) => diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 93dcc95aa5e..3b84ba58377 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -15,9 +15,16 @@ export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ readonly cause?: unknown; }> {} +const isPlatformError = (value: unknown): value is PlatformError.PlatformError => + Predicate.isTagged(value, "PlatformError"); + +export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => + isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; + export interface ServerSecretStoreShape { readonly get: (name: string) => Effect.Effect; readonly set: (name: string, value: Uint8Array) => Effect.Effect; + readonly create: (name: string, value: Uint8Array) => Effect.Effect; readonly getOrCreateRandom: ( name: string, bytes: number, @@ -48,9 +55,6 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); - const isPlatformError = (u: unknown): u is PlatformError.PlatformError => - Predicate.isTagged(u, "PlatformError"); - const get: ServerSecretStoreShape["get"] = (name) => fileSystem.readFile(resolveSecretPath(name)).pipe( Effect.map((bytes) => Uint8Array.from(bytes)), @@ -104,7 +108,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { ); }; - const create: ServerSecretStoreShape["set"] = (name, value) => { + const create: ServerSecretStoreShape["create"] = (name, value) => { const secretPath = resolveSecretPath(name); return Effect.scoped( Effect.gen(function* () { @@ -146,7 +150,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { create(name, generated).pipe( Effect.as(Uint8Array.from(generated)), Effect.catchTag("SecretStoreError", (error) => - isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists" + isSecretAlreadyExistsError(error) ? get(name).pipe( Effect.flatMap((created) => created !== null @@ -185,6 +189,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { return { get, set, + create, getOrCreateRandom, remove, } satisfies ServerSecretStoreShape; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 83f38c6b6d7..8de145ca338 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -40,6 +40,7 @@ export interface IssuedSession { readonly client: AuthClientMetadata; readonly expiresAt: DateTime.DateTime; readonly scopes: ReadonlyArray; + readonly proofKeyThumbprint?: string; } export interface VerifiedSession { @@ -50,6 +51,7 @@ export interface VerifiedSession { readonly expiresAt?: DateTime.DateTime; readonly subject: string; readonly scopes: ReadonlyArray; + readonly proofKeyThumbprint?: string; } export type SessionCredentialChange = @@ -86,6 +88,7 @@ export interface SessionStoreShape { readonly method?: ServerAuthSessionMethod; readonly scopes?: ReadonlyArray; readonly client?: AuthClientMetadata; + readonly proofKeyThumbprint?: string; }) => Effect.Effect; readonly verify: (token: string) => Effect.Effect; readonly issueWebSocketToken: ( @@ -132,7 +135,8 @@ const SessionClaims = Schema.Struct({ sid: AuthSessionId, sub: Schema.String, scopes: AuthEnvironmentScopes, - method: Schema.Literals(["browser-session-cookie", "bearer-access-token"]), + method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), + jkt: Schema.optionalKey(Schema.String), iat: Schema.Number, exp: Schema.Number, }); @@ -310,6 +314,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { sub: input?.subject ?? "browser", scopes: input?.scopes ?? AuthStandardClientScopes, method: input?.method ?? "browser-session-cookie", + ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), iat: issuedAt.epochMilliseconds, exp: expiresAt.epochMilliseconds, }; @@ -360,6 +365,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { client, expiresAt: expiresAt, scopes: claims.scopes, + ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), @@ -425,6 +431,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { expiresAt: expiresAt.value, subject: claims.sub, scopes: claims.scopes, + ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies VerifiedSession; }, Effect.mapError((cause) => diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts new file mode 100644 index 00000000000..379e77844ec --- /dev/null +++ b/apps/server/src/auth/dpop.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import * as PlatformError from "effect/PlatformError"; + +import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { mapDpopReplayStoreError } from "./dpop.ts"; + +const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => + new ServerSecretStore.SecretStoreError({ + message: "Failed to persist DPoP proof.", + cause: PlatformError.systemError({ + _tag: tag, + module: "FileSystem", + method: "open", + pathOrDescriptor: "dpop-proof.bin", + }), + }); + +describe("mapDpopReplayStoreError", () => { + it("reports replay conflicts as invalid credentials", () => { + const error = mapDpopReplayStoreError(storeFailure("AlreadyExists")); + + expect(error._tag).toBe("ServerAuthInvalidCredentialError"); + }); + + it("reports replay-store availability failures as internal errors", () => { + const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); + + expect(error._tag).toBe("ServerAuthInternalError"); + if (error._tag === "ServerAuthInternalError") { + expect(error.message).toBe("Failed to record DPoP proof replay state."); + } + }); +}); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts new file mode 100644 index 00000000000..66cd07f9e2e --- /dev/null +++ b/apps/server/src/auth/dpop.ts @@ -0,0 +1,92 @@ +import { verifyDpopProof } from "@t3tools/shared/dpop"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; + +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import * as ServerSecretStore from "./ServerSecretStore.ts"; + +function firstHeaderValue(value: string | undefined): string | undefined { + const first = value?.split(",")[0]?.trim(); + return first && first.length > 0 ? first : undefined; +} + +export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string { + try { + return new URL(request.originalUrl).href; + } catch { + const host = firstHeaderValue(request.headers.host) ?? "127.0.0.1"; + const forwardedProto = firstHeaderValue(request.headers["x-forwarded-proto"]); + const proto = forwardedProto === "https" || forwardedProto === "http" ? forwardedProto : "http"; + return new URL(request.originalUrl, `${proto}://${host}`).href; + } +} + +export const mapDpopReplayStoreError = ( + error: ServerSecretStore.SecretStoreError, +): EnvironmentAuth.ServerAuthInvalidCredentialError | EnvironmentAuth.ServerAuthInternalError => + ServerSecretStore.isSecretAlreadyExistsError(error) + ? new EnvironmentAuth.ServerAuthInvalidCredentialError({ + reason: "invalid_credential", + cause: "DPoP proof replayed.", + }) + : new EnvironmentAuth.ServerAuthInternalError({ + message: "Failed to record DPoP proof replay state.", + cause: error, + }); + +export const verifyRequestDpopProof = (input: { + readonly request: HttpServerRequest.HttpServerRequest; + readonly expectedThumbprint?: string; + readonly expectedAccessToken?: string; +}) => + Effect.gen(function* () { + const proof = input.request.headers.dpop; + const now = yield* DateTime.now; + const result = verifyDpopProof({ + proof, + method: input.request.method, + url: requestAbsoluteUrl(input.request), + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), + ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), + }); + if (!result.ok) { + return yield* new EnvironmentAuth.ServerAuthInvalidCredentialError({ + reason: "invalid_credential", + cause: result.reason, + }); + } + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const replayKey = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => + crypto.digest("SHA-256", new TextEncoder().encode(`${result.thumbprint}:${result.jti}`)), + ), + Effect.map(Encoding.encodeBase64Url), + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthInternalError({ + message: "Failed to calculate DPoP replay key.", + cause, + }), + ), + ); + yield* secretStore + .create( + `dpop-proof-${replayKey}`, + new TextEncoder().encode( + [ + `thumbprint=${result.thumbprint}`, + `jti=${result.jti}`, + `iat=${result.iat}`, + `consumedAt=${DateTime.formatIso(now)}`, + ].join("\n"), + ), + ) + .pipe( + Effect.catchTag("SecretStoreError", (error) => Effect.fail(mapDpopReplayStoreError(error))), + ); + return result.thumbprint; + }); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index b36c8dd647f..ed640863d21 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -34,6 +34,32 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; +import { verifyRequestDpopProof } from "./dpop.ts"; + +const CREDENTIAL_RESPONSE_HEADERS = { + "cache-control": "no-store", + pragma: "no-cache", +} as const; + +const appendCredentialResponseHeaders = HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeaders(response, CREDENTIAL_RESPONSE_HEADERS)), +); + +const appendDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", "DPoP")), +); + +const appendDpopChallengeOnUnauthorized = (error: EnvironmentAuthInvalidError) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const usesDpop = + (request.originalUrl.startsWith("/oauth/token") && request.headers.dpop !== undefined) || + request.headers.authorization?.startsWith("DPoP ") === true; + if (usesDpop) { + yield* appendDpopChallengeHeader; + } + return yield* error; + }); export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( Effect.map((span) => span.traceId), @@ -152,7 +178,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( scopes: new Set(session.scopes), }), ); - }); + }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); }), ); @@ -199,6 +225,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* HttpEffect.appendPreResponseHandler((_request, response) => Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), ); + yield* appendCredentialResponseHeaders; return result.response; }, Effect.catchTags({ @@ -230,8 +257,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( AuthRelayWriteScope, ]), }); - if (requestedScopes === null) + if (requestedScopes === null) { return yield* failEnvironmentInvalidRequest("invalid_scope"); + } + const proofKeyThumbprint = args.headers.dpop + ? yield* verifyRequestDpopProof({ request }).pipe( + Effect.catchTags({ + ServerAuthInvalidCredentialError: () => + appendDpopChallengeHeader.pipe( + Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), + ), + ServerAuthInternalError: (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + }), + ) + : undefined; + yield* appendCredentialResponseHeaders; return yield* serverAuth.exchangeBootstrapCredentialForAccessToken( args.payload.subject_token, requestedScopes, @@ -245,6 +286,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( ...(args.payload.client_os ? { os: args.payload.client_os } : {}), }, }), + proofKeyThumbprint ? { proofKeyThumbprint } : undefined, ); }, Effect.catchTags({ @@ -261,6 +303,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); const session = yield* EnvironmentAuthenticatedPrincipal; + yield* appendCredentialResponseHeaders; return yield* serverAuth.issueWebSocketTicket(session); }, Effect.catchTag("ServerAuthInternalError", (error) => diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 26b4ae4c4b2..7260ac7c54d 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -5,6 +5,8 @@ import type { } from "@t3tools/contracts"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as Crypto from "node:crypto"; +import * as Encoding from "effect/Encoding"; +import * as Result from "effect/Result"; const SESSION_COOKIE_NAME = "t3_session"; @@ -20,12 +22,13 @@ export function resolveSessionCookieName(input: { } export function base64UrlEncode(input: string | Uint8Array): string { - const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); - return buffer.toString("base64url"); + return typeof input === "string" + ? Encoding.encodeBase64Url(new TextEncoder().encode(input)) + : Encoding.encodeBase64Url(input); } export function base64UrlDecodeUtf8(input: string): string { - return Buffer.from(input, "base64url").toString("utf8"); + return Result.getOrThrow(Encoding.decodeBase64UrlString(input)); } export function signPayload(payload: string, secret: Uint8Array): string { diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 2cd8bb68c7c..5911bfd9874 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -1,6 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. import * as NodeHttp from "node:http"; -import { mkdtempSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,7 +19,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; -import { cli } from "./bin.ts"; +import { cli, makeCli } from "./bin.ts"; import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; @@ -38,7 +38,11 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} -const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); +const cloudCli = makeCli({ cloudEnabled: true }); +const noCloudCli = makeCli({ cloudEnabled: false }); +const runCli = (args: ReadonlyArray, command = cli) => + Command.runWith(command, { version: "0.0.0" })(args); +const runCloudCli = (args: ReadonlyArray) => runCli(args, cloudCli); const runCliWithRuntime = (args: ReadonlyArray) => runCli(args).pipe(Effect.provide(CliRuntimeLayer)); @@ -175,6 +179,121 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { }), ); + it.effect("rejects cloud commands when public cloud configuration is missing", () => + Effect.gen(function* () { + const error = yield* runCli(["cloud", "status"], noCloudCli).pipe(Effect.flip); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + if (error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${error._tag}`); + } + assert.deepEqual(error.commandPath, ["t3", "cloud"]); + assert.include(error.errors[0]?.message ?? "", "missing T3 Cloud public configuration"); + + const output = (yield* TestConsole.errorLines).join("\n"); + assert.include(output, "ERROR"); + assert.include(output, "missing T3 Cloud public configuration"); + }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))), + ); + + it.effect("reports fresh headless cloud state without requiring local configuration", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-test-")); + const { output } = yield* captureStdout( + runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO. + const status = JSON.parse(output) as { + readonly desired: boolean; + readonly authenticated: boolean; + readonly linked: boolean; + readonly cloudUserId: string | null; + readonly relayUrl: string | null; + }; + + assert.equal(status.desired, false); + assert.equal(status.authenticated, false); + assert.equal(status.linked, false); + assert.equal(status.cloudUserId, null); + assert.equal(status.relayUrl, null); + }), + ); + + it.effect("reports actionable human-readable headless cloud state", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-human-test-")); + const { output } = yield* captureStdout( + runCloudCli(["cloud", "status", "--base-dir", baseDir]), + ); + + assert.include(output, "T3 Cloud\n Exposure: disabled"); + assert.include(output, " Authorization: missing"); + assert.include(output, " Environment link: not provisioned"); + assert.include(output, "Next: Run `t3 cloud link` to authorize and enable cloud exposure."); + }), + ); + + it.effect("logs in to headless cloud without enabling exposure", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-login-test-")); + const { secretsDir } = yield* deriveServerPaths(baseDir, undefined); + mkdirSync(secretsDir, { recursive: true }); + writeFileSync( + join(secretsDir, "cloud-cli-oauth-token.bin"), + // @effect-diagnostics-next-line preferSchemaOverJson:off - Test fixture matches the persisted CLI token representation. + JSON.stringify({ + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + }), + ); + + const login = yield* captureStdout(runCloudCli(["cloud", "login", "--base-dir", baseDir])); + const status = yield* captureStdout( + runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO. + const decoded = JSON.parse(status.output) as { + readonly desired: boolean; + readonly authenticated: boolean; + }; + + assert.equal(login.output, "Signed in to T3 Cloud."); + assert.isFalse(decoded.desired); + assert.isTrue(decoded.authenticated); + }), + ); + + it.effect("disables headless cloud exposure without a running server", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-unlink-test-")); + const { output } = yield* captureStdout( + runCloudCli(["cloud", "unlink", "--base-dir", baseDir]), + ); + + assert.equal(output, "T3 Cloud exposure is disabled locally."); + }), + ); + + it.effect("logs out of headless cloud and removes the stored CLI authorization", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-logout-test-")); + const { secretsDir } = yield* deriveServerPaths(baseDir, undefined); + const tokenPath = join(secretsDir, "cloud-cli-oauth-token.bin"); + mkdirSync(secretsDir, { recursive: true }); + writeFileSync(tokenPath, "invalid persisted token"); + + const { output } = yield* captureStdout( + runCloudCli(["cloud", "logout", "--base-dir", baseDir]), + ); + + assert.equal(output, "Signed out of T3 Cloud locally."); + assert.isFalse(existsSync(tokenPath)); + }), + ); + it.effect("executes auth pairing subcommands and redacts secrets from list output", () => Effect.gen(function* () { const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-")); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 4e829332c17..04d5bdfadaa 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -3,22 +3,56 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { Command } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { authCommand } from "./cli/auth.ts"; +import { cloudCommand } from "./cli/cloud.ts"; +import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); -export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( - Command.withDescription("Run the T3 Code server."), - Command.withHandler((flags) => runServerCommand(flags)), - Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), +const cloudPublicConfigMissingMessage = + "T3 Cloud commands are unavailable: this build is missing T3 Cloud public configuration."; + +class CloudPublicConfigMissingError extends CliError.UserError { + override get message() { + return cloudPublicConfigMissingMessage; + } +} + +const cloudUnavailableCommand = Command.make("cloud").pipe( + Command.withDescription("T3 Cloud is unavailable in builds without public cloud configuration."), + Command.withHidden, + Command.withHandler(() => + Effect.fail( + new CliError.ShowHelp({ + commandPath: ["t3", "cloud"], + errors: [new CloudPublicConfigMissingError({ cause: cloudPublicConfigMissingMessage })], + }), + ), + ), ); +export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => + Command.make("t3", { ...sharedServerCommandFlags }).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => runServerCommand(flags)), + Command.withSubcommands([ + startCommand, + serveCommand, + authCommand, + projectCommand, + cloudEnabled ? cloudCommand : cloudUnavailableCommand, + ]), + ); + +export const cli = makeCli(); + if (import.meta.main) { Command.run(cli, { version: packageJson.version }).pipe( Effect.scoped, diff --git a/apps/server/src/cli/cloud.test.ts b/apps/server/src/cli/cloud.test.ts new file mode 100644 index 00000000000..4dda50c0ce3 --- /dev/null +++ b/apps/server/src/cli/cloud.test.ts @@ -0,0 +1,102 @@ +import * as RelayClient from "@t3tools/shared/relayClient"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { acquireRelayClientForLink } from "./cloud.ts"; + +const managedExecutable = { + status: "available", + executablePath: "/tmp/cloudflared", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, +} as const; + +it.effect("does not install the relay client when the user declines the managed download", () => + Effect.gen(function* () { + let installCalls = 0; + const result = yield* acquireRelayClientForLink( + { + resolve: Effect.succeed({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.sync(() => { + installCalls += 1; + return managedExecutable; + }), + installWithProgress: () => + Effect.sync(() => { + installCalls += 1; + return managedExecutable; + }), + }, + () => Effect.succeed(false), + () => Effect.void, + ); + + assert.isTrue(Option.isNone(result)); + assert.equal(installCalls, 0); + }), +); + +it.effect("installs the relay client after the user accepts the managed download", () => + Effect.gen(function* () { + let installCalls = 0; + const progress: Array = []; + const result = yield* acquireRelayClientForLink( + { + resolve: Effect.succeed({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.sync(() => { + installCalls += 1; + return managedExecutable; + }), + installWithProgress: (report) => + report({ type: "progress", stage: "downloading" }).pipe( + Effect.andThen( + Effect.sync(() => { + installCalls += 1; + return managedExecutable; + }), + ), + ), + }, + () => Effect.succeed(true), + (event) => + Effect.sync(() => { + if (event.type === "progress") { + progress.push(event.stage); + } + }), + ); + + assert.deepEqual(Option.getOrThrow(result), managedExecutable); + assert.equal(installCalls, 1); + assert.deepEqual(progress, ["downloading"]); + }), +); + +it.effect("reuses an available relay client executable without prompting", () => + Effect.gen(function* () { + let promptCalls = 0; + const result = yield* acquireRelayClientForLink( + { + resolve: Effect.succeed(managedExecutable), + install: Effect.die("unexpected install"), + installWithProgress: () => Effect.die("unexpected install"), + }, + () => + Effect.sync(() => { + promptCalls += 1; + return false; + }), + () => Effect.void, + ); + + assert.deepEqual(Option.getOrThrow(result), managedExecutable); + assert.equal(promptCalls, 0); + }), +); diff --git a/apps/server/src/cli/cloud.ts b/apps/server/src/cli/cloud.ts new file mode 100644 index 00000000000..78027d1e802 --- /dev/null +++ b/apps/server/src/cli/cloud.ts @@ -0,0 +1,425 @@ +import { + AuthRelayWriteScope, + EnvironmentHttpApi, + type RelayClientInstallProgressEvent, + type RelayClientInstallProgressStage, +} from "@t3tools/contracts"; +import { RelayOkResponse } from "@t3tools/contracts/relay"; +import * as RelayClient from "@t3tools/shared/relayClient"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as CliState from "../cloud/CliState.ts"; +import * as CliTokenManager from "../cloud/CliTokenManager.ts"; +import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; +import { relayUrlConfig } from "../cloud/publicConfig.ts"; +import { ServerConfig } from "../config.ts"; +import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; +import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + +function bytesToString(value: Uint8Array): string { + return new TextDecoder().decode(value); +} + +interface CloudCliStatus { + readonly desired: boolean; + readonly authenticated: boolean; + readonly linked: boolean; + readonly cloudUserId: string | null; + readonly relayUrl: string | null; + readonly relayClient: RelayClient.RelayClientStatus; +} + +function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): ReadonlyArray { + switch (executable.status) { + case "available": { + const source = + executable.source === "path" + ? "PATH" + : executable.source === "managed" + ? "managed install" + : "configured override"; + return [ + ` Relay client: available via ${source}`, + ` Path: ${executable.executablePath}`, + ` Version: ${executable.version}`, + ]; + } + case "missing": + return [" Relay client: not installed"]; + case "unsupported": + return [ + ` Relay client: unsupported on ${executable.platform}-${executable.arch}`, + ` Managed version: ${executable.version}`, + ]; + } +} + +function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: boolean }): string { + if (options?.json) { + return JSON.stringify(status, null, 2); + } + + const provisioned = status.linked + ? "provisioned" + : status.desired && status.authenticated + ? "pending server startup" + : "not provisioned"; + const nextStep = !status.authenticated + ? "Run `t3 cloud link` to authorize and enable cloud exposure." + : !status.desired + ? "Run `t3 cloud link` to enable cloud exposure." + : !status.linked + ? "Start T3 to provision the environment link and launch its managed tunnel." + : undefined; + + return [ + "T3 Cloud", + ` Exposure: ${status.desired ? "enabled" : "disabled"}`, + ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`, + ` Environment link: ${provisioned}`, + ` Relay: ${status.relayUrl ?? "not provisioned"}`, + ...formatRelayClientStatus(status.relayClient), + ...(nextStep ? ["", `Next: ${nextStep}`] : []), + ].join("\n"); +} + +const CLOUD_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5); + +const confirmRelayClientInstall = (version: string) => + Prompt.run( + Prompt.confirm({ + message: `The T3 relay client is required for T3 Cloud exposure. Download and install version ${version}?`, + initial: false, + }), + ); + +function relayClientInstallProgressMessage(stage: RelayClientInstallProgressStage): string { + switch (stage) { + case "checking": + return "Checking existing installation"; + case "waiting_for_lock": + return "Waiting for installation lock"; + case "downloading": + return "Downloading"; + case "verifying": + return "Verifying download"; + case "installing": + return "Installing"; + case "validating": + return "Validating executable"; + case "activating": + return "Activating installation"; + } +} + +const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent) => + event.type === "progress" + ? Console.log(`Relay client: ${relayClientInstallProgressMessage(event.stage)}...`) + : Effect.void; + +export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")( + function* ( + relayClient: RelayClient.RelayClientShape, + confirmInstall: (version: string) => Effect.Effect, + reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect, + ) { + const executable = yield* relayClient.resolve; + if (executable.status === "available") { + return Option.some(executable); + } + if (executable.status === "unsupported") { + return Option.some(yield* relayClient.installWithProgress(reportProgress)); + } + if (!(yield* confirmInstall(executable.version))) { + return Option.none(); + } + return Option.some(yield* relayClient.installWithProgress(reportProgress)); + }, +); + +const withCloudCliSessionToken = ( + environmentAuth: EnvironmentAuth.EnvironmentAuthShape, + run: (token: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + environmentAuth.issueSession({ + scopes: [AuthRelayWriteScope], + subject: "cloud-cli", + label: "t3 cloud cli", + }), + (issued) => run(issued.token), + (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + +type LiveCloudActionResult = + | { readonly status: "not-running" } + | { readonly status: "succeeded" } + | { readonly status: "failed"; readonly cause: unknown }; + +const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () { + const config = yield* ServerConfig; + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return { status: "not-running" } satisfies LiveCloudActionResult; + } + + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const result = yield* Effect.exit( + withCloudCliSessionToken(environmentAuth, (token) => + HttpApiClient.make(EnvironmentHttpApi, { + baseUrl: runtimeState.value.origin, + }).pipe( + Effect.flatMap((client) => + client.cloud.unlink({ headers: { authorization: `Bearer ${token}` } }), + ), + Effect.timeout(CLOUD_CLI_LIVE_SERVER_TIMEOUT), + ), + ), + ); + return Exit.isSuccess(result) + ? ({ status: "succeeded" } satisfies LiveCloudActionResult) + : ({ status: "failed", cause: result.cause } satisfies LiveCloudActionResult); +}); + +type RelayUnlinkResult = + | { readonly status: "not-authenticated" } + | { readonly status: "revoked" } + | { readonly status: "not-linked" }; + +const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () { + const tokens = yield* CliTokenManager.CloudCliTokenManager; + const token = yield* tokens.getExisting; + if (Option.isNone(token)) { + return { status: "not-authenticated" } satisfies RelayUnlinkResult; + } + + const environment = yield* ServerEnvironment; + const environmentId = yield* environment.getEnvironmentId; + const relayUrl = yield* relayUrlConfig; + const httpClient = yield* HttpClient.HttpClient; + const response = yield* HttpClientRequest.delete( + `${relayUrl}/v1/client/environment-links/${encodeURIComponent(environmentId)}`, + ).pipe( + HttpClientRequest.bearerToken(token.value.accessToken), + httpClient.execute, + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), + ); + return response.ok + ? ({ status: "revoked" } satisfies RelayUnlinkResult) + : ({ status: "not-linked" } satisfies RelayUnlinkResult); +}); + +const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { + readonly clearAuthorization: boolean; +}) { + yield* CliState.setCliDesiredCloudLink(false); + const liveResult = yield* runLiveCloudUnlink(); + const relayResult = yield* Effect.exit(unlinkRelayEnvironment()); + yield* CliState.clearPersistedCloudLink; + + if (options.clearAuthorization) { + const tokens = yield* CliTokenManager.CloudCliTokenManager; + yield* tokens.clear; + } + + if (liveResult.status === "failed") { + yield* Console.warn( + `T3 Cloud exposure is disabled, but the running server could not stop its tunnel: ${String(liveResult.cause)}\nRestart that server to stop the connector.`, + ); + } else { + yield* Console.log("T3 Cloud exposure is disabled locally."); + } + + if (Exit.isFailure(relayResult)) { + yield* Console.warn( + options.clearAuthorization + ? `Could not revoke the relay-side environment record before signing out: ${String(relayResult.cause)}\nThe stored CLI authorization was still removed locally.` + : `Could not revoke the relay-side environment record yet: ${String(relayResult.cause)}\nRun \`t3 cloud unlink\` again when the relay is reachable.`, + ); + } else if (relayResult.value.status === "revoked") { + yield* Console.log("Revoked the relay-side environment record."); + } + + if (options.clearAuthorization) { + yield* Console.log("Signed out of T3 Cloud locally."); + } +}); + +const runCloudCommand = ( + flags: { readonly baseDir: Option.Option }, + run: Effect.Effect< + A, + E, + | ServerSecretStore.ServerSecretStore + | CliTokenManager.CloudCliTokenManager + | RelayClient.RelayClient + | EnvironmentAuth.EnvironmentAuth + | FileSystem.FileSystem + | HttpClient.HttpClient + | Prompt.Environment + | ServerConfig + | ServerEnvironment + >, + options?: { + readonly quietLogs?: boolean; + }, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; + const runtimeLayer = Layer.mergeAll( + ServerSecretStore.layer, + CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), + RelayClient.layerCloudflared({ baseDir: config.baseDir }), + EnvironmentAuth.runtimeLayer, + ServerEnvironmentLive, + ).pipe( + Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ); + return yield* run.pipe(Effect.provide(runtimeLayer)); + }); + +const cloudLoginCommand = Command.make("login", { + ...projectLocationFlags, +}).pipe( + Command.withDescription("Authorize the T3 Cloud CLI without enabling cloud exposure."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const tokens = yield* CliTokenManager.CloudCliTokenManager; + yield* tokens.get; + yield* Console.log("Signed in to T3 Cloud."); + }), + ), + ), +); + +const cloudLinkCommand = Command.make("link", { + ...projectLocationFlags, +}).pipe( + Command.withDescription("Authorize this environment for T3 Cloud and expose it on next start."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const relayClient = yield* RelayClient.RelayClient; + const installed = yield* acquireRelayClientForLink( + relayClient, + confirmRelayClientInstall, + reportRelayClientInstallProgress, + ); + if (Option.isNone(installed)) { + yield* Console.log("T3 Cloud link cancelled. The relay client was not installed."); + return; + } + yield* Console.log( + `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, + ); + + const tokens = yield* CliTokenManager.CloudCliTokenManager; + yield* tokens.get; + yield* CliState.setCliDesiredCloudLink(true); + yield* Console.log( + "This T3 environment will be available over T3 Cloud the next time T3 starts.", + ); + }), + ), + ), +); + +const cloudStatusCommand = Command.make("status", { + ...projectLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("Show persisted T3 Cloud and relay client state."), + Command.withHandler((flags) => + runCloudCommand( + flags, + Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const relayClient = yield* RelayClient.RelayClient; + const tokens = yield* CliTokenManager.CloudCliTokenManager; + const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all( + [ + CliState.readCliDesiredCloudLink, + tokens.hasCredential, + secrets.get(CLOUD_LINKED_USER_ID), + secrets.get(RELAY_URL_SECRET), + relayClient.resolve, + ], + { concurrency: "unbounded" }, + ); + const status: CloudCliStatus = { + desired, + authenticated, + linked: cloudUserId !== null, + cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, + relayUrl: relayUrl ? bytesToString(relayUrl) : null, + relayClient: executable, + }; + yield* Console.log(formatCloudStatus(status, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const cloudUnlinkCommand = Command.make("unlink", { + ...projectLocationFlags, +}).pipe( + Command.withDescription("Disable T3 Cloud exposure while retaining the stored authorization."), + Command.withHandler((flags) => + runCloudCommand(flags, disconnectCloud({ clearAuthorization: false })), + ), +); + +const cloudLogoutCommand = Command.make("logout", { + ...projectLocationFlags, +}).pipe( + Command.withDescription("Disable T3 Cloud exposure and clear the stored CLI authorization."), + Command.withHandler((flags) => + runCloudCommand(flags, disconnectCloud({ clearAuthorization: true })), + ), +); + +export const cloudCommand = Command.make("cloud").pipe( + Command.withDescription("Manage headless T3 Cloud exposure."), + Command.withSubcommands([ + cloudLoginCommand, + cloudLinkCommand, + cloudStatusCommand, + cloudUnlinkCommand, + cloudLogoutCommand, + ]), +); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 95e9ed7f383..0d8e7eca15d 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -116,12 +116,8 @@ const failLiveServerRequest = (cause: unknown) => { }; const makeLiveServerClient = (origin: string) => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - return yield* HttpApiClient.makeWith(EnvironmentHttpApi, { - baseUrl: origin, - httpClient, - }); + HttpApiClient.make(EnvironmentHttpApi, { + baseUrl: origin, }); const normalizeWorkspaceRootForProjectCommand = Effect.fn( diff --git a/apps/server/src/cloud/CliState.test.ts b/apps/server/src/cloud/CliState.test.ts new file mode 100644 index 00000000000..2798f5b6ede --- /dev/null +++ b/apps/server/src/cloud/CliState.test.ts @@ -0,0 +1,58 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { ServerConfig } from "../config.ts"; +import * as CliState from "./CliState.ts"; +import { + CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_LINKED_USER_ID, + CLOUD_MINT_PUBLIC_KEY, + PUBLISH_AGENT_ACTIVITY_SECRET, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "./config.ts"; + +const persistedCloudLinkSecrets = [ + CLOUD_LINKED_USER_ID, + RELAY_URL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + CLOUD_MINT_PUBLIC_KEY, + CLOUD_ENDPOINT_RUNTIME_CONFIG, + PUBLISH_AGENT_ACTIVITY_SECRET, +] as const; + +const makeTestLayer = () => + ServerSecretStore.layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-cloud-cli-state-test-", + }), + ), + ); + +it.layer(NodeServices.layer)("CliState", (it) => { + it.effect("persists desired exposure and clears provisioned relay state", () => + Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + + expect(yield* CliState.readCliDesiredCloudLink).toBe(false); + yield* CliState.setCliDesiredCloudLink(true); + expect(yield* CliState.readCliDesiredCloudLink).toBe(true); + + for (const name of persistedCloudLinkSecrets) { + yield* secrets.set(name, new TextEncoder().encode(name)); + } + yield* CliState.clearPersistedCloudLink; + + expect(yield* CliState.readCliDesiredCloudLink).toBe(false); + for (const name of persistedCloudLinkSecrets) { + expect(yield* secrets.get(name)).toBe(null); + } + }).pipe(Effect.provide(makeTestLayer())), + ); +}); diff --git a/apps/server/src/cloud/CliState.ts b/apps/server/src/cloud/CliState.ts new file mode 100644 index 00000000000..f344a0b73cc --- /dev/null +++ b/apps/server/src/cloud/CliState.ts @@ -0,0 +1,49 @@ +import * as Effect from "effect/Effect"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { + CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_LINKED_USER_ID, + CLOUD_MINT_PUBLIC_KEY, + PUBLISH_AGENT_ACTIVITY_SECRET, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "./config.ts"; + +export const CLOUD_CLI_DESIRED_LINK_SECRET = "cloud-cli-desired-link"; + +const TRUE_BYTES = new TextEncoder().encode("true"); + +export const readCliDesiredCloudLink = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + return (yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET)) !== null; +}); + +export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(function* ( + desired: boolean, +) { + const secrets = yield* ServerSecretStore.ServerSecretStore; + if (desired) { + yield* secrets.set(CLOUD_CLI_DESIRED_LINK_SECRET, TRUE_BYTES); + } else { + yield* secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET); + } +}); + +export const clearPersistedCloudLink = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + yield* Effect.all( + [ + secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET), + secrets.remove(CLOUD_LINKED_USER_ID), + secrets.remove(RELAY_URL_SECRET), + secrets.remove(RELAY_ISSUER_SECRET), + secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + secrets.remove(CLOUD_MINT_PUBLIC_KEY), + secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), + secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), + ], + { concurrency: "unbounded" }, + ); +}); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts new file mode 100644 index 00000000000..c10b8922930 --- /dev/null +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -0,0 +1,236 @@ +// @effect-diagnostics nodeBuiltinImport:off - The CLI loopback OAuth callback is a Node HTTP boundary. +import { createServer } from "node:http"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as Clock from "effect/Clock"; +import * as Console from "effect/Console"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; + +const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; +const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); +const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); + +const PersistedToken = Schema.Struct({ + accessToken: Schema.String, + refreshToken: Schema.String, + expiresAtEpochMs: Schema.Number, +}); +type PersistedToken = typeof PersistedToken.Type; + +const PersistedTokenJson = Schema.fromJsonString(PersistedToken); +const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson); +const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson); + +const OAuthTokenResponse = Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.Number, + token_type: Schema.String, +}); + +export class CloudCliTokenManagerError extends Data.TaggedError("CloudCliTokenManagerError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface CloudCliTokenManagerShape { + readonly get: Effect.Effect; + readonly getExisting: Effect.Effect, CloudCliTokenManagerError>; + readonly hasCredential: Effect.Effect; + readonly clear: Effect.Effect; +} + +export class CloudCliTokenManager extends Context.Service< + CloudCliTokenManager, + CloudCliTokenManagerShape +>()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} + +const wrapError = + (message: string) => + (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.mapError( + (cause) => + new CloudCliTokenManagerError({ + message, + cause, + }), + ), + ); + +function stringToBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +function bytesToString(value: Uint8Array): string { + return new TextDecoder().decode(value); +} + +const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const secrets = yield* ServerSecretStore.ServerSecretStore; + const semaphore = yield* Semaphore.make(1); + const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) { + const encoded = yield* encodePersistedToken(token); + yield* secrets.set(CLOUD_CLI_OAUTH_TOKEN_SECRET, stringToBytes(encoded)); + return token; + }); + + const clear = secrets + .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) + .pipe(wrapError("Could not remove the stored T3 Cloud CLI credential.")); + + const read = Effect.fn("cloud.cli_token.read")(function* () { + const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); + if (!encoded) return Option.none(); + return Option.some(yield* decodePersistedToken(bytesToString(encoded))); + }); + + const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( + metadata: CloudCliOAuthConfig, + params: Record, + ) { + const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( + HttpClientRequest.bodyUrlParams(params), + httpClient.execute, + Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)), + ); + const now = yield* Clock.currentTimeMillis; + return { + accessToken: response.access_token, + refreshToken: response.refresh_token ?? params.refresh_token ?? "", + expiresAtEpochMs: now + response.expires_in * 1_000, + } satisfies PersistedToken; + }); + + const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { + const metadata = yield* cloudCliOAuthConfig; + return yield* exchangeToken(metadata, { + grant_type: "refresh_token", + refresh_token: token.refreshToken, + client_id: metadata.clientId, + }); + }); + + const login = Effect.fn("cloud.cli_token.login")(function* () { + const metadata = yield* cloudCliOAuthConfig; + const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); + const challenge = Encoding.encodeBase64Url( + yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), + ); + const state = yield* crypto.randomUUIDv4; + const callback = yield* Deferred.make(); + const callbackRoute = HttpRouter.add( + "GET", + "/callback", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = new URL(request.originalUrl, metadata.redirectUri); + const code = url.searchParams.get("code"); + if (url.searchParams.get("state") !== state || !code) { + return HttpServerResponse.text("Invalid T3 Cloud authorization callback.", { + status: 400, + }); + } + yield* Deferred.succeed(callback, code); + return yield* HttpServerResponse.html` + + +

T3 Cloud authorization complete

+

You can close this window and return to your terminal.

+ + +`; + }), + ); + yield* HttpRouter.serve(callbackRoute, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provide( + NodeHttpServer.layer(createServer, { + host: "127.0.0.1", + port: 34338, + disablePreemptiveShutdown: true, + }), + ), + Layer.build, + ); + const authorizationUrl = new URL(metadata.authorizationEndpoint); + authorizationUrl.searchParams.set("client_id", metadata.clientId); + authorizationUrl.searchParams.set("redirect_uri", metadata.redirectUri); + authorizationUrl.searchParams.set("response_type", "code"); + authorizationUrl.searchParams.set("scope", metadata.scopes.join(" ")); + authorizationUrl.searchParams.set("state", state); + authorizationUrl.searchParams.set("code_challenge", challenge); + authorizationUrl.searchParams.set("code_challenge_method", "S256"); + yield* Console.log(`Open this URL to authorize T3 Cloud:\n${authorizationUrl.toString()}\n`); + const code = yield* Deferred.await(callback).pipe( + Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), + Effect.catchTag("TimeoutError", () => + Effect.fail( + new CloudCliTokenManagerError({ + message: "Timed out waiting for T3 Cloud authorization.", + }), + ), + ), + ); + return yield* exchangeToken(metadata, { + grant_type: "authorization_code", + code, + redirect_uri: metadata.redirectUri, + client_id: metadata.clientId, + code_verifier: verifier, + }); + }); + + const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () { + const token = yield* read(); + if (Option.isNone(token)) return token; + const now = yield* Clock.currentTimeMillis; + if (token.value.expiresAtEpochMs - CLOUD_CLI_OAUTH_REFRESH_EARLY_MS > now) { + return token; + } + return Option.some(yield* refresh(token.value).pipe(Effect.flatMap(persist))); + }); + + const getExisting = semaphore.withPermits(1)( + getExistingNoLock().pipe(wrapError("Could not refresh the T3 Cloud CLI credential.")), + ); + const hasCredential = semaphore.withPermits(1)( + read().pipe( + Effect.map(Option.isSome), + wrapError("Could not read the stored T3 Cloud CLI credential."), + ), + ); + const get = semaphore.withPermits(1)( + Effect.gen(function* () { + const token = yield* getExistingNoLock(); + return Option.isSome(token) + ? token.value + : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); + }).pipe(wrapError("Could not authorize the T3 Cloud CLI.")), + ); + + return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); +}); + +export const layer = Layer.effect(CloudCliTokenManager, make); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts new file mode 100644 index 00000000000..16cf946cf05 --- /dev/null +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as RelayClient from "@t3tools/shared/relayClient"; + +import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; + +const relayClientAvailableLayer = Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "available", + executablePath: "cloudflared", + source: "path", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.die("unused"), + installWithProgress: () => Effect.die("unused"), + }), +); + +const runtimeDependencies = (spawner: ReturnType) => + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + relayClientAvailableLayer, + ); + +function makeHandle(input: { + readonly pid: number; + readonly onKill: () => void; + readonly isRunning?: () => boolean; + readonly exitCode?: Effect.Effect; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(input.pid), + exitCode: input.exitCode ?? Effect.never, + isRunning: Effect.sync(() => input.isRunning?.() ?? true), + kill: () => + Effect.sync(() => { + input.onKill(); + }), + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +describe("CloudManagedEndpointRuntime", () => { + it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => + Effect.gen(function* () { + const spawned: Array = []; + const killed: Array = []; + let nextPid = 100; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + throw new Error("Expected standard command."); + } + spawned.push(command); + const pid = nextPid; + nextPid += 1; + const handle = makeHandle({ + pid, + onKill: () => { + killed.push(pid); + }, + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* makeCloudManagedEndpointRuntime.pipe( + Effect.provide(runtimeDependencies(spawner)), + ); + + yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token-1", + tunnelId: "tunnel-1", + tunnelName: "t3-code-env-1", + }); + yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token-1", + tunnelId: "tunnel-1", + tunnelName: "t3-code-env-1", + }); + yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token-2", + tunnelId: "tunnel-1", + tunnelName: "t3-code-env-1", + }); + const stopped = yield* runtime.applyConfig(null); + + expect(spawned.map((command) => command.command)).toEqual(["cloudflared", "cloudflared"]); + expect(spawned.map((command) => command.args)).toEqual([ + ["tunnel", "run"], + ["tunnel", "run"], + ]); + expect(spawned.map((command) => command.options.env?.TUNNEL_TOKEN)).toEqual([ + "token-1", + "token-2", + ]); + expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]); + expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]); + expect(spawned.map((command) => command.options.detached)).toEqual([false, false]); + expect(spawned.map((command) => command.options.shell)).toEqual([false, false]); + expect(killed).toEqual([100, 101]); + expect(stopped).toEqual({ status: "disabled" }); + }), + ); + + it.effect("stops an active connector when a non-Cloudflare runtime config is applied", () => + Effect.gen(function* () { + const killed: Array = []; + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const handle = makeHandle({ + pid: 200, + onKill: () => { + killed.push(200); + }, + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* makeCloudManagedEndpointRuntime.pipe( + Effect.provide(runtimeDependencies(spawner)), + ); + + const started = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + }); + const unsupported = yield* runtime.applyConfig({ + providerKind: "manual", + connectorToken: "manual-token", + }); + + expect(started.status).toBe("running"); + expect(unsupported).toEqual({ status: "unsupported", providerKind: "manual" }); + expect(killed).toEqual([200]); + }), + ); + + it.effect("restarts the connector when the active process has exited", () => + Effect.gen(function* () { + const spawned: Array = []; + const killed: Array = []; + let firstRunning = true; + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const pid = spawned.length === 0 ? 300 : 301; + spawned.push(pid); + const handle = makeHandle({ + pid, + isRunning: () => (pid === 300 ? firstRunning : true), + onKill: () => { + killed.push(pid); + }, + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* makeCloudManagedEndpointRuntime.pipe( + Effect.provide(runtimeDependencies(spawner)), + ); + const config = { + providerKind: "cloudflare_tunnel" as const, + connectorToken: "token", + tunnelId: "tunnel-1", + }; + + const first = yield* runtime.applyConfig(config); + firstRunning = false; + const second = yield* runtime.applyConfig(config); + + expect(first).toMatchObject({ status: "running", pid: 300 }); + expect(second).toMatchObject({ status: "running", pid: 301 }); + expect(spawned).toEqual([300, 301]); + expect(killed).toEqual([300]); + }), + ); + + it.effect("supervises the active connector and restarts it after process exit", () => + Effect.gen(function* () { + const spawned: Array = []; + const killed: Array = []; + const firstExit = yield* Deferred.make(); + const secondSpawned = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const pid = spawned.length === 0 ? 400 : 401; + spawned.push(pid); + if (pid === 401) { + yield* Deferred.succeed(secondSpawned, undefined); + } + const handle = makeHandle({ + pid, + exitCode: + pid === 400 + ? Deferred.await(firstExit) + : (Effect.never as Effect.Effect), + onKill: () => { + killed.push(pid); + }, + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* makeCloudManagedEndpointRuntime.pipe( + Effect.provide(runtimeDependencies(spawner)), + ); + + const started = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + tunnelId: "tunnel-1", + }); + yield* Deferred.succeed(firstExit, ChildProcessSpawner.ExitCode(1)); + yield* Deferred.await(secondSpawned); + + expect(started).toMatchObject({ status: "running", pid: 400 }); + expect(spawned).toEqual([400, 401]); + expect(killed).toEqual([400]); + }), + ); + + it.effect("serializes concurrent connector config changes", () => + Effect.gen(function* () { + const spawned: Array = []; + const killed: Array = []; + const firstSpawnEntered = yield* Deferred.make(); + const releaseFirstSpawn = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const pid = 500 + spawned.length; + spawned.push(pid); + if (pid === 500) { + yield* Deferred.succeed(firstSpawnEntered, undefined); + yield* Deferred.await(releaseFirstSpawn); + } + const handle = makeHandle({ + pid, + onKill: () => { + killed.push(pid); + }, + }); + yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); + return handle; + }), + ); + const runtime = yield* makeCloudManagedEndpointRuntime.pipe( + Effect.provide(runtimeDependencies(spawner)), + ); + + const first = yield* runtime + .applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token-1", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstSpawnEntered); + const second = yield* runtime + .applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token-2", + }) + .pipe(Effect.forkChild); + yield* Deferred.succeed(releaseFirstSpawn, undefined); + + yield* Fiber.join(first); + const status = yield* Fiber.join(second); + + expect(status).toMatchObject({ status: "running", pid: 501 }); + expect(spawned).toEqual([500, 501]); + expect(killed).toEqual([500]); + }), + ); + + it.effect("reports connector spawn failures", () => + Effect.gen(function* () { + const spawner = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "cloudflared missing", + }), + ), + ); + const runtime = yield* makeCloudManagedEndpointRuntime.pipe( + Effect.provide(runtimeDependencies(spawner)), + ); + + const status = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + tunnelId: "tunnel-1", + }); + + expect(status).toMatchObject({ + status: "failed", + providerKind: "cloudflare_tunnel", + tunnelId: "tunnel-1", + }); + }), + ); + + it.effect("reports a missing relay client executable without spawning", () => + Effect.gen(function* () { + const spawn = vi.fn(); + const spawner = ChildProcessSpawner.make(spawn); + const runtime = yield* makeCloudManagedEndpointRuntime.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.die("unused"), + installWithProgress: () => Effect.die("unused"), + }), + ), + ), + ), + ); + + const status = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + }); + + expect(status).toEqual({ + status: "failed", + providerKind: "cloudflare_tunnel", + reason: "The relay client is not installed.", + }); + expect(spawn).not.toHaveBeenCalled(); + }), + ); +}); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts new file mode 100644 index 00000000000..65656292ebc --- /dev/null +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -0,0 +1,283 @@ +import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; +import * as RelayClient from "@t3tools/shared/relayClient"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts"; + +function bytesToString(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +const readRuntimeConfig = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const bytes = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); + if (!bytes) { + return null; + } + return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes))); +}); + +export interface CloudManagedEndpointRuntimeShape { + readonly applyConfig: ( + config: RelayManagedEndpointRuntimeConfig | null, + ) => Effect.Effect; +} + +export class CloudManagedEndpointRuntime extends Context.Service< + CloudManagedEndpointRuntime, + CloudManagedEndpointRuntimeShape +>()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {} + +export type CloudManagedEndpointRuntimeStatus = + | { + readonly status: "disabled"; + } + | { + readonly status: "failed"; + readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"]; + readonly reason: string; + readonly tunnelId?: string; + readonly tunnelName?: string; + } + | { + readonly status: "running"; + readonly providerKind: "cloudflare_tunnel"; + readonly pid: number; + readonly tunnelId?: string; + readonly tunnelName?: string; + } + | { + readonly status: "unsupported"; + readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"]; + }; + +interface ActiveConnector { + readonly child: ChildProcessSpawner.ChildProcessHandle; + readonly scope: Scope.Closeable; + readonly configKey: string; + readonly config: RelayManagedEndpointRuntimeConfig; +} + +function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string { + return JSON.stringify({ + providerKind: config.providerKind, + connectorToken: config.connectorToken, + tunnelId: config.tunnelId ?? null, + tunnelName: config.tunnelName ?? null, + }); +} + +const stopConnector = (connector: ActiveConnector | null) => + connector + ? Scope.close(connector.scope, Exit.void).pipe( + Effect.tap(() => + Effect.logInfo("Relay client stopped", { + pid: Number(connector.child.pid), + }), + ), + Effect.ignore, + ) + : Effect.void; + +export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const relayClient = yield* RelayClient.RelayClient; + const activeRef = yield* Ref.make(null); + const desiredConfigRef = yield* Ref.make(null); + const reconcileSemaphore = yield* Semaphore.make(1); + let reconcileConfig: CloudManagedEndpointRuntimeShape["applyConfig"]; + + const stopActive = Effect.gen(function* () { + const active = yield* Ref.getAndSet(activeRef, null); + yield* stopConnector(active); + }); + + const superviseConnector = (connector: ActiveConnector) => + Effect.gen(function* () { + const result = yield* Effect.result(connector.child.exitCode); + yield* reconcileSemaphore.withPermits(1)( + Effect.gen(function* () { + const active = yield* Ref.get(activeRef); + if ( + active?.child.pid !== connector.child.pid || + active.configKey !== connector.configKey + ) { + return; + } + yield* Ref.set(activeRef, null); + yield* stopConnector(connector); + + const desiredConfig = yield* Ref.get(desiredConfigRef); + if ( + !desiredConfig || + desiredConfig.providerKind !== "cloudflare_tunnel" || + runtimeConfigKey(desiredConfig) !== connector.configKey + ) { + return; + } + + yield* Effect.logWarning("Relay client exited; restarting", { + pid: Number(connector.child.pid), + ...(Result.isSuccess(result) + ? { exitCode: Number(result.success) } + : { cause: result.failure }), + tunnelId: connector.config.tunnelId, + tunnelName: connector.config.tunnelName, + }); + yield* reconcileConfig(desiredConfig); + }), + ); + }).pipe( + Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), + ); + + reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { + if (!config || config.providerKind !== "cloudflare_tunnel") { + yield* stopActive; + return config + ? { status: "unsupported", providerKind: config.providerKind } + : { status: "disabled" }; + } + + const nextConfigKey = runtimeConfigKey(config); + const active = yield* Ref.get(activeRef); + if (active?.configKey === nextConfigKey) { + const isRunning = yield* active.child.isRunning.pipe( + Effect.catch(() => Effect.succeed(false)), + ); + if (isRunning) { + return { + status: "running", + providerKind: "cloudflare_tunnel", + pid: Number(active.child.pid), + ...(active.config.tunnelId ? { tunnelId: active.config.tunnelId } : {}), + ...(active.config.tunnelName ? { tunnelName: active.config.tunnelName } : {}), + } satisfies CloudManagedEndpointRuntimeStatus; + } + } + + yield* stopActive; + + const executable = yield* relayClient.resolve; + if (executable.status !== "available") { + return { + status: "failed", + providerKind: "cloudflare_tunnel", + reason: + executable.status === "unsupported" + ? `Relay client is unsupported on ${executable.platform}-${executable.arch}.` + : "The relay client is not installed.", + ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), + ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), + } satisfies CloudManagedEndpointRuntimeStatus; + } + + const connectorScope = yield* Scope.make("sequential"); + const child = yield* spawner + .spawn( + ChildProcess.make(executable.executablePath, ["tunnel", "run"], { + detached: false, + env: { + ...process.env, + TUNNEL_TOKEN: config.connectorToken, + }, + shell: false, + stderr: "ignore", + stdout: "ignore", + }), + ) + .pipe( + Effect.provideService(Scope.Scope, connectorScope), + Effect.tap(() => + Effect.logInfo("Relay client started", { + tunnelId: config.tunnelId, + tunnelName: config.tunnelName, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to start relay client", { + cause, + tunnelId: config.tunnelId, + tunnelName: config.tunnelName, + }).pipe( + Effect.andThen(Scope.close(connectorScope, Exit.void).pipe(Effect.ignore)), + Effect.as({ + status: "failed", + providerKind: "cloudflare_tunnel", + reason: String(cause), + ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), + ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), + } satisfies CloudManagedEndpointRuntimeStatus), + ), + ), + ); + + if ("status" in child && child.status === "failed") { + return child; + } + + if (!("status" in child)) { + const connector = { + child, + scope: connectorScope, + configKey: nextConfigKey, + config, + } satisfies ActiveConnector; + yield* Ref.set(activeRef, connector); + yield* Effect.forkIn(superviseConnector(connector), connectorScope); + return { + status: "running", + providerKind: "cloudflare_tunnel", + pid: Number(child.pid), + ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), + ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), + } satisfies CloudManagedEndpointRuntimeStatus; + } + + return { + status: "failed", + providerKind: "cloudflare_tunnel", + reason: "Relay client did not start.", + ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), + ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), + } satisfies CloudManagedEndpointRuntimeStatus; + }); + + const applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")( + (config: RelayManagedEndpointRuntimeConfig | null) => + reconcileSemaphore.withPermits(1)( + Ref.set(desiredConfigRef, config).pipe(Effect.andThen(reconcileConfig(config))), + ), + ); + + return CloudManagedEndpointRuntime.of({ + applyConfig, + }); +}); + +export const layer = Layer.effect( + CloudManagedEndpointRuntime, + Effect.gen(function* () { + const runtime = yield* makeCloudManagedEndpointRuntime; + const initialConfig = yield* readRuntimeConfig.pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( + Effect.as(null), + ), + ), + ); + yield* runtime.applyConfig(initialConfig); + yield* Effect.addFinalizer(() => runtime.applyConfig(null)); + return runtime; + }), +); diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts new file mode 100644 index 00000000000..f5642393abf --- /dev/null +++ b/apps/server/src/cloud/config.ts @@ -0,0 +1,18 @@ +import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; +import * as Schema from "effect/Schema"; + +export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key"; +export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config"; +export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id"; +export const RELAY_URL_SECRET = "cloud-relay-url"; +export const RELAY_ISSUER_SECRET = "cloud-relay-issuer"; +export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential"; +export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity"; + +export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect( + Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), +); + +export const decodeRuntimeConfig = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), +); diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts new file mode 100644 index 00000000000..3a033d50303 --- /dev/null +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -0,0 +1,87 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { ServerConfig } from "../config.ts"; +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; + +const makeServerSecretStoreLayer = () => + ServerSecretStore.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-environment-keys-test-" })), + ); + +const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); + +it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it) => { + it.effect("persists one atomic keypair secret and reuses it", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + + const first = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore); + const second = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore); + + expect(second).toEqual(first); + expect(yield* secretStore.get("cloud-link-ed25519-key-pair")).not.toBeNull(); + expect(yield* secretStore.get("cloud-link-ed25519-private-key")).toBeNull(); + expect(yield* secretStore.get("cloud-link-ed25519-public-key")).toBeNull(); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("migrates a legacy keypair into the atomic secret", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + yield* secretStore.set("cloud-link-ed25519-private-key", new TextEncoder().encode("private")); + yield* secretStore.set("cloud-link-ed25519-public-key", new TextEncoder().encode("public")); + + expect(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore)).toEqual({ + privateKey: "private", + publicKey: "public", + }); + expect(yield* secretStore.get("cloud-link-ed25519-key-pair")).not.toBeNull(); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("uses the persisted keypair when a concurrent creator wins", () => + Effect.gen(function* () { + const winner = new TextEncoder().encode( + '{"privateKey":"winner-private","publicKey":"winner-public"}', + ); + let createAttempted = false; + const secretStore = { + get: (name) => + Effect.sync(() => + name === "cloud-link-ed25519-key-pair" && createAttempted ? winner : null, + ), + set: unusedSecretStoreOperation, + create: () => + Effect.sync(() => { + createAttempted = true; + }).pipe( + Effect.flatMap(() => + Effect.fail( + new ServerSecretStore.SecretStoreError({ + message: "Concurrent keypair creation won.", + cause: PlatformError.systemError({ + _tag: "AlreadyExists", + module: "FileSystem", + method: "open", + pathOrDescriptor: "cloud-link-ed25519-key-pair.bin", + }), + }), + ), + ), + ), + getOrCreateRandom: unusedSecretStoreOperation, + remove: unusedSecretStoreOperation, + } satisfies ServerSecretStore.ServerSecretStoreShape; + + expect(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore)).toEqual({ + privateKey: "winner-private", + publicKey: "winner-public", + }); + }), + ); +}); diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts new file mode 100644 index 00000000000..beef4729992 --- /dev/null +++ b/apps/server/src/cloud/environmentKeys.ts @@ -0,0 +1,100 @@ +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +const CLOUD_LINK_KEY_PAIR = "cloud-link-ed25519-key-pair"; +const CLOUD_LINK_PRIVATE_KEY = "cloud-link-ed25519-private-key"; +const CLOUD_LINK_PUBLIC_KEY = "cloud-link-ed25519-public-key"; + +const EnvironmentKeyPair = Schema.Struct({ + privateKey: Schema.String, + publicKey: Schema.String, +}); +type EnvironmentKeyPair = typeof EnvironmentKeyPair.Type; + +const EnvironmentKeyPairJson = Schema.fromJsonString(EnvironmentKeyPair); +const decodeEnvironmentKeyPair = Schema.decodeUnknownEffect(EnvironmentKeyPairJson); +const encodeEnvironmentKeyPair = Schema.encodeEffect(EnvironmentKeyPairJson); + +function bytesToString(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +function stringToBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +const keyPairPersistenceError = (message: string, cause?: unknown) => + new ServerSecretStore.SecretStoreError({ message, cause }); + +const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( + secrets: ServerSecretStore.ServerSecretStoreShape, +) { + const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR); + if (encoded === null) { + return null; + } + return yield* decodeEnvironmentKeyPair(bytesToString(encoded)).pipe( + Effect.mapError((cause) => + keyPairPersistenceError("Failed to decode environment signing key pair.", cause), + ), + ); +}); + +const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* ( + secrets: ServerSecretStore.ServerSecretStoreShape, + keyPair: EnvironmentKeyPair, +) { + const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( + Effect.mapError((cause) => + keyPairPersistenceError("Failed to encode environment signing key pair.", cause), + ), + ); + return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( + Effect.as(keyPair), + Effect.catchTag("SecretStoreError", (error) => + ServerSecretStore.isSecretAlreadyExistsError(error) + ? readEnvironmentKeyPair(secrets).pipe( + Effect.flatMap((existing) => + existing !== null + ? Effect.succeed(existing) + : Effect.fail( + keyPairPersistenceError( + "Failed to read environment signing key pair after concurrent creation.", + ), + ), + ), + ) + : Effect.fail(error), + ), + ); +}); + +export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( + secrets: ServerSecretStore.ServerSecretStoreShape, +) { + const existing = yield* readEnvironmentKeyPair(secrets); + if (existing !== null) { + return existing; + } + + const existingPrivate = yield* secrets.get(CLOUD_LINK_PRIVATE_KEY); + const existingPublic = yield* secrets.get(CLOUD_LINK_PUBLIC_KEY); + if (existingPrivate && existingPublic) { + return yield* persistEnvironmentKeyPair(secrets, { + privateKey: bytesToString(existingPrivate), + publicKey: bytesToString(existingPublic), + }); + } + + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + return yield* persistEnvironmentKeyPair(secrets, { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + }); +}); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts new file mode 100644 index 00000000000..2b7e7d249ef --- /dev/null +++ b/apps/server/src/cloud/http.test.ts @@ -0,0 +1,119 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import { HttpClient } from "effect/unstable/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import * as CliTokenManager from "./CliTokenManager.ts"; +import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; +import { + CloudManagedEndpointRuntime, + type CloudManagedEndpointRuntimeShape, +} from "./ManagedEndpointRuntime.ts"; + +const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => + new ServerSecretStore.SecretStoreError({ + message: "Failed to persist cloud replay guard.", + cause: PlatformError.systemError({ + _tag: tag, + module: "FileSystem", + method: "open", + pathOrDescriptor: "cloud-replay-guard.bin", + }), + }); + +const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); + +function makeSecretStore( + create: ServerSecretStore.ServerSecretStoreShape["create"], +): ServerSecretStore.ServerSecretStoreShape { + return { + get: unusedSecretStoreOperation, + set: unusedSecretStoreOperation, + create, + getOrCreateRandom: unusedSecretStoreOperation, + remove: unusedSecretStoreOperation, + }; +} + +describe("consumeCloudReplayGuards", () => { + it.effect("reports already-created guards as replay conflicts", () => + Effect.gen(function* () { + const consumed = yield* consumeCloudReplayGuards({ + secrets: makeSecretStore(() => Effect.fail(storeFailure("AlreadyExists"))), + names: ["cloud-jti", "cloud-nonce"], + value: new Uint8Array(), + }); + + expect(consumed).toBe(false); + }), + ); + + it.effect("preserves replay-store availability failures", () => + Effect.gen(function* () { + const failure = storeFailure("PermissionDenied"); + const error = yield* Effect.flip( + consumeCloudReplayGuards({ + secrets: makeSecretStore(() => Effect.fail(failure)), + names: ["cloud-jti", "cloud-nonce"], + value: new Uint8Array(), + }), + ); + + expect(error).toBe(failure); + }), + ); +}); + +describe("reconcileDesiredCloudLink", () => { + it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => + Effect.gen(function* () { + const error = yield* Effect.flip(reconcileDesiredCloudLink("http://127.0.0.1:3774")); + + expect(error).toMatchObject({ + _tag: "EnvironmentHttpUnauthorizedError", + message: "Run `t3 cloud link` to authorize this environment.", + }); + }).pipe( + Effect.provideService( + ServerSecretStore.ServerSecretStore, + makeSecretStore(unusedSecretStoreOperation), + ), + Effect.provideService( + ServerEnvironment, + ServerEnvironment.of({ + getEnvironmentId: unusedSecretStoreOperation(), + getDescriptor: unusedSecretStoreOperation(), + }), + ), + Effect.provideService( + CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.of({ + applyConfig: unusedSecretStoreOperation, + } satisfies CloudManagedEndpointRuntimeShape), + ), + Effect.provideService( + EnvironmentAuth.EnvironmentAuth, + EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuthShape), + ), + Effect.provideService( + CliTokenManager.CloudCliTokenManager, + CliTokenManager.CloudCliTokenManager.of({ + get: unusedSecretStoreOperation(), + getExisting: Effect.succeed(Option.none()), + hasCredential: unusedSecretStoreOperation(), + clear: unusedSecretStoreOperation(), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => unusedSecretStoreOperation()), + ), + Effect.provide(NodeServices.layer), + ), + ); +}); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts new file mode 100644 index 00000000000..38203dae37e --- /dev/null +++ b/apps/server/src/cloud/http.ts @@ -0,0 +1,944 @@ +import * as NodeCrypto from "node:crypto"; +import { + AuthRelayReadScope, + AuthRelayWriteScope, + AuthStandardClientScopes, + EnvironmentCloudEndpointUnavailableError, + EnvironmentCloudLinkStateResult, + EnvironmentCloudRelayConfigResult, + EnvironmentHttpApi, + EnvironmentHttpBadRequestError, + EnvironmentHttpConflictError, + EnvironmentHttpInternalServerError, + EnvironmentHttpUnauthorizedError, +} from "@t3tools/contracts"; +import { + RelayCloudEnvironmentHealthProofPayload, + RelayCloudEnvironmentHealthRequest, + RelayCloudMintCredentialProofPayload, + RelayCloudMintCredentialRequest, + RelayEnvironmentHealthResponseProofPayload, + type RelayEnvironmentHealthResponse as RelayEnvironmentHealthResponseShape, + RelayEnvironmentConfigRequest, + RelayEnvironmentLinkChallengeResponse, + RelayEnvironmentLinkResponse, + RelayEnvironmentMintResponseProofPayload, + type RelayEnvironmentMintResponse as RelayEnvironmentMintResponseShape, + RelayEnvironmentLinkProof, + RelayEnvironmentLinkProofPayload, + RelayLinkProofRequest, + RelayManagedEndpointOrigin, +} from "@t3tools/contracts/relay"; +import { + normalizeRelayIssuer, + RELAY_HEALTH_REQUEST_TYP, + RELAY_HEALTH_RESPONSE_TYP, + RELAY_LINK_PROOF_TYP, + RELAY_MINT_REQUEST_TYP, + RELAY_MINT_RESPONSE_TYP, + signRelayJwt, + verifyRelayJwt, +} from "@t3tools/shared/relayJwt"; +import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as HttpEffect from "effect/unstable/http/HttpEffect"; +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { requireEnvironmentScope } from "../auth/http.ts"; +import { + ServerEnvironment, + type ServerEnvironmentShape, +} from "../environment/Services/ServerEnvironment.ts"; +import { + CloudManagedEndpointRuntime, + type CloudManagedEndpointRuntimeShape, +} from "./ManagedEndpointRuntime.ts"; +import { + CLOUD_ENDPOINT_RUNTIME_CONFIG, + CLOUD_LINKED_USER_ID, + CLOUD_MINT_PUBLIC_KEY, + encodeEndpointRuntimeConfigJson, + PUBLISH_AGENT_ACTIVITY_SECRET, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "./config.ts"; +import { relayUrlConfig } from "./publicConfig.ts"; +import * as CliState from "./CliState.ts"; +import * as CliTokenManager from "./CliTokenManager.ts"; +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; + +const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; +const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; +const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-"; +const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; +const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; +const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; +const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); +const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { + "cache-control": "no-store", + pragma: "no-cache", +} as const; + +const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler( + (_request, response) => + Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)), +); + +const failEnvironmentCloudInternalError = + (message: string) => + (cause: unknown): Effect.Effect => + Effect.logError(message, { cause }).pipe( + Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))), + ); + +const requireRelayUrl = relayUrlConfig.pipe( + Effect.mapError( + () => + new EnvironmentHttpInternalServerError({ + message: "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.", + }), + ), +); + +function bytesToString(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +function stringToBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +export function consumeCloudReplayGuards(input: { + readonly secrets: ServerSecretStore.ServerSecretStoreShape; + readonly names: ReadonlyArray; + readonly value: Uint8Array; +}) { + return Effect.all( + input.names.map((name) => + input.secrets.create(name, input.value).pipe( + Effect.as(true), + Effect.catchTag("SecretStoreError", (error) => + ServerSecretStore.isSecretAlreadyExistsError(error) + ? Effect.succeed(false) + : Effect.fail(error), + ), + ), + ), + { concurrency: input.names.length }, + ).pipe(Effect.map((created) => created.every(Boolean))); +} + +function normalizePemForSignedPayload(value: string): string { + return value.trim(); +} + +function normalizeHostname(hostname: string): string { + return hostname + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, "$1"); +} + +function validateCloudMintPublicKey( + publicKey: string, +): Effect.Effect { + return Effect.try({ + try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), + catch: () => + new EnvironmentHttpBadRequestError({ + message: "Cloud mint public key must be a valid Ed25519 public key.", + }), + }).pipe( + Effect.flatMap((key) => + key.asymmetricKeyType === "ed25519" + ? Effect.void + : Effect.fail( + new EnvironmentHttpBadRequestError({ + message: "Cloud mint public key must be a valid Ed25519 public key.", + }), + ), + ), + ); +} + +function validateRelayConfigPayload( + payload: RelayEnvironmentConfigRequest, +): Effect.Effect { + if (!isSecureRelayUrl(payload.relayUrl)) { + return Effect.fail( + new EnvironmentHttpBadRequestError({ + message: "Relay URL must be a secure absolute HTTPS URL.", + }), + ); + } + if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) { + return Effect.fail( + new EnvironmentHttpBadRequestError({ + message: "Relay issuer must be a secure absolute HTTPS URL.", + }), + ); + } + if (payload.environmentCredential.trim().length === 0) { + return Effect.fail( + new EnvironmentHttpBadRequestError({ + message: "Relay environment credential is required.", + }), + ); + } + if (payload.cloudUserId.trim().length === 0) { + return Effect.fail( + new EnvironmentHttpBadRequestError({ + message: "Cloud user id is required.", + }), + ); + } + return Effect.void; +} + +function validateLinkedCloudUser(input: { + readonly secrets: ServerSecretStore.ServerSecretStoreShape; + readonly cloudUserId: string; +}): Effect.Effect { + return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthInternalError({ + message: "Could not verify the linked cloud account.", + cause, + }), + ), + Effect.flatMap((existing) => { + if (!existing) { + return Effect.void; + } + const existingCloudUserId = bytesToString(existing); + return existingCloudUserId === input.cloudUserId + ? Effect.void + : Effect.fail( + new EnvironmentHttpConflictError({ + message: + "This environment is already linked to a different cloud account. Unlink it before switching accounts.", + }), + ); + }), + ); +} + +function readInstalledCloudUserId( + secrets: ServerSecretStore.ServerSecretStoreShape, +): Effect.Effect { + return secrets.get(CLOUD_LINKED_USER_ID).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthInternalError({ + message: "Could not read the linked cloud account.", + cause, + }), + ), + Effect.flatMap((bytes) => + bytes + ? Effect.succeed(bytesToString(bytes)) + : Effect.fail( + new EnvironmentAuth.ServerAuthInternalError({ + message: "Cloud linked user is not installed for this environment.", + }), + ), + ), + ); +} + +function isLoopbackHostname(hostname: string): boolean { + return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); +} + +function firstForwardedHeaderValue(value: string | undefined): string | undefined { + const first = value?.split(",")[0]?.trim(); + return first && first.length > 0 ? first : undefined; +} + +function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string | null { + try { + return new URL(request.originalUrl).href; + } catch { + const host = firstForwardedHeaderValue(request.headers.host) ?? "127.0.0.1"; + try { + return new URL(request.originalUrl, `http://${host}`).href; + } catch { + return null; + } + } +} + +function hasForwardedAuthorityHeaders(request: HttpServerRequest.HttpServerRequest): boolean { + return ( + firstForwardedHeaderValue(request.headers["x-forwarded-host"]) !== undefined || + firstForwardedHeaderValue(request.headers["x-forwarded-proto"]) !== undefined + ); +} + +function endpointRequestPort(url: URL): number { + return Number(url.port || (url.protocol === "https:" ? 443 : 80)); +} + +function isAllowedEndpointOrigin(input: { + readonly origin: RelayManagedEndpointOrigin; + readonly requestUrl: string; +}): boolean { + if (!isLoopbackHostname(input.origin.localHttpHost)) { + return false; + } + + const url = new URL(input.requestUrl); + if (!isLoopbackHostname(url.hostname)) { + return false; + } + + return input.origin.localHttpPort === endpointRequestPort(url); +} + +function providerKindMatchesRequestedLinkScopes(request: RelayLinkProofRequest): boolean { + return request.endpoint.providerKind === "cloudflare_tunnel"; +} + +function hasExactScope(input: { + readonly scopes: ReadonlyArray; + readonly expected: string; +}): boolean { + return input.scopes.length === 1 && input.scopes[0] === input.expected; +} + +function hasBoundedCloudProofLifetime(input: { + readonly iat: number; + readonly exp: number; + readonly nowSeconds: number; +}): boolean { + return ( + input.exp > input.iat && + input.exp - input.iat <= CLOUD_PROOF_MAX_LIFETIME_SECONDS && + input.iat <= input.nowSeconds + CLOUD_PROOF_CLOCK_SKEW_SECONDS + ); +} + +const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentHealthProofPayload); +const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload); + +interface CloudHttpDependencies { + readonly secrets: ServerSecretStore.ServerSecretStoreShape; + readonly environment: ServerEnvironmentShape; + readonly endpointRuntime: CloudManagedEndpointRuntimeShape; + readonly environmentAuth: EnvironmentAuth.EnvironmentAuthShape; + readonly cliTokenManager: CliTokenManager.CloudCliTokenManagerShape; + readonly httpClient: HttpClient.HttpClient; +} + +const cloudHttpDependencies = Effect.gen(function* () { + return { + secrets: yield* ServerSecretStore.ServerSecretStore, + environment: yield* ServerEnvironment, + endpointRuntime: yield* CloudManagedEndpointRuntime, + environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, + cliTokenManager: yield* CliTokenManager.CloudCliTokenManager, + httpClient: yield* HttpClient.HttpClient, + } satisfies CloudHttpDependencies; +}); + +const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function* ( + dependencies: CloudHttpDependencies, + request: RelayLinkProofRequest, + requestUrl: string, +) { + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); + if ( + !providerKindMatchesRequestedLinkScopes(request) || + !isAllowedEndpointOrigin({ + origin: request.origin, + requestUrl, + }) + ) { + return yield* new EnvironmentHttpBadRequestError({ + message: "Invalid managed endpoint origin.", + }); + } + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 5 }); + const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); + const descriptor = yield* dependencies.environment.getDescriptor; + const payload = { + iss: `t3-env:${descriptor.environmentId}`, + aud: normalizeRelayIssuer(request.relayIssuer), + sub: descriptor.environmentId, + jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)), + iat: nowSeconds, + exp: Math.floor(expiresAt.epochMilliseconds / 1_000), + challenge: request.challenge, + descriptor, + environmentId: descriptor.environmentId, + environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey), + endpoint: request.endpoint, + origin: request.origin, + scopes: ["agent_activity_notifications", "managed_tunnels"], + } satisfies RelayEnvironmentLinkProofPayload; + return yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_LINK_PROOF_TYP, + payload, + }).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthInternalError({ + message: "Failed to sign cloud link JWT.", + cause, + }), + ), + ); +}); + +const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( + function* (dependencies: CloudHttpDependencies, request: RelayLinkProofRequest) { + yield* requireEnvironmentScope(AuthRelayWriteScope); + const httpRequest = yield* HttpServerRequest.HttpServerRequest; + const requestUrl = requestAbsoluteUrl(httpRequest); + if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { + return yield* new EnvironmentHttpBadRequestError({ + message: "Invalid managed endpoint origin.", + }); + } + const proof = yield* makeCloudLinkProof(dependencies, request, requestUrl); + yield* appendCloudCredentialResponseHeaders; + return proof satisfies RelayEnvironmentLinkProof; + }, + Effect.catchTag("ServerAuthInternalError", (error) => + failEnvironmentCloudInternalError(error.message)(error.cause), + ), + Effect.catchTags({ + PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."), + SecretStoreError: failEnvironmentCloudInternalError( + "Could not generate environment link proof.", + ), + }), +); + +const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( + dependencies: CloudHttpDependencies, + payload: RelayEnvironmentConfigRequest, +) { + yield* validateRelayConfigPayload(payload); + yield* validateLinkedCloudUser({ + secrets: dependencies.secrets, + cloudUserId: payload.cloudUserId, + }); + yield* validateCloudMintPublicKey(payload.cloudMintPublicKey); + const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig( + payload.endpointRuntime, + ); + const ok = + endpointRuntimeStatus.status === "disabled" || endpointRuntimeStatus.status === "running"; + if (!ok) { + return yield* new EnvironmentCloudEndpointUnavailableError({ + message: "Managed endpoint runtime could not be started.", + endpointRuntimeStatus, + }); + } + + yield* dependencies.secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl)); + yield* dependencies.secrets.set( + RELAY_ISSUER_SECRET, + stringToBytes(payload.relayIssuer ?? payload.relayUrl), + ); + yield* dependencies.secrets.set(CLOUD_LINKED_USER_ID, stringToBytes(payload.cloudUserId)); + yield* dependencies.secrets.set( + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + stringToBytes(payload.environmentCredential), + ); + yield* dependencies.secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); + if (payload.endpointRuntime) { + const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); + yield* dependencies.secrets.set( + CLOUD_ENDPOINT_RUNTIME_CONFIG, + stringToBytes(endpointRuntimeJson), + ); + } else { + yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); + } + return { ok, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; +}); + +const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( + function* (dependencies: CloudHttpDependencies, payload: RelayEnvironmentConfigRequest) { + yield* requireEnvironmentScope(AuthRelayWriteScope); + return yield* applyCloudRelayConfig(dependencies, payload); + }, + Effect.catchTag("ServerAuthInternalError", (error) => + failEnvironmentCloudInternalError(error.message)(error.cause), + ), + Effect.catchTags({ + SchemaError: failEnvironmentCloudInternalError( + "Could not persist environment relay configuration.", + ), + SecretStoreError: failEnvironmentCloudInternalError( + "Could not persist environment relay configuration.", + ), + }), +); + +const relayClientRequest =
( + dependencies: CloudHttpDependencies, + input: { + readonly url: string; + readonly token: string; + readonly payload: unknown; + readonly schema: Schema.Decoder; + }, +) => + HttpClientRequest.post(input.url).pipe( + HttpClientRequest.bearerToken(input.token), + HttpClientRequest.bodyJson(input.payload), + Effect.flatMap(dependencies.httpClient.execute), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), + Effect.mapError( + (cause) => + new EnvironmentHttpInternalServerError({ + message: `T3 Cloud relay request failed: ${String(cause)}`, + }), + ), + ); + +const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")( + function* (dependencies: CloudHttpDependencies, localOrigin: string) { + const localUrl = yield* Effect.try({ + try: () => new URL(localOrigin), + catch: () => + new EnvironmentHttpBadRequestError({ + message: "Could not resolve local environment origin.", + }), + }); + if (localUrl.origin !== localOrigin) { + return yield* new EnvironmentHttpBadRequestError({ + message: "Could not resolve local environment origin.", + }); + } + const localWsOrigin = localOrigin.replace(/^http/u, "ws"); + const token = yield* dependencies.cliTokenManager.getExisting.pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new EnvironmentHttpUnauthorizedError({ + message: "Run `t3 cloud link` to authorize this environment.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + const relayUrl = yield* requireRelayUrl; + const challenge = yield* relayClientRequest(dependencies, { + url: `${relayUrl}/v1/client/environment-link-challenges`, + token: token.accessToken, + payload: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + schema: RelayEnvironmentLinkChallengeResponse, + }); + const proof = yield* makeCloudLinkProof( + dependencies, + { + challenge: challenge.challenge, + relayIssuer: relayUrl, + endpoint: { + httpBaseUrl: localOrigin, + wsBaseUrl: localWsOrigin, + providerKind: "cloudflare_tunnel", + }, + origin: { + localHttpHost: localUrl.hostname, + localHttpPort: endpointRequestPort(localUrl), + }, + }, + localOrigin, + ); + const link = yield* relayClientRequest(dependencies, { + url: `${relayUrl}/v1/client/environment-links`, + token: token.accessToken, + payload: { + proof, + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + schema: RelayEnvironmentLinkResponse, + }); + yield* CliState.setCliDesiredCloudLink(true); + return yield* applyCloudRelayConfig(dependencies, { + relayUrl, + relayIssuer: link.relayIssuer, + cloudUserId: link.cloudUserId, + environmentCredential: link.environmentCredential, + cloudMintPublicKey: link.cloudMintPublicKey, + endpointRuntime: link.endpointRuntime, + }); + }, + Effect.catchTags({ + CloudCliTokenManagerError: (error) => + failEnvironmentCloudInternalError(error.message)(error.cause), + SecretStoreError: failEnvironmentCloudInternalError( + "Could not persist desired T3 Cloud link state.", + ), + }), +); + +export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileDesiredLink")( + function* (localOrigin: string) { + return yield* reconcileDesiredCloudLinkWith(yield* cloudHttpDependencies, localOrigin); + }, +); + +const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* ( + dependencies: CloudHttpDependencies, +) { + const [cloudUserId, relayUrl, relayIssuer, publishAgentActivity] = yield* Effect.all( + [ + dependencies.secrets.get(CLOUD_LINKED_USER_ID), + dependencies.secrets.get(RELAY_URL_SECRET), + dependencies.secrets.get(RELAY_ISSUER_SECRET), + dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), + ], + { concurrency: 4 }, + ); + return { + linked: cloudUserId !== null, + cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, + relayUrl: relayUrl ? bytesToString(relayUrl) : null, + relayIssuer: relayIssuer ? bytesToString(relayIssuer) : null, + publishAgentActivity: publishAgentActivity + ? bytesToString(publishAgentActivity) === "true" + : false, + } satisfies EnvironmentCloudLinkStateResult; +}); + +const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( + function* (dependencies: CloudHttpDependencies) { + yield* requireEnvironmentScope(AuthRelayReadScope); + return yield* readCloudLinkState(dependencies); + }, + Effect.catchTag( + "SecretStoreError", + failEnvironmentCloudInternalError("Could not read environment relay configuration."), + ), +); + +const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( + function* (dependencies: CloudHttpDependencies) { + yield* requireEnvironmentScope(AuthRelayWriteScope); + const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(null); + yield* Effect.all( + [ + dependencies.secrets.remove(CLOUD_LINKED_USER_ID), + dependencies.secrets.remove(RELAY_URL_SECRET), + dependencies.secrets.remove(RELAY_ISSUER_SECRET), + dependencies.secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + dependencies.secrets.remove(CLOUD_MINT_PUBLIC_KEY), + dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), + dependencies.secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), + ], + { concurrency: 7 }, + ); + yield* CliState.setCliDesiredCloudLink(false); + return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; + }, + Effect.catchTag( + "SecretStoreError", + failEnvironmentCloudInternalError("Could not remove environment relay configuration."), + ), +); + +const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( + function* ( + dependencies: CloudHttpDependencies, + payload: { readonly publishAgentActivity: boolean }, + ) { + yield* requireEnvironmentScope(AuthRelayWriteScope); + yield* dependencies.secrets.set( + PUBLISH_AGENT_ACTIVITY_SECRET, + stringToBytes(String(payload.publishAgentActivity)), + ); + return yield* readCloudLinkState(dependencies); + }, + Effect.catchTag( + "SecretStoreError", + failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), + ), +); + +const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( + function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) { + const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( + Effect.flatMap((bytes) => + bytes + ? Effect.succeed(bytesToString(bytes)) + : Effect.fail( + new EnvironmentAuth.ServerAuthInternalError({ + message: "Cloud mint public key is not installed for this environment.", + }), + ), + ), + ); + const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( + Effect.flatMap((bytes) => + bytes + ? Effect.succeed(bytesToString(bytes)) + : dependencies.secrets.get(RELAY_URL_SECRET).pipe( + Effect.flatMap((fallbackBytes) => + fallbackBytes + ? Effect.succeed(bytesToString(fallbackBytes)) + : Effect.fail( + new EnvironmentAuth.ServerAuthInternalError({ + message: "Cloud relay issuer is not installed for this environment.", + }), + ), + ), + ), + ), + ); + const environmentId = yield* dependencies.environment.getEnvironmentId; + const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); + const now = yield* DateTime.now; + const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); + const proofOption = yield* verifyRelayJwt({ + publicKey: cloudMintPublicKey, + token: request.proof, + typ: RELAY_HEALTH_REQUEST_TYP, + issuer: normalizeRelayIssuer(relayIssuer), + audience: `t3-env:${environmentId}`, + nowEpochSeconds: nowSeconds, + }).pipe(Effect.flatMap(decodeCloudHealthProof), Effect.option); + if ( + Option.isNone(proofOption) || + proofOption.value.environmentId !== environmentId || + proofOption.value.sub !== linkedCloudUserId || + !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || + !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" }) + ) { + return yield* new EnvironmentHttpUnauthorizedError({ + message: "Invalid cloud health request.", + }); + } + const proof = proofOption.value; + + const jtiSecretName = `${CLOUD_HEALTH_JTI_PREFIX}${proof.jti}`; + const nonceSecretName = `${CLOUD_HEALTH_NONCE_PREFIX}${proof.nonce}`; + const consumedReplayGuards = yield* consumeCloudReplayGuards({ + secrets: dependencies.secrets, + names: [jtiSecretName, nonceSecretName], + value: stringToBytes(DateTime.formatIso(now)), + }); + if (!consumedReplayGuards) { + return yield* new EnvironmentHttpConflictError({ + message: "Cloud health request was already consumed.", + }); + } + + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); + const descriptor = yield* dependencies.environment.getDescriptor; + const responseExpiresAt = DateTime.add(now, { minutes: 5 }); + const responsePayload = { + iss: `t3-env:${environmentId}`, + aud: normalizeRelayIssuer(relayIssuer), + sub: environmentId, + jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)), + iat: nowSeconds, + exp: Math.floor(responseExpiresAt.epochMilliseconds / 1_000), + environmentId, + requestNonce: proof.nonce, + status: "online", + descriptor, + checkedAt: DateTime.formatIso(now), + } satisfies RelayEnvironmentHealthResponseProofPayload; + const responseProof = yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_HEALTH_RESPONSE_TYP, + payload: responsePayload, + }).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthInternalError({ + message: "Failed to sign cloud health JWT.", + cause, + }), + ), + ); + const response = { + environmentId, + status: "online", + descriptor, + checkedAt: responsePayload.checkedAt, + proof: responseProof, + } satisfies RelayEnvironmentHealthResponseShape; + + yield* appendCloudCredentialResponseHeaders; + return response; + }, + Effect.catchTag("ServerAuthInternalError", (error) => + failEnvironmentCloudInternalError(error.message)(error.cause), + ), + Effect.catchTags({ + PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."), + SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."), + }), +); + +const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( + function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) { + const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( + Effect.flatMap((bytes) => + bytes + ? Effect.succeed(bytesToString(bytes)) + : Effect.fail( + new EnvironmentAuth.ServerAuthInternalError({ + message: "Cloud mint public key is not installed for this environment.", + }), + ), + ), + ); + const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( + Effect.flatMap((bytes) => + bytes + ? Effect.succeed(bytesToString(bytes)) + : dependencies.secrets.get(RELAY_URL_SECRET).pipe( + Effect.flatMap((fallbackBytes) => + fallbackBytes + ? Effect.succeed(bytesToString(fallbackBytes)) + : Effect.fail( + new EnvironmentAuth.ServerAuthInternalError({ + message: "Cloud relay issuer is not installed for this environment.", + }), + ), + ), + ), + ), + ); + const environmentId = yield* dependencies.environment.getEnvironmentId; + const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); + const now = yield* DateTime.now; + const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); + const proofOption = yield* verifyRelayJwt({ + publicKey: cloudMintPublicKey, + token: request.proof, + typ: RELAY_MINT_REQUEST_TYP, + issuer: normalizeRelayIssuer(relayIssuer), + audience: `t3-env:${environmentId}`, + nowEpochSeconds: nowSeconds, + }).pipe(Effect.flatMap(decodeCloudMintProof), Effect.option); + if ( + Option.isNone(proofOption) || + proofOption.value.environmentId !== environmentId || + proofOption.value.sub !== linkedCloudUserId || + proofOption.value.cnf.jkt !== proofOption.value.clientProofKeyThumbprint || + !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || + !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" }) + ) { + return yield* new EnvironmentHttpUnauthorizedError({ + message: "Invalid cloud mint request.", + }); + } + const proof = proofOption.value; + + const jtiSecretName = `${CLOUD_MINT_JTI_PREFIX}${proof.jti}`; + const nonceSecretName = `${CLOUD_MINT_NONCE_PREFIX}${proof.nonce}`; + const consumedReplayGuards = yield* consumeCloudReplayGuards({ + secrets: dependencies.secrets, + names: [jtiSecretName, nonceSecretName], + value: stringToBytes(DateTime.formatIso(now)), + }); + if (!consumedReplayGuards) { + return yield* new EnvironmentHttpConflictError({ + message: "Cloud mint request was already consumed.", + }); + } + + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); + const issued = yield* dependencies.environmentAuth.createPairingLink({ + scopes: AuthStandardClientScopes, + subject: "cloud-connect", + ttl: Duration.minutes(2), + label: "T3 Cloud connect", + proofKeyThumbprint: proof.clientProofKeyThumbprint, + }); + const responsePayload = { + iss: `t3-env:${environmentId}`, + aud: normalizeRelayIssuer(relayIssuer), + sub: environmentId, + jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)), + iat: nowSeconds, + exp: Math.floor(issued.expiresAt.epochMilliseconds / 1_000), + environmentId, + clientProofKeyThumbprint: proof.clientProofKeyThumbprint, + requestNonce: proof.nonce, + credential: issued.credential, + } satisfies RelayEnvironmentMintResponseProofPayload; + const responseProof = yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: RELAY_MINT_RESPONSE_TYP, + payload: responsePayload, + }).pipe( + Effect.mapError( + (cause) => + new EnvironmentAuth.ServerAuthInternalError({ + message: "Failed to sign cloud mint JWT.", + cause, + }), + ), + ); + const response = { + credential: issued.credential, + expiresAt: DateTime.formatIso(issued.expiresAt), + proof: responseProof, + } satisfies RelayEnvironmentMintResponseShape; + + yield* appendCloudCredentialResponseHeaders; + return response; + }, + Effect.catchTag("ServerAuthInternalError", (error) => + failEnvironmentCloudInternalError(error.message)(error.cause), + ), + Effect.catchTags({ + PlatformError: failEnvironmentCloudInternalError( + "Could not issue cloud connection credential.", + ), + SecretStoreError: failEnvironmentCloudInternalError( + "Could not issue cloud connection credential.", + ), + }), +); + +export const cloudHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "cloud", + Effect.fnUntraced(function* (handlers) { + const dependencies = yield* cloudHttpDependencies; + return handlers + .handle("linkProof", ({ payload }) => cloudLinkProofHandler(dependencies, payload)) + .handle("relayConfig", ({ payload }) => cloudRelayConfigHandler(dependencies, payload)) + .handle("linkState", () => cloudLinkStateHandler(dependencies)) + .handle("unlink", () => cloudUnlinkHandler(dependencies)) + .handle("preferences", ({ payload }) => cloudPreferencesHandler(dependencies, payload)) + .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) + .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) + .handle("t3MintCredential", ({ payload }) => + cloudMintCredentialHandler(dependencies, payload), + ); + }), +); diff --git a/apps/server/src/cloud/publicConfig.test.ts b/apps/server/src/cloud/publicConfig.test.ts new file mode 100644 index 00000000000..558560bfffb --- /dev/null +++ b/apps/server/src/cloud/publicConfig.test.ts @@ -0,0 +1,85 @@ +import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; + +import { makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts"; + +const provideEnv = (env: Readonly>) => + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))); + +it.effect("uses the statically injected relay URL when no runtime override exists", () => + Effect.gen(function* () { + const relayUrl = yield* makeRelayUrlConfig("https://embedded.example.test///").pipe( + provideEnv({}), + ); + + assert.equal(relayUrl, "https://embedded.example.test"); + }), +); + +it.effect("prefers a runtime relay URL override over the statically injected value", () => + Effect.gen(function* () { + const relayUrl = yield* makeRelayUrlConfig("https://embedded.example.test").pipe( + provideEnv({ T3CODE_RELAY_URL: "https://runtime.example.test///" }), + ); + + assert.equal(relayUrl, "https://runtime.example.test"); + }), +); + +it.effect("requires a relay URL when the server bundle has no injected value", () => + makeRelayUrlConfig("").pipe(provideEnv({}), Effect.flip), +); + +it.effect("rejects an insecure runtime relay URL override", () => + makeRelayUrlConfig("https://embedded.example.test").pipe( + provideEnv({ T3CODE_RELAY_URL: "http://runtime.example.test" }), + Effect.flip, + ), +); + +it.effect("rejects an injected relay URL with a non-origin path", () => + makeRelayUrlConfig("https://embedded.example.test/path").pipe(provideEnv({}), Effect.flip), +); + +it.effect("derives direct Clerk OAuth endpoints from statically injected public config", () => + Effect.gen(function* () { + const config = yield* makeCloudCliOAuthConfig({ + clerkPublishableKeyFallback: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==", + clerkCliOAuthClientIdFallback: "oauth_client_embedded", + }).pipe(provideEnv({})); + + assert.deepEqual(config, { + authorizationEndpoint: "https://clerk.example.test/oauth/authorize", + tokenEndpoint: "https://clerk.example.test/oauth/token", + clientId: "oauth_client_embedded", + redirectUri: "http://127.0.0.1:34338/callback", + scopes: ["openid", "profile", "email"], + }); + }), +); + +it.effect("prefers runtime Clerk OAuth config overrides over statically injected values", () => + Effect.gen(function* () { + const config = yield* makeCloudCliOAuthConfig({ + clerkPublishableKeyFallback: "pk_test_ZW1iZWRkZWQuZXhhbXBsZS50ZXN0JA==", + clerkCliOAuthClientIdFallback: "oauth_client_embedded", + }).pipe( + provideEnv({ + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_cnVudGltZS5leGFtcGxlLnRlc3Qk", + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_runtime", + }), + ); + + assert.equal(config.authorizationEndpoint, "https://runtime.example.test/oauth/authorize"); + assert.equal(config.tokenEndpoint, "https://runtime.example.test/oauth/token"); + assert.equal(config.clientId, "oauth_client_runtime"); + }), +); + +it.effect("requires Clerk OAuth config when the server bundle has no injected values", () => + makeCloudCliOAuthConfig({ + clerkPublishableKeyFallback: "", + clerkCliOAuthClientIdFallback: "", + }).pipe(provideEnv({}), Effect.flip), +); diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts new file mode 100644 index 00000000000..5c64a242377 --- /dev/null +++ b/apps/server/src/cloud/publicConfig.ts @@ -0,0 +1,110 @@ +import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; + +declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; +declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; +declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined; + +const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; +const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const; + +function validateRelayUrl(value: string) { + const relayUrl = normalizeSecureRelayUrl(value); + return relayUrl === null + ? Effect.fail( + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue(Option.some(value), { + message: "Relay URL must be a secure absolute HTTPS origin.", + }), + ), + ), + ) + : Effect.succeed(relayUrl); +} + +function readBuildTimeValue(value: string | undefined): string { + return typeof value === "undefined" ? "" : value.trim(); +} + +export const buildTimeRelayUrl = + typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" + ? "" + : (normalizeSecureRelayUrl(__T3CODE_BUILD_RELAY_URL__) ?? ""); +export const buildTimeClerkPublishableKey = readBuildTimeValue( + typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" + ? undefined + : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, +); +export const buildTimeClerkCliOAuthClientId = readBuildTimeValue( + typeof __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__ === "undefined" + ? undefined + : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, +); + +export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { + const runtimeConfig = Config.nonEmptyString("T3CODE_RELAY_URL"); + return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe( + Config.mapOrFail(validateRelayUrl), + ); +} + +export const relayUrlConfig = makeRelayUrlConfig(); + +function makePublicValueConfig(name: string, fallback: string) { + const runtimeConfig = Config.nonEmptyString(name); + return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe( + Config.map((value) => value.trim()), + ); +} + +export interface CloudCliOAuthConfig { + readonly authorizationEndpoint: string; + readonly tokenEndpoint: string; + readonly clientId: string; + readonly redirectUri: string; + readonly scopes: typeof CLOUD_CLI_OAUTH_SCOPES; +} + +export function makeCloudCliOAuthConfig({ + clerkPublishableKeyFallback = buildTimeClerkPublishableKey, + clerkCliOAuthClientIdFallback = buildTimeClerkCliOAuthClientId, +}: { + readonly clerkPublishableKeyFallback?: string; + readonly clerkCliOAuthClientIdFallback?: string; +} = {}) { + return Config.all({ + clerkPublishableKey: makePublicValueConfig( + "T3CODE_CLERK_PUBLISHABLE_KEY", + clerkPublishableKeyFallback, + ), + clientId: makePublicValueConfig( + "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID", + clerkCliOAuthClientIdFallback, + ), + }).pipe( + Config.map(({ clerkPublishableKey, clientId }) => { + const clerkFrontendApiUrl = clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey); + return { + authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, + tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, + clientId, + redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI, + scopes: CLOUD_CLI_OAUTH_SCOPES, + } satisfies CloudCliOAuthConfig; + }), + ); +} + +export const cloudCliOAuthConfig = makeCloudCliOAuthConfig(); + +export const hasCloudPublicConfig = Boolean( + (normalizeSecureRelayUrl(process.env.T3CODE_RELAY_URL ?? "") ?? buildTimeRelayUrl) && + (process.env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() || buildTimeClerkPublishableKey) && + (process.env.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || buildTimeClerkCliOAuthClientId), +); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0e521ecca9c..4e815aae8cc 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -8,6 +8,7 @@ import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import { cast } from "effect/Function"; @@ -47,11 +48,18 @@ const FALLBACK_PROJECT_FAVICON_SVG = ` { let runtime: ManagedRuntime.ManagedRuntime | null = null; @@ -63,6 +64,15 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { + publishThread: () => Effect.void, + start: () => { + started.push("agent-awareness-relay"); + return Effect.void; + }, + }), + ), ), ); @@ -75,6 +85,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "agent-awareness-relay", ]); await Effect.runPromise(Scope.close(scope, Exit.void)); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index 5e432d9884f..fb7543e31af 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -9,18 +9,21 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; +import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; + const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { yield* providerRuntimeIngestion.start(); yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* agentAwarenessRelay.start(); }); return { diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/Layers/AuthPairingLinks.ts index 7519c65c123..9d2760d1449 100644 --- a/apps/server/src/persistence/Layers/AuthPairingLinks.ts +++ b/apps/server/src/persistence/Layers/AuthPairingLinks.ts @@ -41,6 +41,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { scopes, subject, label, + proof_key_thumbprint, created_at, expires_at, consumed_at, @@ -53,6 +54,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { ${JSON.stringify(input.scopes)}, ${input.subject}, ${input.label}, + ${input.proofKeyThumbprint}, ${input.createdAt}, ${input.expiresAt}, NULL, @@ -64,7 +66,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ Request: ConsumeAuthPairingLinkInput, Result: AuthPairingLinkRecord, - execute: ({ credential, consumedAt, now }) => + execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => sql` UPDATE auth_pairing_links SET consumed_at = ${consumedAt} @@ -72,6 +74,10 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { AND revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ${now} + AND ( + proof_key_thumbprint IS NULL + OR proof_key_thumbprint = ${proofKeyThumbprint} + ) RETURNING id AS "id", credential AS "credential", @@ -79,6 +85,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { scopes AS "scopes", subject AS "subject", label AS "label", + proof_key_thumbprint AS "proofKeyThumbprint", created_at AS "createdAt", expires_at AS "expiresAt", consumed_at AS "consumedAt", @@ -98,6 +105,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { scopes AS "scopes", subject AS "subject", label AS "label", + proof_key_thumbprint AS "proofKeyThumbprint", created_at AS "createdAt", expires_at AS "expiresAt", consumed_at AS "consumedAt", @@ -136,6 +144,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { scopes AS "scopes", subject AS "subject", label AS "label", + proof_key_thumbprint AS "proofKeyThumbprint", created_at AS "createdAt", expires_at AS "expiresAt", consumed_at AS "consumedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 9524deae8d8..ba1131ee259 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -44,6 +44,7 @@ import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; +import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; /** * Migration loader with all migrations defined inline. @@ -87,6 +88,7 @@ export const migrationEntries = [ [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], + [32, "AuthPairingProofKeyThumbprint", Migration0032], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/032_AuthPairingProofKeyThumbprint.ts b/apps/server/src/persistence/Migrations/032_AuthPairingProofKeyThumbprint.ts new file mode 100644 index 00000000000..db414213a11 --- /dev/null +++ b/apps/server/src/persistence/Migrations/032_AuthPairingProofKeyThumbprint.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const pairingLinkColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_pairing_links) + `; + if (!pairingLinkColumns.some((column) => column.name === "proof_key_thumbprint")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN proof_key_thumbprint TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/AuthPairingLinks.ts b/apps/server/src/persistence/Services/AuthPairingLinks.ts index 16a9c4035ae..c8745982d29 100644 --- a/apps/server/src/persistence/Services/AuthPairingLinks.ts +++ b/apps/server/src/persistence/Services/AuthPairingLinks.ts @@ -13,6 +13,7 @@ export const AuthPairingLinkRecord = Schema.Struct({ scopes: Schema.fromJsonString(AuthEnvironmentScopes), subject: Schema.String, label: Schema.NullOr(Schema.String), + proofKeyThumbprint: Schema.NullOr(Schema.String), createdAt: Schema.DateTimeUtcFromString, expiresAt: Schema.DateTimeUtcFromString, consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), @@ -27,6 +28,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ scopes: AuthEnvironmentScopes, subject: Schema.String, label: Schema.NullOr(Schema.String), + proofKeyThumbprint: Schema.NullOr(Schema.String), createdAt: Schema.DateTimeUtcFromString, expiresAt: Schema.DateTimeUtcFromString, }); @@ -34,6 +36,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; export const ConsumeAuthPairingLinkInput = Schema.Struct({ credential: Schema.String, + proofKeyThumbprint: Schema.NullOr(Schema.String), consumedAt: Schema.DateTimeUtcFromString, now: Schema.DateTimeUtcFromString, }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts new file mode 100644 index 00000000000..bbfbd236ad0 --- /dev/null +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -0,0 +1,662 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import type { + EnvironmentId, + ExecutionEnvironmentDescriptor, + OrchestrationEvent, + OrchestrationProjectShell, + OrchestrationShellSnapshot, + OrchestrationThreadShell, + ProjectId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import type { + RelayAgentActivityPublishProofPayload, + RelayAgentActivityState, +} from "@t3tools/contracts/relay"; +import { CommandId, ProviderInstanceId } from "@t3tools/contracts"; +import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../orchestration/Services/OrchestrationEngine.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, + PUBLISH_AGENT_ACTIVITY_SECRET, +} from "../cloud/config.ts"; +import * as AgentAwarenessRelay from "./AgentAwarenessRelay.ts"; + +const state: RelayAgentActivityState = { + environmentId: "env" as RelayAgentActivityState["environmentId"], + threadId: "thread" as RelayAgentActivityState["threadId"], + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running", + headline: "Running", + updatedAt: "2026-05-25T00:00:00.000Z", + deepLink: "/threads/env/thread", +}; + +const encodeSecret = (value: string): Uint8Array => new TextEncoder().encode(value); + +function makeMemorySecretStore() { + const values = new Map(); + const store = { + get: ((name) => + Effect.sync( + () => values.get(name) ?? null, + )) satisfies ServerSecretStore.ServerSecretStoreShape["get"], + set: ((name, value) => + Effect.sync(() => { + values.set(name, Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStoreShape["set"], + create: ((name, value) => + Effect.sync(() => { + values.set(name, Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStoreShape["create"], + getOrCreateRandom: ((name, bytes) => + Effect.sync(() => { + const existing = values.get(name); + if (existing) { + return existing; + } + const generated = new Uint8Array(bytes); + values.set(name, generated); + return generated; + })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"], + remove: ((name) => + Effect.sync(() => { + values.delete(name); + })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"], + } satisfies ServerSecretStore.ServerSecretStoreShape; + return { + store, + setString: (name: string, value: string) => store.set(name, encodeSecret(value)), + }; +} + +describe.sequential("signRelayAgentActivityPublishProof", () => { + it("derives the thread id from the aggregate id for thread events without payload thread ids", () => { + const threadId = "thread-aggregate-1" as ThreadId; + const now = "2026-05-25T00:00:00.000Z"; + const event = { + type: "thread.activity-appended", + sequence: 1, + eventId: "evt-aggregate-1", + commandId: CommandId.make("cmd-1"), + aggregateKind: "thread", + aggregateId: threadId, + actor: { kind: "server" }, + payload: {}, + occurredAt: now, + } as unknown as OrchestrationEvent; + + expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); + }); + + it("does not publish streaming content or non-awareness activity events", () => { + const now = "2026-05-25T00:00:00.000Z"; + const base = { + sequence: 1, + eventId: "evt-1", + commandId: CommandId.make("cmd-1"), + aggregateKind: "thread", + aggregateId: "thread-1" as ThreadId, + occurredAt: now, + }; + + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.message-sent", + payload: { + threadId: "thread-1" as ThreadId, + streaming: true, + }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.activity-appended", + payload: { + threadId: "thread-1" as ThreadId, + activity: { + kind: "task.progress", + }, + }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.activity-appended", + payload: { + threadId: "thread-1" as ThreadId, + activity: { + kind: "approval.requested", + }, + }, + } as unknown as OrchestrationEvent), + ).toBe(true); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.message-sent", + payload: { + threadId: "thread-1" as ThreadId, + streaming: false, + }, + } as unknown as OrchestrationEvent), + ).toBe(true); + }); + + it("deduplicates awareness state updates whose only change is their event timestamp", () => { + expect(AgentAwarenessRelay.agentAwarenessPublishIdentity(state)).toBe( + AgentAwarenessRelay.agentAwarenessPublishIdentity({ + ...state, + updatedAt: "2026-05-25T00:10:00.000Z", + }), + ); + expect(AgentAwarenessRelay.agentAwarenessPublishIdentity(state)).not.toBe( + AgentAwarenessRelay.agentAwarenessPublishIdentity({ + ...state, + phase: "completed", + headline: "Agent finished", + }), + ); + }); + + it("requires an explicit opt-in before publishing agent activity", () => { + expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled(null)).toBe(false); + expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("false")).toBe(false); + expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("true")).toBe(true); + }); + + it("redacts failed activity details and caps other relay detail", () => { + expect( + AgentAwarenessRelay.sanitizeRelayAgentActivityState({ + ...state, + phase: "failed", + detail: "Provider process exited with secret token.", + }), + ).toMatchObject({ + phase: "failed", + detail: "The agent run failed.", + }); + expect( + AgentAwarenessRelay.sanitizeRelayAgentActivityState({ + ...state, + detail: "x".repeat(200), + })?.detail, + ).toHaveLength(160); + }); + + it("resolves a null publish state when a thread or project snapshot disappeared", () => { + const environmentId = "env-1" as EnvironmentId; + const threadId = "thread-1" as ThreadId; + const thread = { + id: threadId, + projectId: "project-1" as ProjectId, + title: "Deleted thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + session: null, + latestTurn: null, + updatedAt: "2026-05-25T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + } as OrchestrationThreadShell; + + expect( + AgentAwarenessRelay.resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.none(), + project: Option.none(), + }), + ).toEqual({ + projectId: null, + state: null, + reason: "thread-not-found", + }); + + expect( + AgentAwarenessRelay.resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.some(thread), + project: Option.none(), + }), + ).toEqual({ + projectId: "project-1", + state: null, + reason: "project-not-found", + }); + }); + + it("selects only active shell snapshot threads for startup catch-up", () => { + const now = "2026-05-25T00:00:00.000Z"; + const environmentId = "env-1" as EnvironmentId; + const projectId = "project-1" as ProjectId; + const activeThreadId = "thread-active" as ThreadId; + const idleThreadId = "thread-idle" as ThreadId; + + const baseThread = { + projectId, + title: "Run remote agent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } satisfies Omit; + + expect( + AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ + environmentId, + projects: [ + { + id: projectId, + title: "T3 Code", + }, + ], + threads: [ + { + ...baseThread, + id: activeThreadId, + latestTurn: { + turnId: "turn-1" as TurnId, + state: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + assistantMessageId: null, + }, + }, + { + ...baseThread, + id: idleThreadId, + }, + { + ...baseThread, + id: "thread-missing-project" as ThreadId, + projectId: "missing-project" as ProjectId, + latestTurn: { + turnId: "turn-2" as TurnId, + state: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + assistantMessageId: null, + }, + }, + ], + }), + ).toEqual([activeThreadId]); + }); + + it("signs the activity publish JWT and rejects tampering", async () => { + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const payload = { + iss: "t3-env:env", + aud: "https://relay.example.test", + sub: "env", + jti: "nonce-1", + iat: 100, + exp: 200, + environmentId: state.environmentId, + threadId: state.threadId, + state, + } satisfies RelayAgentActivityPublishProofPayload; + const proof = await Effect.runPromise( + AgentAwarenessRelay.signRelayAgentActivityPublishProof({ + privateKey: keyPair.privateKey, + payload, + }), + ); + + await expect( + Effect.runPromise( + verifyRelayJwt({ + publicKey: keyPair.publicKey, + token: proof, + typ: RELAY_ACTIVITY_PUBLISH_TYP, + issuer: "t3-env:env", + audience: "https://relay.example.test", + nowEpochSeconds: 150, + }), + ), + ).resolves.toMatchObject({ jti: "nonce-1", state }); + await expect( + Effect.runPromise( + verifyRelayJwt({ + publicKey: keyPair.publicKey, + token: (() => { + const [header, body, signature = ""] = proof.split("."); + const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`; + return `${header}.${body}.${corruptedSignature}`; + })(), + typ: RELAY_ACTIVITY_PUBLISH_TYP, + issuer: "t3-env:env", + audience: "https://relay.example.test", + nowEpochSeconds: 150, + }), + ), + ).rejects.toBeDefined(); + }); + + it.effect("keeps the orchestration listener armed until relay config is installed", () => + Effect.scoped( + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const threadShellRequested = yield* Deferred.make(); + const secrets = makeMemorySecretStore(); + const now = "2026-05-25T00:00:00.000Z"; + const projectId = "project-1" as ProjectId; + const threadId = "thread-1" as ThreadId; + const environmentId = "env-1" as EnvironmentId; + + const project = { + id: projectId, + title: "T3 Code", + workspaceRoot: "/workspace", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + } satisfies OrchestrationProjectShell; + + const thread = { + id: threadId, + projectId, + title: "Run remote agent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: { + turnId: "turn-1" as TurnId, + state: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + assistantMessageId: null, + }, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: { + threadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: now, + }, + latestUserMessageAt: now, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } satisfies OrchestrationThreadShell; + + const orchestrationEngine = { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.fromQueue(events), + } satisfies OrchestrationEngineShape; + + const snapshotQuery = { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: [project], + threads: [thread], + updatedAt: now, + } satisfies OrchestrationShellSnapshot), + getThreadShellById: () => + Deferred.succeed(threadShellRequested, undefined).pipe( + Effect.ignore, + Effect.as(Option.some(thread)), + ), + getProjectShellById: () => Effect.succeed(Option.some(project)), + } as unknown as ProjectionSnapshotQueryShape; + + const descriptor = { + environmentId, + label: "Test Desktop", + platform: { + os: "darwin", + arch: "arm64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + } satisfies ExecutionEnvironmentDescriptor; + + const layer = Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), + Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + Layer.succeed(OrchestrationEngineService, orchestrationEngine), + Layer.succeed(ProjectionSnapshotQuery, snapshotQuery), + ); + + yield* Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* relay.start(); + yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1"); + yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); + yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); + yield* Queue.offer(events, { + type: "thread.activity-appended", + sequence: 1, + eventId: "evt-1", + commandId: CommandId.make("cmd-1"), + aggregateKind: "thread", + aggregateId: threadId, + actor: { kind: "server" }, + payload: { + threadId, + activity: { + kind: "approval.requested", + }, + }, + occurredAt: now, + } as unknown as OrchestrationEvent); + + yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds")); + }).pipe( + Effect.provide( + AgentAwarenessRelay.layer.pipe( + Layer.provide(layer), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }), + ), + ); + + it.effect("publishes agent activity to the relay transport URL, not the relay issuer", () => + Effect.scoped( + Effect.gen(function* () { + const originalFetch = globalThis.fetch; + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + const events = yield* Queue.unbounded(); + const fetchSeen = yield* Deferred.make(); + const secrets = makeMemorySecretStore(); + const now = "2026-05-25T00:00:00.000Z"; + const projectId = "project-1" as ProjectId; + const threadId = "thread-1" as ThreadId; + const environmentId = "env-1" as EnvironmentId; + + const project = { + id: projectId, + title: "T3 Code", + workspaceRoot: "/workspace", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + } satisfies OrchestrationProjectShell; + + const thread = { + id: threadId, + projectId, + title: "Run remote agent", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: { + turnId: "turn-1" as TurnId, + state: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + assistantMessageId: null, + }, + createdAt: now, + updatedAt: now, + archivedAt: null, + session: { + threadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: now, + }, + latestUserMessageAt: now, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } satisfies OrchestrationThreadShell; + + const descriptor = { + environmentId, + label: "Test Desktop", + platform: { + os: "darwin", + arch: "arm64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + } satisfies ExecutionEnvironmentDescriptor; + + globalThis.fetch = ((input: Parameters[0]) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + runFork(Deferred.succeed(fetchSeen, url)); + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ); + + const layer = Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), + Layer.succeed(ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + }), + Layer.succeed(OrchestrationEngineService, { + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 1 }), + streamDomainEvents: Stream.fromQueue(events), + } satisfies OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: [project], + threads: [thread], + updatedAt: now, + } satisfies OrchestrationShellSnapshot), + getThreadShellById: () => Effect.succeed(Option.some(thread)), + getProjectShellById: () => Effect.succeed(Option.some(project)), + } as unknown as ProjectionSnapshotQueryShape), + ); + + yield* Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* secrets.setString(RELAY_URL_SECRET, "https://transport.example.test"); + yield* secrets.setString(RELAY_ISSUER_SECRET, "https://issuer.example.test"); + yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); + yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); + yield* relay.start(); + yield* Queue.offer(events, { + type: "thread.activity-appended", + sequence: 1, + eventId: "evt-1", + commandId: CommandId.make("cmd-1"), + aggregateKind: "thread", + aggregateId: threadId, + actor: { kind: "server" }, + payload: { + threadId, + activity: { + kind: "approval.requested", + }, + }, + occurredAt: now, + } as unknown as OrchestrationEvent); + + const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds")); + expect(url.origin).toBe("https://transport.example.test"); + }).pipe( + Effect.provide( + AgentAwarenessRelay.layer.pipe( + Layer.provide(layer), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }), + ), + ); +}); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts new file mode 100644 index 00000000000..6266a39b798 --- /dev/null +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -0,0 +1,503 @@ +import { + RelayApi, + type RelayAgentActivityPublishProofPayload, + type RelayAgentActivityState, +} from "@t3tools/contracts/relay"; +import type { + EnvironmentId, + OrchestrationEvent, + OrchestrationProjectShell, + OrchestrationThreadShell, + ThreadId, +} from "@t3tools/contracts"; +import { projectThreadAwareness } from "@t3tools/shared/agentAwareness"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { + RELAY_ACTIVITY_PUBLISH_TYP, + signRelayJwt, + normalizeRelayIssuer, +} from "@t3tools/shared/relayJwt"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; +import { + PUBLISH_AGENT_ACTIVITY_SECRET, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "../cloud/config.ts"; +import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; + +export interface AgentAwarenessRelayShape { + readonly publishThread: (threadId: ThreadId) => Effect.Effect; + readonly start: () => Effect.Effect; +} + +export class AgentAwarenessRelay extends Context.Service< + AgentAwarenessRelay, + AgentAwarenessRelayShape +>()("t3/relay/AgentAwarenessRelay") {} + +export function eventThreadId(event: OrchestrationEvent): ThreadId | null { + const payload = event.payload as { readonly threadId?: unknown }; + if (typeof payload.threadId === "string") { + return payload.threadId as ThreadId; + } + if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") { + return event.aggregateId as ThreadId; + } + return null; +} + +export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { + switch (event.type) { + case "thread.message-sent": + return !event.payload.streaming; + case "thread.proposed-plan-upserted": + case "thread.runtime-mode-set": + case "thread.interaction-mode-set": + return false; + case "thread.activity-appended": + return ( + event.payload.activity.kind === "approval.requested" || + event.payload.activity.kind === "approval.resolved" || + event.payload.activity.kind === "provider.approval.respond.failed" || + event.payload.activity.kind === "user-input.requested" || + event.payload.activity.kind === "user-input.resolved" || + event.payload.activity.kind === "runtime.error" + ); + default: + return true; + } +} + +export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): string { + if (state === null) { + return "null"; + } + const { updatedAt: _updatedAt, ...meaningfulState } = state; + return JSON.stringify(meaningfulState); +} + +export function isAgentActivityPublishingEnabled(value: string | null): boolean { + return value === "true"; +} + +const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160; +const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed."; + +export function sanitizeRelayAgentActivityState( + state: RelayAgentActivityState | null, +): RelayAgentActivityState | null { + if (state === null) { + return null; + } + const { detail: _detail, ...rest } = state; + const detail = (state.phase === "failed" ? REDACTED_RELAY_AGENT_FAILURE_DETAIL : state.detail) + ?.trim() + .slice(0, RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH) + .trim(); + return detail ? { ...rest, detail } : rest; +} + +function relayEnvironmentClient(token: string) { + return HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); +} + +function deliveryStats( + deliveries: ReadonlyArray<{ + readonly ok: boolean; + readonly queued?: boolean | undefined; + readonly kind: string; + readonly apnsStatus?: number | null; + readonly apnsReason?: string | null; + }>, +) { + let queued = 0; + let successful = 0; + let failed = 0; + const failedReasons: string[] = []; + const kinds = new Set(); + + for (const delivery of deliveries) { + kinds.add(delivery.kind); + if (delivery.queued) { + queued += 1; + continue; + } + if (delivery.ok) { + successful += 1; + continue; + } + failed += 1; + failedReasons.push(`${delivery.apnsStatus ?? "transport"}:${delivery.apnsReason ?? "unknown"}`); + } + + return { + total: deliveries.length, + queued, + successful, + failed, + kinds: [...kinds], + failedReasons, + }; +} + +export function signRelayAgentActivityPublishProof(input: { + readonly privateKey: string; + readonly payload: RelayAgentActivityPublishProofPayload; +}) { + return signRelayJwt({ + privateKey: input.privateKey, + typ: RELAY_ACTIVITY_PUBLISH_TYP, + payload: input.payload, + }); +} + +const makePublishProof = Effect.fn("makePublishProof")(function* (input: { + readonly privateKey: string; + readonly relayIssuer: string; + readonly environmentId: string; + readonly threadId: ThreadId; + readonly state: RelayAgentActivityState | null; + readonly jti: string; +}) { + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 5 }); + const payload = { + iss: `t3-env:${input.environmentId}`, + aud: normalizeRelayIssuer(input.relayIssuer), + sub: input.environmentId, + jti: input.jti, + iat: Math.floor(now.epochMilliseconds / 1_000), + exp: Math.floor(expiresAt.epochMilliseconds / 1_000), + environmentId: input.environmentId as RelayAgentActivityPublishProofPayload["environmentId"], + threadId: input.threadId, + state: input.state, + } satisfies RelayAgentActivityPublishProofPayload; + return yield* signRelayAgentActivityPublishProof({ privateKey: input.privateKey, payload }); +}); + +export function resolveAgentAwarenessRelayPublishSnapshot(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly thread: Option.Option; + readonly project: Option.Option; +}): { + readonly projectId: string | null; + readonly state: RelayAgentActivityState | null; + readonly reason: "snapshot" | "thread-not-found" | "project-not-found"; +} { + if (Option.isNone(input.thread)) { + return { + projectId: null, + state: null, + reason: "thread-not-found", + }; + } + if (Option.isNone(input.project)) { + return { + projectId: input.thread.value.projectId, + state: null, + reason: "project-not-found", + }; + } + return { + projectId: input.thread.value.projectId, + state: sanitizeRelayAgentActivityState( + projectThreadAwareness({ + environmentId: input.environmentId, + project: input.project.value, + thread: input.thread.value, + }), + ), + reason: "snapshot", + }; +} + +export function resolveAgentAwarenessRelayActiveThreadIds(input: { + readonly environmentId: EnvironmentId; + readonly projects: ReadonlyArray>; + readonly threads: ReadonlyArray; +}): ReadonlyArray { + const projectById = new Map(input.projects.map((project) => [project.id, project])); + return input.threads + .filter((thread) => { + const project = projectById.get(thread.projectId); + if (!project) { + return false; + } + return ( + projectThreadAwareness({ + environmentId: input.environmentId, + project, + thread, + }) !== null + ); + }) + .map((thread) => thread.id); +} + +const make = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverEnvironment = yield* ServerEnvironment; + const snapshotQuery = yield* ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngineService; + const crypto = yield* Crypto.Crypto; + const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + const activeSnapshotPublishedRef = yield* Ref.make(false); + const publishedStateByThreadRef = yield* Ref.make(new Map()); + + const readSecretString = (name: string) => + secrets.get(name).pipe(Effect.map((bytes) => (bytes ? new TextDecoder().decode(bytes) : null))); + + const readRelayConfig = Effect.gen(function* () { + const [url, issuer, environmentCredential] = yield* Effect.all([ + readSecretString(RELAY_URL_SECRET), + readSecretString(RELAY_ISSUER_SECRET), + readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), + ]); + return url && environmentCredential + ? { url, issuer: issuer ?? url, environmentCredential } + : null; + }); + + const readPublishAgentActivityEnabled = readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET).pipe( + Effect.map(isAgentActivityPublishingEnabled), + ); + + const makeRelayClient = (relayConfig: { + readonly url: string; + readonly environmentCredential: string; + }) => + HttpApiClient.make(RelayApi, { + baseUrl: relayConfig.url, + transformClient: relayEnvironmentClient(relayConfig.environmentCredential), + }).pipe(Effect.provide(FetchHttpClient.layer)); + + const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { + const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( + Effect.orElseSucceed(() => false), + ); + if (!publishAgentActivity) { + yield* Effect.logDebug("agent activity publish skipped; publication disabled", { + threadId, + }); + return; + } + const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); + if (!relayConfig) { + yield* Effect.logDebug("agent activity publish skipped; T3 Cloud config missing", { + threadId, + }); + return; + } + const relayClient = yield* makeRelayClient(relayConfig); + const environmentId = yield* serverEnvironment.getEnvironmentId; + + const publishState = (input: { + readonly projectId: string | null; + readonly state: RelayAgentActivityState | null; + readonly reason: string; + }) => + Effect.gen(function* () { + const proof = yield* makePublishProof({ + privateKey: cloudLinkKeyPair.privateKey, + relayIssuer: relayConfig.issuer, + environmentId, + threadId, + state: input.state, + jti: yield* crypto.randomUUIDv4, + }); + + yield* Effect.logInfo("publishing agent activity for thread", { + environmentId, + threadId, + projectId: input.projectId, + statePhase: input.state?.phase ?? null, + hasState: input.state !== null, + reason: input.reason, + }); + + const response = yield* relayClient.server.publishAgentActivity({ + params: { + environmentId, + threadId, + }, + payload: { + state: input.state, + proof, + }, + }); + + yield* Effect.logInfo("agent activity publish completed", { + environmentId, + threadId, + ok: response.ok, + deliveries: deliveryStats(response.deliveries), + }); + }); + + const thread = yield* snapshotQuery.getThreadShellById(threadId); + const project = Option.isSome(thread) + ? yield* snapshotQuery.getProjectShellById(thread.value.projectId) + : Option.none(); + const snapshot = resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread, + project, + }); + const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); + const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); + if (publishedStateByThread.get(threadId) === publishIdentity) { + yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { + environmentId, + threadId, + reason: snapshot.reason, + }); + return; + } + + if (snapshot.reason === "thread-not-found") { + yield* Effect.logDebug("publishing agent activity tombstone; thread not found", { + environmentId, + threadId, + }); + } else if (snapshot.reason === "project-not-found") { + yield* Effect.logDebug("publishing agent activity tombstone; project not found", { + environmentId, + threadId, + projectId: snapshot.projectId, + }); + } + + yield* publishState({ + projectId: snapshot.projectId, + state: snapshot.state, + reason: snapshot.reason, + }); + yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { + const nextPublishedStates = new Map(publishedStates); + nextPublishedStates.set(threadId, publishIdentity); + return nextPublishedStates; + }); + }); + + const publishThread: AgentAwarenessRelayShape["publishThread"] = (threadId) => + publishThreadUnsafe(threadId).pipe( + Effect.catchCause((cause) => { + return Effect.logWarning("agent activity publish failed", { + threadId, + cause: Cause.pretty(cause), + }); + }), + Effect.withSpan("AgentAwarenessRelay.publishThread"), + ); + + const publishActiveThreadsUnsafe = Effect.gen(function* () { + const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( + Effect.orElseSucceed(() => false), + ); + if (!publishAgentActivity) { + yield* Effect.logDebug("agent activity snapshot skipped; publication disabled"); + return false; + } + const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); + if (!relayConfig) { + yield* Effect.logDebug("agent activity snapshot skipped; T3 Cloud config missing"); + return false; + } + const environmentId = yield* serverEnvironment.getEnvironmentId; + const snapshot = yield* snapshotQuery.getShellSnapshot(); + const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ + environmentId, + projects: snapshot.projects, + threads: snapshot.threads, + }); + if (activeThreadIds.length === 0) { + yield* Effect.logDebug("agent activity snapshot has no publishable threads"); + return true; + } + yield* Effect.logInfo("publishing active agent activity snapshot", { + count: activeThreadIds.length, + }); + yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true }); + return true; + }); + + const publishActiveThreadsOnceWhenConfigured = Effect.gen(function* () { + while (!(yield* Ref.get(activeSnapshotPublishedRef))) { + const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); + if (published) { + yield* Ref.set(activeSnapshotPublishedRef, true); + return; + } + yield* Effect.sleep("5 seconds"); + } + }); + + const worker = yield* makeDrainableWorker(publishThread); + + const start: AgentAwarenessRelayShape["start"] = Effect.fn("AgentAwarenessRelay.start")( + function* () { + const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); + if (!relayConfig) { + yield* Effect.logInfo("agent activity publishing standby; T3 Cloud config missing"); + } else { + yield* Effect.logInfo("agent activity publishing enabled", { + relayUrl: relayConfig.url, + }); + } + yield* Effect.forkScoped( + Effect.sleep("1 second").pipe(Effect.andThen(publishActiveThreadsOnceWhenConfigured)), + ); + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + const threadId = eventThreadId(event); + if (threadId === null) { + return Effect.logDebug("agent activity publishing ignored event without thread id", { + eventType: event.type, + }); + } + if (!shouldPublishAgentAwarenessEvent(event)) { + return Effect.logDebug( + "agent activity publishing ignored event without activity changes", + { + eventType: event.type, + threadId, + }, + ); + } + return Effect.logDebug("agent activity publishing queued thread publish", { + eventType: event.type, + threadId, + }).pipe(Effect.andThen(worker.enqueue(threadId))); + }), + ); + }, + ); + + return { + publishThread, + start, + } satisfies AgentAwarenessRelayShape; +}); + +export const layer = Layer.effect(AgentAwarenessRelay, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 495a1264b14..d061578ca68 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1,6 +1,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeCrypto from "node:crypto"; import { AuthAccessTokenType, @@ -28,6 +29,13 @@ import { WsRpcGroup, EditorId, } from "@t3tools/contracts"; +import { + computeDpopAccessTokenHash, + computeDpopJwkThumbprint, + type DpopPublicJwk, +} from "@t3tools/shared/dpop"; +import { RELAY_HEALTH_REQUEST_TYP, RELAY_MINT_REQUEST_TYP } from "@t3tools/shared/relayJwt"; +import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import * as Clock from "effect/Clock"; @@ -43,13 +51,13 @@ import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - Cookies, FetchHttpClient, HttpBody, HttpClient, + HttpClientRequest, + HttpClientResponse, HttpRouter, HttpServer, - UrlParams, } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; @@ -120,6 +128,11 @@ import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { + CloudManagedEndpointRuntime, + type CloudManagedEndpointRuntimeShape, +} from "./cloud/ManagedEndpointRuntime.ts"; +import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -347,6 +360,9 @@ const buildAppUnderTest = (options?: { serverRuntimeStartup?: Partial; serverEnvironment?: Partial; repositoryIdentityResolver?: Partial; + cloudManagedEndpointRuntime?: Partial; + relayClient?: Partial; + cloudCliTokenManager?: Partial; }; }) => Effect.gen(function* () { @@ -741,7 +757,40 @@ const buildAppUnderTest = (options?: { ...options?.layers?.repositoryIdentityResolver, }), ), + Layer.provide( + Layer.succeed( + CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.of({ + applyConfig: () => Effect.succeed({ status: "disabled" }), + ...options?.layers?.cloudManagedEndpointRuntime, + }), + ), + ), + Layer.provide( + Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ + resolve: Effect.succeed({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.die("unused relay-client install"), + installWithProgress: () => Effect.die("unused relay-client install"), + ...options?.layers?.relayClient, + }), + ), + ), + Layer.provide( + Layer.mock(CloudCliTokenManager.CloudCliTokenManager)({ + get: Effect.die(new Error("Unexpected T3 Cloud CLI authorization request.")), + getExisting: Effect.succeed(Option.none()), + hasCredential: Effect.succeed(false), + clear: Effect.void, + ...options?.layers?.cloudCliTokenManager, + }), + ), Layer.provideMerge(makeAuthTestLayer()), + Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -799,6 +848,13 @@ const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; }; +const getHttpServerUrl = (pathname = "") => + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address as HttpServer.TcpAddress; + return `http://127.0.0.1:${address.port}${pathname}`; + }); + const bootstrapBrowserSession = ( credential = defaultDesktopBootstrapToken, options?: { @@ -806,19 +862,26 @@ const bootstrapBrowserSession = ( }, ) => Effect.gen(function* () { - const response = yield* HttpClient.post("/api/auth/browser-session", { - headers: options?.headers, - body: yield* HttpBody.json({ credential }), + const bootstrapUrl = yield* getHttpServerUrl("/api/auth/browser-session"); + const response = yield* fetchEffect(bootstrapUrl, { + method: "POST", + headers: { + "content-type": "application/json", + ...options?.headers, + }, + body: jsonRequestBody({ + credential, + }), }); - const body = (yield* response.json) as { + const body = yield* responseJsonEffect<{ readonly authenticated: boolean; readonly sessionMethod: string; readonly expiresAt: string; - }; + }>(response); return { response, body, - cookie: Cookies.toSetCookieHeaders(response.cookies)[0] ?? null, + cookie: response.headers["set-cookie"], }; }); @@ -835,48 +898,227 @@ const exchangeAccessToken = ( }, ) => Effect.gen(function* () { - const response = yield* HttpClient.post("/oauth/token", { - headers: options?.headers, - body: HttpBody.urlParams( - UrlParams.fromInput({ - grant_type: AuthTokenExchangeGrantType, - subject_token: credential, - subject_token_type: AuthEnvironmentBootstrapTokenType, - requested_token_type: AuthAccessTokenType, - scope: - options?.scope ?? - "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", - ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), - ...(options?.clientMetadata?.deviceType - ? { client_device_type: options.clientMetadata.deviceType } - : {}), - ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}), - }), - ), + const tokenUrl = yield* getHttpServerUrl("/oauth/token"); + const response = yield* fetchEffect(tokenUrl, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + ...options?.headers, + }, + body: new URLSearchParams({ + grant_type: AuthTokenExchangeGrantType, + subject_token: credential, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + scope: + options?.scope ?? + "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", + ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), + ...(options?.clientMetadata?.deviceType + ? { client_device_type: options.clientMetadata.deviceType } + : {}), + ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}), + }).toString(), }); - const body = (yield* response.json) as { + const body = yield* responseJsonEffect<{ readonly access_token?: string; readonly issued_token_type?: string; readonly token_type?: string; readonly expires_in?: number; readonly scope?: string; readonly _tag?: string; - readonly message?: string; - }; + readonly code?: string; + readonly reason?: string; + readonly traceId?: string; + }>(response); return { response, body, }; }); +const makeDpopProof = (input: { + readonly method: string; + readonly url: string; + readonly iat: number; + readonly accessToken?: string; + readonly jti?: string; + readonly privateKey?: NodeCrypto.KeyObject; + readonly publicJwk?: DpopPublicJwk; +}) => { + const keyPair = + input.privateKey && input.publicJwk + ? { privateKey: input.privateKey, publicJwk: input.publicJwk } + : (() => { + const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + return { privateKey, publicJwk: publicKey.export({ format: "jwk" }) as DpopPublicJwk }; + })(); + const header = Buffer.from( + JSON.stringify({ + typ: "dpop+jwt", + alg: "ES256", + jwk: keyPair.publicJwk, + }), + ).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ + htm: input.method, + htu: input.url, + jti: input.jti ?? "proof-1", + iat: input.iat, + ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), + }), + ).toString("base64url"); + const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { + key: keyPair.privateKey, + dsaEncoding: "ieee-p1363", + }).toString("base64url"); + return { + proof: `${header}.${payload}.${signature}`, + thumbprint: computeDpopJwkThumbprint(keyPair.publicJwk), + privateKey: keyPair.privateKey, + publicJwk: keyPair.publicJwk, + }; +}; + +const makeCloudMintCredentialRequest = (input: { + readonly privateKey: string; + readonly environmentId: EnvironmentId; + readonly clientProofKeyThumbprint: string; + readonly issuer?: string; + readonly audience?: string; + readonly subject?: string; + readonly jti?: string; + readonly nonce: string; + readonly issuedAt: string; + readonly expiresAt: string; + readonly scope?: ReadonlyArray<"environment:connect">; +}) => { + const payload = { + iss: input.issuer ?? "https://relay.example.test", + aud: input.audience ?? `t3-env:${input.environmentId}`, + sub: input.subject ?? "user_123", + jti: input.jti ?? "cloud-mint-jti-1", + environmentId: input.environmentId, + clientProofKeyThumbprint: input.clientProofKeyThumbprint, + cnf: { + jkt: input.clientProofKeyThumbprint, + }, + nonce: input.nonce, + iat: Math.floor(DateTime.makeUnsafe(input.issuedAt).epochMilliseconds / 1_000), + exp: Math.floor(DateTime.makeUnsafe(input.expiresAt).epochMilliseconds / 1_000), + scope: input.scope ?? ["environment:connect"], + } as const; + const header = Buffer.from( + JSON.stringify({ alg: "EdDSA", typ: RELAY_MINT_REQUEST_TYP }), + ).toString("base64url"); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signingInput = `${header}.${encodedPayload}`; + return { + proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, + }; +}; + +const makeCloudEnvironmentHealthRequest = (input: { + readonly privateKey: string; + readonly environmentId: EnvironmentId; + readonly issuer?: string; + readonly audience?: string; + readonly subject?: string; + readonly jti?: string; + readonly nonce: string; + readonly issuedAt: string; + readonly expiresAt: string; + readonly scope?: ReadonlyArray<"environment:status">; +}) => { + const payload = { + iss: input.issuer ?? "https://relay.example.test", + aud: input.audience ?? `t3-env:${input.environmentId}`, + sub: input.subject ?? "user_123", + jti: input.jti ?? "cloud-health-jti-1", + environmentId: input.environmentId, + nonce: input.nonce, + iat: Math.floor(DateTime.makeUnsafe(input.issuedAt).epochMilliseconds / 1_000), + exp: Math.floor(DateTime.makeUnsafe(input.expiresAt).epochMilliseconds / 1_000), + scope: input.scope ?? ["environment:status"], + } as const; + const header = Buffer.from( + JSON.stringify({ alg: "EdDSA", typ: RELAY_HEALTH_REQUEST_TYP }), + ).toString("base64url"); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signingInput = `${header}.${encodedPayload}`; + return { + proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, + }; +}; + +const decodeCompactJwtPayload = (token: string): A => { + const encodedPayload = token.split(".")[1]; + if (!encodedPayload) { + throw new Error("JWT does not contain a payload."); + } + return JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")) as A; +}; + class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{ readonly message: string; }> {} +class TestHttpRequestError extends Data.TaggedError("TestHttpRequestError")<{ + readonly cause: unknown; +}> {} + +const testRequestUrl = (input: Parameters[0]): string => { + const value = input.toString(); + if (!/^https?:\/\//i.test(value)) { + return value; + } + const url = new URL(value); + return `${url.pathname}${url.search}`; +}; + +const fetchEffect = (input: Parameters[0], init?: RequestInit) => { + const request = HttpClientRequest.make((init?.method ?? "GET") as "GET" | "POST")( + testRequestUrl(input), + { + headers: init?.headers as Record | undefined, + }, + ).pipe( + typeof init?.body === "string" + ? HttpClientRequest.bodyText( + init.body, + (init.headers as Record | undefined)?.["content-type"] ?? + "application/json", + ) + : (request) => request, + ); + const effect = HttpClient.execute(request); + return ( + init?.redirect === "manual" + ? effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })) + : effect + ).pipe(Effect.mapError((cause) => new TestHttpRequestError({ cause }))); +}; + +const jsonRequestBody = (value: unknown): string => { + return JSON.stringify(value); +}; + +const responseJsonEffect = (response: HttpClientResponse.HttpClientResponse) => + response.json.pipe( + Effect.map((json) => json as A), + Effect.mapError((cause) => new TestHttpRequestError({ cause })), + ); + +const responseOk = (response: HttpClientResponse.HttpClientResponse) => + response.status >= 200 && response.status < 300; + const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => Effect.gen(function* () { const { response, cookie } = yield* bootstrapBrowserSession(credential); - if (response.status !== 200) { + if (!responseOk(response)) { return yield* new AuthenticationGetterError({ message: `Expected bootstrap session response to succeed, got ${response.status}`, }); @@ -894,7 +1136,7 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => Effect.gen(function* () { const { response, body } = yield* exchangeAccessToken(credential); - if (response.status !== 200) { + if (!responseOk(response)) { return yield* new AuthenticationGetterError({ message: `Expected bearer bootstrap response to succeed, got ${response.status}`, }); @@ -918,7 +1160,7 @@ const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { return token; }; -const splitHeaderTokens = (value: string | null) => +const splitHeaderTokens = (value: string | null | undefined) => (value ?? "") .split(",") .map((token) => token.trim()) @@ -927,23 +1169,36 @@ const splitHeaderTokens = (value: string | null) => const assertBrowserApiCorsResponseHeaders = ( headers: Readonly>, + options?: { + readonly origin?: string; + readonly credentials?: boolean; + }, ) => { - assert.equal(headers["access-control-allow-origin"], "*"); + assert.equal(headers["access-control-allow-origin"], options?.origin ?? "*"); + assert.equal( + headers["access-control-allow-credentials"], + options?.credentials ? "true" : undefined, + ); }; const assertBrowserApiCorsPreflightHeaders = ( headers: Readonly>, + options?: { + readonly origin?: string; + readonly credentials?: boolean; + }, ) => { - assertBrowserApiCorsResponseHeaders(headers); + assertBrowserApiCorsResponseHeaders(headers, options); assert.deepEqual(splitHeaderTokens(headers["access-control-allow-methods"] ?? null), [ "GET", "OPTIONS", "POST", ]); - assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"] ?? null), [ + assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"]), [ "authorization", "b3", "content-type", + "dpop", "traceparent", ]); }; @@ -989,9 +1244,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { config: { devUrl: new URL("http://127.0.0.1:5173") }, }); - const response = yield* HttpClient.get("/foo/bar?token=test-token").pipe( - Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), - ); + const url = yield* getHttpServerUrl("/foo/bar?token=test-token"); + const response = yield* fetchEffect(url, { redirect: "manual" }); assert.equal(response.status, 302); assert.equal(response.headers.location, "http://127.0.0.1:5173/foo/bar?token=test-token"); @@ -1057,8 +1311,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const response = yield* HttpClient.get("/.well-known/t3/environment"); - const body = (yield* response.json) as typeof testEnvironmentDescriptor; + const url = yield* getHttpServerUrl("/.well-known/t3/environment"); + const response = yield* fetchEffect(url); + const body = yield* responseJsonEffect(response); assert.equal(response.status, 200); assert.deepEqual(body, testEnvironmentDescriptor); @@ -1069,12 +1324,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const response = yield* HttpClient.get("/.well-known/t3/environment", { + const url = yield* getHttpServerUrl("/.well-known/t3/environment"); + const response = yield* fetchEffect(url, { headers: { origin: crossOriginClientOrigin, }, }); - const body = (yield* response.json) as typeof testEnvironmentDescriptor; + const body = yield* responseJsonEffect(response); assert.equal(response.status, 200); assertBrowserApiCorsResponseHeaders(response.headers); @@ -1086,8 +1342,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const response = yield* HttpClient.get("/api/auth/session"); - const body = (yield* response.json) as { + const url = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* fetchEffect(url); + const body = yield* responseJsonEffect<{ readonly authenticated: boolean; readonly auth: { readonly policy: string; @@ -1095,13 +1352,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { readonly sessionMethods: ReadonlyArray; readonly sessionCookieName: string; }; - }; + }>(response); assert.equal(response.status, 200); assert.equal(body.authenticated, false); assert.equal(body.auth.policy, "desktop-managed-local"); assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); - assert.deepEqual(body.auth.sessionMethods, ["browser-session-cookie", "bearer-access-token"]); + assert.deepEqual(body.auth.sessionMethods, [ + "browser-session-cookie", + "bearer-access-token", + "dpop-access-token", + ]); assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -1122,15 +1383,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken); assert.isDefined(setCookie); - const sessionResponse = yield* HttpClient.get("/api/auth/session", { + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const sessionResponse = yield* fetchEffect(sessionUrl, { headers: { cookie: setCookie?.split(";")[0] ?? "", }, }); - const sessionBody = (yield* sessionResponse.json) as { + const sessionBody = yield* responseJsonEffect<{ readonly authenticated: boolean; readonly sessionMethod?: string; - }; + }>(sessionResponse); assert.equal(sessionResponse.status, 200); assert.equal(sessionBody.authenticated, true); @@ -1142,9 +1404,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest(); - const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken(); + const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken(); - assert.equal(bootstrapResponse.status, 200); + assert.equal(tokenResponse.status, 200); assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); assert.equal(tokenBody.token_type, "Bearer"); assert.equal( @@ -1152,18 +1414,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", ); assert.equal(typeof tokenBody.access_token, "string"); - assert.isTrue((tokenBody.access_token?.length ?? 0) > 0); - const sessionResponse = yield* HttpClient.get("/api/auth/session", { + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const sessionResponse = yield* fetchEffect(sessionUrl, { headers: { authorization: `Bearer ${tokenBody.access_token ?? ""}`, }, }); - const sessionBody = (yield* sessionResponse.json) as { + const sessionBody = yield* responseJsonEffect<{ readonly authenticated: boolean; readonly sessionMethod?: string; readonly scopes?: ReadonlyArray; - }; + }>(sessionResponse); assert.equal(sessionResponse.status, 200); assert.equal(sessionBody.authenticated, true); @@ -1181,187 +1443,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const bearerToken = yield* getAuthenticatedBearerSessionToken(); - const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { - headers: { - authorization: `Bearer ${bearerToken}`, - }, - }); - const wsTicketBody = (yield* wsTicketResponse.json) as { - readonly ticket: string; - readonly expiresAt: string; - }; - - assert.equal(wsTicketResponse.status, 200); - assert.equal(typeof wsTicketBody.ticket, "string"); - assert.isTrue(wsTicketBody.ticket.length > 0); - assert.equal(typeof wsTicketBody.expiresAt, "string"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("does not allow management-only access tokens to operate the environment", () => - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken( - defaultDesktopBootstrapToken, - { scope: "access:write" }, - ); - assert.equal(exchangeResponse.status, 200); - assert.equal(tokenBody.scope, "access:write"); - assert.isDefined(tokenBody.access_token); - - const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - }, - body: yield* HttpBody.json({}), - }); - const overbroadPairingBody = (yield* overbroadPairingResponse.json) as { - readonly requiredScope: string; - }; - const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - }, - body: yield* HttpBody.json({ scopes: ["access:write"] }), - }); - const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - }, - }); - const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; - const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - }, - }); - const faviconBody = (yield* faviconResponse.json) as { - readonly _tag: string; - readonly code: string; - readonly requiredScope: string; - readonly traceId: string; - }; - - assert.equal(overbroadPairingResponse.status, 403); - assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); - assert.equal(pairingResponse.status, 200); - assert.equal(wsTicketResponse.status, 200); - assert.equal(faviconResponse.status, 403); - assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); - assert.equal(faviconBody.code, "insufficient_scope"); - assert.equal(faviconBody.requiredScope, "orchestration:read"); - assert.equal(typeof faviconBody.traceId, "string"); - - const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; - const rpcError = yield* Effect.flip( - Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), - ); - assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); - if (rpcError._tag === "EnvironmentAuthorizationError") { - assert.equal(rpcError.requiredScope, "orchestration:read"); - } - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("includes CORS headers on remote auth success responses", () => - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const origin = crossOriginClientOrigin; - const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken( - defaultDesktopBootstrapToken, - { - headers: { origin }, - }, - ); - - assert.equal(bootstrapResponse.status, 200); - assertBrowserApiCorsResponseHeaders(bootstrapResponse.headers); - assert.equal(tokenBody.token_type, "Bearer"); - assert.equal(typeof tokenBody.access_token, "string"); - - const sessionResponse = yield* HttpClient.get("/api/auth/session", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - origin, - }, - }); - const sessionBody = (yield* sessionResponse.json) as { - readonly authenticated: boolean; - readonly sessionMethod?: string; - }; - - assert.equal(sessionResponse.status, 200); - assertBrowserApiCorsResponseHeaders(sessionResponse.headers); - assert.equal(sessionBody.authenticated, true); - assert.equal(sessionBody.sessionMethod, "bearer-access-token"); - - const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { - headers: { - authorization: `Bearer ${tokenBody.access_token ?? ""}`, - origin, - }, - }); - const wsTicketBody = (yield* wsTicketResponse.json) as { - readonly ticket: string; - }; - - assert.equal(wsTicketResponse.status, 200); - assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers); - assert.equal(typeof wsTicketBody.ticket, "string"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect( - "responds to remote auth websocket-ticket preflight requests with authorization CORS headers", - () => - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const response = yield* HttpClient.options("/api/auth/websocket-ticket", { - headers: { - origin: crossOriginClientOrigin, - "access-control-request-method": "POST", - "access-control-request-headers": "authorization", - }, - }); - - assert.equal(response.status, 204); - assertBrowserApiCorsPreflightHeaders(response.headers); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - - it.effect("includes CORS headers on remote websocket-ticket auth failures", () => - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const response = yield* HttpClient.post("/api/auth/websocket-ticket", { - headers: { - origin: crossOriginClientOrigin, - }, - }); - const body = (yield* response.json) as { - readonly _tag?: string; - readonly code?: string; - readonly reason?: string; - readonly traceId?: string; - }; - - assert.equal(response.status, 401); - assertBrowserApiCorsResponseHeaders(response.headers); - assert.equal(body._tag, "EnvironmentAuthInvalidError"); - assert.equal(body.code, "auth_invalid"); - assert.equal(body.reason, "missing_credential"); - assert.equal(typeof body.traceId, "string"); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("persists token exchange client display metadata for authorized-client listings", () => Effect.gen(function* () { yield* buildAppUnderTest({ @@ -1385,7 +1466,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { headers: { "user-agent": "undici", }, - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", + scope: "orchestration:read orchestration:operate terminal:operate review:write", clientMetadata: { label: "T3 Code Mobile", deviceType: "mobile", @@ -1423,19 +1504,1840 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("issues authenticated one-time pairing credentials for additional clients", () => - Effect.gen(function* () { - yield* buildAppUnderTest(); + it.effect( + "exchanges a bootstrap credential for a DPoP-bound access token without bearer downgrade", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); - const response = yield* HttpClient.post("/api/auth/pairing-token", { - headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), - }, - body: yield* HttpBody.json({}), - }); - const body = (yield* response.json) as { - readonly credential: string; - readonly expiresAt: string; + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const tokenUrl = yield* getHttpServerUrl("/oauth/token"); + const now = yield* DateTime.now; + const tokenProof = makeDpopProof({ + method: "POST", + url: tokenUrl, + iat: Math.floor(now.epochMilliseconds / 1_000), + jti: "token-exchange-proof", + }); + const tokenResponse = yield* fetchEffect(tokenUrl, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + dpop: tokenProof.proof, + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: credential.credential, + subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap", + requested_token_type: "urn:ietf:params:oauth:token-type:access_token", + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }).toString(), + }); + const token = yield* responseJsonEffect<{ + readonly access_token: string; + readonly token_type: string; + }>(tokenResponse); + + assert.equal(tokenResponse.status, 200); + assert.equal(tokenResponse.headers["cache-control"], "no-store"); + assert.equal(token.token_type, "DPoP"); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const bearerResponse = yield* fetchEffect(sessionUrl, { + headers: { authorization: `Bearer ${token.access_token}` }, + }); + const bearerState = yield* responseJsonEffect<{ readonly authenticated: boolean }>( + bearerResponse, + ); + assert.equal(bearerState.authenticated, false); + + const sessionProof = makeDpopProof({ + method: "GET", + url: sessionUrl, + iat: Math.floor(now.epochMilliseconds / 1_000), + jti: "session-proof", + accessToken: token.access_token, + privateKey: tokenProof.privateKey, + publicJwk: tokenProof.publicJwk, + }); + const dpopResponse = yield* fetchEffect(sessionUrl, { + headers: { + authorization: `DPoP ${token.access_token}`, + dpop: sessionProof.proof, + }, + }); + const dpopState = yield* responseJsonEffect<{ + readonly authenticated: boolean; + readonly sessionMethod?: string; + }>(dpopResponse); + assert.equal(dpopState.authenticated, true); + assert.equal(dpopState.sessionMethod, "dpop-access-token"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects replayed DPoP proofs across token exchanges", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const firstCredentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + body: yield* HttpBody.json({}), + }); + const firstCredential = (yield* firstCredentialResponse.json) as { + readonly credential: string; + }; + const secondCredentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + body: yield* HttpBody.json({}), + }); + const secondCredential = (yield* secondCredentialResponse.json) as { + readonly credential: string; + }; + const tokenUrl = yield* getHttpServerUrl("/oauth/token"); + const now = yield* DateTime.now; + const dpop = makeDpopProof({ + method: "POST", + url: tokenUrl, + iat: Math.floor(now.epochMilliseconds / 1_000), + }); + + const firstBootstrap = yield* exchangeAccessToken(firstCredential.credential, { + headers: { + dpop: dpop.proof, + }, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }); + const replayBootstrap = yield* exchangeAccessToken(secondCredential.credential, { + headers: { + dpop: dpop.proof, + }, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }); + + assert.equal(firstBootstrap.response.status, 200); + assert.equal(replayBootstrap.response.status, 401); + assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError"); + assert.equal(replayBootstrap.body.code, "auth_invalid"); + assert.equal(replayBootstrap.body.reason, "invalid_credential"); + assert.equal(typeof replayBootstrap.body.traceId, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("ignores forwarded host headers when validating token exchange DPoP URLs", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { + readonly credential: string; + }; + const tokenUrl = yield* getHttpServerUrl("/oauth/token"); + const now = yield* DateTime.now; + const dpop = makeDpopProof({ + method: "POST", + url: tokenUrl, + iat: Math.floor(now.epochMilliseconds / 1_000), + }); + + const bootstrap = yield* exchangeAccessToken(credential.credential, { + headers: { + dpop: dpop.proof, + "x-forwarded-host": "environment.example.test", + }, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }); + + assert.equal(bootstrap.response.status, 200); + assert.equal(bootstrap.body.token_type, "DPoP"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects token exchange DPoP proofs bound to spoofed forwarded hosts", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { + readonly credential: string; + }; + const tokenUrl = yield* getHttpServerUrl("/oauth/token"); + const spoofedUrl = new URL(tokenUrl); + spoofedUrl.hostname = "environment.example.test"; + const now = yield* DateTime.now; + const dpop = makeDpopProof({ + method: "POST", + url: spoofedUrl.href, + iat: Math.floor(now.epochMilliseconds / 1_000), + }); + + const bootstrap = yield* exchangeAccessToken(credential.credential, { + headers: { + dpop: dpop.proof, + "x-forwarded-host": spoofedUrl.host, + }, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }); + + assert.equal(bootstrap.response.status, 401); + assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError"); + assert.equal(bootstrap.body.code, "auth_invalid"); + assert.equal(bootstrap.body.reason, "invalid_credential"); + assert.equal(typeof bootstrap.body.traceId, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects cloud link proofs for non-loopback managed endpoint origins", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const linkProofResponse = yield* fetchEffect(linkProofUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + challenge: "relay-link-challenge", + relayIssuer: "https://relay.example.test", + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "manual", + }, + origin: { + localHttpHost: "192.168.1.42", + localHttpPort: 3773, + }, + }), + }); + const body = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly message?: string; + }>(linkProofResponse); + + assert.equal(linkProofResponse.status, 400); + assert.equal(body._tag, "EnvironmentHttpBadRequestError"); + assert.equal(body.message, "Invalid managed endpoint origin."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects managed cloud link proofs for manual endpoint providers", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const serverPort = Number(new URL(linkProofUrl).port); + const linkProofResponse = yield* fetchEffect(linkProofUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + challenge: "relay-link-challenge", + relayIssuer: "https://relay.example.test", + endpoint: { + httpBaseUrl: linkProofUrl.replace("/api/cloud/link-proof", ""), + wsBaseUrl: linkProofUrl + .replace("http://", "ws://") + .replace("/api/cloud/link-proof", "/ws"), + providerKind: "manual", + }, + origin: { + localHttpHost: "127.0.0.1", + localHttpPort: serverPort, + }, + }), + }); + const body = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly message?: string; + }>(linkProofResponse); + + assert.equal(linkProofResponse.status, 400); + assert.equal(body._tag, "EnvironmentHttpBadRequestError"); + assert.equal(body.message, "Invalid managed endpoint origin."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects cloud link proofs requested through a public managed endpoint", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const serverPort = Number(new URL(linkProofUrl).port); + const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + "content-type": "application/json", + host: "environment.example.test", + "x-forwarded-host": "environment.example.test", + "x-forwarded-proto": "https", + }, + body: HttpBody.text( + jsonRequestBody({ + challenge: "relay-link-challenge", + relayIssuer: "https://relay.example.test", + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "manual", + }, + origin: { + localHttpHost: "127.0.0.1", + localHttpPort: serverPort, + }, + }), + "application/json", + ), + }); + const body = (yield* linkProofResponse.json) as { + readonly _tag?: string; + readonly message?: string; + }; + + assert.equal(linkProofResponse.status, 400); + assert.equal(body._tag, "EnvironmentHttpBadRequestError"); + assert.equal(body.message, "Invalid managed endpoint origin."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "rejects cloud link proofs when a public request spoofs loopback forwarded headers", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const serverPort = Number(new URL(linkProofUrl).port); + const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + "content-type": "application/json", + host: "environment.example.test", + "x-forwarded-host": `127.0.0.1:${serverPort}`, + "x-forwarded-proto": "http", + }, + body: HttpBody.text( + jsonRequestBody({ + challenge: "relay-link-challenge", + relayIssuer: "https://relay.example.test", + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "manual", + }, + origin: { + localHttpHost: "127.0.0.1", + localHttpPort: serverPort, + }, + }), + "application/json", + ), + }); + const body = (yield* linkProofResponse.json) as { + readonly _tag?: string; + readonly message?: string; + }; + + assert.equal(linkProofResponse.status, 400); + assert.equal(body._tag, "EnvironmentHttpBadRequestError"); + assert.equal(body.message, "Invalid managed endpoint origin."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects cloud link proofs with malformed forwarded request hosts", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const serverPort = Number(new URL(linkProofUrl).port); + const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + "content-type": "application/json", + host: "bad host", + "x-forwarded-host": "bad host", + "x-forwarded-proto": "https", + }, + body: HttpBody.text( + jsonRequestBody({ + challenge: "relay-link-challenge", + relayIssuer: "https://relay.example.test", + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "manual", + }, + origin: { + localHttpHost: "127.0.0.1", + localHttpPort: serverPort, + }, + }), + "application/json", + ), + }); + const body = (yield* linkProofResponse.json) as { + readonly _tag?: string; + readonly message?: string; + }; + + assert.equal(linkProofResponse.status, 400); + assert.equal(body._tag, "EnvironmentHttpBadRequestError"); + assert.equal(body.message, "Invalid managed endpoint origin."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects local cloud link proofs for a different loopback port", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const serverPort = Number(new URL(linkProofUrl).port); + const linkProofResponse = yield* fetchEffect(linkProofUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + challenge: "relay-link-challenge", + relayIssuer: "https://relay.example.test", + endpoint: { + httpBaseUrl: "https://environment.example.test/", + wsBaseUrl: "wss://environment.example.test/ws", + providerKind: "manual", + }, + origin: { + localHttpHost: "127.0.0.1", + localHttpPort: serverPort === 65_535 ? serverPort - 1 : serverPort + 1, + }, + }), + }); + const body = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly message?: string; + }>(linkProofResponse); + + assert.equal(linkProofResponse.status, 400); + assert.equal(body._tag, "EnvironmentHttpBadRequestError"); + assert.equal(body.message, "Invalid managed endpoint origin."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("allows standard clients to read managed relay configuration state", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const pairedCookie = yield* getAuthenticatedSessionCookieHeader(credential.credential); + const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); + const response = yield* fetchEffect(linkStateUrl, { + headers: { cookie: pairedCookie }, + }); + const body = yield* responseJsonEffect<{ + readonly linked?: boolean; + readonly publishAgentActivity?: boolean; + }>(response); + + assert.equal(response.status, 200); + assert.equal(body.linked, false); + assert.equal(body.publishAgentActivity, false); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "reports relay client status and streams installation progress over environment RPC", + () => + Effect.gen(function* () { + const installedRelayClient = { + status: "available" as const, + executablePath: "/tmp/t3/tools/cloudflared", + source: "managed" as const, + version: RelayClient.CLOUDFLARED_VERSION, + }; + yield* buildAppUnderTest({ + layers: { + relayClient: { + resolve: Effect.succeed({ + status: "missing", + version: RelayClient.CLOUDFLARED_VERSION, + }), + install: Effect.succeed(installedRelayClient), + installWithProgress: (report) => + report({ type: "progress", stage: "checking" }).pipe( + Effect.andThen(report({ type: "progress", stage: "downloading" })), + Effect.as(installedRelayClient), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const status = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.cloudGetRelayClientStatus]({})), + ); + const installEvents = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.cloudInstallRelayClient]({}).pipe(Stream.runCollect), + ), + ); + + assert.equal(status.status, "missing"); + assert.deepEqual(Array.from(installEvents), [ + { type: "progress", stage: "checking" }, + { type: "progress", stage: "downloading" }, + { type: "complete", status: installedRelayClient }, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("requires relay write scope to update agent activity publication", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const preferencesUrl = yield* getHttpServerUrl("/api/cloud/preferences"); + const ownerResponse = yield* fetchEffect(preferencesUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ publishAgentActivity: true }), + }); + const ownerBody = yield* responseJsonEffect<{ + readonly publishAgentActivity?: boolean; + }>(ownerResponse); + assert.equal(ownerResponse.status, 200); + assert.equal(ownerBody.publishAgentActivity, true); + + const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { cookie: ownerCookie }, + body: yield* HttpBody.json({}), + }); + const credential = (yield* credentialResponse.json) as { readonly credential: string }; + const pairedCookie = yield* getAuthenticatedSessionCookieHeader(credential.credential); + const pairedResponse = yield* fetchEffect(preferencesUrl, { + method: "POST", + headers: { + cookie: pairedCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ publishAgentActivity: false }), + }); + const pairedBody = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly requiredScope?: string; + }>(pairedResponse); + assert.equal(pairedResponse.status, 403); + assert.equal(pairedBody._tag, "EnvironmentScopeRequiredError"); + assert.equal(pairedBody.requiredScope, "relay:write"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects relay config with an invalid cloud mint public key", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: "not-a-public-key", + endpointRuntime: null, + }), + }); + const body = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly message?: string; + }>(relayConfigResponse); + + assert.equal(relayConfigResponse.status, 400); + assert.equal(body._tag, "EnvironmentHttpBadRequestError"); + assert.equal(body.message, "Cloud mint public key must be a valid Ed25519 public key."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects relay config with insecure relay metadata or empty credentials", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const postRelayConfig = (body: { + readonly relayUrl: string; + readonly relayIssuer?: string; + readonly cloudUserId: string; + readonly environmentCredential: string; + }) => + fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + ...body, + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + + const insecureRelayUrl = yield* postRelayConfig({ + relayUrl: "http://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + }); + const insecureRelayIssuer = yield* postRelayConfig({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + relayIssuer: "http://relay.example.test", + environmentCredential: "t3env_test_credential", + }); + const nonOriginRelayUrl = yield* postRelayConfig({ + relayUrl: "https://relay.example.test/path", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + }); + const emptyCredential = yield* postRelayConfig({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: " ", + }); + const insecureRelayUrlBody = yield* responseJsonEffect<{ readonly message?: string }>( + insecureRelayUrl, + ); + const insecureRelayIssuerBody = yield* responseJsonEffect<{ readonly message?: string }>( + insecureRelayIssuer, + ); + const nonOriginRelayUrlBody = yield* responseJsonEffect<{ readonly message?: string }>( + nonOriginRelayUrl, + ); + const emptyCredentialBody = yield* responseJsonEffect<{ readonly message?: string }>( + emptyCredential, + ); + + assert.equal(insecureRelayUrl.status, 400); + assert.equal(insecureRelayUrlBody.message, "Relay URL must be a secure absolute HTTPS URL."); + assert.equal(insecureRelayIssuer.status, 400); + assert.equal( + insecureRelayIssuerBody.message, + "Relay issuer must be a secure absolute HTTPS URL.", + ); + assert.equal(nonOriginRelayUrl.status, 400); + assert.equal(nonOriginRelayUrlBody.message, "Relay URL must be a secure absolute HTTPS URL."); + assert.equal(emptyCredential.status, 400); + assert.equal(emptyCredentialBody.message, "Relay environment credential is required."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects relay config replacement from a different cloud account", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const postRelayConfig = (cloudUserId: string, environmentCredential: string) => + fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId, + environmentCredential, + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + + const firstResponse = yield* postRelayConfig("user_123", "t3env_first_credential"); + const replacementResponse = yield* postRelayConfig("user_456", "t3env_second_credential"); + const replacementBody = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly message?: string; + }>(replacementResponse); + + assert.equal(firstResponse.status, 200); + assert.equal(replacementResponse.status, 409); + assert.equal(replacementBody._tag, "EnvironmentHttpConflictError"); + assert.equal( + replacementBody.message, + "This environment is already linked to a different cloud account. Unlink it before switching accounts.", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("reports local cloud link state from persisted relay config", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + + const initialResponse = yield* fetchEffect(linkStateUrl, { + headers: { + cookie: ownerCookie, + }, + }); + const initialBody = yield* responseJsonEffect<{ + readonly linked?: boolean; + readonly cloudUserId?: string | null; + }>(initialResponse); + assert.equal(initialResponse.status, 200); + assert.equal(initialBody.linked, false); + assert.equal(initialBody.cloudUserId, null); + + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://transport.example.test", + relayIssuer: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const linkedResponse = yield* fetchEffect(linkStateUrl, { + headers: { + cookie: ownerCookie, + }, + }); + const linkedBody = yield* responseJsonEffect<{ + readonly linked?: boolean; + readonly cloudUserId?: string | null; + readonly relayUrl?: string | null; + readonly relayIssuer?: string | null; + }>(linkedResponse); + + assert.equal(linkedResponse.status, 200); + assert.equal(linkedBody.linked, true); + assert.equal(linkedBody.cloudUserId, "user_123"); + assert.equal(linkedBody.relayUrl, "https://transport.example.test"); + assert.equal(linkedBody.relayIssuer, "https://relay.example.test"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not expose internal cloud reconciliation over HTTP", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const reconcileUrl = yield* getHttpServerUrl("/api/cloud/reconcile"); + const response = yield* fetchEffect(reconcileUrl, { + method: "POST", + }); + + assert.equal(response.status, 404); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("unlinks local cloud state and disables the managed endpoint runtime", () => + Effect.gen(function* () { + const appliedRuntimeConfigs: Array = []; + yield* buildAppUnderTest({ + layers: { + cloudManagedEndpointRuntime: { + applyConfig: (config) => { + appliedRuntimeConfigs.push(config); + if (!config) { + return Effect.succeed({ status: "disabled" }); + } + return Effect.succeed({ + status: "running", + providerKind: "cloudflare_tunnel", + pid: 123, + ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), + ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), + }); + }, + }, + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const unlinkUrl = yield* getHttpServerUrl("/api/cloud/unlink"); + const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); + + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://transport.example.test", + relayIssuer: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-id", + tunnelName: "tunnel-name", + }, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const unlinkResponse = yield* fetchEffect(unlinkUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + }, + }); + const unlinkBody = yield* responseJsonEffect<{ + readonly ok?: boolean; + readonly endpointRuntimeStatus?: { readonly status?: string }; + }>(unlinkResponse); + assert.equal(unlinkResponse.status, 200); + assert.equal(unlinkBody.ok, true); + assert.equal(unlinkBody.endpointRuntimeStatus?.status, "disabled"); + + const linkStateResponse = yield* fetchEffect(linkStateUrl, { + headers: { + cookie: ownerCookie, + }, + }); + const linkStateBody = yield* responseJsonEffect<{ + readonly linked?: boolean; + readonly cloudUserId?: string | null; + readonly relayUrl?: string | null; + readonly relayIssuer?: string | null; + }>(linkStateResponse); + assert.equal(linkStateResponse.status, 200); + assert.equal(linkStateBody.linked, false); + assert.equal(linkStateBody.cloudUserId, null); + assert.equal(linkStateBody.relayUrl, null); + assert.equal(linkStateBody.relayIssuer, null); + assert.deepEqual(appliedRuntimeConfigs, [ + { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-id", + tunnelName: "tunnel-name", + }, + null, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects replayed cloud mint requests atomically", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const request = makeCloudMintCredentialRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + nonce: "cloud-mint-nonce-1", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }); + const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential"); + const postMint = () => + fetchEffect(mintUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody(request), + }); + + const firstResponse = yield* postMint(); + const replayResponse = yield* postMint(); + const replayBody = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly message?: string; + }>(replayResponse); + + assert.equal(firstResponse.status, 200); + assert.equal(replayResponse.status, 409); + assert.equal(replayBody._tag, "EnvironmentHttpConflictError"); + assert.equal(replayBody.message, "Cloud mint request was already consumed."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("serves the documented T3 Cloud mint credential endpoint", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const request = makeCloudMintCredentialRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + jti: "cloud-mint-jti-documented-endpoint", + nonce: "cloud-mint-nonce-documented-endpoint", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }); + const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); + const response = yield* fetchEffect(mintUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody(request), + }); + + assert.equal(response.status, 200); + const body = yield* responseJsonEffect<{ + readonly credential?: string; + readonly proof?: string; + }>(response); + assert.equal(typeof body.credential, "string"); + assert.equal(typeof body.proof, "string"); + assert.equal( + decodeCompactJwtPayload<{ readonly requestNonce?: string }>(body.proof!).requestNonce, + "cloud-mint-nonce-documented-endpoint", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("serves signed T3 Cloud environment health checks", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const request = makeCloudEnvironmentHealthRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + jti: "cloud-health-jti-documented-endpoint", + nonce: "cloud-health-nonce-documented-endpoint", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }); + const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const response = yield* fetchEffect(healthUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody(request), + }); + + assert.equal(response.status, 200); + const body = yield* responseJsonEffect<{ + readonly status?: string; + readonly descriptor?: { readonly environmentId?: string }; + readonly proof?: string; + }>(response); + assert.equal(body.status, "online"); + assert.equal(body.descriptor?.environmentId, testEnvironmentDescriptor.environmentId); + assert.equal(typeof body.proof, "string"); + assert.equal( + decodeCompactJwtPayload<{ readonly requestNonce?: string }>(body.proof!).requestNonce, + "cloud-health-nonce-documented-endpoint", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects replayed cloud health requests atomically", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const request = makeCloudEnvironmentHealthRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + jti: "cloud-health-jti-replay", + nonce: "cloud-health-nonce-replay", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }); + const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const postHealth = () => + fetchEffect(healthUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody(request), + }); + + const firstResponse = yield* postHealth(); + const replayResponse = yield* postHealth(); + const replayBody = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly message?: string; + }>(replayResponse); + + assert.equal(firstResponse.status, 200); + assert.equal(replayResponse.status, 409); + assert.equal(replayBody._tag, "EnvironmentHttpConflictError"); + assert.equal(replayBody.message, "Cloud health request was already consumed."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "validates cloud proofs against the configured relay issuer, not the transport URL", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://transport.example.test", + cloudUserId: "user_123", + relayIssuer: "https://relay.example.test", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); + const postMint = (request: ReturnType) => + fetchEffect(mintUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody(request), + }); + + const acceptedResponse = yield* postMint( + makeCloudMintCredentialRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + issuer: "https://relay.example.test", + jti: "cloud-mint-jti-explicit-relay-issuer", + nonce: "cloud-mint-nonce-explicit-relay-issuer", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }), + ); + const rejectedResponse = yield* postMint( + makeCloudMintCredentialRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + issuer: "https://transport.example.test", + jti: "cloud-mint-jti-transport-url", + nonce: "cloud-mint-nonce-transport-url", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }), + ); + + assert.equal(acceptedResponse.status, 200); + assert.equal(rejectedResponse.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("fails relay config when the managed endpoint connector cannot start", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + cloudManagedEndpointRuntime: { + applyConfig: () => + Effect.succeed({ + status: "failed", + providerKind: "cloudflare_tunnel", + reason: "cloudflared missing", + tunnelId: "tunnel-1", + }), + }, + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-1", + }, + }), + }); + + assert.equal(relayConfigResponse.status, 503); + const relayConfigBody = yield* responseJsonEffect<{ + _tag?: string; + message?: string; + endpointRuntimeStatus?: { status?: string; reason?: string }; + }>(relayConfigResponse); + assert.equal(relayConfigBody._tag, "EnvironmentCloudEndpointUnavailableError"); + assert.equal(relayConfigBody.message, "Managed endpoint runtime could not be started."); + assert.equal(relayConfigBody.endpointRuntimeStatus?.status, "failed"); + assert.equal(relayConfigBody.endpointRuntimeStatus?.reason, "cloudflared missing"); + + const now = yield* DateTime.now; + const healthRequest = makeCloudEnvironmentHealthRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + nonce: "cloud-health-after-failed-runtime", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }); + const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const healthResponse = yield* fetchEffect(healthUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody(healthRequest), + }); + const healthBody = yield* responseJsonEffect<{ + _tag?: string; + message?: string; + }>(healthResponse); + assert.equal(healthResponse.status, 500); + assert.equal(healthBody._tag, "EnvironmentHttpInternalServerError"); + assert.equal( + healthBody.message, + "Cloud mint public key is not installed for this environment.", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects cloud mint requests with the wrong issuer or audience", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test/", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential"); + const postMint = (request: ReturnType) => + fetchEffect(mintUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody(request), + }); + + const wrongIssuer = yield* postMint( + makeCloudMintCredentialRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + issuer: "https://attacker.example.test", + jti: "cloud-mint-jti-wrong-issuer", + nonce: "cloud-mint-nonce-wrong-issuer", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }), + ); + const wrongAudience = yield* postMint( + makeCloudMintCredentialRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + audience: "t3-env:other-environment", + jti: "cloud-mint-jti-wrong-audience", + nonce: "cloud-mint-nonce-wrong-audience", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }), + ); + + assert.equal(wrongIssuer.status, 401); + assert.equal(wrongAudience.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects cloud mint requests for a cloud subject other than the linked user", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test/", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); + const response = yield* fetchEffect(mintUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody( + makeCloudMintCredentialRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + subject: "user_other", + jti: "cloud-mint-jti-wrong-subject", + nonce: "cloud-mint-nonce-wrong-subject", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }), + ), + }); + + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects cloud mint requests without the exact connect scope", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test/", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); + const response = yield* fetchEffect(mintUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody( + makeCloudMintCredentialRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + clientProofKeyThumbprint: "client-proof-key-thumbprint", + jti: "cloud-mint-jti-duplicate-scope", + nonce: "cloud-mint-nonce-duplicate-scope", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + scope: ["environment:connect", "environment:connect"], + }), + ), + }); + + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects cloud health requests with the wrong issuer or audience", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test/", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const postHealth = (request: ReturnType) => + fetchEffect(healthUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody(request), + }); + + const wrongIssuer = yield* postHealth( + makeCloudEnvironmentHealthRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + issuer: "https://attacker.example.test", + jti: "cloud-health-jti-wrong-issuer", + nonce: "cloud-health-nonce-wrong-issuer", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }), + ); + const wrongAudience = yield* postHealth( + makeCloudEnvironmentHealthRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + audience: "t3-env:other-environment", + jti: "cloud-health-jti-wrong-audience", + nonce: "cloud-health-nonce-wrong-audience", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }), + ); + + assert.equal(wrongIssuer.status, 401); + assert.equal(wrongAudience.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects cloud health requests for a cloud subject other than the linked user", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test/", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const response = yield* fetchEffect(healthUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody( + makeCloudEnvironmentHealthRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + subject: "user_other", + jti: "cloud-health-jti-wrong-subject", + nonce: "cloud-health-nonce-wrong-subject", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + }), + ), + }); + + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects cloud health requests without the exact status scope", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); + const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test/", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }); + assert.equal(relayConfigResponse.status, 200); + + const now = yield* DateTime.now; + const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); + const response = yield* fetchEffect(healthUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: jsonRequestBody( + makeCloudEnvironmentHealthRequest({ + privateKey: cloudKeyPair.privateKey, + environmentId: testEnvironmentDescriptor.environmentId, + jti: "cloud-health-jti-duplicate-scope", + nonce: "cloud-health-nonce-duplicate-scope", + issuedAt: DateTime.formatIso(now), + expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), + scope: ["environment:status", "environment:status"], + }), + ), + }); + + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const bearerToken = yield* getAuthenticatedBearerSessionToken(); + const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); + const wsTicketResponse = yield* fetchEffect(wsTicketUrl, { + method: "POST", + headers: { + authorization: `Bearer ${bearerToken}`, + }, + }); + const wsTicketBody = yield* responseJsonEffect<{ + readonly ticket: string; + readonly expiresAt: string; + }>(wsTicketResponse); + + assert.equal(wsTicketResponse.status, 200); + assert.equal(typeof wsTicketBody.ticket, "string"); + assert.isTrue(wsTicketBody.ticket.length > 0); + assert.equal(typeof wsTicketBody.expiresAt, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not allow management-only access tokens to operate the environment", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken( + defaultDesktopBootstrapToken, + { scope: "access:write" }, + ); + assert.equal(exchangeResponse.status, 200); + assert.equal(tokenBody.scope, "access:write"); + assert.isDefined(tokenBody.access_token); + + const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + authorization: `Bearer ${tokenBody.access_token ?? ""}`, + }, + body: yield* HttpBody.json({}), + }); + const overbroadPairingBody = (yield* overbroadPairingResponse.json) as { + readonly requiredScope: string; + }; + const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + authorization: `Bearer ${tokenBody.access_token ?? ""}`, + }, + body: yield* HttpBody.json({ scopes: ["access:write"] }), + }); + const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + headers: { + authorization: `Bearer ${tokenBody.access_token ?? ""}`, + }, + }); + const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; + const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { + headers: { + authorization: `Bearer ${tokenBody.access_token ?? ""}`, + }, + }); + const faviconBody = (yield* faviconResponse.json) as { + readonly _tag: string; + readonly code: string; + readonly requiredScope: string; + readonly traceId: string; + }; + + assert.equal(overbroadPairingResponse.status, 403); + assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); + assert.equal(pairingResponse.status, 200); + assert.equal(wsTicketResponse.status, 200); + assert.equal(faviconResponse.status, 403); + assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); + assert.equal(faviconBody.code, "insufficient_scope"); + assert.equal(faviconBody.requiredScope, "orchestration:read"); + assert.equal(typeof faviconBody.traceId, "string"); + + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; + const rpcError = yield* Effect.flip( + Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), + ); + assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); + if (rpcError._tag === "EnvironmentAuthorizationError") { + assert.equal(rpcError.requiredScope, "orchestration:read"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("includes CORS headers on remote auth success responses", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const origin = crossOriginClientOrigin; + const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken( + defaultDesktopBootstrapToken, + { + headers: { origin }, + }, + ); + + assert.equal(tokenResponse.status, 200); + assertBrowserApiCorsResponseHeaders(tokenResponse.headers); + assert.equal(tokenBody.token_type, "Bearer"); + assert.equal(typeof tokenBody.access_token, "string"); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const sessionResponse = yield* fetchEffect(sessionUrl, { + headers: { + authorization: `Bearer ${tokenBody.access_token ?? ""}`, + origin, + }, + }); + const sessionBody = yield* responseJsonEffect<{ + readonly authenticated: boolean; + readonly sessionMethod?: string; + }>(sessionResponse); + + assert.equal(sessionResponse.status, 200); + assertBrowserApiCorsResponseHeaders(sessionResponse.headers); + assert.equal(sessionBody.authenticated, true); + assert.equal(sessionBody.sessionMethod, "bearer-access-token"); + + const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); + const wsTicketResponse = yield* fetchEffect(wsTicketUrl, { + method: "POST", + headers: { + authorization: `Bearer ${tokenBody.access_token ?? ""}`, + origin, + }, + }); + const wsTicketBody = yield* responseJsonEffect<{ + readonly ticket: string; + }>(wsTicketResponse); + + assert.equal(wsTicketResponse.status, 200); + assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers); + assert.equal(typeof wsTicketBody.ticket, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "responds to remote auth websocket-ticket preflight requests with authorization CORS headers", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); + const response = yield* fetchEffect(wsTicketUrl, { + method: "OPTIONS", + headers: { + origin: crossOriginClientOrigin, + "access-control-request-method": "POST", + "access-control-request-headers": "authorization", + }, + }); + + assert.equal(response.status, 204); + assertBrowserApiCorsPreflightHeaders(response.headers); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("allows credentialed cloud link proof preflights from the configured dev UI", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { devUrl: new URL(crossOriginClientOrigin) }, + }); + + const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); + const response = yield* fetchEffect(linkProofUrl, { + method: "OPTIONS", + headers: { + origin: crossOriginClientOrigin, + "access-control-request-method": "POST", + "access-control-request-headers": "content-type", + }, + }); + + assert.equal(response.status, 204); + assertBrowserApiCorsPreflightHeaders(response.headers, { + origin: crossOriginClientOrigin, + credentials: true, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("includes CORS headers on remote websocket-ticket auth failures", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); + const response = yield* fetchEffect(wsTicketUrl, { + method: "POST", + headers: { + origin: crossOriginClientOrigin, + }, + }); + const body = yield* responseJsonEffect<{ + readonly _tag?: string; + readonly code?: string; + readonly reason?: string; + readonly traceId?: string; + }>(response); + + assert.equal(response.status, 401); + assertBrowserApiCorsResponseHeaders(response.headers); + assert.equal(body._tag, "EnvironmentAuthInvalidError"); + assert.equal(body.code, "auth_invalid"); + assert.equal(body.reason, "missing_credential"); + assert.equal(typeof body.traceId, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("issues authenticated one-time pairing credentials for additional clients", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const response = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + body: yield* HttpBody.json({}), + }); + const body = (yield* response.json) as { + readonly credential: string; + readonly expiresAt: string; }; assert.equal(response.status, 200); @@ -1538,8 +3440,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", { headers: { cookie: ownerCookie, + "content-type": "application/json", }, - body: yield* HttpBody.json({ id: createdBody.id }), + body: HttpBody.text(jsonRequestBody({ id: createdBody.id }), "application/json"), }); const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential); @@ -1601,16 +3504,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); - const ownerPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + const pairingTokenUrl = yield* getHttpServerUrl("/api/auth/pairing-token"); + const ownerPairingResponse = yield* fetchEffect(pairingTokenUrl, { + method: "POST", headers: { cookie: ownerCookie, + "content-type": "application/json", }, - body: yield* HttpBody.json({ label: "Julius iPhone" }), + body: jsonRequestBody({ + label: "Julius iPhone", + }), }); - const ownerPairingBody = (yield* ownerPairingResponse.json) as { + const ownerPairingBody = yield* responseJsonEffect<{ readonly credential: string; readonly label?: string; - }; + }>(ownerPairingResponse); assert.equal(ownerPairingResponse.status, 200); const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, { headers: { @@ -1794,8 +3702,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const revokeResponse = yield* HttpClient.post("/api/auth/clients/revoke", { headers: { cookie: ownerCookie, + "content-type": "application/json", }, - body: yield* HttpBody.json({ sessionId: pairedSessionId }), + body: HttpBody.text(jsonRequestBody({ sessionId: pairedSessionId }), "application/json"), }); const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { headers: { @@ -1890,20 +3799,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); it.effect( - "accepts websocket rpc handshake with a dedicated websocket token in the query string", + "accepts websocket rpc handshake with a dedicated websocket ticket in the query string", () => Effect.gen(function* () { yield* buildAppUnderTest(); const bearerToken = yield* getAuthenticatedBearerSessionToken(); - const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { + const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); + const wsTicketResponse = yield* fetchEffect(wsTicketUrl, { + method: "POST", headers: { authorization: `Bearer ${bearerToken}`, }, }); - const wsTicketBody = (yield* wsTicketResponse.json) as { + const wsTicketBody = yield* responseJsonEffect<{ readonly ticket: string; - }; + }>(wsTicketResponse); const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; const response = yield* Effect.scoped( @@ -2103,7 +4014,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "content-type": "application/json", origin: "http://localhost:5733", }, - body: yield* HttpBody.json(payload), + // @effect-diagnostics-next-line preferSchemaOverJson:off + body: HttpBody.text(JSON.stringify(payload), "application/json"), }); assert.equal(response.status, 204); @@ -2148,8 +4060,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ]); assert.deepEqual(upstreamRequests, [ { - // @effect-diagnostics-next-line preferSchemaOverJson:off - body: JSON.stringify(payload), + body: jsonRequestBody(payload), contentType: "application/json", }, ]); @@ -2170,14 +4081,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.status, 204); assert.equal(response.headers["access-control-allow-origin"], "*"); - assert.deepEqual( - splitHeaderTokens(response.headers["access-control-allow-methods"] ?? null), - ["GET", "OPTIONS", "POST"], - ); - assert.deepEqual( - splitHeaderTokens(response.headers["access-control-allow-headers"] ?? null), - ["authorization", "b3", "content-type", "traceparent"], - ); + assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-methods"]), [ + "GET", + "OPTIONS", + "POST", + ]); + assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-headers"]), [ + "authorization", + "b3", + "content-type", + "dpop", + "traceparent", + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -2214,7 +4129,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", }, - body: yield* HttpBody.json(payload), + // @effect-diagnostics-next-line preferSchemaOverJson:off + body: HttpBody.text(JSON.stringify(payload), "application/json"), }); assert.equal(response.status, 204); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5c0bbb8425a..98bef90bb2e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -44,6 +44,8 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; +import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; +import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import { ServerSettingsLive } from "./serverSettings.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; @@ -67,6 +69,10 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { cloudHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts"; +import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; +import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; +import * as CloudCliState from "./cloud/CliState.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -78,6 +84,7 @@ import { } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import * as NetService from "@t3tools/shared/Net"; +import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; const PtyAdapterLive = Layer.unwrap( @@ -92,6 +99,13 @@ const PtyAdapterLive = Layer.unwrap( }), ); +const RelayClientLive = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + return RelayClient.layerCloudflared({ baseDir: config.baseDir }); + }), +); + const HttpServerLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; @@ -134,6 +148,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(ProviderCommandReactorLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), + Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); @@ -231,6 +246,14 @@ const AuthLayerLive = EnvironmentAuth.layer.pipe( Layer.provide(ServerSecretStore.layer), ); +const CloudManagedEndpointRuntimeLive = Layer.mergeAll( + RelayClientLive, + CloudManagedEndpointRuntime.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(RelayClientLive), + ), +); + const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(OrchestrationLayerLive), @@ -271,6 +294,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge( + Layer.mergeAll( + CloudCliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), + CloudManagedEndpointRuntimeLive, + ), + ), ); const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( @@ -291,6 +321,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( export const makeRoutesLayer = Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( Layer.provide(authHttpApiLayer), + Layer.provide(cloudHttpApiLayer), Layer.provide(orchestrationHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), @@ -387,6 +418,27 @@ export const makeServerLayer = Layer.unwrap( ), ) : Layer.empty; + const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( + Effect.gen(function* () { + if (!hasCloudPublicConfig) return; + if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) return; + yield* Effect.forkScoped( + Effect.sleep("250 millis").pipe( + Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), + Effect.retry({ times: 4 }), + Effect.tap(() => Effect.logInfo("T3 Cloud desired link reconciled on startup")), + Effect.catch((cause) => + Effect.logWarning("Failed to reconcile T3 Cloud desired link on startup", { + cause, + }), + ), + ), + ); + }), + ); const serverApplicationLayer = Layer.mergeAll( HttpRouter.serve(makeRoutesLayer, { @@ -395,6 +447,7 @@ export const makeServerLayer = Layer.unwrap( httpListeningLayer, runtimeStateLayer, tailscaleServeLayer, + cloudDesiredLinkReconcileLayer, ); return serverApplicationLayer.pipe( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 8c41e28b7b3..cde308ffe42 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -8,6 +8,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Data from "effect/Data"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -19,7 +20,6 @@ import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Context from "effect/Context"; import * as Console from "effect/Console"; -import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import { ServerConfig } from "./config.ts"; @@ -289,6 +289,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; + const crypto = yield* Crypto.Crypto; const commandGate = yield* makeCommandGate; const httpListening = yield* Deferred.make(); @@ -361,7 +362,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { runStartupPhase( "welcome.autobootstrap", Effect.gen(function* () { - const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets; + const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(Crypto.Crypto, crypto), + ); if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { return; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index da8e3f694db..059918e132f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -14,6 +14,7 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthReviewWriteScope, + AuthRelayWriteScope, AuthTerminalOperateScope, AuthAccessReadScope, AuthAccessStreamError, @@ -34,6 +35,8 @@ import { ORCHESTRATION_WS_METHODS, ProjectSearchEntriesError, ProjectWriteFileError, + RelayClientInstallFailedError, + type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, FilesystemBrowseError, EnvironmentAuthorizationError, @@ -96,6 +99,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); @@ -145,6 +149,8 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], + [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], + [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], @@ -260,6 +266,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; + const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -1070,6 +1077,39 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), + [WS_METHODS.cloudGetRelayClientStatus]: (_input) => + observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { + "rpc.aggregate": "cloud", + }), + [WS_METHODS.cloudInstallRelayClient]: (_input) => + observeRpcStream( + WS_METHODS.cloudInstallRelayClient, + Stream.callback( + (queue) => + relayClient + .installWithProgress((event) => Queue.offer(queue, event).pipe(Effect.asVoid)) + .pipe( + Effect.flatMap((status) => + Queue.offer(queue, { + type: "complete", + status, + }), + ), + Effect.catchTag("RelayClientInstallError", (error) => + Queue.fail( + queue, + new RelayClientInstallFailedError({ + reason: error.reason, + message: error.message, + }), + ), + ), + Effect.andThen(Queue.end(queue)), + Effect.forkScoped, + ), + ), + { "rpc.aggregate": "cloud" }, + ), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index bace5e00a6c..cf920d80d7e 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -2,8 +2,10 @@ import "vite-plus/test/config"; import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; +import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; const internalPackagePrefixes = ["@t3tools/", "effect-acp", "effect-codex-app-server"]; +const repoEnv = loadRepoEnv(); export default mergeConfig( baseConfig, @@ -21,6 +23,15 @@ export default mergeConfig( banner: { js: "#!/usr/bin/env node\n", }, + define: { + __T3CODE_BUILD_RELAY_URL__: JSON.stringify(repoEnv.T3CODE_RELAY_URL?.trim() ?? ""), + __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( + repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "", + ), + __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify( + repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "", + ), + }, }, test: { // The server suite exercises sqlite, git, temp worktrees, and orchestration diff --git a/apps/web/package.json b/apps/web/package.json index b464a3a0a58..2d09705313b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,8 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", + "@clerk/clerk-js": "^6.13.0", + "@clerk/react": "^6.7.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -33,6 +35,7 @@ "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", + "jose": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", "react": "19.2.6", @@ -44,6 +47,7 @@ }, "devDependencies": { "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", "@rolldown/plugin-babel": "^0.2.0", "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 30c949b37ac..2da74ae6c45 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -27,6 +27,7 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ port: Schema.NullOr(Schema.Number), }), ), + relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })), bearerToken: Schema.optionalKey(Schema.String), }); type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; @@ -53,7 +54,11 @@ function toPersistedSavedEnvironmentRecord( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, }; - return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; + return { + ...nextRecord, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), + ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), + }; } export function readBrowserClientSettings(): ClientSettings | null { @@ -145,6 +150,7 @@ export function writeBrowserSavedEnvironmentRegistry( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), + ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), bearerToken, } : toPersistedSavedEnvironmentRecord(record); @@ -171,6 +177,8 @@ export function writeBrowserSavedEnvironmentSecret( let found = false; writeBrowserSavedEnvironmentRegistryDocument({ version: document.version ?? 1, + // The persistence update is copy-on-write so storage subscribers observe a new document. + // oxlint-disable-next-line oxc/no-map-spread records: records.map((record) => { if (record.environmentId !== environmentId) { return record; @@ -185,7 +193,11 @@ export function writeBrowserSavedEnvironmentSecret( lastConnectedAt: record.lastConnectedAt, bearerToken: secret, }; - return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; + return { + ...nextRecord, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), + ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), + }; }), }); return found; diff --git a/apps/web/src/cloud/desktopAuth.test.ts b/apps/web/src/cloud/desktopAuth.test.ts new file mode 100644 index 00000000000..9a9b2445179 --- /dev/null +++ b/apps/web/src/cloud/desktopAuth.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDesktopCloudAuthOAuthOptions } from "./desktopAuth"; + +describe("resolveDesktopCloudAuthOAuthOptions", () => { + it("ignores absent social provider settings", () => { + expect( + resolveDesktopCloudAuthOAuthOptions({ + environment: { + userSettings: { + social: { + github: null, + google: { + strategy: "oauth_google", + enabled: true, + authenticatable: true, + }, + }, + }, + }, + }), + ).toEqual([ + { + strategy: "oauth_google", + label: "Google", + providerId: "google", + iconUrl: null, + }, + ]); + }); + + it("preserves provider display metadata when Clerk exposes the strategy list", () => { + expect( + resolveDesktopCloudAuthOAuthOptions({ + environment: { + userSettings: { + authenticatableSocialStrategies: ["oauth_google"], + social: { + oauth_google: { + strategy: "oauth_google", + enabled: true, + authenticatable: true, + name: "Google", + logo_url: "https://img.clerk.com/static/google.png", + }, + }, + }, + }, + }), + ).toEqual([ + { + strategy: "oauth_google", + label: "Google", + providerId: "google", + iconUrl: "https://img.clerk.com/static/google.png", + }, + ]); + }); +}); diff --git a/apps/web/src/cloud/desktopAuth.ts b/apps/web/src/cloud/desktopAuth.ts new file mode 100644 index 00000000000..0e2a328c30e --- /dev/null +++ b/apps/web/src/cloud/desktopAuth.ts @@ -0,0 +1,144 @@ +export type DesktopCloudAuthOAuthStrategy = `oauth_${string}`; + +export interface DesktopCloudAuthOAuthOption { + readonly strategy: DesktopCloudAuthOAuthStrategy; + readonly label: string; + readonly providerId: string; + readonly iconUrl: string | null; +} + +interface ClerkOAuthProviderSetting { + readonly enabled?: unknown; + readonly authenticatable?: unknown; + readonly strategy?: unknown; + readonly name?: unknown; + readonly logo_url?: unknown; +} + +interface ClerkUserSettingsLike { + readonly authenticatableSocialStrategies?: unknown; + readonly social?: unknown; +} + +interface ClerkEnvironmentLike { + readonly userSettings?: ClerkUserSettingsLike; +} + +interface ClerkLike { + readonly __internal_environment?: ClerkEnvironmentLike; + readonly environment?: ClerkEnvironmentLike; +} + +const isClerkOAuthProviderSetting = (value: unknown): value is ClerkOAuthProviderSetting => + typeof value === "object" && value !== null; + +const OAUTH_LABELS: Readonly> = { + oauth_apple: "Apple", + oauth_discord: "Discord", + oauth_github: "GitHub", + oauth_gitlab: "GitLab", + oauth_google: "Google", + oauth_linear: "Linear", + oauth_microsoft: "Microsoft", + oauth_slack: "Slack", + oauth_x: "X", +}; + +// Mirrors Clerk UI's enabled-provider projection for the local desktop replacement: +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/hooks/useEnabledThirdPartyProviders.tsx +export function isDesktopCloudAuthOAuthStrategy( + value: unknown, +): value is DesktopCloudAuthOAuthStrategy { + return typeof value === "string" && value.startsWith("oauth_"); +} + +export function getDesktopCloudAuthOAuthStrategyLabel( + strategy: DesktopCloudAuthOAuthStrategy, +): string { + const mapped = OAUTH_LABELS[strategy]; + if (mapped) return mapped; + return strategy + .replace(/^oauth_custom_/, "") + .replace(/^oauth_/, "") + .split("_") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function resolveDesktopCloudAuthOAuthOptions( + clerk: unknown, +): readonly DesktopCloudAuthOAuthOption[] { + const environment = + (clerk as ClerkLike | null | undefined)?.__internal_environment ?? + (clerk as ClerkLike | null | undefined)?.environment; + const userSettings = environment?.userSettings; + const strategies = userSettings?.authenticatableSocialStrategies; + if (Array.isArray(strategies)) { + return uniqueOptions( + strategies + .filter(isDesktopCloudAuthOAuthStrategy) + .map((strategy) => + createOAuthOption(strategy, findProviderSetting(userSettings, strategy)), + ), + ); + } + + const social = userSettings?.social; + if (!social || typeof social !== "object") { + return []; + } + + return uniqueOptions( + Object.values(social as Record) + .filter(isClerkOAuthProviderSetting) + .filter((provider) => provider.enabled !== false && provider.authenticatable !== false) + .map((provider) => { + const strategy = isDesktopCloudAuthOAuthStrategy(provider.strategy) + ? provider.strategy + : null; + if (!strategy) return null; + return createOAuthOption(strategy, provider); + }) + .filter((option): option is DesktopCloudAuthOAuthOption => option !== null), + ); +} + +function findProviderSetting( + userSettings: ClerkUserSettingsLike | undefined, + strategy: DesktopCloudAuthOAuthStrategy, +): ClerkOAuthProviderSetting | undefined { + const social = userSettings?.social; + if (!social || typeof social !== "object") return undefined; + + return Object.values(social as Record) + .filter(isClerkOAuthProviderSetting) + .find((provider) => provider.strategy === strategy); +} + +function createOAuthOption( + strategy: DesktopCloudAuthOAuthStrategy, + provider?: ClerkOAuthProviderSetting, +): DesktopCloudAuthOAuthOption { + return { + strategy, + label: + typeof provider?.name === "string" && provider.name.trim() + ? provider.name + : getDesktopCloudAuthOAuthStrategyLabel(strategy), + providerId: strategy.replace(/^oauth_/, ""), + iconUrl: + typeof provider?.logo_url === "string" && provider.logo_url.trim() ? provider.logo_url : null, + }; +} + +function uniqueOptions( + options: readonly DesktopCloudAuthOAuthOption[], +): readonly DesktopCloudAuthOAuthOption[] { + const seen = new Set(); + return options.filter((option) => { + if (seen.has(option.strategy)) return false; + seen.add(option.strategy); + return true; + }); +} diff --git a/apps/web/src/cloud/desktopClerk.tsx b/apps/web/src/cloud/desktopClerk.tsx new file mode 100644 index 00000000000..68179f5cf03 --- /dev/null +++ b/apps/web/src/cloud/desktopClerk.tsx @@ -0,0 +1,322 @@ +import { Clerk } from "@clerk/clerk-js"; +import { + buildClerkUIScriptAttributes, + clerkUIScriptUrl, + InternalClerkProvider, +} from "@clerk/react/internal"; +import type { ClerkProviderProps } from "@clerk/react"; +import { + clerkFrontendApiHostnameFromPublishableKey, + isAllowedClerkFrontendApiHostname, +} from "@t3tools/shared/relayAuth"; +import React, { useEffect, useState } from "react"; + +import { + makeDesktopClerkExternalAccountAdapter, + type DesktopClerkUser, +} from "./desktopClerkExternalAccounts"; + +type DesktopClerkUiCtor = NonNullable; + +interface ClerkFrontendApiRequest { + credentials?: RequestCredentials; + headers?: Headers; + url?: URL; +} + +interface ClerkFrontendApiResponse { + headers: Headers; + payload?: { + errors?: readonly { + code?: string; + }[]; + }; +} + +interface NativeRequestClerk { + readonly publishableKey?: string; + __internal_onBeforeRequest?: ( + listener: (request: ClerkFrontendApiRequest) => void | Promise, + ) => void; + __internal_onAfterResponse?: ( + listener: ( + request: ClerkFrontendApiRequest, + response?: ClerkFrontendApiResponse, + ) => void | Promise, + ) => void; + __unstable__onBeforeRequest?: ( + listener: (request: ClerkFrontendApiRequest) => void | Promise, + ) => void; + __unstable__onAfterResponse?: ( + listener: ( + request: ClerkFrontendApiRequest, + response?: ClerkFrontendApiResponse, + ) => void | Promise, + ) => void; +} + +interface DesktopClerkProviderProps { + readonly children: React.ReactNode; + readonly publishableKey: string; +} + +let desktopClerk: Clerk | null = null; +let desktopClerkFetchInstalled = false; +let desktopClerkUiLoad: Promise | null = null; +let desktopClerkFrontendApiHostname: string | null = null; +let desktopClerkExternalAccountCleanup: (() => void) | null = null; + +const isNativeRequestClerk = (value: unknown): value is NativeRequestClerk => { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { + __internal_onBeforeRequest?: unknown; + __internal_onAfterResponse?: unknown; + __unstable__onBeforeRequest?: unknown; + __unstable__onAfterResponse?: unknown; + }; + return ( + (typeof candidate.__internal_onBeforeRequest === "function" || + typeof candidate.__unstable__onBeforeRequest === "function") && + (typeof candidate.__internal_onAfterResponse === "function" || + typeof candidate.__unstable__onAfterResponse === "function") + ); +}; + +const getStoredClientJwt = (): Promise => + window.desktopBridge?.getCloudAuthToken() ?? Promise.resolve(null); + +const setStoredClientJwt = (token: string): Promise => + window.desktopBridge?.setCloudAuthToken(token) ?? Promise.resolve(false); + +const clearStoredClientJwt = (): Promise => + window.desktopBridge?.clearCloudAuthToken() ?? Promise.resolve(); + +const isClerkFrontendApiUrl = (url: URL): boolean => + url.protocol === "https:" && + isAllowedClerkFrontendApiHostname(url.hostname, desktopClerkFrontendApiHostname); + +const headersToRecord = (headers: Headers): Record => { + const record: Record = {}; + headers.forEach((value, key) => { + record[key] = value; + }); + return record; +}; + +function installDesktopClerkFetchProxy(publishableKey: string): void { + desktopClerkFrontendApiHostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey); + if (desktopClerkFetchInstalled) return; + const bridge = window.desktopBridge; + if (!bridge) return; + + const browserFetch = window.fetch.bind(window); + window.fetch = async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + if (!isClerkFrontendApiUrl(url)) { + return browserFetch(input, init); + } + + const body = + request.method === "GET" || request.method === "HEAD" + ? undefined + : await request.clone().text(); + const result = await bridge.fetchCloudAuth({ + url: request.url, + method: request.method, + headers: headersToRecord(request.headers), + ...(body === undefined ? {} : { body }), + }); + + return new Response(result.body, { + status: result.status, + statusText: result.statusText, + headers: result.headers, + }); + }; + desktopClerkFetchInstalled = true; +} + +function installDesktopClerkExternalAccounts(clerk: Clerk): void { + desktopClerkExternalAccountCleanup?.(); + desktopClerkExternalAccountCleanup = null; + + const bridge = window.desktopBridge; + if (!bridge) return; + + const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); + const unsubscribe = clerk.addListener(({ user }) => { + if (user) { + adapter.installUser(user as DesktopClerkUser); + } + }); + desktopClerkExternalAccountCleanup = () => { + unsubscribe(); + adapter.dispose(); + }; +} + +function loadDesktopClerkUi(publishableKey: string): Promise { + if (window.__internal_ClerkUICtor) { + return Promise.resolve(window.__internal_ClerkUICtor); + } + if (desktopClerkUiLoad) { + return desktopClerkUiLoad; + } + + const load = new Promise((resolve, reject) => { + const scriptUrl = clerkUIScriptUrl({ publishableKey }); + const existingScript = document.querySelector( + "script[data-clerk-ui-script]", + ); + + const resolveLoadedUi = () => { + const ClerkUI = window.__internal_ClerkUICtor; + if (ClerkUI) { + resolve(ClerkUI); + return true; + } + return false; + }; + if (resolveLoadedUi()) { + return; + } + + const script = existingScript ?? document.createElement("script"); + script.async = true; + script.crossOrigin = "anonymous"; + script.src = scriptUrl; + script.dataset.clerkUiScript = "true"; + const attributes = buildClerkUIScriptAttributes({ publishableKey }); + for (const [name, value] of Object.entries(attributes)) { + script.setAttribute(name, value); + } + + const timeoutId = window.setTimeout(() => { + reject(new Error("Timed out loading Clerk UI for desktop auth.")); + }, 15_000); + script.addEventListener("load", () => { + window.clearTimeout(timeoutId); + if (!resolveLoadedUi()) { + reject(new Error("Clerk UI loaded without exposing the UI constructor.")); + } + }); + script.addEventListener("error", () => { + window.clearTimeout(timeoutId); + reject(new Error("Failed to load Clerk UI for desktop auth.")); + }); + if (!existingScript) { + document.head.append(script); + } + }).catch((error: unknown) => { + desktopClerkUiLoad = null; + throw error; + }); + + desktopClerkUiLoad = load; + return load; +} + +function getDesktopClerkInstance(publishableKey: string): Clerk { + installDesktopClerkFetchProxy(publishableKey); + + const hasKeyChanged = desktopClerk !== null && desktopClerk.publishableKey !== publishableKey; + if (hasKeyChanged) { + void clearStoredClientJwt(); + desktopClerkExternalAccountCleanup?.(); + desktopClerkExternalAccountCleanup = null; + desktopClerk = null; + } + + if (desktopClerk !== null) { + return desktopClerk; + } + + const nextClerk = new Clerk(publishableKey); + installDesktopClerkExternalAccounts(nextClerk); + if (!isNativeRequestClerk(nextClerk)) { + desktopClerk = nextClerk; + return nextClerk; + } + + const onBeforeRequest = + nextClerk.__internal_onBeforeRequest ?? nextClerk.__unstable__onBeforeRequest; + const onAfterResponse = + nextClerk.__internal_onAfterResponse ?? nextClerk.__unstable__onAfterResponse; + + // Keep this aligned with Clerk Expo's native FAPI adapter: + // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/expo/src/provider/singleton/createClerkInstance.ts + onBeforeRequest(async (request) => { + request.credentials = "omit"; + request.url?.searchParams.append("_is_native", "1"); + const headers = new Headers(request.headers); + + const clientJwt = await getStoredClientJwt(); + headers.set("authorization", clientJwt ?? ""); + headers.set("x-mobile", "1"); + request.headers = headers; + }); + + onAfterResponse(async (_request, response) => { + const clientJwt = response?.headers.get("authorization"); + if (clientJwt) { + await setStoredClientJwt(clientJwt); + } + + const errorCode = response?.payload?.errors?.[0]?.code; + if (errorCode === "native_api_disabled") { + console.error( + "Clerk Native API is disabled. Enable Native applications in the Clerk dashboard for desktop sign-in.", + ); + } + }); + + desktopClerk = nextClerk; + return nextClerk; +} + +export function DesktopClerkProvider({ children, publishableKey }: DesktopClerkProviderProps) { + const [clerkUiCtor, setClerkUiCtor] = useState( + () => window.__internal_ClerkUICtor, + ); + const [clerkUiError, setClerkUiError] = useState(null); + + useEffect(() => { + let isCurrent = true; + void loadDesktopClerkUi(publishableKey).then( + (ClerkUI) => { + if (isCurrent) { + setClerkUiCtor(() => ClerkUI); + } + }, + (error: unknown) => { + if (isCurrent) { + setClerkUiError(error); + } + }, + ); + return () => { + isCurrent = false; + }; + }, [publishableKey]); + + if (!clerkUiCtor) { + if (clerkUiError) { + console.error("Failed to load Clerk UI for desktop auth.", clerkUiError); + } + return null; + } + + const clerk = getDesktopClerkInstance(publishableKey); + return ( + + {children} + + ); +} diff --git a/apps/web/src/cloud/desktopClerkExternalAccounts.test.ts b/apps/web/src/cloud/desktopClerkExternalAccounts.test.ts new file mode 100644 index 00000000000..361b52001b5 --- /dev/null +++ b/apps/web/src/cloud/desktopClerkExternalAccounts.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + makeDesktopClerkExternalAccountAdapter, + type DesktopClerkUser, +} from "./desktopClerkExternalAccounts"; + +describe("desktop Clerk external account adapter", () => { + it("replaces renderer redirects with native callbacks and reloads the user on return", async () => { + const callbacks: ((rawUrl: string) => void)[] = []; + const callbackCleanup = vi.fn(); + const bridge = { + createCloudAuthRequest: vi + .fn() + .mockResolvedValueOnce("t3code://auth/callback?t3_state=add") + .mockResolvedValueOnce("t3code://auth/callback?t3_state=reconnect"), + onCloudAuthCallback: vi.fn((listener: (rawUrl: string) => void) => { + callbacks.push(listener); + return callbackCleanup; + }), + }; + const reauthorize = vi.fn(async (_params: Record) => account); + const account = { reauthorize }; + const createExternalAccount = vi.fn(async (_params: Record) => account); + const reload = vi.fn(async () => undefined); + const user = { + externalAccounts: [], + createExternalAccount, + reload, + } satisfies DesktopClerkUser; + const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); + adapter.installUser(user); + + await user.createExternalAccount({ + redirectUrl: "http://127.0.0.1:3773/?__clerk_modal_state=state", + strategy: "oauth_microsoft", + }); + + expect(createExternalAccount).toHaveBeenCalledWith({ + redirectUrl: "t3code://auth/callback?t3_state=add", + strategy: "oauth_microsoft", + }); + + callbacks[0]?.("t3code://auth/callback?t3_state=add"); + await Promise.resolve(); + expect(reload).toHaveBeenCalledOnce(); + + await account.reauthorize({ + redirectUrl: "http://127.0.0.1:3773/?__clerk_modal_state=state", + }); + expect(reauthorize).toHaveBeenCalledWith({ + redirectUrl: "t3code://auth/callback?t3_state=reconnect", + }); + }); + + it("cleans up the pending callback when Clerk rejects account creation", async () => { + const callbackCleanup = vi.fn(); + const bridge = { + createCloudAuthRequest: vi.fn().mockResolvedValue("t3code://auth/callback?t3_state=failed"), + onCloudAuthCallback: vi.fn(() => callbackCleanup), + }; + const createError = new Error("oauth provider unavailable"); + const user = { + externalAccounts: [], + createExternalAccount: vi.fn(async (_params: Record) => { + throw createError; + }), + reload: vi.fn(async () => undefined), + } satisfies DesktopClerkUser; + const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); + adapter.installUser(user); + + await expect(user.createExternalAccount({ strategy: "oauth_microsoft" })).rejects.toBe( + createError, + ); + expect(callbackCleanup).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/cloud/desktopClerkExternalAccounts.ts b/apps/web/src/cloud/desktopClerkExternalAccounts.ts new file mode 100644 index 00000000000..01ff8603e25 --- /dev/null +++ b/apps/web/src/cloud/desktopClerkExternalAccounts.ts @@ -0,0 +1,112 @@ +interface DesktopClerkExternalAccountParams { + readonly redirectUrl?: string; + readonly [key: string]: unknown; +} + +interface DesktopClerkExternalAccount { + reauthorize: (params: DesktopClerkExternalAccountParams) => Promise; +} + +interface DesktopClerkUser { + readonly externalAccounts: readonly DesktopClerkExternalAccount[]; + createExternalAccount: ( + params: DesktopClerkExternalAccountParams, + ) => Promise; + reload: () => Promise; +} + +interface DesktopClerkExternalAccountBridge { + readonly createCloudAuthRequest: () => Promise; + readonly onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; +} + +interface DesktopClerkExternalAccountAdapter { + readonly dispose: () => void; + readonly installUser: (user: DesktopClerkUser) => void; +} + +interface MakeDesktopClerkExternalAccountAdapterInput { + readonly bridge: DesktopClerkExternalAccountBridge; + readonly reportError?: (message: string, error: unknown) => void; +} + +// Clerk's profile component uses window.location.href as the OAuth callback and navigates the +// current window to the provider. Keep the upstream component intact while adapting its resource +// calls to the native callback bridge: +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx +export function makeDesktopClerkExternalAccountAdapter({ + bridge, + reportError = console.error, +}: MakeDesktopClerkExternalAccountAdapterInput): DesktopClerkExternalAccountAdapter { + const installedAccounts = new WeakSet(); + const installedUsers = new WeakSet(); + let callbackGeneration = 0; + let callbackCleanup: (() => void) | null = null; + + const clearCallback = () => { + callbackGeneration += 1; + callbackCleanup?.(); + callbackCleanup = null; + }; + + const createRedirectUrl = async (user: DesktopClerkUser): Promise => { + clearCallback(); + const redirectUrl = await bridge.createCloudAuthRequest(); + const generation = callbackGeneration; + callbackCleanup = bridge.onCloudAuthCallback(() => { + if (generation !== callbackGeneration) return; + clearCallback(); + void user.reload().catch((error: unknown) => { + reportError("Failed to reload Clerk after desktop account linking.", error); + }); + }); + return redirectUrl; + }; + + const installAccount = (user: DesktopClerkUser, account: DesktopClerkExternalAccount): void => { + if (installedAccounts.has(account)) return; + installedAccounts.add(account); + + const reauthorize = account.reauthorize.bind(account); + account.reauthorize = async (params) => { + const redirectUrl = await createRedirectUrl(user); + try { + const nextAccount = await reauthorize({ ...params, redirectUrl }); + installAccount(user, nextAccount); + return nextAccount; + } catch (error) { + clearCallback(); + throw error; + } + }; + }; + + const installUser = (user: DesktopClerkUser): void => { + for (const account of user.externalAccounts) { + installAccount(user, account); + } + if (installedUsers.has(user)) return; + installedUsers.add(user); + + const createExternalAccount = user.createExternalAccount.bind(user); + user.createExternalAccount = async (params) => { + const redirectUrl = await createRedirectUrl(user); + try { + const account = await createExternalAccount({ ...params, redirectUrl }); + installAccount(user, account); + return account; + } catch (error) { + clearCallback(); + throw error; + } + }; + }; + + return { + dispose: clearCallback, + installUser, + }; +} + +export type { DesktopClerkExternalAccountAdapter, DesktopClerkUser }; diff --git a/apps/web/src/cloud/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts new file mode 100644 index 00000000000..bea52c96640 --- /dev/null +++ b/apps/web/src/cloud/dpop.test.ts @@ -0,0 +1,32 @@ +import { verifyDpopProof } from "@t3tools/shared/dpop"; +import * as Effect from "effect/Effect"; +import { describe, expect, it, vi } from "vitest"; + +import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; + +describe("browser DPoP proofs", () => { + it("signs relay resource proofs with an access-token hash", async () => { + vi.stubGlobal("indexedDB", undefined); + const issuedAt = Math.floor(Date.now() / 1_000); + const proofKey = await Effect.runPromise(generateBrowserDpopKey); + const proof = await Effect.runPromise( + createBrowserDpopProof({ + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/connect?ignored=true", + accessToken: "relay-access-token", + proofKey, + }).pipe(Effect.provide(browserCryptoLayer)), + ); + + expect( + verifyDpopProof({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/connect", + expectedThumbprint: proof.thumbprint, + expectedAccessToken: "relay-access-token", + nowEpochSeconds: issuedAt, + }), + ).toMatchObject({ ok: true }); + }); +}); diff --git a/apps/web/src/cloud/dpop.ts b/apps/web/src/cloud/dpop.ts new file mode 100644 index 00000000000..79b439f6109 --- /dev/null +++ b/apps/web/src/cloud/dpop.ts @@ -0,0 +1,188 @@ +import { + computeDpopAccessTokenHash, + computeDpopJwkThumbprint, + DpopPublicJwk, +} from "@t3tools/shared/dpop"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { importJWK, SignJWT, type JWK } from "jose"; + +export interface BrowserDpopKey { + readonly privateKey: CryptoKey; + readonly publicJwk: DpopPublicJwk; + readonly thumbprint: string; +} + +export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const DPOP_DATABASE_NAME = "t3code:cloud-auth"; +const DPOP_DATABASE_VERSION = 1; +const DPOP_KEY_STORE_NAME = "keys"; +const DPOP_KEY_ID = "relay-dpop-proof-key"; +const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk); + +export const browserCryptoLayer = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)), + digest: (algorithm, data) => + Effect.promise(async () => { + const input = new Uint8Array(data.length); + input.set(data); + return new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, input.buffer)); + }), + }), +); + +function dpopError(message: string, cause?: unknown) { + return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) }); +} + +function openDpopDatabase(): Effect.Effect { + return Effect.callback((resume) => { + const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION); + request.addEventListener("error", () => + resume( + Effect.fail(dpopError("Could not open DPoP key storage.", request.error ?? undefined)), + ), + ); + request.addEventListener("upgradeneeded", () => { + if (!request.result.objectStoreNames.contains(DPOP_KEY_STORE_NAME)) { + request.result.createObjectStore(DPOP_KEY_STORE_NAME); + } + }); + request.addEventListener("success", () => resume(Effect.succeed(request.result))); + }); +} + +export function readStoredBrowserDpopKey(): Effect.Effect { + if (typeof indexedDB === "undefined") { + return Effect.succeed(null); + } + return Effect.acquireUseRelease( + openDpopDatabase(), + (database) => + Effect.callback((resume) => { + const request = database + .transaction(DPOP_KEY_STORE_NAME, "readonly") + .objectStore(DPOP_KEY_STORE_NAME) + .get(DPOP_KEY_ID); + request.addEventListener("error", () => + resume(Effect.fail(dpopError("Could not read DPoP key.", request.error ?? undefined))), + ); + request.addEventListener("success", () => + resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)), + ); + }), + (database) => Effect.sync(() => database.close()), + ); +} + +export function writeStoredBrowserDpopKey( + key: BrowserDpopKey, +): Effect.Effect { + if (typeof indexedDB === "undefined") { + return Effect.void; + } + return Effect.acquireUseRelease( + openDpopDatabase(), + (database) => + Effect.callback((resume) => { + const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite"); + transaction.addEventListener("error", () => + resume( + Effect.fail(dpopError("Could not write DPoP key.", transaction.error ?? undefined)), + ), + ); + transaction.addEventListener("complete", () => resume(Effect.void)); + transaction.objectStore(DPOP_KEY_STORE_NAME).put(key, DPOP_KEY_ID); + }), + (database) => Effect.sync(() => database.close()), + ); +} + +export const generateBrowserDpopKey: Effect.Effect = Effect.gen( + function* () { + const generated = yield* Effect.tryPromise({ + try: () => + crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]) as Promise, + catch: (cause) => dpopError("Could not generate DPoP proof key.", cause), + }); + const privateJwk = yield* Effect.tryPromise({ + try: () => crypto.subtle.exportKey("jwk", generated.privateKey), + catch: (cause) => dpopError("Could not export DPoP private key.", cause), + }); + const publicJwk = yield* Effect.tryPromise({ + try: () => crypto.subtle.exportKey("jwk", generated.publicKey), + catch: (cause) => dpopError("Could not export DPoP public key.", cause), + }).pipe( + Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)), + Effect.mapError((cause) => + cause instanceof BrowserDpopError + ? cause + : dpopError("Generated DPoP public key is invalid.", cause), + ), + ); + const privateKey = yield* Effect.tryPromise({ + try: () => + importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise, + catch: (cause) => dpopError("Could not import DPoP private key.", cause), + }); + return { + privateKey, + publicJwk, + thumbprint: computeDpopJwkThumbprint(publicJwk), + }; + }, +); + +export function createBrowserDpopProof(input: { + readonly method: string; + readonly url: string; + readonly accessToken?: string; + readonly proofKey: BrowserDpopKey; +}): Effect.Effect< + { readonly proof: string; readonly thumbprint: string }, + BrowserDpopError, + Crypto.Crypto +> { + return Effect.gen(function* () { + const normalizedUrl = yield* Effect.try({ + try: () => new URL(input.url), + catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause), + }); + normalizedUrl.search = ""; + normalizedUrl.hash = ""; + const jti = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4), + Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), + ); + const proof = yield* Effect.tryPromise({ + try: () => + new SignJWT({ + htm: input.method.toUpperCase(), + htu: normalizedUrl.toString(), + jti, + ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), + }) + .setProtectedHeader({ + typ: "dpop+jwt", + alg: "ES256", + jwk: input.proofKey.publicJwk, + }) + .setIssuedAt() + .sign(input.proofKey.privateKey), + catch: (cause) => dpopError("Could not sign DPoP proof.", cause), + }); + return { proof, thumbprint: input.proofKey.thumbprint }; + }); +} diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts new file mode 100644 index 00000000000..6b7143b1864 --- /dev/null +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -0,0 +1,903 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayWebClientId } from "@t3tools/contracts/relay"; +import { afterEach, beforeEach, vi } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient } from "effect/unstable/http"; +import { + managedRelayClientLayer, + ManagedRelayClient, + ManagedRelayDpopSigner, + remoteHttpClientLayer, +} from "@t3tools/client-runtime"; + +import type { SavedEnvironmentRecord } from "../environments/runtime"; +import { + connectManagedCloudEnvironment, + linkEnvironmentToCloud, + linkPrimaryEnvironmentToCloud, + listManagedCloudEnvironments, + normalizeRelayBaseUrl, + readPrimaryCloudLinkState, + unlinkPrimaryEnvironmentFromCloud, +} from "./linkEnvironment"; +import { + readPrimaryEnvironmentDescriptor, + readPrimaryEnvironmentTarget, + resolvePrimaryEnvironmentHttpUrl, +} from "../environments/primary"; + +const getSavedEnvironmentSecretMock = vi.fn(); +const relayClientInstallDialogHarness = vi.hoisted(() => ({ + requestConfirmation: vi.fn(), + reportProgress: vi.fn(), + finish: vi.fn(), +})); +const getRelayClientStatusMock = vi.fn(); +const installRelayClientMock = vi.fn(); +const environmentConnectionMock = { + client: { + cloud: { + getRelayClientStatus: getRelayClientStatusMock, + installRelayClient: installRelayClientMock, + }, + }, +}; + +const createProofMock = vi.fn( + (_input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => + Effect.succeed("web-dpop-proof"), +); +const testDpopSignerLayer = Layer.succeed( + ManagedRelayDpopSigner, + ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("web-thumbprint"), + createProof: (input) => createProofMock(input), + }), +); + +function cloudClientLayer() { + const httpClientLayer = remoteHttpClientLayer(globalThis.fetch); + return Layer.mergeAll( + httpClientLayer, + managedRelayClientLayer({ + relayUrl: "https://relay.example.test", + clientId: RelayWebClientId, + }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)), + ); +} + +const withCloudServices = ( + effect: Effect.Effect, +) => effect.pipe(Effect.provide(cloudClientLayer())); + +vi.mock("../localApi", () => ({ + ensureLocalApi: () => ({ + persistence: { + getSavedEnvironmentSecret: getSavedEnvironmentSecretMock, + }, + }), +})); + +vi.mock("./relayClientInstallDialog", () => ({ + requestRelayClientInstallConfirmation: relayClientInstallDialogHarness.requestConfirmation, + reportRelayClientInstallProgress: relayClientInstallDialogHarness.reportProgress, + finishRelayClientInstall: relayClientInstallDialogHarness.finish, +})); + +vi.mock("../environments/primary", () => ({ + readPrimaryEnvironmentDescriptor: vi.fn(() => null), + readPrimaryEnvironmentTarget: vi.fn(() => null), + resolvePrimaryEnvironmentHttpUrl: vi.fn((path: string) => `http://127.0.0.1:3000${path}`), +})); + +vi.mock("../environments/runtime", () => ({ + getPrimaryEnvironmentConnection: () => environmentConnectionMock, + readEnvironmentConnection: () => environmentConnectionMock, +})); + +const savedEnvironment: SavedEnvironmentRecord = { + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + httpBaseUrl: "http://127.0.0.1:3000", + wsBaseUrl: "ws://127.0.0.1:3000", + createdAt: "2026-05-25T00:00:00.000Z", + lastConnectedAt: null, +}; + +function validProof() { + return "signed-environment-link-jwt"; +} + +function validChallenge() { + return { + challenge: "link-challenge", + expiresAt: "2026-05-25T00:05:00.000Z", + }; +} + +function availableRelayClient() { + return { + status: "available", + executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", + source: "managed", + version: "2026.5.2", + }; +} + +function requestBodyText(body: BodyInit | null | undefined): string { + return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); +} + +describe("web cloud link environment client", () => { + afterEach(() => { + if ("window" in globalThis) { + Reflect.deleteProperty(window, "desktopBridge"); + } + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + createProofMock.mockClear(); + vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); + getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer"); + relayClientInstallDialogHarness.requestConfirmation.mockResolvedValue(true); + getRelayClientStatusMock.mockResolvedValue(availableRelayClient()); + installRelayClientMock.mockResolvedValue(availableRelayClient()); + vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue(null); + vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue(null); + vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( + (path: string) => `http://127.0.0.1:3000${path}`, + ); + }); + + it("normalizes configured relay base URLs before building relay requests", () => { + expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( + "https://relay.example.test", + ); + expect(normalizeRelayBaseUrl(" ")).toBeNull(); + }); + + it.effect( + "installs the relay client over environment RPC before requesting a cloud challenge", + () => + Effect.gen(function* () { + getRelayClientStatusMock.mockResolvedValue({ + status: "missing", + version: "2026.5.2", + }); + vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }); + vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ + source: "desktop-managed", + target: { + httpBaseUrl: "http://127.0.0.1:3000", + wsBaseUrl: "ws://127.0.0.1:3000", + }, + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(validChallenge())) + .mockResolvedValueOnce(Response.json({ malformed: true })); + vi.stubGlobal("fetch", fetchMock); + installRelayClientMock.mockImplementationOnce(async (onProgress) => { + onProgress({ type: "progress", stage: "downloading" }); + return availableRelayClient(); + }); + + yield* withCloudServices( + linkPrimaryEnvironmentToCloud({ + clerkToken: "clerk-token", + }), + ).pipe(Effect.flip); + + expect(relayClientInstallDialogHarness.requestConfirmation).toHaveBeenCalledWith( + "2026.5.2", + ); + expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); + expect(installRelayClientMock).toHaveBeenCalledOnce(); + expect(relayClientInstallDialogHarness.reportProgress).toHaveBeenCalledWith({ + type: "progress", + stage: "downloading", + }); + expect(relayClientInstallDialogHarness.finish).toHaveBeenCalledOnce(); + expect(installRelayClientMock.mock.invocationCallOrder[0]).toBeLessThan( + fetchMock.mock.invocationCallOrder[0]!, + ); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "https://relay.example.test/v1/client/environment-link-challenges", + ); + }), + ); + + it.effect("lists relay-managed environments for hosted and served web clients", () => + Effect.gen(function* () { + const fetchMock = vi.fn().mockResolvedValueOnce( + Response.json({ + environments: [ + { + environmentId: "env-1", + label: "Managed desktop", + endpoint: { + httpBaseUrl: "https://managed.example.test", + wsBaseUrl: "wss://managed.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }, + ], + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const environments = yield* withCloudServices( + listManagedCloudEnvironments({ clerkToken: "clerk-token" }), + ); + expect(environments).toHaveLength(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "https://relay.example.test/v1/environments", + ); + expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); + expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); + }), + ); + + it.effect("connects web clients to managed environments with a tunnel-only DPoP token", () => + Effect.gen(function* () { + const environment = { + environmentId: EnvironmentId.make("env-1"), + label: "Managed desktop", + endpoint: { + httpBaseUrl: "https://managed.example.test", + wsBaseUrl: "wss://managed.example.test", + providerKind: "cloudflare_tunnel" as const, + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + access_token: "relay-access-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "environment:connect", + }), + ) + .mockResolvedValueOnce( + Response.json({ + environmentId: "env-1", + endpoint: environment.endpoint, + credential: "environment-bootstrap", + expiresAt: "2026-05-25T00:05:00.000Z", + }), + ) + .mockResolvedValueOnce( + Response.json({ + environmentId: "env-1", + label: "Managed desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }), + ) + .mockResolvedValueOnce( + Response.json({ + access_token: "environment-access-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 3600, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const connection = yield* withCloudServices( + connectManagedCloudEnvironment({ clerkToken: "clerk-token", environment }), + ); + expect(connection).toMatchObject({ + environmentId: "env-1", + accessToken: "environment-access-token", + }); + + const tokenBody = requestBodyText(fetchMock.mock.calls[0]?.[1]?.body); + expect(new URLSearchParams(tokenBody).get("client_id")).toBe("t3-web"); + expect(new URLSearchParams(tokenBody).get("scope")).toBe("environment:connect"); + expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("DPoP relay-access-token"); + expect(fetchMock.mock.calls[1]?.[1]?.headers.dpop).toBe("web-dpop-proof"); + expect(createProofMock).toHaveBeenCalledWith({ + method: "POST", + url: "https://managed.example.test/oauth/token", + }); + }), + ); + + it.effect("rejects a stored managed connection for another relay origin", () => + Effect.gen(function* () { + const environment = { + environmentId: EnvironmentId.make("env-1"), + label: "Managed desktop", + endpoint: { + httpBaseUrl: "https://managed.example.test", + wsBaseUrl: "wss://managed.example.test", + providerKind: "cloudflare_tunnel" as const, + }, + linkedAt: "2026-05-25T00:00:00.000Z", + }; + + const error = yield* withCloudServices( + connectManagedCloudEnvironment({ + clerkToken: "clerk-token", + environment, + relayUrl: "https://old-relay.example.test", + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + message: "The saved environment is linked through a different configured relay.", + }); + }), + ); + + it.effect("rejects malformed local environment link proofs", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(Response.json(validChallenge())) + .mockResolvedValueOnce( + Response.json({ + payload: { + environmentId: "env-1", + }, + signature: "signature-1", + }), + ), + ); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + environment: savedEnvironment, + clerkToken: "clerk-token", + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "Could not obtain environment link proof.", + }); + }), + ); + + it.effect("preserves typed local environment failures while obtaining a link proof", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(Response.json(validChallenge())) + .mockResolvedValueOnce( + Response.json( + { + _tag: "EnvironmentHttpUnauthorizedError", + message: "Invalid environment bearer session.", + }, + { status: 401 }, + ), + ), + ); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + environment: savedEnvironment, + clerkToken: "clerk-token", + }), + ).pipe(Effect.flip); + expect(error._tag).toBe("CloudEnvironmentLinkError"); + expect(error.message).toBe( + "Could not obtain environment link proof: Invalid environment bearer session.", + ); + }), + ); + + it.effect("rejects malformed relay environment link responses", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(Response.json(validChallenge())) + .mockResolvedValueOnce(Response.json(validProof())) + .mockResolvedValueOnce( + Response.json({ + ok: true, + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test", + wsBaseUrl: "wss://desktop.example.test", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: null, + relayIssuer: "https://issuer.example.test", + cloudUserId: "user_123", + environmentCredential: "", + cloudMintPublicKey: "cloud-mint-public-key", + }), + ), + ); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + environment: savedEnvironment, + clerkToken: "clerk-token", + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "https://relay.example.test/v1/client/environment-links failed", + }); + }), + ); + + it.effect( + "links the primary local environment through the relay using the owner cookie session", + () => + Effect.gen(function* () { + vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }); + vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ + source: "desktop-managed", + target: { + httpBaseUrl: "http://127.0.0.1:3000", + wsBaseUrl: "ws://127.0.0.1:3000", + }, + }); + vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( + (path: string) => `http://127.0.0.1:3000${path}`, + ); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(validChallenge())) + .mockResolvedValueOnce(Response.json(validProof())) + .mockResolvedValueOnce( + Response.json({ + ok: true, + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test", + wsBaseUrl: "wss://desktop.example.test", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-id", + tunnelName: "tunnel-name", + }, + relayIssuer: "https://issuer.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: "cloud-mint-public-key", + }), + ) + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), + ); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + linkPrimaryEnvironmentToCloud({ + clerkToken: "clerk-token", + }), + ); + + expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "https://relay.example.test/v1/client/environment-link-challenges", + ); + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); + expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); + expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); + + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + "http://127.0.0.1:3000/api/cloud/link-proof", + ); + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + method: "POST", + credentials: "include", + headers: expect.objectContaining({ + "content-type": "application/json", + }), + }); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(requestBodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ + challenge: "link-challenge", + endpoint: { + httpBaseUrl: "http://127.0.0.1:3000", + wsBaseUrl: "ws://127.0.0.1:3000", + providerKind: "cloudflare_tunnel", + }, + origin: { + localHttpHost: "127.0.0.1", + localHttpPort: 3000, + }, + }); + + expect(String(fetchMock.mock.calls[2]?.[0])).toBe( + "https://relay.example.test/v1/client/environment-links", + ); + expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST"); + expect(fetchMock.mock.calls[2]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); + expect(fetchMock.mock.calls[2]?.[1]?.credentials).not.toBe("include"); + expect(fetchMock.mock.calls[2]?.[1]?.headers["content-type"]).toBe("application/json"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(requestBodyText(fetchMock.mock.calls[2]?.[1]?.body))).toMatchObject({ + proof: validProof(), + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }); + + expect(String(fetchMock.mock.calls[3]?.[0])).toBe( + "http://127.0.0.1:3000/api/cloud/relay-config", + ); + expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({ + method: "POST", + credentials: "include", + headers: expect.objectContaining({ + "content-type": "application/json", + }), + }); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ + relayUrl: "https://relay.example.test", + relayIssuer: "https://issuer.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: "cloud-mint-public-key", + endpointRuntime: { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-id", + tunnelName: "tunnel-name", + }, + }); + }), + ); + + it.effect("reads the primary local cloud link state with the owner cookie session", () => + Effect.gen(function* () { + vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }); + vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ + source: "desktop-managed", + target: { + httpBaseUrl: "http://127.0.0.1:3000", + wsBaseUrl: "ws://127.0.0.1:3000", + }, + }); + const fetchMock = vi.fn().mockResolvedValueOnce( + Response.json({ + linked: true, + cloudUserId: "user_123", + relayUrl: "https://relay.example.test", + relayIssuer: "https://issuer.example.test", + publishAgentActivity: false, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const state = yield* withCloudServices(readPrimaryCloudLinkState()); + expect(state).toEqual({ + linked: true, + cloudUserId: "user_123", + relayUrl: "https://relay.example.test", + relayIssuer: "https://issuer.example.test", + publishAgentActivity: false, + }); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:3000/api/cloud/link-state", + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "GET", + credentials: "include", + }); + }), + ); + + it.effect("clears local relay credentials before revoking the primary cloud link", () => + Effect.gen(function* () { + vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }); + vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ + source: "desktop-managed", + target: { + httpBaseUrl: "http://127.0.0.1:3000", + wsBaseUrl: "ws://127.0.0.1:3000", + }, + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), + ) + .mockResolvedValueOnce(Response.json({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + unlinkPrimaryEnvironmentFromCloud({ + clerkToken: "clerk-token", + }), + ); + + expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink"); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + credentials: "include", + }); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + "https://relay.example.test/v1/client/environment-links/env-1", + ); + expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE"); + expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); + }), + ); + + it.effect("still clears local relay credentials when relay revocation fails", () => + Effect.gen(function* () { + vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ + environmentId: EnvironmentId.make("env-1"), + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }); + vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ + source: "desktop-managed", + target: { + httpBaseUrl: "http://127.0.0.1:3000", + wsBaseUrl: "ws://127.0.0.1:3000", + }, + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), + ) + .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + unlinkPrimaryEnvironmentFromCloud({ + clerkToken: "clerk-token", + }), + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink"); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + credentials: "include", + }); + }), + ); + + it.effect("rejects primary environment linking when the local environment is not ready", () => + Effect.gen(function* () { + vi.stubGlobal("fetch", vi.fn()); + + const error = yield* withCloudServices( + linkPrimaryEnvironmentToCloud({ + clerkToken: "clerk-token", + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "Local environment is not ready yet.", + }); + expect(fetch).not.toHaveBeenCalled(); + }), + ); + + it.effect("preserves relay transport failures while linking environments", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(Response.json(validChallenge())) + .mockResolvedValueOnce(Response.json(validProof())) + .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })), + ); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + environment: savedEnvironment, + clerkToken: "clerk-token", + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "https://relay.example.test/v1/client/environment-links failed", + }); + }), + ); + + it.effect("preserves typed relay error bodies while linking environments", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(Response.json(validChallenge())) + .mockResolvedValueOnce(Response.json(validProof())) + .mockResolvedValueOnce( + Response.json( + { + _tag: "RelayEnvironmentLinkProofInvalidError", + code: "environment_link_proof_invalid", + reason: "origin_not_allowed", + traceId: "trace-test", + }, + { status: 400 }, + ), + ), + ); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + environment: savedEnvironment, + clerkToken: "clerk-token", + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: + "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", + }); + }), + ); + + it.effect("rejects relay credentials for a different environment", () => + Effect.gen(function* () { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(validChallenge())) + .mockResolvedValueOnce(Response.json(validProof())) + .mockResolvedValueOnce( + Response.json({ + ok: true, + environmentId: "env-2", + endpoint: { + httpBaseUrl: "https://desktop.example.test", + wsBaseUrl: "wss://desktop.example.test", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: null, + relayIssuer: "https://issuer.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: "cloud-mint-public-key", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + environment: savedEnvironment, + clerkToken: "clerk-token", + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "Relay returned credentials for a different environment.", + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }), + ); + + it.effect("rejects relay credentials for a different managed endpoint provider", () => + Effect.gen(function* () { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(validChallenge())) + .mockResolvedValueOnce(Response.json(validProof())) + .mockResolvedValueOnce( + Response.json({ + ok: true, + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test", + wsBaseUrl: "wss://desktop.example.test", + providerKind: "manual", + }, + endpointRuntime: null, + relayIssuer: "https://issuer.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: "cloud-mint-public-key", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + environment: savedEnvironment, + clerkToken: "clerk-token", + }), + ).pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkError", + message: "Relay returned credentials for a different endpoint provider.", + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }), + ); + + it.effect("passes the relay issuer from the link response into local relay config", () => + Effect.gen(function* () { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json(validChallenge())) + .mockResolvedValueOnce(Response.json(validProof())) + .mockResolvedValueOnce( + Response.json({ + ok: true, + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test", + wsBaseUrl: "wss://desktop.example.test", + providerKind: "cloudflare_tunnel", + }, + endpointRuntime: null, + relayIssuer: "https://issuer.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: "cloud-mint-public-key", + }), + ) + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), + ); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + linkEnvironmentToCloud({ + environment: savedEnvironment, + clerkToken: "clerk-token", + }), + ); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ + relayUrl: "https://relay.example.test", + relayIssuer: "https://issuer.example.test", + cloudUserId: "user_123", + }); + }), + ); +}); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts new file mode 100644 index 00000000000..f68849e2765 --- /dev/null +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -0,0 +1,715 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { + EnvironmentCloudEndpointUnavailableError, + type EnvironmentCloudLinkStateResult, + EnvironmentHttpBadRequestError, + EnvironmentHttpConflictError, + EnvironmentHttpForbiddenError, + EnvironmentHttpInternalServerError, + EnvironmentHttpUnauthorizedError, + EnvironmentId, +} from "@t3tools/contracts"; +import { + RelayEnvironmentConnectScope, + type RelayClientDeviceRecord, + type RelayEnvironmentLinkResponse, + RelayProtectedError, + type RelayClientEnvironmentRecord, + type RelayProtectedError as RelayProtectedErrorType, + type RelayManagedEndpointProviderKind, +} from "@t3tools/contracts/relay"; +import { + exchangeRemoteDpopAccessToken, + fetchRemoteEnvironmentDescriptor, + makeEnvironmentHttpApiClient, + ManagedRelayClient, + ManagedRelayDpopSigner, + type WsRpcClient, +} from "@t3tools/client-runtime"; + +import { ensureLocalApi } from "../localApi"; +import { + getPrimaryEnvironmentConnection, + readEnvironmentConnection, + type SavedEnvironmentRecord, +} from "../environments/runtime"; +import { + readPrimaryEnvironmentDescriptor, + readPrimaryEnvironmentTarget, + resolvePrimaryEnvironmentHttpUrl, +} from "../environments/primary"; +import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit"; +import { resolveCloudPublicConfig } from "./publicConfig"; +import { + finishRelayClientInstall, + reportRelayClientInstallProgress, + requestRelayClientInstallConfirmation, +} from "./relayClientInstallDialog"; + +export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + return trimmed.replace(/\/+$/g, ""); +} + +function relayUrl(): string | null { + return resolveCloudPublicConfig().relayUrl; +} + +export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const relayClientRpcError = (message: string) => (cause: unknown) => + new CloudEnvironmentLinkError({ + message, + cause, + }); + +function ensureRelayClientAvailable( + client: WsRpcClient, +): Effect.Effect { + return Effect.gen(function* () { + const status = yield* Effect.tryPromise({ + try: () => client.cloud.getRelayClientStatus(), + catch: relayClientRpcError("Could not check relay client availability."), + }); + if (status.status === "available") return; + if (status.status === "unsupported") { + return yield* new CloudEnvironmentLinkError({ + message: `T3 Code cannot install the relay client automatically on ${status.platform}-${status.arch}.`, + }); + } + + const confirmed = yield* Effect.tryPromise({ + try: () => requestRelayClientInstallConfirmation(status.version), + catch: relayClientRpcError("Could not confirm relay client installation."), + }); + if (!confirmed) { + return yield* new CloudEnvironmentLinkError({ + message: "Relay client installation was cancelled.", + }); + } + + const installed = yield* Effect.tryPromise({ + try: () => client.cloud.installRelayClient(reportRelayClientInstallProgress), + catch: relayClientRpcError("Could not install the relay client."), + }).pipe(Effect.ensuring(Effect.sync(finishRelayClientInstall))); + if (installed.status !== "available") { + return yield* new CloudEnvironmentLinkError({ + message: + installed.status === "unsupported" + ? `T3 Code cannot install the relay client automatically on ${installed.platform}-${installed.arch}.` + : "The relay client is still unavailable after installation.", + }); + } + }); +} + +const isRelayProtectedError = Schema.is(RelayProtectedError); +const isEnvironmentCloudApiError = Schema.is( + Schema.Union([ + EnvironmentHttpBadRequestError, + EnvironmentHttpUnauthorizedError, + EnvironmentHttpForbiddenError, + EnvironmentHttpConflictError, + EnvironmentHttpInternalServerError, + EnvironmentCloudEndpointUnavailableError, + ]), +); + +function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { + switch (error._tag) { + case "RelayAuthInvalidError": + switch (error.reason) { + case "missing_bearer": + case "invalid_bearer": + return "Relay rejected the cloud session token."; + case "invalid_dpop": + return "Relay rejected the DPoP proof."; + case "not_authorized": + return "Relay rejected the authenticated request."; + } + case "RelayEnvironmentLinkProofExpiredError": + return "Relay rejected an expired environment link proof."; + case "RelayEnvironmentLinkProofInvalidError": + return `Relay rejected the environment link proof (${error.reason}).`; + case "RelayEnvironmentConnectNotAuthorizedError": + return "Relay rejected the environment connection request."; + case "RelayEnvironmentEndpointUnavailableError": + return `Relay could not reach the environment endpoint (${error.reason}).`; + case "RelayEnvironmentEndpointTimedOutError": + return "Relay timed out while contacting the environment endpoint."; + case "RelayEnvironmentLinkFailedError": + return `Relay could not link the environment (${error.reason}).`; + case "RelayEnvironmentLinkUnavailableError": + return `Relay cannot provision the managed endpoint (${error.reason}).`; + case "RelayAgentActivityPublishProofExpiredError": + return "Relay rejected an expired agent activity publish proof."; + case "RelayAgentActivityPublishProofInvalidError": + return `Relay rejected the agent activity publish proof (${error.reason}).`; + case "RelayInternalError": + return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; + } +} + +function decodedRelayClientError(message: string) { + return (cause: unknown) => { + const relayError = findRelayProtectedError(cause); + const detail = relayError ? relayProtectedErrorMessage(relayError) : null; + return new CloudEnvironmentLinkError({ + message: detail ? `${message}: ${detail}` : message, + cause, + }); + }; +} + +function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { + if (isRelayProtectedError(cause)) { + return cause; + } + if (typeof cause !== "object" || cause === null) { + return null; + } + return "cause" in cause ? findRelayProtectedError(cause.cause) : null; +} + +function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { + if (isEnvironmentCloudApiError(cause)) { + return cause; + } + if (typeof cause !== "object" || cause === null) { + return null; + } + return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; +} + +const environmentApiError = (message: string) => (cause: unknown) => { + const environmentError = findEnvironmentCloudApiError(cause); + return new CloudEnvironmentLinkError({ + message: environmentError + ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` + : message, + cause, + }); +}; + +function endpointOrigin(httpBaseUrl: string) { + const url = new URL(httpBaseUrl); + return { + localHttpHost: "127.0.0.1", + localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), + }; +} + +const MANAGED_ENDPOINT_PROVIDER_KIND = + "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; + +function ensureLinkedEnvironmentMatches(input: { + readonly expectedEnvironmentId: string; + readonly expectedProviderKind: RelayManagedEndpointProviderKind; + readonly link: RelayEnvironmentLinkResponse; +}): Effect.Effect { + if (input.link.environmentId !== input.expectedEnvironmentId) { + return new CloudEnvironmentLinkError({ + message: "Relay returned credentials for a different environment.", + }); + } + if (input.link.endpoint.providerKind !== input.expectedProviderKind) { + return new CloudEnvironmentLinkError({ + message: "Relay returned credentials for a different endpoint provider.", + }); + } + return Effect.void; +} + +export interface CloudLinkTarget { + readonly environmentId: string; + readonly label: string; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; +} + +export type CloudLinkState = EnvironmentCloudLinkStateResult; + +export interface CloudManagedConnection { + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly label: string; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly relayUrl: string; + readonly accessToken: string; +} + +export function collectCloudLinkTargets(input: { + readonly primary: CloudLinkTarget | null; + readonly saved: ReadonlyArray; +}): ReadonlyArray { + const byId = new Map(); + if (input.primary) { + byId.set(input.primary.environmentId, input.primary); + } + for (const environment of input.saved) { + if (!byId.has(environment.environmentId)) { + byId.set(environment.environmentId, environment); + } + } + return [...byId.values()]; +} + +export function readPrimaryCloudLinkTarget(): CloudLinkTarget | null { + const descriptor = readPrimaryEnvironmentDescriptor(); + const target = readPrimaryEnvironmentTarget(); + if (!descriptor || !target) { + return null; + } + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: target.target.httpBaseUrl, + wsBaseUrl: target.target.wsBaseUrl, + }; +} + +export function listManagedCloudEnvironments(input: { + readonly clerkToken: string; +}): Effect.Effect< + ReadonlyArray, + CloudEnvironmentLinkError, + ManagedRelayClient +> { + return Effect.gen(function* () { + const configuredRelayUrl = relayUrl(); + if (!configuredRelayUrl) { + return yield* new CloudEnvironmentLinkError({ + message: "T3CODE_RELAY_URL is not configured.", + }); + } + const relayClient = yield* ManagedRelayClient; + return yield* relayClient + .listEnvironments({ + clerkToken: input.clerkToken, + }) + .pipe( + Effect.mapError( + (cause) => + new CloudEnvironmentLinkError({ + message: "Could not list relay-managed environments.", + cause, + }), + ), + ); + }); +} + +export function listCloudDevices(input: { + readonly clerkToken: string; +}): Effect.Effect< + ReadonlyArray, + CloudEnvironmentLinkError, + ManagedRelayClient +> { + return Effect.gen(function* () { + if (!relayUrl()) { + return yield* new CloudEnvironmentLinkError({ + message: "T3CODE_RELAY_URL is not configured.", + }); + } + const relayClient = yield* ManagedRelayClient; + return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe( + Effect.mapError( + (cause) => + new CloudEnvironmentLinkError({ + message: "Could not list cloud devices.", + cause, + }), + ), + ); + }); +} + +export function connectManagedCloudEnvironment(input: { + readonly clerkToken: string; + readonly environment: RelayClientEnvironmentRecord; + readonly relayUrl?: string; +}): Effect.Effect< + CloudManagedConnection, + CloudEnvironmentLinkError, + HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner +> { + return Effect.gen(function* () { + const configuredRelayUrl = relayUrl(); + if (!configuredRelayUrl) { + return yield* new CloudEnvironmentLinkError({ + message: "T3CODE_RELAY_URL is not configured.", + }); + } + const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl); + if (persistedRelayUrl && persistedRelayUrl !== configuredRelayUrl) { + return yield* new CloudEnvironmentLinkError({ + message: "The saved environment is linked through a different configured relay.", + }); + } + const relayClient = yield* ManagedRelayClient; + const connected = yield* relayClient + .connectEnvironment({ + clerkToken: input.clerkToken, + scopes: [RelayEnvironmentConnectScope], + environmentId: input.environment.environmentId, + }) + .pipe( + Effect.mapError( + (cause) => + new CloudEnvironmentLinkError({ + message: "Could not connect to relay-managed environment.", + cause, + }), + ), + ); + if (connected.environmentId !== input.environment.environmentId) { + return yield* new CloudEnvironmentLinkError({ + message: "Relay returned credentials for a different environment.", + }); + } + if ( + connected.endpoint.httpBaseUrl !== input.environment.endpoint.httpBaseUrl || + connected.endpoint.wsBaseUrl !== input.environment.endpoint.wsBaseUrl || + connected.endpoint.providerKind !== input.environment.endpoint.providerKind + ) { + return yield* new CloudEnvironmentLinkError({ + message: "Relay returned credentials for a different endpoint.", + }); + } + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: connected.endpoint.httpBaseUrl, + }).pipe( + Effect.mapError( + (cause) => + new CloudEnvironmentLinkError({ + message: "Could not read connected environment descriptor.", + cause, + }), + ), + ); + if (descriptor.environmentId !== connected.environmentId) { + return yield* new CloudEnvironmentLinkError({ + message: "Connected endpoint does not match the selected environment.", + }); + } + const signer = yield* ManagedRelayDpopSigner; + const bootstrapProof = yield* signer + .createProof({ + method: "POST", + url: new URL("/oauth/token", connected.endpoint.httpBaseUrl).toString(), + }) + .pipe( + Effect.mapError( + (cause) => + new CloudEnvironmentLinkError({ + message: "Could not create environment DPoP proof.", + cause, + }), + ), + ); + const session = yield* exchangeRemoteDpopAccessToken({ + httpBaseUrl: connected.endpoint.httpBaseUrl, + credential: connected.credential, + dpopProof: bootstrapProof, + }).pipe( + Effect.mapError( + (cause) => + new CloudEnvironmentLinkError({ + message: "Could not authorize managed environment.", + cause, + }), + ), + ); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: connected.endpoint.httpBaseUrl, + wsBaseUrl: connected.endpoint.wsBaseUrl, + relayUrl: configuredRelayUrl, + accessToken: session.access_token, + }; + }); +} + +export function readPrimaryCloudLinkState(): Effect.Effect< + CloudLinkState | null, + CloudEnvironmentLinkError, + HttpClient.HttpClient +> { + return Effect.gen(function* () { + if (!readPrimaryCloudLinkTarget()) { + return null; + } + const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + return yield* client.cloud + .linkState({ headers: {} }) + .pipe( + withPrimaryEnvironmentRequestInit, + Effect.mapError(environmentApiError("Could not read environment cloud link state.")), + ); + }); +} + +export function updatePrimaryCloudPreferences(input: { + readonly publishAgentActivity: boolean; +}): Effect.Effect { + return Effect.gen(function* () { + const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + return yield* client.cloud + .preferences({ + headers: {}, + payload: input, + }) + .pipe( + withPrimaryEnvironmentRequestInit, + Effect.mapError(environmentApiError("Could not update environment cloud preferences.")), + ); + }); +} + +export function unlinkPrimaryEnvironmentFromCloud(input: { + readonly clerkToken: string | null; +}): Effect.Effect { + return Effect.gen(function* () { + const target = readPrimaryCloudLinkTarget(); + if (!target) { + return yield* new CloudEnvironmentLinkError({ + message: "Local environment is not ready yet.", + }); + } + const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + yield* client.cloud + .unlink({ headers: {} }) + .pipe( + withPrimaryEnvironmentRequestInit, + Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")), + ); + + const configuredRelayUrl = relayUrl(); + if (configuredRelayUrl && input.clerkToken) { + const relayClient = yield* ManagedRelayClient; + yield* relayClient + .unlinkEnvironment({ + clerkToken: input.clerkToken, + environmentId: EnvironmentId.make(target.environmentId), + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("Could not revoke cloud environment link after local unlink.", { + cause, + }), + ), + ); + } + }); +} + +export function linkEnvironmentToCloud(input: { + readonly environment: SavedEnvironmentRecord; + readonly clerkToken: string; +}): Effect.Effect { + return Effect.gen(function* () { + const configuredRelayUrl = relayUrl(); + if (!configuredRelayUrl) { + return yield* new CloudEnvironmentLinkError({ + message: "T3CODE_RELAY_URL is not configured.", + }); + } + const relayClient = yield* ManagedRelayClient; + const bearerToken = yield* Effect.tryPromise({ + try: () => + ensureLocalApi().persistence.getSavedEnvironmentSecret(input.environment.environmentId), + catch: (cause) => + new CloudEnvironmentLinkError({ + message: `Could not read saved bearer token for ${input.environment.label}.`, + cause, + }), + }); + if (!bearerToken) { + return yield* new CloudEnvironmentLinkError({ + message: `No saved bearer token for ${input.environment.label}.`, + }); + } + + const connection = readEnvironmentConnection(input.environment.environmentId); + if (!connection) { + return yield* new CloudEnvironmentLinkError({ + message: `${input.environment.label} is not connected.`, + }); + } + yield* ensureRelayClientAvailable(connection.client); + + const environmentClient = yield* makeEnvironmentHttpApiClient(input.environment.httpBaseUrl); + const headers = { authorization: `Bearer ${bearerToken}` }; + + const challenge = yield* relayClient + .createEnvironmentLinkChallenge({ + clerkToken: input.clerkToken, + payload: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + }) + .pipe( + Effect.mapError( + decodedRelayClientError( + `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, + ), + ), + ); + const proof = yield* environmentClient.cloud + .linkProof({ + headers, + payload: { + challenge: challenge.challenge, + relayIssuer: configuredRelayUrl, + endpoint: { + httpBaseUrl: input.environment.httpBaseUrl, + wsBaseUrl: input.environment.wsBaseUrl, + providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, + }, + origin: endpointOrigin(input.environment.httpBaseUrl), + }, + }) + .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); + const link = yield* relayClient + .linkEnvironment({ + clerkToken: input.clerkToken, + payload: { + proof, + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + }) + .pipe( + Effect.mapError( + decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), + ), + ); + yield* ensureLinkedEnvironmentMatches({ + expectedEnvironmentId: input.environment.environmentId, + expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, + link, + }); + + yield* environmentClient.cloud + .relayConfig({ + headers, + payload: { + relayUrl: configuredRelayUrl, + relayIssuer: link.relayIssuer, + cloudUserId: link.cloudUserId, + environmentCredential: link.environmentCredential, + cloudMintPublicKey: link.cloudMintPublicKey, + endpointRuntime: link.endpointRuntime, + }, + }) + .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); + }); +} + +export function linkPrimaryEnvironmentToCloud(input: { + readonly clerkToken: string; +}): Effect.Effect { + return Effect.gen(function* () { + const configuredRelayUrl = relayUrl(); + if (!configuredRelayUrl) { + return yield* new CloudEnvironmentLinkError({ + message: "T3CODE_RELAY_URL is not configured.", + }); + } + const relayClient = yield* ManagedRelayClient; + const target = readPrimaryCloudLinkTarget(); + if (!target) { + return yield* new CloudEnvironmentLinkError({ + message: "Local environment is not ready yet.", + }); + } + const environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl); + yield* ensureRelayClientAvailable(getPrimaryEnvironmentConnection().client); + + const challenge = yield* relayClient + .createEnvironmentLinkChallenge({ + clerkToken: input.clerkToken, + payload: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + }) + .pipe( + Effect.mapError( + decodedRelayClientError( + `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, + ), + ), + ); + const proof = yield* environmentClient.cloud + .linkProof({ + headers: {}, + payload: { + challenge: challenge.challenge, + relayIssuer: configuredRelayUrl, + endpoint: { + httpBaseUrl: target.httpBaseUrl, + wsBaseUrl: target.wsBaseUrl, + providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, + }, + origin: endpointOrigin(target.httpBaseUrl), + }, + }) + .pipe( + withPrimaryEnvironmentRequestInit, + Effect.mapError(environmentApiError("Could not obtain environment link proof.")), + ); + const link = yield* relayClient + .linkEnvironment({ + clerkToken: input.clerkToken, + payload: { + proof, + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + }) + .pipe( + Effect.mapError( + decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), + ), + ); + yield* ensureLinkedEnvironmentMatches({ + expectedEnvironmentId: target.environmentId, + expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, + link, + }); + + yield* environmentClient.cloud + .relayConfig({ + headers: {}, + payload: { + relayUrl: configuredRelayUrl, + relayIssuer: link.relayIssuer, + cloudUserId: link.cloudUserId, + environmentCredential: link.environmentCredential, + cloudMintPublicKey: link.cloudMintPublicKey, + endpointRuntime: link.endpointRuntime, + }, + }) + .pipe( + withPrimaryEnvironmentRequestInit, + Effect.mapError(environmentApiError("Could not configure environment relay access.")), + ); + }); +} diff --git a/apps/web/src/cloud/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx new file mode 100644 index 00000000000..b00c445f08d --- /dev/null +++ b/apps/web/src/cloud/managedAuth.tsx @@ -0,0 +1,35 @@ +import { useAuth } from "@clerk/react"; +import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; +import { useEffect, type ReactNode } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { resolveRelayClerkTokenOptions } from "./publicConfig"; + +let relayTokenProvider: (() => Promise) | null = null; + +export async function readManagedRelayClerkToken(): Promise { + return relayTokenProvider?.() ?? null; +} + +export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) { + const { getToken, isSignedIn, userId } = useAuth(); + + useEffect(() => { + relayTokenProvider = isSignedIn ? () => getToken(resolveRelayClerkTokenOptions()) : null; + setManagedRelaySession( + appAtomRegistry, + isSignedIn && userId + ? createManagedRelaySession({ + accountId: userId, + readClerkToken: () => getToken(resolveRelayClerkTokenOptions()), + }) + : null, + ); + return () => { + relayTokenProvider = null; + setManagedRelaySession(appAtomRegistry, null); + }; + }, [getToken, isSignedIn, userId]); + + return children; +} diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts new file mode 100644 index 00000000000..f34ad2f9c99 --- /dev/null +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -0,0 +1,62 @@ +import { + managedRelayClientLayer, + ManagedRelayDpopSigner, + ManagedRelayDpopSignerError, +} from "@t3tools/client-runtime"; +import { RelayWebClientId } from "@t3tools/contracts/relay"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; + +import { + createBrowserDpopProof, + generateBrowserDpopKey, + readStoredBrowserDpopKey, + writeStoredBrowserDpopKey, + type BrowserDpopKey, +} from "./dpop"; + +export const webRelayDpopSignerLayer = Layer.effect( + ManagedRelayDpopSigner, + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const keyLoadSemaphore = yield* Semaphore.make(1); + let loadedKey: BrowserDpopKey | null = null; + const loadOrCreateBrowserDpopKey = keyLoadSemaphore.withPermit( + Effect.gen(function* () { + if (loadedKey) { + return loadedKey; + } + const stored = yield* readStoredBrowserDpopKey(); + if (stored) { + loadedKey = stored; + return stored; + } + const generated = yield* generateBrowserDpopKey; + yield* writeStoredBrowserDpopKey(generated); + loadedKey = generated; + return generated; + }), + ); + const signerError = (cause: unknown) => new ManagedRelayDpopSignerError({ cause }); + return ManagedRelayDpopSigner.of({ + thumbprint: loadOrCreateBrowserDpopKey.pipe( + Effect.map((proofKey) => proofKey.thumbprint), + Effect.mapError(signerError), + ), + createProof: (input) => + loadOrCreateBrowserDpopKey.pipe( + Effect.flatMap((proofKey) => createBrowserDpopProof({ ...input, proofKey })), + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((proof) => proof.proof), + Effect.mapError(signerError), + ), + }); + }), +); + +export const webManagedRelayClientLayer = (relayUrl: string) => + managedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( + Layer.provideMerge(webRelayDpopSignerLayer), + ); diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts new file mode 100644 index 00000000000..a31ee9e16f3 --- /dev/null +++ b/apps/web/src/cloud/managedRelayState.ts @@ -0,0 +1,83 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + createManagedRelayQueryManager, + ManagedRelayClient, + managedRelaySessionAtom, + readManagedRelaySnapshotState, +} from "@t3tools/client-runtime"; +import type { + RelayClientDeviceRecord, + RelayClientEnvironmentRecord, +} from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback } from "react"; + +import { webRuntime } from "../lib/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; + +const managedRelayAtomRuntime = Atom.runtime( + Layer.effect( + ManagedRelayClient, + webRuntime.contextEffect.pipe( + Effect.map((context) => Context.get(context, ManagedRelayClient)), + ), + ), +); + +export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); + +const EMPTY_ENVIRONMENTS_ATOM = Atom.make( + AsyncResult.success>([]), +).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null")); + +const EMPTY_DEVICES_ATOM = Atom.make( + AsyncResult.success>([]), +).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:devices:null")); + +export function useManagedRelayEnvironments() { + const session = useAtomValue(managedRelaySessionAtom); + const accountId = session?.accountId ?? null; + const atom = accountId + ? managedRelayQueryManager.environmentsAtom(accountId) + : EMPTY_ENVIRONMENTS_ATOM; + const result = useAtomValue(atom); + const refresh = useCallback(() => { + if (accountId) { + managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); + } + }, [accountId]); + + return { + ...readManagedRelaySnapshotState(result), + accountId, + refresh, + }; +} + +export function useManagedRelayDevices() { + const session = useAtomValue(managedRelaySessionAtom); + const accountId = session?.accountId ?? null; + const atom = accountId ? managedRelayQueryManager.devicesAtom(accountId) : EMPTY_DEVICES_ATOM; + const result = useAtomValue(atom); + const refresh = useCallback(() => { + if (accountId) { + managedRelayQueryManager.refreshDevices(appAtomRegistry, accountId); + } + }, [accountId]); + + return { + ...readManagedRelaySnapshotState(result), + accountId, + refresh, + }; +} + +export function refreshManagedRelayEnvironments(): void { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session) { + managedRelayQueryManager.refreshEnvironments(appAtomRegistry, session.accountId); + } +} diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts new file mode 100644 index 00000000000..ecc2297595d --- /dev/null +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -0,0 +1,67 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentCloudLinkStateResult, EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { HttpClient } from "effect/unstable/http"; +import { useCallback } from "react"; + +import { usePrimaryEnvironmentId } from "../environments/primary"; +import { webRuntime } from "../lib/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { readPrimaryCloudLinkState } from "./linkEnvironment"; + +const primaryCloudLinkAtomRuntime = Atom.runtime( + Layer.effect( + HttpClient.HttpClient, + webRuntime.contextEffect.pipe( + Effect.map((context) => Context.get(context, HttpClient.HttpClient)), + ), + ), +); + +const primaryCloudLinkStateAtom = Atom.family((environmentId: EnvironmentId) => + primaryCloudLinkAtomRuntime + .atom(readPrimaryCloudLinkState()) + .pipe( + Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`primary-cloud-link:${environmentId}`), + ), +); + +const EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM = Atom.make( + AsyncResult.success(null), +).pipe(Atom.keepAlive, Atom.withLabel("primary-cloud-link:null")); + +export function refreshPrimaryCloudLinkState(environmentId: EnvironmentId | null): void { + if (environmentId) { + appAtomRegistry.refresh(primaryCloudLinkStateAtom(environmentId)); + } +} + +export function usePrimaryCloudLinkState() { + const environmentId = usePrimaryEnvironmentId(); + const atom = environmentId + ? primaryCloudLinkStateAtom(environmentId) + : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; + const result = useAtomValue(atom); + const refresh = useCallback(() => { + refreshPrimaryCloudLinkState(environmentId); + }, [environmentId]); + let error: string | null = null; + if (result._tag === "Failure") { + const cause = Cause.squash(result.cause); + error = cause instanceof Error ? cause.message : "Could not read T3 Cloud link state."; + } + + return { + data: Option.getOrNull(AsyncResult.value(result)), + error, + isPending: result.waiting, + refresh, + }; +} diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts new file mode 100644 index 00000000000..a19814beb3a --- /dev/null +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { hasCloudPublicConfig } from "./publicConfig.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("hasCloudPublicConfig", () => { + it("requires both public cloud values", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", ""); + vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", ""); + vi.stubEnv("VITE_T3CODE_RELAY_URL", ""); + expect(hasCloudPublicConfig()).toBe(false); + + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example"); + expect(hasCloudPublicConfig()).toBe(false); + + vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); + expect(hasCloudPublicConfig()).toBe(false); + + vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); + expect(hasCloudPublicConfig()).toBe(true); + }); + + it("rejects an insecure relay URL", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example"); + vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); + vi.stubEnv("VITE_T3CODE_RELAY_URL", "http://relay.example.test"); + + expect(hasCloudPublicConfig()).toBe(false); + }); +}); diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts new file mode 100644 index 00000000000..291f1830ca3 --- /dev/null +++ b/apps/web/src/cloud/publicConfig.ts @@ -0,0 +1,37 @@ +import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; + +export interface CloudPublicConfig { + readonly clerkPublishableKey: string | null; + readonly clerkJwtTemplate: string | null; + readonly relayUrl: string | null; +} + +function trimNonEmpty(value: string | undefined): string | null { + return value?.trim() || null; +} + +export function resolveCloudPublicConfig(): CloudPublicConfig { + return { + clerkPublishableKey: trimNonEmpty( + import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined, + ), + clerkJwtTemplate: trimNonEmpty(import.meta.env.VITE_CLERK_JWT_TEMPLATE as string | undefined), + relayUrl: normalizeSecureRelayUrl( + (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "", + ), + }; +} + +export function hasCloudPublicConfig(): boolean { + const config = resolveCloudPublicConfig(); + return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); +} + +export function resolveRelayClerkTokenOptions() { + const { clerkJwtTemplate } = resolveCloudPublicConfig(); + if (!clerkJwtTemplate) { + throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); + } + return relayClerkTokenOptions(clerkJwtTemplate); +} diff --git a/apps/web/src/cloud/relayClientInstallDialog.test.ts b/apps/web/src/cloud/relayClientInstallDialog.test.ts new file mode 100644 index 00000000000..85f3cbe57b6 --- /dev/null +++ b/apps/web/src/cloud/relayClientInstallDialog.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + completeRelayClientInstallDialogClose, + finishRelayClientInstall, + readRelayClientInstallDialogState, + reportRelayClientInstallProgress, + requestRelayClientInstallConfirmation, + resetRelayClientInstallDialogForTests, + respondToRelayClientInstallConfirmation, +} from "./relayClientInstallDialog"; + +describe("relay client install dialog coordinator", () => { + beforeEach(() => { + resetRelayClientInstallDialogForTests(); + }); + + it("moves a confirmed installation through streamed progress stages", async () => { + const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); + expect(readRelayClientInstallDialogState()).toEqual({ + status: "confirming", + version: "2026.5.2", + }); + + respondToRelayClientInstallConfirmation(true); + await expect(confirmation).resolves.toBe(true); + expect(readRelayClientInstallDialogState()).toEqual({ + status: "installing", + version: "2026.5.2", + stage: "checking", + }); + + reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); + expect(readRelayClientInstallDialogState()).toEqual({ + status: "installing", + version: "2026.5.2", + stage: "downloading", + }); + + finishRelayClientInstall(); + expect(readRelayClientInstallDialogState()).toEqual({ + status: "closing", + view: { + status: "installing", + version: "2026.5.2", + stage: "downloading", + }, + }); + + completeRelayClientInstallDialogClose(); + expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); + }); + + it("returns to idle when installation is declined", async () => { + const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); + respondToRelayClientInstallConfirmation(false); + + await expect(confirmation).resolves.toBe(false); + expect(readRelayClientInstallDialogState()).toEqual({ + status: "closing", + view: { + status: "confirming", + version: "2026.5.2", + }, + }); + + completeRelayClientInstallDialogClose(); + expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); + }); +}); diff --git a/apps/web/src/cloud/relayClientInstallDialog.ts b/apps/web/src/cloud/relayClientInstallDialog.ts new file mode 100644 index 00000000000..908890ad1f5 --- /dev/null +++ b/apps/web/src/cloud/relayClientInstallDialog.ts @@ -0,0 +1,100 @@ +import type { + RelayClientInstallProgressEvent, + RelayClientInstallProgressStage, +} from "@t3tools/contracts"; + +export type RelayClientInstallDialogState = + | { readonly status: "idle" } + | { readonly status: "confirming"; readonly version: string } + | { + readonly status: "installing"; + readonly version: string; + readonly stage: RelayClientInstallProgressStage; + } + | { + readonly status: "closing"; + readonly view: + | { readonly status: "confirming"; readonly version: string } + | { + readonly status: "installing"; + readonly version: string; + readonly stage: RelayClientInstallProgressStage; + }; + }; + +const idleState: RelayClientInstallDialogState = { status: "idle" }; +let state: RelayClientInstallDialogState = idleState; +let resolveConfirmation: ((confirmed: boolean) => void) | null = null; +const listeners = new Set<() => void>(); + +function publish(next: RelayClientInstallDialogState) { + state = next; + for (const listener of listeners) { + listener(); + } +} + +export function readRelayClientInstallDialogState(): RelayClientInstallDialogState { + return state; +} + +export function subscribeRelayClientInstallDialog(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function requestRelayClientInstallConfirmation(version: string): Promise { + if (state.status !== "idle") { + return Promise.reject(new Error("A relay client installation is already in progress.")); + } + + publish({ status: "confirming", version }); + return new Promise((resolve) => { + resolveConfirmation = resolve; + }); +} + +export function respondToRelayClientInstallConfirmation(confirmed: boolean): void { + if (state.status !== "confirming" || !resolveConfirmation) { + return; + } + + const resolve = resolveConfirmation; + resolveConfirmation = null; + publish( + confirmed + ? { status: "installing", version: state.version, stage: "checking" } + : { status: "closing", view: state }, + ); + resolve(confirmed); +} + +export function reportRelayClientInstallProgress(event: RelayClientInstallProgressEvent): void { + if (state.status !== "installing" || event.type !== "progress") { + return; + } + publish({ ...state, stage: event.stage }); +} + +export function finishRelayClientInstall(): void { + resolveConfirmation?.(false); + resolveConfirmation = null; + if (state.status === "confirming" || state.status === "installing") { + publish({ status: "closing", view: state }); + } +} + +export function completeRelayClientInstallDialogClose(): void { + if (state.status === "closing") { + publish(idleState); + } +} + +export function resetRelayClientInstallDialogForTests(): void { + resolveConfirmation?.(false); + resolveConfirmation = null; + publish(idleState); + listeners.clear(); +} diff --git a/apps/web/src/components/clerk/DesktopClerkCard.tsx b/apps/web/src/components/clerk/DesktopClerkCard.tsx new file mode 100644 index 00000000000..e2e0c4f9aad --- /dev/null +++ b/apps/web/src/components/clerk/DesktopClerkCard.tsx @@ -0,0 +1,137 @@ +import type { ReactNode } from "react"; + +import { cn } from "../../lib/utils"; + +// Mirrors Clerk's raised card/footer/branding composition for the desktop-native flow: +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardRoot.tsx +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardFooter.tsx +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardClerkAndPagesTag.tsx +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/DevModeNotice.tsx +export function DesktopClerkCard({ + children, + footerAction, +}: { + children: ReactNode; + footerAction?: ReactNode; +}) { + return ( +
+
+ {children} +
+
+ {footerAction ? ( +
{footerAction}
+ ) : null} + +
+
+ ); +} + +export function DesktopClerkHeader({ title, subtitle }: { title: string; subtitle: string }) { + return ( +
+

{title}

+

{subtitle}

+
+ ); +} + +export function DesktopClerkFooterAction({ + children, + actionLabel, + onAction, +}: { + children: ReactNode; + actionLabel: string; + onAction: () => void; +}) { + return ( +

+ {children} + +

+ ); +} + +export function DesktopClerkAlert({ children }: { children?: ReactNode }) { + if (!children) return null; + + return ( +
+ {children} +
+ ); +} + +export function DesktopClerkInput({ + className, + ...props +}: React.ComponentPropsWithoutRef<"input">) { + return ( + + ); +} + +export function DesktopClerkPrimaryButton({ + children, + disabled, +}: { + children: ReactNode; + disabled?: boolean; +}) { + return ( + + ); +} + +function DesktopClerkBranding() { + const isDevelopmentMode = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY?.startsWith("pk_test_"); + + return ( +
+ + Secured by{" "} + + clerk + + + {isDevelopmentMode ? ( + Development mode + ) : null} +
+ ); +} diff --git a/apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx b/apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx new file mode 100644 index 00000000000..b3b55c3c314 --- /dev/null +++ b/apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx @@ -0,0 +1,71 @@ +import "../../index.css"; + +import { page, userEvent } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import type { DesktopCloudAuthOAuthOption } from "../../cloud/desktopAuth"; +import { DesktopClerkSignInCard } from "./DesktopClerkSignIn"; + +const GOOGLE: DesktopCloudAuthOAuthOption = { + strategy: "oauth_google", + label: "Google", + providerId: "google", + iconUrl: null, +}; + +const PROVIDERS: readonly DesktopCloudAuthOAuthOption[] = [ + { + strategy: "oauth_apple", + label: "Apple", + providerId: "apple", + iconUrl: null, + }, + GOOGLE, + { + strategy: "oauth_microsoft", + label: "Microsoft", + providerId: "microsoft", + iconUrl: null, + }, +]; + +describe("DesktopClerkSignInCard", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("uses Clerk's compact provider grid when more than two providers are enabled", async () => { + await render( + , + ); + + expect(document.querySelectorAll('button[aria-label^="Continue with "]')).toHaveLength(3); + expect(document.body.textContent).toContain("Want early access?"); + expect(document.body.textContent).not.toContain("Continue with Google"); + }); + + it("renders a full provider label and starts OAuth for a single provider", async () => { + const onStartOAuth = vi.fn(); + await render( + , + ); + + await userEvent.click(page.getByRole("button", { name: "Continue with Google" })); + + expect(document.body.textContent).toContain("Continue with Google"); + expect(onStartOAuth).toHaveBeenCalledWith("oauth_google"); + }); +}); diff --git a/apps/web/src/components/clerk/DesktopClerkSignIn.tsx b/apps/web/src/components/clerk/DesktopClerkSignIn.tsx new file mode 100644 index 00000000000..dc8b432e1c7 --- /dev/null +++ b/apps/web/src/components/clerk/DesktopClerkSignIn.tsx @@ -0,0 +1,150 @@ +import { LoaderCircleIcon } from "lucide-react"; + +import type { + DesktopCloudAuthOAuthOption, + DesktopCloudAuthOAuthStrategy, +} from "../../cloud/desktopAuth"; +import { cn } from "../../lib/utils"; +import { + DesktopClerkAlert, + DesktopClerkCard, + DesktopClerkFooterAction, + DesktopClerkHeader, +} from "./DesktopClerkCard"; +import { useDesktopClerkSignIn } from "./useDesktopClerkSignIn"; + +// Mirrors Clerk's compact social-button layout while delegating OAuth to the desktop bridge: +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/SocialButtons.tsx +export function DesktopClerkSignIn({ onJoinWaitlist }: { onJoinWaitlist: () => void }) { + const { isStarting, oauthOptions, startingStrategy, startOAuth } = useDesktopClerkSignIn(); + + return ( + void startOAuth(strategy)} + /> + ); +} + +export function DesktopClerkSignInCard({ + isStarting, + oauthOptions, + startingStrategy, + onJoinWaitlist, + onStartOAuth, +}: { + isStarting: boolean; + oauthOptions: readonly DesktopCloudAuthOAuthOption[]; + startingStrategy: DesktopCloudAuthOAuthStrategy | null; + onJoinWaitlist: () => void; + onStartOAuth: (strategy: DesktopCloudAuthOAuthStrategy) => void; +}) { + return ( + + Want early access? + + } + > + + {oauthOptions.length === 0 ? ( + No OAuth providers are enabled for desktop sign-in. + ) : ( + + )} + + ); +} + +function DesktopClerkSocialButtons({ + isStarting, + oauthOptions, + startingStrategy, + onStartOAuth, +}: { + isStarting: boolean; + oauthOptions: readonly DesktopCloudAuthOAuthOption[]; + startingStrategy: DesktopCloudAuthOAuthStrategy | null; + onStartOAuth: (strategy: DesktopCloudAuthOAuthStrategy) => void; +}) { + const useBlockButtons = oauthOptions.length <= 2; + + return ( +
+ {oauthOptions.map((option) => { + const isCurrent = option.strategy === startingStrategy; + return ( + + ); + })} +
+ ); +} + +function DesktopClerkProviderIcon({ option }: { option: DesktopCloudAuthOAuthOption }) { + if (!option.iconUrl) { + return ( + + {option.label.slice(0, 1).toUpperCase()} + + ); + } + + if (["apple", "github", "vercel"].includes(option.providerId)) { + return ( + + ); + } + + return ; +} diff --git a/apps/web/src/components/clerk/DesktopClerkWaitlist.tsx b/apps/web/src/components/clerk/DesktopClerkWaitlist.tsx new file mode 100644 index 00000000000..ec9198498df --- /dev/null +++ b/apps/web/src/components/clerk/DesktopClerkWaitlist.tsx @@ -0,0 +1,106 @@ +import { useClerk } from "@clerk/react"; +import { useState } from "react"; + +import { + DesktopClerkAlert, + DesktopClerkCard, + DesktopClerkFooterAction, + DesktopClerkHeader, + DesktopClerkInput, + DesktopClerkPrimaryButton, +} from "./DesktopClerkCard"; +import { DesktopClerkSignIn } from "./DesktopClerkSignIn"; + +type DesktopClerkScreen = "waitlist" | "sign-in"; + +// Mirrors Clerk's waitlist card and form, replacing its router transition with the desktop sign-in flow: +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/Waitlist/index.tsx +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/Waitlist/WaitlistForm.tsx +export function DesktopClerkWaitlist() { + const [screen, setScreen] = useState("waitlist"); + + if (screen === "sign-in") { + return setScreen("waitlist")} />; + } + + return setScreen("sign-in")} />; +} + +function DesktopClerkWaitlistForm({ onSignIn }: { onSignIn: () => void }) { + const clerk = useClerk(); + const [emailAddress, setEmailAddress] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [didJoin, setDidJoin] = useState(false); + + const submitWaitlist = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + try { + await clerk.joinWaitlist({ emailAddress }); + setDidJoin(true); + } catch (cause) { + setError(getClerkErrorMessage(cause)); + } finally { + setIsSubmitting(false); + } + }; + + if (didJoin) { + return ( + + + + ); + } + + return ( + + Already have access? + + } + > + + {error} +
+ + + {isSubmitting ? "Joining the waitlist…" : "Join the waitlist"} + +
+
+ ); +} + +function getClerkErrorMessage(error: unknown): string { + if (typeof error === "object" && error !== null && "errors" in error) { + const errors = (error as { errors?: Array<{ longMessage?: unknown; message?: unknown }> }) + .errors; + const firstError = errors?.[0]; + if (typeof firstError?.longMessage === "string") return firstError.longMessage; + if (typeof firstError?.message === "string") return firstError.message; + } + if (error instanceof Error && error.message) return error.message; + return "Could not join the waitlist. Please try again."; +} diff --git a/apps/web/src/components/clerk/useDesktopClerkSignIn.ts b/apps/web/src/components/clerk/useDesktopClerkSignIn.ts new file mode 100644 index 00000000000..7b58c4f1ee6 --- /dev/null +++ b/apps/web/src/components/clerk/useDesktopClerkSignIn.ts @@ -0,0 +1,199 @@ +import { useClerk } from "@clerk/react"; +import { useSignIn, useSignUp } from "@clerk/react/legacy"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + type DesktopCloudAuthOAuthStrategy, + resolveDesktopCloudAuthOAuthOptions, +} from "../../cloud/desktopAuth"; +import { toastManager } from "../ui/toast"; + +// Mirrors Clerk Expo's browser-based native SSO flow, with Electron handling the external browser +// and callback transport: +// https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/expo/src/hooks/useSSO.ts +class DesktopClerkOperationError extends Error { + override readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "DesktopClerkOperationError"; + this.cause = cause; + } +} + +async function runDesktopClerkOperation( + operation: () => Promise, + message: string, +): Promise { + try { + return await operation(); + } catch (cause) { + throw new DesktopClerkOperationError(message, cause); + } +} + +function desktopClerkErrorMessage(error: unknown, fallback: string): string { + if (error instanceof DesktopClerkOperationError) { + const cause = error.cause; + if (cause instanceof Error && cause.message && cause.message !== error.message) { + return `${error.message}: ${cause.message}`; + } + return error.message; + } + return error instanceof Error ? error.message : fallback; +} + +export function useDesktopClerkSignIn() { + const clerk = useClerk(); + const { setActive } = clerk; + const { isLoaded: signInLoaded, signIn } = useSignIn(); + const { isLoaded: signUpLoaded, signUp } = useSignUp(); + const [startingStrategy, setStartingStrategy] = useState( + null, + ); + const oauthOptions = resolveDesktopCloudAuthOAuthOptions(clerk); + const callbackCleanupRef = useRef<(() => void) | null>(null); + + const clearCallbackListener = useCallback(() => { + callbackCleanupRef.current?.(); + callbackCleanupRef.current = null; + }, []); + + const completeOAuthCallback = useCallback( + async (rawUrl: string) => { + if (!signInLoaded || !signIn || !signUpLoaded || !signUp) { + toastManager.add({ + type: "error", + title: "Cloud sign-in failed", + description: "Clerk is still loading. Try signing in again.", + }); + return; + } + + let rotatingTokenNonce: string | null = null; + let sessionId: string | null = null; + try { + const callbackUrl = new URL(rawUrl); + rotatingTokenNonce = callbackUrl.searchParams.get("rotating_token_nonce"); + sessionId = callbackUrl.searchParams.get("created_session_id"); + } catch { + // Handled by the explicit nonce check below. + } + if (!rotatingTokenNonce) { + toastManager.add({ + type: "error", + title: "Cloud sign-in failed", + description: + "Clerk did not return a native session nonce. Verify this redirect URL is allowlisted for native SSO redirects.", + }); + return; + } + + try { + await runDesktopClerkOperation( + () => signIn.reload({ rotatingTokenNonce }), + "Could not reload the desktop sign-in session.", + ); + sessionId = sessionId || signIn.createdSessionId; + + if (!sessionId && signIn.firstFactorVerification.status === "transferable") { + const signUpAttempt = await runDesktopClerkOperation( + () => signUp.create({ transfer: true }), + "Could not transfer the desktop sign-up session.", + ); + sessionId = signUpAttempt.createdSessionId; + } + + if (!sessionId) { + throw new DesktopClerkOperationError("Clerk did not create a desktop session."); + } + + await runDesktopClerkOperation( + () => setActive({ session: sessionId! }), + "Could not activate the desktop cloud session.", + ); + } catch (error) { + toastManager.add({ + type: "error", + title: "Cloud sign-in failed", + description: desktopClerkErrorMessage(error, "Could not complete cloud sign-in."), + }); + } + }, + [setActive, signIn, signInLoaded, signUp, signUpLoaded], + ); + + useEffect(() => { + return () => { + clearCallbackListener(); + }; + }, [clearCallbackListener]); + + const startOAuth = useCallback( + async (strategy: DesktopCloudAuthOAuthStrategy) => { + if (!signInLoaded || !signIn) { + toastManager.add({ + type: "error", + title: "Cloud sign-in failed", + description: "Clerk is still loading. Try signing in again.", + }); + return; + } + + setStartingStrategy(strategy); + clearCallbackListener(); + try { + const redirectUrl = await runDesktopClerkOperation( + () => window.desktopBridge?.createCloudAuthRequest() ?? Promise.resolve(undefined), + "Desktop auth callback is unavailable.", + ); + if (!redirectUrl) { + throw new DesktopClerkOperationError("Desktop auth callback is unavailable."); + } + + callbackCleanupRef.current = + window.desktopBridge?.onCloudAuthCallback((rawUrl) => { + clearCallbackListener(); + void completeOAuthCallback(rawUrl); + }) ?? null; + + await runDesktopClerkOperation( + () => signIn.create({ strategy, redirectUrl } as never), + "Could not create the desktop OAuth request.", + ); + const externalUrl = + signIn.firstFactorVerification.externalVerificationRedirectURL?.toString(); + if (!externalUrl) { + throw new DesktopClerkOperationError( + "Clerk did not return an external OAuth redirect URL.", + ); + } + + const opened = await runDesktopClerkOperation( + () => window.desktopBridge?.openExternal(externalUrl) ?? Promise.resolve(false), + "Could not open the system browser.", + ); + if (!opened) { + throw new DesktopClerkOperationError("Could not open the system browser."); + } + } catch (error) { + clearCallbackListener(); + toastManager.add({ + type: "error", + title: "Cloud sign-in failed", + description: desktopClerkErrorMessage(error, "Could not start cloud sign-in."), + }); + } finally { + setStartingStrategy(null); + } + }, + [clearCallbackListener, completeOAuthCallback, signIn, signInLoaded], + ); + + return { + isStarting: startingStrategy !== null, + oauthOptions, + startingStrategy, + startOAuth, + }; +} diff --git a/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx b/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx new file mode 100644 index 00000000000..d58b561841f --- /dev/null +++ b/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx @@ -0,0 +1,47 @@ +import "../../index.css"; + +import { page } from "vitest/browser"; +import { beforeEach, describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; + +import { + finishRelayClientInstall, + readRelayClientInstallDialogState, + reportRelayClientInstallProgress, + requestRelayClientInstallConfirmation, + resetRelayClientInstallDialogForTests, +} from "../../cloud/relayClientInstallDialog"; +import { RelayClientInstallDialog } from "./RelayClientInstallDialog"; + +describe("RelayClientInstallDialog", () => { + beforeEach(() => { + resetRelayClientInstallDialogForTests(); + }); + + it("confirms installation and renders streamed progress", async () => { + render(); + const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); + + await expect.element(page.getByText("Install relay client?")).toBeInTheDocument(); + await expect.element(page.getByText(/version 2026\.5\.2 locally/)).toBeInTheDocument(); + + await page.getByRole("button", { name: "Download and install" }).click(); + await expect(confirmation).resolves.toBe(true); + await expect + .element(page.getByRole("heading", { name: "Installing relay client" })) + .toBeInTheDocument(); + + reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); + await expect.element(page.getByText("Downloading relay client")).toBeInTheDocument(); + await expect + .element(page.getByRole("progressbar", { name: "Relay client installation progress" })) + .toHaveAttribute("value", "3"); + + finishRelayClientInstall(); + expect(readRelayClientInstallDialogState().status).toBe("closing"); + await expect + .element(page.getByRole("heading", { name: "Installing relay client" })) + .not.toBeInTheDocument(); + expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); + }); +}); diff --git a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx new file mode 100644 index 00000000000..1ccdf4b10c9 --- /dev/null +++ b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx @@ -0,0 +1,123 @@ +import { DownloadIcon } from "lucide-react"; +import { useSyncExternalStore } from "react"; +import type { RelayClientInstallProgressStage } from "@t3tools/contracts"; + +import { + completeRelayClientInstallDialogClose, + readRelayClientInstallDialogState, + respondToRelayClientInstallConfirmation, + subscribeRelayClientInstallDialog, +} from "../../cloud/relayClientInstallDialog"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +const installSteps: ReadonlyArray<{ + readonly stage: RelayClientInstallProgressStage; + readonly label: string; +}> = [ + { stage: "checking", label: "Checking current installation" }, + { stage: "waiting_for_lock", label: "Waiting for installer" }, + { stage: "downloading", label: "Downloading relay client" }, + { stage: "verifying", label: "Verifying download" }, + { stage: "installing", label: "Installing relay client" }, + { stage: "validating", label: "Validating executable" }, + { stage: "activating", label: "Activating installation" }, +]; + +export function RelayClientInstallDialog() { + const state = useSyncExternalStore( + subscribeRelayClientInstallDialog, + readRelayClientInstallDialogState, + readRelayClientInstallDialogState, + ); + const view = state.status === "closing" ? state.view : state; + const isConfirming = view.status === "confirming"; + const isInstalling = view.status === "installing"; + const activeStepIndex = isInstalling + ? installSteps.findIndex(({ stage }) => stage === view.stage) + : -1; + const activeStep = installSteps[activeStepIndex]; + + return ( + { + if (!open && isConfirming) { + respondToRelayClientInstallConfirmation(false); + } + }} + onOpenChangeComplete={(open) => { + if (!open) { + completeRelayClientInstallDialogClose(); + } + }} + > + + +
+ +
+ + {isInstalling ? "Installing relay client" : "Install relay client?"} + + + {isInstalling + ? "T3 Code is preparing this environment for secure access through T3 Cloud." + : "T3 Code needs the relay client to make this environment available through T3 Cloud."} + +
+ + {isInstalling ? ( +
+
+

+ {activeStep?.label} +

+

+ {activeStepIndex + 1} of {installSteps.length} +

+
+ +

+ Keep T3 Code open while the relay client is installed. +

+
+ ) : ( +
+

Managed relay client

+

+ T3 Code will download and install version{" "} + {view.status === "confirming" ? view.version : ""} locally. +

+
+ )} +
+ {isConfirming ? ( + + + + + ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/settings/CloudSettings.tsx b/apps/web/src/components/settings/CloudSettings.tsx new file mode 100644 index 00000000000..c2a1541b120 --- /dev/null +++ b/apps/web/src/components/settings/CloudSettings.tsx @@ -0,0 +1,197 @@ +import { UserButton, Waitlist, useAuth } from "@clerk/react"; +import { AuthRelayWriteScope } from "@t3tools/contracts"; +import { CloudIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { updatePrimaryCloudPreferences } from "../../cloud/linkEnvironment"; +import { hasCloudPublicConfig } from "../../cloud/publicConfig"; +import { useManagedRelayDevices } from "../../cloud/managedRelayState"; +import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; +import { isElectron } from "../../env"; +import { usePrimarySessionState } from "../../environments/primary"; +import { webRuntime } from "../../lib/runtime"; +import { cn } from "../../lib/utils"; +import { DesktopClerkWaitlist } from "../clerk/DesktopClerkWaitlist"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { Skeleton } from "../ui/skeleton"; +import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +const NOTIFICATION_DEVICE_SKELETON_ROWS = ["primary", "secondary"] as const; + +function NotificationDevicesSkeleton() { + return NOTIFICATION_DEVICE_SKELETON_ROWS.map((row) => ( +
+
+ + + +
+
+ )); +} + +function EmptyNotificationDevices() { + return ( + + + + + + No notification devices + + Sign in on the mobile app to register a device for account-level notifications. + + + + ); +} + +function cloudErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +export function CloudSettingsPanel() { + if (!hasCloudPublicConfig()) return null; + + return ; +} + +function ConfiguredCloudSettingsPanel() { + const { isLoaded, isSignedIn } = useAuth(); + + if (!isLoaded) { + return null; + } + + return isSignedIn ? : ; +} + +function CloudWaitlistPanel() { + return ( + + {isElectron ? : } + + ); +} + +function CloudSettingsPanelInner() { + const primaryLinkState = usePrimaryCloudLinkState(); + const primarySessionState = usePrimarySessionState(); + const devicesState = useManagedRelayDevices(); + const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); + const devices = devicesState.data ?? []; + const canManageRelay = + primarySessionState.data?.authenticated === true && + Boolean(primarySessionState.data.scopes?.includes(AuthRelayWriteScope)); + + useEffect(() => { + if (devicesState.error) { + toastManager.add({ + type: "error", + title: "Cloud devices unavailable", + description: devicesState.error, + }); + } + }, [devicesState.error]); + + const updatePublishAgentActivity = async (enabled: boolean) => { + setIsUpdatingPreference(true); + try { + await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled })); + primaryLinkState.refresh(); + toastManager.add({ + type: "success", + title: enabled ? "Agent activity enabled" : "Agent activity disabled", + description: enabled + ? "This environment can publish agent activity to your notification devices." + : "This environment will stop publishing agent activity.", + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Cloud preference update failed", + description: cloudErrorMessage(error, "Could not update cloud preferences."), + }); + } finally { + setIsUpdatingPreference(false); + } + }; + + return ( + + }> + } + /> + + + void updatePublishAgentActivity(enabled)} + /> + } + /> + + + + + + } + /> + Refresh notification devices + + } + > + {devicesState.data === null ? ( + + ) : devices.length > 0 ? ( + devices.map((device) => ( + + )) + ) : ( + + )} + + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b9aa9ce6c59..9dab7b61fac 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -7,6 +7,7 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; +import { useAuth } from "@clerk/react"; import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; import { AuthAccessReadScope, @@ -29,12 +30,14 @@ import { type EnvironmentId, } from "@t3tools/contracts"; import { WsRpcClient } from "@t3tools/client-runtime"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; +import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig"; import { SettingsPageContainer, SettingsRow, @@ -66,11 +69,13 @@ import { } from "../ui/alert-dialog"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { QRCodeSvg } from "../ui/qr-code"; +import { Skeleton } from "../ui/skeleton"; import { Spinner } from "../ui/spinner"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { Group, GroupSeparator } from "../ui/group"; import { AnimatedHeight } from "../AnimatedHeight"; import { @@ -91,6 +96,7 @@ import { revokeServerClientSession, revokeServerPairingLink, isLoopbackHostname, + usePrimaryEnvironmentId, usePrimarySessionState, type ServerClientSessionRecord, type ServerPairingLinkRecord, @@ -101,6 +107,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, addSavedEnvironment, + addManagedRelayEnvironment, connectDesktopSshEnvironment, disconnectSavedEnvironment, getPrimaryEnvironmentConnection, @@ -110,6 +117,18 @@ import { import { useUiStateStore } from "~/uiStateStore"; import { resolveServerConfigVersionMismatch } from "~/versionSkew"; import { useServerConfig } from "~/rpc/serverState"; +import { + connectManagedCloudEnvironment, + linkPrimaryEnvironmentToCloud, + unlinkPrimaryEnvironmentFromCloud, +} from "~/cloud/linkEnvironment"; +import { + refreshManagedRelayEnvironments, + useManagedRelayEnvironments, +} from "~/cloud/managedRelayState"; +import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; +import { webRuntime } from "~/lib/runtime"; +import { hasCloudPublicConfig } from "~/cloud/publicConfig"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; @@ -1582,8 +1601,242 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({ ); }); +function CloudLinkSwitch({ + checked, + disabled, + disabledReason, + onCheckedChange, +}: { + readonly checked: boolean; + readonly disabled: boolean; + readonly disabledReason: string | null; + readonly onCheckedChange?: (enabled: boolean) => void; +}) { + const control = ( + + ); + return disabledReason ? ( + + {control}
} /> + {disabledReason} + + ) : ( + control + ); +} + +function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { + const { getToken, isSignedIn } = useAuth(); + const primaryCloudLinkState = usePrimaryCloudLinkState(); + const [operationError, setOperationError] = useState(null); + const [isUpdating, setIsUpdating] = useState(false); + + const updateLink = async (enabled: boolean) => { + setIsUpdating(true); + setOperationError(null); + try { + const clerkToken = await getToken(resolveRelayClerkTokenOptions()); + if (enabled) { + if (!clerkToken) { + throw new Error("Sign in from T3 Cloud settings before linking this environment."); + } + await webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken })); + } else { + await webRuntime.runPromise( + unlinkPrimaryEnvironmentFromCloud({ clerkToken: clerkToken ?? null }), + ); + } + primaryCloudLinkState.refresh(); + refreshManagedRelayEnvironments(); + toastManager.add({ + type: "success", + title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", + description: enabled + ? "This environment is available through T3 Cloud." + : "This environment is no longer available through T3 Cloud.", + }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; + setOperationError(message); + toastManager.add({ + type: "error", + title: "Could not update T3 Cloud", + description: message, + }); + } finally { + setIsUpdating(false); + } + }; + const disabledReason = !isSignedIn + ? "Sign in from T3 Cloud settings to manage this environment." + : !canManageRelay + ? "Your session does not have permission to manage T3 Cloud access." + : null; + const linked = primaryCloudLinkState.data?.linked ?? false; + + return ( + void updateLink(enabled)} + /> + } + /> + ); +} + +function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { + return hasCloudPublicConfig() ? : null; +} + +function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) { + return ( + + + + + + No saved remote environments + + {cloudEnabled + ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." + : "Click “Add environment” to pair another environment."} + + + + ); +} + +function RemoteEnvironmentRowsSkeleton() { + return ( +
+
+
+ + +
+ +
+
+ ); +} + +function ConfiguredCloudRemoteEnvironmentRows({ + primaryEnvironmentId, + savedEnvironmentIds, +}: { + readonly primaryEnvironmentId: EnvironmentId | null; + readonly savedEnvironmentIds: ReadonlyArray; +}) { + const { getToken } = useAuth(); + const environmentsState = useManagedRelayEnvironments(); + const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( + null, + ); + const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); + + const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { + setConnectingEnvironmentId(environment.environmentId); + try { + const clerkToken = await getToken(resolveRelayClerkTokenOptions()); + if (!clerkToken) { + throw new Error("Sign in from T3 Cloud settings before connecting this environment."); + } + const connection = await webRuntime.runPromise( + connectManagedCloudEnvironment({ clerkToken, environment }), + ); + await addManagedRelayEnvironment(connection); + toastManager.add({ + type: "success", + title: "Environment connected", + description: `${connection.label} is available through T3 Cloud.`, + }); + } catch (cause) { + toastManager.add({ + type: "error", + title: "Could not connect environment", + description: + cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment.", + }); + } finally { + setConnectingEnvironmentId(null); + } + }; + + const connectableEnvironments = (environmentsState.data ?? []).filter( + (environment) => + environment.environmentId !== primaryEnvironmentId && + !savedIds.has(environment.environmentId), + ); + + if (savedEnvironmentIds.length === 0 && environmentsState.data === null) { + return ; + } + + if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) { + return ; + } + + return connectableEnvironments.map((environment) => ( +
+
+
+
+ +

{environment.label}

+
+

T3 Cloud

+
+ +
+
+ )); +} + +function CloudRemoteEnvironmentRows({ + primaryEnvironmentId, + savedEnvironmentIds, +}: { + readonly primaryEnvironmentId: EnvironmentId | null; + readonly savedEnvironmentIds: ReadonlyArray; +}) { + return hasCloudPublicConfig() ? ( + + ) : savedEnvironmentIds.length === 0 ? ( + + ) : null; +} + export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; + const primaryEnvironmentId = usePrimaryEnvironmentId(); const primarySessionState = usePrimarySessionState(); const currentSessionScopes = desktopBridge ? AuthAdministrativeScopes @@ -1701,6 +1954,7 @@ export function ConnectionsSettings() { (state) => state.setDefaultAdvertisedEndpointKey, ); const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; + const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; const isLocalBackendNetworkAccessible = desktopBridge ? desktopServerExposureState?.mode === "network-accessible" : currentAuthPolicy === "remote-reachable"; @@ -2607,7 +2861,7 @@ export function ConnectionsSettings() { {canManageLocalBackend ? ( <> - + {primaryVersionMismatch ? ( ) : ( - renderDisabledNetworkAccessRow() + <> + {renderDisabledNetworkAccessRow()} + + )} @@ -2805,11 +3063,12 @@ export function ConnectionsSettings() { ) : ( - + + )} @@ -2889,15 +3148,10 @@ export function ConnectionsSettings() { onRemove={handleRemoveSavedBackend} /> ))} - - {savedEnvironmentIds.length === 0 ? ( -
-

- No remote environments yet. Click “Add environment” to pair another - environment. -

-
- ) : null} +
); diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index f059354661e..e8f46505edc 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -148,6 +148,14 @@ const authAccessHarness = vi.hoisted(() => { }); const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn()); +const mockGetClerkToken = vi.hoisted(() => vi.fn(async () => null)); + +vi.mock("@clerk/react", () => ({ + useAuth: () => ({ + getToken: mockGetClerkToken, + isSignedIn: false, + }), +})); vi.mock("../../environments/runtime", () => { const primaryConnection = { @@ -185,6 +193,7 @@ vi.mock("../../environments/runtime", () => { resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => new URL(path, "http://localhost:3000").toString(), waitForSavedEnvironmentRegistryHydration: async () => undefined, + addManagedRelayEnvironment: vi.fn(), addSavedEnvironment: vi.fn(), connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment, disconnectSavedEnvironment: vi.fn(), @@ -450,6 +459,18 @@ const createDesktopBridgeStub = (overrides?: { setTheme: vi.fn().mockResolvedValue(undefined), showContextMenu: vi.fn().mockResolvedValue(null), openExternal: vi.fn().mockResolvedValue(true), + createCloudAuthRequest: vi.fn().mockResolvedValue("t3code-dev://auth/callback?t3_state=test"), + getCloudAuthToken: vi.fn().mockResolvedValue(null), + setCloudAuthToken: vi.fn().mockResolvedValue(true), + clearCloudAuthToken: vi.fn().mockResolvedValue(undefined), + fetchCloudAuth: vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: {}, + body: "", + }), + onCloudAuthCallback: () => () => {}, onMenuAction: () => () => {}, getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), setUpdateChannel: @@ -553,7 +574,9 @@ describe("GeneralSettingsPanel observability", () => { , ); - await expect.element(page.getByText("Manage local backend")).toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "This environment", exact: true })) + .toBeInTheDocument(); await expect.element(page.getByLabelText("Enable network access")).toBeDisabled(); await expect .element( diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index bec96063868..c0238a4651b 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -3,6 +3,7 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, + CloudIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -20,12 +21,15 @@ import { SidebarSeparator, useSidebar, } from "../ui/sidebar"; +import { Badge } from "../ui/badge"; +import { hasCloudPublicConfig } from "../../cloud/publicConfig"; export type SettingsSectionPath = | "/settings/general" | "/settings/keybindings" | "/settings/providers" | "/settings/source-control" + | "/settings/cloud" | "/settings/connections" | "/settings/archived"; @@ -33,11 +37,13 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ label: string; to: SettingsSectionPath; icon: ComponentType<{ className?: string }>; + badgeLabel?: string; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, + { label: "T3 Cloud", to: "/settings/cloud", icon: CloudIcon, badgeLabel: "Private Beta" }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; @@ -71,7 +77,9 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { - {SETTINGS_NAV_ITEMS.map((item) => { + {SETTINGS_NAV_ITEMS.filter( + (item) => item.to !== "/settings/cloud" || hasCloudPublicConfig(), + ).map((item) => { const Icon = item.icon; const isActive = pathname === item.to; return ( @@ -94,6 +102,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { } /> {item.label} + {item.badgeLabel ? ( + + {item.badgeLabel} + + ) : null} ); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 40bd277fc0e..f6f07dbb303 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -3,6 +3,7 @@ import type { AuthClientMetadata, AuthEnvironmentScope, AuthPairingCredentialResult, + ServerAuthSessionMethod, AuthSessionId, AuthSessionState, } from "@t3tools/contracts"; @@ -45,7 +46,7 @@ export interface ServerClientSessionRecord { readonly sessionId: AuthSessionId; readonly subject: string; readonly scopes: ReadonlyArray; - readonly method: "browser-session-cookie" | "bearer-access-token"; + readonly method: ServerAuthSessionMethod; readonly client: AuthClientMetadata; readonly issuedAt: string; readonly expiresAt: string; diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 6856b9ac5fa..09576c34e42 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -34,4 +34,8 @@ export { export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState"; -export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target"; +export { + readPrimaryEnvironmentTarget, + resolvePrimaryEnvironmentHttpUrl, + isLoopbackHostname, +} from "./target"; diff --git a/apps/web/src/environments/primary/requestInit.ts b/apps/web/src/environments/primary/requestInit.ts new file mode 100644 index 00000000000..cf70237380b --- /dev/null +++ b/apps/web/src/environments/primary/requestInit.ts @@ -0,0 +1,7 @@ +import * as Effect from "effect/Effect"; +import { FetchHttpClient } from "effect/unstable/http"; + +export const primaryEnvironmentRequestInit = { credentials: "include" } as const; + +export const withPrimaryEnvironmentRequestInit = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit)); diff --git a/apps/web/src/environments/runtime/catalog.test.ts b/apps/web/src/environments/runtime/catalog.test.ts index d530c8173ba..1002ab08dd6 100644 --- a/apps/web/src/environments/runtime/catalog.test.ts +++ b/apps/web/src/environments/runtime/catalog.test.ts @@ -6,11 +6,13 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + readSavedEnvironmentCredential, resetSavedEnvironmentRegistryStoreForTests, resetSavedEnvironmentRuntimeStoreForTests, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, waitForSavedEnvironmentRegistryHydration, + writeSavedEnvironmentCredential, } from "./catalog"; describe("environment runtime catalog stores", () => { @@ -74,6 +76,47 @@ describe("environment runtime catalog stores", () => { expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); }); + it("decodes legacy bearer secrets and writes versioned DPoP credentials", async () => { + let storedSecret: string | null = "legacy-bearer-token"; + vi.stubGlobal("window", { + nativeApi: { + persistence: { + getClientSettings: async () => null, + setClientSettings: async () => undefined, + getSavedEnvironmentRegistry: async () => [], + setSavedEnvironmentRegistry: async () => undefined, + getSavedEnvironmentSecret: async () => storedSecret, + setSavedEnvironmentSecret: async (_environmentId, secret) => { + storedSecret = secret; + return true; + }, + removeSavedEnvironmentSecret: async () => undefined, + }, + } satisfies Pick, + }); + const { __resetLocalApiForTests } = await import("../../localApi"); + await __resetLocalApiForTests(); + const environmentId = EnvironmentId.make("environment-1"); + + await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ + version: 1, + method: "bearer", + token: "legacy-bearer-token", + }); + await expect( + writeSavedEnvironmentCredential(environmentId, { + version: 1, + method: "dpop", + accessToken: "managed-dpop-access-token", + }), + ).resolves.toBe(true); + await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ + version: 1, + method: "dpop", + accessToken: "managed-dpop-access-token", + }); + }); + it("does not throw when local api lookup fails during registry persistence", async () => { vi.unstubAllGlobals(); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/apps/web/src/environments/runtime/catalog.ts b/apps/web/src/environments/runtime/catalog.ts index 5d1c9560518..570d9753c13 100644 --- a/apps/web/src/environments/runtime/catalog.ts +++ b/apps/web/src/environments/runtime/catalog.ts @@ -6,6 +6,8 @@ import type { PersistedSavedEnvironmentRecord, ServerConfig, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { create } from "zustand"; import { ensureLocalApi } from "../../localApi"; @@ -19,8 +21,29 @@ export interface SavedEnvironmentRecord { readonly createdAt: string; readonly lastConnectedAt: string | null; readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"]; + readonly relayManaged?: PersistedSavedEnvironmentRecord["relayManaged"]; } +export const SavedEnvironmentCredential = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + method: Schema.Literal("bearer"), + token: Schema.String, + }), + Schema.Struct({ + version: Schema.Literal(1), + method: Schema.Literal("dpop"), + accessToken: Schema.String, + }), +]); +export type SavedEnvironmentCredential = typeof SavedEnvironmentCredential.Type; + +const SavedEnvironmentCredentialJson = Schema.fromJsonString(SavedEnvironmentCredential); +const decodeSavedEnvironmentCredentialJson = Schema.decodeUnknownOption( + SavedEnvironmentCredentialJson, +); +const encodeSavedEnvironmentCredentialJson = Schema.encodeSync(SavedEnvironmentCredentialJson); + interface SavedEnvironmentRegistryState { readonly byId: Record; } @@ -47,6 +70,7 @@ export function toPersistedSavedEnvironmentRecord( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), + ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), }; } @@ -250,6 +274,31 @@ export async function readSavedEnvironmentBearerToken( return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); } +export async function readSavedEnvironmentCredential( + environmentId: EnvironmentId, +): Promise { + const secret = await ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); + if (!secret) { + return null; + } + const decoded = decodeSavedEnvironmentCredentialJson(secret); + if (Option.isSome(decoded)) { + return decoded.value; + } + // Legacy bearer secrets were stored directly as strings. + return { version: 1, method: "bearer", token: secret }; +} + +export async function writeSavedEnvironmentCredential( + environmentId: EnvironmentId, + credential: SavedEnvironmentCredential, +): Promise { + return ensureLocalApi().persistence.setSavedEnvironmentSecret( + environmentId, + encodeSavedEnvironmentCredentialJson(credential), + ); +} + export async function writeSavedEnvironmentBearerToken( environmentId: EnvironmentId, bearerToken: string, diff --git a/apps/web/src/environments/runtime/index.ts b/apps/web/src/environments/runtime/index.ts index d6acb67e865..7333e03a42a 100644 --- a/apps/web/src/environments/runtime/index.ts +++ b/apps/web/src/environments/runtime/index.ts @@ -16,6 +16,7 @@ export { export { addSavedEnvironment, + addManagedRelayEnvironment, connectDesktopSshEnvironment, disconnectSavedEnvironment, ensureEnvironmentConnectionBootstrapped, diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index a6924c476f5..e7f2e60b58f 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -1,4 +1,5 @@ import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -10,17 +11,33 @@ const mockResolveRemotePairingTarget = vi.fn(); const mockFetchRemoteEnvironmentDescriptor = vi.fn(); const mockBootstrapRemoteBearerSession = vi.fn(); const mockFetchRemoteSessionState = vi.fn(); +const mockFetchRemoteDpopSessionState = vi.fn(); const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); -const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); +let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner; +const mockRemoteHttpRunPromise = vi.fn((effect: Effect.Effect) => + Effect.runPromise( + effect.pipe( + Effect.provideService( + managedRelayDpopSigner, + managedRelayDpopSigner.of({ + thumbprint: Effect.succeed("thumbprint"), + createProof: () => Effect.succeed("dpop-proof"), + }), + ), + ), + ), +); const mockBootstrapSshBearerSession = vi.fn(); const mockFetchSshSessionState = vi.fn(); const mockPersistSavedEnvironmentRecord = vi.fn(); const mockWriteSavedEnvironmentBearerToken = vi.fn(); +const mockWriteSavedEnvironmentCredential = vi.fn(); const mockSetSavedEnvironmentRegistry = vi.fn(); const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; }); const mockReadSavedEnvironmentBearerToken = vi.fn(); +const mockReadSavedEnvironmentCredential = vi.fn(); const mockRemoveSavedEnvironmentBearerToken = vi.fn(); const mockPatchRuntime = vi.fn(); const mockClearRuntime = vi.fn(); @@ -58,6 +75,8 @@ const mockClientGetConfig = vi.fn(async () => ({ label: "Remote environment", }, })); +const mockConnectManagedCloudEnvironment = vi.fn(); +const mockReadManagedRelayClerkToken = vi.fn(); vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ ...(await importOriginal()), @@ -65,11 +84,19 @@ vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ })); vi.mock("../../lib/runtime", () => ({ - remoteHttpRuntime: { + webRuntime: { runPromise: mockRemoteHttpRunPromise, }, })); +vi.mock("../../cloud/linkEnvironment", () => ({ + connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment, +})); + +vi.mock("../../cloud/managedAuth", () => ({ + readManagedRelayClerkToken: mockReadManagedRelayClerkToken, +})); + vi.mock("~/localApi", () => ({ ensureLocalApi: () => ({ persistence: { @@ -84,6 +111,7 @@ vi.mock("./catalog", () => ({ listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, + readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, useSavedEnvironmentRegistryStore: { @@ -105,6 +133,7 @@ vi.mock("./catalog", () => ({ }, waitForSavedEnvironmentRegistryHydration: vi.fn(), writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, + writeSavedEnvironmentCredential: mockWriteSavedEnvironmentCredential, })); vi.mock("./connection", async (importOriginal) => ({ @@ -114,6 +143,7 @@ vi.mock("./connection", async (importOriginal) => ({ vi.mock("@t3tools/client-runtime", async (importOriginal) => { const actual = await importOriginal(); + managedRelayDpopSigner = actual.ManagedRelayDpopSigner; return { ...actual, bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, @@ -130,6 +160,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { })), fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, fetchRemoteSessionState: mockFetchRemoteSessionState, + fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState, resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, }; }); @@ -172,20 +203,36 @@ describe("addSavedEnvironment", () => { credential: input.pairingCode ?? "pairing-code", }), ); - mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({ - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }); - mockBootstrapRemoteBearerSession.mockResolvedValue({ - access_token: "bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", + mockReadSavedEnvironmentCredential.mockImplementation(async () => { + const token = await mockReadSavedEnvironmentBearerToken(); + return token ? { version: 1, method: "bearer", token } : null; }); - mockFetchRemoteSessionState.mockResolvedValue({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( - "wss://remote.example.com/?wsTicket=remote-token", + mockFetchRemoteEnvironmentDescriptor.mockReturnValue( + Effect.succeed({ + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + }), + ); + mockBootstrapRemoteBearerSession.mockReturnValue( + Effect.succeed({ + access_token: "bearer-token", + scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", + }), + ); + mockFetchRemoteSessionState.mockReturnValue( + Effect.succeed({ + authenticated: true, + scopes: ["orchestration:read", "access:write"], + }), + ); + mockFetchRemoteDpopSessionState.mockReturnValue( + Effect.succeed({ + authenticated: true, + scopes: ["orchestration:read", "access:write"], + }), + ); + mockResolveRemoteWebSocketConnectionUrl.mockReturnValue( + Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"), ); mockFetchSshEnvironmentDescriptor.mockResolvedValue({ environmentId: EnvironmentId.make("environment-1"), @@ -197,6 +244,8 @@ describe("addSavedEnvironment", () => { }); mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); + mockWriteSavedEnvironmentCredential.mockResolvedValue(true); + mockReadManagedRelayClerkToken.mockResolvedValue(null); mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); @@ -323,6 +372,94 @@ describe("addSavedEnvironment", () => { await resetEnvironmentServiceForTests(); }); + it("installs relay-managed environments with versioned DPoP credentials", async () => { + const { addManagedRelayEnvironment, resetEnvironmentServiceForTests } = + await import("./service"); + + await addManagedRelayEnvironment({ + environmentId: EnvironmentId.make("environment-1"), + label: "Managed remote", + httpBaseUrl: "https://managed.example.com/", + wsBaseUrl: "wss://managed.example.com/", + relayUrl: "https://relay.example.com", + accessToken: "managed-access-token", + }); + + expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith( + EnvironmentId.make("environment-1"), + { + version: 1, + method: "dpop", + accessToken: "managed-access-token", + }, + ); + expect(mockFetchRemoteDpopSessionState).toHaveBeenCalledWith({ + httpBaseUrl: "https://managed.example.com/", + accessToken: "managed-access-token", + dpopProof: "dpop-proof", + }); + await resetEnvironmentServiceForTests(); + }); + + it("renews expired managed DPoP credentials through the relay", async () => { + const environmentId = EnvironmentId.make("environment-1"); + mockSavedRecords = [ + { + environmentId, + label: "Managed remote", + httpBaseUrl: "https://managed.example.com/", + wsBaseUrl: "wss://managed.example.com/", + createdAt: "2026-05-25T00:00:00.000Z", + lastConnectedAt: null, + relayManaged: { relayUrl: "https://relay.example.com" }, + }, + ]; + mockReadSavedEnvironmentCredential.mockResolvedValue({ + version: 1, + method: "dpop", + accessToken: "expired-access-token", + }); + mockFetchRemoteDpopSessionState + .mockReturnValueOnce( + Effect.fail( + decodeEnvironmentAuthInvalidError({ + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-auth-expired", + }), + ), + ) + .mockReturnValue(Effect.succeed({ authenticated: true, scopes: ["orchestration:read"] })); + mockReadManagedRelayClerkToken.mockResolvedValue("clerk-token"); + mockConnectManagedCloudEnvironment.mockReturnValue( + Effect.succeed({ + environmentId, + label: "Managed remote", + httpBaseUrl: "https://managed.example.com/", + wsBaseUrl: "wss://managed.example.com/", + relayUrl: "https://relay.example.com", + accessToken: "renewed-access-token", + }), + ); + + const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = + await import("./service"); + await reconnectSavedEnvironment(environmentId); + + expect(mockConnectManagedCloudEnvironment).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + relayUrl: "https://relay.example.com", + environment: expect.objectContaining({ environmentId }), + }); + expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(environmentId, { + version: 1, + method: "dpop", + accessToken: "renewed-access-token", + }); + await resetEnvironmentServiceForTests(); + }); + it("removes an older ssh record when the same target returns a new environment id", async () => { mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); mockFetchSshEnvironmentDescriptor.mockResolvedValue({ @@ -424,7 +561,7 @@ describe("addSavedEnvironment", () => { reason: "invalid_credential", traceId: "trace-auth-test", }); - mockFetchRemoteSessionState.mockRejectedValueOnce(authError); + mockFetchRemoteSessionState.mockReturnValueOnce(Effect.fail(authError)); const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); @@ -747,9 +884,12 @@ describe("addSavedEnvironment", () => { readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; }) => void; mockFetchRemoteSessionState.mockReturnValue( - new Promise((resolve) => { - resolveSessionState = resolve; - }), + Effect.promise( + () => + new Promise((resolve) => { + resolveSessionState = resolve; + }), + ), ); const { diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts index 7aca5149620..e7c15ec6b32 100644 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts @@ -11,6 +11,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); const mockListSavedEnvironmentRecords = vi.fn(); const mockSavedEnvironmentRegistrySubscribe = vi.fn(); const mockReadSavedEnvironmentBearerToken = vi.fn(); +const mockReadSavedEnvironmentCredential = vi.fn(); const mockGetSavedEnvironmentRecord = vi.fn(); function MockWsTransport() { @@ -31,7 +32,7 @@ vi.mock("../primary", () => ({ })); vi.mock("../../lib/runtime", () => ({ - remoteHttpRuntime: { + webRuntime: { runPromise: mockRemoteHttpRunPromise, }, })); @@ -42,6 +43,7 @@ vi.mock("./catalog", () => ({ listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, persistSavedEnvironmentRecord: vi.fn(), readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, + readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, removeSavedEnvironmentBearerToken: vi.fn(), useSavedEnvironmentRegistryStore: { subscribe: mockSavedEnvironmentRegistrySubscribe, @@ -61,6 +63,7 @@ vi.mock("./catalog", () => ({ }, waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, writeSavedEnvironmentBearerToken: vi.fn(), + writeSavedEnvironmentCredential: vi.fn(), })); vi.mock("./connection", async (importOriginal) => ({ @@ -231,6 +234,10 @@ describe("saved environment startup", () => { mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token"); + mockReadSavedEnvironmentCredential.mockImplementation(async () => { + const token = await mockReadSavedEnvironmentBearerToken(); + return token ? { version: 1, method: "bearer", token } : null; + }); mockCreateWsRpcClient.mockImplementation(() => createClient()); mockCreateEnvironmentConnection.mockImplementation((input) => { if (input.kind === "saved") { diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index ec2bcc41100..675a4868032 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -18,6 +18,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); const mockListSavedEnvironmentRecords = vi.fn(); const mockGetSavedEnvironmentRecord = vi.fn(); const mockReadSavedEnvironmentBearerToken = vi.fn(); +const mockReadSavedEnvironmentCredential = vi.fn(); const mockSavedEnvironmentRegistrySubscribe = vi.fn(); const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); const mockFetchRemoteSessionState = vi.fn(); @@ -35,7 +36,7 @@ vi.mock("../primary", () => ({ })); vi.mock("../../lib/runtime", () => ({ - remoteHttpRuntime: { + webRuntime: { runPromise: mockRemoteHttpRunPromise, }, })); @@ -46,6 +47,7 @@ vi.mock("./catalog", () => ({ listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, persistSavedEnvironmentRecord: vi.fn(), readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, + readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, removeSavedEnvironmentBearerToken: vi.fn(), useSavedEnvironmentRegistryStore: { subscribe: mockSavedEnvironmentRegistrySubscribe, @@ -65,6 +67,7 @@ vi.mock("./catalog", () => ({ }, waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, writeSavedEnvironmentBearerToken: vi.fn(), + writeSavedEnvironmentCredential: vi.fn(), })); vi.mock("./connection", async (importOriginal) => ({ @@ -78,6 +81,10 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { dispose: async () => undefined, reconnect: async () => undefined, isHeartbeatFresh: () => false, + cloud: { + getRelayClientStatus: vi.fn(), + installRelayClient: vi.fn(), + }, orchestration: { dispatchCommand: vi.fn(), getTurnDiff: vi.fn(), @@ -301,6 +308,10 @@ describe("retainThreadDetailSubscription", () => { mockListSavedEnvironmentRecords.mockReturnValue([]); mockGetSavedEnvironmentRecord.mockReturnValue(null); mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); + mockReadSavedEnvironmentCredential.mockImplementation(async () => { + const token = await mockReadSavedEnvironmentBearerToken(); + return token ? { version: 1, method: "bearer", token } : null; + }); mockFetchRemoteSessionState.mockResolvedValue({ authenticated: true, scopes: ["orchestration:read"], diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 406a855d3c2..38c62f55aca 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -1,6 +1,5 @@ import { AuthEnvironmentScope, - AuthStandardClientScopes, type DesktopSshEnvironmentBootstrap, type DesktopSshEnvironmentTarget, type EnvironmentId, @@ -16,12 +15,17 @@ import { type WsRpcClient, bootstrapRemoteBearerSession, fetchRemoteEnvironmentDescriptor, + fetchRemoteDpopSessionState, fetchRemoteSessionState, + type ManagedRelayDpopProofInput, + ManagedRelayDpopSigner, + resolveRemoteDpopWebSocketConnectionUrl, resolveRemoteWebSocketConnectionUrl, } from "@t3tools/client-runtime"; import { type QueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { createKnownEnvironment, @@ -40,21 +44,25 @@ import { ensureLocalApi } from "~/localApi"; import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup"; import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; import { getPrimaryKnownEnvironment } from "../primary"; -import { remoteHttpRuntime } from "../../lib/runtime"; +import { webRuntime } from "../../lib/runtime"; +import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment"; +import { readManagedRelayClerkToken } from "../../cloud/managedAuth"; import { getSavedEnvironmentRecord, hasSavedEnvironmentRegistryHydrated, listSavedEnvironmentRecords, persistSavedEnvironmentRecord, - readSavedEnvironmentBearerToken, + readSavedEnvironmentCredential, removeSavedEnvironmentBearerToken, type SavedEnvironmentRecord, + type SavedEnvironmentCredential, toPersistedSavedEnvironmentRecord, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, waitForSavedEnvironmentRegistryHydration, writeSavedEnvironmentBearerToken, + writeSavedEnvironmentCredential, } from "./catalog"; import { createEnvironmentConnection, @@ -159,6 +167,12 @@ const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; const NOOP = () => undefined; const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; +const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) => + Effect.gen(function* () { + const signer = yield* ManagedRelayDpopSigner; + return yield* signer.createProof(input); + }); + function createDeferredPromise() { let resolve: ((value: T) => void) | null = null; const promise = new Promise((nextResolve) => { @@ -1156,7 +1170,7 @@ function createPrimaryEnvironmentClient( function createSavedEnvironmentClient( environmentId: EnvironmentId, - bearerToken: string, + credentialRef: { current: SavedEnvironmentCredential }, ): WsRpcClient { useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); @@ -1167,19 +1181,70 @@ function createSavedEnvironmentClient( if (!record) { throw new Error(`Saved environment ${environmentId} not found.`); } - return record.desktopSsh - ? await resolveDesktopSshWebSocketConnectionUrl( - record.wsBaseUrl, - record.httpBaseUrl, - bearerToken, - ) - : await remoteHttpRuntime.runPromise( - resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: record.wsBaseUrl, - httpBaseUrl: record.httpBaseUrl, - bearerToken, - }), + const credential = credentialRef.current; + if (record.desktopSsh) { + if (credential.method !== "bearer") { + throw new Error("SSH environments require bearer credentials."); + } + return await resolveDesktopSshWebSocketConnectionUrl( + record.wsBaseUrl, + record.httpBaseUrl, + credential.token, + ); + } + if (credential.method === "dpop") { + try { + return await webRuntime.runPromise( + createManagedRelayDpopProof({ + method: "POST", + url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(), + accessToken: credential.accessToken, + }).pipe( + Effect.flatMap((proof) => + resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: record.wsBaseUrl, + httpBaseUrl: record.httpBaseUrl, + accessToken: credential.accessToken, + dpopProof: proof, + }), + ), + ), + ); + } catch (error) { + if (!isEnvironmentAuthInvalidError(error)) { + throw error; + } + const renewed = await renewManagedRelayCredential(record); + if (!renewed || renewed.credential.method !== "dpop") { + throw error; + } + const renewedCredential = renewed.credential; + credentialRef.current = renewedCredential; + return await webRuntime.runPromise( + createManagedRelayDpopProof({ + method: "POST", + url: new URL("/api/auth/websocket-ticket", renewed.record.httpBaseUrl).toString(), + accessToken: renewedCredential.accessToken, + }).pipe( + Effect.flatMap((proof) => + resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: renewed.record.wsBaseUrl, + httpBaseUrl: renewed.record.httpBaseUrl, + accessToken: renewedCredential.accessToken, + dpopProof: proof, + }), + ), + ), ); + } + } + return await webRuntime.runPromise( + resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: record.wsBaseUrl, + httpBaseUrl: record.httpBaseUrl, + bearerToken: credential.token, + }), + ); }, { getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, @@ -1221,7 +1286,7 @@ function createSavedEnvironmentClient( async function refreshSavedEnvironmentMetadata( environmentId: EnvironmentId, - bearerToken: string, + credential: SavedEnvironmentCredential, client: WsRpcClient, scopeHint?: ReadonlyArray | null, configHint?: ServerConfig | null, @@ -1234,13 +1299,31 @@ async function refreshSavedEnvironmentMetadata( const [serverConfig, sessionState] = await Promise.all([ configHint ? Promise.resolve(configHint) : client.server.getConfig(), record.desktopSsh - ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken) - : remoteHttpRuntime.runPromise( - fetchRemoteSessionState({ - httpBaseUrl: record.httpBaseUrl, - bearerToken, - }), - ), + ? credential.method === "bearer" + ? fetchDesktopSshSessionState(record.httpBaseUrl, credential.token) + : Promise.reject(new Error("SSH environments require bearer credentials.")) + : credential.method === "dpop" + ? webRuntime.runPromise( + createManagedRelayDpopProof({ + method: "GET", + url: new URL("/api/auth/session", record.httpBaseUrl).toString(), + accessToken: credential.accessToken, + }).pipe( + Effect.flatMap((proof) => + fetchRemoteDpopSessionState({ + httpBaseUrl: record.httpBaseUrl, + accessToken: credential.accessToken, + dpopProof: proof, + }), + ), + ), + ) + : webRuntime.runPromise( + fetchRemoteSessionState({ + httpBaseUrl: record.httpBaseUrl, + bearerToken: credential.token, + }), + ), ]); useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { @@ -1254,6 +1337,52 @@ async function refreshSavedEnvironmentMetadata( .rename(record.environmentId, serverConfig.environment.label); } +async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{ + readonly record: SavedEnvironmentRecord; + readonly credential: SavedEnvironmentCredential; +} | null> { + if (!record.relayManaged) { + return null; + } + const clerkToken = await readManagedRelayClerkToken(); + if (!clerkToken) { + return null; + } + const connected = await webRuntime.runPromise( + connectManagedCloudEnvironment({ + clerkToken, + relayUrl: record.relayManaged.relayUrl, + environment: { + environmentId: record.environmentId, + label: record.label, + linkedAt: record.createdAt, + endpoint: { + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + providerKind: "cloudflare_tunnel", + }, + }, + }), + ); + const nextRecord: SavedEnvironmentRecord = { + ...record, + label: connected.label, + httpBaseUrl: connected.httpBaseUrl, + wsBaseUrl: connected.wsBaseUrl, + }; + const credential: SavedEnvironmentCredential = { + version: 1, + method: "dpop", + accessToken: connected.accessToken, + }; + await persistSavedEnvironmentRecord(nextRecord); + if (!(await writeSavedEnvironmentCredential(nextRecord.environmentId, credential))) { + throw new Error("Unable to persist refreshed managed environment credentials."); + } + useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); + return { record: nextRecord, credential }; +} + function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { const existing = environmentConnections.get(connection.environmentId); if (existing && existing !== connection) { @@ -1320,8 +1449,10 @@ async function ensureSavedEnvironmentConnection( options?: { readonly client?: WsRpcClient; readonly bearerToken?: string; + readonly credential?: SavedEnvironmentCredential; readonly scopes?: ReadonlyArray | null; readonly serverConfig?: ServerConfig | null; + readonly allowManagedRenewal?: boolean; }, ): Promise { const existing = environmentConnections.get(record.environmentId); @@ -1340,14 +1471,17 @@ async function ensureSavedEnvironmentConnection( promise: Promise.resolve().then(async () => { let activeRecord = record; let scopeHint = options?.scopes ?? null; - let bearerToken = - options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); - if (!bearerToken) { + let credential = + options?.credential ?? + (options?.bearerToken + ? ({ version: 1, method: "bearer", token: options.bearerToken } as const) + : await readSavedEnvironmentCredential(record.environmentId)); + if (!credential) { if (record.desktopSsh) { const issued = await issueDesktopSshBearerSession(record); activeRecord = issued.record; - bearerToken = issued.bearerToken; - scopeHint = [...AuthStandardClientScopes]; + credential = { version: 1, method: "bearer", token: issued.bearerToken }; + scopeHint = issued.scopes; } else { useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { authState: "requires-auth", @@ -1363,10 +1497,10 @@ async function ensureSavedEnvironmentConnection( activeRecord = prepared.record; } - const activeBearerToken = bearerToken; + const activeCredential = { current: credential }; const client = options?.client ?? - createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken); + createSavedEnvironmentClient(activeRecord.environmentId, activeCredential); const initialConfigSnapshot = createDeferredPromise(); const knownEnvironment = createKnownEnvironment({ id: activeRecord.environmentId, @@ -1387,7 +1521,7 @@ async function ensureSavedEnvironmentConnection( refreshMetadata: async () => { await refreshSavedEnvironmentMetadata( activeRecord.environmentId, - activeBearerToken, + activeCredential.current, client, ); }, @@ -1416,7 +1550,7 @@ async function ensureSavedEnvironmentConnection( )); await refreshSavedEnvironmentMetadata( activeRecord.environmentId, - activeBearerToken, + activeCredential.current, client, scopeHint, initialServerConfig, @@ -1429,20 +1563,41 @@ async function ensureSavedEnvironmentConnection( throw error; } if (!activeRecord.desktopSsh) { + if ( + activeCredential.current.method === "dpop" && + options?.allowManagedRenewal !== false + ) { + const renewed = await renewManagedRelayCredential(activeRecord); + if (renewed) { + await connection.dispose().catch(() => undefined); + pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); + return await ensureSavedEnvironmentConnection(renewed.record, { + credential: renewed.credential, + scopes: scopeHint, + serverConfig: options?.serverConfig ?? null, + allowManagedRenewal: false, + }); + } + } await removeSavedEnvironmentBearerToken(activeRecord.environmentId); - throw new Error("Saved environment credential expired. Pair it again.", { - cause: error, - }); + throw new Error( + activeCredential.current.method === "dpop" + ? "Managed tunnel credential expired. Connect it again from T3 Cloud." + : "Saved environment credential expired. Pair it again.", + { + cause: error, + }, + ); } const issued = await issueDesktopSshBearerSession(activeRecord); activeRecord = issued.record; - bearerToken = issued.bearerToken; - scopeHint = [...AuthStandardClientScopes]; + credential = { version: 1, method: "bearer", token: issued.bearerToken }; + scopeHint = issued.scopes; await connection.dispose().catch(() => undefined); pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); return await ensureSavedEnvironmentConnection(activeRecord, { - bearerToken, + credential, scopes: scopeHint, serverConfig: options?.serverConfig ?? null, }); @@ -1647,7 +1802,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P await removeConnection(environmentId).catch(() => false); await ensureSavedEnvironmentConnection(issued.record, { bearerToken: issued.bearerToken, - scopes: [...AuthStandardClientScopes], + scopes: issued.scopes, }); return; } catch (recoveryError) { @@ -1686,7 +1841,7 @@ export async function addSavedEnvironment(input: { }); const descriptor = input.desktopSsh ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) - : await remoteHttpRuntime.runPromise( + : await webRuntime.runPromise( fetchRemoteEnvironmentDescriptor({ httpBaseUrl: resolvedTarget.httpBaseUrl, }), @@ -1701,7 +1856,7 @@ export async function addSavedEnvironment(input: { const bearerSession = input.desktopSsh ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) - : await remoteHttpRuntime.runPromise( + : await webRuntime.runPromise( bootstrapRemoteBearerSession({ httpBaseUrl: resolvedTarget.httpBaseUrl, credential: resolvedTarget.credential, @@ -1741,6 +1896,40 @@ export async function addSavedEnvironment(input: { return record; } +export async function addManagedRelayEnvironment(input: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly relayUrl: string; + readonly accessToken: string; +}): Promise { + const existingRecord = getSavedEnvironmentRecord(input.environmentId); + const record: SavedEnvironmentRecord = { + environmentId: input.environmentId, + label: input.label.trim() || existingRecord?.label || "Managed environment", + httpBaseUrl: input.httpBaseUrl, + wsBaseUrl: input.wsBaseUrl, + createdAt: existingRecord?.createdAt ?? isoNow(), + lastConnectedAt: isoNow(), + relayManaged: { relayUrl: input.relayUrl }, + }; + const credential: SavedEnvironmentCredential = { + version: 1, + method: "dpop", + accessToken: input.accessToken, + }; + + await persistSavedEnvironmentRecord(record); + if (!(await writeSavedEnvironmentCredential(record.environmentId, credential))) { + throw new Error("Unable to persist managed environment credentials."); + } + useSavedEnvironmentRegistryStore.getState().upsert(record); + await removeConnection(record.environmentId).catch(() => false); + await ensureSavedEnvironmentConnection(record, { credential }); + return record; +} + export async function connectDesktopSshEnvironment( target: DesktopSshEnvironmentTarget, options?: { label?: string }, diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index cd254c97548..78d063a9609 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -109,7 +109,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { function syncDesktopTheme(theme: Theme) { if (typeof window === "undefined") return; const bridge = window.desktopBridge; - if (!bridge || lastDesktopTheme === theme) { + if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { return; } diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index cc547323f8d..2caf844af9b 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -10,7 +10,7 @@ export interface HostedPairingRequest { export type HostedAppChannel = "latest" | "nightly"; -function configuredHostedAppUrl(): string { +export function configuredHostedAppUrl(): string { return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; } diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 8c1f0f0a5b5..60510f7ee84 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -8,15 +8,26 @@ import { PrimaryEnvironmentHttpClient, primaryEnvironmentHttpClientLive, } from "../environments/primary/httpClient"; +import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; -export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch)); +import { browserCryptoLayer } from "../cloud/dpop"; +import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer"; +import { resolveCloudPublicConfig } from "../cloud/publicConfig"; + +function configuredRelayUrl(): string { + return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; +} + +const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch); + +export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer); const primaryHttpRuntime = ManagedRuntime.make( primaryEnvironmentHttpClientLive.pipe( Layer.provide( Layer.mergeAll( remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), - Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }), + Layer.succeed(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), ), ), ), @@ -37,3 +48,13 @@ export const runPrimaryHttp = (effect: Effect.Effect = {}): DesktopBridg setTheme: async () => undefined, showContextMenu: async () => null, openExternal: async () => true, + createCloudAuthRequest: async () => "t3code-dev://auth/callback?t3_state=test", + getCloudAuthToken: async () => null, + setCloudAuthToken: async () => true, + clearCloudAuthToken: async () => undefined, + fetchCloudAuth: async () => ({ + ok: true, + status: 200, + statusText: "OK", + headers: {}, + body: "", + }), + onCloudAuthCallback: () => () => undefined, onMenuAction: () => () => undefined, getUpdateState: async () => { throw new Error("getUpdateState not implemented in test"); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 68a7dfaa931..f791f998d74 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,5 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { ClerkProvider } from "@clerk/react"; import { RouterProvider } from "@tanstack/react-router"; import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; @@ -7,6 +8,9 @@ import "@xterm/xterm/css/xterm.css"; import "./index.css"; import { isElectron } from "./env"; +import { DesktopClerkProvider } from "./cloud/desktopClerk"; +import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; +import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; @@ -22,8 +26,25 @@ if (isElectron) { document.title = APP_DISPLAY_NAME; +const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; +const cloudWaitlistUrl = "/settings/cloud"; + +const app = ; + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + {clerkPublishableKey && hasCloudPublicConfig() ? ( + isElectron ? ( + + {app} + + ) : ( + + {app} + + ) + ) : ( + app + )} , ); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 3a9140e278c..cafba0f829f 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' +import { Route as SettingsCloudRouteImport } from './routes/settings.cloud' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -72,6 +73,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ path: '/connections', getParentRoute: () => SettingsRoute, } as any) +const SettingsCloudRoute = SettingsCloudRouteImport.update({ + id: '/cloud', + path: '/cloud', + getParentRoute: () => SettingsRoute, +} as any) const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/cloud': typeof SettingsCloudRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -107,6 +114,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/cloud': typeof SettingsCloudRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -123,6 +131,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/settings/archived': typeof SettingsArchivedRoute + '/settings/cloud': typeof SettingsCloudRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -140,6 +149,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/cloud' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -153,6 +163,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/cloud' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -168,6 +179,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/settings/archived' + | '/settings/cloud' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -257,6 +269,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } + '/settings/cloud': { + id: '/settings/cloud' + path: '/cloud' + fullPath: '/settings/cloud' + preLoaderRoute: typeof SettingsCloudRouteImport + parentRoute: typeof SettingsRoute + } '/settings/archived': { id: '/settings/archived' path: '/archived' @@ -297,6 +316,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute + SettingsCloudRoute: typeof SettingsCloudRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -307,6 +327,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, + SettingsCloudRoute: SettingsCloudRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ec525fa2316..88283d451c3 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { @@ -135,6 +136,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 98a125bdfe4..50ca6c4b328 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -7,6 +7,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; import { useSavedEnvironmentRegistryStore } from "../environments/runtime"; import { APP_DISPLAY_NAME } from "~/branding"; +import { hasCloudPublicConfig } from "~/cloud/publicConfig"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -26,6 +27,8 @@ export const Route = createFileRoute("/_chat/")({ }); function HostedStaticOnboardingState() { + const cloudEnabled = hasCloudPublicConfig(); + return (
@@ -48,13 +51,17 @@ function HostedStaticOnboardingState() { Connect an environment to get started - Open a pairing link from your T3 Code desktop app or add a reachable backend - manually. Your saved environments stay in this browser. + {cloudEnabled + ? "Sign in to T3 Cloud to connect a linked environment through its managed tunnel, or add a reachable backend manually." + : "Add a reachable backend manually to start working from this browser."}
-
diff --git a/apps/web/src/routes/settings.cloud.tsx b/apps/web/src/routes/settings.cloud.tsx new file mode 100644 index 00000000000..a2854e9719b --- /dev/null +++ b/apps/web/src/routes/settings.cloud.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +import { CloudSettingsPanel } from "../components/settings/CloudSettings"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; + +export const Route = createFileRoute("/settings/cloud")({ + beforeLoad: () => { + if (!hasCloudPublicConfig()) { + throw redirect({ to: "/settings/general", replace: true }); + } + }, + component: CloudSettingsPanel, +}); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 59704466ac4..0c06dbb9ddd 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -7,6 +7,8 @@ interface ImportMetaEnv { readonly VITE_WS_URL: string; readonly VITE_HOSTED_APP_URL: string; readonly VITE_HOSTED_APP_CHANNEL: string; + readonly VITE_CLERK_PUBLISHABLE_KEY: string; + readonly VITE_CLERK_JWT_TEMPLATE: string; readonly APP_VERSION: string; } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index cea82976917..f8684c2d0e8 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -26,5 +26,5 @@ } ] }, - "include": ["src", "vite.config.ts", "vercel.ts", "test"] + "include": ["src", "vite.config.ts", "vercel.ts", "test", "../../scripts/lib/public-config.ts"] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 0daab6204dd..b927c40af01 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -8,9 +8,17 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; import pkg from "./package.json" with { type: "json" }; +import { loadRepoEnv } from "../../scripts/lib/public-config"; + +const repoEnv = loadRepoEnv(); +Object.assign(process.env, repoEnv); + const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; const configuredWsUrl = process.env.VITE_WS_URL?.trim(); +const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; +const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; +const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; const configuredHostedAppUrl = (() => { @@ -122,6 +130,9 @@ export default defineConfig(() => { define: { // In dev mode, tell the web app where the WebSocket server lives "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), + "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), + "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), + "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate), "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), diff --git a/docs/relay-observability.md b/docs/relay-observability.md new file mode 100644 index 00000000000..dafad2155af --- /dev/null +++ b/docs/relay-observability.md @@ -0,0 +1,42 @@ +# Relay observability + +The relay Alchemy stack owns a focused Axiom trace setup: + +- `t3-code-relay-traces-prod`, an OpenTelemetry trace dataset for Worker requests +- `t3-code-relay-otel-ingest-prod`, a dataset-scoped ingest token bound to the Worker +- `t3-code-relay-recent-spans-prod`, a view of recent request and endpoint spans + +Alchemy stages append their sanitized stage name to isolate resources, for example +`t3-code-relay-traces-dev-julius` for a personal stage. + +Deploy from `infra/relay` with the normal Alchemy workflow: + +```sh +vp run deploy +``` + +Alchemy resolves Axiom deployment credentials through its provider. At runtime, the Worker +receives only the scoped ingest token; it does not receive the diagnostics query token. + +The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans. +Effect's OpenTelemetry exporter stores semantic HTTP attributes below the `attributes.` prefix. +For example: + +```apl +['t3-code-relay-traces-prod'] +| where name startswith 'http.server' +| project _time, name, trace_id, duration, + ['attributes.http.request.method'], + ['attributes.url.path'], + ['attributes.http.response.status_code'] +| order by _time desc +| limit 200 +``` + +Endpoint failure annotations and other relay-specific attributes are also emitted in the +`attributes.custom` map when present on a span, for example +`['attributes.custom']['relay.endpoint']`. + +Agents should prefer the provisioned view or APL queries for completed incidents instead of +tailing the Cloudflare Worker. Use the read-only query token when scripted access is needed; +keep the ingest token reserved for the Worker. diff --git a/docs/release.md b/docs/release.md index df9033218b9..a90c67ee60a 100644 --- a/docs/release.md +++ b/docs/release.md @@ -7,9 +7,10 @@ This document covers the unified release workflow for stable and nightly desktop - Workflow: `.github/workflows/release.yml` - Triggers: - push tag matching `v*.*.*` for stable releases - - scheduled nightly at `09:00 UTC` + - scheduled nightly check every three hours - manual `workflow_dispatch` for either channel - Runs quality gates first: lint, typecheck, test. +- Reads the shared production T3 Cloud relay URL and Clerk client configuration before packaging clients. - Builds four artifacts in parallel for both channels: - macOS `arm64` DMG - macOS `x64` DMG @@ -29,6 +30,58 @@ This document covers the unified release workflow for stable and nightly desktop - nightly releases are aliased to the `nightly` hosted app channel - Signing is optional and auto-detected per platform from secrets. +## T3 Cloud relay deployment + +The relay is a shared control plane versioned separately from client releases. Stable and nightly +client builds must point at the same relay so users see the same linked environments when switching +release channels. + +`.github/workflows/deploy-relay.yml` deploys Alchemy stage `prod` on every push to `main`. The +release workflow reads the relay URL and Clerk client configuration from the existing `production` +GitHub Actions environment before building desktop, CLI, or hosted web artifacts. + +Required repository variables shared by relay deployments: + +- `CLOUDFLARE_ACCOUNT_ID` +- `PLANETSCALE_ORGANIZATION` +- `AXIOM_ORG_ID` + +Required repository secrets shared by relay deployments: + +- `CLOUDFLARE_API_TOKEN` +- `PLANETSCALE_API_TOKEN_ID` +- `PLANETSCALE_API_TOKEN` +- `AXIOM_TOKEN` + +Required `production` environment variables: + +- `RELAY_DOMAIN` +- `RELAY_ZONE_NAME` +- `CLERK_PUBLISHABLE_KEY` +- `CLERK_JWT_AUDIENCE` +- `CLERK_JWT_TEMPLATE` +- `CLERK_CLI_OAUTH_CLIENT_ID` +- `APNS_ENVIRONMENT` +- `APNS_TEAM_ID` +- `APNS_KEY_ID` +- `APNS_BUNDLE_ID` + +Required `production` environment secrets: + +- `CLERK_SECRET_KEY` +- `APNS_PRIVATE_KEY` + +The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they +are not bound into the relay Worker. The production deployment uses an Axiom personal access token, +so `AXIOM_ORG_ID` must accompany `AXIOM_TOKEN`. The `prod` stage owns the retained PlanetScale +database. Local personal stages provision isolated branches from it and are never deployed by CI. + +Developers deploy personal stages locally rather than through pull-request automation: + +```sh +vp run --filter t3code-relay deploy -- --stage "$USER" --env-file .env.local +``` + ## Hosted web app release deployment The hosted app is intentionally not deployed by Vercel's Git integration. The @@ -85,7 +138,7 @@ One-time Vercel dashboard setup: - Workflow: `.github/workflows/release.yml` - Triggers: - - scheduled every day at `09:00 UTC` + - scheduled check every three hours - manual `workflow_dispatch` with `channel=nightly` - Runs the same desktop quality gates and artifact matrix as the tagged release flow. - Publishes a GitHub prerelease only: diff --git a/docs/t3-cloud-clerk.md b/docs/t3-cloud-clerk.md new file mode 100644 index 00000000000..09c92150585 --- /dev/null +++ b/docs/t3-cloud-clerk.md @@ -0,0 +1,173 @@ +# T3 Cloud Clerk Setup + +T3 Cloud uses one Clerk application for web, desktop, and mobile authentication. The relay accepts +Clerk JWTs only when they are generated from the `t3-relay` template with the shared +`t3-code-relay` audience. + +## Application Keys + +T3 Cloud is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env` +or `.env.local` file: + +```dotenv +T3CODE_CLERK_PUBLISHABLE_KEY= +T3CODE_CLERK_JWT_TEMPLATE= +T3CODE_CLERK_CLI_OAUTH_CLIENT_ID= +T3CODE_RELAY_URL=https://relay.example.com +``` + +The shared client loader projects these canonical values into framework-specific `VITE_*` and +`EXPO_PUBLIC_*` aliases. Existing aliases remain accepted as overrides for compatibility, but new +client configuration should use the canonical names. + +Configuration precedence is: + +1. Process or CI environment variables. +2. Repository-root `.env.local`. +3. Repository-root `.env`. + +The Clerk publishable key, JWT template name, CLI OAuth client ID, and relay URL are public +identifiers, not secrets. +Web, desktop, mobile, and bundled server builds statically inject the values they consume during +their build step. A built artifact does not need an environment file at runtime. CI release builds +should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, +`T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building. EAS preview and +production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS +environment. + +When any client-facing public value is absent, cloud UI is omitted. When the CLI public values are +absent, the `t3 cloud` CLI command group is omitted. The bundled server still accepts runtime +overrides for self-hosted or operator-managed +deployments. + +For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay +deployment reads `RELAY_DOMAIN`, `RELAY_ZONE_NAME`, `CLERK_PUBLISHABLE_KEY`, and +`CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in deployment defaults. +`vp run --filter t3code-relay deploy` invokes Alchemy from the relay directory, so Alchemy loads +`infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env` +with the HTTPS relay URL derived from `RELAY_DOMAIN`. The relay still requires +`CLERK_SECRET_KEY` as an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application +environment or commit it to the repository. + +The `prod` Alchemy stage owns the retained PlanetScale database. Non-production stages reference +that database and provision isolated PlanetScale branches, so deploy `prod` before creating a +personal developer stage. + +## Headless CLI OAuth Application + +The `t3 cloud` commands authorize a headless environment with a separate Clerk OAuth application. +This uses an OAuth public client with PKCE, so the CLI stores no client secret. + +In **Clerk Dashboard > OAuth applications**: + +1. Create an OAuth application for the T3 CLI. +2. Enable the **Public** option so authorization-code exchange uses PKCE. +3. Add `http://127.0.0.1:34338/callback` as an allowed redirect URI. +4. Enable the `openid`, `profile`, and `email` scopes. +5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build + environment to the generated public client ID. + +The CLI derives Clerk's frontend API URL from the publishable key and calls Clerk's +`/oauth/authorize` and `/oauth/token` endpoints directly. The relay is not involved in the OAuth +handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link. + +The CLI supports these headless operations: + +```sh +t3 cloud login +t3 cloud link +t3 cloud status +t3 cloud unlink +t3 cloud logout +t3 serve +``` + +`t3 cloud login` opens the Clerk authorization flow and stores the CLI credential without enabling +cloud exposure. `t3 cloud link` installs the pinned managed `cloudflared` binary when needed, +authorizes when needed, and records durable intent to expose the environment. It works without a +running T3 server. The next `t3 serve` or `t3 start` reconciles the relay link and launches the +managed tunnel. `t3 cloud unlink` records disabled intent immediately, stops a reachable running +connector, and attempts to revoke the relay-side environment record. It retains the stored CLI +authorization so `t3 cloud link` can re-enable exposure without another browser flow. `t3 cloud +logout` performs the same cleanup and removes the stored CLI authorization. + +The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH, +forward that port before running `t3 cloud login` or `t3 cloud link`: + +```sh +ssh -L 34338:127.0.0.1:34338 +``` + +A relay-hosted callback broker can remove this port-forward requirement later without changing the +stored PKCE token model. + +## JWT Template + +In **Clerk Dashboard > JWT templates**, create a template with: + +| Setting | Value | +| ------- | ---------------------------- | +| Name | `t3-relay` | +| Claims | `{ "aud": "t3-code-relay" }` | + +Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` in the repository-root `.env`, and set +`CLERK_JWT_AUDIENCE=t3-code-relay` in `infra/relay/.env`. Define `CLERK_JWT_TEMPLATE` and +`CLERK_JWT_AUDIENCE` in the production relay deployment environment as well. The stable `aud` value +is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still +selects the concrete relay deployment, but changing that URL does not require a JWT template change. + +## Desktop OAuth Redirect Allowlist + +The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme. +In **Clerk Dashboard > Native applications**, enable native application support and add these +entries under the mobile SSO redirect allowlist: + +```text +t3code-dev://auth/callback +t3code://auth/callback +``` + +The first entry is for local desktop development. The second is for packaged desktop builds. +The app also adds a request-scoped `t3_state` query parameter and validates it on callback. Initial +sign-in and linked-account OAuth flows both return through this bridge. The desktop provider keeps +Clerk's stock profile component, replaces its renderer-page callback with the custom-scheme callback, +and opens the provider URL in the system browser. Do not add the local renderer URL as an OAuth +redirect: an external browser cannot use it to reopen the packaged app. + +The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth +flow uses a custom redirect URI, add that exact URI to the same allowlist. + +## Enable Waitlist Access + +For a private beta where people should request access, use **Clerk Dashboard > Waitlist**: + +1. Toggle on **Enable waitlist** and save. +2. Review requests on the same page and select **Invite** or **Deny**. + +Signed-out web and desktop users see Clerk's waitlist enrollment as the T3 Cloud page content, +while approved signed-in users see cloud settings. The browser app also uses `/settings/cloud` as +its Clerk waitlist URL. + +On mobile, signed-out users open **Settings > T3 Account** to reach `/settings/waitlist` within the +Settings form sheet. It submits enrollment through Clerk's `useWaitlist()` flow because the prebuilt +`` component is web-only in the Expo SDK. Approved users can use **Sign in** from that +screen. + +## Alternative: Known-User Allowlist + +For a closed beta where all permitted users are known in advance, use an allowlist instead of a +request-and-approval waitlist: + +To restrict the beta to permitted email addresses or domains: + +1. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email + domain. +2. Enable the allowlist and save. +3. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or + manually created without a waitlist request flow. + +Do not enable an empty allowlist: it blocks all new sign-ups. + +Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud +access. To remove an already-created user's access, ban that user in Clerk so their active +sessions are ended and future sign-ins are rejected. diff --git a/docs/t3-code-cloud-auth-flow.html b/docs/t3-code-cloud-auth-flow.html new file mode 100644 index 00000000000..8b9f5ac40ab --- /dev/null +++ b/docs/t3-code-cloud-auth-flow.html @@ -0,0 +1,1159 @@ + + + + + + T3 Code Cloud Architecture + + + +
+
+
Implemented architecture
+

T3 Code Cloud Control Plane and Managed Endpoint Flow

+

+ T3 Cloud links a locally authorized T3 environment to a signed-in cloud user, provisions a + managed HTTPS/WSS endpoint for that environment, and brokers proof-bound remote + connections. The local environment remains the authority that issues environment access + credentials. The hosted relay stores links, reconciles managed endpoint resources, + validates signed proofs, and delivers agent-activity notifications. +

+
+ Client -> Relay: Clerk bearer or relay DPoP + Relay -> Environment: relay-signed health and mint proofs + Environment -> Relay: environment bearer plus signed activity proof + Client -> Environment: environment access token plus DPoP +
+
+ +
+
+

Security Invariant

+

+ A cloud identity is not an environment login. Remote access requires all of the + following: +

+
    +
  • a Clerk-authenticated T3 Cloud user;
  • +
  • an active relay link for that user and environment;
  • +
  • a DPoP proof key held by the remote client;
  • +
  • a relay-signed mint request accepted by the linked local environment; and
  • +
  • an environment-issued credential bound to the remote client's DPoP thumbprint.
  • +
+
+ The relay cannot mint an environment access token by itself. It can only ask the local + environment to mint a short-lived bootstrap credential for an authorized cloud user. +
+

During an honest connection flow, the relay cannot act as the remote client:

+
    +
  • + Normal environment API traffic goes directly from the remote client through the + managed endpoint. The relay does not receive the resulting environment access token. +
  • +
  • + The one-time bootstrap credential and resulting environment access token are bound to + the remote client's DPoP public-key thumbprint. Redeeming or using them requires + request proofs signed by the corresponding private key, which stays on the client. +
  • +
  • + Relay-signed health and mint proofs are short-lived, audience-bound, scoped, and + replay-guarded. They are accepted only by the narrow cloud endpoints, not as normal + environment API credentials. +
  • +
  • + Environment health and mint responses are signed by the linked environment and bound + to the request nonce, so a different process behind the tunnel cannot impersonate the + linked environment. +
  • +
+
+ The hosted relay remains a trusted mint broker: it holds the cloud-mint signing key. + DPoP prevents credential reuse by the relay or tunnel transport during an honest flow, + but it does not make compromise of the relay signing authority harmless. +
+
+ +
+

Product Boundaries

+

+ The generic local connector is called the relay client. The current + implementation layer uses cloudflared, but that is an internal transport + detail rather than the product contract. +

+
    +
  • + The relay Worker owns cloud links, managed endpoint allocations, and notifications. +
  • +
  • + The environment server owns local sessions, environment keys, and access tokens. +
  • +
  • The relay client exposes only the environment's loopback HTTP server.
  • +
  • + Web and mobile clients connect directly to the managed environment endpoint after + bootstrap. +
  • +
+
+
+ +
+

Current Topology

+
+flowchart LR
+  subgraph Clients["User-facing clients"]
+    Web["Desktop / hosted web UI"]
+    Mobile["Mobile app"]
+    CLI["Headless CLI"]
+  end
+
+  Clerk["Clerk
user identity and OAuth"] + + subgraph Relay["T3 Code Relay on Cloudflare"] + Worker["Relay Worker
HTTP API"] + Queue["APNs delivery queue
with dead-letter queue"] + Hyperdrive["Hyperdrive"] + CF["Cloudflare tunnel + DNS APIs"] + end + + DB["PlanetScale Postgres
prod database + stage branches"] + APNs["Apple Push Notification service"] + Traces["Axiom OTLP traces"] + + subgraph Local["Linked T3 environment"] + Env["Environment server
local auth authority"] + RelayClient["Relay client
cloudflared implementation"] + Secrets["Server secret store
keys + relay config"] + end + + Endpoint["Managed HTTPS/WSS endpoint"] + + Web -->|"Clerk bearer / relay DPoP"| Worker + Mobile -->|"Clerk bearer / relay DPoP"| Worker + CLI -->|"Clerk OAuth bearer"| Worker + Web --> Clerk + Mobile --> Clerk + CLI -->|"PKCE authorization code flow"| Clerk + Worker --> Hyperdrive --> DB + Worker --> Traces + Worker --> CF + Worker --> Queue --> APNs + Worker --> Endpoint + Endpoint --> RelayClient -->|"http://127.0.0.1:port"| Env + Env --> Secrets + Web -->|"environment token + DPoP
HTTPS / WSS"| Endpoint + Mobile -->|"environment token + DPoP
HTTPS / WSS"| Endpoint + Env -->|"environment bearer + signed activity proof"| Worker +
+
+ +
+

Authentication and Transport Matrix

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BoundaryTransportAuthenticationPurpose
Web or mobile -> relay link managementHTTPSClerk session-template bearer tokenCreate a link challenge, submit an environment proof, list links, or unlink.
CLI -> relay link managementHTTPSClerk OAuth access token obtained with PKCEReconcile the desired headless cloud link when the server starts.
Web or mobile -> relay protected client endpointsHTTPSRelay-issued DPoP token plus per-request DPoP proof + Check environment status, request a remote connection, and register mobile devices. +
Relay -> managed environment endpointHTTPS over the managed tunnelShort-lived relay-signed JWT request proof + Request a signed health response or ask the local environment to mint a credential. +
Environment -> relay activity publicationHTTPSRelay-issued environment bearer credential plus environment-signed JWT proof + Publish redacted agent-activity state for push notifications and Live Activities. +
Remote client -> environmentHTTPS and WSS over the managed tunnelEnvironment-issued access token bound to the client DPoP keyUse the normal environment HTTP APIs and request a one-time WebSocket ticket.
+
+ +
+

Credential Ownership

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CredentialCreated byStored byUse
Clerk session-template JWTClerkWeb or mobile client + Authenticate cloud-user relay requests and bootstrap relay DPoP token exchange. +
CLI OAuth access and refresh tokenClerk OAuthLocal server secret storeAuthorize headless CLI link reconciliation.
Environment Ed25519 key pairLocal environment serverLocal server secret store + Sign link proofs, health responses, mint responses, and activity publication proofs. +
Relay cloud-mint key pairRelay deploymentPrivate key in relay Worker config; public key installed into the environmentSign relay-to-environment health and mint requests.
Relay environment bearer credentialRelayHashed in relay Postgres; plaintext in local server secret storeAuthenticate environment-to-relay agent-activity publication.
Relay DPoP access tokenRelayRemote client memory cache + Authorize status, connect, and mobile registration endpoints with proof of + possession. +
Environment bootstrap credentialLocal environment serverPassed relay -> remote clientOne-time exchange at the environment /oauth/token endpoint.
Environment access tokenLocal environment serverRemote clientAuthorize environment HTTP operations and WebSocket ticket issuance.
WebSocket ticketLocal environment serverRemote client until consumedShort-lived one-time credential for the WSS upgrade.
Relay client connector tokenCloudflare tunnel APILocal server secret store + Start the local relay client. It is passed through TUNNEL_TOKEN, not a + shell argument. +
+
+ +
+
+

Flow 1: Link From the Desktop Web UI

+
+sequenceDiagram
+  participant U as Signed-in user
+  participant C as Desktop web UI
+  participant E as Local environment
+  participant R as Relay Worker
+  participant CF as Cloudflare APIs
+  participant RC as Local relay client
+
+  U->>C: Enable T3 Cloud for a locally paired environment
+  C->>E: Check relay client availability
+  alt relay client is missing
+    C->>U: Prompt before download and install
+    U->>C: Confirm
+    C->>E: Stream relay-client install RPC
+    E-->>C: Progress stages
+  end
+  C->>R: POST /v1/client/environment-link-challenges (Clerk bearer)
+  R-->>C: Short-lived challenge
+  C->>E: POST /api/cloud/link-proof (local relay:write bearer)
+  E->>E: Authenticate, reject forwarded authority, get or create key pair, validate loopback origin
+  E-->>C: Environment-signed link proof
+  C->>R: POST /v1/client/environment-links (Clerk bearer + proof)
+  R->>R: Verify challenge, proof, capabilities, nonce, and loopback origin
+  R->>CF: Reconcile tunnel, ingress, and CNAME
+  R->>R: Upsert user/environment link and issue environment bearer
+  R-->>C: Managed endpoint, relay config, and connector token
+  C->>E: POST /api/cloud/relay-config (local relay:write bearer)
+  E->>RC: Start relay client with TUNNEL_TOKEN
+          
+
+ The local link-proof endpoint requires relay:write, rejects forwarded + authority headers, and accepts only an exact loopback origin. Environment keypair + persistence is atomic across concurrent link attempts. +
+

+ A locally paired mobile client uses the same relay challenge, local proof, relay link, + and local relay-config endpoints. The desktop web UI is the surface that checks relay + client availability and offers the managed install dialog. +

+
+ +
+

Flow 2: Headless CLI Link

+
+sequenceDiagram
+  participant U as Operator
+  participant CLI as t3 cloud CLI
+  participant Clerk as Clerk OAuth
+  participant S as Environment server on next start
+  participant R as Relay Worker
+  participant RC as Local relay client
+
+  U->>CLI: t3 cloud link
+  CLI->>CLI: Resolve relay client
+  alt relay client is missing
+    CLI->>U: Confirm managed relay-client install
+    U->>CLI: Confirm
+    CLI->>CLI: Install with terminal progress updates
+  end
+  CLI->>Clerk: Browser PKCE authorization-code flow
+  Clerk-->>CLI: OAuth access and refresh tokens
+  CLI->>CLI: Persist desired cloud-link state
+  U->>S: Start T3 environment server
+  S->>R: Create challenge and submit local signed proof
+  R-->>S: Managed endpoint config and connector token
+  S->>RC: Start relay client
+          
+

+ t3 cloud login stores authorization without enabling exposure. + t3 cloud unlink disables exposure while retaining authorization. + t3 cloud logout also removes the stored CLI authorization. +

+
+ +
+

Flow 3: Remote Connect Bootstrap

+
+sequenceDiagram
+  participant C as Web or mobile client
+  participant Clerk as Clerk
+  participant R as Relay Worker
+  participant E as Managed environment endpoint
+
+  C->>Clerk: Get Clerk session-template JWT
+  C->>R: POST /v1/client/dpop-token (Clerk JWT + DPoP proof)
+  R-->>C: Relay DPoP access token
+  C->>R: POST /v1/environments/:id/connect (relay DPoP)
+  R->>E: POST /api/t3-cloud/mint-credential (relay-signed proof)
+  E->>E: Verify relay signer, linked user, scope, lifetime, cnf, and replay guards
+  E-->>R: Bootstrap credential + environment-signed proof
+  R->>R: Verify environment signature, nonce, endpoint, and DPoP binding
+  R-->>C: Managed endpoint + bootstrap credential
+  C->>E: POST /oauth/token (bootstrap credential + DPoP proof)
+  E-->>C: Environment DPoP-bound access token
+  C->>E: POST /api/auth/websocket-ticket
+  E-->>C: One-time WebSocket ticket
+  C->>E: WSS /ws with ticket
+          
+
+ Relay-to-environment control requests disable redirects, use only a reconciled managed + endpoint allocation, and require a signed environment response before the relay returns + data to the client. +
+
+ +
+

Flow 4: Agent Activity Notifications

+
+sequenceDiagram
+  participant E as Local environment
+  participant R as Relay Worker
+  participant DB as Relay Postgres
+  participant Q as APNs queue
+  participant APNs as Apple Push Notification service
+  participant M as Mobile app
+
+  E->>E: Project publishable thread state and redact failure detail
+  E->>R: POST /v1/environments/:environmentId/threads/:threadId/agent-activity
+  Note over E,R: Environment bearer credential + environment-signed per-state proof
+  R->>R: Verify credential, signature, scope, expiry, and nonce
+  R->>DB: Store current activity row
+  R->>Q: Enqueue push or Live Activity delivery jobs
+  Q->>APNs: Deliver signed APNs request
+  APNs-->>M: Push notification or Live Activity update
+          
+

+ The local server publishes a deliberately narrow projection. Failed runs use a fixed + redacted summary, and detail strings are capped before publication. +

+
+
+ +
+
+

Managed Endpoint Reconciliation

+

+ Managed endpoint resources are keyed by (userId, environmentId). The relay + reserves a stable hostname and tunnel name before provisioning, then checkpoints the + tunnel ID, DNS record ID, and ready state in Postgres. +

+
+
+ 1. Reserve deterministic allocation + Hostname and tunnel name derive from relay stage, cloud user, and environment ID. +
+
+ 2. Reuse or create tunnel + Existing named tunnels are reused. Tunnel ingress is rewritten to the validated + loopback origin. +
+
+ 3. Reconcile DNS + Existing CNAMEs are updated and duplicate records are removed. Create conflicts are + recovered by listing and reconciling the winning record. +
+
+ 4. Mark ready + Connect and status requests use only ready allocations whose hostname still matches + the relay-managed namespace. +
+
+
+ +
+

Unlink Cleanup

+

+ Relay unlink removes the user's managed endpoint allocation before revoking the + user/environment link: +

+
    +
  • delete the managed DNS record if present;
  • +
  • delete the Cloudflare tunnel if present;
  • +
  • remove the allocation row;
  • +
  • revoke the user's environment link; and
  • +
  • + revoke the environment publication credential only after the final active link for + that environment key disappears. +
  • +
+

+ Local unlink stops the relay client and removes the locally persisted relay URL, issuer, + linked user, environment publication credential, cloud-mint public key, connector + config, and agent-activity preference. +

+
+
+ +
+

Cloudflare Tunnel Operational Profile

+

+ Managed tunnels are private application backends, not general-purpose public hosting. The + relay provisions one named Cloudflare tunnel and one DNS record per active + (userId, environmentId) allocation. +

+
    +
  • + Lifecycle: the tunnel is created when an environment is linked, reused + across retry-safe reconciliation, kept for the lifetime of that link, and deleted on + unlink. If teardown fails, the allocation checkpoint remains so cleanup can be retried. +
  • +
  • + Origin: tunnel ingress is restricted to the linked environment server's + validated loopback HTTP origin. Arbitrary upstream hosts and raw TCP origins are + rejected. +
  • +
  • + Protocols: managed endpoints carry HTTPS request/response traffic and + WSS connections only. Remote clients use the normal environment HTTP APIs and + authenticated WebSocket RPC transport through the tunnel. +
  • +
  • + Relay-generated traffic: the hosted relay sends sparse HTTPS health + checks and credential-mint requests during status checks and remote connection + bootstrap. It does not proxy steady-state remote sessions. +
  • +
  • + Traffic shape: expected load is interactive and user-driven. Active + remote sessions may keep WebSockets open and exchange terminal, agent, and UI updates; + inactive linked environments should carry little or no tunnel traffic. +
  • +
  • + Capacity planning: the project is pre-launch, so there is not yet a + production bandwidth baseline. Tunnel count grows with active linked user/environment + allocations, while bandwidth and concurrent WebSockets grow with active remote use. +
  • +
+
+ +
+

SSRF and Tunnel Hardening

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RiskImplemented control
A client asks the relay to tunnel an arbitrary host. + The environment and relay both accept managed tunnel origins only for loopback hosts + and valid ports. +
A forwarded request tricks local origin validation. + The local link-proof handler requires an exact loopback request URL and rejects + forwarded host or protocol headers. +
A stored endpoint is replaced with an arbitrary external URL. + Connect and status resolve a ready allocation from Postgres and require a hostname + under the configured managed endpoint zone. +
A managed endpoint redirects relay egress elsewhere. + Relay-to-environment status and mint requests set redirect handling to + manual. +
A process behind the tunnel returns attacker-controlled data. + The relay verifies environment-signed health and mint responses against the linked + environment public key, expected nonce, and request binding. +
A stolen remote access token is replayed. + Relay and environment access tokens are DPoP-bound; per-request proofs and replay + guards are required. +
The relay client token leaks through process listings or shell parsing. + The relay client is spawned without a shell and receives its connector token in + TUNNEL_TOKEN. +
+
+ Managed tunnel hostnames currently live under the configured relay DNS zone. Serving them + from a dedicated registrable domain with a Public Suffix List entry remains an operational + isolation requirement before broad untrusted use. +
+
+ +
+

Relay HTTP API

+

+ The relay publishes an OpenAPI document at /openapi.json, interactive API + docs at /docs, and redirects / to the docs. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointAuthenticationPurpose
GET /healthNoneRelay health check.
GET /.well-known/oauth-authorization-serverNoneRelay OAuth token-exchange discovery metadata.
GET /.well-known/oauth-protected-resourceNoneRelay DPoP protected-resource discovery metadata.
GET /v1/environmentsClerk bearerList environments linked to the signed-in user.
POST /v1/client/environment-link-challengesClerk bearerCreate a short-lived user-bound environment link challenge.
POST /v1/client/environment-linksClerk bearer + Verify a local environment proof, reconcile a managed endpoint, and upsert the user + link. +
DELETE /v1/client/environment-links/:environmentIdClerk bearerRemove managed endpoint resources and revoke the user's link.
POST /v1/client/dpop-tokenClerk token in token-exchange payload plus DPoP proofIssue a relay DPoP access token for the requested supported scopes.
POST /v1/environments/:environmentId/statusRelay DPoPRequest and validate a signed environment health response.
POST /v1/environments/:environmentId/connectRelay DPoPRequest and validate an environment bootstrap credential.
POST /v1/mobile/devicesRelay DPoPRegister or update a mobile device for notifications.
POST /v1/mobile/live-activitiesRelay DPoPRegister a Live Activity push token.
DELETE /v1/mobile/devices/:deviceIdRelay DPoPUnregister a mobile device.
+ POST /v1/environments/:environmentId/threads/:threadId/agent-activity + Environment bearer plus signed publish proofPublish redacted current agent activity for notification delivery.
+
+ +
+

Environment Cloud HTTP API

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EndpointAuthenticationPurpose
POST /api/cloud/link-proofLocal environment token with relay:writeValidate local origin and sign an environment link proof.
POST /api/cloud/relay-configLocal environment token with relay:writePersist linked relay configuration and start the relay client.
GET /api/cloud/link-stateLocal environment token with relay:readRead the environment's current T3 Cloud link state.
POST /api/cloud/preferencesLocal environment token with relay:writeEnable or disable agent-activity publication.
POST /api/cloud/unlinkLocal environment token with relay:writeStop the relay client and clear local T3 Cloud configuration.
POST /api/t3-cloud/healthRelay-signed proofReturn a signed environment health response.
POST /api/t3-cloud/mint-credentialRelay-signed proofMint a DPoP-bound one-time bootstrap credential for a remote client.
POST /oauth/tokenBootstrap credential plus DPoP proofExchange a bootstrap credential for an environment access token.
POST /api/auth/websocket-ticketEnvironment access tokenIssue a one-time ticket for the WSS upgrade.
+
+ +
+
+

Relay Client Installation

+

+ Desktop web UI availability checks and installs are local WebSocket RPC methods: + cloud.getRelayClientStatus and streaming + cloud.installRelayClient. The install stream emits the current stage: + checking, waiting for lock, downloading, verifying, installing, validating, and + activating. +

+

+ The web UI asks for confirmation before installation and displays a custom progress + dialog. The CLI invokes the same local relay-client service directly and reports the + same progress stages in the terminal. +

+
+ +
+

Relay Infrastructure

+
    +
  • Cloudflare Worker for the hosted relay HTTP API.
  • +
  • PlanetScale Postgres database named t3coderelay.
  • +
  • + Production uses the shared database; non-production stages use database branches. +
  • +
  • + Cloudflare Hyperdrive with caching disabled and a constrained origin connection limit. +
  • +
  • Cloudflare tunnel and DNS bindings for managed endpoint reconciliation.
  • +
  • Cloudflare APNs delivery queue plus dead-letter queue.
  • +
  • A five-minute cron job that prunes expired DPoP replay rows.
  • +
  • Axiom OTLP trace dataset, scoped ingest token, and recent-spans view per stage.
  • +
+
+
+ +
+

Deployment Configuration

+ + + + + + + + + + + + + + + + + + + + + +
BoundaryConfiguration
Relay infrastructure + RELAY_ZONE_NAME, optional RELAY_DOMAIN, + CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, + CLERK_JWT_AUDIENCE, and APNs credentials. +
Source-built desktop/server cloud features + Optional T3CODE_RELAY_URL, T3CODE_CLERK_PUBLISHABLE_KEY, + and T3CODE_CLERK_CLI_OAUTH_CLIENT_ID. Release builds inject public + values at build time. +
Web and mobile cloud clients + Public Clerk publishable key, Clerk JWT template name, and relay URL. Relay URLs + must normalize to an absolute HTTPS origin without credentials, query, fragment, or + non-root path. +
+
+ +
+

Implementation Checklist

+
    +
  • + Keep the relay cloud-mint private key hosted-only; install only its public key into + environments. +
  • +
  • + Keep environment signing private keys local and persist keypair creation atomically. +
  • +
  • + Require a local relay:write session before linking or changing relay + config. +
  • +
  • Validate relay URLs as secure absolute HTTPS origins before sending credentials.
  • +
  • Require loopback-only tunnel origins on both sides of the link-proof boundary.
  • +
  • + Resolve managed endpoint egress from ready allocation rows, not client-supplied URLs. +
  • +
  • Disable redirects on relay-to-environment requests.
  • +
  • Validate environment-signed response proofs before consuming tunneled responses.
  • +
  • Keep tunnel and DNS provisioning retry-safe and deprovision them on unlink.
  • +
  • + Revoke shared environment publication credentials only after the final active link + disappears. +
  • +
  • Redact and cap local agent failure details before publishing them externally.
  • +
+
+ +
+

Standards References

+
    +
  • + RFC 8252: browser-based OAuth for + native apps, used by the CLI PKCE authorization flow. +
  • +
  • + RFC 8693: OAuth token exchange, + used for Clerk-token to relay-DPoP-token exchange. +
  • +
  • + RFC 9449: DPoP proof of possession, + used for relay and environment access tokens. +
  • +
+
+
+ + + diff --git a/infra/relay/.env.example b/infra/relay/.env.example new file mode 100644 index 00000000000..c63cf71985c --- /dev/null +++ b/infra/relay/.env.example @@ -0,0 +1,24 @@ +# Required: Relay domain +# Use the DNS zone managed in your Cloudflare account. Production deploys use +# relay.; personal stages use relay-.. +RELAY_ZONE_NAME=example.com + +# Optional: Relay domain override +# Set this only when the derived relay hostname should not be used. +# RELAY_DOMAIN=relay.example.com + +# Required: Clerk +# Get the keys from the Clerk Dashboard under API keys. Set the JWT audience to +# the `aud` claim configured on the `t3-relay` JWT template. +CLERK_PUBLISHABLE_KEY=pk_test_... +CLERK_SECRET_KEY=sk_test_... +CLERK_JWT_AUDIENCE=t3-code-relay + +# Required: Apple Push Notification service +# Get these values from your Apple Developer account. Use `sandbox` for +# development APNs credentials and `production` for production credentials. +APNS_ENVIRONMENT=sandbox +APNS_TEAM_ID=... +APNS_KEY_ID=... +APNS_BUNDLE_ID=... +APNS_PRIVATE_KEY=... diff --git a/infra/relay/README.md b/infra/relay/README.md new file mode 100644 index 00000000000..823a1ffe526 --- /dev/null +++ b/infra/relay/README.md @@ -0,0 +1,159 @@ +# T3 Code Cloud Relay + +> [!WARNING] +> T3 Code Cloud is currently in private beta. Join the waitlist in the app under Settings > T3 Cloud. + +The relay is the hosted control plane for T3 Code Cloud. It helps clients discover and connect to +remote environments, manages the cloud-side records needed for those connections, and delivers +optional mobile notifications and Live Activities. + +The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects, +regular API and WebSocket traffic goes directly between that client and the selected environment. +See the [cloud architecture overview](../../docs/t3-code-cloud-auth-flow.html) for the larger system +design. + +## Responsibilities + +The relay currently owns: + +- Linking T3 Code environments to a cloud account. +- Provisioning and tracking managed environment endpoints. +- Issuing short-lived credentials used to connect clients to linked environments. +- Listing linked environments and registered mobile devices for an account. +- Registering mobile notification preferences and APNs tokens. +- Receiving published agent activity and delivering notifications or Live Activity updates. +- Persisting relay state and exposing relay-specific traces for diagnostics. + +The environment server and relay have separate credentials and trust boundaries. Read +[Environment Authentication Profile](../../docs/environment-auth.md) before changing token, +credential, or authorization behavior. + +## Code Map + +- [`alchemy.run.ts`](./alchemy.run.ts) defines the deployed Alchemy stack. +- [`src/worker.ts`](./src/worker.ts) wires Cloudflare bindings, runtime layers, queues, and HTTP APIs. +- [`src/http/Api.ts`](./src/http/Api.ts) contains the relay HTTP handlers and authentication + boundaries. +- [`src/environments`](./src/environments) contains environment linking, credentials, endpoint + provisioning, and connection flows. +- [`src/agentActivity`](./src/agentActivity) contains mobile device registration, activity state, + APNs delivery, and queue processing. +- [`src/auth`](./src/auth) contains relay token and DPoP proof handling. +- [`src/persistence/schema.ts`](./src/persistence/schema.ts) defines persisted relay state. Keep + schema and migration changes together. + +Shared request and response schemas live in +[`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay +calls live in +[`packages/client-runtime/src/managedRelay.ts`](../../packages/client-runtime/src/managedRelay.ts). + +## Working Locally + +Install dependencies from the repository root, then run relay-focused checks from this directory: + +```sh +vp install +cd infra/relay +vp test run +vp run typecheck +``` + +To run a smaller test set while iterating: + +```sh +vp test run src/environments/EnvironmentLinker.test.ts +``` + +Before considering a change complete, run the repository-wide checks from the root: + +```sh +vp check +vp run typecheck +``` + +Backend changes should include tests. Prefer testing the real business logic with external +dependencies represented at their boundary rather than mocking internal behavior. + +## Deployment + +The relay deploys through Alchemy: + +```sh +vp run --filter t3code-relay deploy +``` + +The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database +connectivity, and relay tracing resources. Copy [`infra/relay/.env.example`](./.env.example) to +`infra/relay/.env` and fill in the deployment-specific values before deploying. Alchemy loads that +file from the relay directory. Runtime secrets include Clerk and APNs credentials. + +The `prod` Alchemy stage owns the retained PlanetScale database and is the shared hosted relay for +stable and nightly clients. Every other stage references that database and provisions an isolated +PlanetScale branch and runtime role for local development, so deploy `prod` before creating +developer stages: + +```sh +vp run --filter t3code-relay deploy -- --stage prod +vp run --filter t3code-relay deploy -- --env-file .env.local +``` + +Alchemy defaults personal deployments to the `dev_$USER` stage. Relay custom domains apply the same +DNS-safe sanitization as Alchemy physical resource names, so `prod` uses +`relay.` and `dev_julius` uses `relay-dev-julius.`. +`RELAY_DOMAIN` remains available as an explicit override. + +After a successful deploy, the wrapper updates the repository-root `.env` file with the derived relay +URL. That makes subsequent source builds point at the relay that was just deployed without copying +the URL manually. + +### Deployment CI + +The relay is versioned separately from client releases. `.github/workflows/deploy-relay.yml` deploys +the shared Alchemy `prod` stage on every push to `main`. Stable and nightly release builds both +resolve their static public config from the same +`production` GitHub environment. Pull requests do not deploy relay stages. Developers can +deploy personal non-production stages locally with any stage name other than `prod`. + +The repository must define these Actions variables shared by relay deployments: + +- `CLOUDFLARE_ACCOUNT_ID` +- `PLANETSCALE_ORGANIZATION` +- `AXIOM_ORG_ID` + +The repository must define these Actions secrets shared by relay deployments: + +- `CLOUDFLARE_API_TOKEN` +- `PLANETSCALE_API_TOKEN_ID` +- `PLANETSCALE_API_TOKEN` +- `AXIOM_TOKEN` + +The `production` GitHub environment must define these Actions variables: + +- `RELAY_ZONE_NAME` +- `RELAY_DOMAIN` if overriding the derived production relay domain +- `CLERK_PUBLISHABLE_KEY` +- `CLERK_JWT_AUDIENCE` +- `CLERK_JWT_TEMPLATE` +- `APNS_ENVIRONMENT` +- `APNS_TEAM_ID` +- `APNS_KEY_ID` +- `APNS_BUNDLE_ID` + +The `production` GitHub environment must define these Actions secrets: + +- `CLERK_SECRET_KEY` +- `APNS_PRIVATE_KEY` + +The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they +are not bound into the relay Worker. The production deployment uses an Axiom personal access token, +so `AXIOM_ORG_ID` must accompany `AXIOM_TOKEN`. The release workflow reads the production relay's +derived public URL and Clerk publishable key from the same environment for downstream desktop, CLI, +and hosted web builds. + +See: + +- [T3 Cloud Clerk Setup](../../docs/t3-cloud-clerk.md) for Clerk keys, JWT templates, and waitlist + setup. +- [Relay Observability](../../docs/relay-observability.md) for deployment tracing and diagnostics. +- [Cloud Architecture Overview](../../docs/t3-code-cloud-auth-flow.html) for the full link, + connect, endpoint, and notification flows. diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts new file mode 100644 index 00000000000..472d6b95a5c --- /dev/null +++ b/infra/relay/alchemy.run.ts @@ -0,0 +1,40 @@ +// @effect-diagnostics anyUnknownInErrorContext:off layerMergeAllWithDependencies:off - Alchemy provider helpers expose framework-owned any requirements. +import * as Alchemy from "alchemy"; +import * as Axiom from "alchemy/Axiom"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Planetscale from "alchemy/Planetscale"; + +import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; +import { ManagedEndpointZone } from "./src/zone.ts"; +import Api from "./src/worker.ts"; + +export default Alchemy.Stack( + "T3CodeRelay", + { + providers: Layer.mergeAll( + Axiom.providers(), + Cloudflare.providers(), + Drizzle.providers(), + Planetscale.providers(), + ), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const db = yield* PlanetscaleDatabase; + const hyperdrive = yield* RelayHyperdrive; + const zone = yield* ManagedEndpointZone.pipe(Effect.orDie); + const api = yield* Api; + + return { + databaseName: db.database.name, + databaseBranchName: db.branch?.name ?? "main", + hyperdriveName: hyperdrive.name, + workerName: api.workerName, + url: api.url, + managedEndpointZoneId: zone.zoneId, + }; + }), +); diff --git a/infra/relay/migrations/postgres/20260527044716_baseline/migration.sql b/infra/relay/migrations/postgres/20260527044716_baseline/migration.sql new file mode 100644 index 00000000000..694885f380a --- /dev/null +++ b/infra/relay/migrations/postgres/20260527044716_baseline/migration.sql @@ -0,0 +1,104 @@ +CREATE TABLE "relay_agent_activity_rows" ( + "environment_id" varchar(191), + "environment_public_key" text, + "thread_id" varchar(191), + "state_json" jsonb NOT NULL, + "updated_at" varchar(64) NOT NULL, + "created_at" varchar(64) NOT NULL, + CONSTRAINT "relay_agent_activity_rows_pkey" PRIMARY KEY("environment_id","environment_public_key","thread_id") +); +--> statement-breakpoint +CREATE TABLE "relay_delivery_attempts" ( + "id" varchar(36) PRIMARY KEY, + "created_at" varchar(64) NOT NULL, + "user_id" varchar(255), + "environment_id" varchar(191), + "thread_id" varchar(191), + "device_id" varchar(255), + "kind" varchar(64) NOT NULL, + "source_job_id" varchar(64), + "token_suffix" varchar(16), + "apns_status" integer, + "apns_reason" text, + "apns_id" varchar(128), + "transport_error" text +); +--> statement-breakpoint +CREATE TABLE "relay_dpop_proofs" ( + "thumbprint" varchar(128), + "jti" varchar(255), + "iat" integer NOT NULL, + "expires_at" varchar(64) NOT NULL, + "created_at" varchar(64) NOT NULL, + CONSTRAINT "relay_dpop_proofs_pkey" PRIMARY KEY("thumbprint","jti") +); +--> statement-breakpoint +CREATE TABLE "relay_environment_credentials" ( + "credential_id" varchar(64) PRIMARY KEY, + "environment_id" varchar(191) NOT NULL, + "environment_public_key" text NOT NULL, + "credential_hash" varchar(191) NOT NULL, + "revoked_at" varchar(64), + "created_at" varchar(64) NOT NULL, + "updated_at" varchar(64) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "relay_environment_links" ( + "user_id" varchar(191), + "environment_id" varchar(191), + "environment_label" text DEFAULT 'T3 Environment' NOT NULL, + "environment_public_key" text NOT NULL, + "endpoint_http_base_url" text NOT NULL, + "endpoint_ws_base_url" text NOT NULL, + "endpoint_provider_kind" varchar(32) NOT NULL, + "notifications_enabled" boolean DEFAULT true NOT NULL, + "live_activities_enabled" boolean DEFAULT true NOT NULL, + "managed_tunnels_enabled" boolean DEFAULT false NOT NULL, + "created_by_device_id" varchar(191), + "revoked_at" varchar(64), + "created_at" varchar(64) NOT NULL, + "updated_at" varchar(64) NOT NULL, + CONSTRAINT "relay_environment_links_pkey" PRIMARY KEY("user_id","environment_id") +); +--> statement-breakpoint +CREATE TABLE "relay_live_activities" ( + "user_id" varchar(255), + "device_id" varchar(255), + "activity_push_token" text, + "remote_start_queued_at" varchar(64), + "remote_started_at" varchar(64), + "ended_at" varchar(64), + "last_aggregate_json" jsonb, + "last_live_activity_delivery_at" varchar(64), + "created_at" varchar(64) NOT NULL, + "updated_at" varchar(64) NOT NULL, + CONSTRAINT "relay_live_activities_pkey" PRIMARY KEY("user_id","device_id") +); +--> statement-breakpoint +CREATE TABLE "relay_mobile_devices" ( + "user_id" varchar(255), + "device_id" varchar(255), + "platform" varchar(16) NOT NULL, + "ios_major_version" integer NOT NULL, + "app_version" varchar(64), + "push_token" text, + "push_to_start_token" text, + "preferences_json" jsonb NOT NULL, + "created_at" varchar(64) NOT NULL, + "updated_at" varchar(64) NOT NULL, + CONSTRAINT "relay_mobile_devices_pkey" PRIMARY KEY("user_id","device_id") +); +--> statement-breakpoint +CREATE INDEX "idx_relay_agent_activity_rows_updated" ON "relay_agent_activity_rows" ("updated_at");--> statement-breakpoint +CREATE INDEX "idx_relay_delivery_attempts_environment" ON "relay_delivery_attempts" ("environment_id","thread_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_relay_delivery_attempts_source_job" ON "relay_delivery_attempts" ("source_job_id");--> statement-breakpoint +CREATE INDEX "idx_relay_dpop_proofs_expires_at" ON "relay_dpop_proofs" ("expires_at");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_relay_environment_credentials_hash" ON "relay_environment_credentials" ("credential_hash");--> statement-breakpoint +CREATE INDEX "idx_relay_environment_credentials_environment" ON "relay_environment_credentials" ("environment_id","revoked_at");--> statement-breakpoint +CREATE INDEX "idx_relay_environment_credentials_environment_key" ON "relay_environment_credentials" ("environment_id","environment_public_key","revoked_at");--> statement-breakpoint +CREATE INDEX "idx_relay_environment_links_environment" ON "relay_environment_links" ("environment_id","revoked_at");--> statement-breakpoint +CREATE INDEX "idx_relay_live_activities_user" ON "relay_live_activities" ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_relay_live_activities_activity_push_token" ON "relay_live_activities" ("activity_push_token");--> statement-breakpoint +CREATE INDEX "idx_relay_mobile_devices_user" ON "relay_mobile_devices" ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_relay_mobile_devices_push_token" ON "relay_mobile_devices" ("push_token");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_relay_mobile_devices_push_to_start_token" ON "relay_mobile_devices" ("push_to_start_token"); \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json b/infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json new file mode 100644 index 00000000000..aa35a4f8eee --- /dev/null +++ b/infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json @@ -0,0 +1,1267 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "ad620f05-3fd1-43b8-a3ca-d4c02543d892", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_agent_activity_rows_updated", + "entityType": "indexes", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "thread_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_user", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_user", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/infra/relay/migrations/postgres/20260601225421_add_mobile_device_label/migration.sql b/infra/relay/migrations/postgres/20260601225421_add_mobile_device_label/migration.sql new file mode 100644 index 00000000000..291096637a1 --- /dev/null +++ b/infra/relay/migrations/postgres/20260601225421_add_mobile_device_label/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "relay_mobile_devices" ADD COLUMN "label" text DEFAULT 'iOS device' NOT NULL; diff --git a/infra/relay/migrations/postgres/20260601225421_add_mobile_device_label/snapshot.json b/infra/relay/migrations/postgres/20260601225421_add_mobile_device_label/snapshot.json new file mode 100644 index 00000000000..d79130adb08 --- /dev/null +++ b/infra/relay/migrations/postgres/20260601225421_add_mobile_device_label/snapshot.json @@ -0,0 +1,1280 @@ +{ + "dialect": "postgres", + "id": "e189638b-4700-4656-b86e-d8baa63f5fe4", + "prevIds": ["ad620f05-3fd1-43b8-a3ca-d4c02543d892"], + "version": "8", + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'iOS device'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_agent_activity_rows_updated", + "entityType": "indexes", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "thread_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_user", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_user", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/infra/relay/migrations/postgres/20260603035812_managed_endpoint_allocations/migration.sql b/infra/relay/migrations/postgres/20260603035812_managed_endpoint_allocations/migration.sql new file mode 100644 index 00000000000..7b8f004160d --- /dev/null +++ b/infra/relay/migrations/postgres/20260603035812_managed_endpoint_allocations/migration.sql @@ -0,0 +1,15 @@ +CREATE TABLE "relay_managed_endpoint_allocations" ( + "user_id" varchar(191), + "environment_id" varchar(191), + "hostname" text NOT NULL, + "tunnel_id" varchar(191), + "tunnel_name" text NOT NULL, + "dns_record_id" varchar(191), + "ready_at" varchar(64), + "created_at" varchar(64) NOT NULL, + "updated_at" varchar(64) NOT NULL, + CONSTRAINT "relay_managed_endpoint_allocations_pkey" PRIMARY KEY("user_id","environment_id") +); +--> statement-breakpoint +CREATE UNIQUE INDEX "idx_relay_managed_endpoint_allocations_hostname" ON "relay_managed_endpoint_allocations" ("hostname");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_relay_managed_endpoint_allocations_tunnel_name" ON "relay_managed_endpoint_allocations" ("tunnel_name"); \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20260603035812_managed_endpoint_allocations/snapshot.json b/infra/relay/migrations/postgres/20260603035812_managed_endpoint_allocations/snapshot.json new file mode 100644 index 00000000000..ead6f609684 --- /dev/null +++ b/infra/relay/migrations/postgres/20260603035812_managed_endpoint_allocations/snapshot.json @@ -0,0 +1,1453 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "385d476b-d4f6-48a3-99e6-0a95af4ee4e4", + "prevIds": ["e189638b-4700-4656-b86e-d8baa63f5fe4"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_endpoint_allocations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_name", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dns_record_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ready_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'iOS device'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_agent_activity_rows_updated", + "entityType": "indexes", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "thread_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_user", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hostname", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_hostname", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tunnel_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_tunnel_name", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_user", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_managed_endpoint_allocations_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/infra/relay/package.json b/infra/relay/package.json new file mode 100644 index 00000000000..5e1f2c1c903 --- /dev/null +++ b/infra/relay/package.json @@ -0,0 +1,29 @@ +{ + "name": "t3code-relay", + "private": true, + "type": "module", + "scripts": { + "deploy": "node -- scripts/deploy.ts", + "destroy": "alchemy destroy", + "test": "vp test run", + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@clerk/backend": "3.4.14", + "@effect/sql-pg": "catalog:", + "@t3tools/client-runtime": "workspace:*", + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "alchemy": "2.0.0-beta.49", + "drizzle-orm": "1.0.0-rc.3", + "effect": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260601.1", + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "drizzle-kit": "1.0.0-rc.3", + "vitest": "catalog:" + } +} diff --git a/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts new file mode 100644 index 00000000000..3adc2468a81 --- /dev/null +++ b/infra/relay/scripts/deploy.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { hasDeployChanges, reconcileRootEnvRelayUrl } from "./deploy.ts"; + +describe("hasDeployChanges", () => { + it("detects resource, binding, and deletion changes", () => { + expect(hasDeployChanges({ resources: {}, deletions: {} } as never)).toBe(false); + expect( + hasDeployChanges({ + resources: { + api: { action: "create", bindings: [] }, + }, + deletions: {}, + } as never), + ).toBe(true); + expect( + hasDeployChanges({ + resources: { + api: { action: "noop", bindings: [{ action: "update" }] }, + }, + deletions: {}, + } as never), + ).toBe(true); + expect( + hasDeployChanges({ + resources: {}, + deletions: { + api: { action: "delete", bindings: [] }, + }, + } as never), + ).toBe(true); + }); +}); + +describe("reconcileRootEnvRelayUrl", () => { + it("adds the relay URL to an empty root env file", () => { + expect(reconcileRootEnvRelayUrl("", "https://relay.example.test")).toBe( + "T3CODE_RELAY_URL=https://relay.example.test\n", + ); + }); + + it("preserves unrelated root env entries while replacing a previous relay URL", () => { + expect( + reconcileRootEnvRelayUrl( + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example\nT3CODE_RELAY_URL=https://old.example.test\n", + "https://relay.example.test", + ), + ).toBe( + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example\nT3CODE_RELAY_URL=https://relay.example.test\n", + ); + }); +}); diff --git a/infra/relay/scripts/deploy.ts b/infra/relay/scripts/deploy.ts new file mode 100644 index 00000000000..8b5f713d9a8 --- /dev/null +++ b/infra/relay/scripts/deploy.ts @@ -0,0 +1,212 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import { AdoptPolicy } from "alchemy/AdoptPolicy"; +import { AlchemyContext, AlchemyContextLive } from "alchemy/AlchemyContext"; +import * as Apply from "alchemy/Apply"; +import { provideFreshArtifactStore } from "alchemy/Artifacts"; +import { AuthProviders } from "alchemy/Auth/AuthProvider"; +import { CredentialsStoreLive } from "alchemy/Auth/Credentials"; +import { ProfileLive } from "alchemy/Auth/Profile"; +import { Cli } from "alchemy/Cli/Cli"; +import { LoggingCli } from "alchemy/Cli/LoggingCli"; +import * as Plan from "alchemy/Plan"; +import * as Stage from "alchemy/Stage"; +import { TelemetryLive } from "alchemy/Telemetry/Layer"; +import { PlatformServices } from "alchemy/Util/PlatformServices"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Console from "effect/Console"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { Command, Flag, Prompt } from "effect/unstable/cli"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; + +import RelayStack from "../alchemy.run.ts"; + +export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{ + readonly message: string; +}> {} + +export interface RelayDeployOptions { + readonly dryRun: boolean; + readonly force: boolean; + readonly envFile: Option.Option; + readonly stage: Option.Option; + readonly yes: boolean; + readonly adopt: boolean; +} + +export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string { + const entry = `T3CODE_RELAY_URL=${relayUrl}`; + if (/^T3CODE_RELAY_URL=.*$/mu.test(contents)) { + return contents.replace(/^T3CODE_RELAY_URL=.*$/mu, entry); + } + if (!contents) { + return `${entry}\n`; + } + return `${contents}${contents.endsWith("\n") ? "" : "\n"}${entry}\n`; +} + +export function hasDeployChanges(plan: Plan.Plan): boolean { + return ( + Object.keys(plan.deletions).length > 0 || + Object.values(plan.resources).some( + (node) => + node.action !== "noop" || node.bindings.some((binding) => binding.action !== "noop"), + ) + ); +} + +const relayRoot = Effect.service(Path.Path).pipe( + Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), +); +const repoRoot = Effect.service(Path.Path).pipe( + Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), +); + +const loadDeployConfigProvider = Effect.fn("relay.deploy.loadConfigProvider")(function* ( + envFileOverride: Option.Option, +) { + const path = yield* Path.Path; + const root = yield* relayRoot; + const selectedEnvFile = Option.getOrUndefined(envFileOverride); + const envFile = selectedEnvFile ? path.resolve(root, selectedEnvFile) : path.join(root, ".env"); + return yield* ConfigProvider.fromDotEnv({ path: envFile }); +}); + +const relayDeployStage = Config.nonEmptyString("stage").pipe( + Config.option, + Config.map( + Option.getOrElse(() => `dev_${process.env.USER ?? process.env.USERNAME ?? "unknown"}`), + ), +); + +const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (relayUrl: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* repoRoot; + const rootEnvPath = path.join(root, ".env"); + const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : ""; + + yield* fs.writeFileString(rootEnvPath, reconcileRootEnvRelayUrl(contents, relayUrl)); + yield* Console.log(`Updated ${rootEnvPath} with T3CODE_RELAY_URL=${relayUrl}`); +}); + +const deployServices = Layer.mergeAll( + Layer.provideMerge(AlchemyContextLive, PlatformServices), + Layer.provide(ProfileLive, PlatformServices), + Layer.provide(CredentialsStoreLive, PlatformServices), + FetchHttpClient.layer, + TelemetryLive, + LoggingCli, +); + +const runRelayDeploy = Effect.fn("relay.deploy.run")( + function* ( + options: RelayDeployOptions, + _configProvider: ConfigProvider.ConfigProvider, + _stage: string, + ) { + const stack = yield* RelayStack; + + const cli = yield* Cli; + const plan = yield* Plan.make(stack, { force: options.force }).pipe( + Effect.provide(stack.services), + ); + if (options.dryRun) { + yield* cli.displayPlan(plan); + return Option.none(); + } + if (!options.yes && hasDeployChanges(plan)) { + yield* cli.displayPlan(plan); + const approved = yield* Prompt.run( + Prompt.confirm({ + message: "Apply this relay deployment?", + }), + ); + if (!approved) { + yield* Console.log("Deployment cancelled."); + return Option.none(); + } + } + const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); + if (output.url === undefined) { + return yield* new RelayDeployError({ + message: "Alchemy relay deploy output did not include a URL", + }); + } + return Option.some(output.url); + }, + (effect, options, configProvider, stage) => + effect.pipe( + Effect.provide( + Layer.mergeAll( + Layer.effect( + AlchemyContext, + AlchemyContext.pipe(Effect.map((context) => ({ ...context, adopt: options.adopt }))), + ), + Layer.succeed(AdoptPolicy, options.adopt), + Layer.succeed(AuthProviders, {}), + ConfigProvider.layer(configProvider), + Layer.succeed(Stage.Stage, stage), + ), + ), + provideFreshArtifactStore, + ), +); + +export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployOptions) { + const configProvider = yield* loadDeployConfigProvider(options.envFile); + const configuredStage = yield* relayDeployStage.pipe( + Effect.provide(ConfigProvider.layer(configProvider)), + ); + const stage = Option.getOrElse(options.stage, () => configuredStage); + const relayUrl = yield* runRelayDeploy(options, configProvider, stage); + if (Option.isSome(relayUrl)) { + yield* reconcileRootEnv(relayUrl.value); + } +}); + +export const relayDeployCommand = Command.make( + "relay-deploy", + { + dryRun: Flag.boolean("dry-run").pipe( + Flag.withDescription("Dry run the deployment without applying changes."), + Flag.withDefault(false), + ), + force: Flag.boolean("force").pipe( + Flag.withDescription("Force updates for resources that would otherwise no-op."), + Flag.withDefault(false), + ), + envFile: Flag.string("env-file").pipe( + Flag.withDescription("Environment file to load. Defaults to infra/relay/.env."), + Flag.optional, + ), + stage: Flag.string("stage").pipe( + Flag.withDescription("Stage to deploy. Defaults to dev_${USER}."), + Flag.optional, + ), + yes: Flag.boolean("yes").pipe( + Flag.withDescription("Skip the deployment confirmation prompt."), + Flag.withDefault(false), + ), + adopt: Flag.boolean("adopt").pipe( + Flag.withDescription("Adopt pre-existing cloud resources that conflict with this stack."), + Flag.withDefault(false), + ), + }, + deploy, +).pipe(Command.withDescription("Deploy the T3 Code relay through Alchemy.")); + +if (import.meta.main) { + Command.run(relayDeployCommand, { version: "0.0.0" }).pipe( + Effect.provide(deployServices), + Effect.scoped, + NodeRuntime.runMain, + ); +} diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts new file mode 100644 index 00000000000..23f3ba061b1 --- /dev/null +++ b/infra/relay/src/Config.ts @@ -0,0 +1,32 @@ +import * as Context from "effect/Context"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +export const ApnsEnvironment = Schema.Literals(["sandbox", "production"]); +export type ApnsEnvironment = typeof ApnsEnvironment.Type; + +export interface ApnsCredentials { + readonly teamId: string; + readonly keyId: string; + readonly privateKey: Redacted.Redacted; + readonly bundleId: string; + readonly environment: ApnsEnvironment; +} + +export interface RelayConfigurationShape { + readonly relayIssuer: string; + readonly apns: ApnsCredentials; + readonly clerkSecretKey: Redacted.Redacted; + readonly clerkPublishableKey: string; + readonly clerkJwtAudience: string; + readonly apnsDeliveryJobSigningSecret: Redacted.Redacted; + readonly cloudMintPrivateKey: Redacted.Redacted; + readonly cloudMintPublicKey: string; + readonly managedEndpointBaseDomain: string | undefined; + readonly managedEndpointNamespace: string | undefined; +} + +export class RelayConfiguration extends Context.Service< + RelayConfiguration, + RelayConfigurationShape +>()("t3code-relay/Config/RelayConfiguration") {} diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts new file mode 100644 index 00000000000..5f27c2f1821 --- /dev/null +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -0,0 +1,627 @@ +import type { RelayAgentActivityState, RelayDeliveryResult } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as AgentActivityRows from "./AgentActivityRows.ts"; +import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; +import * as LiveActivities from "./LiveActivities.ts"; +import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; +import * as ApnsDeliveries from "./ApnsDeliveries.ts"; + +const state: RelayAgentActivityState = { + environmentId: "env" as RelayAgentActivityState["environmentId"], + threadId: "thread" as RelayAgentActivityState["threadId"], + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running", + headline: "Running", + updatedAt: "1970-01-01T00:00:00.000Z", + deepLink: "/threads/env/thread", +}; + +function target(deviceId: string): LiveActivities.TargetRow { + return { + user_id: "dev:julius", + device_id: deviceId, + platform: "ios", + ios_major_version: 18, + app_version: "1.0.0", + push_token: null, + push_to_start_token: "start-token", + preferences_json: "{}", + activity_push_token: null, + remote_start_queued_at: null, + remote_started_at: null, + ended_at: null, + last_aggregate_json: null, + last_live_activity_delivery_at: null, + }; +} + +function makeLiveActivities( + overrides: Partial = {}, +): LiveActivities.LiveActivitiesShape { + return { + register: () => Effect.void, + listTargets: () => Effect.succeed([]), + markDelivery: () => Effect.void, + markStartQueued: () => Effect.void, + clearStartQueued: () => Effect.void, + invalidateDeliveryToken: () => Effect.void, + ...overrides, + }; +} + +function makeAgentActivityRows( + overrides: Partial = {}, +): AgentActivityRows.AgentActivityRowsShape { + return { + upsert: () => Effect.void, + remove: () => Effect.void, + listForUser: () => Effect.succeed([state]), + ...overrides, + }; +} + +function makeEnvironmentLinks( + overrides: Partial = {}, +): EnvironmentLinks.EnvironmentLinksShape { + return { + upsert: () => Effect.void, + listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), + listDeliveryUsersForEnvironment: () => + Effect.succeed([ + { + userId: "dev:julius", + notificationsEnabled: true, + liveActivitiesEnabled: true, + }, + ]), + listPublicKeysForEnvironment: () => Effect.succeed([]), + listForUser: () => Effect.succeed([]), + getForUser: () => Effect.succeed(null), + revokeForUser: () => Effect.succeed(false), + ...overrides, + }; +} + +function makeApnsDeliveries( + overrides: Partial = {}, +): ApnsDeliveries.ApnsDeliveriesShape { + return { + sendForTarget: () => Effect.succeed(null), + sendPushNotificationForTarget: () => Effect.succeed(null), + sendLiveActivity: () => + Effect.succeed({ + deviceId: "device", + kind: "live_activity_start", + ok: true, + apnsStatus: 200, + apnsReason: null, + apnsId: "apns-id", + }), + sendPushNotification: () => + Effect.succeed({ + deviceId: "device", + kind: "push_notification", + ok: true, + apnsStatus: 200, + apnsReason: null, + apnsId: "apns-id", + }), + processSignedJob: () => + Effect.succeed({ + deviceId: "device", + kind: "live_activity_start", + ok: true, + apnsStatus: 200, + apnsReason: null, + apnsId: "apns-id", + }), + ...overrides, + }; +} + +describe("AgentActivityPublisher", () => { + it.effect("replays the latest aggregate when a Live Activity token registers", () => { + const registeredTarget: LiveActivities.TargetRow = { + ...target("device-1"), + push_to_start_token: null, + activity_push_token: "activity-token", + remote_start_queued_at: null, + remote_started_at: "1970-01-01T00:00:01.000Z", + }; + const sent: Array[0]> = []; + const deliveryResult: RelayDeliveryResult = { + deviceId: "device-1", + kind: "live_activity_update", + ok: true, + apnsStatus: null, + apnsReason: null, + apnsId: "queued", + }; + + return Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + return yield* publisher.replayForLiveActivityRegistration({ + userId: "dev:julius", + deviceId: "device-1", + }); + }).pipe( + Effect.provide( + AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ + listTargets: () => Effect.succeed([registeredTarget, target("device-2")]), + }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + sent.push(input); + return deliveryResult; + }), + }), + ), + ), + ), + ), + ), + ); + + expect(result).toEqual(deliveryResult); + expect(sent).toHaveLength(1); + expect(sent[0]?.target.device_id).toBe("device-1"); + expect(sent[0]?.aggregate).toMatchObject({ + activeCount: 1, + activities: [ + { + environmentId: state.environmentId, + threadId: state.threadId, + status: "Working", + }, + ], + }); + }); + }); + + it.effect("publishes listed targets through the APNs delivery service", () => { + const firstTarget = target("device-1"); + const secondTarget = target("device-2"); + const deliveryResult: RelayDeliveryResult = { + deviceId: "device-1", + kind: "live_activity_start", + ok: true, + apnsStatus: 200, + apnsReason: null, + apnsId: "apns-id", + }; + const sentTargets: Array = []; + const deliveryLookups: Array<{ + readonly environmentId: string; + readonly environmentPublicKey: string; + }> = []; + const upserts: Array[0]> = []; + + return Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + return yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "environment-public-key", + threadId: "thread", + state, + }); + }).pipe( + Effect.provide( + AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + AgentActivityRows.AgentActivityRows, + makeAgentActivityRows({ + upsert: (input) => + Effect.sync(() => { + upserts.push(input); + }), + }), + ), + Layer.succeed( + EnvironmentLinks.EnvironmentLinks, + makeEnvironmentLinks({ + listDeliveryUsersForEnvironment: (input) => + Effect.sync(() => { + deliveryLookups.push(input); + return [ + { + userId: "dev:julius", + notificationsEnabled: true, + liveActivitiesEnabled: true, + }, + ]; + }), + }), + ), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ + listTargets: () => Effect.succeed([firstTarget, secondTarget]), + }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + sentTargets.push(input.target.device_id); + return input.target.device_id === "device-1" ? deliveryResult : null; + }), + }), + ), + ), + ), + ), + ), + ); + + expect(sentTargets).toEqual(["device-1", "device-2"]); + expect(deliveryLookups).toEqual([ + { + environmentId: "env", + environmentPublicKey: "environment-public-key", + }, + ]); + expect(upserts).toMatchObject([ + { + environmentPublicKey: "environment-public-key", + state: { + environmentId: "env", + threadId: "thread", + }, + }, + ]); + expect(result).toEqual({ ok: true, deliveries: [deliveryResult] }); + }); + }); + + it.effect("ends the last remote Live Activity with a terminal content state", () => { + const completedState: RelayAgentActivityState = { + ...state, + phase: "completed", + headline: "Done", + updatedAt: "1970-01-01T00:00:10.000Z", + }; + const sentAggregates: Array< + Parameters[0] + > = []; + const removals: Array[0]> = []; + + return Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + return yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "environment-public-key", + threadId: "thread", + state: completedState, + }); + }).pipe( + Effect.provide( + AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + AgentActivityRows.AgentActivityRows, + makeAgentActivityRows({ + remove: (input) => + Effect.sync(() => { + removals.push(input); + }), + listForUser: () => Effect.succeed([]), + }), + ), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ + listTargets: () => + Effect.succeed([ + { + ...target("device-1"), + push_to_start_token: null, + activity_push_token: "activity-token", + remote_started_at: "1970-01-01T00:00:00.000Z", + }, + ]), + }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + sentAggregates.push(input); + return { + deviceId: input.target.device_id, + kind: "live_activity_end", + ok: true, + apnsStatus: null, + apnsReason: null, + apnsId: "queued", + }; + }), + }), + ), + ), + ), + ), + ), + ); + + expect(result.deliveries).toMatchObject([ + { + deviceId: "device-1", + kind: "live_activity_end", + ok: true, + }, + ]); + expect(removals).toEqual([ + { + environmentId: "env", + environmentPublicKey: "environment-public-key", + threadId: "thread", + }, + ]); + expect(sentAggregates).toHaveLength(1); + expect(sentAggregates[0]?.aggregate).toMatchObject({ + activeCount: 0, + subtitle: "Agent work completed", + activities: [ + { + environmentId: completedState.environmentId, + threadId: completedState.threadId, + phase: "completed", + status: "Done", + }, + ], + }); + }); + }); + + it.effect("queues push notifications for notification-only environment links", () => { + const notificationState: RelayAgentActivityState = { + ...state, + phase: "waiting_for_input", + headline: "Needs input", + }; + const liveAggregates: Array< + Parameters[0] + > = []; + const pushAggregates: Array< + Parameters[0] + > = []; + + return Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + return yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "environment-public-key", + threadId: "thread", + state: notificationState, + }); + }).pipe( + Effect.provide( + AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + AgentActivityRows.AgentActivityRows, + makeAgentActivityRows({ + listForUser: () => Effect.succeed([]), + }), + ), + Layer.succeed( + EnvironmentLinks.EnvironmentLinks, + makeEnvironmentLinks({ + listDeliveryUsersForEnvironment: () => + Effect.succeed([ + { + userId: "dev:julius", + notificationsEnabled: true, + liveActivitiesEnabled: false, + }, + ]), + }), + ), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ + listTargets: () => + Effect.succeed([ + { + ...target("device-1"), + push_token: "apns-device-token", + push_to_start_token: null, + }, + ]), + }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + liveAggregates.push(input); + return null; + }), + sendPushNotificationForTarget: (input) => + Effect.sync(() => { + pushAggregates.push(input); + return { + deviceId: input.target.device_id, + kind: "push_notification", + ok: true, + queued: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }; + }), + }), + ), + ), + ), + ), + ), + ); + + expect(liveAggregates).toMatchObject([{ aggregate: null }]); + expect(pushAggregates).toHaveLength(1); + expect(pushAggregates[0]?.aggregate).toMatchObject({ + activeCount: 1, + activities: [ + { + phase: "waiting_for_input", + status: "Input", + threadId: notificationState.threadId, + }, + ], + }); + expect(result.deliveries).toMatchObject([ + { + deviceId: "device-1", + kind: "push_notification", + queued: true, + }, + ]); + }); + }); + + it.effect( + "does not build Live Activity aggregates for links with Live Activities disabled", + () => { + const notificationState: RelayAgentActivityState = { + ...state, + phase: "waiting_for_approval", + headline: "Needs approval", + }; + const liveAggregates: Array< + Parameters[0] + > = []; + const pushAggregates: Array< + Parameters[0] + > = []; + + return Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + return yield* publisher.publish({ + environmentId: "env", + environmentPublicKey: "environment-public-key", + threadId: "thread", + state: notificationState, + }); + }).pipe( + Effect.provide( + AgentActivityPublisher.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + AgentActivityRows.AgentActivityRows, + makeAgentActivityRows({ + listForUser: () => + Effect.succeed([ + { + ...state, + environmentId: "other-env" as RelayAgentActivityState["environmentId"], + threadId: "other-thread" as RelayAgentActivityState["threadId"], + }, + ]), + }), + ), + Layer.succeed( + EnvironmentLinks.EnvironmentLinks, + makeEnvironmentLinks({ + listDeliveryUsersForEnvironment: () => + Effect.succeed([ + { + userId: "dev:julius", + notificationsEnabled: true, + liveActivitiesEnabled: false, + }, + ]), + }), + ), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ + listTargets: () => + Effect.succeed([ + { + ...target("device-1"), + push_token: "apns-device-token", + push_to_start_token: "push-to-start-token", + }, + ]), + }), + ), + Layer.succeed( + ApnsDeliveries.ApnsDeliveries, + makeApnsDeliveries({ + sendForTarget: (input) => + Effect.sync(() => { + liveAggregates.push(input); + return null; + }), + sendPushNotificationForTarget: (input) => + Effect.sync(() => { + pushAggregates.push(input); + return { + deviceId: input.target.device_id, + kind: "push_notification", + ok: true, + queued: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }; + }), + }), + ), + ), + ), + ), + ), + ); + + expect(liveAggregates).toMatchObject([{ aggregate: null }]); + expect(pushAggregates).toHaveLength(1); + expect(pushAggregates[0]?.aggregate?.activities).toMatchObject([ + { + environmentId: notificationState.environmentId, + threadId: notificationState.threadId, + phase: "waiting_for_approval", + }, + ]); + expect(result.deliveries).toMatchObject([ + { + deviceId: "device-1", + kind: "push_notification", + queued: true, + }, + ]); + }); + }, + ); +}); diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts new file mode 100644 index 00000000000..3881bc6c1a7 --- /dev/null +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -0,0 +1,230 @@ +import type { + RelayAgentActivityAggregateState, + RelayAgentActivityState, + RelayDeliveryResult, + RelayPublishResponse, +} from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { sanitizeAgentActivityAggregateState } from "./agentActivityPayloads.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; +import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; +import * as LiveActivities from "./LiveActivities.ts"; +import * as ApnsDeliveries from "./ApnsDeliveries.ts"; + +export type AgentActivityPublishError = + | AgentActivityRows.AgentActivityRowUpsertPersistenceError + | AgentActivityRows.AgentActivityRowDeletePersistenceError + | AgentActivityRows.AgentActivityRowListPersistenceError + | EnvironmentLinks.EnvironmentLinkUserListPersistenceError + | LiveActivities.LiveActivityTargetListPersistenceError + | ApnsDeliveries.ApnsDeliveryError; + +export interface AgentActivityPublisherShape { + readonly publish: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly threadId: string; + readonly state: RelayAgentActivityState | null; + }) => Effect.Effect; + readonly replayForLiveActivityRegistration: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect; +} + +export class AgentActivityPublisher extends Context.Service< + AgentActivityPublisher, + AgentActivityPublisherShape +>()("t3code-relay/agentActivity/AgentActivityPublisher") {} + +const make = Effect.gen(function* () { + const rows = yield* AgentActivityRows.AgentActivityRows; + const links = yield* EnvironmentLinks.EnvironmentLinks; + const liveActivities = yield* LiveActivities.LiveActivities; + const apnsDeliveries = yield* ApnsDeliveries.ApnsDeliveries; + + const publishForDeliveryUser = Effect.fnUntraced(function* (input: { + readonly deliveryUser: EnvironmentLinks.AgentAwarenessDeliveryUserRecord; + readonly state: RelayAgentActivityState | null; + readonly nowMs: number; + }) { + const activeStates = yield* rows.listForUser({ userId: input.deliveryUser.userId }); + const liveActivityAggregate = input.deliveryUser.liveActivitiesEnabled + ? makeAggregateState({ + activeStates, + terminalState: input.state && isTerminalPhase(input.state) ? input.state : null, + }) + : null; + const notificationOnlyAggregate = + input.deliveryUser.notificationsEnabled && + !input.deliveryUser.liveActivitiesEnabled && + input.state !== null + ? makeAggregateState({ + activeStates: isTerminalPhase(input.state) ? [] : [input.state], + terminalState: isTerminalPhase(input.state) ? input.state : null, + }) + : null; + const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId }); + const deliveriesByTarget = yield* Effect.forEach( + targets, + (target) => + Effect.all( + [ + apnsDeliveries.sendForTarget({ + target, + aggregate: liveActivityAggregate, + nowMs: input.nowMs, + }), + notificationOnlyAggregate === null + ? Effect.succeed(null) + : apnsDeliveries.sendPushNotificationForTarget({ + target, + aggregate: notificationOnlyAggregate, + }), + ], + { concurrency: 2 }, + ), + { concurrency: 4 }, + ); + return deliveriesByTarget.flat(); + }); + + return AgentActivityPublisher.of({ + replayForLiveActivityRegistration: Effect.fn( + "relay.agent_activity_publisher.replay_for_live_activity_registration", + )(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + "relay.operation": "replayForLiveActivityRegistration", + }); + const activeStates = yield* rows.listForUser({ userId: input.userId }); + const targets = yield* liveActivities.listTargets({ userId: input.userId }); + const target = targets.find((row) => row.device_id === input.deviceId) ?? null; + if (target === null) { + return null; + } + const aggregate = makeAggregateState({ activeStates, terminalState: null }); + const now = yield* DateTime.now; + return yield* apnsDeliveries.sendForTarget({ + target, + aggregate, + nowMs: now.epochMilliseconds, + }); + }), + publish: Effect.fn("relay.agent_activity_publisher.publish")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + "relay.thread_id": input.threadId, + "relay.agent_activity.phase": input.state?.phase ?? "deleted", + }); + if (input.state && !isTerminalPhase(input.state)) { + yield* rows.upsert({ + environmentPublicKey: input.environmentPublicKey, + state: input.state, + }); + } else { + yield* rows.remove({ + environmentId: input.environmentId, + environmentPublicKey: input.environmentPublicKey, + threadId: input.threadId, + }); + } + + const deliveryUsers = yield* links.listDeliveryUsersForEnvironment({ + environmentId: input.environmentId, + environmentPublicKey: input.environmentPublicKey, + }); + const now = yield* DateTime.now; + const deliveriesByUser = yield* Effect.forEach( + deliveryUsers, + (deliveryUser) => + publishForDeliveryUser({ + deliveryUser, + state: input.state, + nowMs: now.epochMilliseconds, + }), + { concurrency: 4 }, + ); + const deliveries = deliveriesByUser.flat(); + return { + ok: true, + deliveries: deliveries.filter( + (delivery): delivery is RelayDeliveryResult => delivery !== null, + ), + }; + }), + }); +}); + +function statusForPhase(phase: RelayAgentActivityState["phase"]): string { + switch (phase) { + case "waiting_for_approval": + return "Approval"; + case "waiting_for_input": + return "Input"; + case "completed": + return "Done"; + case "failed": + return "Failed"; + case "starting": + return "Starting"; + case "running": + return "Working"; + case "stale": + return "Waiting"; + } +} + +function isTerminalPhase(state: RelayAgentActivityState): boolean { + return state.phase === "completed" || state.phase === "failed"; +} + +function aggregateRowForState(state: RelayAgentActivityState) { + return { + environmentId: state.environmentId, + threadId: state.threadId, + projectTitle: state.projectTitle, + threadTitle: state.threadTitle, + modelTitle: state.modelTitle, + phase: state.phase, + status: statusForPhase(state.phase), + updatedAt: state.updatedAt, + deepLink: state.deepLink, + }; +} + +function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActivityAggregateState { + return sanitizeAgentActivityAggregateState({ + title: "T3 Code", + subtitle: state.phase === "failed" ? "Agent work failed" : "Agent work completed", + activeCount: 0, + updatedAt: state.updatedAt, + activities: [aggregateRowForState(state)], + }); +} + +function makeAggregateState(input: { + readonly activeStates: ReadonlyArray; + readonly terminalState: RelayAgentActivityState | null; +}): RelayAgentActivityAggregateState | null { + const activeStates = input.activeStates.filter((state) => !isTerminalPhase(state)); + if (activeStates.length === 0) { + return input.terminalState === null ? null : terminalAggregateState(input.terminalState); + } + const updatedAt = activeStates.reduce((latest, state) => + state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest, + ).updatedAt; + return sanitizeAgentActivityAggregateState({ + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: activeStates.length, + updatedAt, + activities: activeStates.slice(0, 3).map(aggregateRowForState), + }); +} + +export const layer = Layer.effect(AgentActivityPublisher, make); diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts new file mode 100644 index 00000000000..00d3f5f7800 --- /dev/null +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -0,0 +1,158 @@ +import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; +import { RelayAgentActivityState as RelayAgentActivityStateSchema } from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import { cast } from "effect/Function"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { and, desc, eq, isNull } from "drizzle-orm"; + +import { RelayDb } from "../db.ts"; +import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; + +export class AgentActivityRowUpsertPersistenceError extends Data.TaggedError( + "AgentActivityRowUpsertPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class AgentActivityRowDeletePersistenceError extends Data.TaggedError( + "AgentActivityRowDeletePersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class AgentActivityRowListPersistenceError extends Data.TaggedError( + "AgentActivityRowListPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export interface AgentActivityRowsShape { + readonly upsert: (input: { + readonly environmentPublicKey: string; + readonly state: RelayAgentActivityState; + }) => Effect.Effect; + readonly remove: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly threadId: string; + }) => Effect.Effect; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect, AgentActivityRowListPersistenceError>; +} + +export class AgentActivityRows extends Context.Service()( + "t3code-relay/agentActivity/AgentActivityRows", +) {} + +const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); +const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); + +const encodeRelayAgentActivityStateJson = Schema.encodeEffect( + Schema.fromJsonString(RelayAgentActivityStateSchema), +); + +const decodeRelayAgentActivityStateJson = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentActivityStateSchema), +); + +const make = Effect.gen(function* () { + const db = yield* RelayDb; + + return AgentActivityRows.of({ + upsert: Effect.fn("relay.agent_activity_rows.upsert")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.state.environmentId, + "relay.thread_id": input.state.threadId, + }); + const now = yield* DateTime.now; + const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( + Effect.flatMap(decodeJsonString), + Effect.map(cast), + ); + yield* db + .insert(relayAgentActivityRows) + .values({ + environmentId: input.state.environmentId, + environmentPublicKey: input.environmentPublicKey, + threadId: input.state.threadId, + stateJson, + updatedAt: input.state.updatedAt, + createdAt: DateTime.formatIso(now), + }) + .onConflictDoUpdate({ + target: [ + relayAgentActivityRows.environmentId, + relayAgentActivityRows.environmentPublicKey, + relayAgentActivityRows.threadId, + ], + set: { + stateJson, + updatedAt: input.state.updatedAt, + }, + }); + }, + Effect.mapError((cause) => new AgentActivityRowUpsertPersistenceError({ cause })), + ), + + remove: Effect.fn("relay.agent_activity_rows.remove")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + "relay.thread_id": input.threadId, + }); + yield* db + .delete(relayAgentActivityRows) + .where( + and( + eq(relayAgentActivityRows.environmentId, input.environmentId), + eq(relayAgentActivityRows.environmentPublicKey, input.environmentPublicKey), + eq(relayAgentActivityRows.threadId, input.threadId), + ), + ) + .pipe(Effect.mapError((cause) => new AgentActivityRowDeletePersistenceError({ cause }))); + }), + + listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) { + return yield* db + .select({ stateJson: relayAgentActivityRows.stateJson }) + .from(relayAgentActivityRows) + .innerJoin( + relayEnvironmentLinks, + and( + eq(relayEnvironmentLinks.environmentId, relayAgentActivityRows.environmentId), + eq( + relayEnvironmentLinks.environmentPublicKey, + relayAgentActivityRows.environmentPublicKey, + ), + ), + ) + .where( + and( + eq(relayEnvironmentLinks.userId, input.userId), + isNull(relayEnvironmentLinks.revokedAt), + eq(relayEnvironmentLinks.liveActivitiesEnabled, true), + ), + ) + .orderBy(desc(relayAgentActivityRows.updatedAt)) + .pipe( + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => encodeJsonValue(row.stateJson), { + concurrency: "unbounded", + }), + ), + Effect.map((rows) => + rows.flatMap((row) => Option.toArray(decodeRelayAgentActivityStateJson(row))), + ), + Effect.mapError((cause) => new AgentActivityRowListPersistenceError({ cause })), + ); + }), + }); +}); + +export const layer = Layer.effect(AgentActivityRows, make); diff --git a/infra/relay/src/agentActivity/ApnsClient.test.ts b/infra/relay/src/agentActivity/ApnsClient.test.ts new file mode 100644 index 00000000000..1d327aa945b --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsClient.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient } from "effect/unstable/http"; + +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as ApnsClient from "./ApnsClient.ts"; + +const TestLayer = ApnsClient.layer.pipe( + Layer.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected APNs HTTP request")), + ), + ), +); + +describe("ApnsClient", () => { + const now = DateTime.makeUnsafe(0); + const state: RelayAgentActivityAggregateState = { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: DateTime.formatIso(now), + activities: [ + { + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running" as const, + status: "Working", + updatedAt: DateTime.formatIso(now), + deepLink: "/", + }, + ], + }; + + it.effect("requests an update push token when remotely starting a Live Activity", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makeLiveActivityRequest({ + event: "start", + token: "token", + state, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + nowIso: DateTime.formatIso(now), + }); + + expect(request.priority).toBe("10"); + expect(request.payload).toMatchObject({ + aps: { + event: "start", + "attributes-type": "LiveActivityAttributes", + "input-push-token": 1, + "content-state": { + name: "AgentActivity", + }, + }, + }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("builds a low-priority update payload", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makeLiveActivityRequest({ + event: "update", + token: "token", + state, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + nowIso: DateTime.formatIso(now), + }); + + expect(request.priority).toBe("5"); + expect(request.payload).toMatchObject({ + aps: { + event: "update", + "content-state": { + name: "AgentActivity", + }, + }, + }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("builds an end payload with a dismissal date", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makeLiveActivityRequest({ + event: "end", + token: "token", + state, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + nowIso: DateTime.formatIso(now), + }); + + expect(request.priority).toBe("10"); + expect(request.payload).toMatchObject({ + aps: { + event: "end", + "dismissal-date": 300, + }, + }); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("builds a standard APNs alert payload with routing metadata", () => + Effect.gen(function* () { + const apns = yield* ApnsClient.ApnsClient; + const request = apns.makePushNotificationRequest({ + token: "push-token", + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", + }, + }); + + expect(request.priority).toBe("10"); + expect(request.payload).toMatchObject({ + aps: { + alert: { + title: "Thread", + body: "Input: Project", + }, + sound: "default", + }, + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", + }); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts new file mode 100644 index 00000000000..92ec060958d --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -0,0 +1,320 @@ +import * as NodeCrypto from "node:crypto"; + +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import { + Headers, + HttpClient, + HttpClientRequest, + type HttpBody, + type HttpClientError, +} from "effect/unstable/http"; +import type { ApnsCredentials } from "../Config.ts"; +import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; + +const LIVE_ACTIVITY_NAME = "AgentActivity"; +const STALE_AFTER_SECONDS = 2 * 60; +const DISMISS_AFTER_SECONDS = 5 * 60; + +export type ApnsLiveActivityEvent = "start" | "update" | "end"; + +interface ApnsLiveActivityRequest { + readonly token: string; + readonly event: ApnsLiveActivityEvent; + readonly priority: "5" | "10"; + readonly payload: unknown; +} + +interface ApnsPushNotificationRequest { + readonly token: string; + readonly priority: "10"; + readonly payload: unknown; +} + +export interface ApnsDeliveryResult { + readonly ok: boolean; + readonly status: number; + readonly reason?: string; + readonly apnsId: string | null; +} + +export class ApnsSigningError extends Data.TaggedError("ApnsSigningError")<{ + readonly phase: "encoding" | "signing"; + readonly cause: unknown; +}> {} + +export class ApnsHttpRequestError extends Data.TaggedError("ApnsHttpRequestError")<{ + readonly cause: HttpClientError.HttpClientError | HttpBody.HttpBodyError; +}> {} + +export class ApnsInvalidResponseError extends Data.TaggedError("ApnsInvalidResponseError")<{ + readonly cause: unknown; +}> {} + +export type ApnsError = ApnsSigningError | ApnsHttpRequestError | ApnsInvalidResponseError; + +const decodeApnsErrorResponseJson = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + reason: Schema.optional(Schema.String), + }), + ), +); +const encodeApnsJwtHeaderJson = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + alg: Schema.Literal("ES256"), + kid: Schema.String, + }), + ), +); +const encodeApnsJwtPayloadJson = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + iss: Schema.String, + iat: Schema.Number, + }), + ), +); + +const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { + readonly teamId: ApnsCredentials["teamId"]; + readonly keyId: ApnsCredentials["keyId"]; + readonly privateKey: ApnsCredentials["privateKey"]; + readonly issuedAtUnixSeconds: number; +}) { + const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( + Effect.mapError((cause) => new ApnsSigningError({ cause, phase: "encoding" })), + ); + const payloadJson = yield* encodeApnsJwtPayloadJson({ + iss: input.teamId, + iat: input.issuedAtUnixSeconds, + }).pipe(Effect.mapError((cause) => new ApnsSigningError({ cause, phase: "encoding" }))); + + const privateKey = Redacted.value(input.privateKey); + const header = Encoding.encodeBase64Url(headerJson); + const payload = Encoding.encodeBase64Url(payloadJson); + const signingInput = `${header}.${payload}`; + + return yield* Effect.try({ + try: () => { + const signature = NodeCrypto.createSign("sha256") + .update(signingInput) + .sign({ + key: privateKey.replace(/\\n/g, "\n"), + dsaEncoding: "ieee-p1363", + }); + return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; + }, + catch: (cause) => new ApnsSigningError({ cause, phase: "signing" }), + }); +}); + +function contentState(state: RelayAgentActivityAggregateState) { + return { + name: LIVE_ACTIVITY_NAME, + props: JSON.stringify(state), + }; +} + +interface LiveActivityRequestBase { + readonly token: string; + readonly nowEpochSeconds: number; + readonly nowIso: string; +} + +type MakeLiveActivityRequestInput = + | (LiveActivityRequestBase & { + readonly event: "end"; + readonly state: RelayAgentActivityAggregateState | null; + }) + | (LiveActivityRequestBase & { + readonly event: "start" | "update"; + readonly state: RelayAgentActivityAggregateState; + }); + +function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest { + const timestamp = input.nowEpochSeconds; + if (input.event === "end") { + return { + token: input.token, + event: input.event, + priority: "10", + payload: { + aps: { + timestamp, + event: "end", + ...(input.state ? { "content-state": contentState(input.state) } : {}), + "dismissal-date": timestamp + DISMISS_AFTER_SECONDS, + }, + }, + }; + } + + const state = input.state; + return { + token: input.token, + event: input.event, + priority: input.event === "update" ? "5" : "10", + payload: { + aps: { + timestamp, + event: input.event, + ...(input.event === "start" + ? { + "attributes-type": "LiveActivityAttributes", + attributes: {}, + "input-push-token": 1, + alert: { + title: state.title, + body: state.subtitle, + }, + } + : {}), + "content-state": contentState(state), + "stale-date": timestamp + STALE_AFTER_SECONDS, + }, + }, + }; +} + +function makePushNotificationRequest(input: { + readonly token: string; + readonly notification: ApnsNotificationPayload; +}): ApnsPushNotificationRequest { + return { + token: input.token, + priority: "10", + payload: { + aps: { + alert: { + title: input.notification.title, + body: input.notification.body, + }, + sound: "default", + }, + environmentId: input.notification.environmentId, + threadId: input.notification.threadId, + deepLink: input.notification.deepLink, + }, + }; +} + +function apnsReasonFromBody(body: string): string | undefined { + if (body.trim().length === 0) { + return undefined; + } + return Option.match(decodeApnsErrorResponseJson(body), { + onNone: () => body, + onSome: (parsed) => parsed.reason ?? body, + }); +} + +export interface ApnsClientShape { + readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; + readonly makePushNotificationRequest: typeof makePushNotificationRequest; + readonly sendLiveActivityRequest: (input: { + readonly credentials: ApnsCredentials; + readonly request: ApnsLiveActivityRequest; + readonly issuedAtUnixSeconds: number; + }) => Effect.Effect; + readonly sendPushNotificationRequest: (input: { + readonly credentials: ApnsCredentials; + readonly request: ApnsPushNotificationRequest; + readonly issuedAtUnixSeconds: number; + }) => Effect.Effect; +} + +export class ApnsClient extends Context.Service()( + "t3code-relay/agentActivity/ApnsClient", +) {} + +const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + + const sendLiveActivityRequest: ApnsClientShape["sendLiveActivityRequest"] = Effect.fn( + "relay.apns.send_live_activity_request", + )(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event }); + const jwt = yield* makeApnsJwt({ + ...input.credentials, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + }); + const host = + input.credentials.environment === "production" + ? "https://api.push.apple.com" + : "https://api.sandbox.push.apple.com"; + const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe( + HttpClientRequest.setHeaders({ + authorization: `bearer ${jwt}`, + "apns-priority": input.request.priority, + "apns-push-type": "liveactivity", + "apns-topic": `${input.credentials.bundleId}.push-type.liveactivity`, + }), + HttpClientRequest.bodyJson(input.request.payload), + Effect.flatMap(httpClient.execute), + Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + ); + const responseText = yield* response.text.pipe( + Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + ); + const reason = apnsReasonFromBody(responseText); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + ...(reason === undefined ? {} : { reason }), + apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), + }; + }); + + const sendPushNotificationRequest: ApnsClientShape["sendPushNotificationRequest"] = Effect.fn( + "relay.apns.send_push_notification_request", + )(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); + const jwt = yield* makeApnsJwt({ + ...input.credentials, + issuedAtUnixSeconds: input.issuedAtUnixSeconds, + }); + const host = + input.credentials.environment === "production" + ? "https://api.push.apple.com" + : "https://api.sandbox.push.apple.com"; + const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe( + HttpClientRequest.setHeaders({ + authorization: `bearer ${jwt}`, + "apns-priority": input.request.priority, + "apns-push-type": "alert", + "apns-topic": input.credentials.bundleId, + }), + HttpClientRequest.bodyJson(input.request.payload), + Effect.flatMap(httpClient.execute), + Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + ); + const responseText = yield* response.text.pipe( + Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), + ); + const reason = apnsReasonFromBody(responseText); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + ...(reason === undefined ? {} : { reason }), + apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), + }; + }); + + return ApnsClient.of({ + makeLiveActivityRequest, + makePushNotificationRequest, + sendLiveActivityRequest, + sendPushNotificationRequest, + }); +}); + +export const layer = Layer.effect(ApnsClient, make); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts new file mode 100644 index 00000000000..0dfee1fb0cd --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -0,0 +1,1128 @@ +import type { + RelayAgentActivityAggregateState, + RelayAgentActivityState, +} from "@t3tools/contracts/relay"; +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import { + makeApnsDeliveryJobPayload, + signApnsDeliveryJob, + type SignedApnsDeliveryJob, +} from "./apnsDeliveryJobs.ts"; +import * as DeliveryAttempts from "./DeliveryAttempts.ts"; +import * as LiveActivities from "./LiveActivities.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as ApnsDeliveries from "./ApnsDeliveries.ts"; +import * as ApnsClient from "./ApnsClient.ts"; + +const config = RelayConfiguration.RelayConfiguration.of({ + relayIssuer: "https://relay.example.test", + apns: { + environment: "sandbox", + teamId: "team-id", + keyId: "key-id", + privateKey: Redacted.make("not-a-private-key"), + bundleId: "com.t3tools.t3code.dev", + }, + apnsDeliveryJobSigningSecret: Redacted.make("job-signing-secret"), + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + cloudMintPrivateKey: Redacted.make("cloud-private-key"), + cloudMintPublicKey: "cloud-public-key", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +}); + +const apnsSigningKeyPair = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "P-256", + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); + +const signingConfig = RelayConfiguration.RelayConfiguration.of({ + ...config, + apns: { + ...config.apns, + privateKey: Redacted.make(apnsSigningKeyPair.privateKey), + }, +}); + +const state: RelayAgentActivityState = { + environmentId: "env" as RelayAgentActivityState["environmentId"], + threadId: "thread" as RelayAgentActivityState["threadId"], + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running", + headline: "Running", + updatedAt: "1970-01-01T00:00:00.000Z", + deepLink: "/", +}; + +const aggregate: RelayAgentActivityAggregateState = { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: state.updatedAt, + activities: [ + { + environmentId: state.environmentId, + threadId: state.threadId, + projectTitle: state.projectTitle, + threadTitle: state.threadTitle, + modelTitle: state.modelTitle, + phase: state.phase, + status: "Working", + updatedAt: state.updatedAt, + deepLink: state.deepLink, + }, + ], +}; + +const enabledPreferences = JSON.stringify({ + liveActivitiesEnabled: true, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, +}); + +const disabledPreferences = JSON.stringify({ + liveActivitiesEnabled: false, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, +}); + +const notificationsDisabledPreferences = JSON.stringify({ + liveActivitiesEnabled: false, + notificationsEnabled: false, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, +}); + +const target: LiveActivities.TargetRow = { + user_id: "dev:julius", + device_id: "device-1", + platform: "ios", + ios_major_version: 18, + app_version: "1.0.0", + push_token: null, + push_to_start_token: "start-token", + preferences_json: enabledPreferences, + activity_push_token: "activity-token", + remote_start_queued_at: null, + remote_started_at: "1970-01-01T00:00:00.000Z", + ended_at: null, + last_aggregate_json: null, + last_live_activity_delivery_at: null, +}; + +function makeLayer(input: { + readonly attempts: Array; + readonly sourceJobClaims?: ReadonlyMap; + readonly queuedJobs?: Array; + readonly queuedStarts?: Array< + Parameters[0] + >; + readonly clearedStarts?: Array< + Parameters[0] + >; + readonly markedDeliveries?: Array< + Parameters[0] + >; + readonly invalidatedTokens?: Array< + Parameters[0] + >; + readonly currentTargets?: ReadonlyArray; + readonly config?: RelayConfiguration.RelayConfigurationShape; + readonly execute?: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect; +}) { + return ApnsDeliveries.layer.pipe( + Layer.provide(ApnsClient.layer), + Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), + Layer.provide( + Layer.mergeAll( + Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { + send: (body) => + Effect.sync(() => { + input.queuedJobs?.push(body); + }), + }), + Layer.succeed(DeliveryAttempts.DeliveryAttempts, { + record: (attempt) => + Effect.sync(() => { + input.attempts.push(attempt); + }), + claimSourceJob: (attempt) => + Effect.sync(() => { + const claim = input.sourceJobClaims?.get(attempt.sourceJobId); + if (claim) { + return claim; + } + input.attempts.push(attempt); + return "claimed"; + }), + completeSourceJob: (completion) => + Effect.sync(() => { + const attempt = input.attempts.find( + (row) => row.sourceJobId === completion.sourceJobId, + ); + if (attempt) { + Object.assign(attempt, completion); + } + }), + }), + Layer.succeed(LiveActivities.LiveActivities, { + register: () => Effect.void, + listTargets: () => Effect.succeed(input.currentTargets ?? [target]), + markStartQueued: (queued) => + Effect.sync(() => { + input.queuedStarts?.push(queued); + }), + clearStartQueued: (cleared) => + Effect.sync(() => { + input.clearedStarts?.push(cleared); + }), + markDelivery: (delivery) => + Effect.sync(() => { + input.markedDeliveries?.push(delivery); + }), + invalidateDeliveryToken: (invalidated) => + Effect.sync(() => { + input.invalidatedTokens?.push(invalidated); + }), + }), + Layer.succeed(RelayConfiguration.RelayConfiguration, input.config ?? config), + input.execute + ? Layer.succeed(HttpClient.HttpClient, HttpClient.make(input.execute)) + : FetchHttpClient.layer, + ), + ), + ); +} + +describe("ApnsDeliveries", () => { + it.effect("queues a restart using the push-to-start token", () => { + const attempts: Array = []; + const queuedJobs: Array = []; + const queuedStarts: Array< + Parameters[0] + > = []; + const markedDeliveries: Array< + Parameters[0] + > = []; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target: { + ...target, + ended_at: "1970-01-01T00:00:05.000Z", + }, + aggregate, + nowMs: 10_000, + }); + + expect(result?.kind).toBe("live_activity_start"); + expect(result?.ok).toBe(true); + expect(queuedJobs).toMatchObject([ + { + payload: { + kind: "live_activity_start", + target: { + token: "start-token", + }, + }, + }, + ]); + expect(attempts).toEqual([]); + expect(queuedStarts).toMatchObject([ + { + userId: target.user_id, + deviceId: target.device_id, + }, + ]); + expect(markedDeliveries).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs, queuedStarts, markedDeliveries }))); + }); + + it.effect("queues an end using the activity token", () => { + const attempts: Array = []; + const queuedJobs: Array = []; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target, + aggregate: null, + nowMs: 5_000, + }); + + expect(result?.kind).toBe("live_activity_end"); + expect(result?.ok).toBe(true); + expect(queuedJobs).toMatchObject([ + { + payload: { + kind: "live_activity_end", + target: { + token: "activity-token", + }, + }, + }, + ]); + expect(attempts).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }); + + it.effect("does not queue a remote start when Live Activities are disabled", () => { + const attempts: Array = []; + const queuedJobs: Array = []; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target: { + ...target, + activity_push_token: null, + remote_started_at: null, + ended_at: null, + preferences_json: disabledPreferences, + }, + aggregate, + nowMs: 5_000, + }); + + expect(result).toBeNull(); + expect(queuedJobs).toEqual([]); + expect(attempts).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }); + + it.effect("does not queue a duplicate remote start while a start is already queued", () => { + const attempts: Array = []; + const queuedJobs: Array = []; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target: { + ...target, + activity_push_token: null, + remote_start_queued_at: "1970-01-01T00:00:03.000Z", + remote_started_at: null, + ended_at: null, + }, + aggregate, + nowMs: 5_000, + }); + + expect(result).toBeNull(); + expect(queuedJobs).toEqual([]); + expect(attempts).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }); + + it.effect("queues bounded Live Activity aggregate payloads", () => { + const attempts: Array = []; + const queuedJobs: Array = []; + const longTitle = "x".repeat(300); + const inputAggregate: RelayAgentActivityAggregateState = { + ...aggregate, + title: longTitle, + subtitle: longTitle, + activities: [0, 1, 2, 3].map((index) => + Object.assign({}, aggregate.activities[0]!, { + projectTitle: longTitle, + threadTitle: longTitle, + modelTitle: longTitle, + status: longTitle, + threadId: `thread-${index}` as RelayAgentActivityState["threadId"], + deepLink: "https://example.test/not-an-app-link", + }), + ), + }; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + yield* deliveries.sendForTarget({ + target: { + ...target, + activity_push_token: null, + remote_started_at: null, + ended_at: "1970-01-01T00:00:05.000Z", + }, + aggregate: inputAggregate, + nowMs: 10_000, + }); + + const payloadAggregate = queuedJobs[0]?.payload.aggregate; + expect(payloadAggregate?.title.length).toBeLessThanOrEqual(120); + expect(payloadAggregate?.subtitle.length).toBeLessThanOrEqual(120); + expect(payloadAggregate?.activities).toHaveLength(3); + expect(payloadAggregate?.activities[0]?.projectTitle.length).toBeLessThanOrEqual(120); + expect(payloadAggregate?.activities[0]?.status.length).toBeLessThanOrEqual(40); + expect(payloadAggregate?.activities[0]?.deepLink).toBe("/"); + expect(attempts).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }); + + it.effect("queues an end for an active Live Activity when Live Activities are disabled", () => { + const attempts: Array = []; + const queuedJobs: Array = []; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target: { + ...target, + preferences_json: disabledPreferences, + }, + aggregate, + nowMs: 5_000, + }); + + expect(result?.kind).toBe("live_activity_end"); + expect(queuedJobs).toMatchObject([ + { + payload: { + kind: "live_activity_end", + target: { + token: "activity-token", + }, + }, + }, + ]); + expect(attempts).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }); + + it.effect( + "queues an alert while ending an active Live Activity when only Live Activities are disabled", + () => { + const attempts: Array = []; + const queuedJobs: Array = []; + const inputAggregate: RelayAgentActivityAggregateState = { + ...aggregate, + activities: [ + { + ...aggregate.activities[0]!, + phase: "waiting_for_input", + status: "Input", + }, + ], + }; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target: { + ...target, + push_token: "apns-device-token", + preferences_json: disabledPreferences, + }, + aggregate: inputAggregate, + nowMs: 5_000, + }); + + expect(result?.kind).toBe("live_activity_end"); + expect(queuedJobs).toMatchObject([ + { + payload: { + kind: "live_activity_end", + target: { + token: "activity-token", + }, + }, + }, + { + payload: { + kind: "push_notification", + target: { + token: "apns-device-token", + }, + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + }, + }, + ]); + expect(attempts).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }, + ); + + it.effect("does not queue alert pushes when notification permission is disabled", () => { + const attempts: Array = []; + const queuedJobs: Array = []; + const inputAggregate: RelayAgentActivityAggregateState = { + ...aggregate, + activities: [ + { + ...aggregate.activities[0]!, + phase: "waiting_for_input", + status: "Input", + }, + ], + }; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target: { + ...target, + push_token: "apns-device-token", + preferences_json: notificationsDisabledPreferences, + }, + aggregate: inputAggregate, + nowMs: 5_000, + }); + + expect(result?.kind).toBe("live_activity_end"); + expect(queuedJobs).toMatchObject([ + { + payload: { + kind: "live_activity_end", + target: { + token: "activity-token", + }, + }, + }, + ]); + expect(attempts).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }); + + it.effect( + "queues a push notification for approval and input states when no Live Activity delivery is available", + () => { + const attempts: Array = []; + const queuedJobs: Array = []; + const inputAggregate: RelayAgentActivityAggregateState = { + ...aggregate, + activities: [ + { + ...aggregate.activities[0]!, + phase: "waiting_for_input", + status: "Input", + }, + ], + }; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.sendForTarget({ + target: { + ...target, + push_token: "apns-device-token", + push_to_start_token: null, + activity_push_token: null, + remote_started_at: null, + }, + aggregate: inputAggregate, + nowMs: 5_000, + }); + + expect(result?.kind).toBe("push_notification"); + expect(result?.ok).toBe(true); + expect(queuedJobs).toMatchObject([ + { + payload: { + kind: "push_notification", + target: { + token: "apns-device-token", + }, + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + }, + }, + ]); + expect(attempts).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }, + ); + + it.effect("queues bounded alert notification payloads", () => { + const attempts: Array = []; + const queuedJobs: Array = []; + const longTitle = "x".repeat(300); + const inputAggregate: RelayAgentActivityAggregateState = { + ...aggregate, + activities: [ + { + ...aggregate.activities[0]!, + projectTitle: longTitle, + threadTitle: longTitle, + phase: "waiting_for_input", + status: "Input", + deepLink: "https://example.test/not-an-app-link", + }, + ], + }; + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + yield* deliveries.sendForTarget({ + target: { + ...target, + push_token: "apns-device-token", + push_to_start_token: null, + activity_push_token: null, + remote_started_at: null, + }, + aggregate: inputAggregate, + nowMs: 5_000, + }); + + const notification = queuedJobs[0]?.payload.notification; + expect(notification?.title.length).toBeLessThanOrEqual(120); + expect(notification?.body.length).toBeLessThanOrEqual(120); + expect(notification?.deepLink).toBe("/"); + expect(attempts).toEqual([]); + }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); + }); + + it.effect("processes signed jobs through APNs and records attempts", () => { + const attempts: Array = []; + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_update", + userId: target.user_id, + deviceId: target.device_id, + token: target.activity_push_token ?? "activity-token", + aggregate, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-1", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result.kind).toBe("live_activity_update"); + expect(result.ok).toBe(false); + expect(attempts).toMatchObject([ + { + kind: "live_activity_update", + sourceJobId: "job-1", + token: "activity-token", + }, + ]); + }).pipe(Effect.provide(makeLayer({ attempts }))); + }); + + it.effect("processes signed push notification jobs through APNs and records attempts", () => { + const attempts: Array = []; + const payload = makeApnsDeliveryJobPayload({ + kind: "push_notification", + userId: target.user_id, + deviceId: target.device_id, + token: "apns-device-token", + aggregate: null, + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-push-1", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result.kind).toBe("push_notification"); + expect(result.ok).toBe(true); + expect(result.apnsStatus).toBe(200); + expect(attempts).toMatchObject([ + { + kind: "push_notification", + sourceJobId: "job-push-1", + token: "apns-device-token", + environmentId: "env", + threadId: "thread", + deviceId: target.device_id, + apnsStatus: 200, + }, + ]); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + currentTargets: [ + { + ...target, + push_token: "apns-device-token", + }, + ], + config: signingConfig, + execute, + }), + ), + ); + }); + + it.effect("skips duplicate signed queue jobs before calling APNs", () => { + const attempts: Array = []; + let executeCount = 0; + const payload = makeApnsDeliveryJobPayload({ + kind: "push_notification", + userId: target.user_id, + deviceId: target.device_id, + token: "apns-device-token", + aggregate: null, + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-push-duplicate", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + executeCount += 1; + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result).toMatchObject({ + kind: "push_notification", + ok: true, + apnsStatus: null, + apnsReason: "Duplicate APNs delivery job skipped.", + }); + expect(executeCount).toBe(0); + expect(attempts).toEqual([]); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + sourceJobClaims: new Map([["job-push-duplicate", "completed"]]), + config: signingConfig, + execute, + }), + ), + ); + }); + + it.effect("skips stale signed Live Activity jobs when the registered token changed", () => { + const attempts: Array = []; + let executeCount = 0; + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_update", + userId: target.user_id, + deviceId: target.device_id, + token: "stale-activity-token", + aggregate, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-update-stale-token", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + executeCount += 1; + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result).toMatchObject({ + kind: "live_activity_update", + ok: true, + apnsStatus: null, + apnsReason: "Stale APNs delivery job skipped.", + }); + expect(executeCount).toBe(0); + expect(attempts).toMatchObject([ + { + kind: "live_activity_update", + sourceJobId: "job-update-stale-token", + token: "stale-activity-token", + apnsReason: "Stale APNs delivery job skipped.", + }, + ]); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + config: signingConfig, + execute, + }), + ), + ); + }); + + it.effect("skips stale signed push notification jobs when the device token changed", () => { + const attempts: Array = []; + let executeCount = 0; + const payload = makeApnsDeliveryJobPayload({ + kind: "push_notification", + userId: target.user_id, + deviceId: target.device_id, + token: "stale-device-token", + aggregate: null, + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-push-stale-token", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + executeCount += 1; + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result).toMatchObject({ + kind: "push_notification", + ok: true, + apnsStatus: null, + apnsReason: "Stale APNs delivery job skipped.", + }); + expect(executeCount).toBe(0); + expect(attempts).toMatchObject([ + { + kind: "push_notification", + sourceJobId: "job-push-stale-token", + token: "stale-device-token", + apnsReason: "Stale APNs delivery job skipped.", + }, + ]); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + currentTargets: [ + { + ...target, + push_token: "current-device-token", + }, + ], + config: signingConfig, + execute, + }), + ), + ); + }); + + it.effect("retries signed queue jobs that are already claimed but not completed", () => { + const attempts: Array = []; + let executeCount = 0; + const payload = makeApnsDeliveryJobPayload({ + kind: "push_notification", + userId: target.user_id, + deviceId: target.device_id, + token: "apns-device-token", + aggregate: null, + notification: { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-push-in-flight", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + executeCount += 1; + return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* Effect.exit(deliveries.processSignedJob(signed)); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.cause.toString()).toContain("ApnsDeliveryJobClaimInFlight"); + } + expect(executeCount).toBe(0); + expect(attempts).toEqual([]); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + sourceJobClaims: new Map([["job-push-in-flight", "in_flight"]]), + config: signingConfig, + execute, + }), + ), + ); + }); + + it.effect("invalidates dead device push tokens after permanent APNs alert failures", () => { + const attempts: Array = []; + const invalidatedTokens: Array< + Parameters[0] + > = []; + const payload = makeApnsDeliveryJobPayload({ + kind: "push_notification", + userId: target.user_id, + deviceId: target.device_id, + token: "apns-device-token", + aggregate: null, + notification: { + title: "Thread", + body: "Failed: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/", + }, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-push-bad-token", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ reason: "BadDeviceToken" }, { status: 400 }), + ), + ); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result.kind).toBe("push_notification"); + expect(result.ok).toBe(false); + expect(result.apnsStatus).toBe(400); + expect(result.apnsReason).toBe("BadDeviceToken"); + expect(invalidatedTokens).toMatchObject([ + { + userId: target.user_id, + deviceId: target.device_id, + kind: "push_notification", + }, + ]); + }).pipe( + Effect.provide( + makeLayer({ + attempts, + invalidatedTokens, + currentTargets: [ + { + ...target, + push_token: "apns-device-token", + }, + ], + config: signingConfig, + execute, + }), + ), + ); + }); + + it.effect("clears queued start state when a start job fails in APNs", () => { + const attempts: Array = []; + const clearedStarts: Array< + Parameters[0] + > = []; + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_start", + userId: target.user_id, + deviceId: target.device_id, + token: target.push_to_start_token ?? "start-token", + aggregate, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-start-1", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result.kind).toBe("live_activity_start"); + expect(result.ok).toBe(false); + expect(clearedStarts).toEqual([ + { + userId: target.user_id, + deviceId: target.device_id, + }, + ]); + }).pipe(Effect.provide(makeLayer({ attempts, clearedStarts }))); + }); + + it.effect("invalidates dead push-to-start tokens after permanent APNs start failures", () => { + const attempts: Array = []; + const invalidatedTokens: Array< + Parameters[0] + > = []; + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_start", + userId: target.user_id, + deviceId: target.device_id, + token: target.push_to_start_token ?? "start-token", + aggregate, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-start-bad-token", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ reason: "BadDeviceToken" }, { status: 400 }), + ), + ); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result.kind).toBe("live_activity_start"); + expect(result.ok).toBe(false); + expect(result.apnsStatus).toBe(400); + expect(result.apnsReason).toBe("BadDeviceToken"); + expect(invalidatedTokens).toMatchObject([ + { + userId: target.user_id, + deviceId: target.device_id, + kind: "live_activity_start", + }, + ]); + }).pipe( + Effect.provide(makeLayer({ attempts, invalidatedTokens, config: signingConfig, execute })), + ); + }); + + it.effect("invalidates dead Live Activity tokens after APNs unregisters them", () => { + const attempts: Array = []; + const invalidatedTokens: Array< + Parameters[0] + > = []; + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_update", + userId: target.user_id, + deviceId: target.device_id, + token: target.activity_push_token ?? "activity-token", + aggregate, + createdAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-01T00:10:00.000Z", + jobId: "job-update-unregistered", + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ reason: "Unregistered" }, { status: 410 }), + ), + ); + + return Effect.gen(function* () { + const deliveries = yield* ApnsDeliveries.ApnsDeliveries; + const result = yield* deliveries.processSignedJob(signed); + + expect(result.kind).toBe("live_activity_update"); + expect(result.ok).toBe(false); + expect(result.apnsStatus).toBe(410); + expect(result.apnsReason).toBe("Unregistered"); + expect(invalidatedTokens).toMatchObject([ + { + userId: target.user_id, + deviceId: target.device_id, + kind: "live_activity_update", + }, + ]); + }).pipe( + Effect.provide(makeLayer({ attempts, invalidatedTokens, config: signingConfig, execute })), + ); + }); +}); diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts new file mode 100644 index 00000000000..d6f61b37599 --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -0,0 +1,795 @@ +import type { + RelayAgentActivityAggregateState, + RelayAgentAwarenessPreferences, + RelayDeliveryKind, + RelayDeliveryResult, +} from "@t3tools/contracts/relay"; +import { + RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema, + RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema, +} from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { + sanitizeAgentActivityAggregateState, + sanitizeApnsNotificationPayload, +} from "./agentActivityPayloads.ts"; +import * as Apns from "./ApnsClient.ts"; +import { + ApnsDeliveryJobInvalid, + type ApnsNotificationPayload, + SignedApnsDeliveryJob, + verifySignedApnsDeliveryJob, + type ApnsDeliveryJobVerificationError, +} from "./apnsDeliveryJobs.ts"; +import * as DeliveryAttempts from "./DeliveryAttempts.ts"; +import * as LiveActivities from "./LiveActivities.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import { withSpanAttributes } from "../observability.ts"; + +const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000; +const PERMANENT_APNS_TOKEN_REASONS = new Set([ + "BadDeviceToken", + "DeviceTokenNotForTopic", + "Unregistered", +]); + +type LiveActivityDeliveryKind = Extract< + RelayDeliveryKind, + "live_activity_start" | "live_activity_update" | "live_activity_end" +>; + +type ChosenLiveActivityDelivery = + | { + readonly kind: "live_activity_start" | "live_activity_update"; + readonly token: string; + readonly aggregate: RelayAgentActivityAggregateState; + } + | { + readonly kind: "live_activity_end"; + readonly token: string; + readonly aggregate: RelayAgentActivityAggregateState | null; + }; + +type ChosenPushNotificationDelivery = { + readonly kind: "push_notification"; + readonly token: string; + readonly notification: ApnsNotificationPayload; +}; + +type ChosenDelivery = ChosenLiveActivityDelivery | ChosenPushNotificationDelivery; + +export type ApnsDeliveryError = + | ApnsDeliveryQueue.ApnsDeliveryQueueError + | ApnsDeliveryJobVerificationError + | ApnsDeliveryJobClaimInFlight + | DeliveryAttempts.DeliveryAttemptRecordPersistenceError + | LiveActivities.LiveActivityTargetListPersistenceError + | LiveActivities.LiveActivityDeliveryMarkPersistenceError; + +export class ApnsDeliveryJobClaimInFlight extends Data.TaggedError("ApnsDeliveryJobClaimInFlight")<{ + readonly sourceJobId: string; +}> {} + +const decodeRelayAgentActivityAggregateStateJson = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), +); +const decodeRelayAgentAwarenessPreferencesJson = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayAgentAwarenessPreferencesSchema), +); +const decodeSignedApnsDeliveryJob = Schema.decodeUnknownEffect(SignedApnsDeliveryJob); + +function apnsErrorMessage(error: Apns.ApnsError): string { + switch (error._tag) { + case "ApnsSigningError": + return "Failed to sign APNs request."; + case "ApnsHttpRequestError": + return "Failed to send APNs request."; + case "ApnsInvalidResponseError": + return "APNs returned an invalid response."; + } +} + +function parseAggregate(value: string | null): RelayAgentActivityAggregateState | null { + if (!value) { + return null; + } + return Option.getOrNull(decodeRelayAgentActivityAggregateStateJson(value)); +} + +function parsePreferences(value: string): RelayAgentAwarenessPreferences | null { + return Option.getOrNull(decodeRelayAgentAwarenessPreferencesJson(value)); +} + +function shouldUpdateLiveActivity(input: { + readonly previousAggregate: RelayAgentActivityAggregateState | null; + readonly nextAggregate: RelayAgentActivityAggregateState; + readonly lastDeliveryAt: string | null; + readonly nowMs: number; +}): boolean { + if (!input.previousAggregate) { + return true; + } + if (input.previousAggregate.activeCount !== input.nextAggregate.activeCount) { + return true; + } + if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) { + return false; + } + const lastDeliveryAtMs = + input.lastDeliveryAt === null + ? null + : Option.match(DateTime.make(input.lastDeliveryAt), { + onNone: () => Number.NaN, + onSome: (dt) => dt.epochMilliseconds, + }); + return ( + lastDeliveryAtMs === null || + Number.isNaN(lastDeliveryAtMs) || + input.nowMs - lastDeliveryAtMs >= MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS + ); +} + +function notificationForAggregate(input: { + readonly target: LiveActivities.TargetRow; + readonly aggregate: RelayAgentActivityAggregateState | null; +}): ApnsNotificationPayload | null { + if (!input.target.push_token || input.aggregate === null) { + return null; + } + const preferences = parsePreferences(input.target.preferences_json); + if (!preferences?.notificationsEnabled) { + return null; + } + const activity = input.aggregate.activities[0]; + if (!activity) { + return null; + } + const enabled = + (activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) || + (activity.phase === "waiting_for_input" && preferences.notifyOnInput) || + (activity.phase === "completed" && preferences.notifyOnCompletion) || + (activity.phase === "failed" && preferences.notifyOnFailure); + if (!enabled) { + return null; + } + return { + title: activity.threadTitle, + body: `${activity.status}: ${activity.projectTitle}`, + environmentId: activity.environmentId, + threadId: activity.threadId, + deepLink: activity.deepLink, + }; +} + +function chooseLiveActivityDelivery(input: { + readonly target: LiveActivities.TargetRow; + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly nowMs: number; +}): ChosenLiveActivityDelivery | null { + const hasActiveActivity = + input.target.ended_at === null && + (input.target.remote_start_queued_at !== null || + input.target.remote_started_at !== null || + input.target.activity_push_token !== null); + const preferences = parsePreferences(input.target.preferences_json); + if (preferences?.liveActivitiesEnabled === false) { + return hasActiveActivity && input.target.activity_push_token + ? { + kind: "live_activity_end", + token: input.target.activity_push_token, + aggregate: null, + } + : null; + } + if (input.aggregate === null || input.aggregate.activeCount === 0) { + return hasActiveActivity && input.target.activity_push_token + ? { + kind: "live_activity_end", + token: input.target.activity_push_token, + aggregate: input.aggregate, + } + : null; + } + if (!hasActiveActivity) { + return input.target.push_to_start_token + ? { + kind: "live_activity_start", + token: input.target.push_to_start_token, + aggregate: input.aggregate, + } + : null; + } + if (!input.target.activity_push_token) { + return null; + } + return shouldUpdateLiveActivity({ + previousAggregate: parseAggregate(input.target.last_aggregate_json), + nextAggregate: input.aggregate, + lastDeliveryAt: input.target.last_live_activity_delivery_at, + nowMs: input.nowMs, + }) || + input.aggregate.activities.some( + (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", + ) + ? { + kind: "live_activity_update", + token: input.target.activity_push_token, + aggregate: input.aggregate, + } + : null; +} + +function chooseDelivery(input: { + readonly target: LiveActivities.TargetRow; + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly nowMs: number; +}): ChosenDelivery | null { + const liveActivityDelivery = chooseLiveActivityDelivery(input); + if (liveActivityDelivery) { + return liveActivityDelivery; + } + const notification = notificationForAggregate(input); + return notification && input.target.push_token + ? { + kind: "push_notification", + token: input.target.push_token, + notification, + } + : null; +} + +function deliveryEvent(kind: LiveActivityDeliveryKind): Apns.ApnsLiveActivityEvent { + switch (kind) { + case "live_activity_start": + return "start"; + case "live_activity_update": + return "update"; + case "live_activity_end": + return "end"; + } +} + +function isPermanentApnsTokenFailure(result: Apns.ApnsDeliveryResult): boolean { + return ( + !result.ok && + (result.status === 410 || + (result.status === 400 && + result.reason !== undefined && + PERMANENT_APNS_TOKEN_REASONS.has(result.reason))) + ); +} + +function isDeliveryJobVerificationError(value: unknown): value is ApnsDeliveryJobVerificationError { + return ( + typeof value === "object" && + value !== null && + "_tag" in value && + (value._tag === "ApnsDeliveryJobInvalid" || value._tag === "ApnsDeliveryJobExpired") + ); +} + +function duplicateJobResult(input: { + readonly deviceId: string; + readonly kind: RelayDeliveryKind; +}): RelayDeliveryResult { + return { + deviceId: input.deviceId, + kind: input.kind, + ok: true, + apnsStatus: null, + apnsReason: "Duplicate APNs delivery job skipped.", + apnsId: null, + }; +} + +function staleJobResult(input: { + readonly deviceId: string; + readonly kind: RelayDeliveryKind; +}): RelayDeliveryResult { + return { + deviceId: input.deviceId, + kind: input.kind, + ok: true, + apnsStatus: null, + apnsReason: "Stale APNs delivery job skipped.", + apnsId: null, + }; +} + +function deliveryAttemptOutcome(result: Apns.ApnsDeliveryResult) { + return { + ...(result.status === 0 ? {} : { apnsStatus: result.status }), + ...(result.reason === undefined ? {} : { apnsReason: result.reason }), + apnsId: result.apnsId, + ...(result.status === 0 ? { transportError: result.reason ?? "APNs request failed." } : {}), + }; +} + +interface LiveActivityDeliveryTarget { + readonly user_id: string; + readonly device_id: string; +} + +function expectedCurrentToken(input: { + readonly target: LiveActivities.TargetRow; + readonly kind: RelayDeliveryKind; +}): string | null { + switch (input.kind) { + case "live_activity_start": + return input.target.push_to_start_token; + case "live_activity_update": + case "live_activity_end": + return input.target.activity_push_token; + case "push_notification": + return input.target.push_token; + } +} + +interface SendLiveActivityDeliveryInputBase { + readonly target: LiveActivityDeliveryTarget; + readonly token: string; + readonly sourceJobId?: string | null; +} + +export type SendLiveActivityDeliveryInput = + | (SendLiveActivityDeliveryInputBase & { + readonly kind: "live_activity_start" | "live_activity_update"; + readonly aggregate: RelayAgentActivityAggregateState; + }) + | (SendLiveActivityDeliveryInputBase & { + readonly kind: "live_activity_end"; + readonly aggregate: RelayAgentActivityAggregateState | null; + }); + +function makeLiveActivityDeliveryRequest( + apns: Apns.ApnsClientShape, + input: SendLiveActivityDeliveryInput, + now: DateTime.DateTime, +) { + const epochSeconds = Math.floor(now.epochMilliseconds / 1_000); + const base = { + token: input.token, + nowEpochSeconds: epochSeconds, + nowIso: DateTime.formatIso(now), + }; + switch (input.kind) { + case "live_activity_start": + case "live_activity_update": + return { + epochSeconds, + iso: base.nowIso, + request: apns.makeLiveActivityRequest({ + ...base, + event: deliveryEvent(input.kind), + state: input.aggregate, + }), + }; + case "live_activity_end": + return { + epochSeconds, + iso: base.nowIso, + request: apns.makeLiveActivityRequest({ + ...base, + event: "end", + state: input.aggregate, + }), + }; + } +} + +export interface ApnsDeliveriesShape { + readonly sendForTarget: (input: { + readonly target: LiveActivities.TargetRow; + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly nowMs: number; + }) => Effect.Effect; + readonly sendPushNotificationForTarget: (input: { + readonly target: LiveActivities.TargetRow; + readonly aggregate: RelayAgentActivityAggregateState | null; + }) => Effect.Effect; + readonly sendLiveActivity: ( + input: SendLiveActivityDeliveryInput, + ) => Effect.Effect; + readonly processSignedJob: ( + body: unknown, + ) => Effect.Effect; + readonly sendPushNotification: (input: { + readonly target: LiveActivityDeliveryTarget; + readonly token: string; + readonly sourceJobId?: string | null; + readonly notification: ApnsNotificationPayload; + }) => Effect.Effect; +} + +export class ApnsDeliveries extends Context.Service()( + "t3code-relay/agentActivity/ApnsDeliveries", +) {} + +const make = Effect.gen(function* () { + const attempts = yield* DeliveryAttempts.DeliveryAttempts; + const liveActivities = yield* LiveActivities.LiveActivities; + const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; + const config = yield* RelayConfiguration.RelayConfiguration; + const apns = yield* Apns.ApnsClient; + + const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { + readonly target: LiveActivityDeliveryTarget; + readonly kind: RelayDeliveryKind; + readonly token: string; + }) { + return yield* liveActivities.listTargets({ userId: input.target.user_id }).pipe( + Effect.map((targets) => { + const currentTarget = targets.find((row) => row.device_id === input.target.device_id); + return ( + currentTarget !== undefined && + expectedCurrentToken({ target: currentTarget, kind: input.kind }) === input.token + ); + }), + ); + }); + + const sendLiveActivity: ApnsDeliveriesShape["sendLiveActivity"] = Effect.fn( + "relay.apns_deliveries.send_live_activity", + )(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.target.device_id, + "relay.delivery.kind": input.kind, + ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), + }); + const now = yield* DateTime.now; + const aggregate = + input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate); + const { epochSeconds, iso, request } = makeLiveActivityDeliveryRequest( + apns, + { ...input, aggregate } as SendLiveActivityDeliveryInput, + now, + ); + if (input.sourceJobId) { + const claim = yield* attempts.claimSourceJob({ + userId: input.target.user_id, + environmentId: null, + threadId: null, + deviceId: input.target.device_id, + kind: input.kind, + sourceJobId: input.sourceJobId, + token: input.token, + }); + if (claim === "completed") { + return duplicateJobResult({ deviceId: input.target.device_id, kind: input.kind }); + } + if (claim === "in_flight") { + return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId }); + } + const tokenIsCurrent = yield* isCurrentSignedJobToken({ + target: input.target, + kind: input.kind, + token: input.token, + }); + if (!tokenIsCurrent) { + yield* attempts.completeSourceJob({ + sourceJobId: input.sourceJobId, + apnsReason: "Stale APNs delivery job skipped.", + }); + return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); + } + } + const result = yield* apns + .sendLiveActivityRequest({ + credentials: config.apns, + request, + issuedAtUnixSeconds: epochSeconds, + }) + .pipe( + Effect.catch((error) => + Effect.succeed({ + ok: false, + status: 0, + reason: apnsErrorMessage(error), + apnsId: null, + }), + ), + ); + if (result.ok) { + yield* liveActivities.markDelivery({ + userId: input.target.user_id, + deviceId: input.target.device_id, + kind: input.kind, + aggregate, + deliveredAt: iso, + }); + } else if (isPermanentApnsTokenFailure(result)) { + yield* liveActivities.invalidateDeliveryToken({ + userId: input.target.user_id, + deviceId: input.target.device_id, + kind: input.kind, + invalidatedAt: iso, + }); + } else if (input.kind === "live_activity_start") { + yield* liveActivities.clearStartQueued({ + userId: input.target.user_id, + deviceId: input.target.device_id, + }); + } + if (input.sourceJobId) { + yield* attempts.completeSourceJob({ + sourceJobId: input.sourceJobId, + ...deliveryAttemptOutcome(result), + }); + } else { + yield* attempts.record({ + userId: input.target.user_id, + environmentId: null, + threadId: null, + deviceId: input.target.device_id, + kind: input.kind, + token: input.token, + ...deliveryAttemptOutcome(result), + }); + } + return { + deviceId: input.target.device_id, + kind: input.kind, + ok: result.ok, + apnsStatus: result.status === 0 ? null : result.status, + apnsReason: result.reason ?? null, + apnsId: result.apnsId, + }; + }); + + const sendPushNotification: ApnsDeliveriesShape["sendPushNotification"] = Effect.fn( + "relay.apns_deliveries.send_push_notification", + )(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.target.device_id, + "relay.delivery.kind": "push_notification", + ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), + }); + const now = yield* DateTime.now; + const epochSeconds = Math.floor(now.epochMilliseconds / 1_000); + const notification = sanitizeApnsNotificationPayload(input.notification); + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": notification.environmentId, + "relay.thread_id": notification.threadId, + }); + const request = apns.makePushNotificationRequest({ + token: input.token, + notification, + }); + if (input.sourceJobId) { + const claim = yield* attempts.claimSourceJob({ + userId: input.target.user_id, + environmentId: notification.environmentId, + threadId: notification.threadId, + deviceId: input.target.device_id, + kind: "push_notification", + sourceJobId: input.sourceJobId, + token: input.token, + }); + if (claim === "completed") { + return duplicateJobResult({ + deviceId: input.target.device_id, + kind: "push_notification", + }); + } + if (claim === "in_flight") { + return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId }); + } + const tokenIsCurrent = yield* isCurrentSignedJobToken({ + target: input.target, + kind: "push_notification", + token: input.token, + }); + if (!tokenIsCurrent) { + yield* attempts.completeSourceJob({ + sourceJobId: input.sourceJobId, + apnsReason: "Stale APNs delivery job skipped.", + }); + return staleJobResult({ + deviceId: input.target.device_id, + kind: "push_notification", + }); + } + } + const result = yield* apns + .sendPushNotificationRequest({ + credentials: config.apns, + request, + issuedAtUnixSeconds: epochSeconds, + }) + .pipe( + Effect.catch((error) => + Effect.succeed({ + ok: false, + status: 0, + reason: apnsErrorMessage(error), + apnsId: null, + }), + ), + ); + if (isPermanentApnsTokenFailure(result)) { + yield* liveActivities.invalidateDeliveryToken({ + userId: input.target.user_id, + deviceId: input.target.device_id, + kind: "push_notification", + invalidatedAt: DateTime.formatIso(now), + }); + } + if (input.sourceJobId) { + yield* attempts.completeSourceJob({ + sourceJobId: input.sourceJobId, + ...deliveryAttemptOutcome(result), + }); + } else { + yield* attempts.record({ + userId: input.target.user_id, + environmentId: notification.environmentId, + threadId: notification.threadId, + deviceId: input.target.device_id, + kind: "push_notification", + token: input.token, + ...deliveryAttemptOutcome(result), + }); + } + return { + deviceId: input.target.device_id, + kind: "push_notification" as const, + ok: result.ok, + apnsStatus: result.status === 0 ? null : result.status, + apnsReason: result.reason ?? null, + apnsId: result.apnsId, + }; + }); + + const processSignedJob: ApnsDeliveriesShape["processSignedJob"] = Effect.fn( + "relay.apns_deliveries.process_signed_job", + )(function* (body) { + const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe( + Effect.mapError( + () => + new ApnsDeliveryJobInvalid({ + message: "Invalid APNs delivery queue job.", + }), + ), + ); + const now = yield* DateTime.now; + const payload = verifySignedApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + job: signedJob, + nowMs: now.epochMilliseconds, + }); + if (isDeliveryJobVerificationError(payload)) { + return yield* payload; + } + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": payload.target.deviceId, + "relay.delivery.kind": payload.kind, + "relay.delivery.job_id": payload.jobId, + }); + return yield* Effect.suspend(() => { + switch (payload.kind) { + case "live_activity_start": + case "live_activity_update": + if (payload.aggregate === null) { + return Effect.fail( + new ApnsDeliveryJobInvalid({ + message: "Live Activity start/update jobs require an aggregate.", + }), + ); + } + return sendLiveActivity({ + target: { + user_id: payload.target.userId, + device_id: payload.target.deviceId, + }, + token: payload.target.token, + sourceJobId: payload.jobId, + kind: payload.kind, + aggregate: payload.aggregate, + }); + case "live_activity_end": + return sendLiveActivity({ + target: { + user_id: payload.target.userId, + device_id: payload.target.deviceId, + }, + token: payload.target.token, + sourceJobId: payload.jobId, + kind: payload.kind, + aggregate: payload.aggregate, + }); + case "push_notification": + if (payload.notification === null) { + return Effect.fail( + new ApnsDeliveryJobInvalid({ + message: "Push notification jobs require a notification payload.", + }), + ); + } + return sendPushNotification({ + target: { + user_id: payload.target.userId, + device_id: payload.target.deviceId, + }, + token: payload.target.token, + sourceJobId: payload.jobId, + notification: payload.notification, + }); + } + }).pipe(withSpanAttributes({ "user.id": payload.target.userId })); + }); + + return ApnsDeliveries.of({ + sendLiveActivity, + sendPushNotification, + processSignedJob, + sendPushNotificationForTarget: Effect.fnUntraced(function* (input) { + const notification = notificationForAggregate(input); + const token = input.target.push_token; + return yield* notification && token + ? deliveryQueue.enqueuePushNotification({ + userId: input.target.user_id, + deviceId: input.target.device_id, + token, + notification, + }) + : Effect.succeed(null); + }), + sendForTarget: Effect.fnUntraced(function* (input) { + const delivery = chooseDelivery({ + target: input.target, + aggregate: input.aggregate, + nowMs: input.nowMs, + }); + if (!delivery) { + return null; + } + if (delivery.kind === "push_notification") { + const result = yield* deliveryQueue.enqueuePushNotification({ + userId: input.target.user_id, + deviceId: input.target.device_id, + token: delivery.token, + notification: delivery.notification, + }); + return result; + } + const result = yield* deliveryQueue.enqueueLiveActivity({ + userId: input.target.user_id, + deviceId: input.target.device_id, + kind: delivery.kind, + token: delivery.token, + aggregate: delivery.aggregate, + }); + const notification = notificationForAggregate({ + target: input.target, + aggregate: input.aggregate, + }); + if (delivery.kind === "live_activity_end" && notification && input.target.push_token) { + yield* deliveryQueue.enqueuePushNotification({ + userId: input.target.user_id, + deviceId: input.target.device_id, + token: input.target.push_token, + notification, + }); + } + if (delivery.kind === "live_activity_start") { + const now = yield* DateTime.now; + yield* liveActivities.markStartQueued({ + userId: input.target.user_id, + deviceId: input.target.device_id, + queuedAt: DateTime.formatIso(now), + }); + } + return result; + }), + }); +}); + +export const layer = Layer.effect(ApnsDeliveries, make); diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts new file mode 100644 index 00000000000..219c0595293 --- /dev/null +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -0,0 +1,163 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Crypto from "effect/Crypto"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import type { RelayDeliveryResult } from "@t3tools/contracts/relay"; + +import { + sanitizeAgentActivityAggregateState, + sanitizeApnsNotificationPayload, +} from "./agentActivityPayloads.ts"; +import { + expiresAtForJob, + makeApnsDeliveryJobPayload, + signApnsDeliveryJob, + type ApnsDeliveryJobPayload, + type SignedApnsDeliveryJob, +} from "./apnsDeliveryJobs.ts"; +import * as RelayConfiguration from "../Config.ts"; + +export class ApnsDeliveryQueueSendError extends Data.TaggedError("ApnsDeliveryQueueSendError")<{ + readonly cause: unknown; +}> {} + +export type ApnsDeliveryQueueError = ApnsDeliveryQueueSendError; + +export interface ApnsDeliveryQueueSenderShape { + readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect; +} + +export class ApnsDeliveryQueueSender extends Context.Service< + ApnsDeliveryQueueSender, + ApnsDeliveryQueueSenderShape +>()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {} + +export interface ApnsDeliveryQueueShape { + readonly enqueueLiveActivity: (input: { + readonly kind: ApnsDeliveryJobPayload["kind"]; + readonly userId: string; + readonly deviceId: string; + readonly token: string; + readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; + }) => Effect.Effect; + readonly enqueuePushNotification: (input: { + readonly userId: string; + readonly deviceId: string; + readonly token: string; + readonly notification: NonNullable; + }) => Effect.Effect; +} + +export class ApnsDeliveryQueue extends Context.Service()( + "t3code-relay/agentActivity/ApnsDeliveryQueue", +) {} + +const make = Effect.gen(function* () { + const sender = yield* ApnsDeliveryQueueSender; + const crypto = yield* Crypto.Crypto; + const config = yield* RelayConfiguration.RelayConfiguration; + + return ApnsDeliveryQueue.of({ + enqueueLiveActivity: Effect.fn("relay.apns_delivery_queue.enqueue_live_activity")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + "relay.delivery.kind": input.kind, + }); + const now = yield* DateTime.now; + const jobId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), + ); + yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); + const payload = makeApnsDeliveryJobPayload({ + ...input, + aggregate: + input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate), + jobId, + createdAt: DateTime.formatIso(now), + expiresAt: expiresAtForJob(now.epochMilliseconds), + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + yield* sender.send(signed); + return { + deviceId: input.deviceId, + kind: input.kind, + ok: true, + queued: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }; + }, + ), + enqueuePushNotification: Effect.fn("relay.apns_delivery_queue.enqueue_push_notification")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + "relay.delivery.kind": "push_notification", + "relay.environment_id": input.notification.environmentId, + "relay.thread_id": input.notification.threadId, + }); + const now = yield* DateTime.now; + const jobId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), + ); + yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); + const payload = makeApnsDeliveryJobPayload({ + kind: "push_notification", + userId: input.userId, + deviceId: input.deviceId, + token: input.token, + aggregate: null, + notification: sanitizeApnsNotificationPayload(input.notification), + jobId, + createdAt: DateTime.formatIso(now), + expiresAt: expiresAtForJob(now.epochMilliseconds), + }); + const signed = signApnsDeliveryJob({ + secret: config.apnsDeliveryJobSigningSecret, + payload, + }); + yield* sender.send(signed); + return { + deviceId: input.deviceId, + kind: "push_notification" as const, + ok: true, + queued: true, + apnsStatus: null, + apnsReason: null, + apnsId: null, + }; + }, + ), + }); +}); + +export const layer = Layer.effect(ApnsDeliveryQueue, make); + +export const layerCloudflareQueues = ( + sender: Cloudflare.QueueSender, + alchemyRuntimeContext: Alchemy.BaseRuntimeContext, +) => + layer.pipe( + Layer.provide( + Layer.succeed( + ApnsDeliveryQueueSender, + ApnsDeliveryQueueSender.of({ + send: (body) => + sender.send(body).pipe( + Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }), + ), + ), + ); diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.test.ts b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts new file mode 100644 index 00000000000..81abb330726 --- /dev/null +++ b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts @@ -0,0 +1,323 @@ +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { RelayDb, type RelayDatabase } from "../db.ts"; +import { relayDeliveryAttempts } from "../persistence/schema.ts"; +import * as DeliveryAttempts from "./DeliveryAttempts.ts"; + +describe("DeliveryAttempts", () => { + it.effect("records the signed queue source job id for APNs delivery auditability", () => { + const insertedValues: Array> = []; + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayDeliveryAttempts); + return { + values: (values: Record) => { + insertedValues.push(values); + return Effect.void; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const attempts = yield* DeliveryAttempts.DeliveryAttempts; + yield* attempts.record({ + userId: "user-1", + environmentId: "env-1", + threadId: "thread-1", + deviceId: "device-1", + kind: "live_activity_update", + sourceJobId: "job-1", + token: "apns-token", + apnsStatus: 200, + apnsId: "apns-id", + }); + + expect(insertedValues).toHaveLength(1); + expect(insertedValues[0]).toMatchObject({ + userId: "user-1", + environmentId: "env-1", + threadId: "thread-1", + deviceId: "device-1", + kind: "live_activity_update", + sourceJobId: "job-1", + tokenSuffix: "ns-token", + apnsStatus: 200, + apnsId: "apns-id", + }); + }).pipe( + Effect.provide( + DeliveryAttempts.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb, fakeDb)), + ), + ), + ); + }); + + it.effect("claims signed queue source jobs before APNs delivery", () => { + const insertedValues: Array> = []; + const conflictTargets: Array = []; + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayDeliveryAttempts); + return { + values: (values: Record) => { + insertedValues.push(values); + return { + onConflictDoNothing: (config: { readonly target: unknown }) => { + conflictTargets.push(config.target); + return { + returning: () => Effect.succeed([{ id: values.id }]), + }; + }, + }; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const attempts = yield* DeliveryAttempts.DeliveryAttempts; + const claimed = yield* attempts.claimSourceJob({ + userId: "user-1", + environmentId: "env-1", + threadId: "thread-1", + deviceId: "device-1", + kind: "push_notification", + sourceJobId: "job-1", + token: "apns-token", + }); + + expect(claimed).toBe("claimed"); + expect(conflictTargets).toEqual([relayDeliveryAttempts.sourceJobId]); + expect(insertedValues[0]).toMatchObject({ + kind: "push_notification", + sourceJobId: "job-1", + tokenSuffix: "ns-token", + apnsStatus: null, + }); + }).pipe( + Effect.provide( + DeliveryAttempts.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb, fakeDb)), + ), + ), + ); + }); + + it.effect("reports completed source jobs when the durable claim already exists", () => { + const fakeDb = { + insert: () => ({ + values: () => ({ + onConflictDoNothing: () => ({ + returning: () => Effect.succeed([]), + }), + }), + }), + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => + Effect.succeed([ + { + createdAt: "2026-05-26T00:00:00.000Z", + apnsStatus: 200, + apnsReason: null, + apnsId: null, + transportError: null, + }, + ]), + }), + }), + }), + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const attempts = yield* DeliveryAttempts.DeliveryAttempts; + const claimed = yield* attempts.claimSourceJob({ + userId: "user-1", + environmentId: null, + threadId: null, + deviceId: "device-1", + kind: "live_activity_update", + sourceJobId: "job-1", + token: "apns-token", + }); + + expect(claimed).toBe("completed"); + }).pipe( + Effect.provide( + DeliveryAttempts.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb, fakeDb)), + ), + ), + ); + }); + + it.effect("reports in-flight source jobs while an active claim lease exists", () => { + const fakeDb = { + insert: () => ({ + values: () => ({ + onConflictDoNothing: () => ({ + returning: () => Effect.succeed([]), + }), + }), + }), + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => + Effect.succeed([ + { + createdAt: "2999-01-01T00:00:00.000Z", + apnsStatus: null, + apnsReason: null, + apnsId: null, + transportError: null, + }, + ]), + }), + }), + }), + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const attempts = yield* DeliveryAttempts.DeliveryAttempts; + const claimed = yield* attempts.claimSourceJob({ + userId: "user-1", + environmentId: null, + threadId: null, + deviceId: "device-1", + kind: "live_activity_update", + sourceJobId: "job-1", + token: "apns-token", + }); + + expect(claimed).toBe("in_flight"); + }).pipe( + Effect.provide( + DeliveryAttempts.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb, fakeDb)), + ), + ), + ); + }); + + it.effect("reclaims source jobs after the claim lease expires", () => { + const updatedValues: Array> = []; + const fakeDb = { + insert: () => ({ + values: () => ({ + onConflictDoNothing: () => ({ + returning: () => Effect.succeed([]), + }), + }), + }), + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => + Effect.succeed([ + { + createdAt: "1969-12-31T23:00:00.000Z", + apnsStatus: null, + apnsReason: null, + apnsId: null, + transportError: null, + }, + ]), + }), + }), + }), + update: () => ({ + set: (values: Record) => { + updatedValues.push(values); + return { + where: () => ({ + returning: () => Effect.succeed([{ id: "attempt-1" }]), + }), + }; + }, + }), + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const attempts = yield* DeliveryAttempts.DeliveryAttempts; + const claimed = yield* attempts.claimSourceJob({ + userId: "user-1", + environmentId: null, + threadId: null, + deviceId: "device-1", + kind: "live_activity_update", + sourceJobId: "job-1", + token: "apns-token", + }); + + expect(claimed).toBe("claimed"); + expect(updatedValues[0]?.createdAt).toEqual(expect.any(String)); + }).pipe( + Effect.provide( + DeliveryAttempts.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb, fakeDb)), + ), + ), + ); + }); + + it.effect("completes a claimed source job with the APNs outcome", () => { + const updatedValues: Array> = []; + const whereClauses: Array = []; + const fakeDb = { + update: (table: unknown) => { + expect(table).toBe(relayDeliveryAttempts); + return { + set: (values: Record) => { + updatedValues.push(values); + return { + where: (clause: unknown) => { + whereClauses.push(clause); + return Effect.void; + }, + }; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const attempts = yield* DeliveryAttempts.DeliveryAttempts; + yield* attempts.completeSourceJob({ + sourceJobId: "job-1", + apnsStatus: 410, + apnsReason: "Unregistered", + apnsId: "apns-id", + }); + + expect(whereClauses).toHaveLength(1); + expect(updatedValues).toEqual([ + { + createdAt: expect.any(String), + apnsStatus: 410, + apnsReason: "Unregistered", + apnsId: "apns-id", + transportError: null, + }, + ]); + }).pipe( + Effect.provide( + DeliveryAttempts.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb, fakeDb)), + ), + ), + ); + }); +}); diff --git a/infra/relay/src/agentActivity/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts new file mode 100644 index 00000000000..52c58b84a83 --- /dev/null +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -0,0 +1,203 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { and, eq, isNull } from "drizzle-orm"; +import * as Crypto from "effect/Crypto"; + +import { RelayDb } from "../db.ts"; +import { relayDeliveryAttempts } from "../persistence/schema.ts"; + +export class DeliveryAttemptRecordPersistenceError extends Data.TaggedError( + "DeliveryAttemptRecordPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export interface DeliveryAttemptInput { + readonly userId: string | null; + readonly environmentId: string | null; + readonly threadId: string | null; + readonly deviceId: string | null; + readonly kind: string; + readonly sourceJobId?: string | null; + readonly token: string | null; + readonly apnsStatus?: number; + readonly apnsReason?: string; + readonly apnsId?: string | null; + readonly transportError?: string; +} + +export interface DeliveryAttemptCompletionInput { + readonly sourceJobId: string; + readonly apnsStatus?: number; + readonly apnsReason?: string; + readonly apnsId?: string | null; + readonly transportError?: string; +} + +export type DeliverySourceJobClaimResult = "claimed" | "completed" | "in_flight"; + +export interface DeliveryAttemptsShape { + readonly record: ( + input: DeliveryAttemptInput, + ) => Effect.Effect; + readonly claimSourceJob: ( + input: DeliveryAttemptInput & { readonly sourceJobId: string }, + ) => Effect.Effect; + readonly completeSourceJob: ( + input: DeliveryAttemptCompletionInput, + ) => Effect.Effect; +} + +export class DeliveryAttempts extends Context.Service()( + "t3code-relay/agentActivity/DeliveryAttempts", +) {} + +const SOURCE_JOB_CLAIM_LEASE_MINUTES = 10; + +function insertValues( + input: DeliveryAttemptInput, + id: string, + createdAt: string, +): typeof relayDeliveryAttempts.$inferInsert { + return { + id, + createdAt, + userId: input.userId, + environmentId: input.environmentId, + threadId: input.threadId, + deviceId: input.deviceId, + kind: input.kind, + sourceJobId: input.sourceJobId ?? null, + tokenSuffix: input.token?.slice(-8) ?? null, + apnsStatus: input.apnsStatus ?? null, + apnsReason: input.apnsReason ?? null, + apnsId: input.apnsId ?? null, + transportError: input.transportError ?? null, + }; +} + +const make = Effect.gen(function* () { + const db = yield* RelayDb; + const crypto = yield* Crypto.Crypto; + + const isExpiredClaim = (claimedAt: string | null, now: DateTime.DateTime) => { + if (claimedAt === null) { + return true; + } + return Option.match(DateTime.make(claimedAt), { + onNone: () => true, + onSome: (dateTime) => + now.epochMilliseconds - dateTime.epochMilliseconds >= + SOURCE_JOB_CLAIM_LEASE_MINUTES * 60 * 1_000, + }); + }; + + return DeliveryAttempts.of({ + record: Effect.fn("relay.delivery_attempts.record")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.delivery.kind": input.kind, + ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), + ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), + ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), + ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), + }); + const id = yield* crypto.randomUUIDv4; + const createdAt = DateTime.formatIso(yield* DateTime.now); + yield* db.insert(relayDeliveryAttempts).values(insertValues(input, id, createdAt)); + }, + Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), + ), + claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.delivery.kind": input.kind, + "relay.delivery.job_id": input.sourceJobId, + ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), + ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), + ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), + }); + const id = yield* crypto.randomUUIDv4; + const now = yield* DateTime.now; + const createdAt = DateTime.formatIso(now); + const inserted = yield* db + .insert(relayDeliveryAttempts) + .values(insertValues(input, id, createdAt)) + .onConflictDoNothing({ target: relayDeliveryAttempts.sourceJobId }) + .returning({ id: relayDeliveryAttempts.id }); + if (inserted.length > 0) { + return "claimed"; + } + + const existing = yield* db + .select({ + createdAt: relayDeliveryAttempts.createdAt, + apnsStatus: relayDeliveryAttempts.apnsStatus, + apnsReason: relayDeliveryAttempts.apnsReason, + apnsId: relayDeliveryAttempts.apnsId, + transportError: relayDeliveryAttempts.transportError, + }) + .from(relayDeliveryAttempts) + .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId)) + .limit(1); + const row = existing[0]; + if (!row) { + return "in_flight"; + } + if ( + row.apnsStatus !== null || + row.apnsReason !== null || + row.apnsId !== null || + row.transportError !== null + ) { + return "completed"; + } + if (!isExpiredClaim(row.createdAt, now)) { + return "in_flight"; + } + + const reclaimed = yield* db + .update(relayDeliveryAttempts) + .set({ + createdAt, + }) + .where( + and( + eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId), + eq(relayDeliveryAttempts.createdAt, row.createdAt), + isNull(relayDeliveryAttempts.apnsStatus), + isNull(relayDeliveryAttempts.apnsReason), + isNull(relayDeliveryAttempts.apnsId), + isNull(relayDeliveryAttempts.transportError), + ), + ) + .returning({ id: relayDeliveryAttempts.id }); + return reclaimed.length > 0 ? "claimed" : "in_flight"; + }, + Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), + ), + completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")( + function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId }); + const completedAt = DateTime.formatIso(yield* DateTime.now); + yield* db + .update(relayDeliveryAttempts) + .set({ + createdAt: completedAt, + apnsStatus: input.apnsStatus ?? null, + apnsReason: input.apnsReason ?? null, + apnsId: input.apnsId ?? null, + transportError: input.transportError ?? null, + }) + .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId)); + }, + Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), + ), + }); +}); + +export const layer = Layer.effect(DeliveryAttempts, make); diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts new file mode 100644 index 00000000000..7a3b227703f --- /dev/null +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -0,0 +1,220 @@ +import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { RelayDb, type RelayDatabase } from "../db.ts"; +import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; +import * as Devices from "./Devices.ts"; + +const registration: RelayDeviceRegistrationRequest = { + deviceId: "device-1" as RelayDeviceRegistrationRequest["deviceId"], + label: "Julius's iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"], + pushToken: "apns-device-token" as RelayDeviceRegistrationRequest["pushToken"], + pushToStartToken: "push-to-start-token" as RelayDeviceRegistrationRequest["pushToStartToken"], + preferences: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, +}; + +describe("Devices", () => { + it.effect("claims APNs tokens globally before upserting the current user device", () => { + const calls: Array = []; + const updateSets: Array> = []; + const updateConditions: Array = []; + const insertedValues: Array> = []; + const dialect = new PgDialect(); + + const fakeDb = { + update: (table: unknown) => { + expect(table).toBe(relayMobileDevices); + calls.push("update"); + return { + set: (values: Record) => { + updateSets.push(values); + calls.push("update.set"); + return { + where: (condition: SQL) => { + expect(condition).toBeDefined(); + updateConditions.push(condition); + calls.push("update.where"); + return Effect.void; + }, + }; + }, + }; + }, + insert: (table: unknown) => { + expect(table).toBe(relayMobileDevices); + calls.push("insert"); + return { + values: (values: Record) => { + insertedValues.push(values); + calls.push("insert.values"); + return { + onConflictDoUpdate: (config: unknown) => { + expect(config).toBeDefined(); + calls.push("insert.onConflictDoUpdate"); + return Effect.void; + }, + }; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + yield* devices.register({ userId: "user-2", registration }); + + expect(calls).toEqual([ + "update", + "update.set", + "update.where", + "update", + "update.set", + "update.where", + "insert", + "insert.values", + "insert.onConflictDoUpdate", + ]); + expect(updateSets).toEqual([ + expect.objectContaining({ pushToken: null }), + expect.objectContaining({ pushToStartToken: null }), + ]); + expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([ + { + sql: '"relay_mobile_devices"."push_token" = $1', + params: ["apns-device-token"], + }, + { + sql: '"relay_mobile_devices"."push_to_start_token" = $1', + params: ["push-to-start-token"], + }, + ]); + expect(insertedValues).toEqual([ + expect.objectContaining({ + userId: "user-2", + deviceId: "device-1", + pushToken: "apns-device-token", + pushToStartToken: "push-to-start-token", + }), + ]); + }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }); + + it.effect("unregisters APNs state only for the current user device", () => { + const calls: Array = []; + const deleteConditions: Array = []; + const dialect = new PgDialect(); + + const fakeDb = { + delete: (table: unknown) => { + calls.push(table === relayLiveActivities ? "delete.liveActivities" : "delete.devices"); + return { + where: (condition: SQL) => { + expect(condition).toBeDefined(); + deleteConditions.push(condition); + calls.push("delete.where"); + return Effect.void; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + yield* devices.unregister({ userId: "user-2", deviceId: "device-1" }); + + expect(calls).toEqual([ + "delete.liveActivities", + "delete.where", + "delete.devices", + "delete.where", + ]); + expect(deleteConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([ + { + sql: + '(("relay_live_activities"."user_id" = $1) and ' + + '("relay_live_activities"."device_id" = $2))', + params: ["user-2", "device-1"], + }, + { + sql: + '(("relay_mobile_devices"."user_id" = $1) and ' + + '("relay_mobile_devices"."device_id" = $2))', + params: ["user-2", "device-1"], + }, + ]); + }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }); + + it.effect("lists safe notification state without exposing APNs tokens", () => { + const dialect = new PgDialect(); + let condition: SQL | null = null; + const fakeDb = { + select: () => ({ + from: (table: unknown) => { + expect(table).toBe(relayMobileDevices); + return { + where: (nextCondition: SQL) => { + condition = nextCondition; + return Effect.succeed([ + { + deviceId: "device-1", + label: "Julius's iPhone", + platform: "ios" as const, + iosMajorVersion: 18, + appVersion: "1.0.0", + preferences: registration.preferences, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ]); + }, + }; + }, + }), + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const devices = yield* Devices.Devices; + const listed = yield* devices.listForUser({ userId: "user-2" }); + + expect(condition).not.toBeNull(); + expect(dialect.sqlToQuery(condition!)).toEqual({ + sql: '"relay_mobile_devices"."user_id" = $1', + params: ["user-2"], + }); + expect(listed).toEqual([ + { + deviceId: "device-1", + label: "Julius's iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ]); + }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }); +}); diff --git a/infra/relay/src/agentActivity/Devices.ts b/infra/relay/src/agentActivity/Devices.ts new file mode 100644 index 00000000000..417dba80dd0 --- /dev/null +++ b/infra/relay/src/agentActivity/Devices.ts @@ -0,0 +1,181 @@ +import type { + RelayClientDeviceRecord, + RelayDeviceRegistrationRequest, +} from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { and, eq } from "drizzle-orm"; +import { sql } from "drizzle-orm"; + +import { RelayDb } from "../db.ts"; +import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; + +export class DeviceRegistrationPersistenceError extends Data.TaggedError( + "DeviceRegistrationPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class DeviceUnregistrationPersistenceError extends Data.TaggedError( + "DeviceUnregistrationPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class DeviceListPersistenceError extends Data.TaggedError("DeviceListPersistenceError")<{ + readonly cause: unknown; +}> {} + +export interface DevicesShape { + readonly register: (input: { + readonly userId: string; + readonly registration: RelayDeviceRegistrationRequest; + }) => Effect.Effect; + readonly unregister: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect, DeviceListPersistenceError>; +} + +export class Devices extends Context.Service()( + "t3code-relay/agentActivity/Devices", +) {} + +const make = Effect.gen(function* () { + const db = yield* RelayDb; + + return Devices.of({ + register: Effect.fn("relay.devices.register")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.registration.deviceId, + }); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + const registration = input.registration; + + yield* Effect.all( + [ + registration.pushToken + ? db + .update(relayMobileDevices) + .set({ pushToken: null, updatedAt }) + .where(eq(relayMobileDevices.pushToken, registration.pushToken)) + : Effect.void, + registration.pushToStartToken + ? db + .update(relayMobileDevices) + .set({ pushToStartToken: null, updatedAt }) + .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) + : Effect.void, + ], + { concurrency: 2, discard: true }, + ); + + yield* db + .insert(relayMobileDevices) + .values({ + userId: input.userId, + deviceId: registration.deviceId, + label: registration.label, + platform: registration.platform, + iosMajorVersion: registration.iosMajorVersion, + appVersion: registration.appVersion ?? null, + pushToken: registration.pushToken ?? null, + pushToStartToken: registration.pushToStartToken ?? null, + preferencesJson: registration.preferences, + createdAt: updatedAt, + updatedAt, + }) + .onConflictDoUpdate({ + target: [relayMobileDevices.userId, relayMobileDevices.deviceId], + set: { + platform: registration.platform, + label: registration.label, + iosMajorVersion: registration.iosMajorVersion, + appVersion: registration.appVersion ?? null, + pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, + pushToStartToken: sql`coalesce( + excluded.push_to_start_token, + ${relayMobileDevices.pushToStartToken} + )`, + preferencesJson: registration.preferences, + updatedAt, + }, + }); + }, + Effect.mapError((cause) => new DeviceRegistrationPersistenceError({ cause })), + ), + unregister: Effect.fn("relay.devices.unregister")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + }); + yield* Effect.all( + [ + db + .delete(relayLiveActivities) + .where( + and( + eq(relayLiveActivities.userId, input.userId), + eq(relayLiveActivities.deviceId, input.deviceId), + ), + ), + db + .delete(relayMobileDevices) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ), + ], + { concurrency: 2, discard: true }, + ); + }, + Effect.mapError((cause) => new DeviceUnregistrationPersistenceError({ cause })), + ), + listForUser: Effect.fn("relay.devices.listForUser")( + function* (input) { + const rows = yield* db + .select({ + deviceId: relayMobileDevices.deviceId, + label: relayMobileDevices.label, + platform: relayMobileDevices.platform, + iosMajorVersion: relayMobileDevices.iosMajorVersion, + appVersion: relayMobileDevices.appVersion, + preferences: relayMobileDevices.preferencesJson, + updatedAt: relayMobileDevices.updatedAt, + }) + .from(relayMobileDevices) + .where(eq(relayMobileDevices.userId, input.userId)); + return rows.map((row) => ({ + deviceId: row.deviceId, + label: row.label, + platform: row.platform, + iosMajorVersion: row.iosMajorVersion, + appVersion: row.appVersion, + notifications: { + enabled: row.preferences.notificationsEnabled, + notifyOnApproval: row.preferences.notifyOnApproval, + notifyOnInput: row.preferences.notifyOnInput, + notifyOnCompletion: row.preferences.notifyOnCompletion, + notifyOnFailure: row.preferences.notifyOnFailure, + }, + liveActivities: { + enabled: row.preferences.liveActivitiesEnabled, + }, + updatedAt: row.updatedAt, + })); + }, + Effect.mapError((cause) => new DeviceListPersistenceError({ cause })), + ), + }); +}); + +export const layer = Layer.effect(Devices, make); diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts new file mode 100644 index 00000000000..19a1179b305 --- /dev/null +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -0,0 +1,196 @@ +import type { + RelayAgentActivityAggregateState, + RelayLiveActivityRegistrationRequest, +} from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { RelayDb, type RelayDatabase } from "../db.ts"; +import { relayLiveActivities } from "../persistence/schema.ts"; +import * as LiveActivities from "./LiveActivities.ts"; + +const aggregate: RelayAgentActivityAggregateState = { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: "2026-05-25T00:00:00.000Z", + activities: [ + { + environmentId: + "env" as RelayAgentActivityAggregateState["activities"][number]["environmentId"], + threadId: "thread" as RelayAgentActivityAggregateState["activities"][number]["threadId"], + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running", + status: "Working", + updatedAt: "2026-05-25T00:00:00.000Z", + deepLink: "/threads/env/thread", + }, + ], +}; + +describe("LiveActivities", () => { + it.effect( + "claims Live Activity push tokens globally before upserting the current user device", + () => { + const registration: RelayLiveActivityRegistrationRequest = { + deviceId: "device-1" as RelayLiveActivityRegistrationRequest["deviceId"], + activityPushToken: + "activity-push-token" as RelayLiveActivityRegistrationRequest["activityPushToken"], + }; + const calls: Array = []; + const updateSets: Array> = []; + const updateConditions: Array = []; + const insertedValues: Array> = []; + const conflictConfigs: Array<{ + readonly set?: Record; + }> = []; + const dialect = new PgDialect(); + + const fakeDb = { + update: (table: unknown) => { + expect(table).toBe(relayLiveActivities); + calls.push("update"); + return { + set: (values: Record) => { + updateSets.push(values); + calls.push("update.set"); + return { + where: (condition: SQL) => { + expect(condition).toBeDefined(); + updateConditions.push(condition); + calls.push("update.where"); + return Effect.void; + }, + }; + }, + }; + }, + insert: (table: unknown) => { + expect(table).toBe(relayLiveActivities); + calls.push("insert"); + return { + values: (values: Record) => { + insertedValues.push(values); + calls.push("insert.values"); + return { + onConflictDoUpdate: (config: { readonly set?: Record }) => { + expect(config).toBeDefined(); + conflictConfigs.push(config); + calls.push("insert.onConflictDoUpdate"); + return Effect.void; + }, + }; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const liveActivities = yield* LiveActivities.LiveActivities; + yield* liveActivities.register({ userId: "user-2", registration }); + + expect(calls).toEqual([ + "update", + "update.set", + "update.where", + "insert", + "insert.values", + "insert.onConflictDoUpdate", + ]); + expect(updateSets).toEqual([ + expect.objectContaining({ + activityPushToken: null, + remoteStartQueuedAt: null, + remoteStartedAt: null, + }), + ]); + expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([ + { + sql: '"relay_live_activities"."activity_push_token" = $1', + params: ["activity-push-token"], + }, + ]); + expect(insertedValues).toEqual([ + expect.objectContaining({ + userId: "user-2", + deviceId: "device-1", + activityPushToken: "activity-push-token", + remoteStartQueuedAt: null, + remoteStartedAt: expect.any(String), + endedAt: null, + lastAggregateJson: null, + lastLiveActivityDeliveryAt: null, + }), + ]); + expect(conflictConfigs[0]?.set).toEqual( + expect.objectContaining({ + activityPushToken: "activity-push-token", + remoteStartQueuedAt: null, + remoteStartedAt: expect.any(String), + endedAt: null, + lastAggregateJson: null, + lastLiveActivityDeliveryAt: null, + }), + ); + }).pipe( + Effect.provide(LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb)))), + ); + }, + ); + + it.effect("preserves ended state when a delayed update delivery is marked", () => { + const insertedValues: Array> = []; + const conflictConfigs: Array<{ + readonly set?: Record; + }> = []; + + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayLiveActivities); + return { + values: (values: Record) => { + insertedValues.push(values); + return { + onConflictDoUpdate: (config: { readonly set?: Record }) => { + conflictConfigs.push(config); + return Effect.void; + }, + }; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const liveActivities = yield* LiveActivities.LiveActivities; + yield* liveActivities.markDelivery({ + userId: "user-2", + deviceId: "device-1", + kind: "live_activity_update", + aggregate, + deliveredAt: "2026-05-25T00:00:10.000Z", + }); + + expect(insertedValues).toEqual([ + expect.objectContaining({ + userId: "user-2", + deviceId: "device-1", + endedAt: null, + }), + ]); + expect(conflictConfigs[0]?.set).toEqual( + expect.objectContaining({ + endedAt: relayLiveActivities.endedAt, + lastLiveActivityDeliveryAt: "2026-05-25T00:00:10.000Z", + }), + ); + }).pipe( + Effect.provide(LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb)))), + ); + }); +}); diff --git a/infra/relay/src/agentActivity/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts new file mode 100644 index 00000000000..d90e5695b7c --- /dev/null +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -0,0 +1,373 @@ +import type { + RelayAgentActivityAggregateState, + RelayDeliveryKind, + RelayLiveActivityRegistrationRequest, +} from "@t3tools/contracts/relay"; +import { RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema } from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import { cast } from "effect/Function"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { and, eq, sql } from "drizzle-orm"; + +import { RelayDb } from "../db.ts"; +import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; + +export class LiveActivityRegistrationPersistenceError extends Data.TaggedError( + "LiveActivityRegistrationPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class LiveActivityTargetListPersistenceError extends Data.TaggedError( + "LiveActivityTargetListPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class LiveActivityDeliveryMarkPersistenceError extends Data.TaggedError( + "LiveActivityDeliveryMarkPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export interface DeviceRow { + readonly user_id: string; + readonly device_id: string; + readonly platform: "ios"; + readonly ios_major_version: number; + readonly app_version: string | null; + readonly push_token: string | null; + readonly push_to_start_token: string | null; + readonly preferences_json: string; +} + +export interface LiveActivityRow { + readonly activity_push_token: string | null; + readonly remote_start_queued_at: string | null; + readonly remote_started_at: string | null; + readonly ended_at: string | null; + readonly last_aggregate_json: string | null; + readonly last_live_activity_delivery_at: string | null; +} + +export type TargetRow = DeviceRow & LiveActivityRow; + +export interface LiveActivitiesShape { + readonly register: (input: { + readonly userId: string; + readonly registration: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect; + readonly listTargets: (input: { + readonly userId: string; + }) => Effect.Effect, LiveActivityTargetListPersistenceError>; + readonly markDelivery: (input: { + readonly userId: string; + readonly deviceId: string; + readonly kind: RelayDeliveryKind; + readonly aggregate: RelayAgentActivityAggregateState | null; + readonly deliveredAt: string; + }) => Effect.Effect; + readonly markStartQueued: (input: { + readonly userId: string; + readonly deviceId: string; + readonly queuedAt: string; + }) => Effect.Effect; + readonly clearStartQueued: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly invalidateDeliveryToken: (input: { + readonly userId: string; + readonly deviceId: string; + readonly kind: RelayDeliveryKind; + readonly invalidatedAt: string; + }) => Effect.Effect; +} + +export class LiveActivities extends Context.Service()( + "t3code-relay/agentActivity/LiveActivities", +) {} + +const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); +const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); + +const encodeRelayAgentActivityAggregateStateJson = Schema.encodeEffect( + Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), +); + +const make = Effect.gen(function* () { + const db = yield* RelayDb; + + return LiveActivities.of({ + register: Effect.fn("relay.live_activities.register")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.registration.deviceId, + }); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + const registration = input.registration; + + yield* db + .update(relayLiveActivities) + .set({ + activityPushToken: null, + remoteStartQueuedAt: null, + remoteStartedAt: null, + endedAt: updatedAt, + updatedAt, + }) + .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken)); + + yield* db + .insert(relayLiveActivities) + .values({ + userId: input.userId, + deviceId: registration.deviceId, + activityPushToken: registration.activityPushToken, + remoteStartQueuedAt: null, + remoteStartedAt: updatedAt, + endedAt: null, + lastAggregateJson: null, + lastLiveActivityDeliveryAt: null, + createdAt: updatedAt, + updatedAt, + }) + .onConflictDoUpdate({ + target: [relayLiveActivities.userId, relayLiveActivities.deviceId], + set: { + activityPushToken: registration.activityPushToken, + remoteStartQueuedAt: null, + remoteStartedAt: updatedAt, + endedAt: null, + lastAggregateJson: null, + lastLiveActivityDeliveryAt: null, + updatedAt, + }, + }); + }, + Effect.mapError((cause) => new LiveActivityRegistrationPersistenceError({ cause })), + ), + + listTargets: Effect.fn("relay.live_activities.list_targets")(function* (input) { + return yield* db + .select({ + device_id: relayMobileDevices.deviceId, + user_id: relayMobileDevices.userId, + platform: relayMobileDevices.platform, + ios_major_version: relayMobileDevices.iosMajorVersion, + app_version: relayMobileDevices.appVersion, + push_token: relayMobileDevices.pushToken, + push_to_start_token: relayMobileDevices.pushToStartToken, + preferences_json: relayMobileDevices.preferencesJson, + activity_push_token: relayLiveActivities.activityPushToken, + remote_start_queued_at: relayLiveActivities.remoteStartQueuedAt, + remote_started_at: relayLiveActivities.remoteStartedAt, + ended_at: relayLiveActivities.endedAt, + last_aggregate_json: relayLiveActivities.lastAggregateJson, + last_live_activity_delivery_at: relayLiveActivities.lastLiveActivityDeliveryAt, + }) + .from(relayMobileDevices) + .leftJoin( + relayLiveActivities, + and( + eq(relayLiveActivities.userId, relayMobileDevices.userId), + eq(relayLiveActivities.deviceId, relayMobileDevices.deviceId), + ), + ) + .where(eq(relayMobileDevices.userId, input.userId)) + .pipe( + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => + Effect.all({ + preferences_json: encodeJsonValue(row.preferences_json), + last_aggregate_json: + row.last_aggregate_json === null + ? Effect.succeed(null) + : encodeJsonValue(row.last_aggregate_json), + }).pipe( + Effect.map((json) => ({ + ...row, + ...json, + })), + ), + { concurrency: "unbounded" }, + ), + ), + Effect.map((rows): ReadonlyArray => rows), + Effect.mapError((cause) => new LiveActivityTargetListPersistenceError({ cause })), + ); + }), + + markDelivery: Effect.fn("relay.live_activities.mark_delivery")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + "relay.delivery.kind": input.kind, + }); + const aggregateJson = + input.aggregate === null + ? null + : yield* encodeRelayAgentActivityAggregateStateJson(input.aggregate).pipe( + Effect.flatMap(decodeJsonString), + Effect.map(cast), + ); + + yield* db + .insert(relayLiveActivities) + .values({ + userId: input.userId, + deviceId: input.deviceId, + remoteStartedAt: input.kind === "live_activity_start" ? input.deliveredAt : null, + remoteStartQueuedAt: null, + endedAt: input.kind === "live_activity_end" ? input.deliveredAt : null, + lastAggregateJson: aggregateJson, + lastLiveActivityDeliveryAt: input.deliveredAt, + createdAt: input.deliveredAt, + updatedAt: input.deliveredAt, + }) + .onConflictDoUpdate({ + target: [relayLiveActivities.userId, relayLiveActivities.deviceId], + set: { + remoteStartedAt: sql`coalesce( + ${relayLiveActivities.remoteStartedAt}, + excluded.remote_started_at + )`, + remoteStartQueuedAt: null, + endedAt: + input.kind === "live_activity_start" + ? null + : input.kind === "live_activity_end" + ? input.deliveredAt + : relayLiveActivities.endedAt, + lastAggregateJson: aggregateJson, + lastLiveActivityDeliveryAt: input.deliveredAt, + updatedAt: input.deliveredAt, + }, + }); + }, + Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause })), + ), + + markStartQueued: Effect.fn("relay.live_activities.mark_start_queued")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + }); + yield* db + .insert(relayLiveActivities) + .values({ + userId: input.userId, + deviceId: input.deviceId, + remoteStartQueuedAt: input.queuedAt, + remoteStartedAt: null, + endedAt: null, + createdAt: input.queuedAt, + updatedAt: input.queuedAt, + }) + .onConflictDoUpdate({ + target: [relayLiveActivities.userId, relayLiveActivities.deviceId], + set: { + remoteStartQueuedAt: sql`coalesce( + ${relayLiveActivities.remoteStartQueuedAt}, + excluded.remote_start_queued_at + )`, + endedAt: null, + updatedAt: input.queuedAt, + }, + }) + .pipe(Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause }))); + }), + + clearStartQueued: Effect.fn("relay.live_activities.clear_start_queued")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + }); + yield* db + .update(relayLiveActivities) + .set({ remoteStartQueuedAt: null }) + .where( + and( + eq(relayLiveActivities.userId, input.userId), + eq(relayLiveActivities.deviceId, input.deviceId), + ), + ) + .pipe(Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause }))); + }), + + invalidateDeliveryToken: Effect.fn("relay.live_activities.invalidate_delivery_token")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + "relay.delivery.kind": input.kind, + }); + if (input.kind === "push_notification") { + yield* db + .update(relayMobileDevices) + .set({ + pushToken: null, + updatedAt: input.invalidatedAt, + }) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ); + return; + } + + if (input.kind === "live_activity_start") { + yield* db + .update(relayMobileDevices) + .set({ + pushToStartToken: null, + updatedAt: input.invalidatedAt, + }) + .where( + and( + eq(relayMobileDevices.userId, input.userId), + eq(relayMobileDevices.deviceId, input.deviceId), + ), + ); + yield* db + .update(relayLiveActivities) + .set({ + remoteStartQueuedAt: null, + updatedAt: input.invalidatedAt, + }) + .where( + and( + eq(relayLiveActivities.userId, input.userId), + eq(relayLiveActivities.deviceId, input.deviceId), + ), + ); + return; + } + + yield* db + .update(relayLiveActivities) + .set({ + activityPushToken: null, + remoteStartQueuedAt: null, + remoteStartedAt: null, + endedAt: input.invalidatedAt, + updatedAt: input.invalidatedAt, + }) + .where( + and( + eq(relayLiveActivities.userId, input.userId), + eq(relayLiveActivities.deviceId, input.deviceId), + ), + ); + }, + Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause })), + ), + }); +}); + +export const layer = Layer.effect(LiveActivities, make); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts new file mode 100644 index 00000000000..74cf905523b --- /dev/null +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -0,0 +1,464 @@ +import type { + RelayAgentActivityState, + RelayDeviceRegistrationRequest, +} from "@t3tools/contracts/relay"; +import type { SignedApnsDeliveryJob } from "./apnsDeliveryJobs.ts"; +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { FetchHttpClient } from "effect/unstable/http"; + +import * as Devices from "./Devices.ts"; +import * as AgentActivityRows from "./AgentActivityRows.ts"; +import * as DeliveryAttempts from "./DeliveryAttempts.ts"; +import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; +import * as LiveActivities from "./LiveActivities.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; +import * as ApnsDeliveries from "./ApnsDeliveries.ts"; +import * as ApnsClient from "./ApnsClient.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as MobileRegistrations from "./MobileRegistrations.ts"; + +const device: RelayDeviceRegistrationRequest = { + deviceId: "device-1" as RelayDeviceRegistrationRequest["deviceId"], + label: "Julius's iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"], + preferences: { + liveActivitiesEnabled: true, + notificationsEnabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, +}; + +function makeDevices(overrides: Partial = {}): Devices.DevicesShape { + return { + register: () => Effect.void, + unregister: () => Effect.void, + listForUser: () => Effect.succeed([]), + ...overrides, + }; +} + +function makeLiveActivities( + overrides: Partial = {}, +): LiveActivities.LiveActivitiesShape { + return { + register: () => Effect.void, + listTargets: () => Effect.succeed([]), + markDelivery: () => Effect.void, + markStartQueued: () => Effect.void, + clearStartQueued: () => Effect.void, + invalidateDeliveryToken: () => Effect.void, + ...overrides, + }; +} + +function makeAgentActivityRows( + overrides: Partial = {}, +): AgentActivityRows.AgentActivityRowsShape { + return { + upsert: () => Effect.void, + remove: () => Effect.void, + listForUser: () => { + const activeState: RelayAgentActivityState = { + environmentId: "env-1" as RelayAgentActivityState["environmentId"], + threadId: "thread-1" as RelayAgentActivityState["threadId"], + projectTitle: "Project", + threadTitle: "Implement APNs", + modelTitle: "gpt-5.4", + phase: "running", + headline: "Working", + updatedAt: "1970-01-01T00:00:10.000Z", + deepLink: "/env-1/thread-1", + }; + return Effect.succeed([activeState]); + }, + ...overrides, + }; +} + +function makeEnvironmentLinks( + overrides: Partial = {}, +): EnvironmentLinks.EnvironmentLinksShape { + return { + upsert: () => Effect.void, + listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), + listDeliveryUsersForEnvironment: () => + Effect.succeed([ + { + userId: "dev:julius", + notificationsEnabled: true, + liveActivitiesEnabled: true, + }, + ]), + listPublicKeysForEnvironment: () => Effect.succeed([]), + listForUser: () => Effect.succeed([]), + getForUser: () => Effect.succeed(null), + revokeForUser: () => Effect.succeed(false), + ...overrides, + }; +} + +function makeDeliveryAttempts( + overrides: Partial = {}, +): DeliveryAttempts.DeliveryAttemptsShape { + return { + record: () => Effect.void, + claimSourceJob: () => Effect.succeed("claimed"), + completeSourceJob: () => Effect.void, + ...overrides, + }; +} + +const config = RelayConfiguration.RelayConfiguration.of({ + relayIssuer: "https://relay.example.test", + apns: { + environment: "sandbox", + teamId: "team-id", + keyId: "key-id", + bundleId: "codes.t3.mobile", + privateKey: Redacted.make("apns-private-key"), + }, + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"), + cloudMintPrivateKey: Redacted.make("cloud-private-key"), + cloudMintPublicKey: "cloud-public-key", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +}); + +function makeRegistrationReplayLayer(input: { + readonly devices: Devices.DevicesShape; + readonly liveActivities: LiveActivities.LiveActivitiesShape; + readonly queuedJobs: Array; +}) { + return MobileRegistrations.layer.pipe( + Layer.provide(AgentActivityPublisher.layer), + Layer.provide(ApnsDeliveries.layer.pipe(Layer.provide(ApnsClient.layer))), + Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), + Layer.provide( + Layer.mergeAll( + Layer.succeed(Devices.Devices, input.devices), + Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), + Layer.succeed(LiveActivities.LiveActivities, input.liveActivities), + Layer.succeed(DeliveryAttempts.DeliveryAttempts, makeDeliveryAttempts()), + Layer.succeed(RelayConfiguration.RelayConfiguration, config), + Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { + send: (body) => + Effect.sync(() => { + input.queuedJobs.push(body); + }), + }), + ), + ), + Layer.provide(FetchHttpClient.layer), + ); +} + +function makeAgentActivityPublisher( + overrides: Partial = {}, +): AgentActivityPublisher.AgentActivityPublisherShape { + return { + publish: () => Effect.succeed({ ok: true, deliveries: [] }), + replayForLiveActivityRegistration: () => Effect.succeed(null), + ...overrides, + }; +} + +describe("MobileRegistrations", () => { + it.effect("registers devices through the device persistence service", () => { + let registered: Parameters[0] | null = null; + let replayed: + | Parameters< + AgentActivityPublisher.AgentActivityPublisherShape["replayForLiveActivityRegistration"] + >[0] + | null = null; + + return Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const registrations = yield* MobileRegistrations.MobileRegistrations; + return yield* registrations.registerDevice({ userId: "dev:julius", payload: device }); + }).pipe( + Effect.provide( + MobileRegistrations.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + Devices.Devices, + makeDevices({ + register: (input) => + Effect.sync(() => { + registered = input; + }), + }), + ), + Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed( + AgentActivityPublisher.AgentActivityPublisher, + makeAgentActivityPublisher({ + replayForLiveActivityRegistration: (input) => + Effect.sync(() => { + replayed = input; + return null; + }), + }), + ), + ), + ), + ), + ), + ); + + expect(result).toEqual({ ok: true }); + expect(registered).toEqual({ userId: "dev:julius", registration: device }); + expect(replayed).toEqual({ + userId: "dev:julius", + deviceId: "device-1", + }); + }); + }); + + it.effect("keeps device registration successful when activity replay fails", () => { + return Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const registrations = yield* MobileRegistrations.MobileRegistrations; + return yield* registrations.registerDevice({ userId: "dev:julius", payload: device }); + }).pipe( + Effect.provide( + MobileRegistrations.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Devices.Devices, makeDevices()), + Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed( + AgentActivityPublisher.AgentActivityPublisher, + makeAgentActivityPublisher({ + replayForLiveActivityRegistration: () => + Effect.fail( + new AgentActivityRows.AgentActivityRowListPersistenceError({ + cause: "replay failed", + }), + ), + }), + ), + ), + ), + ), + ), + ); + + expect(result).toEqual({ ok: true }); + }); + }); + + it.effect("unregisters the current user's device", () => { + let unregistered: Parameters[0] | null = null; + + return Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const registrations = yield* MobileRegistrations.MobileRegistrations; + return yield* registrations.unregisterDevice({ + userId: "dev:julius", + deviceId: "device-1", + }); + }).pipe( + Effect.provide( + MobileRegistrations.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + Devices.Devices, + makeDevices({ + unregister: (input) => + Effect.sync(() => { + unregistered = input; + }), + }), + ), + Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), + Layer.succeed( + AgentActivityPublisher.AgentActivityPublisher, + makeAgentActivityPublisher(), + ), + ), + ), + ), + ), + ); + + expect(result).toEqual({ ok: true }); + expect(unregistered).toEqual({ + userId: "dev:julius", + deviceId: "device-1", + }); + }); + }); + + it.effect("replays the latest activity state after registering a Live Activity token", () => { + const liveActivity = { + deviceId: "device-1" as const, + activityPushToken: "activity-token" as const, + }; + let registered: Parameters[0] | null = null; + let replayed: + | Parameters< + AgentActivityPublisher.AgentActivityPublisherShape["replayForLiveActivityRegistration"] + >[0] + | null = null; + + return Effect.gen(function* () { + const result = yield* Effect.gen(function* () { + const registrations = yield* MobileRegistrations.MobileRegistrations; + return yield* registrations.registerLiveActivity({ + userId: "dev:julius", + payload: liveActivity, + }); + }).pipe( + Effect.provide( + MobileRegistrations.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Devices.Devices, makeDevices()), + Layer.succeed( + LiveActivities.LiveActivities, + makeLiveActivities({ + register: (input) => + Effect.sync(() => { + registered = input; + }), + }), + ), + Layer.succeed( + AgentActivityPublisher.AgentActivityPublisher, + makeAgentActivityPublisher({ + replayForLiveActivityRegistration: (input) => + Effect.sync(() => { + replayed = input; + return null; + }), + }), + ), + ), + ), + ), + ), + ); + + expect(result).toEqual({ ok: true }); + expect(registered).toEqual({ + userId: "dev:julius", + registration: liveActivity, + }); + expect(replayed).toEqual({ + userId: "dev:julius", + deviceId: "device-1", + }); + }); + }); + + it.effect( + "starts a remote Live Activity through the real publisher and APNs queue when a device registers after work is already active", + () => { + const queuedJobs: Array = []; + const queuedStarts: Array< + Parameters[0] + > = []; + const registeredDevices: Array[0]> = []; + const devices = makeDevices({ + register: (input) => + Effect.sync(() => { + registeredDevices.push(input); + }), + }); + const liveActivities = makeLiveActivities({ + listTargets: () => + Effect.succeed([ + { + user_id: "dev:julius", + device_id: "device-1", + platform: "ios", + ios_major_version: 18, + app_version: "1.0.0", + push_token: "apns-device-token", + push_to_start_token: "push-to-start-token", + preferences_json: JSON.stringify(device.preferences), + activity_push_token: null, + remote_start_queued_at: null, + remote_started_at: null, + ended_at: null, + last_aggregate_json: null, + last_live_activity_delivery_at: null, + }, + ]), + markStartQueued: (input) => + Effect.sync(() => { + queuedStarts.push(input); + }), + }); + + return Effect.gen(function* () { + const registrations = yield* MobileRegistrations.MobileRegistrations; + const result = yield* registrations.registerDevice({ + userId: "dev:julius", + payload: { + ...device, + pushToken: "apns-device-token", + pushToStartToken: "push-to-start-token", + }, + }); + + expect(result).toEqual({ ok: true }); + expect(registeredDevices).toEqual([ + { + userId: "dev:julius", + registration: { + ...device, + pushToken: "apns-device-token", + pushToStartToken: "push-to-start-token", + }, + }, + ]); + expect(queuedStarts).toMatchObject([ + { + userId: "dev:julius", + deviceId: "device-1", + }, + ]); + expect(queuedJobs).toHaveLength(1); + expect(queuedJobs[0]?.payload).toMatchObject({ + kind: "live_activity_start", + target: { + userId: "dev:julius", + deviceId: "device-1", + token: "push-to-start-token", + }, + aggregate: { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + activities: [ + { + environmentId: "env-1", + threadId: "thread-1", + threadTitle: "Implement APNs", + status: "Working", + }, + ], + }, + notification: null, + }); + }).pipe(Effect.provide(makeRegistrationReplayLayer({ devices, liveActivities, queuedJobs }))); + }, + ); +}); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts new file mode 100644 index 00000000000..d9c013232a3 --- /dev/null +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -0,0 +1,93 @@ +import type { + RelayDeviceRegistrationRequest, + RelayLiveActivityRegistrationRequest, +} from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Devices from "./Devices.ts"; +import * as LiveActivities from "./LiveActivities.ts"; +import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; + +export type MobileRegistrationError = + | Devices.DeviceRegistrationPersistenceError + | Devices.DeviceUnregistrationPersistenceError + | LiveActivities.LiveActivityRegistrationPersistenceError; + +export interface MobileRegistrationsShape { + readonly registerDevice: (input: { + readonly userId: string; + readonly payload: RelayDeviceRegistrationRequest; + }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; + readonly registerLiveActivity: (input: { + readonly userId: string; + readonly payload: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; + readonly unregisterDevice: (input: { + readonly userId: string; + readonly deviceId: string; + }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; +} + +export class MobileRegistrations extends Context.Service< + MobileRegistrations, + MobileRegistrationsShape +>()("t3code-relay/agentActivity/MobileRegistrations") {} + +const make = Effect.gen(function* () { + const devices = yield* Devices.Devices; + const liveActivities = yield* LiveActivities.LiveActivities; + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + + return MobileRegistrations.of({ + registerDevice: Effect.fn("relay.mobile_registrations.register_device")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.payload.deviceId, + "relay.mobile.platform": input.payload.platform, + }); + yield* devices.register({ userId: input.userId, registration: input.payload }); + yield* publisher + .replayForLiveActivityRegistration({ + userId: input.userId, + deviceId: input.payload.deviceId, + }) + .pipe( + Effect.tapError((cause) => + Effect.logWarning("device registration activity replay failed", { cause }), + ), + Effect.ignore, + ); + return { ok: true as const }; + }), + registerLiveActivity: Effect.fn("relay.mobile_registrations.register_live_activity")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.payload.deviceId, + }); + yield* liveActivities.register({ userId: input.userId, registration: input.payload }); + yield* publisher + .replayForLiveActivityRegistration({ + userId: input.userId, + deviceId: input.payload.deviceId, + }) + .pipe( + Effect.tapError((cause) => + Effect.logWarning("live activity registration replay failed", { cause }), + ), + Effect.ignore, + ); + return { ok: true as const }; + }, + ), + unregisterDevice: Effect.fn("relay.mobile_registrations.unregister_device")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.mobile.device_id": input.deviceId, + }); + yield* devices.unregister(input); + return { ok: true as const }; + }), + }); +}); + +export const layer = Layer.effect(MobileRegistrations, make); diff --git a/infra/relay/src/agentActivity/agentActivityPayloads.ts b/infra/relay/src/agentActivity/agentActivityPayloads.ts new file mode 100644 index 00000000000..ed3fc3f0116 --- /dev/null +++ b/infra/relay/src/agentActivity/agentActivityPayloads.ts @@ -0,0 +1,63 @@ +import type { + RelayAgentActivityAggregateRow, + RelayAgentActivityAggregateState, +} from "@t3tools/contracts/relay"; +import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; + +const MAX_SUMMARY_TEXT_LENGTH = 120; +const MAX_STATUS_TEXT_LENGTH = 40; +const MAX_DEEP_LINK_LENGTH = 512; +const MAX_ACTIVITY_ROWS = 3; + +function truncateText(value: string, maxLength: number): string { + const trimmed = value.trim(); + if (trimmed.length <= maxLength) { + return trimmed; + } + return trimmed.slice(0, maxLength - 3).trimEnd() + "..."; +} + +function sanitizeDeepLink(value: string): string { + const trimmed = value.trim(); + if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { + return "/"; + } + return truncateText(trimmed, MAX_DEEP_LINK_LENGTH); +} + +export function sanitizeAgentActivityAggregateRow( + row: RelayAgentActivityAggregateRow, +): RelayAgentActivityAggregateRow { + return { + ...row, + projectTitle: truncateText(row.projectTitle, MAX_SUMMARY_TEXT_LENGTH), + threadTitle: truncateText(row.threadTitle, MAX_SUMMARY_TEXT_LENGTH), + modelTitle: truncateText(row.modelTitle, MAX_SUMMARY_TEXT_LENGTH), + status: truncateText(row.status, MAX_STATUS_TEXT_LENGTH), + deepLink: sanitizeDeepLink(row.deepLink), + }; +} + +export function sanitizeAgentActivityAggregateState( + aggregate: RelayAgentActivityAggregateState, +): RelayAgentActivityAggregateState { + return { + ...aggregate, + title: truncateText(aggregate.title, MAX_SUMMARY_TEXT_LENGTH), + subtitle: truncateText(aggregate.subtitle, MAX_SUMMARY_TEXT_LENGTH), + activities: aggregate.activities + .slice(0, MAX_ACTIVITY_ROWS) + .map(sanitizeAgentActivityAggregateRow), + }; +} + +export function sanitizeApnsNotificationPayload( + notification: ApnsNotificationPayload, +): ApnsNotificationPayload { + return { + ...notification, + title: truncateText(notification.title, MAX_SUMMARY_TEXT_LENGTH), + body: truncateText(notification.body, MAX_SUMMARY_TEXT_LENGTH), + deepLink: sanitizeDeepLink(notification.deepLink), + }; +} diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts new file mode 100644 index 00000000000..428dc3a82b6 --- /dev/null +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; +import * as Redacted from "effect/Redacted"; + +import { + makeApnsDeliveryJobPayload, + signApnsDeliveryJob, + verifySignedApnsDeliveryJob, +} from "./apnsDeliveryJobs.ts"; + +const secret = Redacted.make("queue-signing-secret"); +const aggregate: RelayAgentActivityAggregateState = { + title: "T3 Code", + subtitle: "Agent work in progress", + activeCount: 1, + updatedAt: "2026-05-25T00:00:00.000Z", + activities: [ + { + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running", + status: "Working", + updatedAt: "2026-05-25T00:00:00.000Z", + deepLink: "/threads/env/thread", + }, + ], +}; + +const notification = { + title: "Thread", + body: "Input: Project", + environmentId: "env", + threadId: "thread", + deepLink: "/threads/env/thread", +}; + +describe("apnsDeliveryJobs", () => { + it("rejects tampered signed queue jobs", () => { + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + token: "token-1", + aggregate: null, + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-25T00:05:00.000Z", + jobId: "job-1", + }); + const signed = signApnsDeliveryJob({ secret, payload }); + const tampered = { + ...signed, + payload: { + ...signed.payload, + target: { + ...signed.payload.target, + token: "attacker-token", + }, + }, + }; + + const result = verifySignedApnsDeliveryJob({ + secret, + job: tampered, + nowMs: 0, + }); + + expect(result).toMatchObject({ + _tag: "ApnsDeliveryJobInvalid", + }); + }); + + it("rejects Live Activity start jobs without aggregate state", () => { + const payload = makeApnsDeliveryJobPayload({ + kind: "live_activity_start", + userId: "user-1", + deviceId: "device-1", + token: "token-1", + aggregate: null, + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-25T00:05:00.000Z", + jobId: "job-start-invalid", + }); + const signed = signApnsDeliveryJob({ secret, payload }); + + const result = verifySignedApnsDeliveryJob({ + secret, + job: signed, + nowMs: 0, + }); + + expect(result).toMatchObject({ + _tag: "ApnsDeliveryJobInvalid", + message: "Live Activity start/update jobs require an aggregate.", + }); + }); + + it("rejects push notification jobs carrying aggregate state", () => { + const payload = makeApnsDeliveryJobPayload({ + kind: "push_notification", + userId: "user-1", + deviceId: "device-1", + token: "token-1", + aggregate, + notification, + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-25T00:05:00.000Z", + jobId: "job-push-invalid", + }); + const signed = signApnsDeliveryJob({ secret, payload }); + + const result = verifySignedApnsDeliveryJob({ + secret, + job: signed, + nowMs: 0, + }); + + expect(result).toMatchObject({ + _tag: "ApnsDeliveryJobInvalid", + message: "Push notification jobs must not carry aggregate state.", + }); + }); + + it("accepts minimal kind-specific signed queue jobs", () => { + const pushPayload = makeApnsDeliveryJobPayload({ + kind: "push_notification", + userId: "user-1", + deviceId: "device-1", + token: "token-1", + aggregate: null, + notification, + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-25T00:05:00.000Z", + jobId: "job-push-valid", + }); + const liveActivityPayload = makeApnsDeliveryJobPayload({ + kind: "live_activity_update", + userId: "user-1", + deviceId: "device-1", + token: "token-1", + aggregate, + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-25T00:05:00.000Z", + jobId: "job-live-valid", + }); + + expect( + verifySignedApnsDeliveryJob({ + secret, + job: signApnsDeliveryJob({ secret, payload: pushPayload }), + nowMs: 0, + }), + ).toEqual(pushPayload); + expect( + verifySignedApnsDeliveryJob({ + secret, + job: signApnsDeliveryJob({ secret, payload: liveActivityPayload }), + nowMs: 0, + }), + ).toEqual(liveActivityPayload); + }); + + it("rejects jobs with invalid or overlong time windows", () => { + const basePayload = makeApnsDeliveryJobPayload({ + kind: "live_activity_end", + userId: "user-1", + deviceId: "device-1", + token: "token-1", + aggregate: null, + createdAt: "2026-05-25T00:00:00.000Z", + expiresAt: "2026-05-25T00:10:00.000Z", + jobId: "job-window", + }); + const invalidCreatedAt = { + ...basePayload, + createdAt: "not-a-date", + }; + const invertedWindow = { + ...basePayload, + expiresAt: "2026-05-24T23:59:59.000Z", + }; + const overlongWindow = { + ...basePayload, + expiresAt: "2026-05-25T00:10:01.000Z", + }; + + expect( + verifySignedApnsDeliveryJob({ + secret, + job: signApnsDeliveryJob({ secret, payload: invalidCreatedAt }), + nowMs: 0, + }), + ).toMatchObject({ + _tag: "ApnsDeliveryJobInvalid", + message: "Invalid APNs delivery job creation time.", + }); + expect( + verifySignedApnsDeliveryJob({ + secret, + job: signApnsDeliveryJob({ secret, payload: invertedWindow }), + nowMs: 0, + }), + ).toMatchObject({ + _tag: "ApnsDeliveryJobInvalid", + message: "Invalid APNs delivery job time window.", + }); + expect( + verifySignedApnsDeliveryJob({ + secret, + job: signApnsDeliveryJob({ secret, payload: overlongWindow }), + nowMs: 0, + }), + ).toMatchObject({ + _tag: "ApnsDeliveryJobInvalid", + message: "APNs delivery job time window is too long.", + }); + }); +}); diff --git a/infra/relay/src/agentActivity/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts new file mode 100644 index 00000000000..0de28197e0b --- /dev/null +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts @@ -0,0 +1,196 @@ +import * as NodeCrypto from "node:crypto"; + +import { RelayAgentActivityAggregateState, type RelayDeliveryKind } from "@t3tools/contracts/relay"; +import { stableStringify } from "@t3tools/shared/relaySigning"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +const MAX_JOB_AGE_MS = 10 * 60 * 1_000; +export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256"; + +const ApnsDeliveryKind = Schema.Literals([ + "live_activity_start", + "live_activity_update", + "live_activity_end", + "push_notification", +]); + +export const ApnsNotificationPayload = Schema.Struct({ + title: Schema.String, + body: Schema.String, + environmentId: Schema.String, + threadId: Schema.String, + deepLink: Schema.String, +}); +export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; + +export const ApnsDeliveryJobPayload = Schema.Struct({ + version: Schema.Literal(1), + jobId: Schema.String, + kind: ApnsDeliveryKind, + target: Schema.Struct({ + userId: Schema.String, + deviceId: Schema.String, + token: Schema.String, + }), + aggregate: Schema.NullOr(RelayAgentActivityAggregateState), + notification: Schema.NullOr(ApnsNotificationPayload), + createdAt: Schema.String, + expiresAt: Schema.String, +}); +export type ApnsDeliveryJobPayload = typeof ApnsDeliveryJobPayload.Type; + +export const SignedApnsDeliveryJob = Schema.Struct({ + algorithm: Schema.Literal(APNS_DELIVERY_JOB_SIGNING_ALGORITHM), + payload: ApnsDeliveryJobPayload, + signature: Schema.String, +}); +export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; + +export class ApnsDeliveryJobInvalid extends Data.TaggedError("ApnsDeliveryJobInvalid")<{ + readonly message: string; +}> {} + +export class ApnsDeliveryJobExpired extends Data.TaggedError("ApnsDeliveryJobExpired")<{ + readonly expiresAt: string; +}> {} + +export type ApnsDeliveryJobVerificationError = ApnsDeliveryJobInvalid | ApnsDeliveryJobExpired; + +export function makeApnsDeliveryJobPayload(input: { + readonly kind: RelayDeliveryKind; + readonly userId: string; + readonly deviceId: string; + readonly token: string; + readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; + readonly notification?: ApnsNotificationPayload | null; + readonly createdAt: string; + readonly expiresAt: string; + readonly jobId: string; +}): ApnsDeliveryJobPayload { + return { + version: 1, + jobId: input.jobId, + kind: input.kind, + target: { + userId: input.userId, + deviceId: input.deviceId, + token: input.token, + }, + aggregate: input.aggregate, + notification: input.notification ?? null, + createdAt: input.createdAt, + expiresAt: input.expiresAt, + }; +} + +export function expiresAtForJob(createdAtMs: number): string { + return DateTime.formatIso(Option.getOrThrow(DateTime.make(createdAtMs + MAX_JOB_AGE_MS))); +} + +function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobInvalid | null { + switch (payload.kind) { + case "live_activity_start": + case "live_activity_update": + if (payload.aggregate === null) { + return new ApnsDeliveryJobInvalid({ + message: "Live Activity start/update jobs require an aggregate.", + }); + } + if (payload.notification !== null) { + return new ApnsDeliveryJobInvalid({ + message: "Live Activity jobs must not carry push notification payloads.", + }); + } + return null; + case "live_activity_end": + if (payload.notification !== null) { + return new ApnsDeliveryJobInvalid({ + message: "Live Activity jobs must not carry push notification payloads.", + }); + } + return null; + case "push_notification": + if (payload.notification === null) { + return new ApnsDeliveryJobInvalid({ + message: "Push notification jobs require a notification payload.", + }); + } + if (payload.aggregate !== null) { + return new ApnsDeliveryJobInvalid({ + message: "Push notification jobs must not carry aggregate state.", + }); + } + return null; + } +} + +function signatureForPayload(input: { + readonly secret: Redacted.Redacted; + readonly payload: ApnsDeliveryJobPayload; +}): string { + return NodeCrypto.createHmac("sha256", Redacted.value(input.secret)) + .update(stableStringify(input.payload)) + .digest("base64url"); +} + +function timingSafeEqualBase64Url(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "base64url"); + const rightBuffer = Buffer.from(right, "base64url"); + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +export function signApnsDeliveryJob(input: { + readonly secret: Redacted.Redacted; + readonly payload: ApnsDeliveryJobPayload; +}): SignedApnsDeliveryJob { + return { + algorithm: APNS_DELIVERY_JOB_SIGNING_ALGORITHM, + payload: input.payload, + signature: signatureForPayload(input), + }; +} + +export function verifySignedApnsDeliveryJob(input: { + readonly secret: Redacted.Redacted; + readonly job: SignedApnsDeliveryJob; + readonly nowMs: number; +}): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError { + const invalidPayload = validatePayloadShape(input.job.payload); + if (invalidPayload !== null) { + return invalidPayload; + } + const createdAt = DateTime.make(input.job.payload.createdAt); + if (Option.isNone(createdAt)) { + return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job creation time." }); + } + const expiresAt = DateTime.make(input.job.payload.expiresAt); + if (Option.isNone(expiresAt)) { + return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job expiry." }); + } + const createdAtMs = createdAt.value.epochMilliseconds; + const expiresAtMs = expiresAt.value.epochMilliseconds; + if (expiresAtMs <= createdAtMs) { + return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job time window." }); + } + if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) { + return new ApnsDeliveryJobInvalid({ message: "APNs delivery job time window is too long." }); + } + if (expiresAtMs <= input.nowMs) { + return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt }); + } + const expected = signatureForPayload({ + secret: input.secret, + payload: input.job.payload, + }); + if (!timingSafeEqualBase64Url(input.job.signature, expected)) { + return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job signature." }); + } + return input.job.payload; +} diff --git a/infra/relay/src/auth/DpopProofs.test.ts b/infra/relay/src/auth/DpopProofs.test.ts new file mode 100644 index 00000000000..9fae6298c9c --- /dev/null +++ b/infra/relay/src/auth/DpopProofs.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { RelayDb, type RelayDatabase } from "../db.ts"; +import { relayDpopProofs } from "../persistence/schema.ts"; +import * as DpopProofs from "./DpopProofs.ts"; + +describe("DpopProofReplay", () => { + it.effect("consumes proof ids without pruning expired rows on the request path", () => { + const calls: Array = []; + const insertedValues: Array<{ + readonly thumbprint: string; + readonly jti: string; + readonly iat: number; + readonly expiresAt: string; + readonly createdAt: string; + }> = []; + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayDpopProofs); + calls.push("insert"); + return { + values: (values: (typeof insertedValues)[number]) => { + insertedValues.push(values); + calls.push("insert.values"); + return { + onConflictDoNothing: () => { + calls.push("insert.onConflictDoNothing"); + return { + returning: (selection: unknown) => { + expect(selection).toBeDefined(); + calls.push("insert.returning"); + return Effect.succeed([{ jti: values.jti }]); + }, + }; + }, + }; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; + const consumed = yield* replay.consume({ + thumbprint: "thumbprint", + jti: "jti", + iat: 1_771_000_000, + expiresAt: Option.getOrThrow(DateTime.make("2026-05-25T12:00:00.000Z")), + }); + + expect(consumed).toBe(true); + expect(calls).toEqual([ + "insert", + "insert.values", + "insert.onConflictDoNothing", + "insert.returning", + ]); + expect(insertedValues).toMatchObject([ + { + thumbprint: "thumbprint", + jti: "jti", + iat: 1_771_000_000, + expiresAt: "2026-05-25T12:00:00.000Z", + }, + ]); + }).pipe(Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }); + + it.effect("prunes expired proof rows from the maintenance path", () => { + const calls: Array = []; + const fakeDb = { + delete: (table: unknown) => { + expect(table).toBe(relayDpopProofs); + calls.push("delete"); + return { + where: (condition: unknown) => { + expect(condition).toBeDefined(); + calls.push("delete.where"); + return Effect.void; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; + yield* replay.pruneExpired; + expect(calls).toEqual(["delete", "delete.where"]); + }).pipe(Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }); +}); diff --git a/infra/relay/src/auth/DpopProofs.ts b/infra/relay/src/auth/DpopProofs.ts new file mode 100644 index 00000000000..fb94d59ffd2 --- /dev/null +++ b/infra/relay/src/auth/DpopProofs.ts @@ -0,0 +1,128 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; +import { lt } from "drizzle-orm"; + +import { verifyDpopProof } from "@t3tools/shared/dpop"; +import { RelayDb } from "../db.ts"; +import { relayDpopProofs } from "../persistence/schema.ts"; + +export class DpopProofReplayPersistenceError extends Data.TaggedError( + "DpopProofReplayPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export interface DpopProofReplayShape { + readonly verifyAndConsume: (input: { + readonly proof: string | undefined; + readonly method: string; + readonly url: string; + readonly expectedThumbprint?: string; + readonly expectedAccessToken?: string; + readonly now: DateTime.DateTime; + }) => Effect.Effect; + + readonly consume: (input: { + readonly thumbprint: string; + readonly jti: string; + readonly iat: number; + readonly expiresAt: DateTime.DateTime; + }) => Effect.Effect; + + readonly pruneExpired: Effect.Effect; +} + +export class DpopProofReplay extends Context.Service()( + "t3code-relay/auth/DpopProofs/DpopProofReplay", +) {} + +const make = Effect.gen(function* () { + const db = yield* RelayDb; + + const consume: DpopProofReplayShape["consume"] = Effect.fn("relay.dpop_proofs.consume")( + function* (input) { + const createdAt = DateTime.formatIso(yield* DateTime.now); + const inserted = yield* db + .insert(relayDpopProofs) + .values({ + thumbprint: input.thumbprint, + jti: input.jti, + iat: input.iat, + expiresAt: DateTime.formatIso(input.expiresAt), + createdAt, + }) + .onConflictDoNothing() + .returning({ jti: relayDpopProofs.jti }); + return inserted.length > 0; + }, + Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), + ); + + const verifyAndConsume: DpopProofReplayShape["verifyAndConsume"] = Effect.fn( + "relay.dpop_proofs.verify_and_consume", + )(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.dpop.method": input.method, + "relay.dpop.expected_thumbprint_present": input.expectedThumbprint !== undefined, + "relay.dpop.expected_access_token_present": input.expectedAccessToken !== undefined, + }); + const result = verifyDpopProof({ + proof: input.proof, + method: input.method, + url: input.url, + nowEpochSeconds: Math.floor(input.now.epochMilliseconds / 1_000), + ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), + ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), + }); + if (!result.ok) { + yield* Effect.logWarning("relay dpop proof rejected", { + reason: result.reason, + method: input.method, + url: input.url, + expectedThumbprintPresent: input.expectedThumbprint !== undefined, + expectedAccessTokenPresent: input.expectedAccessToken !== undefined, + }); + return yield* new HttpApiError.Unauthorized({}); + } + const consumed = yield* consume({ + thumbprint: result.thumbprint, + jti: result.jti, + iat: result.iat, + expiresAt: DateTime.add(input.now, { minutes: 5 }), + }); + if (!consumed) { + yield* Effect.logWarning("relay dpop proof replay rejected", { + thumbprint: result.thumbprint, + jti: result.jti, + iat: result.iat, + }); + return yield* new HttpApiError.Unauthorized({}); + } + yield* Effect.annotateCurrentSpan({ + "relay.dpop.thumbprint": result.thumbprint, + "relay.dpop.iat": result.iat, + }); + return result.thumbprint; + }); + + const pruneExpired: DpopProofReplayShape["pruneExpired"] = Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); + yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now }); + yield* db.delete(relayDpopProofs).where(lt(relayDpopProofs.expiresAt, now)); + }).pipe( + Effect.withSpan("relay.dpop_proofs.prune_expired"), + Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), + ); + + return DpopProofReplay.of({ + verifyAndConsume, + consume, + pruneExpired, + }); +}); + +export const layer = Layer.effect(DpopProofReplay, make); diff --git a/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts new file mode 100644 index 00000000000..d09ee76e42c --- /dev/null +++ b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts @@ -0,0 +1,217 @@ +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; +import { + computeDpopAccessTokenHash, + computeDpopJwkThumbprint, + type DpopPublicJwk, +} from "@t3tools/shared/dpop"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { RelayDb, type RelayDatabase } from "../db.ts"; +import { relayDpopProofs } from "../persistence/schema.ts"; +import * as DpopProofs from "./DpopProofs.ts"; + +interface DpopProofInsertValues { + readonly thumbprint: string; + readonly jti: string; + readonly iat: number; + readonly expiresAt: string; + readonly createdAt: string; +} + +function makeDpopProof(input: { + readonly method: string; + readonly url: string; + readonly iat: number; + readonly jti: string; + readonly accessToken?: string; +}) { + const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk; + const header = Buffer.from( + JSON.stringify({ + typ: "dpop+jwt", + alg: "ES256", + jwk: publicJwk, + }), + ).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ + htm: input.method, + htu: input.url, + jti: input.jti, + iat: input.iat, + ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), + }), + ).toString("base64url"); + const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { + key: privateKey, + dsaEncoding: "ieee-p1363", + }).toString("base64url"); + return { + proof: `${header}.${payload}.${signature}`, + thumbprint: computeDpopJwkThumbprint(publicJwk), + }; +} + +function layer( + insert: ( + values: DpopProofInsertValues, + ) => Effect.Effect, { _tag: string }>, +) { + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayDpopProofs); + return { + values: (values: DpopProofInsertValues) => ({ + onConflictDoNothing: () => ({ + returning: (selection: unknown) => { + expect(selection).toBeDefined(); + return insert(values); + }, + }), + }), + }; + }, + } as unknown as RelayDatabase; + return DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))); +} + +function consumeEachProofOnce() { + const consumed = new Set(); + return (values: DpopProofInsertValues) => + Effect.sync(() => { + const key = `${values.thumbprint}:${values.jti}`; + if (consumed.has(key)) { + return []; + } + consumed.add(key); + return [{ jti: values.jti }]; + }); +} + +describe("DpopProofReplay.verifyAndConsume", () => { + it.effect("rejects replayed proofs after persistence consumes the jti once", () => { + const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z"); + const proof = makeDpopProof({ + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + iat: Math.floor(now.epochMilliseconds / 1_000), + jti: "proof-1", + }); + + return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; + const first = yield* replay.verifyAndConsume({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + expectedThumbprint: proof.thumbprint, + now, + }); + const second = yield* Effect.exit( + replay.verifyAndConsume({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + expectedThumbprint: proof.thumbprint, + now, + }), + ); + + expect(first).toBe(proof.thumbprint); + expect(second._tag).toBe("Failure"); + }).pipe(Effect.provide(layer(consumeEachProofOnce()))); + }); + + it.effect("rejects proofs missing the expected access token hash", () => { + const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z"); + const proof = makeDpopProof({ + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + iat: Math.floor(now.epochMilliseconds / 1_000), + jti: "proof-1", + }); + + return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; + const result = yield* Effect.exit( + replay.verifyAndConsume({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + expectedThumbprint: proof.thumbprint, + expectedAccessToken: "clerk-access-token", + now, + }), + ); + + expect(result._tag).toBe("Failure"); + }).pipe(Effect.provide(layer(() => Effect.die("unexpected DPoP replay persistence")))); + }); + + it.effect("preserves replay persistence failures", () => { + const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z"); + const proof = makeDpopProof({ + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + iat: Math.floor(now.epochMilliseconds / 1_000), + jti: "proof-persistence-failure", + }); + const cause = "database unavailable"; + + return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; + const error = yield* Effect.flip( + replay.verifyAndConsume({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + expectedThumbprint: proof.thumbprint, + now, + }), + ); + + expect(error).toEqual(new DpopProofs.DpopProofReplayPersistenceError({ cause })); + }).pipe(Effect.provide(layer(() => Effect.fail({ _tag: cause })))); + }); + + it.effect("accepts proofs bound to the access token hash", () => { + const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z"); + const proof = makeDpopProof({ + method: "POST", + url: "https://relay.example.com/v1/environments/env/status", + iat: Math.floor(now.epochMilliseconds / 1_000), + jti: "proof-status-1", + accessToken: "clerk-access-token", + }); + + return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; + const thumbprint = yield* replay.verifyAndConsume({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/status", + expectedAccessToken: "clerk-access-token", + now, + }); + const second = yield* Effect.exit( + replay.verifyAndConsume({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/status", + expectedAccessToken: "clerk-access-token", + now, + }), + ); + + expect(thumbprint).toBe(proof.thumbprint); + expect(second._tag).toBe("Failure"); + }).pipe(Effect.provide(layer(consumeEachProofOnce()))); + }); +}); diff --git a/infra/relay/src/auth/RelayTokens.test.ts b/infra/relay/src/auth/RelayTokens.test.ts new file mode 100644 index 00000000000..171981834c8 --- /dev/null +++ b/infra/relay/src/auth/RelayTokens.test.ts @@ -0,0 +1,188 @@ +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; +import { signRelayJwt } from "@t3tools/shared/relayJwt"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +import * as RelayConfiguration from "../Config.ts"; +import * as RelayTokens from "./RelayTokens.ts"; + +const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); + +const config = RelayConfiguration.RelayConfiguration.of({ + relayIssuer: "https://relay.example.test/", + apns: { + environment: "sandbox", + teamId: "team-id", + keyId: "key-id", + privateKey: Redacted.make("private-key"), + bundleId: "com.t3tools.t3code.dev", + }, + apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + cloudMintPrivateKey: Redacted.make(keyPair.privateKey), + cloudMintPublicKey: keyPair.publicKey, + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +}); + +const layer = RelayTokens.layer.pipe( + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), +); + +describe("RelayTokens", () => { + it.effect("issues a user-bound environment link challenge", () => + Effect.gen(function* () { + const relayTokens = yield* RelayTokens.RelayTokens; + const token = yield* relayTokens.issueLinkChallenge({ + userId: "user_123", + request: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + jti: "challenge-1", + issuedAtEpochSeconds: 100, + expiresAtEpochSeconds: 200, + }); + + expect( + yield* relayTokens.verifyLinkChallenge({ + token, + userId: "user_123", + request: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + nowEpochSeconds: 150, + }), + ).toMatchObject({ sub: "user_123", jti: "challenge-1" }); + expect( + yield* relayTokens.verifyLinkChallenge({ + token, + userId: "attacker", + request: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + nowEpochSeconds: 150, + }), + ).toBeNull(); + }).pipe(Effect.provide(layer)), + ); + + it.effect("issues and verifies DPoP access tokens bound to one proof-key thumbprint", () => + Effect.gen(function* () { + const relayTokens = yield* RelayTokens.RelayTokens; + const token = yield* relayTokens.issueDpopAccessToken({ + userId: "user_123", + proofKeyThumbprint: "proof-key-thumbprint", + jti: "access-token-1", + issuedAtEpochSeconds: 100, + expiresAtEpochSeconds: 200, + clientId: "t3-mobile", + scopes: ["environment:connect", "environment:status", "mobile:registration"], + }); + + expect( + yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 }), + ).toMatchObject({ + sub: "user_123", + cnf: { jkt: "proof-key-thumbprint" }, + client_id: "t3-mobile", + scope: ["environment:connect", "environment:status", "mobile:registration"], + }); + expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 261 })).toBeNull(); + }).pipe(Effect.provide(layer)), + ); + + it.effect("issues tunnel-only DPoP access tokens to web public clients", () => + Effect.gen(function* () { + const relayTokens = yield* RelayTokens.RelayTokens; + const token = yield* relayTokens.issueDpopAccessToken({ + userId: "user_123", + proofKeyThumbprint: "web-proof-key-thumbprint", + jti: "web-access-token-1", + issuedAtEpochSeconds: 100, + expiresAtEpochSeconds: 200, + clientId: "t3-web", + scopes: ["environment:connect", "environment:status"], + }); + + expect( + yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 }), + ).toMatchObject({ + client_id: "t3-web", + scope: ["environment:connect", "environment:status"], + cnf: { jkt: "web-proof-key-thumbprint" }, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("treats requested scope as an order-independent set", () => + Effect.gen(function* () { + const relayTokens = yield* RelayTokens.RelayTokens; + expect( + relayTokens.resolveDpopAccessTokenScopes({ + clientId: "t3-mobile", + scope: "environment:status environment:connect environment:status", + }), + ).toEqual(["environment:status", "environment:connect"]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("rejects signed DPoP tokens whose scope is outside the relay policy", () => + Effect.gen(function* () { + const relayTokens = yield* RelayTokens.RelayTokens; + const token = yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: "t3-relay-dpop-access+jwt", + payload: { + iss: "https://relay.example.test", + aud: "https://relay.example.test", + sub: "user_123", + jti: "access-token-invalid-scope", + iat: 100, + exp: 200, + client_id: "t3-mobile", + scope: "environment:admin", + cnf: { jkt: "proof-key-thumbprint" }, + }, + }); + + expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 })).toBeNull(); + }).pipe(Effect.provide(layer)), + ); + + it.effect("rejects mobile registration scope on a web public client token", () => + Effect.gen(function* () { + const relayTokens = yield* RelayTokens.RelayTokens; + const token = yield* signRelayJwt({ + privateKey: keyPair.privateKey, + typ: "t3-relay-dpop-access+jwt", + payload: { + iss: "https://relay.example.test", + aud: "https://relay.example.test", + sub: "user_123", + jti: "web-token-invalid-mobile-scope", + iat: 100, + exp: 200, + client_id: "t3-web", + scope: "environment:connect mobile:registration", + cnf: { jkt: "proof-key-thumbprint" }, + }, + }); + + expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 })).toBeNull(); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/infra/relay/src/auth/RelayTokens.ts b/infra/relay/src/auth/RelayTokens.ts new file mode 100644 index 00000000000..b9c50bd2e81 --- /dev/null +++ b/infra/relay/src/auth/RelayTokens.ts @@ -0,0 +1,220 @@ +import { + RelayDpopAccessTokenScope, + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, + RelayMobileClientId, + RelayMobileRegistrationScope, + RelayWebClientId, + type RelayPublicClientId, + type RelayEnvironmentLinkChallengeRequest, +} from "@t3tools/contracts/relay"; +import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; +import { + normalizeRelayIssuer, + signRelayJwt, + verifyRelayJwt, + type RelayJwtError, +} from "@t3tools/shared/relayJwt"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; + +import * as RelayConfiguration from "../Config.ts"; + +const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt"; +const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt"; +const LINK_CHALLENGE_KIND = "environment_link_challenge"; + +const LinkChallengeClaims = Schema.Struct({ + kind: Schema.Literal(LINK_CHALLENGE_KIND), + iss: Schema.String, + aud: Schema.String, + sub: Schema.String, + jti: Schema.String, + iat: Schema.Int, + exp: Schema.Int, + notificationsEnabled: Schema.Boolean, + liveActivitiesEnabled: Schema.Boolean, + managedTunnelsEnabled: Schema.Boolean, +}); +export type LinkChallengeClaims = typeof LinkChallengeClaims.Type; + +const RelayDpopAccessTokenClaims = Schema.Struct({ + iss: Schema.String, + aud: Schema.String, + sub: Schema.String, + jti: Schema.String, + iat: Schema.Int, + exp: Schema.Int, + client_id: Schema.Literals([RelayMobileClientId, RelayWebClientId]), + scope: Schema.String, + cnf: Schema.Struct({ jkt: Schema.String }), +}); +export type RelayDpopAccessTokenClaims = Omit & { + readonly scope: ReadonlyArray; +}; + +const decodeLinkChallengeClaims = Schema.decodeUnknownEffect(LinkChallengeClaims); +const decodeDpopAccessTokenClaims = Schema.decodeUnknownEffect(RelayDpopAccessTokenClaims); + +const allowedScopesByClientId: Record< + RelayPublicClientId, + ReadonlySet +> = { + [RelayMobileClientId]: new Set([ + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, + RelayMobileRegistrationScope, + ]), + [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]), +}; + +function resolveDpopAccessTokenScopes(input: { + readonly clientId: RelayPublicClientId; + readonly scope: string; +}): ReadonlyArray | null { + return parseAllowedOAuthScope({ + value: input.scope, + allowedScopes: allowedScopesByClientId[input.clientId], + }); +} + +export interface RelayTokensShape { + readonly resolveDpopAccessTokenScopes: typeof resolveDpopAccessTokenScopes; + readonly issueLinkChallenge: (input: { + readonly userId: string; + readonly request: RelayEnvironmentLinkChallengeRequest; + readonly jti: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; + }) => Effect.Effect; + readonly verifyLinkChallenge: (input: { + readonly token: string; + readonly userId: string; + readonly request: RelayEnvironmentLinkChallengeRequest; + readonly nowEpochSeconds: number; + }) => Effect.Effect; + readonly issueDpopAccessToken: (input: { + readonly userId: string; + readonly proofKeyThumbprint: string; + readonly jti: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; + readonly clientId: RelayPublicClientId; + readonly scopes: ReadonlyArray; + }) => Effect.Effect; + readonly verifyDpopAccessToken: (input: { + readonly token: string; + readonly nowEpochSeconds: number; + }) => Effect.Effect; +} + +export class RelayTokens extends Context.Service()( + "t3code-relay/auth/RelayTokens", +) {} + +const make = Effect.gen(function* () { + const config = yield* RelayConfiguration.RelayConfiguration; + const issuer = normalizeRelayIssuer(config.relayIssuer); + + const issueLinkChallenge: RelayTokensShape["issueLinkChallenge"] = Effect.fn( + "relay.tokens.issue_link_challenge", + )(function* (input) { + return yield* signRelayJwt({ + privateKey: Redacted.value(config.cloudMintPrivateKey), + typ: LINK_CHALLENGE_TYP, + payload: { + kind: LINK_CHALLENGE_KIND, + iss: issuer, + aud: issuer, + sub: input.userId, + jti: input.jti, + iat: input.issuedAtEpochSeconds, + exp: input.expiresAtEpochSeconds, + ...input.request, + }, + }); + }); + + const verifyLinkChallenge: RelayTokensShape["verifyLinkChallenge"] = Effect.fn( + "relay.tokens.verify_link_challenge", + )((input) => + verifyRelayJwt({ + publicKey: config.cloudMintPublicKey, + token: input.token, + typ: LINK_CHALLENGE_TYP, + issuer, + audience: issuer, + nowEpochSeconds: input.nowEpochSeconds, + }).pipe( + Effect.flatMap(decodeLinkChallengeClaims), + Effect.map((claims) => { + if ( + claims.sub !== input.userId || + (input.request.notificationsEnabled && claims.notificationsEnabled !== true) || + (input.request.liveActivitiesEnabled && claims.liveActivitiesEnabled !== true) || + (input.request.managedTunnelsEnabled && claims.managedTunnelsEnabled !== true) + ) { + return null; + } + return claims; + }), + Effect.catch(() => Effect.succeed(null)), + ), + ); + + const issueDpopAccessToken: RelayTokensShape["issueDpopAccessToken"] = Effect.fn( + "relay.tokens.issue_dpop_access_token", + )(function* (input) { + return yield* signRelayJwt({ + privateKey: Redacted.value(config.cloudMintPrivateKey), + typ: ACCESS_TOKEN_TYP, + payload: { + iss: issuer, + aud: issuer, + sub: input.userId, + jti: input.jti, + iat: input.issuedAtEpochSeconds, + exp: input.expiresAtEpochSeconds, + client_id: input.clientId, + scope: encodeOAuthScope(input.scopes), + cnf: { jkt: input.proofKeyThumbprint }, + }, + }); + }); + + const verifyDpopAccessToken: RelayTokensShape["verifyDpopAccessToken"] = Effect.fn( + "relay.tokens.verify_dpop_access_token", + )((input) => + verifyRelayJwt({ + publicKey: config.cloudMintPublicKey, + token: input.token, + typ: ACCESS_TOKEN_TYP, + issuer, + audience: issuer, + nowEpochSeconds: input.nowEpochSeconds, + }).pipe( + Effect.flatMap(decodeDpopAccessTokenClaims), + Effect.map((claims): RelayDpopAccessTokenClaims | null => { + const scopes = resolveDpopAccessTokenScopes({ + clientId: claims.client_id, + scope: claims.scope, + }); + return scopes === null ? null : { ...claims, scope: scopes }; + }), + Effect.orElseSucceed(() => null), + ), + ); + + return RelayTokens.of({ + resolveDpopAccessTokenScopes, + issueLinkChallenge, + verifyLinkChallenge, + issueDpopAccessToken, + verifyDpopAccessToken, + }); +}); + +export const layer = Layer.effect(RelayTokens, make); diff --git a/infra/relay/src/db.ts b/infra/relay/src/db.ts new file mode 100644 index 00000000000..1a778293736 --- /dev/null +++ b/infra/relay/src/db.ts @@ -0,0 +1,68 @@ +import type { PgClient } from "@effect/sql-pg/PgClient"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Planetscale from "alchemy/Planetscale"; +import * as Alchemy from "alchemy"; +import * as RemovalPolicy from "alchemy/RemovalPolicy"; +import type { EffectPgDatabase } from "drizzle-orm/effect-postgres"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +import { relayDatabaseMode } from "./dbConfig.ts"; + +export interface RelayDatabase extends EffectPgDatabase { + readonly $client: PgClient; +} + +export class RelayDb extends Context.Service()("t3code-relay/db/RelayDb") {} + +export const PlanetscaleDatabase = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + const schema = yield* Drizzle.Schema("RelaySchema", { + schema: "./src/persistence/schema.ts", + out: "./migrations/postgres", + dialect: "postgres", + }); + + const mode = relayDatabaseMode(stage); + const database = + mode === "shared-database" + ? yield* Planetscale.PostgresDatabase("RelayPostgresDatabase", { + name: "t3coderelay", + region: { slug: "us-west" }, + clusterSize: "PS_5", + migrationsDir: schema.out, + migrationsTable: "relay_migrations", + replicas: 0, // BUMP BEFORE GOING TO PROD + }).pipe(RemovalPolicy.retain()) + : yield* Planetscale.PostgresDatabase.ref("RelayPostgresDatabase", { + stage: "prod", + }); + const branch = + mode === "stage-branch" + ? yield* Planetscale.PostgresBranch("RelayPostgresBranch", { + database, + migrationsDir: schema.out, + migrationsTable: "relay_migrations", + }) + : undefined; + + const runtimeRole = yield* Planetscale.PostgresRole("RelayPostgresRuntimeRole", { + database, + ...(branch ? { branch } : {}), + inheritedRoles: ["pg_read_all_data", "pg_write_all_data"], + }); + + return { branch, database, runtimeRole }; +}); + +export const RelayHyperdrive = Effect.gen(function* () { + const { runtimeRole } = yield* PlanetscaleDatabase; + return yield* Cloudflare.Hyperdrive("RelayHyperdrive", { + origin: runtimeRole.origin, + caching: { + disabled: true, + }, + originConnectionLimit: 5, + }); +}); diff --git a/infra/relay/src/dbConfig.test.ts b/infra/relay/src/dbConfig.test.ts new file mode 100644 index 00000000000..31a1c065318 --- /dev/null +++ b/infra/relay/src/dbConfig.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { relayDatabaseMode } from "./dbConfig.ts"; + +describe("relayDatabaseMode", () => { + it("uses the shared database only for production", () => { + expect(relayDatabaseMode("prod")).toBe("shared-database"); + expect(relayDatabaseMode("dev_julius")).toBe("stage-branch"); + expect(relayDatabaseMode("preview")).toBe("stage-branch"); + }); +}); diff --git a/infra/relay/src/dbConfig.ts b/infra/relay/src/dbConfig.ts new file mode 100644 index 00000000000..9f6416aedce --- /dev/null +++ b/infra/relay/src/dbConfig.ts @@ -0,0 +1,5 @@ +export type RelayDatabaseMode = "shared-database" | "stage-branch"; + +export function relayDatabaseMode(stage: string): RelayDatabaseMode { + return stage === "prod" ? "shared-database" : "stage-branch"; +} diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts new file mode 100644 index 00000000000..1b69d55058f --- /dev/null +++ b/infra/relay/src/deploymentConfig.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { + managedEndpointDigestInput, + managedEndpointForHostname, + managedEndpointHostname, + isManagedEndpointHostname, + managedEndpointTunnelName, + relayOwnsManagedEndpointZone, + relayPublicDomainForStage, + relayResourceNameForStage, + relayStageSlug, +} from "./deploymentConfig.ts"; + +describe("relayStageSlug", () => { + it("matches Alchemy physical-name sanitization for default developer stages", () => { + expect(relayStageSlug("dev_julius")).toBe("dev-julius"); + }); +}); + +describe("relayPublicDomainForStage", () => { + it("uses the canonical relay hostname for production", () => { + expect(relayPublicDomainForStage("prod", ".example.com.")).toBe("relay.example.com"); + }); + + it("isolates personal stages below the imported zone", () => { + expect(relayPublicDomainForStage("dev_julius", "example.com")).toBe( + "relay-dev-julius.example.com", + ); + }); +}); + +describe("relayOwnsManagedEndpointZone", () => { + it("keeps the shared Cloudflare zone owned by production", () => { + expect(relayOwnsManagedEndpointZone("prod")).toBe(true); + expect(relayOwnsManagedEndpointZone("dev_julius")).toBe(false); + }); +}); + +describe("relayResourceNameForStage", () => { + it("isolates production and personal stages", () => { + expect(relayResourceNameForStage("t3-code-relay-traces", "prod")).toBe( + "t3-code-relay-traces-prod", + ); + expect(relayResourceNameForStage("t3-code-relay-traces", "dev_julius")).toBe( + "t3-code-relay-traces-dev-julius", + ); + }); +}); + +describe("managed endpoint names", () => { + it("uses the stage slug and a stable stage-scoped digest suffix", () => { + const hash = "ABCDEF0123456789ABCDEF0123456789"; + + expect(managedEndpointDigestInput("dev_julius", "user_123", "env_123")).toBe( + "dev_julius:user_123:env_123", + ); + expect(managedEndpointHostname("dev_julius", ".example.com.", hash)).toBe( + "tunnels-dev-julius-abcdef0123456789.example.com", + ); + expect(managedEndpointTunnelName("dev_julius", hash)).toBe( + "t3coderelay-managedendpoint-dev-julius-abcdef0123456789", + ); + }); + + it("keeps the DNS label within the provider limit for long stage names", () => { + const hostname = managedEndpointHostname( + "dev_" + "x".repeat(100), + "example.com", + "a".repeat(64), + ); + + expect(hostname.split(".")[0]?.length).toBeLessThanOrEqual(63); + expect(hostname).toMatch(/-a{16}\.example\.com$/); + }); + + it("accepts allocated hostnames within the relay zone", () => { + expect( + isManagedEndpointHostname("tunnels-dev-julius-abcdef0123456789.example.com", "example.com"), + ).toBe(true); + expect(managedEndpointForHostname("tunnels-dev-julius-abcdef0123456789.example.com")).toEqual({ + httpBaseUrl: "https://tunnels-dev-julius-abcdef0123456789.example.com/", + wsBaseUrl: "wss://tunnels-dev-julius-abcdef0123456789.example.com/ws", + providerKind: "cloudflare_tunnel", + }); + }); + + it("rejects hostnames outside the relay zone", () => { + expect(isManagedEndpointHostname("internal.example.net", "example.com")).toBe(false); + expect(isManagedEndpointHostname("example.com.attacker.test", "example.com")).toBe(false); + expect(isManagedEndpointHostname("tunnels-dev-julius.example.com.", "example.com")).toBe(false); + }); +}); diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts new file mode 100644 index 00000000000..253fcaf3453 --- /dev/null +++ b/infra/relay/src/deploymentConfig.ts @@ -0,0 +1,108 @@ +import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; + +const DNS_LABEL_MAX_LENGTH = 63; +const MANAGED_ENDPOINT_HASH_LENGTH = 16; +const MANAGED_ENDPOINT_HOST_PREFIX = "tunnels"; +const MANAGED_ENDPOINT_TUNNEL_PREFIX = "t3coderelay-managedendpoint"; +export const MANAGED_ENDPOINT_ZONE_OWNER_STAGE = "prod"; + +function normalizeZoneName(zoneName: string): string { + return zoneName + .trim() + .toLowerCase() + .replace(/^\.+|\.+$/g, ""); +} + +function isDnsName(name: string): boolean { + return ( + name.length > 0 && + name.length <= 253 && + name + .split(".") + .every( + (label) => + label.length > 0 && + label.length <= DNS_LABEL_MAX_LENGTH && + /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label), + ) + ); +} + +function stableSuffix(hash: string): string { + return hash.toLowerCase().slice(0, MANAGED_ENDPOINT_HASH_LENGTH); +} + +function appendDnsSafeSuffix(prefix: string, suffix: string): string { + const truncatedPrefix = prefix + .slice(0, DNS_LABEL_MAX_LENGTH - suffix.length - 1) + .replace(/-+$/g, ""); + return `${truncatedPrefix}-${suffix}`; +} + +/** + * Alchemy's physical-name helper sanitizes resource names after adding the + * stage. Keep custom domains and runtime-created resources aligned with it. + */ +export function relayStageSlug(stage: string): string { + return stage + .toLowerCase() + .replaceAll(/[^a-z0-9-]/g, "-") + .replaceAll(/-+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function relayResourceNameForStage(name: string, stage: string): string { + return `${name}-${relayStageSlug(stage)}`; +} + +export function relayOwnsManagedEndpointZone(stage: string): boolean { + return stage === MANAGED_ENDPOINT_ZONE_OWNER_STAGE; +} + +export function relayPublicDomainForStage(stage: string, zoneName: string): string { + const stageSlug = relayStageSlug(stage); + const relayLabel = stage === "prod" ? "relay" : `relay-${stageSlug}`; + if (relayLabel.length > DNS_LABEL_MAX_LENGTH) { + throw new Error(`Relay stage is too long for a custom domain: ${stage}`); + } + return `${relayLabel}.${normalizeZoneName(zoneName)}`; +} + +export function managedEndpointDigestInput( + stage: string, + userId: string, + environmentId: string, +): string { + return `${stage}:${userId}:${environmentId}`; +} + +export function managedEndpointHostname(stage: string, baseDomain: string, hash: string): string { + const label = appendDnsSafeSuffix( + `${MANAGED_ENDPOINT_HOST_PREFIX}-${relayStageSlug(stage)}`, + stableSuffix(hash), + ); + return `${label}.${normalizeZoneName(baseDomain)}`; +} + +export function isManagedEndpointHostname(hostname: string, baseDomain: string): boolean { + const normalizedHostname = normalizeZoneName(hostname); + const normalizedBaseDomain = normalizeZoneName(baseDomain); + return ( + hostname === normalizedHostname && + isDnsName(normalizedHostname) && + isDnsName(normalizedBaseDomain) && + normalizedHostname.endsWith(`.${normalizedBaseDomain}`) + ); +} + +export function managedEndpointForHostname(hostname: string): RelayManagedEndpoint { + return { + httpBaseUrl: `https://${hostname}/`, + wsBaseUrl: `wss://${hostname}/ws`, + providerKind: "cloudflare_tunnel", + }; +} + +export function managedEndpointTunnelName(stage: string, hash: string): string { + return `${MANAGED_ENDPOINT_TUNNEL_PREFIX}-${relayStageSlug(stage)}-${stableSuffix(hash)}`; +} diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts new file mode 100644 index 00000000000..505d1f31d00 --- /dev/null +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -0,0 +1,720 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; + +import { + RelayCloudEnvironmentHealthRequest, + RelayCloudMintCredentialRequest, + RelayCloudEnvironmentHealthProofPayload, + RelayCloudMintCredentialProofPayload, + RelayEnvironmentHealthResponse, + RelayEnvironmentHealthResponseProofPayload, + RelayEnvironmentMintResponse, + RelayEnvironmentMintResponseProofPayload, +} from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import { RELAY_HEALTH_RESPONSE_TYP, RELAY_MINT_RESPONSE_TYP } from "@t3tools/shared/relayJwt"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as EnvironmentLinks from "./EnvironmentLinks.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as EnvironmentConnector from "./EnvironmentConnector.ts"; +import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; + +const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); + +const environmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); + +const otherEnvironmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); + +const decodeHealthRequestBody = Schema.decodeUnknownSync( + Schema.fromJsonString(RelayCloudEnvironmentHealthRequest), +); +const decodeMintRequestBody = Schema.decodeUnknownSync( + Schema.fromJsonString(RelayCloudMintCredentialRequest), +); + +function requestBodyText(request: HttpClientRequest.HttpClientRequest): string { + return request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}"; +} + +const settings = RelayConfiguration.RelayConfiguration.of({ + relayIssuer: "https://relay.example.test", + apns: { + environment: "sandbox", + teamId: "team-id", + keyId: "key-id", + privateKey: Redacted.make("private-key"), + bundleId: "com.t3tools.t3code.dev", + }, + apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + cloudMintPrivateKey: Redacted.make(cloudKeyPair.privateKey), + cloudMintPublicKey: cloudKeyPair.publicKey, + managedEndpointBaseDomain: "example.test", + managedEndpointNamespace: undefined, +}); + +function signTestJwt(payload: object, typ: string, privateKey: string): string { + const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url"); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const input = `${header}.${encodedPayload}`; + return `${input}.${NodeCrypto.sign(null, Buffer.from(input), privateKey).toString("base64url")}`; +} + +function decodeRequestProof(proof: string): T { + const payload = proof.split(".")[1]; + if (!payload) throw new Error("Missing JWT payload."); + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as T; +} + +function signMintResponse( + request: RelayCloudMintCredentialRequest, + overrides: Partial = {}, + privateKey = environmentKeyPair.privateKey, +): RelayEnvironmentMintResponse { + const requestProof = decodeRequestProof(request.proof); + const payload = { + iss: `t3-env:${requestProof.environmentId}`, + aud: "https://relay.example.test", + sub: requestProof.environmentId, + jti: "mint-response-jti", + iat: requestProof.iat, + exp: requestProof.exp, + environmentId: requestProof.environmentId, + clientProofKeyThumbprint: requestProof.clientProofKeyThumbprint, + requestNonce: requestProof.nonce, + credential: "pairing_credential", + ...overrides, + } satisfies RelayEnvironmentMintResponseProofPayload; + return { + credential: payload.credential, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(payload.exp * 1_000)), + proof: signTestJwt(payload, RELAY_MINT_RESPONSE_TYP, privateKey), + }; +} + +function signHealthResponse( + request: RelayCloudEnvironmentHealthRequest, + privateKey = environmentKeyPair.privateKey, + overrides: Partial = {}, + payloadOverrides: Partial = {}, +): RelayEnvironmentHealthResponse { + const requestProof = decodeRequestProof(request.proof); + const payload = { + iss: `t3-env:${requestProof.environmentId}`, + aud: "https://relay.example.test", + sub: requestProof.environmentId, + jti: "health-response-jti", + iat: requestProof.iat, + exp: requestProof.exp, + environmentId: requestProof.environmentId, + requestNonce: requestProof.nonce, + status: "online", + descriptor: { + environmentId: requestProof.environmentId, + label: "Connector Test Environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + checkedAt: DateTime.formatIso(DateTime.makeUnsafe(requestProof.iat * 1_000)), + ...payloadOverrides, + } satisfies RelayEnvironmentHealthResponseProofPayload; + return { + environmentId: payload.environmentId, + status: "online", + descriptor: payload.descriptor, + checkedAt: payload.checkedAt, + proof: signTestJwt(payload, RELAY_HEALTH_RESPONSE_TYP, privateKey), + ...overrides, + }; +} + +function connectorTestLayer( + execute: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, + options?: { + readonly links?: EnvironmentLinks.EnvironmentLinksShape; + readonly allocations?: ManagedEndpointAllocations.ManagedEndpointAllocationsShape; + }, +) { + return EnvironmentConnector.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(EnvironmentLinks.EnvironmentLinks, options?.links ?? makeLinks())), + Layer.provide( + Layer.succeed( + ManagedEndpointAllocations.ManagedEndpointAllocations, + options?.allocations ?? makeAllocations(), + ), + ), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), + ); +} + +function makeAllocations( + allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null = { + userId: "user_123", + environmentId: "env-connector-test", + hostname: "env.example.test", + tunnelId: "tunnel-id", + tunnelName: "tunnel-name", + dnsRecordId: "dns-record-id", + readyAt: "2026-05-25T00:00:00.000Z", + }, +): ManagedEndpointAllocations.ManagedEndpointAllocationsShape { + return { + get: () => Effect.succeed(allocation), + reserve: () => Effect.die("unused"), + recordTunnel: () => Effect.die("unused"), + recordDns: () => Effect.die("unused"), + markReady: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + }; +} + +function makeLinks( + overrides: Partial = {}, +): EnvironmentLinks.EnvironmentLinksShape { + return { + upsert: () => Effect.void, + listUsersForEnvironment: () => Effect.succeed([]), + listDeliveryUsersForEnvironment: () => Effect.succeed([]), + listPublicKeysForEnvironment: () => Effect.succeed([environmentKeyPair.publicKey]), + listForUser: () => Effect.succeed([]), + getForUser: () => + Effect.succeed({ + environmentId: "env-connector-test" as never, + label: "Connector Test Environment", + endpoint: { + httpBaseUrl: "https://env.example.test/", + wsBaseUrl: "wss://env.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-05-25T00:00:00.000Z", + environmentPublicKey: environmentKeyPair.publicKey, + ...overrides, + }), + revokeForUser: () => Effect.succeed(false), + }; +} + +describe("EnvironmentConnector", () => { + it.effect("checks linked environment health through the managed endpoint", () => { + const seenUrls: Array = []; + const seenProofs: Array = []; + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); + seenUrls.push(request.url); + seenProofs.push(decodeRequestProof(healthRequest.proof)); + return HttpClientResponse.fromWeb( + request, + Response.json(signHealthResponse(healthRequest), { status: 200 }), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* connector.status({ + userId: "user_123", + environmentId: "env-connector-test", + }); + + expect(seenUrls).toEqual(["https://env.example.test/api/t3-cloud/health"]); + expect(seenProofs[0]).toMatchObject({ + iss: "https://relay.example.test", + aud: "t3-env:env-connector-test", + sub: "user_123", + environmentId: "env-connector-test", + scope: ["environment:status"], + }); + expect(result).toMatchObject({ + environmentId: "env-connector-test", + status: "online", + descriptor: { + environmentId: "env-connector-test", + label: "Connector Test Environment", + }, + }); + }).pipe(Effect.provide(connectorTestLayer(execute))); + }); + + it.effect("rejects manual endpoints before sending a health request", () => { + let requestCount = 0; + const execute = () => + Effect.sync(() => { + requestCount += 1; + throw new Error("unexpected request"); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.result( + connector.status({ + userId: "user_123", + environmentId: "env-connector-test", + }), + ); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(EnvironmentConnector.EnvironmentConnectNotAuthorized); + } + expect(requestCount).toBe(0); + }).pipe( + Effect.provide( + connectorTestLayer(execute, { + links: makeLinks({ + endpoint: { + httpBaseUrl: "https://127.0.0.1/", + wsBaseUrl: "wss://127.0.0.1/ws", + providerKind: "manual", + }, + }), + }), + ), + ); + }); + + it.effect("rejects stale managed endpoints before sending a mint request", () => { + let requestCount = 0; + const execute = () => + Effect.sync(() => { + requestCount += 1; + throw new Error("unexpected request"); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.result( + connector.connect({ + userId: "user_123", + environmentId: "env-connector-test", + clientProofKeyThumbprint: "client-proof-key-thumbprint", + }), + ); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(EnvironmentConnector.EnvironmentConnectNotAuthorized); + } + expect(requestCount).toBe(0); + }).pipe( + Effect.provide( + connectorTestLayer(execute, { + links: makeLinks({ + endpoint: { + httpBaseUrl: "https://attacker.example.test/", + wsBaseUrl: "wss://attacker.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + }), + }), + ), + ); + }); + + it.effect("rejects unready managed endpoint allocations before sending a request", () => { + let requestCount = 0; + const execute = () => + Effect.sync(() => { + requestCount += 1; + throw new Error("unexpected request"); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.result( + connector.status({ + userId: "user_123", + environmentId: "env-connector-test", + }), + ); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(EnvironmentConnector.EnvironmentConnectNotAuthorized); + } + expect(requestCount).toBe(0); + }).pipe( + Effect.provide( + connectorTestLayer(execute, { + allocations: makeAllocations({ + userId: "user_123", + environmentId: "env-connector-test", + hostname: "env.example.test", + tunnelId: "tunnel-id", + tunnelName: "tunnel-name", + dnsRecordId: "dns-record-id", + readyAt: null, + }), + }), + ), + ); + }); + + it.effect("rejects signed health responses with stale checkedAt timestamps", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); + return HttpClientResponse.fromWeb( + request, + Response.json( + signHealthResponse( + healthRequest, + environmentKeyPair.privateKey, + {}, + { + checkedAt: "2026-05-24T00:00:00.000Z", + }, + ), + { status: 200 }, + ), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.exit( + connector.status({ + userId: "user_123", + environmentId: "env-connector-test", + }), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); + } + }).pipe(Effect.provide(connectorTestLayer(execute))); + }); + + it.effect("reports offline status when the managed endpoint health request fails", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { + _tag: "EnvironmentHttpInternalServerError", + message: "Environment is unavailable.", + }, + { status: 500 }, + ), + ), + ); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* connector.status({ + userId: "user_123", + environmentId: "env-connector-test", + }); + + expect(result).toMatchObject({ + environmentId: "env-connector-test", + status: "offline", + error: "Managed endpoint health request failed: Environment is unavailable.", + }); + }).pipe(Effect.provide(connectorTestLayer(execute))); + }); + + it.effect("rejects health responses with a mismatched top-level environment id", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); + return HttpClientResponse.fromWeb( + request, + Response.json( + signHealthResponse(healthRequest, environmentKeyPair.privateKey, { + environmentId: "other-env" as RelayEnvironmentHealthResponse["environmentId"], + }), + { status: 200 }, + ), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.exit( + connector.status({ + userId: "user_123", + environmentId: "env-connector-test", + }), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); + } + }).pipe(Effect.provide(connectorTestLayer(execute))); + }); + + it.effect("rejects health responses with an unsigned top-level descriptor mutation", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); + const response = signHealthResponse(healthRequest); + return HttpClientResponse.fromWeb( + request, + Response.json( + { + ...response, + descriptor: { + ...response.descriptor, + label: "Tampered Environment Label", + }, + } satisfies RelayEnvironmentHealthResponse, + { status: 200 }, + ), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.exit( + connector.status({ + userId: "user_123", + environmentId: "env-connector-test", + }), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); + } + }).pipe(Effect.provide(connectorTestLayer(execute))); + }); + + it.effect("rejects health responses when the linked environment public key is malformed", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); + return HttpClientResponse.fromWeb( + request, + Response.json(signHealthResponse(healthRequest), { status: 200 }), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.exit( + connector.status({ + userId: "user_123", + environmentId: "env-connector-test", + }), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); + } + }).pipe( + Effect.provide( + connectorTestLayer(execute, { + links: makeLinks({ + environmentPublicKey: "not a pem public key", + }), + }), + ), + ); + }); + + it.effect("mints a one-time environment credential through the linked endpoint", () => { + const seenUrls: Array = []; + const seenProofs: Array = []; + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const mintRequest = decodeMintRequestBody(requestBodyText(request)); + seenUrls.push(request.url); + seenProofs.push(decodeRequestProof(mintRequest.proof)); + return HttpClientResponse.fromWeb( + request, + Response.json(signMintResponse(mintRequest), { status: 200 }), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* connector.connect({ + userId: "user_123", + environmentId: "env-connector-test", + clientProofKeyThumbprint: "client-proof-key-thumbprint", + deviceId: "device-123", + }); + + expect(seenUrls).toEqual(["https://env.example.test/api/t3-cloud/mint-credential"]); + expect(seenProofs[0]).toMatchObject({ + iss: "https://relay.example.test", + aud: "t3-env:env-connector-test", + sub: "user_123", + environmentId: "env-connector-test", + clientProofKeyThumbprint: "client-proof-key-thumbprint", + cnf: { jkt: "client-proof-key-thumbprint" }, + deviceId: "device-123", + scope: ["environment:connect"], + }); + expect(result).toMatchObject({ + environmentId: "env-connector-test", + credential: "pairing_credential", + endpoint: { + httpBaseUrl: "https://env.example.test/", + wsBaseUrl: "wss://env.example.test/ws", + }, + }); + }).pipe(Effect.provide(connectorTestLayer(execute))); + }); + + it.effect("only accepts mint responses signed by the user's linked environment key", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const mintRequest = decodeMintRequestBody(requestBodyText(request)); + return HttpClientResponse.fromWeb( + request, + Response.json(signMintResponse(mintRequest, {}, otherEnvironmentKeyPair.privateKey), { + status: 200, + }), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.exit( + connector.connect({ + userId: "user_123", + environmentId: "env-connector-test", + clientProofKeyThumbprint: "client-proof-key-thumbprint", + }), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); + } + }).pipe(Effect.provide(connectorTestLayer(execute))); + }); + + it.effect("rejects mint responses when the linked environment public key is malformed", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const mintRequest = decodeMintRequestBody(requestBodyText(request)); + return HttpClientResponse.fromWeb( + request, + Response.json(signMintResponse(mintRequest), { status: 200 }), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.exit( + connector.connect({ + userId: "user_123", + environmentId: "env-connector-test", + clientProofKeyThumbprint: "client-proof-key-thumbprint", + }), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); + } + }).pipe( + Effect.provide( + connectorTestLayer(execute, { + links: makeLinks({ + environmentPublicKey: "not a pem public key", + }), + }), + ), + ); + }); + + it.effect("rejects environment mint responses with an overlong credential window", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const mintRequest = decodeMintRequestBody(requestBodyText(request)); + return HttpClientResponse.fromWeb( + request, + Response.json( + { ...signMintResponse(mintRequest), expiresAt: "2999-01-01T00:00:00.000Z" }, + { status: 200 }, + ), + ); + }); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* Effect.exit( + connector.connect({ + userId: "user_123", + environmentId: "env-connector-test", + clientProofKeyThumbprint: "client-proof-key-thumbprint", + }), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); + } + }).pipe(Effect.provide(connectorTestLayer(execute))); + }); + + it.effect("times out hung managed endpoint mint requests", () => { + let resolveRequestStarted: (() => void) | undefined; + const requestStarted = new Promise((resolve) => { + resolveRequestStarted = () => resolve(); + }); + const execute = () => + Effect.sync(() => { + resolveRequestStarted?.(); + }).pipe(Effect.andThen(Effect.never as Effect.Effect)); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const resultFiber = yield* connector + .connect({ + userId: "user_123", + environmentId: "env-connector-test", + clientProofKeyThumbprint: "client-proof-key-thumbprint", + }) + .pipe(Effect.result, Effect.forkScoped); + + yield* Effect.promise(() => requestStarted); + yield* TestClock.adjust( + Duration.millis(EnvironmentConnector.ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS), + ); + const result = yield* Fiber.join(resultFiber); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure._tag).toBe("EnvironmentMintRequestTimedOut"); + expect(result.failure).toMatchObject({ + environmentId: "env-connector-test", + timeoutMs: EnvironmentConnector.ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS, + }); + } + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), connectorTestLayer(execute)))); + }); +}); diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts new file mode 100644 index 00000000000..d31cf499e44 --- /dev/null +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -0,0 +1,430 @@ +import { + EnvironmentHttpBadRequestError, + EnvironmentHttpConflictError, + EnvironmentHttpForbiddenError, + EnvironmentHttpInternalServerError, + EnvironmentHttpUnauthorizedError, +} from "@t3tools/contracts"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime"; +import { + RelayCloudEnvironmentHealthProofPayload, + RelayEnvironmentHealthResponse, + RelayEnvironmentHealthResponseProofPayload, + RelayEnvironmentMintResponse, + RelayEnvironmentMintResponseProofPayload, + RelayCloudMintCredentialProofPayload, + type RelayEnvironmentConnectResponse, + type RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { + normalizeRelayIssuer, + RELAY_HEALTH_REQUEST_TYP, + RELAY_HEALTH_RESPONSE_TYP, + RELAY_MINT_REQUEST_TYP, + RELAY_MINT_RESPONSE_TYP, + signRelayJwt, + verifyRelayJwt, +} from "@t3tools/shared/relayJwt"; +import { stableStringify } from "@t3tools/shared/relaySigning"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; + +import * as EnvironmentLinks from "./EnvironmentLinks.ts"; +import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; +import * as RelayConfiguration from "../Config.ts"; + +export class EnvironmentConnectNotAuthorized extends Data.TaggedError( + "EnvironmentConnectNotAuthorized", +)<{ + readonly environmentId: string; +}> {} + +export class EnvironmentMintRequestFailed extends Data.TaggedError("EnvironmentMintRequestFailed")<{ + readonly cause: unknown; +}> {} + +export class EnvironmentMintRequestTimedOut extends Data.TaggedError( + "EnvironmentMintRequestTimedOut", +)<{ + readonly environmentId: string; + readonly timeoutMs: number; +}> {} + +export class EnvironmentMintResponseInvalid extends Data.TaggedError( + "EnvironmentMintResponseInvalid", +)<{ + readonly environmentId: string; +}> {} + +export type EnvironmentConnectorError = + | EnvironmentConnectNotAuthorized + | EnvironmentMintRequestFailed + | EnvironmentMintRequestTimedOut + | EnvironmentMintResponseInvalid + | EnvironmentLinks.EnvironmentLinkLookupPersistenceError + | ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError; + +export const ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS = 10_000; +const ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS = 60 * 1_000; + +export interface EnvironmentConnectorShape { + readonly connect: (input: { + readonly userId: string; + readonly environmentId: string; + readonly clientProofKeyThumbprint: string; + readonly deviceId?: string; + }) => Effect.Effect; + readonly status: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; +} + +export class EnvironmentConnector extends Context.Service< + EnvironmentConnector, + EnvironmentConnectorShape +>()("t3code-relay/environments/EnvironmentConnector") {} + +const decodeMintResponseProof = Schema.decodeUnknownEffect( + RelayEnvironmentMintResponseProofPayload, +); +const decodeHealthResponseProof = Schema.decodeUnknownEffect( + RelayEnvironmentHealthResponseProofPayload, +); +const isEnvironmentHealthError = Schema.is( + Schema.Union([ + EnvironmentHttpBadRequestError, + EnvironmentHttpUnauthorizedError, + EnvironmentHttpForbiddenError, + EnvironmentHttpConflictError, + EnvironmentHttpInternalServerError, + ]), +); + +function environmentHealthRequestFailureMessage(cause: unknown): string { + return isEnvironmentHealthError(cause) + ? `Managed endpoint health request failed: ${cause.message}` + : "Managed endpoint health request failed."; +} + +const withoutRedirects = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); + +const verifyWithEnvironmentKeys = Effect.fnUntraced(function* (input: { + readonly token: string; + readonly typ: string; + readonly issuer: string; + readonly audience: string; + readonly nowEpochSeconds: number; + readonly environmentPublicKeys: ReadonlyArray; + readonly decodePayload: (input: unknown) => Effect.Effect; +}) { + const { decodePayload, ...rest } = input; + for (const publicKey of input.environmentPublicKeys) { + const proof = yield* verifyRelayJwt({ ...rest, publicKey }).pipe( + Effect.flatMap(decodePayload), + Effect.option, + ); + if (Option.isSome(proof)) { + return proof.value; + } + // A linked environment can have rotated keys; try the remaining active keys. + } + return null; +}); + +function verifyEnvironmentResponse(input: { + readonly response: RelayEnvironmentMintResponse; + readonly environmentId: string; + readonly requestNonce: string; + readonly clientProofKeyThumbprint: string; + readonly environmentPublicKeys: ReadonlyArray; + readonly relayIssuer: string; + readonly nowEpochSeconds: number; +}) { + return verifyWithEnvironmentKeys({ + token: input.response.proof, + typ: RELAY_MINT_RESPONSE_TYP, + issuer: `t3-env:${input.environmentId}`, + audience: normalizeRelayIssuer(input.relayIssuer), + nowEpochSeconds: input.nowEpochSeconds, + environmentPublicKeys: input.environmentPublicKeys, + decodePayload: decodeMintResponseProof, + }).pipe( + Effect.map( + (proof) => + proof !== null && + proof.environmentId === input.environmentId && + proof.requestNonce === input.requestNonce && + proof.clientProofKeyThumbprint === input.clientProofKeyThumbprint && + proof.credential === input.response.credential && + Option.match(DateTime.make(input.response.expiresAt), { + onNone: () => false, + onSome: (expiresAt) => Math.floor(expiresAt.epochMilliseconds / 1_000) === proof.exp, + }), + ), + ); +} + +function verifyEnvironmentHealthResponse(input: { + readonly response: RelayEnvironmentHealthResponse; + readonly environmentId: string; + readonly requestNonce: string; + readonly requestIssuedAt: DateTime.DateTime; + readonly environmentPublicKeys: ReadonlyArray; + readonly relayIssuer: string; + readonly now: DateTime.DateTime; +}) { + return verifyWithEnvironmentKeys({ + token: input.response.proof, + typ: RELAY_HEALTH_RESPONSE_TYP, + issuer: `t3-env:${input.environmentId}`, + audience: normalizeRelayIssuer(input.relayIssuer), + nowEpochSeconds: Math.floor(input.now.epochMilliseconds / 1_000), + environmentPublicKeys: input.environmentPublicKeys, + decodePayload: decodeHealthResponseProof, + }).pipe( + Effect.map((proof) => { + if ( + proof === null || + input.response.environmentId !== input.environmentId || + proof.environmentId !== input.environmentId || + proof.requestNonce !== input.requestNonce || + proof.status !== input.response.status || + proof.checkedAt !== input.response.checkedAt || + stableStringify(proof.descriptor) !== stableStringify(input.response.descriptor) + ) { + return false; + } + const checkedAt = DateTime.make(input.response.checkedAt); + if (Option.isNone(checkedAt)) { + return false; + } + return ( + checkedAt.value.epochMilliseconds >= + input.requestIssuedAt.epochMilliseconds - ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS && + checkedAt.value.epochMilliseconds <= + input.now.epochMilliseconds + ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS + ); + }), + ); +} + +const make = Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + const settings = yield* RelayConfiguration.RelayConfiguration; + const httpClient = yield* HttpClient.HttpClient; + const crypto = yield* Crypto.Crypto; + const relayIssuer = normalizeRelayIssuer(settings.relayIssuer); + const makeEnvironmentClient = (httpBaseUrl: string) => + makeEnvironmentHttpApiClient(httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")( + function* (input: { + readonly userId: string; + readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord; + }) { + if (input.link.endpoint.providerKind !== "cloudflare_tunnel") { + return yield* new EnvironmentConnectNotAuthorized({ + environmentId: input.link.environmentId, + }); + } + const allocation = yield* allocations.get({ + userId: input.userId, + environmentId: input.link.environmentId, + }); + const endpoint = allocation + ? ManagedEndpointAllocations.resolveReadyManagedEndpoint({ + allocation, + baseDomain: settings.managedEndpointBaseDomain, + }) + : null; + if ( + endpoint === null || + endpoint.httpBaseUrl !== input.link.endpoint.httpBaseUrl || + endpoint.wsBaseUrl !== input.link.endpoint.wsBaseUrl + ) { + return yield* new EnvironmentConnectNotAuthorized({ + environmentId: input.link.environmentId, + }); + } + return endpoint; + }, + ); + + return EnvironmentConnector.of({ + status: Effect.fn("relay.environment_connector.status")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + "relay.operation": "status", + }); + const link = yield* links.getForUser(input); + if (!link) { + return yield* new EnvironmentConnectNotAuthorized({ environmentId: input.environmentId }); + } + const endpoint = yield* resolveManagedEndpoint({ userId: input.userId, link }); + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 2 }); + const nonce = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause })), + ); + const payload = { + iss: relayIssuer, + aud: `t3-env:${link.environmentId}`, + sub: input.userId, + jti: yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause })), + ), + iat: Math.floor(now.epochMilliseconds / 1_000), + exp: Math.floor(expiresAt.epochMilliseconds / 1_000), + environmentId: link.environmentId, + nonce, + scope: ["environment:status"], + } satisfies RelayCloudEnvironmentHealthProofPayload; + const proof = yield* signRelayJwt({ + privateKey: Redacted.value(settings.cloudMintPrivateKey), + typ: RELAY_HEALTH_REQUEST_TYP, + payload, + }).pipe(Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause }))); + const checkedAt = DateTime.formatIso(now); + const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl); + const responseOption = yield* environmentClient.cloud.health({ payload: { proof } }).pipe( + withoutRedirects, + Effect.match({ + onFailure: (cause) => ({ _tag: "Failure" as const, cause }), + onSuccess: (response) => ({ _tag: "Success" as const, response }), + }), + Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)), + ); + if (Option.isNone(responseOption)) { + return { + environmentId: link.environmentId, + endpoint, + status: "offline" as const, + checkedAt, + error: "Managed endpoint health request timed out.", + }; + } + if (responseOption.value._tag === "Failure") { + return { + environmentId: link.environmentId, + endpoint, + status: "offline" as const, + checkedAt, + error: environmentHealthRequestFailureMessage(responseOption.value.cause), + }; + } + const decoded = responseOption.value.response; + const verified = yield* verifyEnvironmentHealthResponse({ + response: decoded, + environmentId: input.environmentId, + requestNonce: nonce, + requestIssuedAt: now, + environmentPublicKeys: [link.environmentPublicKey], + relayIssuer, + now: yield* DateTime.now, + }); + if (!verified) { + return yield* new EnvironmentMintResponseInvalid({ environmentId: input.environmentId }); + } + return { + environmentId: link.environmentId, + endpoint, + status: "online" as const, + checkedAt: decoded.checkedAt, + descriptor: decoded.descriptor, + }; + }), + connect: Effect.fn("relay.environment_connector.connect")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + "relay.operation": "connect", + "relay.connect.has_device_id": input.deviceId !== undefined, + ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), + }); + if (input.clientProofKeyThumbprint.trim().length === 0) { + return yield* new EnvironmentConnectNotAuthorized({ environmentId: input.environmentId }); + } + const link = yield* links.getForUser(input); + if (!link) { + return yield* new EnvironmentConnectNotAuthorized({ environmentId: input.environmentId }); + } + const endpoint = yield* resolveManagedEndpoint({ userId: input.userId, link }); + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 2 }); + const nonce = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause })), + ); + const payload = { + iss: relayIssuer, + aud: `t3-env:${link.environmentId}`, + sub: input.userId, + jti: yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause })), + ), + iat: Math.floor(now.epochMilliseconds / 1_000), + exp: Math.floor(expiresAt.epochMilliseconds / 1_000), + environmentId: link.environmentId, + clientProofKeyThumbprint: input.clientProofKeyThumbprint, + cnf: { jkt: input.clientProofKeyThumbprint }, + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + nonce, + scope: ["environment:connect"], + } satisfies RelayCloudMintCredentialProofPayload; + const proof = yield* signRelayJwt({ + privateKey: Redacted.value(settings.cloudMintPrivateKey), + typ: RELAY_MINT_REQUEST_TYP, + payload, + }).pipe(Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause }))); + const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl); + const decoded = yield* environmentClient.cloud.t3MintCredential({ payload: { proof } }).pipe( + withoutRedirects, + Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause })), + Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new EnvironmentMintRequestTimedOut({ + environmentId: input.environmentId, + timeoutMs: ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + const verified = yield* verifyEnvironmentResponse({ + response: decoded, + environmentId: input.environmentId, + requestNonce: nonce, + clientProofKeyThumbprint: input.clientProofKeyThumbprint, + environmentPublicKeys: [link.environmentPublicKey], + relayIssuer, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + }); + if (!verified) { + return yield* new EnvironmentMintResponseInvalid({ environmentId: input.environmentId }); + } + return { + environmentId: link.environmentId, + endpoint, + credential: decoded.credential, + expiresAt: decoded.expiresAt, + }; + }), + }); +}); + +export const layer = Layer.effect(EnvironmentConnector, make); diff --git a/infra/relay/src/environments/EnvironmentCredentials.test.ts b/infra/relay/src/environments/EnvironmentCredentials.test.ts new file mode 100644 index 00000000000..9282564e985 --- /dev/null +++ b/infra/relay/src/environments/EnvironmentCredentials.test.ts @@ -0,0 +1,158 @@ +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +import { PgDialect, QueryBuilder } from "drizzle-orm/pg-core"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { RelayDb, type RelayDatabase } from "../db.ts"; +import { relayEnvironmentCredentials } from "../persistence/schema.ts"; +import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; + +describe("EnvironmentCredentials", () => { + it.effect( + "creates opaque credentials and revokes only older credentials for the same key", + () => { + const insertedValues: Array<{ + readonly credentialId: string; + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly credentialHash: string; + readonly revokedAt: null; + readonly createdAt: string; + readonly updatedAt: string; + }> = []; + const staleCredentialRevocations: Array<{ + readonly values: Record; + readonly condition: unknown; + }> = []; + + const fakeDb = { + insert: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + values: (values: (typeof insertedValues)[number]) => { + insertedValues.push(values); + return Effect.void; + }, + }; + }, + update: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + set: (values: Record) => ({ + where: (condition: unknown) => { + staleCredentialRevocations.push({ values, condition }); + return Effect.void; + }, + }), + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const token = yield* credentials.create({ + environmentId: "env_test", + environmentPublicKey: "environment-public-key", + }); + const [, credentialId, secret] = token.split("_"); + + expect(token).toMatch(/^t3env_[0-9a-f]{64}_[0-9a-f]{96}$/); + expect(credentialId).toHaveLength(64); + expect(secret).toHaveLength(96); + expect(insertedValues).toHaveLength(1); + expect(insertedValues[0]).toMatchObject({ + credentialId, + environmentId: "env_test", + environmentPublicKey: "environment-public-key", + revokedAt: null, + }); + expect(insertedValues[0]?.credentialHash).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(insertedValues[0]?.credentialHash).not.toContain(token); + expect(insertedValues[0]?.createdAt).toBe(insertedValues[0]?.updatedAt); + expect(staleCredentialRevocations).toHaveLength(1); + expect(staleCredentialRevocations[0]?.values.revokedAt).toEqual( + staleCredentialRevocations[0]?.values.updatedAt, + ); + + const query = new PgDialect().sqlToQuery(staleCredentialRevocations[0]?.condition as never); + expect(query.sql).toContain('"relay_environment_credentials"."environment_id" = $1'); + expect(query.sql).toContain( + '"relay_environment_credentials"."environment_public_key" = $2', + ); + expect(query.sql).toContain('"relay_environment_credentials"."credential_id" <> $3'); + expect(query.sql).toContain('"relay_environment_credentials"."revoked_at" is null'); + expect(query.params).toEqual(["env_test", "environment-public-key", credentialId]); + }).pipe( + Effect.provide( + EnvironmentCredentials.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb, fakeDb)), + ), + ), + ); + }, + ); + + it.effect("revokes active credentials for an environment public key", () => { + const updateValues: Array> = []; + const whereConditions: Array = []; + const fakeDb = { + select: (fields: Parameters[0]) => new QueryBuilder().select(fields), + update: (table: unknown) => { + expect(table).toBe(relayEnvironmentCredentials); + return { + set: (values: Record) => { + updateValues.push(values); + return { + where: (condition: unknown) => { + whereConditions.push(condition); + return { + returning: (selection: unknown) => { + expect(selection).toBeDefined(); + return Effect.succeed([{ credentialId: "credential-1" }]); + }, + }; + }, + }; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const revoked = yield* credentials.revokeForEnvironmentPublicKey({ + environmentId: "env_test", + environmentPublicKey: "environment-public-key", + }); + + expect(revoked).toBe(true); + expect(updateValues).toHaveLength(1); + expect(updateValues[0]?.revokedAt).toEqual(updateValues[0]?.updatedAt); + expect(whereConditions).toHaveLength(1); + + const query = new PgDialect().sqlToQuery(whereConditions[0] as never); + expect(query.sql).toContain('"relay_environment_credentials"."environment_id" = $1'); + expect(query.sql).toContain('"relay_environment_credentials"."environment_public_key" = $2'); + expect(query.sql).toContain('"relay_environment_credentials"."revoked_at" is null'); + expect(query.sql).toContain("not exists"); + expect(query.sql).toContain('"relay_environment_links"."environment_id" = $3'); + expect(query.sql).toContain('"relay_environment_links"."environment_public_key" = $4'); + expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); + expect(query.params).toEqual([ + "env_test", + "environment-public-key", + "env_test", + "environment-public-key", + ]); + }).pipe( + Effect.provide( + EnvironmentCredentials.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(RelayDb, fakeDb)), + ), + ), + ); + }); +}); diff --git a/infra/relay/src/environments/EnvironmentCredentials.ts b/infra/relay/src/environments/EnvironmentCredentials.ts new file mode 100644 index 00000000000..9acde2eef6c --- /dev/null +++ b/infra/relay/src/environments/EnvironmentCredentials.ts @@ -0,0 +1,188 @@ +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { and, eq, isNull, ne, notExists } from "drizzle-orm"; + +import { RelayDb } from "../db.ts"; +import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts"; + +export class EnvironmentCredentialCreatePersistenceError extends Data.TaggedError( + "EnvironmentCredentialCreatePersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class EnvironmentCredentialAuthenticatePersistenceError extends Data.TaggedError( + "EnvironmentCredentialAuthenticatePersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class EnvironmentCredentialRevokePersistenceError extends Data.TaggedError( + "EnvironmentCredentialRevokePersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export interface EnvironmentCredentialPrincipal { + readonly credentialId: string; + readonly environmentId: string; + readonly environmentPublicKey: string; +} + +export interface EnvironmentCredentialsShape { + readonly create: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + }) => Effect.Effect; + readonly authenticate: ( + token: string, + ) => Effect.Effect< + Option.Option, + EnvironmentCredentialAuthenticatePersistenceError + >; + readonly revokeForEnvironmentPublicKey: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + }) => Effect.Effect; +} + +export class EnvironmentCredentials extends Context.Service< + EnvironmentCredentials, + EnvironmentCredentialsShape +>()("t3code-relay/environments/EnvironmentCredentials") {} + +const make = Effect.gen(function* () { + const db = yield* RelayDb; + const crypto = yield* Crypto.Crypto; + const hashToken = (token: string) => + crypto + .digest("SHA-256", new TextEncoder().encode(token)) + .pipe(Effect.map(Encoding.encodeBase64Url)); + const randomTokenPart = (segments: number) => + Effect.map(Effect.all(Array.from({ length: segments }, () => crypto.randomUUIDv4)), (values) => + values.join("").replaceAll("-", ""), + ); + const makeCredential = Effect.fnUntraced(function* () { + const credentialId = yield* randomTokenPart(2); + const secret = yield* randomTokenPart(3); + return { + credentialId, + token: `t3env_${credentialId}_${secret}`, + }; + }); + + return EnvironmentCredentials.of({ + create: Effect.fn("relay.environment_credentials.create")( + function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + const credential = yield* makeCredential(); + const credentialHash = yield* hashToken(credential.token); + const now = DateTime.formatIso(yield* DateTime.now); + yield* db.insert(relayEnvironmentCredentials).values({ + credentialId: credential.credentialId, + environmentId: input.environmentId, + environmentPublicKey: input.environmentPublicKey, + credentialHash, + revokedAt: null, + createdAt: now, + updatedAt: now, + }); + yield* db + .update(relayEnvironmentCredentials) + .set({ + revokedAt: now, + updatedAt: now, + }) + .where( + and( + eq(relayEnvironmentCredentials.environmentId, input.environmentId), + eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), + ne(relayEnvironmentCredentials.credentialId, credential.credentialId), + isNull(relayEnvironmentCredentials.revokedAt), + ), + ); + return credential.token; + }, + Effect.mapError((cause) => new EnvironmentCredentialCreatePersistenceError({ cause })), + ), + + authenticate: Effect.fn("relay.environment_credentials.authenticate")( + function* (token) { + const credentialHash = yield* hashToken(token); + const rows = yield* db + .select({ + credentialId: relayEnvironmentCredentials.credentialId, + environmentId: relayEnvironmentCredentials.environmentId, + environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey, + }) + .from(relayEnvironmentCredentials) + .where( + and( + eq(relayEnvironmentCredentials.credentialHash, credentialHash), + isNull(relayEnvironmentCredentials.revokedAt), + ), + ) + .limit(1); + const row = rows[0]; + if (row) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId }); + } + return row + ? Option.some({ + credentialId: row.credentialId, + environmentId: row.environmentId, + environmentPublicKey: row.environmentPublicKey, + }) + : Option.none(); + }, + Effect.mapError((cause) => new EnvironmentCredentialAuthenticatePersistenceError({ cause })), + ), + + revokeForEnvironmentPublicKey: Effect.fn( + "relay.environment_credentials.revoke_for_environment_public_key", + )( + function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + const revokedAt = DateTime.formatIso(yield* DateTime.now); + const rows = yield* db + .update(relayEnvironmentCredentials) + .set({ + revokedAt, + updatedAt: revokedAt, + }) + .where( + and( + eq(relayEnvironmentCredentials.environmentId, input.environmentId), + eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), + isNull(relayEnvironmentCredentials.revokedAt), + notExists( + db + .select({ userId: relayEnvironmentLinks.userId }) + .from(relayEnvironmentLinks) + .where( + and( + eq(relayEnvironmentLinks.environmentId, input.environmentId), + eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), + isNull(relayEnvironmentLinks.revokedAt), + ), + ), + ), + ), + ) + .returning({ + credentialId: relayEnvironmentCredentials.credentialId, + }); + return rows.length > 0; + }, + Effect.mapError((cause) => new EnvironmentCredentialRevokePersistenceError({ cause })), + ), + }); +}); + +export const layer = Layer.effect(EnvironmentCredentials, make); diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts new file mode 100644 index 00000000000..dce364bffac --- /dev/null +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -0,0 +1,206 @@ +import * as NodeCrypto from "node:crypto"; +import type { + RelayEnvironmentLinkProofPayload, + RelayEnvironmentLinkRequest, +} from "@t3tools/contracts/relay"; +import { RELAY_LINK_PROOF_TYP } from "@t3tools/shared/relayJwt"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; + +import * as DpopProofs from "../auth/DpopProofs.ts"; +import * as RelayTokens from "../auth/RelayTokens.ts"; +import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; +import * as EnvironmentLinks from "./EnvironmentLinks.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as EnvironmentLinker from "./EnvironmentLinker.ts"; +import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; + +const relayKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); +const environmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); +const config = RelayConfiguration.RelayConfiguration.of({ + relayIssuer: "https://relay.example.test", + apns: { + environment: "sandbox", + teamId: "team-id", + keyId: "key-id", + privateKey: Redacted.make("private-key"), + bundleId: "com.t3tools.t3code.dev", + }, + apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + cloudMintPrivateKey: Redacted.make(relayKeyPair.privateKey), + cloudMintPublicKey: relayKeyPair.publicKey, + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +}); + +function signTestJwt(payload: object, typ: string, privateKey: string): string { + const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url"); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signingInput = `${header}.${encodedPayload}`; + return `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), privateKey).toString("base64url")}`; +} + +const makeRequest = Effect.gen(function* () { + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 5 }); + const relayTokens = yield* RelayTokens.RelayTokens; + const challenge = yield* relayTokens.issueLinkChallenge({ + userId: "user_123", + request: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + jti: "challenge-jti", + issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000), + }); + const payload = { + iss: "t3-env:env-link-test", + aud: "https://relay.example.test", + sub: "env-link-test", + jti: "link-proof-jti", + iat: Math.floor(now.epochMilliseconds / 1_000), + exp: Math.floor(expiresAt.epochMilliseconds / 1_000), + challenge, + environmentId: "env-link-test" as RelayEnvironmentLinkProofPayload["environmentId"], + descriptor: { + environmentId: "env-link-test" as RelayEnvironmentLinkProofPayload["environmentId"], + label: "Link Test Environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + environmentPublicKey: environmentKeyPair.publicKey.trim(), + endpoint: { + httpBaseUrl: "https://env.example.test/", + wsBaseUrl: "wss://env.example.test/", + providerKind: "manual", + }, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + scopes: ["agent_activity_notifications", "managed_tunnels"], + } satisfies RelayEnvironmentLinkProofPayload; + return { + request: { + proof: signTestJwt(payload, RELAY_LINK_PROOF_TYP, environmentKeyPair.privateKey), + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: false, + } satisfies RelayEnvironmentLinkRequest, + payload, + }; +}); + +function testLayer(input?: { + readonly upsert?: EnvironmentLinks.EnvironmentLinksShape["upsert"]; + readonly consume?: DpopProofs.DpopProofReplayShape["consume"]; +}) { + return EnvironmentLinker.layer.pipe( + Layer.provideMerge(RelayTokens.layer), + Layer.provide( + Layer.mergeAll( + Layer.succeed(RelayConfiguration.RelayConfiguration, config), + Layer.succeed(DpopProofs.DpopProofReplay, { + verifyAndConsume: () => Effect.die("unexpected DPoP proof verification"), + consume: input?.consume ?? (() => Effect.succeed(true)), + pruneExpired: Effect.void, + }), + Layer.succeed(EnvironmentLinks.EnvironmentLinks, { + upsert: input?.upsert ?? (() => Effect.void), + listUsersForEnvironment: () => Effect.succeed([]), + listDeliveryUsersForEnvironment: () => Effect.succeed([]), + listPublicKeysForEnvironment: () => Effect.succeed([]), + listForUser: () => Effect.succeed([]), + getForUser: () => Effect.succeed(null), + revokeForUser: () => Effect.succeed(false), + }), + Layer.succeed(EnvironmentCredentials.EnvironmentCredentials, { + create: () => Effect.succeed("t3env_credential_secret"), + authenticate: () => Effect.succeedNone, + revokeForEnvironmentPublicKey: () => Effect.succeed(false), + }), + Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { + deprovision: () => Effect.void, + provision: () => + Effect.succeed({ + endpoint: { + httpBaseUrl: "https://managed.example.test/", + wsBaseUrl: "wss://managed.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + runtime: { providerKind: "cloudflare_tunnel", connectorToken: "connector-token" }, + }), + }), + ), + ), + ); +} + +describe("EnvironmentLinker", () => { + it.effect("uses verified JWT claims when linking an environment", () => { + let persistedEnvironmentId: string | null = null; + return Effect.gen(function* () { + const { request, payload } = yield* makeRequest; + const linker = yield* EnvironmentLinker.EnvironmentLinker; + const result = yield* linker.link({ userId: "user_123", request }); + expect(result.environmentId).toBe(payload.environmentId); + expect(result.environmentCredential).toBe("t3env_credential_secret"); + expect(persistedEnvironmentId).toBe(payload.environmentId); + }).pipe( + Effect.provide( + testLayer({ + upsert: (input) => + Effect.sync(() => { + persistedEnvironmentId = input.proof.environmentId; + }), + }), + ), + ); + }); + + it.effect("rejects a tampered compact proof before persistence", () => { + let persisted = false; + return Effect.gen(function* () { + const { request } = yield* makeRequest; + const segments = request.proof.split("."); + const signature = segments[2]!; + segments[2] = `${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; + const tampered = { ...request, proof: segments.join(".") }; + const linker = yield* EnvironmentLinker.EnvironmentLinker; + const result = yield* Effect.result(linker.link({ userId: "user_123", request: tampered })); + expect(Result.isFailure(result)).toBe(true); + expect(persisted).toBe(false); + }).pipe( + Effect.provide( + testLayer({ + upsert: () => + Effect.sync(() => { + persisted = true; + }), + }), + ), + ); + }); + + it.effect("rejects replayed JWT ids", () => + Effect.gen(function* () { + const { request } = yield* makeRequest; + const linker = yield* EnvironmentLinker.EnvironmentLinker; + const result = yield* Effect.result(linker.link({ userId: "user_123", request })); + expect(Result.isFailure(result)).toBe(true); + }).pipe(Effect.provide(testLayer({ consume: () => Effect.succeed(false) }))), + ); +}); diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts new file mode 100644 index 00000000000..853ea41cbda --- /dev/null +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -0,0 +1,264 @@ +import { + RelayEnvironmentLinkProofPayload, + type RelayEnvironmentLinkProofInvalidReason, + type RelayEnvironmentLinkRequest, +} from "@t3tools/contracts/relay"; +import { + decodeRelayJwt, + normalizeRelayIssuer, + RELAY_LINK_PROOF_TYP, + verifyRelayJwt, +} from "@t3tools/shared/relayJwt"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DpopProofs from "../auth/DpopProofs.ts"; +import * as RelayTokens from "../auth/RelayTokens.ts"; +import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; +import * as EnvironmentLinks from "./EnvironmentLinks.ts"; +import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; +import * as RelayConfiguration from "../Config.ts"; + +export class EnvironmentLinkProofExpired extends Data.TaggedError("EnvironmentLinkProofExpired")<{ + readonly expiresAt: string; +}> {} + +export class EnvironmentLinkProofInvalid extends Data.TaggedError("EnvironmentLinkProofInvalid")<{ + readonly environmentId: string; + readonly reason: RelayEnvironmentLinkProofInvalidReason; +}> {} + +export type EnvironmentLinkError = + | EnvironmentLinkProofExpired + | EnvironmentLinkProofInvalid + | DpopProofs.DpopProofReplayPersistenceError + | EnvironmentLinks.EnvironmentLinkUpsertPersistenceError + | EnvironmentCredentials.EnvironmentCredentialCreatePersistenceError + | ManagedEndpointProvider.ManagedEndpointProviderError; + +export interface EnvironmentLinkerShape { + readonly link: (input: { + readonly userId: string; + readonly request: RelayEnvironmentLinkRequest; + }) => Effect.Effect< + { + readonly environmentId: RelayEnvironmentLinkProofPayload["environmentId"]; + readonly endpoint: RelayEnvironmentLinkProofPayload["endpoint"]; + readonly endpointRuntime: + | ManagedEndpointProvider.ManagedEndpointProvisioningResult["runtime"] + | null; + readonly environmentCredential: string; + }, + EnvironmentLinkError + >; +} + +export class EnvironmentLinker extends Context.Service()( + "t3code-relay/environments/EnvironmentLinker", +) {} + +const decodeProof = Schema.decodeUnknownEffect(RelayEnvironmentLinkProofPayload); + +function proofAuthorizesRequestedCapabilities( + proof: RelayEnvironmentLinkProofPayload, + request: RelayEnvironmentLinkRequest, +): boolean { + const scopes = new Set(proof.scopes); + if (request.managedTunnelsEnabled && !scopes.has("managed_tunnels")) { + return false; + } + return !( + (request.notificationsEnabled || request.liveActivitiesEnabled) && + !scopes.has("agent_activity_notifications") + ); +} + +function isSecureManagedEndpoint(endpoint: RelayEnvironmentLinkProofPayload["endpoint"]): boolean { + try { + const httpUrl = new URL(endpoint.httpBaseUrl); + const wsUrl = new URL(endpoint.wsBaseUrl); + return httpUrl.protocol === "https:" && wsUrl.protocol === "wss:"; + } catch { + return false; + } +} + +function normalizeHostname(hostname: string): string { + return hostname + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/u, "$1"); +} + +function isLoopbackManagedTunnelOrigin( + origin: RelayEnvironmentLinkProofPayload["origin"], +): boolean { + const hostname = normalizeHostname(origin.localHttpHost); + return ( + (hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost") && + Number.isInteger(origin.localHttpPort) && + origin.localHttpPort > 0 && + origin.localHttpPort <= 65_535 + ); +} + +const make = Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const proofReplay = yield* DpopProofs.DpopProofReplay; + const relayTokens = yield* RelayTokens.RelayTokens; + const config = yield* RelayConfiguration.RelayConfiguration; + + return EnvironmentLinker.of({ + link: Effect.fn("relay.environment_linker.link")(function* (input) { + const now = yield* DateTime.now; + const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); + const unverified = yield* Effect.try({ + try: () => decodeRelayJwt(input.request.proof), + catch: () => + new EnvironmentLinkProofInvalid({ + environmentId: "unknown", + reason: "invalid_signature_or_scope", + }), + }); + const decoded = yield* decodeProof(unverified).pipe(Effect.option); + if (decoded._tag === "None") { + return yield* new EnvironmentLinkProofInvalid({ + environmentId: "unknown", + reason: "invalid_signature_or_scope", + }); + } + const candidate = decoded.value; + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": candidate.environmentId, + "relay.link.notifications_enabled": input.request.notificationsEnabled, + "relay.link.live_activities_enabled": input.request.liveActivitiesEnabled, + "relay.link.managed_tunnels_enabled": input.request.managedTunnelsEnabled, + }); + if (candidate.exp <= nowSeconds) { + return yield* new EnvironmentLinkProofExpired({ + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)), + }); + } + const issuer = `t3-env:${candidate.environmentId}`; + const relayIssuer = normalizeRelayIssuer(config.relayIssuer); + const verified = yield* verifyRelayJwt({ + publicKey: candidate.environmentPublicKey, + token: input.request.proof, + typ: RELAY_LINK_PROOF_TYP, + issuer, + audience: relayIssuer, + nowEpochSeconds: nowSeconds, + }).pipe( + Effect.flatMap(decodeProof), + Effect.mapError( + () => + new EnvironmentLinkProofInvalid({ + environmentId: candidate.environmentId, + reason: "invalid_signature_or_scope", + }), + ), + ); + if ( + verified.sub !== verified.environmentId || + !proofAuthorizesRequestedCapabilities(verified, input.request) + ) { + return yield* new EnvironmentLinkProofInvalid({ + environmentId: candidate.environmentId, + reason: "invalid_signature_or_scope", + }); + } + if (verified.descriptor.environmentId !== verified.environmentId) { + return yield* new EnvironmentLinkProofInvalid({ + environmentId: verified.environmentId, + reason: "descriptor_mismatch", + }); + } + const challenge = yield* relayTokens.verifyLinkChallenge({ + token: verified.challenge, + userId: input.userId, + request: { + notificationsEnabled: input.request.notificationsEnabled, + liveActivitiesEnabled: input.request.liveActivitiesEnabled, + managedTunnelsEnabled: input.request.managedTunnelsEnabled, + }, + nowEpochSeconds: nowSeconds, + }); + if (challenge === null) { + return yield* new EnvironmentLinkProofInvalid({ + environmentId: verified.environmentId, + reason: "challenge_invalid", + }); + } + const expiresAt = DateTime.make(verified.exp * 1_000); + if (expiresAt._tag === "None") { + return yield* new EnvironmentLinkProofInvalid({ + environmentId: verified.environmentId, + reason: "invalid_signature_or_scope", + }); + } + const consumedNonce = yield* proofReplay.consume({ + thumbprint: verified.environmentPublicKey, + jti: verified.jti, + iat: verified.iat, + expiresAt: expiresAt.value, + }); + if (!consumedNonce) { + return yield* new EnvironmentLinkProofInvalid({ + environmentId: verified.environmentId, + reason: "replayed_nonce", + }); + } + const consumedChallenge = yield* proofReplay.consume({ + thumbprint: "relay-environment-link-challenge", + jti: challenge.jti, + iat: challenge.iat, + expiresAt: expiresAt.value, + }); + if (!consumedChallenge) { + return yield* new EnvironmentLinkProofInvalid({ + environmentId: verified.environmentId, + reason: "challenge_invalid", + }); + } + if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) { + return yield* new EnvironmentLinkProofInvalid({ + environmentId: verified.environmentId, + reason: "origin_not_allowed", + }); + } + const provisioned = input.request.managedTunnelsEnabled + ? yield* managedEndpointProvider.provision({ + userId: input.userId, + environmentId: verified.environmentId, + origin: verified.origin, + }) + : null; + const endpoint = provisioned?.endpoint ?? verified.endpoint; + if (!isSecureManagedEndpoint(endpoint)) { + return yield* new EnvironmentLinkProofInvalid({ + environmentId: verified.environmentId, + reason: "endpoint_not_secure", + }); + } + yield* links.upsert({ ...input, proof: verified, endpoint }); + const environmentCredential = yield* credentials.create({ + environmentId: verified.environmentId, + environmentPublicKey: verified.environmentPublicKey, + }); + return { + environmentId: verified.environmentId, + endpoint, + endpointRuntime: provisioned?.runtime ?? null, + environmentCredential, + }; + }), + }); +}); + +export const layer = Layer.effect(EnvironmentLinker, make); diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts new file mode 100644 index 00000000000..b67dfb8e430 --- /dev/null +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { PgDialect } from "drizzle-orm/pg-core"; + +import { RelayDb, type RelayDatabase } from "../db.ts"; +import { relayEnvironmentLinks } from "../persistence/schema.ts"; +import { EnvironmentLinks, layer } from "./EnvironmentLinks.ts"; + +describe("EnvironmentLinks", () => { + it.effect("selects users when either notifications or Live Activities are enabled", () => { + const whereConditions: Array = []; + const fakeDb = { + select: (selection: unknown) => { + expect(selection).toBeDefined(); + return { + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + where: (condition: unknown) => { + whereConditions.push(condition); + return Effect.succeed([]); + }, + }; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks; + expect(yield* links.listUsersForEnvironment({ environmentId: "env-1" })).toEqual([]); + expect(whereConditions).toHaveLength(1); + + const query = new PgDialect().sqlToQuery(whereConditions[0] as never); + expect(query.sql).toContain('"relay_environment_links"."environment_id" = $1'); + expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); + expect(query.sql).toContain('"relay_environment_links"."notifications_enabled" = $2'); + expect(query.sql).toContain('"relay_environment_links"."live_activities_enabled" = $3'); + expect(query.sql).toContain(" or "); + expect(query.params).toEqual(["env-1", true, true]); + }).pipe(Effect.provide(layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }); + + it.effect("revokes only the active link owned by the requesting user", () => { + const updateValues: Array> = []; + const whereConditions: Array = []; + const fakeDb = { + update: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + set: (values: Record) => { + updateValues.push(values); + return { + where: (condition: unknown) => { + whereConditions.push(condition); + return { + returning: (selection: unknown) => { + expect(selection).toBeDefined(); + return Effect.succeed([{ environmentId: "env-1" }]); + }, + }; + }, + }; + }, + }; + }, + } as unknown as RelayDatabase; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks; + const revoked = yield* links.revokeForUser({ + userId: "user-1", + environmentId: "env-1", + }); + + expect(revoked).toBe(true); + expect(updateValues).toHaveLength(1); + expect(updateValues[0]?.revokedAt).toEqual(updateValues[0]?.updatedAt); + expect(typeof updateValues[0]?.revokedAt).toBe("string"); + expect(whereConditions).toHaveLength(1); + + const dialect = new PgDialect(); + const query = dialect.sqlToQuery(whereConditions[0] as never); + expect(query.sql).toContain('"relay_environment_links"."user_id" = $1'); + expect(query.sql).toContain('"relay_environment_links"."environment_id" = $2'); + expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); + expect(query.params).toEqual(["user-1", "env-1"]); + }).pipe(Effect.provide(layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); + }); +}); diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts new file mode 100644 index 00000000000..b1e28d0da0a --- /dev/null +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -0,0 +1,345 @@ +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentLinkProofPayload, + RelayEnvironmentLinkRequest, + RelayManagedEndpoint, +} from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { and, eq, isNull, or } from "drizzle-orm"; + +import { RelayDb } from "../db.ts"; +import { relayEnvironmentLinks } from "../persistence/schema.ts"; + +export interface RelayLinkedEnvironmentRecord extends RelayClientEnvironmentRecord { + readonly environmentPublicKey: string; +} + +export interface AgentAwarenessDeliveryUserRecord { + readonly userId: string; + readonly notificationsEnabled: boolean; + readonly liveActivitiesEnabled: boolean; +} + +export class EnvironmentLinkUpsertPersistenceError extends Data.TaggedError( + "EnvironmentLinkUpsertPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class EnvironmentLinkUserListPersistenceError extends Data.TaggedError( + "EnvironmentLinkUserListPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class EnvironmentPublicKeyListPersistenceError extends Data.TaggedError( + "EnvironmentPublicKeyListPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class EnvironmentLinkListPersistenceError extends Data.TaggedError( + "EnvironmentLinkListPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class EnvironmentLinkLookupPersistenceError extends Data.TaggedError( + "EnvironmentLinkLookupPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class EnvironmentLinkRevokePersistenceError extends Data.TaggedError( + "EnvironmentLinkRevokePersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export interface EnvironmentLinksShape { + readonly upsert: (input: { + readonly userId: string; + readonly request: RelayEnvironmentLinkRequest; + readonly proof: RelayEnvironmentLinkProofPayload; + readonly endpoint: RelayManagedEndpoint; + }) => Effect.Effect; + readonly listUsersForEnvironment: (input: { + readonly environmentId: string; + }) => Effect.Effect, EnvironmentLinkUserListPersistenceError>; + readonly listDeliveryUsersForEnvironment: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + }) => Effect.Effect< + ReadonlyArray, + EnvironmentLinkUserListPersistenceError + >; + readonly listPublicKeysForEnvironment: (input: { + readonly environmentId: string; + }) => Effect.Effect, EnvironmentPublicKeyListPersistenceError>; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect< + ReadonlyArray, + EnvironmentLinkListPersistenceError + >; + readonly getForUser: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; + readonly revokeForUser: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; +} + +export class EnvironmentLinks extends Context.Service()( + "t3code-relay/environments/EnvironmentLinks", +) {} + +function agentAwarenessDeliveryUserCondition(environmentId: string) { + return and( + eq(relayEnvironmentLinks.environmentId, environmentId), + isNull(relayEnvironmentLinks.revokedAt), + or( + eq(relayEnvironmentLinks.notificationsEnabled, true), + eq(relayEnvironmentLinks.liveActivitiesEnabled, true), + ), + ); +} + +function agentAwarenessDeliveryUserKeyCondition(input: { + readonly environmentId: string; + readonly environmentPublicKey: string; +}) { + return and( + agentAwarenessDeliveryUserCondition(input.environmentId), + eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), + ); +} + +const make = Effect.gen(function* () { + const db = yield* RelayDb; + + return EnvironmentLinks.of({ + upsert: Effect.fn("relay.environment_links.upsert")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.proof.environmentId, + }); + const now = DateTime.formatIso(yield* DateTime.now); + const { request, proof } = input; + const environmentId = proof.environmentId; + const { endpoint } = input; + yield* db + .insert(relayEnvironmentLinks) + .values({ + userId: input.userId, + environmentId, + environmentLabel: proof.descriptor.label, + environmentPublicKey: proof.environmentPublicKey, + endpointHttpBaseUrl: endpoint.httpBaseUrl, + endpointWsBaseUrl: endpoint.wsBaseUrl, + endpointProviderKind: endpoint.providerKind, + notificationsEnabled: request.notificationsEnabled, + liveActivitiesEnabled: request.liveActivitiesEnabled, + managedTunnelsEnabled: request.managedTunnelsEnabled, + createdByDeviceId: request.deviceId ?? null, + revokedAt: null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId], + set: { + environmentPublicKey: proof.environmentPublicKey, + environmentLabel: proof.descriptor.label, + endpointHttpBaseUrl: endpoint.httpBaseUrl, + endpointWsBaseUrl: endpoint.wsBaseUrl, + endpointProviderKind: endpoint.providerKind, + notificationsEnabled: request.notificationsEnabled, + liveActivitiesEnabled: request.liveActivitiesEnabled, + managedTunnelsEnabled: request.managedTunnelsEnabled, + createdByDeviceId: request.deviceId ?? null, + revokedAt: null, + updatedAt: now, + }, + }); + }, + Effect.mapError((cause) => new EnvironmentLinkUpsertPersistenceError({ cause })), + ), + + listUsersForEnvironment: Effect.fn("relay.environment_links.list_users_for_environment")( + function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + return yield* db + .select({ userId: relayEnvironmentLinks.userId }) + .from(relayEnvironmentLinks) + .where(agentAwarenessDeliveryUserCondition(input.environmentId)) + .pipe( + Effect.map((rows) => rows.map((row) => row.userId)), + Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })), + ); + }, + ), + + listDeliveryUsersForEnvironment: Effect.fn( + "relay.environment_links.list_delivery_users_for_environment", + )(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + return yield* db + .select({ + userId: relayEnvironmentLinks.userId, + notificationsEnabled: relayEnvironmentLinks.notificationsEnabled, + liveActivitiesEnabled: relayEnvironmentLinks.liveActivitiesEnabled, + }) + .from(relayEnvironmentLinks) + .where(agentAwarenessDeliveryUserKeyCondition(input)) + .pipe( + Effect.map((rows) => + rows.map((row) => ({ + userId: row.userId, + notificationsEnabled: row.notificationsEnabled, + liveActivitiesEnabled: row.liveActivitiesEnabled, + })), + ), + Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })), + ); + }), + + listPublicKeysForEnvironment: Effect.fn( + "relay.environment_links.list_public_keys_for_environment", + )(function* (input) { + yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); + return yield* db + .select({ environmentPublicKey: relayEnvironmentLinks.environmentPublicKey }) + .from(relayEnvironmentLinks) + .where( + and( + eq(relayEnvironmentLinks.environmentId, input.environmentId), + isNull(relayEnvironmentLinks.revokedAt), + ), + ) + .pipe( + Effect.map((rows) => [ + ...new Set(rows.map((row) => row.environmentPublicKey).filter((key) => key.length > 0)), + ]), + Effect.mapError((cause) => new EnvironmentPublicKeyListPersistenceError({ cause })), + ); + }), + + listForUser: Effect.fn("relay.environment_links.list_for_user")(function* (input) { + return yield* db + .select({ + environmentId: relayEnvironmentLinks.environmentId, + environmentLabel: relayEnvironmentLinks.environmentLabel, + endpointHttpBaseUrl: relayEnvironmentLinks.endpointHttpBaseUrl, + endpointWsBaseUrl: relayEnvironmentLinks.endpointWsBaseUrl, + endpointProviderKind: relayEnvironmentLinks.endpointProviderKind, + createdAt: relayEnvironmentLinks.createdAt, + }) + .from(relayEnvironmentLinks) + .where( + and( + eq(relayEnvironmentLinks.userId, input.userId), + isNull(relayEnvironmentLinks.revokedAt), + ), + ) + .pipe( + Effect.map((rows) => + rows.map((row) => ({ + environmentId: row.environmentId as RelayClientEnvironmentRecord["environmentId"], + label: + row.environmentLabel.trim().length > 0 ? row.environmentLabel : row.environmentId, + endpoint: { + httpBaseUrl: row.endpointHttpBaseUrl, + wsBaseUrl: row.endpointWsBaseUrl, + providerKind: + row.endpointProviderKind as RelayClientEnvironmentRecord["endpoint"]["providerKind"], + }, + linkedAt: row.createdAt, + })), + ), + Effect.mapError((cause) => new EnvironmentLinkListPersistenceError({ cause })), + ); + }), + + getForUser: Effect.fn("relay.environment_links.get_for_user")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + }); + return yield* db + .select({ + environmentId: relayEnvironmentLinks.environmentId, + environmentLabel: relayEnvironmentLinks.environmentLabel, + environmentPublicKey: relayEnvironmentLinks.environmentPublicKey, + endpointHttpBaseUrl: relayEnvironmentLinks.endpointHttpBaseUrl, + endpointWsBaseUrl: relayEnvironmentLinks.endpointWsBaseUrl, + endpointProviderKind: relayEnvironmentLinks.endpointProviderKind, + createdAt: relayEnvironmentLinks.createdAt, + }) + .from(relayEnvironmentLinks) + .where( + and( + eq(relayEnvironmentLinks.userId, input.userId), + eq(relayEnvironmentLinks.environmentId, input.environmentId), + isNull(relayEnvironmentLinks.revokedAt), + ), + ) + .limit(1) + .pipe( + Effect.map((rows) => { + const row = rows[0]; + return row + ? { + environmentId: row.environmentId as RelayClientEnvironmentRecord["environmentId"], + label: + row.environmentLabel.trim().length > 0 + ? row.environmentLabel + : row.environmentId, + endpoint: { + httpBaseUrl: row.endpointHttpBaseUrl, + wsBaseUrl: row.endpointWsBaseUrl, + providerKind: + row.endpointProviderKind as RelayClientEnvironmentRecord["endpoint"]["providerKind"], + }, + environmentPublicKey: row.environmentPublicKey, + linkedAt: row.createdAt, + } + : null; + }), + Effect.mapError((cause) => new EnvironmentLinkLookupPersistenceError({ cause })), + ); + }), + + revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + }); + const revokedAt = DateTime.formatIso(yield* DateTime.now); + const rows = yield* db + .update(relayEnvironmentLinks) + .set({ + revokedAt, + updatedAt: revokedAt, + }) + .where( + and( + eq(relayEnvironmentLinks.userId, input.userId), + eq(relayEnvironmentLinks.environmentId, input.environmentId), + isNull(relayEnvironmentLinks.revokedAt), + ), + ) + .returning({ environmentId: relayEnvironmentLinks.environmentId }); + return rows.length > 0; + }, + Effect.mapError((cause) => new EnvironmentLinkRevokePersistenceError({ cause })), + ), + }); +}); + +export const layer = Layer.effect(EnvironmentLinks, make); diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts new file mode 100644 index 00000000000..a74ce670cfb --- /dev/null +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -0,0 +1,166 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { + RelayAgentActivityPublishProofPayload, + RelayAgentActivityPublishRequest, + RelayAgentActivityState, +} from "@t3tools/contracts/relay"; +import { RELAY_ACTIVITY_PUBLISH_TYP } from "@t3tools/shared/relayJwt"; +import { stableStringify } from "@t3tools/shared/relaySigning"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; + +import * as DpopProofs from "../auth/DpopProofs.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as EnvironmentPublishSignatures from "./EnvironmentPublishSignatures.ts"; + +const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, +}); +const config = RelayConfiguration.RelayConfiguration.of({ + relayIssuer: "https://relay.example.test", + apns: { + environment: "sandbox", + teamId: "team-id", + keyId: "key-id", + privateKey: Redacted.make("private-key"), + bundleId: "com.t3tools.t3code.dev", + }, + apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + cloudMintPrivateKey: Redacted.make(keyPair.privateKey), + cloudMintPublicKey: keyPair.publicKey, + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +}); +const state: RelayAgentActivityState = { + environmentId: "env" as RelayAgentActivityState["environmentId"], + threadId: "thread" as RelayAgentActivityState["threadId"], + projectTitle: "Project", + threadTitle: "Thread", + modelTitle: "gpt-5.4", + phase: "running", + headline: "Running", + updatedAt: "2026-05-25T00:00:00.000Z", + deepLink: "/threads/env/thread", +}; + +function signTestJwt(payload: object, privateKey: string): string { + const header = Buffer.from( + JSON.stringify({ alg: "EdDSA", typ: RELAY_ACTIVITY_PUBLISH_TYP }), + ).toString("base64url"); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signingInput = `${header}.${encodedPayload}`; + return `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), privateKey).toString("base64url")}`; +} + +const freshRequest = Effect.gen(function* () { + const now = yield* DateTime.now; + const payload = { + iss: "t3-env:env", + aud: "https://relay.example.test", + sub: "env", + jti: "publish-jti", + iat: Math.floor(now.epochMilliseconds / 1_000), + exp: Math.floor(DateTime.add(now, { minutes: 5 }).epochMilliseconds / 1_000), + environmentId: state.environmentId, + threadId: state.threadId, + state, + } satisfies RelayAgentActivityPublishProofPayload; + return { + state, + proof: signTestJwt(payload, keyPair.privateKey), + } satisfies RelayAgentActivityPublishRequest; +}); + +function layer(replay?: Partial) { + return EnvironmentPublishSignatures.layer.pipe( + Layer.provide( + Layer.merge( + Layer.succeed(RelayConfiguration.RelayConfiguration, config), + Layer.succeed(DpopProofs.DpopProofReplay, { + verifyAndConsume: + replay?.verifyAndConsume ?? (() => Effect.die("unexpected DPoP proof verification")), + consume: replay?.consume ?? (() => Effect.succeed(true)), + pruneExpired: replay?.pruneExpired ?? Effect.void, + }), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); +} + +describe("EnvironmentPublishSignatures", () => { + it.effect("verifies activity JWTs and scopes replay storage to the environment key", () => { + let replayThumbprint: string | null = null; + return Effect.gen(function* () { + const request = yield* freshRequest; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + yield* signatures.verify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + threadId: state.threadId, + request, + }); + expect(replayThumbprint).toBe( + `env-publish:${NodeCrypto.createHash("sha256") + .update( + stableStringify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + }), + ) + .digest("base64url")}`, + ); + }).pipe( + Effect.provide( + layer({ + consume: (input) => + Effect.sync(() => { + replayThumbprint = input.thumbprint; + return true; + }), + }), + ), + ); + }); + + it.effect("rejects top-level state tampering", () => + Effect.gen(function* () { + const request = yield* freshRequest; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + const result = yield* Effect.result( + signatures.verify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + threadId: state.threadId, + request: { ...request, state: { ...state, headline: "Tampered" } }, + }), + ); + expect(Result.isFailure(result)).toBe(true); + }).pipe(Effect.provide(layer())), + ); + + it.effect("rejects replayed activity JWT ids", () => + Effect.gen(function* () { + const request = yield* freshRequest; + const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + const result = yield* Effect.result( + signatures.verify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + threadId: state.threadId, + request, + }), + ); + expect(Result.isFailure(result)).toBe(true); + }).pipe(Effect.provide(layer({ consume: () => Effect.succeed(false) }))), + ); +}); diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts new file mode 100644 index 00000000000..cca694ac512 --- /dev/null +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -0,0 +1,164 @@ +import { + RelayAgentActivityPublishProofPayload, + type RelayAgentActivityPublishRequest, +} from "@t3tools/contracts/relay"; +import { + decodeRelayJwt, + normalizeRelayIssuer, + RELAY_ACTIVITY_PUBLISH_TYP, + verifyRelayJwt, +} from "@t3tools/shared/relayJwt"; +import { stableStringify } from "@t3tools/shared/relaySigning"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DpopProofs from "../auth/DpopProofs.ts"; +import * as RelayConfiguration from "../Config.ts"; + +export class EnvironmentPublishSignatureExpired extends Data.TaggedError( + "EnvironmentPublishSignatureExpired", +)<{ + readonly expiresAt: string; +}> {} + +export class EnvironmentPublishSignatureInvalid extends Data.TaggedError( + "EnvironmentPublishSignatureInvalid", +)<{ + readonly environmentId: string; +}> {} + +export class EnvironmentPublishPublicKeyMissing extends Data.TaggedError( + "EnvironmentPublishPublicKeyMissing", +)<{ + readonly environmentId: string; +}> {} + +export type EnvironmentPublishSignatureError = + | EnvironmentPublishSignatureExpired + | EnvironmentPublishSignatureInvalid + | EnvironmentPublishPublicKeyMissing + | DpopProofs.DpopProofReplayPersistenceError; + +export interface EnvironmentPublishSignaturesShape { + readonly verify: (input: { + readonly environmentId: string; + readonly environmentPublicKey: string; + readonly threadId: string; + readonly request: RelayAgentActivityPublishRequest; + }) => Effect.Effect; +} + +export class EnvironmentPublishSignatures extends Context.Service< + EnvironmentPublishSignatures, + EnvironmentPublishSignaturesShape +>()("t3code-relay/environments/EnvironmentPublishSignatures") {} + +const decodeProof = Schema.decodeUnknownEffect(RelayAgentActivityPublishProofPayload); + +function environmentPublishReplayThumbprintData(input: { + readonly environmentId: string; + readonly environmentPublicKey: string; +}) { + return new TextEncoder().encode( + stableStringify({ + environmentId: input.environmentId, + environmentPublicKey: input.environmentPublicKey, + }), + ); +} + +const formatEnvironmentPublishReplayThumbprint = (digest: Uint8Array) => + `env-publish:${Encoding.encodeBase64Url(digest)}`; + +const make = Effect.gen(function* () { + const proofReplay = yield* DpopProofs.DpopProofReplay; + const config = yield* RelayConfiguration.RelayConfiguration; + const crypto = yield* Crypto.Crypto; + + return EnvironmentPublishSignatures.of({ + verify: Effect.fn("relay.environment_publish_signatures.verify")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + "relay.thread_id": input.threadId, + }); + const now = yield* DateTime.now; + const decoded = yield* Effect.try({ + try: () => decodeRelayJwt(input.request.proof), + catch: () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + }); + if ( + typeof decoded.exp === "number" && + decoded.exp <= Math.floor(now.epochMilliseconds / 1_000) + ) { + return yield* new EnvironmentPublishSignatureExpired({ + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(decoded.exp * 1_000)), + }); + } + const proof = yield* verifyRelayJwt({ + publicKey: input.environmentPublicKey, + token: input.request.proof, + typ: RELAY_ACTIVITY_PUBLISH_TYP, + issuer: `t3-env:${input.environmentId}`, + audience: normalizeRelayIssuer(config.relayIssuer), + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + }).pipe( + Effect.flatMap(decodeProof), + Effect.mapError( + () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + ), + ); + if ( + proof.environmentId !== input.environmentId || + proof.threadId !== input.threadId || + proof.sub !== input.environmentId || + stableStringify(proof.state) !== stableStringify(input.request.state) || + (input.request.state !== null && + (input.request.state.environmentId !== input.environmentId || + input.request.state.threadId !== input.threadId)) + ) { + return yield* new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + }); + } + const expiresAt = DateTime.make(proof.exp * 1_000); + if (expiresAt._tag === "None") { + return yield* new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + }); + } + const thumbprint = yield* crypto + .digest( + "SHA-256", + environmentPublishReplayThumbprintData({ + environmentId: input.environmentId, + environmentPublicKey: input.environmentPublicKey, + }), + ) + .pipe( + Effect.map(formatEnvironmentPublishReplayThumbprint), + Effect.mapError( + () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), + ), + ); + const consumedNonce = yield* proofReplay.consume({ + thumbprint, + jti: proof.jti, + iat: proof.iat, + expiresAt: expiresAt.value, + }); + if (!consumedNonce) { + return yield* new EnvironmentPublishSignatureInvalid({ + environmentId: input.environmentId, + }); + } + }), + }); +}); + +export const layer = Layer.effect(EnvironmentPublishSignatures, make); diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts new file mode 100644 index 00000000000..236414e9552 --- /dev/null +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -0,0 +1,200 @@ +import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import { and, eq } from "drizzle-orm"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { RelayDb } from "../db.ts"; +import { isManagedEndpointHostname, managedEndpointForHostname } from "../deploymentConfig.ts"; +import { relayManagedEndpointAllocations } from "../persistence/schema.ts"; + +export interface ManagedEndpointAllocation { + readonly userId: string; + readonly environmentId: string; + readonly hostname: string; + readonly tunnelId: string | null; + readonly tunnelName: string; + readonly dnsRecordId: string | null; + readonly readyAt: string | null; +} + +export function resolveReadyManagedEndpoint(input: { + readonly allocation: ManagedEndpointAllocation; + readonly baseDomain: string | undefined; +}): RelayManagedEndpoint | null { + if ( + !input.baseDomain || + input.allocation.readyAt === null || + input.allocation.tunnelId === null || + input.allocation.dnsRecordId === null || + !isManagedEndpointHostname(input.allocation.hostname, input.baseDomain) + ) { + return null; + } + return managedEndpointForHostname(input.allocation.hostname); +} + +export class ManagedEndpointAllocationPersistenceError extends Data.TaggedError( + "ManagedEndpointAllocationPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +interface ManagedEndpointAllocationKey { + readonly userId: string; + readonly environmentId: string; +} + +interface ReserveManagedEndpointAllocationInput extends ManagedEndpointAllocationKey { + readonly hostname: string; + readonly tunnelName: string; +} + +interface RecordManagedEndpointTunnelInput extends ManagedEndpointAllocationKey { + readonly tunnelId: string; +} + +interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey { + readonly dnsRecordId: string; +} + +export interface ManagedEndpointAllocationsShape { + readonly get: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; + readonly reserve: ( + input: ReserveManagedEndpointAllocationInput, + ) => Effect.Effect; + readonly recordTunnel: ( + input: RecordManagedEndpointTunnelInput, + ) => Effect.Effect; + readonly recordDns: ( + input: RecordManagedEndpointDnsInput, + ) => Effect.Effect; + readonly markReady: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; + readonly remove: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; +} + +const allocationSelection = { + userId: relayManagedEndpointAllocations.userId, + environmentId: relayManagedEndpointAllocations.environmentId, + hostname: relayManagedEndpointAllocations.hostname, + tunnelId: relayManagedEndpointAllocations.tunnelId, + tunnelName: relayManagedEndpointAllocations.tunnelName, + dnsRecordId: relayManagedEndpointAllocations.dnsRecordId, + readyAt: relayManagedEndpointAllocations.readyAt, +}; + +const whereAllocation = (input: ManagedEndpointAllocationKey) => + and( + eq(relayManagedEndpointAllocations.userId, input.userId), + eq(relayManagedEndpointAllocations.environmentId, input.environmentId), + ); + +const persistenceError = (cause: unknown) => + cause instanceof ManagedEndpointAllocationPersistenceError + ? cause + : new ManagedEndpointAllocationPersistenceError({ cause }); + +const make = Effect.gen(function* () { + const db = yield* RelayDb; + + return ManagedEndpointAllocations.of({ + get: Effect.fn("relay.managed_endpoint_allocations.get")(function* ( + input: ManagedEndpointAllocationKey, + ) { + return yield* db + .select(allocationSelection) + .from(relayManagedEndpointAllocations) + .where(whereAllocation(input)) + .limit(1) + .pipe( + Effect.map((rows) => rows[0] ?? null), + Effect.mapError(persistenceError), + ); + }), + reserve: Effect.fn("relay.managed_endpoint_allocations.reserve")(function* ( + input: ReserveManagedEndpointAllocationInput, + ) { + const now = DateTime.formatIso(yield* DateTime.now); + const inserted = yield* db + .insert(relayManagedEndpointAllocations) + .values({ + ...input, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .returning(allocationSelection); + + const allocation = + inserted[0] ?? + (yield* db + .select(allocationSelection) + .from(relayManagedEndpointAllocations) + .where(whereAllocation(input)) + .limit(1) + .pipe(Effect.map((rows) => rows[0]))); + + if (allocation === undefined) { + return yield* new ManagedEndpointAllocationPersistenceError({ + cause: new Error("Managed endpoint allocation was not persisted."), + }); + } + + return allocation; + }, Effect.mapError(persistenceError)), + recordTunnel: Effect.fn("relay.managed_endpoint_allocations.record_tunnel")(function* ( + input: RecordManagedEndpointTunnelInput, + ) { + yield* db + .update(relayManagedEndpointAllocations) + .set({ + tunnelId: input.tunnelId, + updatedAt: DateTime.formatIso(yield* DateTime.now), + }) + .where(whereAllocation(input)); + }, Effect.mapError(persistenceError)), + recordDns: Effect.fn("relay.managed_endpoint_allocations.record_dns")(function* ( + input: RecordManagedEndpointDnsInput, + ) { + yield* db + .update(relayManagedEndpointAllocations) + .set({ + dnsRecordId: input.dnsRecordId, + updatedAt: DateTime.formatIso(yield* DateTime.now), + }) + .where(whereAllocation(input)); + }, Effect.mapError(persistenceError)), + markReady: Effect.fn("relay.managed_endpoint_allocations.mark_ready")(function* ( + input: ManagedEndpointAllocationKey, + ) { + const now = DateTime.formatIso(yield* DateTime.now); + yield* db + .update(relayManagedEndpointAllocations) + .set({ + readyAt: now, + updatedAt: now, + }) + .where(whereAllocation(input)); + }, Effect.mapError(persistenceError)), + remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( + input: ManagedEndpointAllocationKey, + ) { + yield* db.delete(relayManagedEndpointAllocations).where(whereAllocation(input)); + }, Effect.mapError(persistenceError)), + }); +}); + +export class ManagedEndpointAllocations extends Context.Service< + ManagedEndpointAllocations, + ManagedEndpointAllocationsShape +>()("t3code-relay/environments/ManagedEndpointAllocations") { + static readonly layer = Layer.effect(this, make); +} diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts new file mode 100644 index 00000000000..5d82711745c --- /dev/null +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -0,0 +1,760 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +import * as RelayConfiguration from "../Config.ts"; +import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; +import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; + +const config = RelayConfiguration.RelayConfiguration.of({ + relayIssuer: "https://relay.example.test", + apns: { + environment: "sandbox", + teamId: "team-id", + keyId: "key-id", + privateKey: Redacted.make("private-key"), + bundleId: "com.t3tools.t3code.dev", + }, + apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), + clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + cloudMintPrivateKey: Redacted.make("cloud-private-key"), + cloudMintPublicKey: "cloud-public-key", + managedEndpointBaseDomain: "t3code.test", + managedEndpointNamespace: "dev_julius", +}); + +interface TunnelCall { + readonly operation: "list" | "create" | "putConfiguration" | "getToken" | "delete"; + readonly input: unknown; +} + +interface DnsCall { + readonly operation: "listRecords" | "createRecord" | "updateRecord" | "deleteRecord"; + readonly input: unknown; +} + +interface AllocationCall { + readonly operation: "get" | "reserve" | "recordTunnel" | "recordDns" | "markReady" | "remove"; + readonly input: unknown; +} + +function allocationKey(input: { readonly userId: string; readonly environmentId: string }) { + return `${input.userId}:${input.environmentId}`; +} + +function makeTunnelClient(calls: TunnelCall[] = []) { + return ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + list: (request) => + Effect.sync(() => { + calls.push({ operation: "list", input: request }); + return { result: [] }; + }), + create: (request) => + Effect.sync(() => { + calls.push({ operation: "create", input: request }); + return { id: "tunnel-id", name: request.name }; + }), + putConfiguration: (tunnelId, tunnelConfig) => + Effect.sync(() => { + calls.push({ operation: "putConfiguration", input: { tunnelId, tunnelConfig } }); + }), + getToken: (tunnelId) => + Effect.sync(() => { + calls.push({ operation: "getToken", input: tunnelId }); + return "connector-token"; + }), + delete: (tunnelId) => + Effect.sync(() => { + calls.push({ operation: "delete", input: tunnelId }); + }), + }); +} + +function makePersistentTunnelClient(calls: TunnelCall[] = []) { + let tunnel: { readonly id: string; readonly name: string } | null = null; + return ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + list: (request) => + Effect.sync(() => { + calls.push({ operation: "list", input: request }); + return { result: tunnel === null ? [] : [tunnel] }; + }), + create: (request) => + Effect.sync(() => { + calls.push({ operation: "create", input: request }); + tunnel = { id: "tunnel-id", name: request.name }; + return tunnel; + }), + putConfiguration: (tunnelId, tunnelConfig) => + Effect.sync(() => { + calls.push({ operation: "putConfiguration", input: { tunnelId, tunnelConfig } }); + }), + getToken: (tunnelId) => + Effect.sync(() => { + calls.push({ operation: "getToken", input: tunnelId }); + return "connector-token"; + }), + delete: (tunnelId) => + Effect.sync(() => { + calls.push({ operation: "delete", input: tunnelId }); + tunnel = null; + }), + }); +} + +function makeDnsClient( + calls: DnsCall[] = [], + records: ReadonlyArray<{ readonly id: string }> = [], +) { + let currentRecords = [...records]; + return ManagedEndpointProvider.ManagedEndpointDnsClient.of({ + listRecords: (hostname) => + Effect.sync(() => { + calls.push({ operation: "listRecords", input: hostname }); + return currentRecords; + }), + createRecord: (request) => + Effect.sync(() => { + calls.push({ operation: "createRecord", input: request }); + const record = { id: "created-record-id" }; + currentRecords = [record]; + return record; + }), + updateRecord: (dnsRecordId, request) => + Effect.gen(function* () { + calls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); + if (!currentRecords.some((record) => record.id === dnsRecordId)) { + return yield* new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + cause: `DNS record ${dnsRecordId} does not exist.`, + }); + } + }), + deleteRecord: (dnsRecordId) => + Effect.sync(() => { + calls.push({ operation: "deleteRecord", input: dnsRecordId }); + currentRecords = currentRecords.filter((record) => record.id !== dnsRecordId); + }), + }); +} + +function makeAllocations(calls: AllocationCall[] = []) { + const allocations = new Map(); + return ManagedEndpointAllocations.ManagedEndpointAllocations.of({ + get: (input) => + Effect.sync(() => { + calls.push({ operation: "get", input }); + return allocations.get(allocationKey(input)) ?? null; + }), + reserve: (input) => + Effect.sync(() => { + calls.push({ operation: "reserve", input }); + const allocation = allocations.get(allocationKey(input)) ?? { + ...input, + tunnelId: null, + dnsRecordId: null, + readyAt: null, + }; + allocations.set(allocationKey(input), allocation); + return allocation; + }), + recordTunnel: (input) => + Effect.sync(() => { + calls.push({ operation: "recordTunnel", input }); + const allocation = allocations.get(allocationKey(input)); + if (allocation !== undefined) { + allocations.set(allocationKey(input), { ...allocation, tunnelId: input.tunnelId }); + } + }), + recordDns: (input) => + Effect.sync(() => { + calls.push({ operation: "recordDns", input }); + const allocation = allocations.get(allocationKey(input)); + if (allocation !== undefined) { + allocations.set(allocationKey(input), { ...allocation, dnsRecordId: input.dnsRecordId }); + } + }), + markReady: (input) => + Effect.sync(() => { + calls.push({ operation: "markReady", input }); + const allocation = allocations.get(allocationKey(input)); + if (allocation !== undefined) { + allocations.set(allocationKey(input), { + ...allocation, + readyAt: "2026-06-02T00:00:00.000Z", + }); + } + }), + remove: (input) => + Effect.sync(() => { + calls.push({ operation: "remove", input }); + allocations.delete(allocationKey(input)); + }), + }); +} + +function providerLayer( + tunnelClient = makeTunnelClient(), + dnsClient = makeDnsClient(), + allocations = makeAllocations(), +) { + return ManagedEndpointProvider.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), + Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointTunnelClient, tunnelClient)), + Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointDnsClient, dnsClient)), + Layer.provide( + Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, allocations), + ), + ); +} + +function expectedManagedHostname(environmentId: string, userId = "user_ABC"): string { + const hash = NodeCrypto.createHash("sha256") + .update(`dev_julius:${userId}:${environmentId}`) + .digest("hex") + .slice(0, 16); + return `tunnels-dev-julius-${hash}.t3code.test`; +} + +function expectedManagedTunnelName(environmentId: string, userId = "user_ABC"): string { + const hash = NodeCrypto.createHash("sha256") + .update(`dev_julius:${userId}:${environmentId}`) + .digest("hex") + .slice(0, 16); + return `t3coderelay-managedendpoint-dev-julius-${hash}`; +} + +describe("ManagedEndpointProvider", () => { + it.effect("provisions a Cloudflare tunnel endpoint and connector token", () => { + const tunnelCalls: TunnelCall[] = []; + const dnsCalls: DnsCall[] = []; + const allocationCalls: AllocationCall[] = []; + + return Effect.gen(function* () { + const hostname = expectedManagedHostname("env_ABC"); + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const result = yield* provider.provision({ + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + + expect(result).toEqual({ + endpoint: { + httpBaseUrl: `https://${hostname}/`, + wsBaseUrl: `wss://${hostname}/ws`, + providerKind: "cloudflare_tunnel", + }, + runtime: { + providerKind: "cloudflare_tunnel", + connectorToken: "connector-token", + tunnelId: "tunnel-id", + tunnelName: expectedManagedTunnelName("env_ABC"), + }, + }); + expect(dnsCalls).toEqual([ + { operation: "listRecords", input: hostname }, + { + operation: "createRecord", + input: { + type: "CNAME", + name: hostname, + content: "tunnel-id.cfargotunnel.com", + ttl: 1, + proxied: true, + }, + }, + ]); + expect(tunnelCalls.map((call) => call.operation)).toEqual([ + "list", + "create", + "putConfiguration", + "getToken", + ]); + expect(tunnelCalls[2]?.input).toMatchObject({ + tunnelConfig: { + ingress: [ + { + hostname, + service: "http://127.0.0.1:3773", + }, + { service: "http_status:404" }, + ], + }, + }); + expect(tunnelCalls[0]?.input).toEqual({ + name: expectedManagedTunnelName("env_ABC"), + isDeleted: false, + }); + expect(allocationCalls.map((call) => call.operation)).toEqual([ + "reserve", + "recordTunnel", + "recordDns", + "markReady", + ]); + }).pipe( + Effect.provide( + providerLayer( + makeTunnelClient(tunnelCalls), + makeDnsClient(dnsCalls), + makeAllocations(allocationCalls), + ), + ), + ); + }); + + it.effect("uses stage-scoped stable names without leaking unusual environment ids", () => { + const tunnelCalls: TunnelCall[] = []; + + return Effect.gen(function* () { + const environmentId = "ENV With Spaces/../Symbols!" + "x".repeat(80); + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + yield* provider.provision({ + userId: "user_ABC", + environmentId, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + + const requestedName = ( + tunnelCalls.find((call) => call.operation === "list")?.input as + | { readonly name?: string } + | undefined + )?.name; + expect(requestedName).toMatch(/^t3coderelay-managedendpoint-dev-julius-[a-f0-9]{16}$/); + const configBody = ( + tunnelCalls.find((call) => call.operation === "putConfiguration")?.input as + | { readonly tunnelConfig?: unknown } + | undefined + )?.tunnelConfig; + expect(configBody).toMatchObject({ + ingress: [ + { + hostname: expect.stringMatching(/^tunnels-dev-julius-[a-f0-9]{16}\.t3code\.test$/), + }, + { service: "http_status:404" }, + ], + }); + const hostname = ( + configBody as + | { + readonly ingress?: readonly [{ readonly hostname?: unknown }, unknown]; + } + | undefined + )?.ingress?.[0]?.hostname; + expect(typeof hostname === "string" ? hostname.split(".")[0]?.length : 0).toBeLessThanOrEqual( + 63, + ); + expect(tunnelCalls.find((call) => call.operation === "create")?.input).toMatchObject({ + name: requestedName, + configSrc: "cloudflare", + }); + }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls)))); + }); + + it.effect("formats IPv6 loopback origins as valid Cloudflare ingress service URLs", () => { + const tunnelCalls: TunnelCall[] = []; + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + yield* provider.provision({ + userId: "user_ABC", + environmentId: "env-ipv6", + origin: { localHttpHost: "::1", localHttpPort: 3773 }, + }); + + expect( + tunnelCalls.find((call) => call.operation === "putConfiguration")?.input, + ).toMatchObject({ + tunnelConfig: { + ingress: [ + { + service: "http://[::1]:3773", + }, + { service: "http_status:404" }, + ], + }, + }); + }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls)))); + }); + + it.effect("rejects non-loopback managed endpoint origins before calling Cloudflare", () => { + const dnsCalls: DnsCall[] = []; + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const result = yield* Effect.result( + provider.provision({ + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "192.168.1.10", localHttpPort: 3773 }, + }), + ); + + expect(dnsCalls).toHaveLength(0); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); + } + }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls)))); + }); + + it.effect("rejects invalid managed endpoint origin ports before calling Cloudflare", () => { + const dnsCalls: DnsCall[] = []; + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const result = yield* Effect.result( + provider.provision({ + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 65_536 }, + }), + ); + + expect(dnsCalls).toHaveLength(0); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); + } + }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls)))); + }); + + it.effect("reconciles an existing same-host DNS record through the DNS client", () => { + const dnsCalls: DnsCall[] = []; + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + yield* provider.provision({ + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + + expect(dnsCalls.map((call) => call.operation)).toEqual(["listRecords", "updateRecord"]); + expect(dnsCalls[1]?.input).toMatchObject({ dnsRecordId: "existing-record-id" }); + }).pipe( + Effect.provide( + providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls, [{ id: "existing-record-id" }])), + ), + ); + }); + + it.effect("reuses checkpointed resources when provisioning is retried", () => { + const tunnelCalls: TunnelCall[] = []; + const dnsCalls: DnsCall[] = []; + const allocationCalls: AllocationCall[] = []; + const layer = providerLayer( + makePersistentTunnelClient(tunnelCalls), + makeDnsClient(dnsCalls), + makeAllocations(allocationCalls), + ); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const request = { + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + } as const; + yield* provider.provision(request); + yield* provider.provision(request); + + expect(tunnelCalls.map((call) => call.operation)).toEqual([ + "list", + "create", + "putConfiguration", + "getToken", + "list", + "putConfiguration", + "getToken", + ]); + expect(dnsCalls.map((call) => call.operation)).toEqual([ + "listRecords", + "createRecord", + "updateRecord", + ]); + expect(allocationCalls.map((call) => call.operation)).toEqual([ + "reserve", + "recordTunnel", + "recordDns", + "markReady", + "reserve", + "recordTunnel", + "recordDns", + "markReady", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("recreates a checkpointed DNS record when it was removed externally", () => { + const dnsCalls: DnsCall[] = []; + const allocationCalls: AllocationCall[] = []; + const dnsClient = makeDnsClient(dnsCalls); + const layer = providerLayer( + makePersistentTunnelClient(), + dnsClient, + makeAllocations(allocationCalls), + ); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const request = { + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + } as const; + yield* provider.provision(request); + yield* dnsClient.deleteRecord("created-record-id"); + yield* provider.provision(request); + + expect(dnsCalls.map((call) => call.operation)).toEqual([ + "listRecords", + "createRecord", + "deleteRecord", + "updateRecord", + "listRecords", + "createRecord", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "deprovisions checkpointed DNS and tunnel resources before removing the allocation", + () => { + const tunnelCalls: TunnelCall[] = []; + const dnsCalls: DnsCall[] = []; + const allocationCalls: AllocationCall[] = []; + const layer = providerLayer( + makePersistentTunnelClient(tunnelCalls), + makeDnsClient(dnsCalls), + makeAllocations(allocationCalls), + ); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + yield* provider.provision({ + ...key, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + yield* provider.deprovision(key); + + expect(dnsCalls.map((call) => call.operation)).toEqual([ + "listRecords", + "createRecord", + "deleteRecord", + ]); + expect(tunnelCalls.map((call) => call.operation)).toEqual([ + "list", + "create", + "putConfiguration", + "getToken", + "delete", + ]); + expect(allocationCalls.map((call) => call.operation)).toEqual([ + "reserve", + "recordTunnel", + "recordDns", + "markReady", + "get", + "remove", + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("treats an absent allocation as already deprovisioned", () => { + const tunnelCalls: TunnelCall[] = []; + const dnsCalls: DnsCall[] = []; + const allocationCalls: AllocationCall[] = []; + const layer = providerLayer( + makePersistentTunnelClient(tunnelCalls), + makeDnsClient(dnsCalls), + makeAllocations(allocationCalls), + ); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + yield* provider.deprovision(key); + + expect(tunnelCalls).toEqual([]); + expect(dnsCalls).toEqual([]); + expect(allocationCalls).toEqual([{ operation: "get", input: key }]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps the allocation when tunnel cleanup fails so unlink can retry", () => { + const allocationCalls: AllocationCall[] = []; + const tunnelCalls: TunnelCall[] = []; + let deleteAttempts = 0; + const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ + cause: "Cloudflare tunnel deletion failed", + }); + const tunnels = makePersistentTunnelClient(tunnelCalls); + const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + ...tunnels, + delete: (tunnelId) => + Effect.gen(function* () { + tunnelCalls.push({ operation: "delete", input: tunnelId }); + deleteAttempts++; + if (deleteAttempts === 1) { + return yield* failure; + } + }), + }); + const layer = providerLayer(tunnelClient, makeDnsClient(), makeAllocations(allocationCalls)); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + yield* provider.provision({ + ...key, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + const first = yield* Effect.result(provider.deprovision(key)); + expect(first._tag).toBe("Failure"); + yield* provider.deprovision(key); + + expect(allocationCalls.map((call) => call.operation)).toEqual([ + "reserve", + "recordTunnel", + "recordDns", + "markReady", + "get", + "get", + "remove", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("treats already deleted remote resources as successfully deprovisioned", () => { + const allocationCalls: AllocationCall[] = []; + const notFound = { _tag: "NotFound" } as const; + const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + ...makeTunnelClient(), + delete: () => + Effect.fail( + new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause: notFound }), + ), + }); + const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ + ...makeDnsClient(), + deleteRecord: () => + Effect.fail(new ManagedEndpointProvider.ManagedEndpointDnsClientError({ cause: notFound })), + }); + const layer = providerLayer(tunnelClient, dnsClient, makeAllocations(allocationCalls)); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + yield* provider.provision({ + ...key, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + yield* provider.deprovision(key); + + expect(allocationCalls.map((call) => call.operation)).toContain("remove"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("scopes managed endpoint resources by user", () => { + const tunnelCalls: TunnelCall[] = []; + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + yield* provider.provision({ + userId: "user_ABC", + environmentId: "env_shared", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + yield* provider.provision({ + userId: "user_DEF", + environmentId: "env_shared", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + + expect( + tunnelCalls.filter((call) => call.operation === "list").map((call) => call.input), + ).toEqual([ + { name: expectedManagedTunnelName("env_shared", "user_ABC"), isDeleted: false }, + { name: expectedManagedTunnelName("env_shared", "user_DEF"), isDeleted: false }, + ]); + }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls)))); + }); + + it.effect("recovers when DNS creation reports failure after the record became visible", () => { + const dnsCalls: DnsCall[] = []; + const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + cause: "ambiguous Cloudflare DNS response", + }); + let records: ReadonlyArray<{ readonly id: string }> = []; + const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ + listRecords: (hostname) => + Effect.sync(() => { + dnsCalls.push({ operation: "listRecords", input: hostname }); + return records; + }), + createRecord: (request) => + Effect.gen(function* () { + dnsCalls.push({ operation: "createRecord", input: request }); + records = [{ id: "created-record-id" }]; + return yield* failure; + }), + updateRecord: (dnsRecordId, request) => + Effect.sync(() => { + dnsCalls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); + }), + deleteRecord: (dnsRecordId) => + Effect.sync(() => { + dnsCalls.push({ operation: "deleteRecord", input: dnsRecordId }); + }), + }); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + yield* provider.provision({ + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + + expect(dnsCalls.map((call) => call.operation)).toEqual([ + "listRecords", + "createRecord", + "listRecords", + "updateRecord", + ]); + }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); + }); + + it.effect("fails provisioning when the DNS client fails", () => { + const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + cause: "Cloudflare DNS failure", + }); + const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ + listRecords: () => Effect.fail(failure), + createRecord: () => Effect.die("unused"), + updateRecord: () => Effect.die("unused"), + deleteRecord: () => Effect.die("unused"), + }); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const error = yield* Effect.flip( + provider.provision({ + userId: "user_ABC", + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }), + ); + + expect(error._tag).toBe("ManagedEndpointProvisioningFailed"); + expect(error.cause).toBe(failure); + }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); + }); +}); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts new file mode 100644 index 00000000000..068beccff00 --- /dev/null +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -0,0 +1,498 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Arr from "effect/Array"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; + +import type { + RelayManagedEndpoint, + RelayManagedEndpointOrigin, + RelayManagedEndpointRuntimeConfig, +} from "@t3tools/contracts/relay"; + +import * as RelayConfiguration from "../Config.ts"; +import { + managedEndpointDigestInput, + managedEndpointForHostname, + managedEndpointHostname, + managedEndpointTunnelName, +} from "../deploymentConfig.ts"; +import { ManagedEndpointAllocations } from "./ManagedEndpointAllocations.ts"; + +export class ManagedEndpointProvisioningNotConfigured extends Data.TaggedError( + "ManagedEndpointProvisioningNotConfigured", +)<{}> {} + +export class ManagedEndpointProvisioningFailed extends Data.TaggedError( + "ManagedEndpointProvisioningFailed", +)<{ + readonly cause: unknown; +}> {} + +export class ManagedEndpointDeprovisioningFailed extends Data.TaggedError( + "ManagedEndpointDeprovisioningFailed", +)<{ + readonly cause: unknown; +}> {} + +export class ManagedEndpointOriginNotAllowed extends Data.TaggedError( + "ManagedEndpointOriginNotAllowed", +)<{ + readonly host: string; + readonly port: number; +}> {} + +export type ManagedEndpointProviderError = + | ManagedEndpointProvisioningNotConfigured + | ManagedEndpointProvisioningFailed + | ManagedEndpointOriginNotAllowed; + +export interface ManagedEndpointProvisioningResult { + readonly endpoint: RelayManagedEndpoint; + readonly runtime: RelayManagedEndpointRuntimeConfig; +} + +export interface ManagedEndpointProviderShape { + readonly provision: (input: { + readonly userId: string; + readonly environmentId: string; + readonly origin: RelayManagedEndpointOrigin; + }) => Effect.Effect; + readonly deprovision: (input: { + readonly userId: string; + readonly environmentId: string; + }) => Effect.Effect; +} + +export class ManagedEndpointProvider extends Context.Service< + ManagedEndpointProvider, + ManagedEndpointProviderShape +>()("t3code-relay/environments/ManagedEndpointProvider") {} + +interface ManagedEndpointTunnel { + readonly id?: string | null; + readonly name?: string | null; +} + +export class ManagedEndpointTunnelClientError extends Data.TaggedError( + "ManagedEndpointTunnelClientError", +)<{ + readonly cause: unknown; +}> {} + +export interface ManagedEndpointTunnelClientShape { + readonly list: (request: { + readonly name: string; + readonly isDeleted: false; + }) => Effect.Effect< + { readonly result: ReadonlyArray }, + ManagedEndpointTunnelClientError + >; + readonly create: (request: { + readonly name: string; + readonly configSrc: "cloudflare"; + }) => Effect.Effect; + readonly putConfiguration: ( + tunnelId: string, + config: { + readonly ingress: Array<{ + readonly hostname?: string; + readonly service: string; + }>; + }, + ) => Effect.Effect; + readonly getToken: (tunnelId: string) => Effect.Effect; + readonly delete: (tunnelId: string) => Effect.Effect; +} + +export class ManagedEndpointTunnelClient extends Context.Service< + ManagedEndpointTunnelClient, + ManagedEndpointTunnelClientShape +>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {} + +interface ManagedEndpointCnameRecordInput { + readonly type: "CNAME"; + readonly name: string; + readonly content: string; + readonly ttl: 1; + readonly proxied: true; +} + +export class ManagedEndpointDnsClientError extends Data.TaggedError( + "ManagedEndpointDnsClientError", +)<{ + readonly cause: unknown; +}> {} + +export interface ManagedEndpointDnsClientShape { + readonly listRecords: ( + hostname: string, + ) => Effect.Effect, ManagedEndpointDnsClientError>; + readonly createRecord: ( + request: ManagedEndpointCnameRecordInput, + ) => Effect.Effect<{ readonly id: string }, ManagedEndpointDnsClientError>; + readonly updateRecord: ( + dnsRecordId: string, + request: ManagedEndpointCnameRecordInput, + ) => Effect.Effect; + readonly deleteRecord: ( + dnsRecordId: string, + ) => Effect.Effect; +} + +export class ManagedEndpointDnsClient extends Context.Service< + ManagedEndpointDnsClient, + ManagedEndpointDnsClientShape +>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointDnsClient") {} + +const requireCloudflareSettings = Effect.fnUntraced(function* ( + settings: RelayConfiguration.RelayConfigurationShape, +) { + if (!settings.managedEndpointBaseDomain || !settings.managedEndpointNamespace) { + return yield* new ManagedEndpointProvisioningNotConfigured(); + } + return { + baseDomain: settings.managedEndpointBaseDomain, + namespace: settings.managedEndpointNamespace, + }; +}); + +function formatOriginService(origin: RelayManagedEndpointOrigin): string { + const host = origin.localHttpHost.includes(":") + ? `[${origin.localHttpHost.replace(/^\[(.*)\]$/u, "$1")}]` + : origin.localHttpHost; + return `http://${host}:${origin.localHttpPort}`; +} + +function normalizeHostname(hostname: string): string { + return hostname + .trim() + .toLowerCase() + .replace(/\.$/u, "") + .replace(/^\[(.*)\]$/u, "$1"); +} + +function isLoopbackOrigin(origin: RelayManagedEndpointOrigin): boolean { + const hostname = normalizeHostname(origin.localHttpHost); + return ( + (hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost") && + Number.isInteger(origin.localHttpPort) && + origin.localHttpPort > 0 && + origin.localHttpPort <= 65_535 + ); +} + +function isNotFoundCause(cause: unknown): boolean { + if (typeof cause !== "object" || cause === null) { + return false; + } + if ("_tag" in cause && cause._tag === "NotFound") { + return true; + } + if ("status" in cause && cause.status === 404) { + return true; + } + return "cause" in cause && isNotFoundCause(cause.cause); +} + +const ignoreNotFound = (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.asVoid, + Effect.catch((cause) => (isNotFoundCause(cause) ? Effect.void : Effect.fail(cause))), + ); + +const make = Effect.gen(function* () { + const config = yield* RelayConfiguration.RelayConfiguration; + const crypto = yield* Crypto.Crypto; + const tunnels = yield* ManagedEndpointTunnelClient; + const dns = yield* ManagedEndpointDnsClient; + const allocations = yield* ManagedEndpointAllocations; + + const updateExistingDnsRecords = Effect.fnUntraced(function* ( + records: ReadonlyArray<{ readonly id: string }>, + preferredDnsRecordId: string | null, + dnsRecord: ManagedEndpointCnameRecordInput, + ) { + const keptRecord = records.find((record) => record.id === preferredDnsRecordId) ?? records[0]; + if (keptRecord === undefined) { + return null; + } + yield* Effect.forEach( + records, + (record) => (record.id === keptRecord.id ? Effect.void : dns.deleteRecord(record.id)), + { discard: true }, + ); + yield* dns.updateRecord(keptRecord.id, dnsRecord); + return keptRecord.id; + }); + + const ensureDnsRecord = Effect.fnUntraced(function* ( + hostname: string, + preferredDnsRecordId: string | null, + dnsRecord: ManagedEndpointCnameRecordInput, + ) { + if (preferredDnsRecordId !== null) { + const checkpointedRecordUpdated = yield* dns + .updateRecord(preferredDnsRecordId, dnsRecord) + .pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ); + if (checkpointedRecordUpdated) { + return preferredDnsRecordId; + } + } + const existingDnsRecords = yield* dns.listRecords(hostname); + const existingDnsRecordId = yield* updateExistingDnsRecords( + existingDnsRecords, + preferredDnsRecordId, + dnsRecord, + ); + if (existingDnsRecordId !== null) { + return existingDnsRecordId; + } + return yield* dns.createRecord(dnsRecord).pipe( + Effect.map((record) => record.id), + Effect.catch((createError) => + Effect.gen(function* () { + let records = yield* dns.listRecords(hostname); + for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) { + yield* Effect.sleep("200 millis"); + records = yield* dns.listRecords(hostname); + } + return records; + }).pipe( + Effect.flatMap((records) => + records.length > 0 + ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord) + : Effect.fail(createError), + ), + Effect.flatMap((dnsRecordId) => + dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId), + ), + ), + ), + ); + }); + + return ManagedEndpointProvider.of({ + deprovision: Effect.fn("relay.managed_endpoint_provider.deprovision")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.user_id": input.userId, + "relay.environment_id": input.environmentId, + }); + const allocation = yield* allocations + .get(input) + .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause }))); + if (allocation === null) { + return; + } + if (allocation.dnsRecordId !== null) { + yield* ignoreNotFound(dns.deleteRecord(allocation.dnsRecordId)).pipe( + Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })), + ); + } + if (allocation.tunnelId !== null) { + yield* ignoreNotFound(tunnels.delete(allocation.tunnelId)).pipe( + Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })), + ); + } + yield* allocations + .remove(input) + .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause }))); + }), + provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.user_id": input.userId, + "relay.environment_id": input.environmentId, + "relay.managed_endpoint.origin_host": input.origin.localHttpHost, + "relay.managed_endpoint.origin_port": input.origin.localHttpPort, + }); + if (!isLoopbackOrigin(input.origin)) { + return yield* new ManagedEndpointOriginNotAllowed({ + host: input.origin.localHttpHost, + port: input.origin.localHttpPort, + }); + } + const cf = yield* requireCloudflareSettings(config); + const environmentHash = yield* crypto + .digest( + "SHA-256", + new TextEncoder().encode( + managedEndpointDigestInput(cf.namespace, input.userId, input.environmentId), + ), + ) + .pipe( + Effect.map(Encoding.encodeHex), + Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + ); + const allocation = yield* allocations + .reserve({ + userId: input.userId, + environmentId: input.environmentId, + hostname: managedEndpointHostname(cf.namespace, cf.baseDomain, environmentHash), + tunnelName: managedEndpointTunnelName(cf.namespace, environmentHash), + }) + .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + const { hostname, tunnelName } = allocation; + + const tunnel = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe( + Effect.map((tunnels) => tunnels.result), + Effect.map(Arr.findFirst((tunnel) => tunnel.name === tunnelName)), + Effect.flatMap( + Option.match({ + onSome: (tunnel) => Effect.succeed(tunnel), + onNone: () => tunnels.create({ name: tunnelName, configSrc: "cloudflare" }), + }), + ), + Effect.filterMapOrFail((tunnel) => + tunnel.id && tunnel.name + ? Result.succeed({ id: tunnel.id, name: tunnel.name }) + : Result.fail(new ManagedEndpointProvisioningFailed({ cause: tunnel })), + ), + Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + ); + yield* allocations + .recordTunnel({ + userId: input.userId, + environmentId: input.environmentId, + tunnelId: tunnel.id, + }) + .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + + yield* tunnels + .putConfiguration(tunnel.id, { + ingress: [ + { + hostname, + service: formatOriginService(input.origin), + }, + { service: "http_status:404" }, + ], + }) + .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + + const dnsRecord = { + type: "CNAME", + name: hostname, + content: `${tunnel.id}.cfargotunnel.com`, + ttl: 1, + proxied: true, + } as const; + + const dnsRecordId = yield* ensureDnsRecord(hostname, allocation.dnsRecordId, dnsRecord).pipe( + Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + ); + yield* allocations + .recordDns({ + userId: input.userId, + environmentId: input.environmentId, + dnsRecordId, + }) + .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + + const connectorToken = yield* tunnels + .getToken(tunnel.id) + .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + yield* allocations + .markReady({ + userId: input.userId, + environmentId: input.environmentId, + }) + .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + + return { + endpoint: managedEndpointForHostname(hostname), + runtime: { + providerKind: "cloudflare_tunnel", + connectorToken, + tunnelId: tunnel.id, + tunnelName: tunnel.name, + }, + } satisfies ManagedEndpointProvisioningResult; + }), + }); +}); + +export const layer = Layer.effect(ManagedEndpointProvider, make); + +export const layerCloudflareBindings = ( + tunnelClient: Cloudflare.TunnelReadWriteClient, + dnsClient: Cloudflare.DnsReadWriteClient, + alchemyRuntimeContext: Alchemy.BaseRuntimeContext, +) => + layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + ManagedEndpointTunnelClient, + ManagedEndpointTunnelClient.of({ + list: (request) => + tunnelClient.list(request).pipe( + Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + create: (request) => + tunnelClient.create(request).pipe( + Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + putConfiguration: (tunnelId, config) => + tunnelClient.putConfiguration(tunnelId, config).pipe( + Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + getToken: (tunnelId) => + tunnelClient.getToken(tunnelId).pipe( + Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + delete: (tunnelId) => + tunnelClient.delete(tunnelId).pipe( + Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }), + ), + Layer.succeed( + ManagedEndpointDnsClient, + ManagedEndpointDnsClient.of({ + listRecords: (hostname) => + dnsClient.listDnsRecords({ search: hostname }).pipe( + Effect.map((response) => + response.result.filter( + (record): record is typeof record & { readonly id: string } => + typeof record.id === "string" && + normalizeHostname(record.name) === normalizeHostname(hostname), + ), + ), + Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + createRecord: (request) => + dnsClient.createDnsRecord(request).pipe( + Effect.map((response) => ({ id: response.id })), + Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + updateRecord: (dnsRecordId, request) => + dnsClient.updateDnsRecord(dnsRecordId, request).pipe( + Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + deleteRecord: (dnsRecordId) => + dnsClient.deleteDnsRecord(dnsRecordId).pipe( + Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }), + ), + ), + ), + ); diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts new file mode 100644 index 00000000000..0f25e4632c4 --- /dev/null +++ b/infra/relay/src/http/Api.test.ts @@ -0,0 +1,232 @@ +import { createClerkClient, verifyToken } from "@clerk/backend"; +import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Redacted from "effect/Redacted"; +import * as Tracer from "effect/Tracer"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; + +import { + relayCors, + relayDocsRedirectRoute, + relayEnvironmentAuthLayer, + relayNotFoundRoute, + traceRelayHttpRequestWith, + verifyRelayClientBearerToken, + withoutCapturedParentSpan, +} from "./Api.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; + +vi.mock("@clerk/backend", () => ({ + createClerkClient: vi.fn(), + verifyToken: vi.fn(), +})); + +const relaySettings: RelayConfiguration.RelayConfigurationShape = { + relayIssuer: "https://relay.example.test", + apns: { + teamId: "apns-team", + keyId: "apns-key", + privateKey: Redacted.make("apns-private-key"), + bundleId: "com.example.t3", + environment: "sandbox", + }, + clerkSecretKey: Redacted.make("clerk-secret-key"), + clerkPublishableKey: "pk_test_test", + clerkJwtAudience: "t3-code-relay", + apnsDeliveryJobSigningSecret: Redacted.make("apns-delivery-secret"), + cloudMintPrivateKey: Redacted.make("cloud-mint-private-key"), + cloudMintPublicKey: "cloud-mint-public-key", + managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, +}; + +describe("relay client authentication", () => { + it.effect("preserves the existing Clerk session JWT path", () => + Effect.gen(function* () { + vi.mocked(verifyToken).mockResolvedValue({ + sub: "user_session", + aud: relaySettings.clerkJwtAudience, + } as never); + + expect(yield* verifyRelayClientBearerToken(relaySettings, "session-token")).toEqual({ + sub: "user_session", + mode: "clerk_session_bearer", + }); + expect(verifyToken).toHaveBeenCalledWith("session-token", { + secretKey: "clerk-secret-key", + audience: relaySettings.clerkJwtAudience, + }); + expect(createClerkClient).not.toHaveBeenCalled(); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + vi.mocked(verifyToken).mockReset(); + vi.mocked(createClerkClient).mockReset(); + }), + ), + ), + ); + + it.effect("falls back to Clerk OAuth token verification for the headless CLI", () => + Effect.gen(function* () { + vi.mocked(verifyToken).mockRejectedValue(new Error("not a session JWT")); + vi.mocked(createClerkClient).mockReturnValue({ + authenticateRequest: vi.fn().mockResolvedValue({ + isAuthenticated: true, + toAuth: () => ({ userId: "user_oauth" }), + }), + } as never); + + expect(yield* verifyRelayClientBearerToken(relaySettings, "oauth-token")).toEqual({ + sub: "user_oauth", + mode: "clerk_oauth_bearer", + }); + expect(createClerkClient).toHaveBeenCalledWith({ + secretKey: "clerk-secret-key", + publishableKey: "pk_test_test", + }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + vi.mocked(verifyToken).mockReset(); + vi.mocked(createClerkClient).mockReset(); + }), + ), + ), + ); +}); + +describe("relay environment authentication", () => { + it.effect("preserves credential lookup persistence failures as internal errors", () => { + const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({ + cause: "database unavailable", + }); + const credentials: EnvironmentCredentials.EnvironmentCredentialsShape = { + create: () => Effect.die("unused create"), + authenticate: () => Effect.fail(failure), + revokeForEnvironmentPublicKey: () => Effect.die("unused revoke"), + }; + + return Effect.gen(function* () { + const auth = yield* RelayEnvironmentAuth; + const error = yield* Effect.flip( + auth.environmentBearer(Effect.succeed(HttpServerResponse.empty()), { + credential: Redacted.make("environment-credential"), + endpoint: {} as never, + group: {} as never, + }), + ); + + expect(Predicate.isTagged(error, "RelayInternalError")).toBe(true); + if (Predicate.isTagged(error, "RelayInternalError")) { + expect(error.reason).toBe("persistence_failed"); + } + }).pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request("https://relay.test/v1/server/link")), + ), + Effect.provideService(HttpServerRequest.ParsedSearchParams, {}), + Effect.provideService(HttpRouter.RouteContext, { + params: {}, + route: {} as never, + }), + Effect.provide( + relayEnvironmentAuthLayer.pipe( + Layer.provide(Layer.succeed(EnvironmentCredentials.EnvironmentCredentials, credentials)), + ), + ), + Effect.scoped, + ); + }); +}); + +describe("relay request tracing", () => { + it.effect( + "does not parent endpoint spans to an ambient parent captured while building handlers", + () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const ambientParent = Tracer.externalSpan({ + traceId: "00000000000000000000000000000001", + spanId: "0000000000000001", + sampled: true, + }); + const endpoint = yield* withoutCapturedParentSpan( + Effect.context().pipe( + Effect.map((capturedContext: Context.Context) => + Effect.succeed(HttpServerResponse.empty({ status: 204 })).pipe( + Effect.withSpan("relay.test.endpoint"), + Effect.provideContext(capturedContext), + ), + ), + ), + ).pipe(Effect.provideService(Tracer.ParentSpan, ambientParent)); + const request = HttpServerRequest.fromWeb( + new Request("https://relay.test/v1/mobile/devices?client=mobile", { + method: "POST", + }), + ); + + yield* traceRelayHttpRequestWith(endpoint, Layer.succeed(Tracer.Tracer, tracer)).pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + ); + + expect(spans.map((span) => span.name)).toEqual(["http.server POST", "relay.test.endpoint"]); + expect(spans[0]?.kind).toBe("server"); + expect(spans[0]?.attributes.get("url.path")).toBe("/v1/mobile/devices"); + expect(spans[0]?.attributes.get("http.response.status_code")).toBe(204); + expect(Option.isNone(spans[0]!.parent)).toBe(true); + expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId); + }), + ); +}); + +describe("relay routing fallback", () => { + it.effect("redirects the relay root to the API docs", () => + Effect.gen(function* () { + const request = HttpServerRequest.fromWeb(new Request("https://relay.test/")); + const httpEffect = yield* HttpRouter.toHttpEffect( + Layer.mergeAll(relayDocsRedirectRoute, relayNotFoundRoute, relayCors), + ); + const response = yield* httpEffect.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + ); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe("/docs"); + expect(response.headers["access-control-allow-origin"]).toBe("*"); + }).pipe(Effect.scoped), + ); + + it.effect("returns a CORS-compatible 404 response for unmatched paths", () => + Effect.gen(function* () { + const request = HttpServerRequest.fromWeb( + new Request("https://relay.test/v1/environmentsd", { method: "GET" }), + ); + const httpEffect = yield* HttpRouter.toHttpEffect(Layer.merge(relayNotFoundRoute, relayCors)); + const response = yield* httpEffect.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + ); + + expect(response.status).toBe(404); + expect(response.headers["access-control-allow-origin"]).toBe("*"); + }).pipe(Effect.scoped), + ); +}); diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts new file mode 100644 index 00000000000..3a9e59a6719 --- /dev/null +++ b/infra/relay/src/http/Api.ts @@ -0,0 +1,1109 @@ +import { createClerkClient, verifyToken } from "@clerk/backend"; +import { sql as drizzleSql } from "drizzle-orm"; +import * as Data from "effect/Data"; +import * as Crypto from "effect/Crypto"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Record from "effect/Record"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Tracer from "effect/Tracer"; +import * as HttpEffect from "effect/unstable/http/HttpEffect"; +import * as HttpMiddleware from "effect/unstable/http/HttpMiddleware"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; +import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; + +import { + RelayApi, + RelayAgentActivityPublishProofExpiredError, + RelayAgentActivityPublishProofInvalidError, + RelayClientAuth, + RelayClientPrincipal, + RelayAccessTokenType, + RelayDpopClientAuth, + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, + RelayMobileRegistrationScope, + RelayAuthInvalidError, + type RelayAuthInvalidReason, + RelayEnvironmentAuth, + RelayEnvironmentConnectNotAuthorizedError, + RelayEnvironmentEndpointTimedOutError, + RelayEnvironmentEndpointUnavailableError, + RelayEnvironmentLinkFailedError, + RelayEnvironmentLinkProofExpiredError, + RelayEnvironmentLinkProofInvalidError, + RelayEnvironmentLinkUnavailableError, + RelayEnvironmentPrincipal, + type RelayEnvironmentConnectRequest, + type RelayDpopAccessTokenScope, + RelayInternalError, +} from "@t3tools/contracts/relay"; +import { normalizeRelayIssuer } from "@t3tools/shared/relayJwt"; + +import * as DeliveryAttempts from "../agentActivity/DeliveryAttempts.ts"; +import * as AgentActivityRows from "../agentActivity/AgentActivityRows.ts"; +import * as Devices from "../agentActivity/Devices.ts"; +import * as DpopProofs from "../auth/DpopProofs.ts"; +import * as RelayTokens from "../auth/RelayTokens.ts"; +import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; +import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; +import * as LiveActivities from "../agentActivity/LiveActivities.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts"; +import * as EnvironmentConnector from "../environments/EnvironmentConnector.ts"; +import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; +import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; +import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; +import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; +import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; +import { withSpanAttributes } from "../observability.ts"; +import { RelayDb } from "../db.ts"; + +const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; +const relayCorsAllowedHeaders = [ + "authorization", + "b3", + "traceparent", + "content-type", + "dpop", +] as const; +const relayCorsExposedHeaders = [ + "traceparent", + "x-t3-relay-auth-failure", + "www-authenticate", +] as const; + +const relayCorsHeaders = { + "access-control-allow-origin": "*", + "access-control-expose-headers": relayCorsExposedHeaders.join(","), +} as const; + +const relayCorsPreflightHeaders = { + ...relayCorsHeaders, + "access-control-allow-methods": relayCorsAllowedMethods.join(","), + "access-control-allow-headers": relayCorsAllowedHeaders.join(","), + "access-control-max-age": "86400", +} as const; + +const appendRelayCredentialResponseHeaders = HttpEffect.appendPreResponseHandler( + (_request, response) => + Effect.succeed( + HttpServerResponse.setHeaders(response, { + "cache-control": "no-store", + pragma: "no-cache", + }), + ), +); + +const appendRelayDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed( + response.status === 401 + ? HttpServerResponse.setHeader(response, "www-authenticate", "DPoP") + : response, + ), +); + +const appendRelayTraceContextResponseHeader = Effect.gen(function* () { + const span = yield* Effect.currentParentSpan; + if (span._tag !== "Span") { + return; + } + const traceparent = HttpTraceContext.toHeaders(span).traceparent; + if (traceparent === undefined) { + return; + } + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "traceparent", traceparent)), + ); +}).pipe(Effect.ignore); + +export const relayCors = HttpRouter.middleware( + Effect.fnUntraced(function* ( + httpEffect: Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + HttpServerRequest.HttpServerRequest | R + >, + ) { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.method === "OPTIONS") { + return HttpServerResponse.empty({ + status: 204, + headers: relayCorsPreflightHeaders, + }); + } + const response = yield* httpEffect; + return HttpServerResponse.setHeaders(response, relayCorsHeaders); + }), + { global: true }, +); + +export const relayNotFoundRoute = HttpRouter.add( + "*", + "/*", + HttpServerResponse.empty({ status: 404 }), +); + +export const relayDocsRedirectRoute = HttpRouter.add( + "GET", + "/", + HttpServerResponse.redirect("/docs"), +); + +export const traceRelayHttpRequest = ( + httpEffect: Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + HttpServerRequest.HttpServerRequest | R + >, +) => + // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first. + HttpMiddleware.tracer( + appendRelayTraceContextResponseHeader.pipe(Effect.andThen(httpEffect)), + ).pipe(Effect.ensuring(Effect.yieldNow)); + +export const traceRelayHttpRequestWith = ( + httpEffect: Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + HttpServerRequest.HttpServerRequest | R + >, + tracerLayer: Layer.Layer, +) => traceRelayHttpRequest(httpEffect).pipe(Effect.provide(tracerLayer)); + +export const withoutCapturedParentSpan = ( + effect: Effect.Effect, +): Effect.Effect => + Effect.withFiber((fiber) => { + const context = fiber.context; + // HttpApiBuilder captures its build context for route handlers; an event parent would outlive export. + fiber.setContext(Context.omit(Tracer.ParentSpan)(context)); + return effect.pipe(Effect.ensuring(Effect.sync(() => fiber.setContext(context)))); + }); + +export const relayClientAuthLayer = Layer.effect( + RelayClientAuth, + Effect.gen(function* () { + const config = yield* RelayConfiguration.RelayConfiguration; + return { + clientBearer: Effect.fn("relay.auth.client.bearer")(function* (httpEffect, { credential }) { + const token = readHttpAuthorizationCredential(credential); + const verified = yield* verifyRelayClientBearerToken(config, token).pipe( + Effect.tapError((error) => + Effect.annotateCurrentSpan( + "relay.auth.clerk_verification_failure", + clerkVerificationFailureReason(error.cause), + ), + ), + Effect.catch(() => relayAuthInvalidError("invalid_bearer")), + ); + if (!verified.sub) { + yield* Effect.annotateCurrentSpan({ + "relay.auth.clerk_verification_failure": "missing_subject", + }); + return yield* relayAuthInvalidError("invalid_bearer"); + } + yield* Effect.annotateCurrentSpan({ + "relay.auth.mode": verified.mode, + "relay.auth.subject": verified.sub, + }); + + return yield* httpEffect.pipe( + withSpanAttributes({ "user.id": verified.sub }), + Effect.provideService(RelayClientPrincipal, { + userId: verified.sub, + token, + }), + ); + }), + }; + }), +); + +export const relayEnvironmentAuthLayer = Layer.effect( + RelayEnvironmentAuth, + Effect.gen(function* () { + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + return { + environmentBearer: Effect.fn("relay.auth.environment.bearer")(function* ( + httpEffect, + { credential }, + ) { + const token = readHttpAuthorizationCredential(credential); + const principal = yield* credentials + .authenticate(token) + .pipe( + Effect.catchTag("EnvironmentCredentialAuthenticatePersistenceError", () => + relayInternalErrorResponse("persistence_failed"), + ), + ); + if (principal._tag === "None") { + return yield* relayAuthInvalidError("not_authorized"); + } + yield* Effect.annotateCurrentSpan({ + "relay.auth.mode": "environment_credential", + }); + return yield* httpEffect.pipe( + withSpanAttributes({ + "relay.environment_id": principal.value.environmentId, + }), + Effect.provideService(RelayEnvironmentPrincipal, principal.value), + ); + }), + }; + }), +); + +export const relayDpopClientAuthLayer = Layer.effect( + RelayDpopClientAuth, + Effect.gen(function* () { + const relayTokens = yield* RelayTokens.RelayTokens; + return { + relayDpop: Effect.fn("relay.auth.dpop_client")(function* (httpEffect, { credential }) { + yield* appendRelayDpopChallengeHeader; + const request = yield* HttpServerRequest.HttpServerRequest; + if (!isDpopAuthorizationHeader(request.headers.authorization)) { + return yield* relayAuthInvalidError("invalid_bearer"); + } + const token = readHttpAuthorizationCredential(credential); + const now = yield* DateTime.now; + const verified = yield* relayTokens.verifyDpopAccessToken({ + token, + nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + }); + if (!verified) { + return yield* relayAuthInvalidError("invalid_bearer"); + } + yield* Effect.annotateCurrentSpan({ + "relay.auth.mode": "dpop", + "relay.auth.subject": verified.sub, + }); + return yield* httpEffect.pipe( + withSpanAttributes({ "user.id": verified.sub }), + Effect.provideService(RelayClientPrincipal, { + userId: verified.sub, + token, + proofKeyThumbprint: verified.cnf.jkt, + dpopScopes: verified.scope, + }), + ); + }), + }; + }), +); + +function isDpopAuthorizationHeader(value: string | undefined): boolean { + return /^DPoP +/iu.test(value ?? ""); +} + +function readHttpAuthorizationCredential(credential: Redacted.Redacted): string { + // Effect beta.73 leaves the scheme separator in decoded HTTP credentials. + return Redacted.value(credential).trimStart(); +} + +export const metadataApi = HttpApiBuilder.group( + RelayApi, + "metadata", + Effect.fnUntraced(function* (handlers) { + const settings = yield* RelayConfiguration.RelayConfiguration; + const issuer = normalizeRelayIssuer(settings.relayIssuer); + const scopes = [ + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, + RelayMobileRegistrationScope, + ] as const; + return handlers + .handle("authorizationServer", () => + Effect.succeed({ + issuer, + token_endpoint: `${issuer}/v1/client/dpop-token`, + grant_types_supported: ["urn:ietf:params:oauth:grant-type:token-exchange"], + token_endpoint_auth_methods_supported: ["none"], + dpop_signing_alg_values_supported: ["ES256"], + scopes_supported: scopes, + }), + ) + .handle("protectedResource", () => + Effect.succeed({ + resource: issuer, + authorization_servers: [issuer], + scopes_supported: scopes, + dpop_bound_access_tokens_required: true, + dpop_signing_alg_values_supported: ["ES256"], + }), + ); + }), +); + +export const healthApi = HttpApiBuilder.group( + RelayApi, + "health", + Effect.fnUntraced(function* (handlers) { + const db = yield* RelayDb; + return handlers.handle( + "health", + Effect.fn("relay.api.health")( + function* () { + const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis); + yield* db.execute(drizzleSql`SELECT 1`); + const completedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis); + yield* Effect.logInfo("relay health db probe completed", { + durationMs: completedAt - startedAt, + }); + return { ok: true, service: "relay" as const }; + }, + Effect.catch(() => relayInternalErrorResponse("database_unavailable")), + ), + ); + }), +); + +export const mobileApi = HttpApiBuilder.group( + RelayApi, + "mobile", + Effect.fnUntraced(function* (handlers) { + const registrations = yield* MobileRegistrations.MobileRegistrations; + const dpopProofs = yield* DpopProofs.DpopProofReplay; + return handlers + .handle( + "registerDevice", + Effect.fn("relay.api.mobile.registerDevice")(function* (args) { + const { payload } = args; + const { userId, token } = yield* RelayClientPrincipal; + const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); + yield* requireDpopThumbprint(proofKeyThumbprint, { + expectedAccessToken: token, + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + return yield* registrations.registerDevice({ userId, payload }); + }, mapRelayCommonApiErrors("invalid_dpop")), + ) + .handle( + "registerLiveActivity", + Effect.fn("relay.api.mobile.registerLiveActivity")(function* (args) { + const { payload } = args; + const { userId, token } = yield* RelayClientPrincipal; + const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); + yield* requireDpopThumbprint(proofKeyThumbprint, { + expectedAccessToken: token, + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + return yield* registrations.registerLiveActivity({ userId, payload }); + }, mapRelayCommonApiErrors("invalid_dpop")), + ) + .handle( + "unregisterDevice", + Effect.fn("relay.api.mobile.unregisterDevice")(function* (args) { + const { params } = args; + const { userId, token } = yield* RelayClientPrincipal; + const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); + yield* requireDpopThumbprint(proofKeyThumbprint, { + expectedAccessToken: token, + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + return yield* registrations.unregisterDevice({ userId, deviceId: params.deviceId }); + }, mapRelayCommonApiErrors("invalid_dpop")), + ); + }), +); + +export const clientApi = HttpApiBuilder.group( + RelayApi, + "client", + Effect.fnUntraced(function* (handlers) { + const config = yield* RelayConfiguration.RelayConfiguration; + const crypto = yield* Crypto.Crypto; + const relayTokens = yield* RelayTokens.RelayTokens; + const linker = yield* EnvironmentLinker.EnvironmentLinker; + const links = yield* EnvironmentLinks.EnvironmentLinks; + const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const devices = yield* Devices.Devices; + return handlers + .handle( + "listEnvironments", + Effect.fn("relay.api.client.listEnvironments")(function* () { + const { userId } = yield* RelayClientPrincipal; + const environments = yield* links.listForUser({ userId }); + return { environments }; + }, mapRelayCommonApiErrors("not_authorized")), + ) + .handle( + "listDevices", + Effect.fn("relay.api.client.listDevices")(function* () { + const { userId } = yield* RelayClientPrincipal; + return { devices: yield* devices.listForUser({ userId }) }; + }, mapRelayCommonApiErrors("not_authorized")), + ) + .handle( + "linkEnvironment", + Effect.fn("relay.api.client.linkEnvironment")( + function* (args) { + const { payload } = args; + yield* appendRelayCredentialResponseHeaders; + const { userId } = yield* RelayClientPrincipal; + const result = yield* linker.link({ userId, request: payload }); + return { + ok: true, + cloudUserId: userId, + environmentId: result.environmentId, + endpoint: result.endpoint, + endpointRuntime: result.endpointRuntime, + relayIssuer: config.relayIssuer, + environmentCredential: result.environmentCredential, + cloudMintPublicKey: config.cloudMintPublicKey, + }; + }, + mapErrorTags({ + EnvironmentLinkProofExpired: (_error, traceId) => + new RelayEnvironmentLinkProofExpiredError({ + code: "environment_link_proof_expired", + traceId, + }), + EnvironmentLinkProofInvalid: (linkError, traceId) => + new RelayEnvironmentLinkProofInvalidError({ + code: "environment_link_proof_invalid", + reason: linkError.reason, + traceId, + }), + ManagedEndpointProvisioningNotConfigured: (_error, traceId) => + new RelayEnvironmentLinkUnavailableError({ + code: "environment_link_unavailable", + reason: "managed_endpoint_not_configured", + traceId, + }), + ManagedEndpointProvisioningFailed: (_error, traceId) => + new RelayEnvironmentLinkUnavailableError({ + code: "environment_link_unavailable", + reason: "managed_endpoint_provisioning_failed", + traceId, + }), + ManagedEndpointOriginNotAllowed: (_error, traceId) => + new RelayEnvironmentLinkProofInvalidError({ + code: "environment_link_proof_invalid", + reason: "origin_not_allowed", + traceId, + }), + EnvironmentLinkUpsertPersistenceError: (_error, traceId) => + new RelayEnvironmentLinkFailedError({ + code: "environment_link_failed", + reason: "link_persistence_failed", + traceId, + }), + EnvironmentCredentialCreatePersistenceError: (_error, traceId) => + new RelayEnvironmentLinkFailedError({ + code: "environment_link_failed", + reason: "credential_persistence_failed", + traceId, + }), + DpopProofReplayPersistenceError: (_error, traceId) => + new RelayEnvironmentLinkFailedError({ + code: "environment_link_failed", + reason: "replay_persistence_failed", + traceId, + }), + }), + mapRelayCommonApiErrors("not_authorized"), + ), + ) + .handle( + "createEnvironmentLinkChallenge", + Effect.fn("relay.api.client.createEnvironmentLinkChallenge")(function* (args) { + yield* appendRelayCredentialResponseHeaders; + const { userId } = yield* RelayClientPrincipal; + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 5 }); + const jti = yield* crypto.randomUUIDv4.pipe( + Effect.catch(() => relayInternalErrorResponse("internal_error")), + ); + const challenge = yield* relayTokens + .issueLinkChallenge({ + userId, + request: args.payload, + jti, + issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000), + }) + .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))); + return { challenge, expiresAt: DateTime.formatIso(expiresAt) }; + }, mapRelayCommonApiErrors("not_authorized")), + ) + .handle( + "unlinkEnvironment", + Effect.fn("relay.api.client.unlinkEnvironment")(function* (args) { + const { params } = args; + const { userId } = yield* RelayClientPrincipal; + yield* managedEndpointProvider + .deprovision({ + userId, + environmentId: params.environmentId, + }) + .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))); + const link = yield* links.getForUser({ + userId, + environmentId: params.environmentId, + }); + if (link === null) { + return { ok: false }; + } + const unlinked = yield* links.revokeForUser({ + userId, + environmentId: params.environmentId, + }); + if (unlinked) { + yield* credentials.revokeForEnvironmentPublicKey({ + environmentId: link.environmentId, + environmentPublicKey: link.environmentPublicKey, + }); + } + return { ok: unlinked }; + }, mapRelayCommonApiErrors("not_authorized")), + ); + }), +); + +export const tokenApi = HttpApiBuilder.group( + RelayApi, + "token", + Effect.fnUntraced(function* (handlers) { + const config = yield* RelayConfiguration.RelayConfiguration; + const crypto = yield* Crypto.Crypto; + const dpopProofs = yield* DpopProofs.DpopProofReplay; + const relayTokens = yield* RelayTokens.RelayTokens; + return handlers.handle( + "exchangeDpopAccessToken", + Effect.fn("relay.api.token.exchangeDpopAccessToken")(function* (args) { + yield* appendRelayCredentialResponseHeaders; + const issuer = normalizeRelayIssuer(config.relayIssuer); + const requestedScopes = relayTokens.resolveDpopAccessTokenScopes({ + clientId: args.payload.client_id, + scope: args.payload.scope, + }); + yield* Effect.annotateCurrentSpan({ + "relay.auth.mode": "clerk_bearer_token_exchange", + "relay.oauth.client_id": args.payload.client_id, + "relay.oauth.scopes": args.payload.scope, + }); + if (args.payload.resource !== issuer || requestedScopes === null) { + return yield* new HttpApiError.Unauthorized({}); + } + + const verified = yield* verifyClerkBearerToken(config, args.payload.subject_token).pipe( + Effect.catch(() => relayAuthInvalidError("invalid_bearer")), + ); + if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience)) { + return yield* relayAuthInvalidError("invalid_bearer"); + } + const proofKeyThumbprint = yield* requireDpopProof().pipe( + Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), + ); + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { minutes: 5 }); + const jti = yield* crypto.randomUUIDv4.pipe( + Effect.catch(() => relayInternalErrorResponse("internal_error")), + ); + return { + access_token: yield* relayTokens + .issueDpopAccessToken({ + userId: verified.sub, + proofKeyThumbprint, + jti, + issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), + expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000), + clientId: args.payload.client_id, + scopes: requestedScopes, + }) + .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))), + issued_token_type: RelayAccessTokenType, + token_type: "DPoP" as const, + expires_in: 300, + scope: encodeOAuthScope(requestedScopes), + }; + }, mapRelayCommonApiErrors("invalid_dpop")), + ); + }), +); + +export const dpopClientApi = HttpApiBuilder.group( + RelayApi, + "dpopClient", + Effect.fnUntraced(function* (handlers) { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const dpopProofs = yield* DpopProofs.DpopProofReplay; + return handlers + .handle( + "connectEnvironment", + Effect.fn("relay.api.dpopClient.connectEnvironment")( + function* (args) { + const { params, payload } = args; + yield* appendRelayCredentialResponseHeaders; + const { userId, token } = yield* RelayClientPrincipal; + const proofKeyThumbprint = yield* requireDpopPrincipalScope("environment:connect"); + const requestedThumbprint = resolveConnectClientKeyThumbprint(payload); + if (!requestedThumbprint || requestedThumbprint !== proofKeyThumbprint) { + return yield* new HttpApiError.Unauthorized({}); + } + const clientProofKeyThumbprint = yield* requireDpopThumbprint(proofKeyThumbprint, { + expectedAccessToken: token, + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + return yield* connector.connect({ + userId, + environmentId: params.environmentId, + clientProofKeyThumbprint, + ...(payload.deviceId ? { deviceId: payload.deviceId } : {}), + }); + }, + mapRelayCommonApiErrors("invalid_dpop"), + mapErrorTags({ + EnvironmentConnectNotAuthorized: (_error, traceId) => + new RelayEnvironmentConnectNotAuthorizedError({ + code: "environment_connect_not_authorized", + traceId, + }), + EnvironmentMintRequestFailed: (_error, traceId) => + new RelayEnvironmentEndpointUnavailableError({ + code: "environment_endpoint_unavailable", + reason: "endpoint_request_failed", + traceId, + }), + EnvironmentMintRequestTimedOut: (_error, traceId) => + new RelayEnvironmentEndpointTimedOutError({ + code: "environment_endpoint_timed_out", + traceId, + }), + EnvironmentMintResponseInvalid: (_error, traceId) => + new RelayEnvironmentEndpointUnavailableError({ + code: "environment_endpoint_unavailable", + reason: "endpoint_response_invalid", + traceId, + }), + }), + ), + ) + .handle( + "getEnvironmentStatus", + Effect.fn("relay.api.dpopClient.getEnvironmentStatus")( + function* (args) { + const { params } = args; + const { userId, token } = yield* RelayClientPrincipal; + const proofKeyThumbprint = yield* requireDpopPrincipalScope("environment:status"); + yield* requireDpopThumbprint(proofKeyThumbprint, { + expectedAccessToken: token, + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + return yield* connector.status({ + userId, + environmentId: params.environmentId, + }); + }, + mapRelayCommonApiErrors("invalid_dpop"), + mapErrorTags({ + EnvironmentConnectNotAuthorized: (_error, traceId) => + new RelayEnvironmentConnectNotAuthorizedError({ + code: "environment_connect_not_authorized", + traceId, + }), + EnvironmentMintRequestFailed: (_error, traceId) => + new RelayEnvironmentEndpointUnavailableError({ + code: "environment_endpoint_unavailable", + reason: "endpoint_request_failed", + traceId, + }), + EnvironmentMintRequestTimedOut: (_error, traceId) => + new RelayEnvironmentEndpointTimedOutError({ + code: "environment_endpoint_timed_out", + traceId, + }), + EnvironmentMintResponseInvalid: (_error, traceId) => + new RelayEnvironmentEndpointUnavailableError({ + code: "environment_endpoint_unavailable", + reason: "endpoint_response_invalid", + traceId, + }), + }), + ), + ); + }), +); + +export const serverApi = HttpApiBuilder.group( + RelayApi, + "server", + Effect.fnUntraced(function* (handlers) { + const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; + const publishSignatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; + return handlers.handle( + "publishAgentActivity", + Effect.fn("relay.api.server.publishAgentActivity")( + function* (args) { + const { params, payload } = args; + const principal = yield* RelayEnvironmentPrincipal; + if (principal.environmentId !== params.environmentId) { + return yield* new HttpApiError.Unauthorized({}); + } + yield* publishSignatures.verify({ + environmentId: params.environmentId, + environmentPublicKey: principal.environmentPublicKey, + threadId: params.threadId, + request: payload, + }); + return yield* publisher.publish({ + environmentId: params.environmentId, + environmentPublicKey: principal.environmentPublicKey, + threadId: params.threadId, + state: payload.state, + }); + }, + mapErrorTags({ + EnvironmentPublishPublicKeyMissing: (_error, traceId) => + new RelayAuthInvalidError({ + code: "auth_invalid", + reason: "not_authorized", + traceId, + }), + EnvironmentPublishSignatureExpired: (_error, traceId) => + new RelayAgentActivityPublishProofExpiredError({ + code: "agent_activity_publish_proof_expired", + traceId, + }), + EnvironmentPublishSignatureInvalid: (_error, traceId) => + new RelayAgentActivityPublishProofInvalidError({ + code: "agent_activity_publish_proof_invalid", + reason: "invalid_signature_or_payload", + traceId, + }), + DpopProofReplayPersistenceError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "persistence_failed", + traceId, + }), + ApnsDeliveryJobInvalid: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobExpired: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryJobClaimInFlight: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "internal_error", + traceId, + }), + ApnsDeliveryQueueSendError: (_error, traceId) => + new RelayInternalError({ + code: "internal_error", + reason: "upstream_unavailable", + traceId, + }), + }), + mapRelayCommonApiErrors("not_authorized"), + ), + ); + }), +); + +class ClerkTokenVerificationFailed extends Data.TaggedError("ClerkTokenVerificationFailed")<{ + readonly cause: unknown; +}> {} + +const isHttpUnauthorized = Schema.is(HttpApiError.Unauthorized); + +const currentTraceId = Effect.currentParentSpan.pipe( + Effect.map((span) => span.traceId), + Effect.orElseSucceed(() => "unavailable"), +); + +const COMMON_AUTH_INVALID_REASONS = [ + Devices.DeviceRegistrationPersistenceError, + Devices.DeviceUnregistrationPersistenceError, + Devices.DeviceListPersistenceError, + LiveActivities.LiveActivityRegistrationPersistenceError, + EnvironmentLinks.EnvironmentLinkUserListPersistenceError, + EnvironmentLinks.EnvironmentPublicKeyListPersistenceError, + EnvironmentLinks.EnvironmentLinkListPersistenceError, + EnvironmentLinks.EnvironmentLinkLookupPersistenceError, + EnvironmentLinks.EnvironmentLinkRevokePersistenceError, + ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError, + EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError, + EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError, + DpopProofs.DpopProofReplayPersistenceError, + LiveActivities.LiveActivityTargetListPersistenceError, + AgentActivityRows.AgentActivityRowUpsertPersistenceError, + AgentActivityRows.AgentActivityRowDeletePersistenceError, + AgentActivityRows.AgentActivityRowListPersistenceError, + LiveActivities.LiveActivityDeliveryMarkPersistenceError, + DeliveryAttempts.DeliveryAttemptRecordPersistenceError, +] as const; +type RelayCommonPersistenceError = InstanceType<(typeof COMMON_AUTH_INVALID_REASONS)[number]>; + +type MapRelayCommonApiError = + | Exclude + | (Extract extends never ? never : RelayAuthInvalidError) + | (Extract extends never ? never : RelayInternalError); + +function isRelayCommonPersistenceError(error: unknown): error is RelayCommonPersistenceError { + return COMMON_AUTH_INVALID_REASONS.some((ErrorType) => error instanceof ErrorType); +} + +function relayInternalErrorResponse(reason: RelayInternalError["reason"]) { + return currentTraceId.pipe( + Effect.flatMap((traceId) => + Effect.fail(new RelayInternalError({ code: "internal_error", reason, traceId })), + ), + ); +} + +function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) { + const mapError = Effect.fnUntraced(function* (error: E) { + const traceId = yield* currentTraceId; + if (isHttpUnauthorized(error)) { + return yield* Effect.fail( + new RelayAuthInvalidError({ + code: "auth_invalid", + reason: authReason, + traceId, + }) as MapRelayCommonApiError, + ); + } + if (isRelayCommonPersistenceError(error)) { + return yield* Effect.fail( + new RelayInternalError({ + code: "internal_error", + reason: "persistence_failed", + traceId, + }) as MapRelayCommonApiError, + ); + } + + return yield* Effect.fail(error as MapRelayCommonApiError); + }); + + return ( + effect: Effect.Effect, + ): Effect.Effect, R> => effect.pipe(Effect.catch(mapError)); +} + +type TaggedErrorTag = Extract["_tag"]; + +type MapErrorTagCases = { + readonly [K in TaggedErrorTag]+?: ( + error: Extract, + traceId: string, + ) => unknown; +}; + +type MappedTagError = Cases[keyof Cases] extends ( + ...args: ReadonlyArray +) => infer Error + ? Error + : never; + +type CatchTagCases = { + readonly [K in TaggedErrorTag]+?: ( + error: Extract, + ) => Effect.Effect>; +} & (unknown extends E ? {} : { readonly [K in Exclude>]: never }); + +function mapErrorTags< + E, + Cases extends MapErrorTagCases & + (unknown extends E ? {} : { readonly [K in Exclude>]: never }), +>(cases: Cases) { + const catchCases = Record.map( + cases as Record.ReadonlyRecord< + string, + (error: never, traceId: string) => MappedTagError + >, + (makeError) => (error: never) => + currentTraceId.pipe(Effect.flatMap((traceId) => Effect.fail(makeError(error, traceId)))), + ) as CatchTagCases; + + return ( + self: Effect.Effect, + ): Effect.Effect | MappedTagError, R> => + // @effect-diagnostics-next-line unsafeEffectTypeAssertion:off + Effect.catchTags(self, catchCases) as Effect.Effect< + A, + Exclude | MappedTagError, + R + >; +} + +function resolveConnectClientKeyThumbprint(payload: RelayEnvironmentConnectRequest): string | null { + const requestedThumbprint = payload.clientKeyThumbprint ?? payload.clientProofKeyThumbprint; + if (!requestedThumbprint) { + return null; + } + if ( + payload.clientKeyThumbprint && + payload.clientProofKeyThumbprint && + payload.clientKeyThumbprint !== payload.clientProofKeyThumbprint + ) { + return null; + } + return requestedThumbprint; +} + +function safeAuthFailureReason(value: string): string { + return /^[a-z0-9._-]+$/i.test(value) ? value : "unknown"; +} + +function clerkVerificationFailureReason(cause: unknown): string { + if ( + cause instanceof Error && + (cause.message.startsWith("Invalid JWT audience claim ") || + cause.message.startsWith("Invalid JWT audience claim array ")) + ) { + return "audience_mismatch"; + } + if (typeof cause === "object" && cause !== null && "reason" in cause) { + const reason = (cause as { readonly reason?: unknown }).reason; + if (typeof reason === "string" && reason.length > 0) { + return safeAuthFailureReason(reason); + } + } + if (cause instanceof Error && cause.name) { + return safeAuthFailureReason(cause.name); + } + return "unknown"; +} + +function hasExpectedClerkAudience(audience: unknown, expectedAudience: string): boolean { + return typeof audience === "string" + ? audience === expectedAudience + : Array.isArray(audience) && + audience.some((entry) => typeof entry === "string" && entry === expectedAudience); +} + +function verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationShape, token: string) { + return Effect.tryPromise({ + try: () => + verifyToken(token, { + secretKey: Redacted.value(config.clerkSecretKey), + audience: config.clerkJwtAudience, + }), + catch: (cause) => new ClerkTokenVerificationFailed({ cause }), + }).pipe( + Effect.withSpan("verify_clerk_bearer_token", { + attributes: { "relay.auth.token_length": token.length }, + }), + ); +} + +function verifyClerkOAuthBearerToken( + config: RelayConfiguration.RelayConfigurationShape, + token: string, +) { + return Effect.tryPromise({ + try: async () => { + const client = createClerkClient({ + secretKey: Redacted.value(config.clerkSecretKey), + publishableKey: config.clerkPublishableKey, + }); + const state = await client.authenticateRequest( + new Request(config.relayIssuer, { + headers: { authorization: `Bearer ${token}` }, + }), + { acceptsToken: "oauth_token" }, + ); + const auth = state.toAuth(); + if (!state.isAuthenticated || !auth.userId) { + throw new Error("Clerk OAuth token is not authenticated."); + } + return { sub: auth.userId }; + }, + catch: (cause) => new ClerkTokenVerificationFailed({ cause }), + }); +} + +export function verifyRelayClientBearerToken( + config: RelayConfiguration.RelayConfigurationShape, + token: string, +) { + return verifyClerkBearerToken(config, token).pipe( + Effect.flatMap((verified) => + verified.sub && hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience) + ? Effect.succeed({ sub: verified.sub, mode: "clerk_session_bearer" as const }) + : Effect.fail(new ClerkTokenVerificationFailed({ cause: "missing_relay_audience" })), + ), + Effect.catch(() => + verifyClerkOAuthBearerToken(config, token).pipe( + Effect.map((verified) => ({ ...verified, mode: "clerk_oauth_bearer" as const })), + ), + ), + ); +} + +const requireDpopPrincipalScope = Effect.fn("relay.api.require_dpop_principal_scope")(function* ( + scope: RelayDpopAccessTokenScope, +) { + yield* Effect.annotateCurrentSpan({ "relay.dpop.required_scope": scope }); + const principal = yield* RelayClientPrincipal; + if (!principal.proofKeyThumbprint || !principal.dpopScopes?.includes(scope)) { + return yield* new HttpApiError.Unauthorized({}); + } + return principal.proofKeyThumbprint; +}); + +const requireDpopThumbprint = Effect.fn("relay.api.require_dpop_thumbprint")(function* ( + expectedThumbprint: string, + options?: { + readonly expectedAccessToken?: string; + }, +) { + const request = yield* HttpServerRequest.HttpServerRequest; + const now = yield* DateTime.now; + const url = HttpServerRequest.toURL(request); + if (url._tag === "None") { + return yield* new HttpApiError.Unauthorized({}); + } + const dpopProofs = yield* DpopProofs.DpopProofReplay; + return yield* dpopProofs.verifyAndConsume({ + proof: request.headers.dpop, + method: request.method, + url: url.value.href, + now, + expectedThumbprint, + ...(options?.expectedAccessToken ? { expectedAccessToken: options.expectedAccessToken } : {}), + }); +}); + +const requireDpopProof = Effect.fn("relay.api.require_dpop_proof")(function* (options?: { + readonly expectedAccessToken?: string; +}) { + const request = yield* HttpServerRequest.HttpServerRequest; + const now = yield* DateTime.now; + const url = HttpServerRequest.toURL(request); + if (url._tag === "None") { + return yield* new HttpApiError.Unauthorized({}); + } + const dpopProofs = yield* DpopProofs.DpopProofReplay; + return yield* dpopProofs.verifyAndConsume({ + proof: request.headers.dpop, + method: request.method, + url: url.value.href, + now, + ...(options?.expectedAccessToken ? { expectedAccessToken: options.expectedAccessToken } : {}), + }); +}); + +const relayAuthInvalidError = Effect.fnUntraced(function* (reason: RelayAuthInvalidReason) { + const traceId = yield* currentTraceId; + yield* Effect.annotateCurrentSpan({ + "relay.trace_id": traceId, + "relay.error.outbound_tag": "RelayAuthInvalidError", + "relay.error.outbound_reason": reason, + }); + return yield* new RelayAuthInvalidError({ code: "auth_invalid", reason, traceId }); +}); diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts new file mode 100644 index 00000000000..b54567d1f0a --- /dev/null +++ b/infra/relay/src/observability.ts @@ -0,0 +1,76 @@ +import * as Alchemy from "alchemy"; +import * as Axiom from "alchemy/Axiom"; +import * as Output from "alchemy/Output"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; + +import { relayResourceNameForStage } from "./deploymentConfig.ts"; + +const relayRecentSpansQuery = (dataset: string) => + [ + `['${dataset}']`, + `| where isnotnull(span_id) or isnotnull(trace_id)`, + `| extend requestMethod = column_ifexists('attributes.http.request.method', ''), path = column_ifexists('attributes.url.path', ''), endpoint = column_ifexists('attributes.http.route', ''), statusCode = column_ifexists('attributes.http.response.status_code', 0), customAttributes = column_ifexists('attributes.custom', dynamic({}))`, + `| extend userId = customAttributes['user']['id']`, + `| project _time, name, trace_id, span_id, duration, requestMethod, path, statusCode, endpoint, userId`, + `| order by _time desc`, + `| limit 200`, + ].join("\n"); + +export const RelayObservability = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + const traces = yield* Axiom.Dataset("RelayTracesDataset", { + name: relayResourceNameForStage("t3-code-relay-traces", stage), + kind: "otel:traces:v1", + description: "T3 Code relay Worker HTTP request spans.", + retentionDays: 30, + useRetentionPeriod: true, + }); + + const ingestToken = yield* Axiom.ApiToken("RelayAxiomIngestToken", { + name: relayResourceNameForStage("t3-code-relay-otel-ingest", stage), + description: "Owned by Alchemy. Scoped OTLP ingest token for relay HTTP spans.", + datasetCapabilities: Output.map(traces.name, (dataset) => ({ + [dataset]: { ingest: ["create" as const] }, + })), + }); + + yield* Axiom.View("RelayRecentSpansView", { + name: relayResourceNameForStage("t3-code-relay-recent-spans", stage), + description: "Recent relay HTTP request spans.", + datasets: [traces.name], + aplQuery: Output.map(traces.name, relayRecentSpansQuery), + }); + + return { traces, ingestToken } as const; +}); + +export const withSpanAttributes = + (attributes: Record) => + (effect: Effect.Effect): Effect.Effect => + Effect.annotateCurrentSpan(attributes).pipe( + Effect.andThen(effect.pipe(Effect.annotateSpans(attributes))), + ); + +export const makeRelayTraceLayer = (input: { + readonly tracesEndpoint: string; + readonly tracesDatasetName: string; + readonly ingestToken: Redacted.Redacted; +}) => + OtlpTracer.layer({ + url: input.tracesEndpoint, + resource: { + serviceName: "t3-code-relay-worker", + attributes: { + "service.runtime": "cloudflare-worker", + "service.component": "relay", + }, + }, + headers: { + Authorization: `Bearer ${Redacted.value(input.ingestToken)}`, + "X-Axiom-Dataset": input.tracesDatasetName, + }, + exportInterval: "1 second", + }).pipe(Layer.provide(OtlpSerialization.layerJson)); diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts new file mode 100644 index 00000000000..ab3d2dfd97a --- /dev/null +++ b/infra/relay/src/persistence/schema.ts @@ -0,0 +1,184 @@ +import type { + RelayAgentActivityAggregateState, + RelayAgentActivityState, + RelayAgentAwarenessPreferences, +} from "@t3tools/contracts/relay"; +import { + boolean, + index, + integer, + jsonb, + pgTable, + primaryKey, + text, + uniqueIndex, + varchar, +} from "drizzle-orm/pg-core"; + +export const relayMobileDevices = pgTable( + "relay_mobile_devices", + { + userId: varchar("user_id", { length: 255 }).notNull(), + deviceId: varchar("device_id", { length: 255 }).notNull(), + label: text("label").notNull().default("iOS device"), + platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(), + iosMajorVersion: integer("ios_major_version").notNull(), + appVersion: varchar("app_version", { length: 64 }), + pushToken: text("push_token"), + pushToStartToken: text("push_to_start_token"), + preferencesJson: jsonb("preferences_json").notNull().$type(), + createdAt: varchar("created_at", { length: 64 }).notNull(), + updatedAt: varchar("updated_at", { length: 64 }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.userId, table.deviceId] }), + index("idx_relay_mobile_devices_user").on(table.userId), + uniqueIndex("idx_relay_mobile_devices_push_token").on(table.pushToken), + uniqueIndex("idx_relay_mobile_devices_push_to_start_token").on(table.pushToStartToken), + ], +); + +export const relayLiveActivities = pgTable( + "relay_live_activities", + { + userId: varchar("user_id", { length: 255 }).notNull(), + deviceId: varchar("device_id", { length: 255 }).notNull(), + activityPushToken: text("activity_push_token"), + remoteStartQueuedAt: varchar("remote_start_queued_at", { length: 64 }), + remoteStartedAt: varchar("remote_started_at", { length: 64 }), + endedAt: varchar("ended_at", { length: 64 }), + lastAggregateJson: jsonb("last_aggregate_json").$type(), + lastLiveActivityDeliveryAt: varchar("last_live_activity_delivery_at", { length: 64 }), + createdAt: varchar("created_at", { length: 64 }).notNull(), + updatedAt: varchar("updated_at", { length: 64 }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.userId, table.deviceId] }), + index("idx_relay_live_activities_user").on(table.userId), + uniqueIndex("idx_relay_live_activities_activity_push_token").on(table.activityPushToken), + ], +); + +export const relayEnvironmentLinks = pgTable( + "relay_environment_links", + { + userId: varchar("user_id", { length: 191 }).notNull(), + environmentId: varchar("environment_id", { length: 191 }).notNull(), + environmentLabel: text("environment_label").notNull().default("T3 Environment"), + environmentPublicKey: text("environment_public_key").notNull(), + endpointHttpBaseUrl: text("endpoint_http_base_url").notNull(), + endpointWsBaseUrl: text("endpoint_ws_base_url").notNull(), + endpointProviderKind: varchar("endpoint_provider_kind", { length: 32 }).notNull(), + notificationsEnabled: boolean("notifications_enabled").notNull().default(true), + liveActivitiesEnabled: boolean("live_activities_enabled").notNull().default(true), + managedTunnelsEnabled: boolean("managed_tunnels_enabled").notNull().default(false), + createdByDeviceId: varchar("created_by_device_id", { length: 191 }), + revokedAt: varchar("revoked_at", { length: 64 }), + createdAt: varchar("created_at", { length: 64 }).notNull(), + updatedAt: varchar("updated_at", { length: 64 }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.userId, table.environmentId] }), + index("idx_relay_environment_links_environment").on(table.environmentId, table.revokedAt), + ], +); + +export const relayManagedEndpointAllocations = pgTable( + "relay_managed_endpoint_allocations", + { + userId: varchar("user_id", { length: 191 }).notNull(), + environmentId: varchar("environment_id", { length: 191 }).notNull(), + hostname: text("hostname").notNull(), + tunnelId: varchar("tunnel_id", { length: 191 }), + tunnelName: text("tunnel_name").notNull(), + dnsRecordId: varchar("dns_record_id", { length: 191 }), + readyAt: varchar("ready_at", { length: 64 }), + createdAt: varchar("created_at", { length: 64 }).notNull(), + updatedAt: varchar("updated_at", { length: 64 }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.userId, table.environmentId] }), + uniqueIndex("idx_relay_managed_endpoint_allocations_hostname").on(table.hostname), + uniqueIndex("idx_relay_managed_endpoint_allocations_tunnel_name").on(table.tunnelName), + ], +); + +export const relayEnvironmentCredentials = pgTable( + "relay_environment_credentials", + { + credentialId: varchar("credential_id", { length: 64 }).primaryKey(), + environmentId: varchar("environment_id", { length: 191 }).notNull(), + environmentPublicKey: text("environment_public_key").notNull(), + credentialHash: varchar("credential_hash", { length: 191 }).notNull(), + revokedAt: varchar("revoked_at", { length: 64 }), + createdAt: varchar("created_at", { length: 64 }).notNull(), + updatedAt: varchar("updated_at", { length: 64 }).notNull(), + }, + (table) => [ + uniqueIndex("idx_relay_environment_credentials_hash").on(table.credentialHash), + index("idx_relay_environment_credentials_environment").on(table.environmentId, table.revokedAt), + index("idx_relay_environment_credentials_environment_key").on( + table.environmentId, + table.environmentPublicKey, + table.revokedAt, + ), + ], +); + +export const relayAgentActivityRows = pgTable( + "relay_agent_activity_rows", + { + environmentId: varchar("environment_id", { length: 191 }).notNull(), + environmentPublicKey: text("environment_public_key").notNull(), + threadId: varchar("thread_id", { length: 191 }).notNull(), + stateJson: jsonb("state_json").notNull().$type(), + updatedAt: varchar("updated_at", { length: 64 }).notNull(), + createdAt: varchar("created_at", { length: 64 }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.environmentId, table.environmentPublicKey, table.threadId] }), + index("idx_relay_agent_activity_rows_updated").on(table.updatedAt), + ], +); + +export const relayDeliveryAttempts = pgTable( + "relay_delivery_attempts", + { + id: varchar("id", { length: 36 }).primaryKey(), + createdAt: varchar("created_at", { length: 64 }).notNull(), + userId: varchar("user_id", { length: 255 }), + environmentId: varchar("environment_id", { length: 191 }), + threadId: varchar("thread_id", { length: 191 }), + deviceId: varchar("device_id", { length: 255 }), + kind: varchar("kind", { length: 64 }).notNull(), + sourceJobId: varchar("source_job_id", { length: 64 }), + tokenSuffix: varchar("token_suffix", { length: 16 }), + apnsStatus: integer("apns_status"), + apnsReason: text("apns_reason"), + apnsId: varchar("apns_id", { length: 128 }), + transportError: text("transport_error"), + }, + (table) => [ + index("idx_relay_delivery_attempts_environment").on( + table.environmentId, + table.threadId, + table.createdAt, + ), + uniqueIndex("idx_relay_delivery_attempts_source_job").on(table.sourceJobId), + ], +); + +export const relayDpopProofs = pgTable( + "relay_dpop_proofs", + { + thumbprint: varchar("thumbprint", { length: 128 }).notNull(), + jti: varchar("jti", { length: 255 }).notNull(), + iat: integer("iat").notNull(), + expiresAt: varchar("expires_at", { length: 64 }).notNull(), + createdAt: varchar("created_at", { length: 64 }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.thumbprint, table.jti] }), + index("idx_relay_dpop_proofs_expires_at").on(table.expiresAt), + ], +); diff --git a/infra/relay/src/queues.ts b/infra/relay/src/queues.ts new file mode 100644 index 00000000000..7275a8d9dbd --- /dev/null +++ b/infra/relay/src/queues.ts @@ -0,0 +1,7 @@ +import * as Cloudflare from "alchemy/Cloudflare"; + +export const RelayApnsDeliveryDeadLetterQueue = Cloudflare.Queue( + "RelayApnsDeliveryDeadLetterQueue", +); + +export const RelayApnsDeliveryQueue = Cloudflare.Queue("RelayApnsDeliveryQueue"); diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts new file mode 100644 index 00000000000..8f00cc452f5 --- /dev/null +++ b/infra/relay/src/worker.ts @@ -0,0 +1,280 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Config from "effect/Config"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as Etag from "effect/unstable/http/Etag"; +import * as HttpPlatform from "effect/unstable/http/HttpPlatform"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as HttpApiScalar from "effect/unstable/httpapi/HttpApiScalar"; + +import { RelayApi } from "@t3tools/contracts/relay"; + +import { + clientApi, + dpopClientApi, + healthApi, + metadataApi, + mobileApi, + relayClientAuthLayer, + relayDpopClientAuthLayer, + relayCors, + relayDocsRedirectRoute, + relayEnvironmentAuthLayer, + relayNotFoundRoute, + serverApi, + traceRelayHttpRequestWith, + tokenApi, + withoutCapturedParentSpan, +} from "./http/Api.ts"; +import { ManagedEndpointZone, RelayDeploymentConfig } from "./zone.ts"; +import { makeRelayTraceLayer, RelayObservability } from "./observability.ts"; +import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts"; +import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts"; +import * as Devices from "./agentActivity/Devices.ts"; +import * as DpopProofs from "./auth/DpopProofs.ts"; +import * as RelayTokens from "./auth/RelayTokens.ts"; +import * as EnvironmentCredentials from "./environments/EnvironmentCredentials.ts"; +import * as EnvironmentLinks from "./environments/EnvironmentLinks.ts"; +import * as ManagedEndpointAllocations from "./environments/ManagedEndpointAllocations.ts"; +import * as LiveActivities from "./agentActivity/LiveActivities.ts"; +import { RelayDb, RelayHyperdrive } from "./db.ts"; +import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts"; +import * as RelayConfiguration from "./Config.ts"; +import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; +import * as ApnsClient from "./agentActivity/ApnsClient.ts"; +import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts"; +import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts"; +import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts"; +import * as EnvironmentLinker from "./environments/EnvironmentLinker.ts"; +import * as EnvironmentPublishSignatures from "./environments/EnvironmentPublishSignatures.ts"; +import * as ManagedEndpointProvider from "./environments/ManagedEndpointProvider.ts"; +import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts"; + +const webcryptoLayer = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)), + digest: (algorithm, data) => + Effect.promise(async () => { + const input = new Uint8Array(data.length); + input.set(data); + return new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, input.buffer)); + }), + }), +); + +const httpPlatformNotSupportedLayer = Layer.succeed(HttpPlatform.HttpPlatform, { + fileResponse: () => Effect.die("Relay API does not serve filesystem responses"), + fileWebResponse: () => Effect.die("Relay API does not serve file responses"), +}); + +const relayApiLayer = Layer.mergeAll( + healthApi, + metadataApi, + mobileApi, + clientApi, + tokenApi, + dpopClientApi, + serverApi, +); + +const CloudMintKeyPair = Alchemy.KeyPair("CloudMintKeyPair"); +const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningSecret", { + bytes: 32, +}); + +export default class Api extends Cloudflare.Worker()( + "Api", + RelayDeploymentConfig.pipe( + Effect.map(({ relayPublicDomain }) => ({ + main: import.meta.filename, + compatibility: { + date: "2026-05-22", + flags: ["nodejs_compat"], + }, + domain: relayPublicDomain, + })), + Effect.orDie, + ), + Effect.gen(function* () { + // + // 1. Provision Infrastructure for the Worker to use + // + const { relayPublicOrigin, stage } = yield* RelayDeploymentConfig; + const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; + const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; + const cloudMintKeyPair = yield* CloudMintKeyPair; + const managedEndpointZone = yield* ManagedEndpointZone; + const randomApnsDeliveryJobSigningSecret = yield* ApnsDeliveryJobSigningSecret; + const observability = yield* RelayObservability; + + // + // 2. Create bindings + // + const environment = yield* Config.schema( + RelayConfiguration.ApnsEnvironment, + "APNS_ENVIRONMENT", + ); + const apnsTeamId = yield* Config.string("APNS_TEAM_ID"); + const apnsKeyId = yield* Config.string("APNS_KEY_ID"); + const apnsBundleId = yield* Config.string("APNS_BUNDLE_ID"); + const apnsPrivateKey = yield* Config.redacted("APNS_PRIVATE_KEY"); + const apnsDeliveryJobSigningSecret = yield* randomApnsDeliveryJobSigningSecret; + const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue); + + const axiomDatasetName = yield* observability.traces.name; + const axiomIngestToken = yield* observability.ingestToken.token; + const axiomTracesEndpoint = yield* observability.traces.otelTracesEndpoint; + + const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY"); + const clerkPublishableKey = yield* Config.string("CLERK_PUBLISHABLE_KEY"); + const clerkJwtAudience = yield* Config.string("CLERK_JWT_AUDIENCE"); + + const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; + const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; + const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayHyperdrive); + const db = yield* Drizzle.postgres(hyperdrive.connectionString); + + const managedEndpointTunnelBinding = yield* Cloudflare.TunnelReadWrite.bind(); + const managedEndpointDnsBinding = yield* Cloudflare.DnsReadWrite.bind(managedEndpointZone); + const managedEndpointZoneName = yield* managedEndpointZone.name; + + // + // 3. Runtime layers and app construction + // + const alchemyRuntimeContext = yield* Alchemy.RuntimeContext; + + const loadSettings = Effect.gen(function* () { + return RelayConfiguration.RelayConfiguration.of({ + relayIssuer: relayPublicOrigin, + apns: { + environment, + teamId: apnsTeamId, + keyId: apnsKeyId, + bundleId: apnsBundleId, + privateKey: apnsPrivateKey, + }, + apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret, + clerkSecretKey, + clerkPublishableKey, + clerkJwtAudience, + cloudMintPrivateKey: yield* cloudMintPrivateKey, + cloudMintPublicKey: yield* cloudMintPublicKey, + managedEndpointBaseDomain: yield* managedEndpointZoneName, + managedEndpointNamespace: stage, + }); + }); + + const relayTraceLayer = Layer.unwrap( + Effect.all({ + tracesDatasetName: axiomDatasetName, + tracesEndpoint: axiomTracesEndpoint, + ingestToken: axiomIngestToken, + }).pipe(Effect.map(makeRelayTraceLayer)), + ); + + const runtimeLayer = Layer.empty.pipe( + Layer.provideMerge(MobileRegistrations.layer), + Layer.provideMerge(AgentActivityPublisher.layer), + Layer.provideMerge(EnvironmentConnector.layer), + Layer.provideMerge(EnvironmentLinker.layer), + Layer.provideMerge(EnvironmentPublishSignatures.layer), + Layer.provideMerge( + ManagedEndpointProvider.layerCloudflareBindings( + managedEndpointTunnelBinding, + managedEndpointDnsBinding, + alchemyRuntimeContext, + ), + ), + Layer.provideMerge(DpopProofs.layer), + Layer.provideMerge(ApnsDeliveries.layer), + Layer.provideMerge(ApnsClient.layer), + Layer.provideMerge( + ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext), + ), + Layer.provideMerge(AgentActivityRows.layer), + Layer.provideMerge(Devices.layer), + Layer.provideMerge(EnvironmentCredentials.layer), + Layer.provideMerge( + Layer.mergeAll( + EnvironmentLinks.layer, + ManagedEndpointAllocations.ManagedEndpointAllocations.layer, + ), + ), + Layer.provideMerge(LiveActivities.layer), + Layer.provideMerge(DeliveryAttempts.layer), + Layer.provideMerge(RelayTokens.layer), + Layer.provideMerge(Layer.succeed(RelayDb, db)), + Layer.provideMerge(Layer.effect(RelayConfiguration.RelayConfiguration, loadSettings)), + Layer.provideMerge(webcryptoLayer), + ); + + const appLayer = relayApiLayer.pipe( + Layer.provideMerge(relayClientAuthLayer), + Layer.provideMerge(relayDpopClientAuthLayer), + Layer.provideMerge(relayEnvironmentAuthLayer), + Layer.provide(runtimeLayer), + ); + + yield* Cloudflare.messages(apnsDeliveryQueue, { + batchSize: 10, + maxRetries: 5, + maxWaitTime: "5 seconds", + retryDelay: "30 seconds", + // Alchemy beta.45 expects a resolved string here although Queue names are Outputs. + deadLetterQueue: apnsDeliveryDeadLetterQueue.queueName as unknown as string, + }).subscribe((stream) => + stream.pipe( + Stream.withSpan("relay.apn_delivery_queue.process_batch"), + Stream.runForEach((message) => + ApnsDeliveries.ApnsDeliveries.pipe( + Effect.flatMap((deliveries) => deliveries.processSignedJob(message.body)), + Effect.withSpan("relay.apn_delivery_queue.process_message"), + ), + ), + Effect.provide(runtimeLayer), + ), + ); + + yield* Cloudflare.cron("*/5 * * * *").subscribe(() => + DpopProofs.DpopProofReplay.pipe( + Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired), + Effect.withSpan("relay.cron.prune_expired_dpop_proofs"), + Effect.provide(runtimeLayer), + ), + ); + + const fetch = Layer.merge( + Layer.mergeAll( + HttpApiBuilder.layer(RelayApi, { openapiPath: "/openapi.json" }).pipe( + Layer.provide(appLayer), + ), + HttpApiScalar.layer(RelayApi, { path: "/docs" }), + relayDocsRedirectRoute, + ).pipe(Layer.provide([Etag.layerWeak, httpPlatformNotSupportedLayer, relayCors])), + relayNotFoundRoute, + ).pipe( + HttpRouter.toHttpEffect, + withoutCapturedParentSpan, + Effect.flatMap((httpEffect) => traceRelayHttpRequestWith(httpEffect, relayTraceLayer)), + ); + + return { fetch }; + }).pipe( + Effect.provide( + Layer.empty.pipe( + Layer.provideMerge(Cloudflare.HyperdriveBindingLive), + Layer.provideMerge(Cloudflare.CronEventSourceLive), + Layer.provideMerge(Cloudflare.QueueBindingLive), + Layer.provideMerge(Cloudflare.QueueEventSourceLive), + Layer.provideMerge(Cloudflare.TunnelReadWriteLive), + Layer.provideMerge(Cloudflare.DnsReadWriteLive), + ), + ), + ), +) {} diff --git a/infra/relay/src/zone.test.ts b/infra/relay/src/zone.test.ts new file mode 100644 index 00000000000..cac7f9e6cbc --- /dev/null +++ b/infra/relay/src/zone.test.ts @@ -0,0 +1,36 @@ +import * as Alchemy from "alchemy"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vitest"; + +import { RelayDeploymentConfig } from "./zone.ts"; + +describe("RelayDeploymentConfig", () => { + it("reads the stage from the stack context available in the Worker runtime", async () => { + const config = await Effect.runPromise( + RelayDeploymentConfig.pipe( + Effect.provideService(Alchemy.Stack, { + name: "T3CodeRelay", + stage: "dev_julius", + bindings: {}, + resources: {}, + actions: {}, + }), + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + RELAY_ZONE_NAME: "example.com", + }), + ), + ), + ), + ); + + expect(config).toEqual({ + stage: "dev_julius", + relayPublicDomain: "relay-dev-julius.example.com", + relayPublicOrigin: "https://relay-dev-julius.example.com", + managedEndpointZoneName: "example.com", + }); + }); +}); diff --git a/infra/relay/src/zone.ts b/infra/relay/src/zone.ts new file mode 100644 index 00000000000..a626e94f72a --- /dev/null +++ b/infra/relay/src/zone.ts @@ -0,0 +1,52 @@ +import * as Alchemy from "alchemy"; +import { adopt } from "alchemy/AdoptPolicy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { + MANAGED_ENDPOINT_ZONE_OWNER_STAGE, + relayOwnsManagedEndpointZone, + relayPublicDomainForStage, +} from "./deploymentConfig.ts"; + +function withLogicalId(resource: Resource, logicalId: string): Resource { + return new Proxy(resource, { + has: (target, property) => property === "LogicalId" || property in target, + get: (target, property, receiver) => + property === "LogicalId" ? logicalId : Reflect.get(target, property, receiver), + }); +} + +export const RelayDeploymentConfig = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + const managedEndpointZoneName = yield* Config.nonEmptyString("RELAY_ZONE_NAME"); + const relayPublicDomainOverride = yield* Config.nonEmptyString("RELAY_DOMAIN").pipe( + Config.option, + ); + const relayPublicDomain = Option.getOrElse(relayPublicDomainOverride, () => + relayPublicDomainForStage(stage, managedEndpointZoneName), + ); + + return { + stage, + relayPublicDomain, + relayPublicOrigin: `https://${relayPublicDomain}`, + managedEndpointZoneName, + }; +}); + +export const ManagedEndpointZone = RelayDeploymentConfig.pipe( + Effect.flatMap(({ stage, managedEndpointZoneName }) => + relayOwnsManagedEndpointZone(stage) + ? Cloudflare.Zone("ManagedEndpointZone", { name: managedEndpointZoneName }).pipe(adopt(true)) + : Cloudflare.Zone.ref("ManagedEndpointZone", { + stage: MANAGED_ENDPOINT_ZONE_OWNER_STAGE, + }).pipe( + // Alchemy beta's DNS binding policy uses LogicalId to derive a + // stable SID, but Resource.ref returns a lazy output proxy. + Effect.map((zone) => withLogicalId(zone, "ManagedEndpointZone")), + ), + ), +); diff --git a/infra/relay/tsconfig.json b/infra/relay/tsconfig.json new file mode 100644 index 00000000000..6c0840a678b --- /dev/null +++ b/infra/relay/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "WebWorker"], + "types": ["@cloudflare/workers-types", "node"] + }, + "include": ["alchemy.run.ts", "scripts/**/*.ts", "src/**/*.ts"] +} diff --git a/package.json b/package.json index 9b03728f3eb..16eac3b6e8d 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "apps/*", "oxlint-plugin-t3code", "packages/*", + "infra/*", "scripts" ], "catalog": { @@ -15,14 +16,19 @@ "@effect/platform-bun": "4.0.0-beta.73", "@effect/platform-node": "4.0.0-beta.73", "@effect/platform-node-shared": "4.0.0-beta.73", + "@effect/sql-pg": "4.0.0-beta.73", "@effect/sql-sqlite-bun": "4.0.0-beta.73", "@effect/vitest": "4.0.0-beta.73", "@effect/tsgo": "0.11.4", + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", "@pierre/diffs": "1.1.20", "@vitest/runner": "^4.1.8", "@types/bun": "^1.3.11", "@types/node": "24.12.4", "@typescript/native-preview": "7.0.0-dev.20260527.2", + "jose": "6.2.2", + "tsdown": "^0.20.3", "typescript": "~6.0.3", "vitest": "npm:@voidzero-dev/vite-plus-test@latest", "vite": "npm:@voidzero-dev/vite-plus-core@latest", @@ -82,6 +88,7 @@ "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", + "@effect/sql-pg": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@effect/vitest": "catalog:", "@types/node": "catalog:", @@ -100,6 +107,7 @@ }, "patchedDependencies": { "@pierre/diffs@1.1.20@1.1.20": "patches/@pierre%2Fdiffs@1.1.20.patch", + "alchemy@2.0.0-beta.49@2.0.0-beta.49": "patches/alchemy@2.0.0-beta.49.patch", "effect@4.0.0-beta.73@4.0.0-beta.73": "patches/effect@4.0.0-beta.73.patch", "react-native-nitro-modules@0.35.9@0.35.9": "patches/react-native-nitro-modules@0.35.9.patch" } diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts index 51efb313ee5..ac32e794fe4 100644 --- a/packages/client-runtime/src/index.ts +++ b/packages/client-runtime/src/index.ts @@ -26,3 +26,5 @@ export * from "./composerPathSearchState.ts"; export * from "./archivedThreadsState.ts"; export * from "./checkpointDiffState.ts"; export * from "./remote.ts"; +export * from "./managedRelay.ts"; +export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/managedRelay.test.ts b/packages/client-runtime/src/managedRelay.test.ts new file mode 100644 index 00000000000..e340f12f620 --- /dev/null +++ b/packages/client-runtime/src/managedRelay.test.ts @@ -0,0 +1,185 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import { + MANAGED_RELAY_REQUEST_TIMEOUT_MS, + ManagedRelayClient, + ManagedRelayDpopSigner, + managedRelayClientLayer, + type ManagedRelayDpopProofInput, +} from "./managedRelay.ts"; +import { remoteHttpClientLayer } from "./remote.ts"; + +function managedRelayTestLayer( + fetchFn: typeof globalThis.fetch, + relayUrl = "https://relay.example.test", +) { + const httpClientLayer = remoteHttpClientLayer(fetchFn); + const signerLayer = Layer.succeed( + ManagedRelayDpopSigner, + ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("client-thumbprint"), + createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), + }), + ); + return managedRelayClientLayer({ + relayUrl, + clientId: "t3-mobile", + }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); +} + +describe("ManagedRelayClient", () => { + it.effect("rejects unsafe relay URLs before sending credentials", () => { + let requestCount = 0; + const fetchFn = (() => { + requestCount += 1; + return Promise.resolve(Response.json({})); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const error = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ManagedRelayClientError", + message: "Relay URL must be a secure absolute HTTPS origin.", + }); + expect(requestCount).toBe(0); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); + }); + + it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { + let tokenExchangeCount = 0; + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + tokenExchangeCount += 1; + return Promise.resolve( + Response.json({ + access_token: `relay-token-${tokenExchangeCount}`, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 10, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-05-25T00:01:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const statusInput = { + clerkToken: "clerk-token", + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + } as const; + + yield* relayClient.getEnvironmentStatus(statusInput); + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(1); + + yield* TestClock.adjust(Duration.seconds(6)); + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(2); + + yield* relayClient.resetTokenCache; + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(3); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); + + it.effect("times out stalled relay environment listing requests", () => { + const fetchFn = (() => + new Promise(() => undefined)) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const errorFiber = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip, Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); + const error = yield* Fiber.join(errorFiber); + + expect(error).toMatchObject({ + _tag: "ManagedRelayClientError", + message: "Relay environment listing timed out.", + }); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); + }); + + it.effect("lists account devices through the Clerk bearer client endpoint", () => { + const fetchFn = ((input, init) => { + expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); + expect(init?.headers).toMatchObject({ + authorization: "Bearer clerk-token", + }); + return Promise.resolve( + Response.json({ + devices: [ + { + deviceId: "device-1", + label: "Julius's iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0", + notifications: { + enabled: false, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ], + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); + expect(devices).toMatchObject([ + { + deviceId: "device-1", + label: "Julius's iPhone", + notifications: { + enabled: false, + }, + }, + ]); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); +}); diff --git a/packages/client-runtime/src/managedRelay.ts b/packages/client-runtime/src/managedRelay.ts new file mode 100644 index 00000000000..a3bc973c64c --- /dev/null +++ b/packages/client-runtime/src/managedRelay.ts @@ -0,0 +1,510 @@ +import { + RelayAccessTokenType, + RelayApi, + type RelayClientEnvironmentRecord, + type RelayClientDeviceRecord, + RelayConnectEnvironmentEndpoint, + type RelayDeviceRegistrationRequest, + type RelayDpopAccessTokenScope, + RelayDpopTokenExchangeGrantType, + type RelayEnvironmentConnectRequest, + type RelayEnvironmentConnectResponse, + type RelayEnvironmentLinkChallengeRequest, + type RelayEnvironmentLinkChallengeResponse, + type RelayEnvironmentLinkRequest, + type RelayEnvironmentLinkResponse, + type RelayEnvironmentStatusResponse, + RelayExchangeDpopAccessTokenEndpoint, + RelayGetEnvironmentStatusEndpoint, + RelayJwtSubjectTokenType, + type RelayLiveActivityRegistrationRequest, + RelayMobileRegistrationScope, + type RelayOkResponse, + type RelayPublicClientId, + RelayRegisterDeviceEndpoint, + RelayRegisterLiveActivityEndpoint, + RelayUnregisterDeviceEndpoint, +} from "@t3tools/contracts/relay"; +import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import type { HttpMethod } from "effect/unstable/http/HttpMethod"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +export interface ManagedRelayDpopProofInput { + readonly method: HttpMethod; + readonly url: string; + readonly accessToken?: string; +} + +export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ + readonly cause: unknown; +}> {} + +export interface ManagedRelayDpopSignerShape { + readonly thumbprint: Effect.Effect; + readonly createProof: ( + input: ManagedRelayDpopProofInput, + ) => Effect.Effect; +} + +export class ManagedRelayDpopSigner extends Context.Service< + ManagedRelayDpopSigner, + ManagedRelayDpopSignerShape +>()("@t3tools/client-runtime/managedRelay/ManagedRelayDpopSigner") {} + +export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; + +interface CachedRelayAccessToken { + readonly clerkToken: string; + readonly thumbprint: string; + readonly scopes: ReadonlyArray; + readonly accessToken: string; + readonly expiresAtMillis: number; +} + +export interface ManagedRelayAuthorization { + readonly accessToken: string; + readonly proof: string; + readonly thumbprint: string; +} + +export interface ManagedRelayClientLayerOptions { + readonly relayUrl: string; + readonly clientId: RelayPublicClientId; +} + +export interface ManagedRelayClientShape { + readonly relayUrl: string; + readonly listEnvironments: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly listDevices: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly createEnvironmentLinkChallenge: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkChallengeRequest; + }) => Effect.Effect; + readonly linkEnvironment: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkRequest; + }) => Effect.Effect; + readonly unlinkEnvironment: (input: { + readonly clerkToken: string; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly getEnvironmentStatus: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly connectEnvironment: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly deviceId?: string; + }) => Effect.Effect; + readonly registerDevice: (input: { + readonly clerkToken: string; + readonly payload: RelayDeviceRegistrationRequest; + }) => Effect.Effect; + readonly unregisterDevice: (input: { + readonly clerkToken: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly registerLiveActivity: (input: { + readonly clerkToken: string; + readonly payload: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect; + readonly resetTokenCache: Effect.Effect; +} + +export class ManagedRelayClient extends Context.Service< + ManagedRelayClient, + ManagedRelayClientShape +>()("@t3tools/client-runtime/managedRelay/ManagedRelayClient") {} + +function relayClientError(message: string, cause?: unknown): ManagedRelayClientError { + return new ManagedRelayClientError({ message, ...(cause === undefined ? {} : { cause }) }); +} + +function timeoutRelayRequest(message: string) { + return ( + request: Effect.Effect, + ): Effect.Effect => + request.pipe( + Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(relayClientError(message)), + onSome: Effect.succeed, + }), + ), + ); +} + +function tokenMatches( + token: CachedRelayAccessToken, + input: { + readonly clerkToken: string; + readonly thumbprint: string; + readonly scopes: ReadonlyArray; + readonly nowMillis: number; + }, +): boolean { + return ( + token.clerkToken === input.clerkToken && + token.thumbprint === input.thumbprint && + token.expiresAtMillis > input.nowMillis + 5_000 && + input.scopes.every((scope) => token.scopes.includes(scope)) + ); +} + +function bearerHeaders(clerkToken: string) { + return { authorization: `Bearer ${clerkToken}` }; +} + +function dpopHeaders(authorization: ManagedRelayAuthorization) { + return { + authorization: `DPoP ${authorization.accessToken}`, + dpop: authorization.proof, + }; +} + +function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { + const unavailable = () => + Effect.fail(relayClientError("Relay URL must be a secure absolute HTTPS origin.")); + return ManagedRelayClient.of({ + relayUrl, + listEnvironments: unavailable, + listDevices: unavailable, + createEnvironmentLinkChallenge: unavailable, + linkEnvironment: unavailable, + unlinkEnvironment: unavailable, + getEnvironmentStatus: unavailable, + connectEnvironment: unavailable, + registerDevice: unavailable, + unregisterDevice: unavailable, + registerLiveActivity: unavailable, + resetTokenCache: Effect.void, + }); +} + +export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { + return Layer.effect( + ManagedRelayClient, + Effect.gen(function* () { + const relayUrl = normalizeSecureRelayUrl(options.relayUrl); + if (relayUrl === null) { + return disabledManagedRelayClient(options.relayUrl); + } + const signer = yield* ManagedRelayDpopSigner; + const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); + const cachedTokens = yield* SynchronizedRef.make>([]); + const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); + + type DpopProofTarget = Pick; + const dpopProofTargets = { + exchangeAccessToken: (): DpopProofTarget => ({ + method: RelayExchangeDpopAccessTokenEndpoint.method, + url: urlBuilder.token.exchangeDpopAccessToken(), + }), + getEnvironmentStatus: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayGetEnvironmentStatusEndpoint.method, + url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), + }), + connectEnvironment: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayConnectEnvironmentEndpoint.method, + url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), + }), + registerDevice: (): DpopProofTarget => ({ + method: RelayRegisterDeviceEndpoint.method, + url: urlBuilder.mobile.registerDevice(), + }), + unregisterDevice: (deviceId: string): DpopProofTarget => ({ + method: RelayUnregisterDeviceEndpoint.method, + url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), + }), + registerLiveActivity: (): DpopProofTarget => ({ + method: RelayRegisterLiveActivityEndpoint.method, + url: urlBuilder.mobile.registerLiveActivity(), + }), + }; + + const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly thumbprint: string; + }) { + const nowMillis = yield* Clock.currentTimeMillis; + return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { + const activeTokens = tokens.filter( + (token) => token.expiresAtMillis > nowMillis + 5_000, + ); + const cached = activeTokens.find((token) => + tokenMatches(token, { ...input, nowMillis }), + ); + if (cached) { + return Effect.succeed([cached, activeTokens] as const); + } + return Effect.gen(function* () { + const proof = yield* signer + .createProof(dpopProofTargets.exchangeAccessToken()) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not create relay token DPoP proof.", cause), + ), + ); + const response = yield* client.token + .exchangeDpopAccessToken({ + headers: { dpop: proof }, + payload: { + grant_type: RelayDpopTokenExchangeGrantType, + subject_token: input.clerkToken, + subject_token_type: RelayJwtSubjectTokenType, + requested_token_type: RelayAccessTokenType, + resource: relayUrl, + scope: encodeOAuthScope(input.scopes), + client_id: options.clientId, + }, + }) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not exchange relay DPoP access token.", cause), + ), + timeoutRelayRequest("Relay DPoP access token exchange timed out."), + ); + if (!oauthScopeSetEquals(response.scope, input.scopes)) { + return yield* relayClientError( + "Relay granted unexpected DPoP access token scopes.", + ); + } + const next: CachedRelayAccessToken = { + clerkToken: input.clerkToken, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + }; + return [next, [...activeTokens, next]] as const; + }); + }); + }, + ); + + const authorize = (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly target: DpopProofTarget; + }) => + Effect.gen(function* () { + const thumbprint = yield* signer.thumbprint.pipe( + Effect.mapError((cause) => + relayClientError("Could not load relay DPoP proof key.", cause), + ), + ); + const token = yield* obtainAccessToken({ + clerkToken: input.clerkToken, + scopes: input.scopes, + thumbprint, + }); + const proof = yield* signer + .createProof({ + ...input.target, + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not create relay request DPoP proof.", cause), + ), + ); + return { accessToken: token.accessToken, proof, thumbprint }; + }); + + const authorizeMobileRegistration = (input: { + readonly clerkToken: string; + readonly target: DpopProofTarget; + }) => + authorize({ + ...input, + scopes: [RelayMobileRegistrationScope], + }); + + return ManagedRelayClient.of({ + relayUrl, + listEnvironments: (input) => + client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe( + Effect.map((response) => response.environments), + Effect.mapError((cause) => + relayClientError("Could not list relay-managed environments.", cause), + ), + timeoutRelayRequest("Relay environment listing timed out."), + ), + listDevices: (input) => + client.client + .listDevices({ + headers: bearerHeaders(input.clerkToken), + }) + .pipe( + Effect.map((response) => response.devices), + Effect.mapError((cause) => + relayClientError("Could not list relay client devices.", cause), + ), + timeoutRelayRequest("Relay client device listing timed out."), + ), + createEnvironmentLinkChallenge: (input) => + client.client + .createEnvironmentLinkChallenge({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not create relay environment link challenge.", cause), + ), + timeoutRelayRequest("Relay environment link challenge timed out."), + ), + linkEnvironment: (input) => + client.client + .linkEnvironment({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not link relay environment.", cause), + ), + timeoutRelayRequest("Relay environment linking timed out."), + ), + unlinkEnvironment: (input) => + client.client + .unlinkEnvironment({ + headers: bearerHeaders(input.clerkToken), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not unlink relay environment.", cause), + ), + timeoutRelayRequest("Relay environment unlinking timed out."), + ), + getEnvironmentStatus: (input) => + Effect.gen(function* () { + const authorization = yield* authorize({ + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.getEnvironmentStatus(input.environmentId), + }); + return yield* client.dpopClient + .getEnvironmentStatus({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not get relay environment status.", cause), + ), + timeoutRelayRequest("Relay environment status request timed out."), + ); + }), + connectEnvironment: (input) => + Effect.gen(function* () { + const authorization = yield* authorize({ + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.connectEnvironment(input.environmentId), + }); + const payload: RelayEnvironmentConnectRequest = { + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + clientKeyThumbprint: authorization.thumbprint, + }; + return yield* client.dpopClient + .connectEnvironment({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + payload, + }) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not connect relay environment.", cause), + ), + timeoutRelayRequest("Relay environment connection timed out."), + ); + }), + registerDevice: (input) => + Effect.gen(function* () { + const authorization = yield* authorizeMobileRegistration({ + clerkToken: input.clerkToken, + target: dpopProofTargets.registerDevice(), + }); + return yield* client.mobile + .registerDevice({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not register relay mobile device.", cause), + ), + timeoutRelayRequest("Relay mobile device registration timed out."), + ); + }), + unregisterDevice: (input) => + Effect.gen(function* () { + const authorization = yield* authorizeMobileRegistration({ + clerkToken: input.clerkToken, + target: dpopProofTargets.unregisterDevice(input.deviceId), + }); + return yield* client.mobile + .unregisterDevice({ + headers: dpopHeaders(authorization), + params: { deviceId: input.deviceId }, + }) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not unregister relay mobile device.", cause), + ), + timeoutRelayRequest("Relay mobile device unregistration timed out."), + ); + }), + registerLiveActivity: (input) => + Effect.gen(function* () { + const authorization = yield* authorizeMobileRegistration({ + clerkToken: input.clerkToken, + target: dpopProofTargets.registerLiveActivity(), + }); + return yield* client.mobile + .registerLiveActivity({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError((cause) => + relayClientError("Could not register relay live activity.", cause), + ), + timeoutRelayRequest("Relay Live Activity registration timed out."), + ); + }), + resetTokenCache: SynchronizedRef.set(cachedTokens, []), + }); + }), + ); +} diff --git a/packages/client-runtime/src/managedRelayState.test.ts b/packages/client-runtime/src/managedRelayState.test.ts new file mode 100644 index 00000000000..382fcae4cac --- /dev/null +++ b/packages/client-runtime/src/managedRelayState.test.ts @@ -0,0 +1,155 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { + RelayClientDeviceRecord, + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ManagedRelayClient, type ManagedRelayClientShape } from "./managedRelay.ts"; +import { + createManagedRelayQueryManager, + createManagedRelaySession, + managedRelaySessionAtom, + readManagedRelaySnapshotState, + setManagedRelaySession, + waitForManagedRelayClerkToken, +} from "./managedRelayState.ts"; + +let registry = AtomRegistry.make(); + +const environment = { + environmentId: EnvironmentId.make("environment-1"), + label: "Main environment", + endpoint: { + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-06-01T00:00:00.000Z", +} satisfies RelayClientEnvironmentRecord; + +const device = { + deviceId: "device-1", + label: "Julius iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: null, + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", +} satisfies RelayClientDeviceRecord; + +function resetRegistry() { + registry.dispose(); + registry = AtomRegistry.make(); +} + +function createManager(overrides?: Partial) { + const client = ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => Effect.succeed([environment]), + listDevices: () => Effect.succeed([device]), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + getEnvironmentStatus: () => + Effect.succeed({ + environmentId: environment.environmentId, + endpoint: environment.endpoint, + status: "online", + checkedAt: "2026-06-01T00:00:00.000Z", + }), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.die("unused"), + resetTokenCache: Effect.void, + ...overrides, + }); + const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); + return createManagedRelayQueryManager(runtime, { staleTimeMs: 60_000 }); +} + +function setSession() { + setManagedRelaySession( + registry, + createManagedRelaySession({ + accountId: "account-1", + readClerkToken: () => Promise.resolve("clerk-token"), + }), + ); +} + +describe("createManagedRelayQueryManager", () => { + afterEach(resetRegistry); + + it("waits for the current cloud session before reading its token", async () => { + const token = Effect.runPromise(waitForManagedRelayClerkToken(registry)); + + setSession(); + + await expect(token).resolves.toBe("clerk-token"); + expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); + }); + + it("keeps environment snapshots cached and refreshes them explicitly", async () => { + const listEnvironments = vi.fn(() => Effect.succeed([environment])); + const manager = createManager({ listEnvironments }); + setSession(); + const atom = manager.environmentsAtom("account-1"); + + registry.get(atom); + await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(1)); + + registry.get(manager.environmentsAtom("account-1")); + expect(listEnvironments).toHaveBeenCalledTimes(1); + + manager.refreshEnvironments(registry, "account-1"); + await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(2)); + }); + + it("loads device snapshots through the current account session", async () => { + const listDevices = vi.fn(() => Effect.succeed([device])); + const manager = createManager({ listDevices }); + setSession(); + const atom = manager.devicesAtom("account-1"); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).data).toEqual([device]); + }); + }); + + it("rejects status responses for a different environment", async () => { + const mismatchedStatus = { + environmentId: EnvironmentId.make("environment-2"), + endpoint: environment.endpoint, + status: "online", + checkedAt: "2026-06-01T00:00:00.000Z", + } satisfies RelayEnvironmentStatusResponse; + const manager = createManager({ + getEnvironmentStatus: () => Effect.succeed(mismatchedStatus), + }); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( + "Relay returned status for a different environment.", + ); + }); + }); +}); diff --git a/packages/client-runtime/src/managedRelayState.ts b/packages/client-runtime/src/managedRelayState.ts new file mode 100644 index 00000000000..7d50f0fdb7c --- /dev/null +++ b/packages/client-runtime/src/managedRelayState.ts @@ -0,0 +1,289 @@ +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, +} from "@t3tools/contracts/relay"; +import * as Cause from "effect/Cause"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; + +import { ManagedRelayClient } from "./managedRelay.ts"; + +const DEFAULT_STALE_TIME_MS = 15_000; +const DEFAULT_IDLE_TTL_MS = 5 * 60_000; + +export interface ManagedRelaySession { + readonly accountId: string; + readonly readClerkToken: () => Effect.Effect; +} + +export interface ManagedRelaySnapshotState { + readonly data: A | null; + readonly error: string | null; + readonly isPending: boolean; +} + +export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySnapshotError")<{ + readonly message: string; +}> {} + +export const managedRelaySessionAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("managed-relay:session"), +); + +export function createManagedRelaySession(input: { + readonly accountId: string; + readonly readClerkToken: () => Promise; +}): ManagedRelaySession { + return { + accountId: input.accountId, + readClerkToken: () => + Effect.tryPromise({ + try: input.readClerkToken, + catch: (cause) => + new ManagedRelaySessionError({ + message: "Could not obtain the T3 Cloud session token.", + cause, + }), + }), + }; +} + +export function setManagedRelaySession( + registry: AtomRegistry.AtomRegistry, + session: ManagedRelaySession | null, +): void { + registry.set(managedRelaySessionAtom, session); +} + +function readSessionClerkToken( + session: ManagedRelaySession, +): Effect.Effect { + return session.readClerkToken().pipe( + Effect.flatMap((token) => + token + ? Effect.succeed(token) + : Effect.fail( + new ManagedRelaySessionError({ + message: "The T3 Cloud session token is unavailable.", + }), + ), + ), + ); +} + +export function waitForManagedRelayClerkToken( + registry: AtomRegistry.AtomRegistry, +): Effect.Effect { + return Effect.callback((resume) => { + let unsubscribe: (() => void) | undefined; + let completed = false; + const readCurrentSession = () => { + if (completed) { + return true; + } + const session = registry.get(managedRelaySessionAtom); + if (!session) { + return false; + } + completed = true; + unsubscribe?.(); + resume(readSessionClerkToken(session)); + return true; + }; + + if (readCurrentSession()) { + return; + } + + unsubscribe = registry.subscribe(managedRelaySessionAtom, readCurrentSession); + readCurrentSession(); + return Effect.sync(() => unsubscribe?.()); + }); +} + +function requireClerkToken( + get: Atom.AtomContext, + accountId: string, +): Effect.Effect { + const session = get(managedRelaySessionAtom); + if (!session || session.accountId !== accountId) { + return Effect.fail( + new ManagedRelaySessionError({ + message: "Sign in to T3 Cloud before loading relay data.", + }), + ); + } + return readSessionClerkToken(session); +} + +function statusKey(input: { + readonly accountId: string; + readonly environment: RelayClientEnvironmentRecord; +}): string { + return JSON.stringify(input); +} + +function parseStatusKey(key: string): { + readonly accountId: string; + readonly environment: RelayClientEnvironmentRecord; +} { + return JSON.parse(key) as { + readonly accountId: string; + readonly environment: RelayClientEnvironmentRecord; + }; +} + +function endpointMatches( + left: RelayClientEnvironmentRecord["endpoint"], + right: RelayClientEnvironmentRecord["endpoint"], +): boolean { + return ( + left.httpBaseUrl === right.httpBaseUrl && + left.wsBaseUrl === right.wsBaseUrl && + left.providerKind === right.providerKind + ); +} + +function validateEnvironmentStatus( + environment: RelayClientEnvironmentRecord, + status: RelayEnvironmentStatusResponse, +): Effect.Effect { + if (status.environmentId !== environment.environmentId) { + return Effect.fail( + new ManagedRelaySnapshotError({ + message: "Relay returned status for a different environment.", + }), + ); + } + if (!endpointMatches(status.endpoint, environment.endpoint)) { + return Effect.fail( + new ManagedRelaySnapshotError({ + message: "Relay returned status for a different endpoint.", + }), + ); + } + if (status.descriptor && status.descriptor.environmentId !== environment.environmentId) { + return Effect.fail( + new ManagedRelaySnapshotError({ + message: "Relay returned status descriptor for a different environment.", + }), + ); + } + return Effect.succeed(status); +} + +export function readManagedRelaySnapshotState( + result: AsyncResult.AsyncResult, +): ManagedRelaySnapshotState { + let error: string | null = null; + if (result._tag === "Failure") { + const cause = Cause.squash(result.cause); + error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data."; + } + return { + data: Option.getOrNull(AsyncResult.value(result)), + error, + isPending: result.waiting, + }; +} + +export function createManagedRelayQueryManager( + runtime: Atom.AtomRuntime, + options?: { + readonly staleTimeMs?: number; + readonly idleTtlMs?: number; + }, +) { + const staleTime = options?.staleTimeMs ?? DEFAULT_STALE_TIME_MS; + const idleTtl = options?.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; + + const environmentsAtom = Atom.family((accountId: string) => + runtime + .atom((get) => + Effect.gen(function* () { + const clerkToken = yield* requireClerkToken(get, accountId); + const relay = yield* ManagedRelayClient; + return yield* relay.listEnvironments({ clerkToken }); + }), + ) + .pipe( + Atom.swr({ staleTime, revalidateOnMount: true }), + Atom.setIdleTTL(idleTtl), + Atom.withLabel(`managed-relay:environments:${accountId}`), + ), + ); + + const devicesAtom = Atom.family((accountId: string) => + runtime + .atom((get) => + Effect.gen(function* () { + const clerkToken = yield* requireClerkToken(get, accountId); + const relay = yield* ManagedRelayClient; + return yield* relay.listDevices({ clerkToken }); + }), + ) + .pipe( + Atom.swr({ staleTime, revalidateOnMount: true }), + Atom.setIdleTTL(idleTtl), + Atom.withLabel(`managed-relay:devices:${accountId}`), + ), + ); + + const environmentStatusAtom = Atom.family((key: string) => { + const { accountId, environment } = parseStatusKey(key); + return runtime + .atom((get) => + Effect.gen(function* () { + const clerkToken = yield* requireClerkToken(get, accountId); + const relay = yield* ManagedRelayClient; + const status = yield* relay.getEnvironmentStatus({ + clerkToken, + scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], + environmentId: environment.environmentId, + }); + return yield* validateEnvironmentStatus(environment, status); + }), + ) + .pipe( + Atom.swr({ staleTime, revalidateOnMount: true }), + Atom.setIdleTTL(idleTtl), + Atom.withLabel(`managed-relay:environment-status:${key}`), + ); + }); + + return { + environmentsAtom, + devicesAtom, + environmentStatusAtom: (input: { + readonly accountId: string; + readonly environment: RelayClientEnvironmentRecord; + }) => environmentStatusAtom(statusKey(input)), + refreshEnvironments(registry: AtomRegistry.AtomRegistry, accountId: string): void { + registry.refresh(environmentsAtom(accountId)); + }, + refreshDevices(registry: AtomRegistry.AtomRegistry, accountId: string): void { + registry.refresh(devicesAtom(accountId)); + }, + refreshEnvironmentStatus( + registry: AtomRegistry.AtomRegistry, + input: { + readonly accountId: string; + readonly environment: RelayClientEnvironmentRecord; + }, + ): void { + registry.refresh(environmentStatusAtom(statusKey(input))); + }, + }; +} diff --git a/packages/client-runtime/src/remote.test.ts b/packages/client-runtime/src/remote.test.ts index 27c0ce9271d..c20832bd37e 100644 --- a/packages/client-runtime/src/remote.test.ts +++ b/packages/client-runtime/src/remote.test.ts @@ -8,8 +8,11 @@ import * as TestClock from "effect/testing/TestClock"; import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; import { bootstrapRemoteBearerSession, + exchangeRemoteDpopAccessToken, + fetchRemoteDpopSessionState, fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, + issueRemoteDpopWebSocketTicket, issueRemoteWebSocketTicket, remoteHttpClientLayer, RemoteEnvironmentAuthInvalidJsonError, @@ -123,6 +126,55 @@ describe("remote", () => { }), ); + it.effect("exchanges managed credentials and admits websocket requests with DPoP", () => + Effect.gen(function* () { + const fetch = recordedFetch( + Response.json({ + access_token: "dpop-access-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 3600, + scope: "orchestration:read orchestration:operate terminal:operate review:write", + }), + Response.json({ + ticket: "ws-ticket", + expiresAt: "2026-05-01T12:05:00.000Z", + }), + ); + + const token = yield* exchangeRemoteDpopAccessToken({ + httpBaseUrl: "https://remote.example.com/", + credential: "one-time-credential", + dpopProof: "token-proof", + clientMetadata: { + label: "T3 Code Mobile", + deviceType: "mobile", + os: "iOS", + }, + }).pipe(provideRemoteHttp(fetch.fetchFn)); + yield* issueRemoteDpopWebSocketTicket({ + httpBaseUrl: "https://remote.example.com/", + accessToken: token.access_token, + dpopProof: "resource-proof", + }).pipe(provideRemoteHttp(fetch.fetchFn)); + + expectFetchCall(fetch.calls, 1, { + url: "https://remote.example.com/oauth/token", + method: "POST", + headers: { dpop: "token-proof", "content-type": "application/x-www-form-urlencoded" }, + body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=one-time-credential&subject_token_type=urn%3At3%3Aparams%3Aoauth%3Atoken-type%3Aenvironment-bootstrap&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_label=T3+Code+Mobile&client_device_type=mobile&client_os=iOS", + }); + expectFetchCall(fetch.calls, 2, { + url: "https://remote.example.com/api/auth/websocket-ticket", + method: "POST", + headers: { + authorization: "DPoP dpop-access-token", + dpop: "resource-proof", + }, + }); + }), + ); + it.effect("submits optional client display metadata during bearer token exchange", () => Effect.gen(function* () { const fetch = recordedFetch( @@ -186,7 +238,7 @@ describe("remote", () => { }), ); - it.effect("loads remote session state and websocket tokens over bearer auth", () => + it.effect("loads remote session state and websocket tickets over bearer auth", () => Effect.gen(function* () { const fetch = recordedFetch( Response.json( @@ -257,11 +309,11 @@ describe("remote", () => { ], }); - const token = yield* issueRemoteWebSocketTicket({ + const ticket = yield* issueRemoteWebSocketTicket({ httpBaseUrl: "https://remote.example.com/", bearerToken: "bearer-token", }).pipe(provideRemoteHttp(fetch.fetchFn)); - expect(token).toMatchObject({ + expect(ticket).toMatchObject({ ticket: "ws-ticket", }); @@ -286,6 +338,45 @@ describe("remote", () => { }), ); + it.effect("loads remote session state with a DPoP-bound access token", () => + Effect.gen(function* () { + const fetch = recordedFetch( + Response.json({ + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["dpop-access-token"], + sessionCookieName: "t3_session", + }, + sessionMethod: "dpop-access-token", + scopes: [ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + ], + expiresAt: "2026-05-01T12:00:00.000Z", + }), + ); + + yield* fetchRemoteDpopSessionState({ + httpBaseUrl: "https://remote.example.com/", + accessToken: "dpop-access-token", + dpopProof: "dpop-proof", + }).pipe(provideRemoteHttp(fetch.fetchFn)); + + expectFetchCall(fetch.calls, 1, { + url: "https://remote.example.com/api/auth/session", + method: "GET", + headers: { + authorization: "DPoP dpop-access-token", + dpop: "dpop-proof", + }, + }); + }), + ); + it.effect("fails hung fetch requests on the configured timeout", () => Effect.gen(function* () { const fetch = hangingFetch(); @@ -360,27 +451,25 @@ describe("remote", () => { }), ); - it.effect( - "mints a websocket url that targets the rpc route with a short-lived websocket ticket", - () => - Effect.gen(function* () { - const fetch = recordedFetch( - Response.json( - { - ticket: "ws-ticket", - expiresAt: "2026-05-01T12:05:00.000Z", - }, - { status: 200 }, - ), - ); - - const url = yield* resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: "wss://remote.example.com/", - httpBaseUrl: "https://remote.example.com/", - bearerToken: "bearer-token", - }).pipe(provideRemoteHttp(fetch.fetchFn)); - - expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); - }), + it.effect("mints a websocket url that targets the rpc route with a short-lived ticket", () => + Effect.gen(function* () { + const fetch = recordedFetch( + Response.json( + { + ticket: "ws-ticket", + expiresAt: "2026-05-01T12:05:00.000Z", + }, + { status: 200 }, + ), + ); + + const url = yield* resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: "wss://remote.example.com/", + httpBaseUrl: "https://remote.example.com/", + bearerToken: "bearer-token", + }).pipe(provideRemoteHttp(fetch.fetchFn)); + + expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); + }), ); }); diff --git a/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts index cc8ef361b77..41b4dce7312 100644 --- a/packages/client-runtime/src/remote.ts +++ b/packages/client-runtime/src/remote.ts @@ -7,7 +7,6 @@ import { EnvironmentHttpApi, EnvironmentHttpCommonError, } from "@t3tools/contracts"; -import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import type { EnvironmentAuthInvalidError, EnvironmentInternalError, @@ -15,6 +14,7 @@ import type { EnvironmentRequestInvalidError, EnvironmentScopeRequiredError, } from "@t3tools/contracts"; +import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -27,7 +27,7 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); -const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { +export const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { const url = new URL(httpBaseUrl); url.pathname = pathname; url.search = ""; @@ -43,6 +43,14 @@ const remoteApiBaseUrl = (httpBaseUrl: string): string => { return url.toString(); }; +const clientMetadataTokenExchangeFields = ( + clientMetadata: AuthClientPresentationMetadata | undefined, +) => ({ + ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), + ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), + ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), +}); + export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( "RemoteEnvironmentAuthFetchError", )<{ @@ -162,14 +170,39 @@ const executeRemoteRequest = ( ); export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - return yield* HttpApiClient.makeWith(EnvironmentHttpApi, { - httpClient, - baseUrl: remoteApiBaseUrl(httpBaseUrl), - }); + HttpApiClient.make(EnvironmentHttpApi, { + baseUrl: remoteApiBaseUrl(httpBaseUrl), }); +export const exchangeRemoteDpopAccessToken = Effect.fn( + "clientRuntime.remote.exchangeRemoteDpopAccessToken", +)(function* (input: { + readonly httpBaseUrl: string; + readonly credential: string; + readonly scopes?: ReadonlyArray; + readonly clientMetadata?: AuthClientPresentationMetadata; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const response = yield* executeRemoteRequest( + remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.token({ + headers: { dpop: input.dpopProof }, + payload: { + grant_type: AuthTokenExchangeGrantType, + subject_token: input.credential, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), + ...clientMetadataTokenExchangeFields(input.clientMetadata), + }, + }), + ); + return response; +}); + export const bootstrapRemoteBearerSession = Effect.fn( "clientRuntime.remote.bootstrapRemoteBearerSession", )(function* (input: { @@ -184,17 +217,14 @@ export const bootstrapRemoteBearerSession = Effect.fn( remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, client.auth.token({ + headers: {}, payload: { grant_type: AuthTokenExchangeGrantType, subject_token: input.credential, subject_token_type: AuthEnvironmentBootstrapTokenType, requested_token_type: AuthAccessTokenType, ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), - ...(input.clientMetadata?.label ? { client_label: input.clientMetadata.label } : {}), - ...(input.clientMetadata?.deviceType - ? { client_device_type: input.clientMetadata.deviceType } - : {}), - ...(input.clientMetadata?.os ? { client_os: input.clientMetadata.os } : {}), + ...clientMetadataTokenExchangeFields(input.clientMetadata), }, }), ); @@ -219,6 +249,27 @@ export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemo }, ); +export const fetchRemoteDpopSessionState = Effect.fn( + "clientRuntime.remote.fetchRemoteDpopSessionState", +)(function* (input: { + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeRemoteRequest( + remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.session({ + headers: { + authorization: `DPoP ${input.accessToken}`, + dpop: input.dpopProof, + }, + }), + ); +}); + export const fetchRemoteEnvironmentDescriptor = Effect.fn( "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { @@ -249,6 +300,27 @@ export const issueRemoteWebSocketTicket = Effect.fn( ); }); +export const issueRemoteDpopWebSocketTicket = Effect.fn( + "clientRuntime.remote.issueRemoteDpopWebSocketTicket", +)(function* (input: { + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeRemoteRequest( + remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.webSocketTicket({ + headers: { + authorization: `DPoP ${input.accessToken}`, + dpop: input.dpopProof, + }, + }), + ); +}); + export const resolveRemoteWebSocketConnectionUrl = Effect.fn( "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl", )(function* (input: { @@ -270,3 +342,26 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( url.searchParams.set("wsTicket", issued.ticket); return url.toString(); }); + +export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( + "clientRuntime.remote.resolveRemoteDpopWebSocketConnectionUrl", +)(function* (input: { + readonly wsBaseUrl: string; + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const issued = yield* issueRemoteDpopWebSocketTicket({ + httpBaseUrl: input.httpBaseUrl, + accessToken: input.accessToken, + dpopProof: input.dpopProof, + ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), + }); + const url = new URL(input.wsBaseUrl); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/ws"; + } + url.searchParams.set("wsTicket", issued.ticket); + return url.toString(); +}); diff --git a/packages/client-runtime/src/transportError.test.ts b/packages/client-runtime/src/transportError.test.ts index 87d81d82da3..7c0417a91ef 100644 --- a/packages/client-runtime/src/transportError.test.ts +++ b/packages/client-runtime/src/transportError.test.ts @@ -11,6 +11,14 @@ describe("isTransportConnectionErrorMessage", () => { expect(isTransportConnectionErrorMessage("SocketOpenError: ECONNREFUSED")).toBe(true); }); + it("returns true for React Native disconnected socket errors", () => { + expect( + isTransportConnectionErrorMessage( + "The operation couldn't be completed. Socket is not connected", + ), + ).toBe(true); + }); + it("returns true for the T3 server WebSocket message", () => { expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( true, diff --git a/packages/client-runtime/src/transportError.ts b/packages/client-runtime/src/transportError.ts index 93adfad99d1..fe0ad9f98d6 100644 --- a/packages/client-runtime/src/transportError.ts +++ b/packages/client-runtime/src/transportError.ts @@ -1,6 +1,7 @@ const TRANSPORT_ERROR_PATTERNS = [ /\bSocketCloseError\b/i, /\bSocketOpenError\b/i, + /\bSocket is not connected\b/i, /Unable to connect to the T3 server WebSocket\./i, /\bping timeout\b/i, ] as const; diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index 407f840b46f..c1c683616b2 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -4,6 +4,8 @@ import { type GitRunStackedActionResult, type LocalApi, ORCHESTRATION_WS_METHODS, + type RelayClientInstallProgressEvent, + type RelayClientStatus, type ServerSettingsPatch, type VcsStatusResult, type VcsStatusStreamEvent, @@ -150,6 +152,12 @@ export interface WsRpcClient { >; readonly signalProcess: RpcUnaryMethod; }; + readonly cloud: { + readonly getRelayClientStatus: RpcUnaryNoArgMethod; + readonly installRelayClient: ( + onProgress?: (event: RelayClientInstallProgressEvent) => void, + ) => Promise; + }; readonly orchestration: { readonly dispatchCommand: RpcUnaryMethod; readonly getTurnDiff: RpcUnaryMethod; @@ -320,6 +328,26 @@ export function createWsRpcClient( signalProcess: (input) => transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), }, + cloud: { + getRelayClientStatus: () => + transport.request((client) => client[WS_METHODS.cloudGetRelayClientStatus]({})), + installRelayClient: async (onProgress) => { + let installed: RelayClientStatus | null = null; + await transport.requestStream( + (client) => client[WS_METHODS.cloudInstallRelayClient]({}), + (event) => { + onProgress?.(event); + if (event.type === "complete") { + installed = event.status; + } + }, + ); + if (installed) { + return installed; + } + throw new Error("Relay client install stream completed without a final status."); + }, + }, orchestration: { dispatchCommand: (input) => transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), diff --git a/packages/client-runtime/src/wsTransport.test.ts b/packages/client-runtime/src/wsTransport.test.ts index bf3f4f14660..72a698d2fcf 100644 --- a/packages/client-runtime/src/wsTransport.test.ts +++ b/packages/client-runtime/src/wsTransport.test.ts @@ -788,7 +788,7 @@ describe("WsTransport", () => { () => Stream.suspend(() => { attempts += 1; - return Stream.fail(new Error("SocketCloseError: WebSocket closed")); + return Stream.fail(new Error("Socket is not connected")); }), vi.fn(), { retryDelay: 10 }, @@ -805,7 +805,7 @@ describe("WsTransport", () => { }); expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { - error: "SocketCloseError: WebSocket closed", + error: "Socket is not connected", }); unsubscribe(); diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 7b945f91b39..5f2cb0909dc 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -14,6 +14,10 @@ "./settings": { "types": "./src/settings.ts", "import": "./src/settings.ts" + }, + "./relay": { + "types": "./src/relay.ts", + "import": "./src/relay.ts" } }, "scripts": { diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 2b062fc2d6e..70b2899757d 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -63,10 +63,13 @@ export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type; * app after bootstrap/pairing * - `bearer-access-token`: scoped token suitable for non-cookie or * non-browser clients + * - `dpop-access-token`: scoped proof-of-possession token used by managed + * relay connections */ export const ServerAuthSessionMethod = Schema.Literals([ "browser-session-cookie", "bearer-access-token", + "dpop-access-token", ]); export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; @@ -184,7 +187,7 @@ export type AuthTokenExchangeRequest = typeof AuthTokenExchangeRequest.Type; export const AuthAccessTokenResult = Schema.Struct({ access_token: TrimmedNonEmptyString, issued_token_type: Schema.Literal(AuthAccessTokenType), - token_type: Schema.Literal("Bearer"), + token_type: Schema.Literals(["Bearer", "DPoP"]), expires_in: Schema.Number, scope: TrimmedNonEmptyString, }); diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 93189f62a9e..10c9587a0f1 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -31,9 +31,23 @@ import { DispatchResult, OrchestrationReadModel, } from "./orchestration.ts"; +import { + RelayCloudEnvironmentHealthRequest, + RelayCloudMintCredentialRequest, + RelayEnvironmentConfigRequest, + RelayEnvironmentHealthResponse, + RelayEnvironmentLinkProof, + RelayEnvironmentMintResponse, + RelayLinkProofRequest, +} from "./relay.ts"; const OptionalBearerHeaders = Schema.Struct({ authorization: Schema.optionalKey(Schema.String), + dpop: Schema.optionalKey(Schema.String), +}); + +const OptionalDpopProofHeaders = Schema.Struct({ + dpop: Schema.optionalKey(Schema.String), }); export const EnvironmentRequestInvalidReason = Schema.Literals([ @@ -154,6 +168,81 @@ const EnvironmentAuthenticationErrors = [ EnvironmentAuthInvalidError, EnvironmentInternalError, ] as const; + +export class EnvironmentHttpBadRequestError extends Schema.TaggedErrorClass()( + "EnvironmentHttpBadRequestError", + { + message: Schema.String, + }, + { httpApiStatus: 400 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(EnvironmentHttpBadRequestError)(this, { status: 400 }); + } +} + +export class EnvironmentHttpUnauthorizedError extends Schema.TaggedErrorClass()( + "EnvironmentHttpUnauthorizedError", + { + message: Schema.String, + }, + { httpApiStatus: 401 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(EnvironmentHttpUnauthorizedError)(this, { status: 401 }); + } +} + +export class EnvironmentHttpForbiddenError extends Schema.TaggedErrorClass()( + "EnvironmentHttpForbiddenError", + { + message: Schema.String, + }, + { httpApiStatus: 403 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(EnvironmentHttpForbiddenError)(this, { status: 403 }); + } +} + +export class EnvironmentHttpInternalServerError extends Schema.TaggedErrorClass()( + "EnvironmentHttpInternalServerError", + { + message: Schema.String, + }, + { httpApiStatus: 500 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(EnvironmentHttpInternalServerError)(this, { status: 500 }); + } +} + +export class EnvironmentHttpConflictError extends Schema.TaggedErrorClass()( + "EnvironmentHttpConflictError", + { + message: Schema.String, + }, + { httpApiStatus: 409 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(EnvironmentHttpConflictError)(this, { status: 409 }); + } +} + +export class EnvironmentCloudEndpointUnavailableError extends Schema.TaggedErrorClass()( + "EnvironmentCloudEndpointUnavailableError", + { + message: Schema.String, + endpointRuntimeStatus: Schema.Unknown, + }, + { httpApiStatus: 503 }, +) { + [HttpServerRespondable.symbol]() { + return HttpServerResponse.schemaJson(EnvironmentCloudEndpointUnavailableError)(this, { + status: 503, + }); + } +} const EnvironmentSessionCreationErrors = [ EnvironmentAuthInvalidError, EnvironmentInternalError, @@ -191,6 +280,7 @@ export interface EnvironmentSessionPrincipalShape { readonly subject: string; readonly method: ServerAuthSessionMethod; readonly scopes: ReadonlySet; + readonly proofKeyThumbprint?: string; readonly expiresAt?: DateTime.DateTime; } @@ -206,6 +296,35 @@ export class EnvironmentAuthenticatedAuth extends HttpApiMiddleware.Service< error: EnvironmentAuthenticationErrors, }) {} +const EnvironmentHttpCloudErrors = [ + EnvironmentHttpBadRequestError, + EnvironmentHttpUnauthorizedError, + EnvironmentHttpForbiddenError, + EnvironmentHttpConflictError, + EnvironmentHttpInternalServerError, + EnvironmentScopeRequiredError, +] as const; + +export const EnvironmentCloudRelayConfigResult = Schema.Struct({ + ok: Schema.Boolean, + endpointRuntimeStatus: Schema.Unknown, +}); +export type EnvironmentCloudRelayConfigResult = typeof EnvironmentCloudRelayConfigResult.Type; + +export const EnvironmentCloudLinkStateResult = Schema.Struct({ + linked: Schema.Boolean, + cloudUserId: Schema.NullOr(Schema.String), + relayUrl: Schema.NullOr(Schema.String), + relayIssuer: Schema.NullOr(Schema.String), + publishAgentActivity: Schema.Boolean, +}); +export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; + +export const EnvironmentCloudPreferencesRequest = Schema.Struct({ + publishAgentActivity: Schema.Boolean, +}); +export type EnvironmentCloudPreferencesRequest = typeof EnvironmentCloudPreferencesRequest.Type; + export const AuthPairingLinkRevokeResult = Schema.Struct({ revoked: Schema.Boolean, }); @@ -244,6 +363,7 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") ) .add( HttpApiEndpoint.post("token", "/oauth/token", { + headers: OptionalDpopProofHeaders, payload: AuthTokenExchangeRequest, success: AuthAccessTokenResult, error: EnvironmentTokenExchangeErrors, @@ -319,7 +439,69 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr }).middleware(EnvironmentAuthenticatedAuth), ) {} +export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud") + .add( + HttpApiEndpoint.post("linkProof", "/api/cloud/link-proof", { + headers: OptionalBearerHeaders, + payload: RelayLinkProofRequest, + success: RelayEnvironmentLinkProof, + error: EnvironmentHttpCloudErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("relayConfig", "/api/cloud/relay-config", { + headers: OptionalBearerHeaders, + payload: RelayEnvironmentConfigRequest, + success: EnvironmentCloudRelayConfigResult, + error: [...EnvironmentHttpCloudErrors, EnvironmentCloudEndpointUnavailableError], + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get("linkState", "/api/cloud/link-state", { + headers: OptionalBearerHeaders, + success: EnvironmentCloudLinkStateResult, + error: EnvironmentHttpCloudErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("unlink", "/api/cloud/unlink", { + headers: OptionalBearerHeaders, + success: EnvironmentCloudRelayConfigResult, + error: EnvironmentHttpCloudErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("preferences", "/api/cloud/preferences", { + headers: OptionalBearerHeaders, + payload: EnvironmentCloudPreferencesRequest, + success: EnvironmentCloudLinkStateResult, + error: EnvironmentHttpCloudErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.post("health", "/api/t3-cloud/health", { + payload: RelayCloudEnvironmentHealthRequest, + success: RelayEnvironmentHealthResponse, + error: EnvironmentHttpCloudErrors, + }), + ) + .add( + HttpApiEndpoint.post("mintCredential", "/api/cloud/mint-credential", { + payload: RelayCloudMintCredentialRequest, + success: RelayEnvironmentMintResponse, + error: EnvironmentHttpCloudErrors, + }), + ) + .add( + HttpApiEndpoint.post("t3MintCredential", "/api/t3-cloud/mint-credential", { + payload: RelayCloudMintCredentialRequest, + success: RelayEnvironmentMintResponse, + error: EnvironmentHttpCloudErrors, + }), + ) {} + export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) - .add(EnvironmentOrchestrationHttpApi) {} + .add(EnvironmentOrchestrationHttpApi) + .add(EnvironmentCloudHttpApi) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index bf26014c46e..163d5e236cd 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -2,6 +2,7 @@ export * from "./baseSchemas.ts"; export * from "./auth.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; +export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; export * from "./remoteAccess.ts"; export * from "./ipc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 684974fcac5..56f929f7def 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -338,6 +338,11 @@ export const PersistedSavedEnvironmentRecordSchema = Schema.Struct({ createdAt: Schema.String, lastConnectedAt: Schema.NullOr(Schema.String), desktopSsh: Schema.optionalKey(DesktopSshEnvironmentTargetSchema), + relayManaged: Schema.optionalKey( + Schema.Struct({ + relayUrl: Schema.String, + }), + ), }); export type PersistedSavedEnvironmentRecord = typeof PersistedSavedEnvironmentRecordSchema.Type; @@ -372,6 +377,23 @@ export const PickFolderOptionsSchema = Schema.Struct({ initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), }); +export const DesktopCloudAuthFetchInputSchema = Schema.Struct({ + url: Schema.String, + method: Schema.optionalKey(Schema.String), + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.optionalKey(Schema.String), +}); +export type DesktopCloudAuthFetchInput = typeof DesktopCloudAuthFetchInputSchema.Type; + +export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ + ok: Schema.Boolean, + status: Schema.Number, + statusText: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, +}); +export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -417,6 +439,12 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + createCloudAuthRequest: () => Promise; + getCloudAuthToken: () => Promise; + setCloudAuthToken: (token: string) => Promise; + clearCloudAuthToken: () => Promise; + fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise; + onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; setUpdateChannel: (channel: DesktopUpdateChannel) => Promise; diff --git a/packages/contracts/src/relay.test.ts b/packages/contracts/src/relay.test.ts new file mode 100644 index 00000000000..42ce2e196ab --- /dev/null +++ b/packages/contracts/src/relay.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import * as OpenApi from "effect/unstable/httpapi/OpenApi"; + +import { RelayApi } from "./relay.ts"; + +describe("RelayApi security", () => { + it("describes DPoP access tokens using the HTTP DPoP authorization scheme", () => { + const document = OpenApi.fromApi(RelayApi); + + expect(document.components.securitySchemes?.relayDpop).toEqual({ + type: "http", + scheme: "DPoP", + description: "DPoP-bound access token. Requests must also include the DPoP proof JWT header.", + }); + }); +}); diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts new file mode 100644 index 00000000000..11b30ac3eee --- /dev/null +++ b/packages/contracts/src/relay.ts @@ -0,0 +1,971 @@ +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; +import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; +import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware"; +import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; +import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity"; +import * as OpenApi from "effect/unstable/httpapi/OpenApi"; + +import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ExecutionEnvironmentDescriptor } from "./environment.ts"; + +export const RelayAgentAwarenessPlatform = Schema.Literal("ios"); +export type RelayAgentAwarenessPlatform = typeof RelayAgentAwarenessPlatform.Type; + +export const RelayAgentAwarenessPhase = Schema.Literals([ + "starting", + "running", + "waiting_for_approval", + "waiting_for_input", + "completed", + "failed", + "stale", +]); +export type RelayAgentAwarenessPhase = typeof RelayAgentAwarenessPhase.Type; + +export const RelayAgentAwarenessPreferences = Schema.Struct({ + liveActivitiesEnabled: Schema.Boolean, + notificationsEnabled: Schema.Boolean, + notifyOnApproval: Schema.Boolean, + notifyOnInput: Schema.Boolean, + notifyOnCompletion: Schema.Boolean, + notifyOnFailure: Schema.Boolean, +}); +export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type; + +export const RelayDeviceRegistrationRequest = Schema.Struct({ + deviceId: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + platform: RelayAgentAwarenessPlatform, + iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), + appVersion: Schema.optional(TrimmedNonEmptyString), + pushToken: Schema.optional(TrimmedNonEmptyString), + pushToStartToken: Schema.optional(TrimmedNonEmptyString), + preferences: RelayAgentAwarenessPreferences, +}); +export type RelayDeviceRegistrationRequest = typeof RelayDeviceRegistrationRequest.Type; + +export const RelayClientDeviceRecord = Schema.Struct({ + deviceId: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, + platform: RelayAgentAwarenessPlatform, + iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), + appVersion: Schema.NullOr(TrimmedNonEmptyString), + notifications: Schema.Struct({ + enabled: Schema.Boolean, + notifyOnApproval: Schema.Boolean, + notifyOnInput: Schema.Boolean, + notifyOnCompletion: Schema.Boolean, + notifyOnFailure: Schema.Boolean, + }), + liveActivities: Schema.Struct({ + enabled: Schema.Boolean, + }), + updatedAt: TrimmedNonEmptyString, +}); +export type RelayClientDeviceRecord = typeof RelayClientDeviceRecord.Type; + +export const RelayListDevicesResponse = Schema.Struct({ + devices: Schema.Array(RelayClientDeviceRecord), +}); +export type RelayListDevicesResponse = typeof RelayListDevicesResponse.Type; + +export const RelayLiveActivityRegistrationRequest = Schema.Struct({ + deviceId: TrimmedNonEmptyString, + activityPushToken: TrimmedNonEmptyString, +}); +export type RelayLiveActivityRegistrationRequest = typeof RelayLiveActivityRegistrationRequest.Type; + +export const RelayDeviceUnregistrationParams = Schema.Struct({ + deviceId: TrimmedNonEmptyString, +}); +export type RelayDeviceUnregistrationParams = typeof RelayDeviceUnregistrationParams.Type; + +export const RelayAgentActivityState = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, + projectTitle: TrimmedNonEmptyString, + threadTitle: TrimmedNonEmptyString, + phase: RelayAgentAwarenessPhase, + headline: TrimmedNonEmptyString, + detail: Schema.optional(TrimmedNonEmptyString), + modelTitle: TrimmedNonEmptyString, + updatedAt: TrimmedNonEmptyString, + deepLink: TrimmedNonEmptyString, +}); +export type RelayAgentActivityState = typeof RelayAgentActivityState.Type; + +export const RelayAgentActivityAggregateRow = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, + projectTitle: TrimmedNonEmptyString, + threadTitle: TrimmedNonEmptyString, + modelTitle: TrimmedNonEmptyString, + phase: RelayAgentAwarenessPhase, + status: TrimmedNonEmptyString, + updatedAt: TrimmedNonEmptyString, + deepLink: TrimmedNonEmptyString, +}); +export type RelayAgentActivityAggregateRow = typeof RelayAgentActivityAggregateRow.Type; + +export const RelayAgentActivityAggregateState = Schema.Struct({ + title: TrimmedNonEmptyString, + subtitle: TrimmedNonEmptyString, + activeCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + updatedAt: TrimmedNonEmptyString, + activities: Schema.Array(RelayAgentActivityAggregateRow), +}); +export type RelayAgentActivityAggregateState = typeof RelayAgentActivityAggregateState.Type; + +export const RelayManagedEndpointProviderKind = Schema.Literals([ + "manual", + "cloudflare_tunnel", + "t3_relay", +]); +export type RelayManagedEndpointProviderKind = typeof RelayManagedEndpointProviderKind.Type; + +export const RelayManagedEndpoint = Schema.Struct({ + httpBaseUrl: TrimmedNonEmptyString, + wsBaseUrl: TrimmedNonEmptyString, + providerKind: RelayManagedEndpointProviderKind, +}); +export type RelayManagedEndpoint = typeof RelayManagedEndpoint.Type; + +export const RelayManagedEndpointOrigin = Schema.Struct({ + localHttpHost: TrimmedNonEmptyString, + localHttpPort: Schema.Int.check( + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(65_535), + ), +}); +export type RelayManagedEndpointOrigin = typeof RelayManagedEndpointOrigin.Type; + +export const RelayManagedEndpointRuntimeConfig = Schema.Struct({ + providerKind: RelayManagedEndpointProviderKind, + connectorToken: TrimmedNonEmptyString, + tunnelId: Schema.optional(TrimmedNonEmptyString), + tunnelName: Schema.optional(TrimmedNonEmptyString), +}); +export type RelayManagedEndpointRuntimeConfig = typeof RelayManagedEndpointRuntimeConfig.Type; + +export const RelayLinkProofRequest = Schema.Struct({ + challenge: Schema.String, + relayIssuer: Schema.String, + endpoint: RelayManagedEndpoint, + origin: RelayManagedEndpointOrigin, +}); +export type RelayLinkProofRequest = typeof RelayLinkProofRequest.Type; + +export const RelayEnvironmentConfigRequest = Schema.Struct({ + relayUrl: Schema.String, + relayIssuer: Schema.optional(Schema.String), + cloudUserId: Schema.String, + environmentCredential: Schema.String, + cloudMintPublicKey: Schema.String, + endpointRuntime: Schema.NullOr(RelayManagedEndpointRuntimeConfig), +}); +export type RelayEnvironmentConfigRequest = typeof RelayEnvironmentConfigRequest.Type; + +const RelaySignedJwtRegisteredClaims = { + iss: TrimmedNonEmptyString, + aud: TrimmedNonEmptyString, + sub: TrimmedNonEmptyString, + jti: TrimmedNonEmptyString, + iat: Schema.Int, + exp: Schema.Int, +} as const; + +export const RelayAgentActivityPublishProofPayload = Schema.Struct({ + ...RelaySignedJwtRegisteredClaims, + environmentId: EnvironmentId, + threadId: ThreadId, + state: Schema.NullOr(RelayAgentActivityState), +}); +export type RelayAgentActivityPublishProofPayload = + typeof RelayAgentActivityPublishProofPayload.Type; +export type RelayAgentActivityPublishProof = string; + +export const RelayAgentActivityPublishRequest = Schema.Struct({ + state: Schema.NullOr(RelayAgentActivityState).annotate({ + description: "Current agent-awareness state, or null to remove the published state.", + }), + proof: TrimmedNonEmptyString.annotate({ + description: "Environment-signed JWT covering this published activity state.", + }), +}).annotate({ description: "Publishes a signed agent-awareness update from an environment." }); +export type RelayAgentActivityPublishRequest = typeof RelayAgentActivityPublishRequest.Type; + +export const RelayEnvironmentLinkScope = Schema.Literals([ + "agent_activity_notifications", + "managed_tunnels", +]); +export type RelayEnvironmentLinkScope = typeof RelayEnvironmentLinkScope.Type; + +export const RelayEnvironmentLinkProofPayload = Schema.Struct({ + ...RelaySignedJwtRegisteredClaims, + challenge: TrimmedNonEmptyString, + descriptor: ExecutionEnvironmentDescriptor, + environmentId: EnvironmentId, + environmentPublicKey: TrimmedNonEmptyString, + endpoint: RelayManagedEndpoint, + origin: RelayManagedEndpointOrigin, + scopes: Schema.Array(RelayEnvironmentLinkScope), +}); +export type RelayEnvironmentLinkProofPayload = typeof RelayEnvironmentLinkProofPayload.Type; + +export const RelayEnvironmentLinkProof = TrimmedNonEmptyString; +export type RelayEnvironmentLinkProof = typeof RelayEnvironmentLinkProof.Type; + +export const RelayEnvironmentLinkChallengeRequest = Schema.Struct({ + notificationsEnabled: Schema.Boolean.annotate({ + description: "Whether this link may deliver push notifications.", + }), + liveActivitiesEnabled: Schema.Boolean.annotate({ + description: "Whether this link may update Live Activities.", + }), + managedTunnelsEnabled: Schema.Boolean.annotate({ + description: "Whether the relay should provision a managed tunnel for this environment.", + }), +}).annotate({ description: "Requested capabilities for a new environment-link challenge." }); +export type RelayEnvironmentLinkChallengeRequest = typeof RelayEnvironmentLinkChallengeRequest.Type; + +export const RelayEnvironmentLinkChallengeResponse = Schema.Struct({ + challenge: TrimmedNonEmptyString, + expiresAt: TrimmedNonEmptyString, +}); +export type RelayEnvironmentLinkChallengeResponse = + typeof RelayEnvironmentLinkChallengeResponse.Type; + +export const RelayEnvironmentLinkRequest = Schema.Struct({ + deviceId: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "Optional client device identifier associated with this link.", + }), + ), + proof: RelayEnvironmentLinkProof.annotate({ + description: "Environment-signed proof bound to a previously issued link challenge.", + }), + notificationsEnabled: Schema.Boolean, + liveActivitiesEnabled: Schema.Boolean, + managedTunnelsEnabled: Schema.Boolean, +}).annotate({ description: "Links an authenticated cloud user to a T3 environment." }); +export type RelayEnvironmentLinkRequest = typeof RelayEnvironmentLinkRequest.Type; + +export const RelayEnvironmentLinkResponse = Schema.Struct({ + ok: Schema.Boolean, + cloudUserId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + endpoint: RelayManagedEndpoint, + endpointRuntime: Schema.NullOr(RelayManagedEndpointRuntimeConfig), + relayIssuer: TrimmedNonEmptyString, + environmentCredential: TrimmedNonEmptyString, + cloudMintPublicKey: TrimmedNonEmptyString, +}); +export type RelayEnvironmentLinkResponse = typeof RelayEnvironmentLinkResponse.Type; + +export const RelayEnvironmentLinkProofInvalidReason = Schema.Literals([ + "invalid_signature_or_scope", + "descriptor_mismatch", + "replayed_nonce", + "challenge_invalid", + "origin_not_allowed", + "endpoint_not_secure", +]); +export type RelayEnvironmentLinkProofInvalidReason = + typeof RelayEnvironmentLinkProofInvalidReason.Type; + +export const RelayEnvironmentLinkFailedReason = Schema.Literals([ + "link_persistence_failed", + "credential_persistence_failed", + "replay_persistence_failed", + "internal_error", +]); +export type RelayEnvironmentLinkFailedReason = typeof RelayEnvironmentLinkFailedReason.Type; + +export const RelayEnvironmentLinkUnavailableReason = Schema.Literals([ + "managed_endpoint_not_configured", + "managed_endpoint_provisioning_failed", +]); +export type RelayEnvironmentLinkUnavailableReason = + typeof RelayEnvironmentLinkUnavailableReason.Type; + +export const RelayEnvironmentEndpointUnavailableReason = Schema.Literals([ + "endpoint_request_failed", + "endpoint_response_invalid", +]); +export type RelayEnvironmentEndpointUnavailableReason = + typeof RelayEnvironmentEndpointUnavailableReason.Type; + +export const RelayAgentActivityPublishProofInvalidReason = Schema.Literals([ + "invalid_signature_or_payload", + "replayed_nonce", +]); +export type RelayAgentActivityPublishProofInvalidReason = + typeof RelayAgentActivityPublishProofInvalidReason.Type; + +export const RelayAuthInvalidReason = Schema.Literals([ + "missing_bearer", + "invalid_bearer", + "invalid_dpop", + "not_authorized", +]); +export type RelayAuthInvalidReason = typeof RelayAuthInvalidReason.Type; + +export const RelayInternalErrorReason = Schema.Literals([ + "database_unavailable", + "persistence_failed", + "upstream_unavailable", + "internal_error", +]); +export type RelayInternalErrorReason = typeof RelayInternalErrorReason.Type; + +export class RelayAuthInvalidError extends Schema.TaggedErrorClass()( + "RelayAuthInvalidError", + { + code: Schema.Literal("auth_invalid"), + reason: RelayAuthInvalidReason, + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 401 }, +) {} + +export class RelayEnvironmentLinkProofExpiredError extends Schema.TaggedErrorClass()( + "RelayEnvironmentLinkProofExpiredError", + { + code: Schema.Literal("environment_link_proof_expired"), + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 401 }, +) {} + +export class RelayEnvironmentLinkProofInvalidError extends Schema.TaggedErrorClass()( + "RelayEnvironmentLinkProofInvalidError", + { + code: Schema.Literal("environment_link_proof_invalid"), + reason: RelayEnvironmentLinkProofInvalidReason, + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 400 }, +) {} + +export class RelayEnvironmentConnectNotAuthorizedError extends Schema.TaggedErrorClass()( + "RelayEnvironmentConnectNotAuthorizedError", + { + code: Schema.Literal("environment_connect_not_authorized"), + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 403 }, +) {} + +export class RelayEnvironmentEndpointUnavailableError extends Schema.TaggedErrorClass()( + "RelayEnvironmentEndpointUnavailableError", + { + code: Schema.Literal("environment_endpoint_unavailable"), + reason: RelayEnvironmentEndpointUnavailableReason, + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 502 }, +) {} + +export class RelayEnvironmentEndpointTimedOutError extends Schema.TaggedErrorClass()( + "RelayEnvironmentEndpointTimedOutError", + { + code: Schema.Literal("environment_endpoint_timed_out"), + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 504 }, +) {} + +export class RelayEnvironmentLinkFailedError extends Schema.TaggedErrorClass()( + "RelayEnvironmentLinkFailedError", + { + code: Schema.Literal("environment_link_failed"), + reason: RelayEnvironmentLinkFailedReason, + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 500 }, +) {} + +export class RelayEnvironmentLinkUnavailableError extends Schema.TaggedErrorClass()( + "RelayEnvironmentLinkUnavailableError", + { + code: Schema.Literal("environment_link_unavailable"), + reason: RelayEnvironmentLinkUnavailableReason, + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 503 }, +) {} + +export class RelayAgentActivityPublishProofExpiredError extends Schema.TaggedErrorClass()( + "RelayAgentActivityPublishProofExpiredError", + { + code: Schema.Literal("agent_activity_publish_proof_expired"), + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 401 }, +) {} + +export class RelayAgentActivityPublishProofInvalidError extends Schema.TaggedErrorClass()( + "RelayAgentActivityPublishProofInvalidError", + { + code: Schema.Literal("agent_activity_publish_proof_invalid"), + reason: RelayAgentActivityPublishProofInvalidReason, + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 401 }, +) {} + +export class RelayInternalError extends Schema.TaggedErrorClass()( + "RelayInternalError", + { + code: Schema.Literal("internal_error"), + reason: RelayInternalErrorReason, + traceId: TrimmedNonEmptyString, + }, + { httpApiStatus: 500 }, +) {} + +export const RelayProtectedError = Schema.Union([ + RelayAuthInvalidError, + RelayEnvironmentLinkProofExpiredError, + RelayEnvironmentLinkProofInvalidError, + RelayEnvironmentConnectNotAuthorizedError, + RelayEnvironmentEndpointUnavailableError, + RelayEnvironmentEndpointTimedOutError, + RelayEnvironmentLinkFailedError, + RelayEnvironmentLinkUnavailableError, + RelayAgentActivityPublishProofExpiredError, + RelayAgentActivityPublishProofInvalidError, + RelayInternalError, +]); +export type RelayProtectedError = typeof RelayProtectedError.Type; + +const RelayAuthAndInternalErrors = [RelayAuthInvalidError, RelayInternalError] as const; + +const RelayEnvironmentLinkErrors = [ + RelayAuthInvalidError, + RelayEnvironmentLinkProofExpiredError, + RelayEnvironmentLinkProofInvalidError, + RelayEnvironmentLinkUnavailableError, + RelayEnvironmentLinkFailedError, + RelayInternalError, +] as const; + +const RelayEnvironmentConnectErrors = [ + RelayAuthInvalidError, + RelayEnvironmentConnectNotAuthorizedError, + RelayEnvironmentEndpointUnavailableError, + RelayEnvironmentEndpointTimedOutError, + RelayInternalError, +] as const; + +const RelayAgentActivityPublishErrors = [ + RelayAuthInvalidError, + RelayAgentActivityPublishProofExpiredError, + RelayAgentActivityPublishProofInvalidError, + RelayInternalError, +] as const; + +export interface RelayClientPrincipalShape { + readonly userId: string; + readonly token: string; + readonly proofKeyThumbprint?: string; + readonly dpopScopes?: ReadonlyArray; +} + +export class RelayClientPrincipal extends Context.Service< + RelayClientPrincipal, + RelayClientPrincipalShape +>()("@t3tools/contracts/relay/RelayClientPrincipal") {} + +export interface RelayEnvironmentPrincipalShape { + readonly environmentId: string; + readonly environmentPublicKey: string; +} + +export class RelayEnvironmentPrincipal extends Context.Service< + RelayEnvironmentPrincipal, + RelayEnvironmentPrincipalShape +>()("@t3tools/contracts/relay/RelayEnvironmentPrincipal") {} + +const RelayClientBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe( + HttpApiSecurity.annotate( + OpenApi.Description, + "Clerk session or OAuth bearer token for the signed-in T3 Cloud user.", + ), +); + +export class RelayClientAuth extends HttpApiMiddleware.Service< + RelayClientAuth, + { provides: RelayClientPrincipal } +>()("RelayClientAuth", { + error: RelayAuthInvalidError, + security: { clientBearer: RelayClientBearerAuthorization }, +}) {} + +const RelayEnvironmentBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe( + HttpApiSecurity.annotate( + OpenApi.Description, + "Relay-issued environment credential installed when the environment is linked.", + ), +); + +export class RelayEnvironmentAuth extends HttpApiMiddleware.Service< + RelayEnvironmentAuth, + { provides: RelayEnvironmentPrincipal } +>()("RelayEnvironmentAuth", { + error: [RelayAuthInvalidError, RelayInternalError], + security: { environmentBearer: RelayEnvironmentBearerAuthorization }, +}) {} + +const RelayDpopAuthorization = HttpApiSecurity.http({ scheme: "DPoP" }).pipe( + HttpApiSecurity.annotate( + OpenApi.Description, + "DPoP-bound access token. Requests must also include the DPoP proof JWT header.", + ), +); + +export class RelayDpopClientAuth extends HttpApiMiddleware.Service< + RelayDpopClientAuth, + { provides: RelayClientPrincipal } +>()("RelayDpopClientAuth", { + error: RelayAuthInvalidError, + security: { relayDpop: RelayDpopAuthorization }, +}) {} + +export const RelayClientEnvironmentRecord = Schema.Struct({ + environmentId: EnvironmentId, + label: TrimmedNonEmptyString, + endpoint: RelayManagedEndpoint, + linkedAt: TrimmedNonEmptyString, +}); +export type RelayClientEnvironmentRecord = typeof RelayClientEnvironmentRecord.Type; + +export const RelayListEnvironmentsResponse = Schema.Struct({ + environments: Schema.Array(RelayClientEnvironmentRecord), +}); +export type RelayListEnvironmentsResponse = typeof RelayListEnvironmentsResponse.Type; + +export const RelayEnvironmentConnectRequest = Schema.Struct({ + deviceId: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "Optional client device identifier requesting the connection.", + }), + ), + clientKeyThumbprint: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "Deprecated alias for clientProofKeyThumbprint.", + }), + ), + clientProofKeyThumbprint: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "JWK thumbprint that the minted environment credential must be bound to.", + }), + ), +}).annotate({ description: "Requests a short-lived credential for connecting to an environment." }); +export type RelayEnvironmentConnectRequest = typeof RelayEnvironmentConnectRequest.Type; + +export const RelayEnvironmentConnectScope = "environment:connect" as const; +export const RelayEnvironmentStatusScope = "environment:status" as const; +export const RelayMobileRegistrationScope = "mobile:registration" as const; +export const RelayDpopAccessTokenScope = Schema.Literals([ + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, + RelayMobileRegistrationScope, +]); +export type RelayDpopAccessTokenScope = typeof RelayDpopAccessTokenScope.Type; + +export const RelayDpopTokenExchangeGrantType = + "urn:ietf:params:oauth:grant-type:token-exchange" as const; +export const RelayJwtSubjectTokenType = "urn:ietf:params:oauth:token-type:jwt" as const; +export const RelayAccessTokenType = "urn:ietf:params:oauth:token-type:access_token" as const; +export const RelayPublicClientId = Schema.Literals(["t3-mobile", "t3-web"]); +export type RelayPublicClientId = typeof RelayPublicClientId.Type; +export const RelayMobileClientId = "t3-mobile" as const; +export const RelayWebClientId = "t3-web" as const; + +export const RelayDpopAccessTokenRequest = Schema.Struct({ + grant_type: Schema.Literal(RelayDpopTokenExchangeGrantType), + subject_token: TrimmedNonEmptyString.annotate({ + description: "Clerk bearer token for the signed-in cloud user.", + }), + subject_token_type: Schema.Literal(RelayJwtSubjectTokenType), + requested_token_type: Schema.Literal(RelayAccessTokenType), + resource: TrimmedNonEmptyString.annotate({ + description: "Relay issuer URL that will receive the DPoP-bound access token.", + }), + scope: TrimmedNonEmptyString.annotate({ + description: "Space-separated relay scopes requested by the client.", + }), + client_id: RelayPublicClientId, +}) + .annotate({ description: "OAuth token exchange request for a DPoP-bound relay access token." }) + .pipe(HttpApiSchema.asFormUrlEncoded()); +export type RelayDpopAccessTokenRequest = typeof RelayDpopAccessTokenRequest.Type; + +export const RelayDpopAccessTokenResponse = Schema.Struct({ + access_token: TrimmedNonEmptyString, + issued_token_type: Schema.Literal(RelayAccessTokenType), + token_type: Schema.Literal("DPoP"), + expires_in: Schema.Int.check(Schema.isGreaterThan(0)), + scope: TrimmedNonEmptyString, +}); +export type RelayDpopAccessTokenResponse = typeof RelayDpopAccessTokenResponse.Type; + +export const RelayBearerRequestHeaders = Schema.Struct({ + authorization: TrimmedNonEmptyString, +}); + +export const RelayDpopProofRequestHeaders = Schema.Struct({ + dpop: TrimmedNonEmptyString, +}); + +export const RelayDpopRequestHeaders = Schema.Struct({ + authorization: TrimmedNonEmptyString, + dpop: TrimmedNonEmptyString, +}); + +export const RelayAuthorizationServerMetadata = Schema.Struct({ + issuer: TrimmedNonEmptyString, + token_endpoint: TrimmedNonEmptyString, + grant_types_supported: Schema.Array(Schema.Literal(RelayDpopTokenExchangeGrantType)), + token_endpoint_auth_methods_supported: Schema.Array(Schema.Literal("none")), + dpop_signing_alg_values_supported: Schema.Array(Schema.Literal("ES256")), + scopes_supported: Schema.Array(RelayDpopAccessTokenScope), +}); + +export const RelayProtectedResourceMetadata = Schema.Struct({ + resource: TrimmedNonEmptyString, + authorization_servers: Schema.Array(TrimmedNonEmptyString), + scopes_supported: Schema.Array(RelayDpopAccessTokenScope), + dpop_bound_access_tokens_required: Schema.Boolean, + dpop_signing_alg_values_supported: Schema.Array(Schema.Literal("ES256")), +}); + +export const RelayEnvironmentUnlinkParams = Schema.Struct({ + environmentId: EnvironmentId, +}); +export type RelayEnvironmentUnlinkParams = typeof RelayEnvironmentUnlinkParams.Type; + +export const RelayEnvironmentConnectResponse = Schema.Struct({ + environmentId: EnvironmentId, + endpoint: RelayManagedEndpoint, + credential: TrimmedNonEmptyString, + expiresAt: TrimmedNonEmptyString, +}); +export type RelayEnvironmentConnectResponse = typeof RelayEnvironmentConnectResponse.Type; + +export const RelayEnvironmentStatusValue = Schema.Literals(["online", "offline"]); +export type RelayEnvironmentStatusValue = typeof RelayEnvironmentStatusValue.Type; + +export const RelayEnvironmentStatusResponse = Schema.Struct({ + environmentId: EnvironmentId, + endpoint: RelayManagedEndpoint, + status: RelayEnvironmentStatusValue, + checkedAt: TrimmedNonEmptyString, + descriptor: Schema.optional(ExecutionEnvironmentDescriptor), + error: Schema.optional(TrimmedNonEmptyString), +}); +export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type; + +export const RelayCloudMintCredentialProofPayload = Schema.Struct({ + ...RelaySignedJwtRegisteredClaims, + environmentId: EnvironmentId, + clientProofKeyThumbprint: TrimmedNonEmptyString, + cnf: Schema.Struct({ + jkt: TrimmedNonEmptyString, + }), + deviceId: Schema.optional(TrimmedNonEmptyString), + nonce: TrimmedNonEmptyString, + scope: Schema.Array(Schema.Literal("environment:connect")), +}); +export type RelayCloudMintCredentialProofPayload = typeof RelayCloudMintCredentialProofPayload.Type; + +export const RelayCloudMintCredentialProof = TrimmedNonEmptyString; +export type RelayCloudMintCredentialProof = typeof RelayCloudMintCredentialProof.Type; + +export const RelayCloudMintCredentialRequest = Schema.Struct({ + proof: RelayCloudMintCredentialProof, +}); +export type RelayCloudMintCredentialRequest = typeof RelayCloudMintCredentialRequest.Type; + +export const RelayCloudEnvironmentHealthProofPayload = Schema.Struct({ + ...RelaySignedJwtRegisteredClaims, + environmentId: EnvironmentId, + nonce: TrimmedNonEmptyString, + scope: Schema.Array(Schema.Literal("environment:status")), +}); +export type RelayCloudEnvironmentHealthProofPayload = + typeof RelayCloudEnvironmentHealthProofPayload.Type; + +export const RelayCloudEnvironmentHealthProof = TrimmedNonEmptyString; +export type RelayCloudEnvironmentHealthProof = typeof RelayCloudEnvironmentHealthProof.Type; + +export const RelayCloudEnvironmentHealthRequest = Schema.Struct({ + proof: RelayCloudEnvironmentHealthProof, +}); +export type RelayCloudEnvironmentHealthRequest = typeof RelayCloudEnvironmentHealthRequest.Type; + +export const RelayEnvironmentHealthResponseProofPayload = Schema.Struct({ + ...RelaySignedJwtRegisteredClaims, + environmentId: EnvironmentId, + requestNonce: TrimmedNonEmptyString, + status: Schema.Literal("online"), + descriptor: ExecutionEnvironmentDescriptor, + checkedAt: TrimmedNonEmptyString, +}); +export type RelayEnvironmentHealthResponseProofPayload = + typeof RelayEnvironmentHealthResponseProofPayload.Type; + +export const RelayEnvironmentHealthResponse = Schema.Struct({ + environmentId: EnvironmentId, + status: Schema.Literal("online"), + descriptor: ExecutionEnvironmentDescriptor, + checkedAt: TrimmedNonEmptyString, + proof: TrimmedNonEmptyString, +}); +export type RelayEnvironmentHealthResponse = typeof RelayEnvironmentHealthResponse.Type; + +export const RelayEnvironmentMintResponseProofPayload = Schema.Struct({ + ...RelaySignedJwtRegisteredClaims, + environmentId: EnvironmentId, + clientProofKeyThumbprint: TrimmedNonEmptyString, + requestNonce: TrimmedNonEmptyString, + credential: TrimmedNonEmptyString, +}); +export type RelayEnvironmentMintResponseProofPayload = + typeof RelayEnvironmentMintResponseProofPayload.Type; + +export const RelayEnvironmentMintResponse = Schema.Struct({ + credential: TrimmedNonEmptyString, + expiresAt: TrimmedNonEmptyString, + proof: TrimmedNonEmptyString, +}); +export type RelayEnvironmentMintResponse = typeof RelayEnvironmentMintResponse.Type; + +export const RelayDeliveryKind = Schema.Literals([ + "live_activity_start", + "live_activity_update", + "live_activity_end", + "push_notification", +]); +export type RelayDeliveryKind = typeof RelayDeliveryKind.Type; + +export const RelayDeliveryResult = Schema.Struct({ + deviceId: TrimmedNonEmptyString, + kind: RelayDeliveryKind, + ok: Schema.Boolean, + queued: Schema.optional(Schema.Boolean), + apnsStatus: Schema.NullOr(Schema.Number), + apnsReason: Schema.NullOr(Schema.String), + apnsId: Schema.NullOr(Schema.String), +}); +export type RelayDeliveryResult = typeof RelayDeliveryResult.Type; + +export const RelayOkResponse = Schema.Struct({ + ok: Schema.Boolean, +}); +export type RelayOkResponse = typeof RelayOkResponse.Type; + +export const RelayPublishResponse = Schema.Struct({ + ok: Schema.Boolean, + deliveries: Schema.Array(RelayDeliveryResult), +}); +export type RelayPublishResponse = typeof RelayPublishResponse.Type; + +export const RelayHealthResponse = Schema.Struct({ + ok: Schema.Boolean, + service: Schema.Literal("relay"), +}); +export type RelayHealthResponse = typeof RelayHealthResponse.Type; + +export const RelayHealthGroup = HttpApiGroup.make("health") + .add( + HttpApiEndpoint.get("health", "/health", { + success: RelayHealthResponse, + error: RelayInternalError, + }).annotate(OpenApi.Summary, "Check relay health"), + ) + .annotate(OpenApi.Description, "Service health and readiness."); + +export const RelayMetadataGroup = HttpApiGroup.make("metadata") + .add( + HttpApiEndpoint.get("authorizationServer", "/.well-known/oauth-authorization-server", { + success: RelayAuthorizationServerMetadata, + }).annotate(OpenApi.Summary, "Read OAuth authorization-server metadata"), + HttpApiEndpoint.get("protectedResource", "/.well-known/oauth-protected-resource", { + success: RelayProtectedResourceMetadata, + }).annotate(OpenApi.Summary, "Read OAuth protected-resource metadata"), + ) + .annotate(OpenApi.Description, "OAuth and DPoP discovery metadata."); + +export const RelayRegisterDeviceEndpoint = HttpApiEndpoint.post( + "registerDevice", + "/v1/mobile/devices", + { + headers: RelayDpopRequestHeaders, + payload: RelayDeviceRegistrationRequest, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }, +).annotate(OpenApi.Summary, "Register or update a mobile device"); + +export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post( + "registerLiveActivity", + "/v1/mobile/live-activities", + { + headers: RelayDpopRequestHeaders, + payload: RelayLiveActivityRegistrationRequest, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }, +).annotate(OpenApi.Summary, "Register a Live Activity push token"); + +export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( + "unregisterDevice", + "/v1/mobile/devices/:deviceId", + { + headers: RelayDpopRequestHeaders, + params: RelayDeviceUnregistrationParams, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }, +).annotate(OpenApi.Summary, "Unregister a mobile device"); + +export const RelayMobileGroup = HttpApiGroup.make("mobile") + .add( + RelayRegisterDeviceEndpoint, + RelayRegisterLiveActivityEndpoint, + RelayUnregisterDeviceEndpoint, + ) + .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") + .middleware(RelayDpopClientAuth); + +export const RelayClientGroup = HttpApiGroup.make("client") + .add( + HttpApiEndpoint.get("listEnvironments", "/v1/environments", { + headers: RelayBearerRequestHeaders, + success: RelayListEnvironmentsResponse, + error: RelayAuthAndInternalErrors, + }).annotate(OpenApi.Summary, "List linked environments"), + HttpApiEndpoint.get("listDevices", "/v1/client/devices", { + headers: RelayBearerRequestHeaders, + success: RelayListDevicesResponse, + error: RelayAuthAndInternalErrors, + }).annotate(OpenApi.Summary, "List registered mobile devices"), + HttpApiEndpoint.post("linkEnvironment", "/v1/client/environment-links", { + headers: RelayBearerRequestHeaders, + payload: RelayEnvironmentLinkRequest, + success: RelayEnvironmentLinkResponse, + error: RelayEnvironmentLinkErrors, + }).annotate(OpenApi.Summary, "Link an environment"), + HttpApiEndpoint.post( + "createEnvironmentLinkChallenge", + "/v1/client/environment-link-challenges", + { + headers: RelayBearerRequestHeaders, + payload: RelayEnvironmentLinkChallengeRequest, + success: RelayEnvironmentLinkChallengeResponse, + error: RelayAuthAndInternalErrors, + }, + ).annotate(OpenApi.Summary, "Create an environment-link challenge"), + HttpApiEndpoint.delete("unlinkEnvironment", "/v1/client/environment-links/:environmentId", { + headers: RelayBearerRequestHeaders, + params: RelayEnvironmentUnlinkParams, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }).annotate(OpenApi.Summary, "Unlink an environment"), + ) + .annotate(OpenApi.Description, "Cloud-user environment links and registered devices.") + .middleware(RelayClientAuth); + +export const RelayExchangeDpopAccessTokenEndpoint = HttpApiEndpoint.post( + "exchangeDpopAccessToken", + "/v1/client/dpop-token", + { + headers: RelayDpopProofRequestHeaders, + payload: RelayDpopAccessTokenRequest, + success: RelayDpopAccessTokenResponse, + error: RelayAuthAndInternalErrors, + }, +) + .annotate(OpenApi.Summary, "Exchange a Clerk token for a DPoP access token") + .annotate( + OpenApi.Description, + "Bootstrap endpoint. Send the DPoP proof JWT in the dpop header and the Clerk token in subject_token. The returned access token is bound to the proof key.", + ); + +export const RelayTokenGroup = HttpApiGroup.make("token") + .add(RelayExchangeDpopAccessTokenEndpoint) + .annotate(OpenApi.Description, "OAuth token exchange for DPoP-bound client access."); + +export const RelayConnectEnvironmentEndpoint = HttpApiEndpoint.post( + "connectEnvironment", + "/v1/environments/:environmentId/connect", + { + headers: RelayDpopRequestHeaders, + params: Schema.Struct({ + environmentId: EnvironmentId, + }), + payload: RelayEnvironmentConnectRequest, + success: RelayEnvironmentConnectResponse, + error: RelayEnvironmentConnectErrors, + }, +).annotate(OpenApi.Summary, "Connect to an environment"); + +export const RelayGetEnvironmentStatusEndpoint = HttpApiEndpoint.post( + "getEnvironmentStatus", + "/v1/environments/:environmentId/status", + { + headers: RelayDpopRequestHeaders, + params: Schema.Struct({ + environmentId: EnvironmentId, + }), + success: RelayEnvironmentStatusResponse, + error: RelayEnvironmentConnectErrors, + }, +).annotate(OpenApi.Summary, "Check environment status"); + +export const RelayDpopClientGroup = HttpApiGroup.make("dpopClient") + .add(RelayConnectEnvironmentEndpoint, RelayGetEnvironmentStatusEndpoint) + .annotate(OpenApi.Description, "DPoP-authenticated client access to linked environments.") + .middleware(RelayDpopClientAuth); + +export const RelayServerGroup = HttpApiGroup.make("server") + .add( + HttpApiEndpoint.post( + "publishAgentActivity", + "/v1/environments/:environmentId/threads/:threadId/agent-activity", + { + params: Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, + }), + payload: RelayAgentActivityPublishRequest, + success: RelayPublishResponse, + error: RelayAgentActivityPublishErrors, + }, + ).annotate(OpenApi.Summary, "Publish agent activity"), + ) + .annotate(OpenApi.Description, "Environment-authenticated activity publication.") + .middleware(RelayEnvironmentAuth); + +export const RelayApi = HttpApi.make("RelayApi") + .add( + RelayHealthGroup, + RelayMetadataGroup, + RelayMobileGroup, + RelayClientGroup, + RelayTokenGroup, + RelayDpopClientGroup, + RelayServerGroup, + ) + .annotate(OpenApi.Title, "T3 Code Relay API") + .annotate(OpenApi.Version, "1.0.0") + .annotate( + OpenApi.Description, + "Control-plane API for linking T3 environments, connecting authorized clients, and publishing agent activity.", + ); +export type RelayApi = typeof RelayApi; diff --git a/packages/contracts/src/relayClient.ts b/packages/contracts/src/relayClient.ts new file mode 100644 index 00000000000..e78078d1eed --- /dev/null +++ b/packages/contracts/src/relayClient.ts @@ -0,0 +1,63 @@ +import * as Schema from "effect/Schema"; + +export const RelayClientStatusSchema = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("available"), + executablePath: Schema.String, + source: Schema.Literals(["override", "managed", "path"]), + version: Schema.String, + }), + Schema.Struct({ + status: Schema.Literal("missing"), + version: Schema.String, + }), + Schema.Struct({ + status: Schema.Literal("unsupported"), + platform: Schema.String, + arch: Schema.String, + version: Schema.String, + }), +]); +export type RelayClientStatus = typeof RelayClientStatusSchema.Type; + +export const RelayClientInstallProgressStageSchema = Schema.Literals([ + "checking", + "waiting_for_lock", + "downloading", + "verifying", + "installing", + "validating", + "activating", +]); +export type RelayClientInstallProgressStage = typeof RelayClientInstallProgressStageSchema.Type; + +export const RelayClientInstallProgressEventSchema = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("progress"), + stage: RelayClientInstallProgressStageSchema, + }), + Schema.Struct({ + type: Schema.Literal("complete"), + status: RelayClientStatusSchema, + }), +]); +export type RelayClientInstallProgressEvent = typeof RelayClientInstallProgressEventSchema.Type; + +export const RelayClientInstallFailureReasonSchema = Schema.Literals([ + "download_failed", + "invalid_checksum", + "install_locked", + "override_missing", + "unsupported_platform", + "validation_failed", + "write_failed", +]); +export type RelayClientInstallFailureReason = typeof RelayClientInstallFailureReasonSchema.Type; + +export class RelayClientInstallFailedError extends Schema.TaggedErrorClass()( + "RelayClientInstallFailedError", + { + reason: RelayClientInstallFailureReasonSchema, + message: Schema.String, + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 814e403b64c..5a145f3f657 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -58,6 +58,11 @@ import { OrchestrationRpcSchemas, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { + RelayClientInstallFailedError, + RelayClientInstallProgressEventSchema, + RelayClientStatusSchema, +} from "./relayClient.ts"; import { ProjectSearchEntriesError, ProjectSearchEntriesInput, @@ -166,6 +171,10 @@ export const WS_METHODS = { serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverSignalProcess: "server.signalProcess", + // Cloud environment methods + cloudGetRelayClientStatus: "cloud.getRelayClientStatus", + cloudInstallRelayClient: "cloud.installRelayClient", + // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", @@ -263,6 +272,19 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, error: EnvironmentAuthorizationError, }); +export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { + payload: Schema.Struct({}), + success: RelayClientStatusSchema, + error: EnvironmentAuthorizationError, +}); + +export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRelayClient, { + payload: Schema.Struct({}), + success: RelayClientInstallProgressEventSchema, + error: Schema.Union([RelayClientInstallFailedError, EnvironmentAuthorizationError]), + stream: true, +}); + export const WsSourceControlLookupRepositoryRpc = Rpc.make( WS_METHODS.sourceControlLookupRepository, { @@ -536,6 +558,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetProcessDiagnosticsRpc, WsServerGetProcessResourceHistoryRpc, WsServerSignalProcessRpc, + WsCloudGetRelayClientStatusRpc, + WsCloudInstallRelayClientRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index 873b8e4c2ff..2fb6a6b6ee7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -11,6 +11,10 @@ "types": "./src/advertisedEndpoint.ts", "import": "./src/advertisedEndpoint.ts" }, + "./agentAwareness": { + "types": "./src/agentAwareness.ts", + "import": "./src/agentAwareness.ts" + }, "./git": { "types": "./src/git.ts", "import": "./src/git.ts" @@ -79,6 +83,30 @@ "types": "./src/remote.ts", "import": "./src/remote.ts" }, + "./relaySigning": { + "types": "./src/relaySigning.ts", + "import": "./src/relaySigning.ts" + }, + "./dpop": { + "types": "./src/dpop.ts", + "import": "./src/dpop.ts" + }, + "./dpopCommon": { + "types": "./src/dpopCommon.ts", + "import": "./src/dpopCommon.ts" + }, + "./relayAuth": { + "types": "./src/relayAuth.ts", + "import": "./src/relayAuth.ts" + }, + "./relayUrl": { + "types": "./src/relayUrl.ts", + "import": "./src/relayUrl.ts" + }, + "./relayJwt": { + "types": "./src/relayJwt.ts", + "import": "./src/relayJwt.ts" + }, "./oauthScope": { "types": "./src/oauthScope.ts", "import": "./src/oauthScope.ts" @@ -110,6 +138,10 @@ "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" + }, + "./relayClient": { + "types": "./src/relayClient.ts", + "import": "./src/relayClient.ts" } }, "scripts": { @@ -117,8 +149,11 @@ "test": "vp test run" }, "dependencies": { + "@noble/curves": "catalog:", + "@noble/hashes": "catalog:", "@t3tools/contracts": "workspace:*", - "effect": "catalog:" + "effect": "catalog:", + "jose": "catalog:" }, "devDependencies": { "@effect/platform-node": "catalog:", diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts new file mode 100644 index 00000000000..217e0016352 --- /dev/null +++ b/packages/shared/src/agentAwareness.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "@effect/vitest"; + +import type { + EnvironmentId, + OrchestrationProjectShell, + OrchestrationThreadShell, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { ProviderInstanceId } from "@t3tools/contracts"; + +import { projectThreadAwareness } from "./agentAwareness.ts"; + +const NOW = "2026-05-22T12:00:00.000Z"; + +const project = { + title: "t3code", +} satisfies Pick; + +function thread( + overrides: Partial = {}, +): Pick< + OrchestrationThreadShell, + | "id" + | "title" + | "modelSelection" + | "session" + | "latestTurn" + | "updatedAt" + | "hasPendingApprovals" + | "hasPendingUserInput" +> { + return { + id: "thread-1" as ThreadId, + title: "Fix failing CI", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + session: null, + latestTurn: null, + updatedAt: NOW, + hasPendingApprovals: false, + hasPendingUserInput: false, + ...overrides, + }; +} + +describe("projectThreadAwareness", () => { + it("returns null for idle threads without an active awareness state", () => { + expect( + projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread(), + }), + ).toBeNull(); + }); + + it("prioritizes approval requests over running state", () => { + const state = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ + hasPendingApprovals: true, + session: { + threadId: "thread-1" as ThreadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + }); + + expect(state?.phase).toBe("waiting_for_approval"); + expect(state?.headline).toBe("Approval needed"); + }); + + it("projects running provider sessions", () => { + const state = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ + session: { + threadId: "thread-1" as ThreadId, + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: "turn-1" as TurnId, + lastError: null, + updatedAt: NOW, + }, + }), + }); + + expect(state).toMatchObject({ + phase: "running", + headline: "Agent is working", + detail: "Codex is active.", + modelTitle: "gpt-5.4", + deepLink: "/threads/env-1/thread-1", + }); + }); + + it("projects failures with the session error detail", () => { + const state = projectThreadAwareness({ + environmentId: "env-1" as EnvironmentId, + project, + thread: thread({ + session: { + threadId: "thread-1" as ThreadId, + status: "error", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "Provider process exited.", + updatedAt: NOW, + }, + }), + }); + + expect(state).toMatchObject({ + phase: "failed", + headline: "Agent failed", + detail: "Provider process exited.", + }); + }); +}); diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts new file mode 100644 index 00000000000..6831e8ba301 --- /dev/null +++ b/packages/shared/src/agentAwareness.ts @@ -0,0 +1,142 @@ +import type { + EnvironmentId, + OrchestrationProjectShell, + OrchestrationThreadShell, + ThreadId, +} from "@t3tools/contracts"; + +export type AgentAwarenessPhase = + | "starting" + | "running" + | "waiting_for_approval" + | "waiting_for_input" + | "completed" + | "failed" + | "stale"; + +export interface AgentAwarenessState { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly projectTitle: string; + readonly threadTitle: string; + readonly phase: AgentAwarenessPhase; + readonly headline: string; + readonly detail?: string; + readonly modelTitle: string; + readonly updatedAt: string; + readonly deepLink: string; +} + +export interface ProjectThreadAwarenessInput { + readonly environmentId: EnvironmentId; + readonly project: Pick; + readonly thread: Pick< + OrchestrationThreadShell, + | "id" + | "title" + | "modelSelection" + | "session" + | "latestTurn" + | "updatedAt" + | "hasPendingApprovals" + | "hasPendingUserInput" + >; +} + +export function buildAgentAwarenessDeepLink(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}): string { + return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; +} + +export function isTerminalAgentAwarenessPhase(phase: AgentAwarenessPhase): boolean { + return phase === "completed" || phase === "failed"; +} + +export function isInterruptiveAgentAwarenessPhase(phase: AgentAwarenessPhase): boolean { + return phase === "waiting_for_approval" || phase === "waiting_for_input" || phase === "failed"; +} + +export function projectThreadAwareness( + input: ProjectThreadAwarenessInput, +): AgentAwarenessState | null { + const { environmentId, project, thread } = input; + const phase = resolveThreadAwarenessPhase(thread); + if (!phase) { + return null; + } + + const detail = detailForPhase(phase, thread); + return { + environmentId, + threadId: thread.id, + projectTitle: project.title, + threadTitle: thread.title, + phase, + headline: headlineForPhase(phase), + ...(detail === undefined ? {} : { detail }), + modelTitle: thread.modelSelection.model, + updatedAt: thread.updatedAt, + deepLink: buildAgentAwarenessDeepLink({ environmentId, threadId: thread.id }), + }; +} + +function resolveThreadAwarenessPhase( + thread: ProjectThreadAwarenessInput["thread"], +): AgentAwarenessPhase | null { + if (thread.hasPendingApprovals) { + return "waiting_for_approval"; + } + if (thread.hasPendingUserInput) { + return "waiting_for_input"; + } + if (thread.session?.status === "error" || thread.latestTurn?.state === "error") { + return "failed"; + } + if (thread.session?.status === "starting") { + return "starting"; + } + if (thread.session?.status === "running" || thread.latestTurn?.state === "running") { + return "running"; + } + if (thread.latestTurn?.state === "completed") { + return "completed"; + } + return null; +} + +function headlineForPhase(phase: AgentAwarenessPhase): string { + switch (phase) { + case "starting": + return "Starting agent"; + case "running": + return "Agent is working"; + case "waiting_for_approval": + return "Approval needed"; + case "waiting_for_input": + return "Waiting for input"; + case "completed": + return "Agent finished"; + case "failed": + return "Agent failed"; + case "stale": + return "Update delayed"; + } +} + +function detailForPhase( + phase: AgentAwarenessPhase, + thread: ProjectThreadAwarenessInput["thread"], +): string | undefined { + if (phase === "failed") { + return thread.session?.lastError ?? undefined; + } + if (phase === "completed") { + return "Review the completed task."; + } + if (phase === "running" && thread.session?.providerName) { + return `${thread.session.providerName} is active.`; + } + return undefined; +} diff --git a/packages/shared/src/dpop.test.ts b/packages/shared/src/dpop.test.ts new file mode 100644 index 00000000000..58bd161e2a3 --- /dev/null +++ b/packages/shared/src/dpop.test.ts @@ -0,0 +1,205 @@ +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; + +import { + computeDpopAccessTokenHash, + computeDpopJwkThumbprint, + normalizeDpopHtu, + type DpopPublicJwk, + verifyDpopProof, +} from "./dpop.ts"; + +function signDpopProof(input: { + readonly method: string; + readonly url: string; + readonly iat: number; + readonly privateKey: NodeCrypto.KeyObject; + readonly publicJwk: DpopPublicJwk | (DpopPublicJwk & { readonly d: string }); + readonly accessToken?: string; +}) { + const header = Buffer.from( + JSON.stringify({ + typ: "dpop+jwt", + alg: "ES256", + jwk: input.publicJwk, + }), + ).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ + htm: input.method, + htu: input.url, + jti: "proof-1", + iat: input.iat, + ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), + }), + ).toString("base64url"); + const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { + key: input.privateKey, + dsaEncoding: "ieee-p1363", + }).toString("base64url"); + return `${header}.${payload}.${signature}`; +} + +describe("verifyDpopProof", () => { + const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk; + const proof = signDpopProof({ + method: "POST", + url: "https://example.com/oauth/token", + iat: 100, + privateKey, + publicJwk, + }); + + it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => { + const thumbprint = computeDpopJwkThumbprint(publicJwk); + expect( + verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }), + ).toMatchObject({ + ok: true, + thumbprint, + jti: "proof-1", + }); + }); + + it("rejects method, URL, thumbprint, and time-window mismatches", () => { + const thumbprint = computeDpopJwkThumbprint(publicJwk); + expect( + verifyDpopProof({ + proof, + method: "GET", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }), + ).toMatchObject({ ok: false }); + expect( + verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/other", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }), + ).toMatchObject({ ok: false }); + expect( + verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: "other-thumbprint", + }), + ).toMatchObject({ ok: false }); + expect( + verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 1_000, + expectedThumbprint: thumbprint, + }), + ).toMatchObject({ ok: false }); + }); + + it("requires the RFC 9449 access token hash when an access token is expected", () => { + const thumbprint = computeDpopJwkThumbprint(publicJwk); + const accessTokenProof = signDpopProof({ + method: "POST", + url: "https://example.com/v1/environments/env/connect", + iat: 100, + privateKey, + publicJwk, + accessToken: "clerk-access-token", + }); + + expect( + verifyDpopProof({ + proof: accessTokenProof, + method: "POST", + url: "https://example.com/v1/environments/env/connect", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + expectedAccessToken: "clerk-access-token", + }), + ).toMatchObject({ ok: true }); + expect( + verifyDpopProof({ + proof, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + expectedAccessToken: "clerk-access-token", + }), + ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); + expect( + verifyDpopProof({ + proof: accessTokenProof, + method: "POST", + url: "https://example.com/v1/environments/env/connect", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + expectedAccessToken: "other-access-token", + }), + ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); + }); + + it("normalizes htu by excluding query and fragment components per RFC 9449", () => { + expect(normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag")).toBe( + "https://example.com/v1/environments/env/connect", + ); + + const thumbprint = computeDpopJwkThumbprint(publicJwk); + const queryProof = signDpopProof({ + method: "POST", + url: "https://example.com/v1/environments/env/connect", + iat: 100, + privateKey, + publicJwk, + }); + + expect( + verifyDpopProof({ + proof: queryProof, + method: "POST", + url: "https://example.com/v1/environments/env/connect?foo=bar#frag", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }), + ).toMatchObject({ ok: true }); + }); + + it("rejects DPoP public JWK headers that expose private key material", () => { + const thumbprint = computeDpopJwkThumbprint(publicJwk); + const privateJwk = privateKey.export({ format: "jwk" }) as DpopPublicJwk & { + readonly d: string; + }; + const proofWithPrivateJwk = signDpopProof({ + method: "POST", + url: "https://example.com/oauth/token", + iat: 100, + privateKey, + publicJwk: privateJwk, + }); + + expect( + verifyDpopProof({ + proof: proofWithPrivateJwk, + method: "POST", + url: "https://example.com/oauth/token", + nowEpochSeconds: 101, + expectedThumbprint: thumbprint, + }), + ).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." }); + }); +}); diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts new file mode 100644 index 00000000000..34210679007 --- /dev/null +++ b/packages/shared/src/dpop.ts @@ -0,0 +1,200 @@ +import { p256 } from "@noble/curves/nist"; +import { sha256 } from "@noble/hashes/sha2"; +import * as Encoding from "effect/Encoding"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import { stableStringify } from "./relaySigning.ts"; + +const DPOP_TYP = "dpop+jwt"; +const DPOP_ALG = "ES256"; +const DEFAULT_MAX_AGE_SECONDS = 300; + +export const DpopPublicJwk = Schema.Struct({ + kty: Schema.Literal("EC"), + crv: Schema.Literal("P-256"), + x: Schema.String.check(Schema.isNonEmpty()), + y: Schema.String.check(Schema.isNonEmpty()), +}); +export type DpopPublicJwk = typeof DpopPublicJwk.Type; +const isDpopPublicJwk = Schema.is(DpopPublicJwk); + +interface DpopJwtHeader { + readonly typ: string; + readonly alg: string; + readonly jwk: DpopPublicJwk; +} + +interface DpopJwtPayload { + readonly htm: string; + readonly htu: string; + readonly jti: string; + readonly iat: number; + readonly ath?: string; +} + +export type DpopVerificationResult = + | { + readonly ok: true; + readonly thumbprint: string; + readonly jti: string; + readonly iat: number; + } + | { + readonly ok: false; + readonly reason: string; + }; + +function base64UrlToBytes(value: string): Uint8Array { + return Result.getOrThrow(Encoding.decodeBase64Url(value)); +} + +function decodeBase64UrlJson(value: string): unknown { + return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown; +} + +function isDpopJwtHeader(value: unknown): value is DpopJwtHeader { + if (typeof value !== "object" || value === null) { + return false; + } + const record = value as Record; + return ( + record.typ === DPOP_TYP && + record.alg === DPOP_ALG && + typeof record.jwk === "object" && + record.jwk !== null && + !("d" in record.jwk) && + isDpopPublicJwk(record.jwk) + ); +} + +function isDpopJwtPayload(value: unknown): value is DpopJwtPayload { + if (typeof value !== "object" || value === null) { + return false; + } + const record = value as Record; + return ( + typeof record.htm === "string" && + record.htm.length > 0 && + typeof record.htu === "string" && + record.htu.length > 0 && + typeof record.jti === "string" && + record.jti.length > 0 && + typeof record.iat === "number" && + Number.isInteger(record.iat) + ); +} + +function dpopThumbprintInput(jwk: DpopPublicJwk): string { + return stableStringify({ + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y, + }); +} + +export function normalizeDpopHtu(url: string): string | null { + try { + const parsed = new URL(url); + parsed.hash = ""; + parsed.search = ""; + return parsed.toString(); + } catch { + return null; + } +} + +export function computeDpopJwkThumbprint(jwk: DpopPublicJwk): string { + return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); +} + +export function computeDpopAccessTokenHash(accessToken: string): string { + return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken))); +} + +function publicKeyBytesFromJwk(jwk: DpopPublicJwk): Uint8Array { + const x = base64UrlToBytes(jwk.x); + const y = base64UrlToBytes(jwk.y); + if (x.length !== 32 || y.length !== 32) { + throw new Error("Invalid P-256 public key coordinate length."); + } + const publicKey = new Uint8Array(65); + publicKey[0] = 0x04; + publicKey.set(x, 1); + publicKey.set(y, 33); + return publicKey; +} + +export function verifyDpopProof(input: { + readonly proof: string | null | undefined; + readonly method: string; + readonly url: string; + readonly nowEpochSeconds: number; + readonly expectedThumbprint?: string; + readonly expectedAccessToken?: string; + readonly maxAgeSeconds?: number; +}): DpopVerificationResult { + if (!input.proof?.trim()) { + return { ok: false, reason: "Missing DPoP proof." }; + } + + const parts = input.proof.split("."); + if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) { + return { ok: false, reason: "Invalid DPoP compact JWT." }; + } + + try { + const header = decodeBase64UrlJson(parts[0]); + const payload = decodeBase64UrlJson(parts[1]); + if (!isDpopJwtHeader(header)) { + return { ok: false, reason: "Invalid DPoP JWT header." }; + } + if (!isDpopJwtPayload(payload)) { + return { ok: false, reason: "Invalid DPoP JWT payload." }; + } + + const thumbprint = computeDpopJwkThumbprint(header.jwk); + if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) { + return { ok: false, reason: "DPoP key thumbprint mismatch." }; + } + if (payload.htm.toUpperCase() !== input.method.toUpperCase()) { + return { ok: false, reason: "DPoP method mismatch." }; + } + const normalizedHtu = normalizeDpopHtu(input.url); + if (normalizedHtu === null || payload.htu !== normalizedHtu) { + return { ok: false, reason: "DPoP URL mismatch." }; + } + if (input.expectedAccessToken) { + const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken); + if (payload.ath !== expectedAth) { + return { ok: false, reason: "DPoP access token hash mismatch." }; + } + } + + const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; + if ( + payload.iat > input.nowEpochSeconds + 5 || + input.nowEpochSeconds - payload.iat > maxAgeSeconds + ) { + return { ok: false, reason: "DPoP proof is outside the allowed time window." }; + } + + const signature = base64UrlToBytes(parts[2]); + const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`)); + const verified = p256.verify(signature, signatureInputHash, publicKeyBytesFromJwk(header.jwk), { + prehash: false, + format: "compact", + }); + return verified + ? { + ok: true, + thumbprint, + jti: payload.jti, + iat: payload.iat, + } + : { ok: false, reason: "Invalid DPoP signature." }; + } catch { + return { ok: false, reason: "Invalid DPoP proof." }; + } +} diff --git a/packages/shared/src/dpopCommon.ts b/packages/shared/src/dpopCommon.ts new file mode 100644 index 00000000000..ba57b016c33 --- /dev/null +++ b/packages/shared/src/dpopCommon.ts @@ -0,0 +1,20 @@ +import * as Schema from "effect/Schema"; + +export const DpopPublicJwk = Schema.Struct({ + kty: Schema.Literal("EC"), + crv: Schema.Literal("P-256"), + x: Schema.String.check(Schema.isNonEmpty()), + y: Schema.String.check(Schema.isNonEmpty()), +}); +export type DpopPublicJwk = typeof DpopPublicJwk.Type; + +export function normalizeDpopHtu(url: string): string | null { + try { + const parsed = new URL(url); + parsed.hash = ""; + parsed.search = ""; + return parsed.toString(); + } catch { + return null; + } +} diff --git a/packages/shared/src/oauthScope.ts b/packages/shared/src/oauthScope.ts index 7016e49280a..47c6dd7051b 100644 --- a/packages/shared/src/oauthScope.ts +++ b/packages/shared/src/oauthScope.ts @@ -1,7 +1,8 @@ const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u; /** - * Decodes an RFC 6749 `scope` value as a set while preserving first-seen order. + * Decodes an RFC 6749 `scope` value as a set while preserving its first-seen + * order for canonical responses and logs. */ export function parseOAuthScope(value: string): ReadonlyArray | null { if (value.length === 0) { @@ -25,6 +26,15 @@ export function encodeOAuthScope(scopes: ReadonlyArray): string { return encoded; } +export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray): boolean { + const scopes = parseOAuthScope(value); + return ( + scopes !== null && + scopes.length === new Set(expectedScopes).size && + scopes.every((scope) => expectedScopes.includes(scope)) + ); +} + export function parseAllowedOAuthScope(input: { readonly value: string; readonly allowedScopes: ReadonlySet; diff --git a/packages/shared/src/relayAuth.test.ts b/packages/shared/src/relayAuth.test.ts new file mode 100644 index 00000000000..0bf9af82ea7 --- /dev/null +++ b/packages/shared/src/relayAuth.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { + clerkFrontendApiHostnameFromPublishableKey, + isAllowedClerkFrontendApiHostname, +} from "./relayAuth.ts"; + +const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; + +describe("Clerk relay auth", () => { + it("derives a custom Frontend API hostname from a Clerk publishable key", () => { + expect(clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( + "clerk.t3.codes", + ); + }); + + it("allows standard Clerk hosts and an exact configured custom hostname", () => { + expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.dev", null)).toBe(true); + expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.com", null)).toBe(true); + expect(isAllowedClerkFrontendApiHostname("clerk.t3.codes", "clerk.t3.codes")).toBe(true); + expect(isAllowedClerkFrontendApiHostname("attacker.example", "clerk.t3.codes")).toBe(false); + expect(isAllowedClerkFrontendApiHostname("nested.clerk.t3.codes", "clerk.t3.codes")).toBe( + false, + ); + }); +}); diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts new file mode 100644 index 00000000000..bf5fb61ee3b --- /dev/null +++ b/packages/shared/src/relayAuth.ts @@ -0,0 +1,30 @@ +export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { + const encodedFrontendApi = publishableKey.split("_").slice(2).join("_"); + const frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); + if (frontendApi.length === 0 || frontendApi.includes("/")) { + throw new Error("Invalid Clerk publishable key."); + } + return `https://${frontendApi}`; +} + +export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: string): string { + return new URL(clerkFrontendApiUrlFromPublishableKey(publishableKey)).hostname; +} + +export function isAllowedClerkFrontendApiHostname( + hostname: string, + configuredHostname: string | null, +): boolean { + return ( + hostname.endsWith(".clerk.accounts.dev") || + hostname.endsWith(".clerk.accounts.com") || + hostname === configuredHostname + ); +} + +export function relayClerkTokenOptions(template: string) { + return { + template, + skipCache: true, + } as const; +} diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts new file mode 100644 index 00000000000..7e552194dae --- /dev/null +++ b/packages/shared/src/relayClient.test.ts @@ -0,0 +1,273 @@ +import { sha256 } from "@noble/hashes/sha2"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + RelayClientInstallError, + CLOUDFLARED_VERSION, + makeCloudflaredRelayClient, + resolveManagedCloudflaredPath, +} from "./relayClient.ts"; + +const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} }); + +function makeHandle(exitCode = 0) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(100), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +const makeHttpClientLayer = (bytes: Uint8Array) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(bytes.buffer as ArrayBuffer)), + ), + ), + ); + +const makeSpawnerLayer = (commands: Array) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command"); + return makeHandle(); + }), + ), + ); + +describe("RelayClient", () => { + it.effect("resolves explicit overrides before managed and PATH executables", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const overridePath = `${baseDir}/override-cloudflared`; + yield* fileSystem.writeFileString(overridePath, "override"); + yield* fileSystem.chmod(overridePath, 0o755); + const manager = yield* makeCloudflaredRelayClient({ + baseDir, + platform: "linux", + arch: "x64", + configProvider: () => + ConfigProvider.fromEnv({ + env: { + PATH: "", + T3CODE_CLOUDFLARED_PATH: overridePath, + }, + }), + }); + + expect(yield* manager.resolve).toEqual({ + status: "available", + executablePath: overridePath, + source: "override", + version: CLOUDFLARED_VERSION, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + makeHttpClientLayer(new Uint8Array()), + makeSpawnerLayer([]), + ), + ), + ), + ); + + it.effect("downloads, verifies, validates, and atomically installs the managed executable", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const bytes = new TextEncoder().encode("test-cloudflared-binary"); + const manager = yield* makeCloudflaredRelayClient({ + baseDir, + platform: "linux", + arch: "x64", + releaseAsset: { + url: "https://example.test/cloudflared", + sha256: Encoding.encodeHex(sha256(bytes)), + archive: "binary", + }, + configProvider: emptyConfigProvider, + }); + + const progress: Array = []; + const installed = yield* manager.installWithProgress((event) => + Effect.sync(() => { + if (event.type === "progress") { + progress.push(event.stage); + } + }), + ); + const managedPath = resolveManagedCloudflaredPath({ + baseDir, + platform: "linux", + arch: "x64", + }); + expect(installed).toEqual({ + status: "available", + executablePath: managedPath, + source: "managed", + version: CLOUDFLARED_VERSION, + }); + expect(new TextDecoder().decode(yield* fileSystem.readFile(managedPath))).toBe( + "test-cloudflared-binary", + ); + expect(progress).toEqual([ + "checking", + "waiting_for_lock", + "downloading", + "verifying", + "installing", + "validating", + "activating", + ]); + expect(yield* manager.resolve).toEqual(installed); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")), + makeSpawnerLayer([]), + ), + ), + ), + ); + + it.effect("rejects downloads whose checksum does not match the pinned manifest", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const manager = yield* makeCloudflaredRelayClient({ + baseDir, + platform: "linux", + arch: "x64", + releaseAsset: { + url: "https://example.test/cloudflared", + sha256: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))), + archive: "binary", + }, + configProvider: emptyConfigProvider, + }); + + const error = yield* manager.install.pipe(Effect.flip); + expect(error).toBeInstanceOf(RelayClientInstallError); + expect(error.reason).toBe("invalid_checksum"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + makeHttpClientLayer(new TextEncoder().encode("tampered")), + makeSpawnerLayer([]), + ), + ), + ), + ); + + it.effect("serializes concurrent installs within one runtime", () => { + const commands: Array = []; + const bytes = new TextEncoder().encode("test-cloudflared-binary"); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const manager = yield* makeCloudflaredRelayClient({ + baseDir, + platform: "linux", + arch: "x64", + releaseAsset: { + url: "https://example.test/cloudflared", + sha256: Encoding.encodeHex(sha256(bytes)), + archive: "binary", + }, + configProvider: emptyConfigProvider, + }); + + const [first, second] = yield* Effect.all([manager.install, manager.install], { + concurrency: "unbounded", + }); + expect(second).toEqual(first); + expect(commands).toHaveLength(1); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, makeHttpClientLayer(bytes), makeSpawnerLayer(commands)), + ), + ); + }); + + it.effect("observes PATH changes after the manager has been constructed", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const binDir = `${baseDir}/bin`; + const executablePath = `${binDir}/cloudflared`; + let path = ""; + const manager = yield* makeCloudflaredRelayClient({ + baseDir, + platform: "linux", + arch: "x64", + configProvider: () => ConfigProvider.fromEnv({ env: { PATH: path } }), + }); + + expect(yield* manager.resolve).toEqual({ + status: "missing", + version: CLOUDFLARED_VERSION, + }); + + yield* fileSystem.makeDirectory(binDir); + yield* fileSystem.writeFileString(executablePath, "cloudflared"); + yield* fileSystem.chmod(executablePath, 0o755); + path = binDir; + + expect(yield* manager.resolve).toEqual({ + status: "available", + executablePath, + source: "path", + version: CLOUDFLARED_VERSION, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + makeHttpClientLayer(new Uint8Array()), + makeSpawnerLayer([]), + ), + ), + ), + ); +}); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts new file mode 100644 index 00000000000..35d002466e9 --- /dev/null +++ b/packages/shared/src/relayClient.ts @@ -0,0 +1,502 @@ +import * as Clock from "effect/Clock"; +import type { + RelayClientInstallProgressEvent, + RelayClientInstallProgressStage, +} from "@t3tools/contracts"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export const CLOUDFLARED_VERSION = "2026.5.2"; +export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; + +export type RelayClientExecutableSource = "override" | "managed" | "path"; + +export type RelayClientStatus = + | { + readonly status: "available"; + readonly executablePath: string; + readonly source: RelayClientExecutableSource; + readonly version: string; + } + | { + readonly status: "missing"; + readonly version: string; + } + | { + readonly status: "unsupported"; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly version: string; + }; + +export type AvailableRelayClient = Extract; + +export class RelayClientInstallError extends Data.TaggedError("RelayClientInstallError")<{ + readonly reason: + | "download_failed" + | "invalid_checksum" + | "install_locked" + | "override_missing" + | "unsupported_platform" + | "validation_failed" + | "write_failed"; + readonly message: string; + readonly cause?: unknown; +}> {} + +class CloudflaredCommandError extends Data.TaggedError("CloudflaredCommandError")<{ + readonly command: string; + readonly exitCode: number; +}> {} + +export interface CloudflaredReleaseAsset { + readonly url: string; + readonly sha256: string; + readonly archive: "binary" | "tgz"; +} + +const CLOUDFLARED_RELEASE_ASSETS: Readonly< + Partial> +> = { + "darwin-arm64": { + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-darwin-arm64.tgz", + sha256: "ba94054c9fd4297645093d59d51442e5e546d07bb0516120e694a13d5b216d38", + archive: "tgz", + }, + "darwin-x64": { + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-darwin-amd64.tgz", + sha256: "7240f709506bc2c1eb9da4d89cf2555499c60280ecb854b7d80e8f17d4b7903d", + archive: "tgz", + }, + "linux-arm64": { + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-linux-arm64", + sha256: "5a4e8ce2701105271412059f44b6a0bf1ae4542b4d98ff3180c0c019443a5815", + archive: "binary", + }, + "linux-x64": { + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-linux-amd64", + sha256: "5286698547f03df745adb2355f04c12dde52ef425491e81f433642d695521886", + archive: "binary", + }, + "win32-x64": { + url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-windows-amd64.exe", + sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", + archive: "binary", + }, +}; + +const INSTALL_LOCK_RETRY_COUNT = 100; +const INSTALL_LOCK_RETRY_DELAY = "100 millis"; +const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; + +const trimmedString = (name: string) => + Config.string(name).pipe( + Config.option, + Config.map( + Option.flatMap((value) => { + const trimmed = value.trim(); + return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); + }), + ), + ); + +const CloudflaredConfig = Config.all({ + executableOverride: trimmedString(CLOUDFLARED_PATH_ENV_NAME), + path: trimmedString("PATH"), +}); + +export interface CloudflaredRelayClientOptions { + readonly baseDir: string; + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly releaseAsset?: CloudflaredReleaseAsset; + readonly configProvider?: () => ConfigProvider.ConfigProvider; +} + +export interface RelayClientShape { + readonly resolve: Effect.Effect; + readonly install: Effect.Effect; + readonly installWithProgress: ( + report: (event: RelayClientInstallProgressEvent) => Effect.Effect, + ) => Effect.Effect; +} + +export class RelayClient extends Context.Service()( + "@t3tools/shared/relayClient", +) {} + +function executableFileName(platform: NodeJS.Platform): string { + return platform === "win32" ? "cloudflared.exe" : "cloudflared"; +} + +export function resolveManagedCloudflaredPath(input: { + readonly baseDir: string; + readonly platform: NodeJS.Platform; + readonly arch: string; +}): string { + const separator = input.platform === "win32" ? "\\" : "/"; + return [ + input.baseDir.replace(/[\\/]+$/u, ""), + "tools", + "cloudflared", + CLOUDFLARED_VERSION, + `${input.platform}-${input.arch}`, + executableFileName(input.platform), + ].join(separator); +} + +function resolveReleaseAsset( + platform: NodeJS.Platform, + arch: string, +): CloudflaredReleaseAsset | null { + return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null; +} + +function isAlreadyExists(error: PlatformError.PlatformError): boolean { + return error.reason._tag === "AlreadyExists"; +} + +const wrapInstallFailure = + ( + reason: RelayClientInstallError["reason"], + message: string, + ): (( + effect: Effect.Effect, + ) => Effect.Effect) => + (effect) => + effect.pipe( + Effect.mapError( + (cause) => + new RelayClientInstallError({ + reason, + message, + cause, + }), + ), + ); + +export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function* ( + options: CloudflaredRelayClientOptions, +): Effect.fn.Return< + RelayClientShape, + never, + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path +> { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const httpClient = yield* HttpClient.HttpClient; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const installSemaphore = yield* Semaphore.make(1); + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); + const loadCloudflaredConfig = Effect.suspend(() => + CloudflaredConfig.pipe( + Effect.provideService( + ConfigProvider.ConfigProvider, + options.configProvider?.() ?? ConfigProvider.fromEnv(), + ), + ), + ).pipe(Effect.orDie); + const managedPath = path.join( + options.baseDir, + "tools", + "cloudflared", + CLOUDFLARED_VERSION, + `${platform}-${arch}`, + executableFileName(platform), + ); + + const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* ( + executablePath: string, + ) { + const info = yield* fileSystem.stat(executablePath).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "File") return false; + return platform === "win32" || (info.value.mode & 0o111) !== 0; + }); + + const resolvePathExecutable = Effect.gen(function* () { + const config = yield* loadCloudflaredConfig; + const pathValue = Option.getOrUndefined(config.path); + if (!pathValue) return null; + const delimiter = platform === "win32" ? ";" : ":"; + for (const directory of pathValue.split(delimiter)) { + const trimmed = directory.trim().replace(/^"|"$/gu, ""); + if (trimmed.length === 0) continue; + const candidate = path.join(trimmed, executableFileName(platform)); + if (yield* isExecutableFile(candidate)) return candidate; + } + return null; + }); + + const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { + const config = yield* loadCloudflaredConfig; + if (Option.isSome(config.executableOverride)) { + return (yield* isExecutableFile(config.executableOverride.value)) + ? { + status: "available", + executablePath: config.executableOverride.value, + source: "override", + version: CLOUDFLARED_VERSION, + } + : { status: "missing", version: CLOUDFLARED_VERSION }; + } + if (yield* isExecutableFile(managedPath)) { + return { + status: "available", + executablePath: managedPath, + source: "managed", + version: CLOUDFLARED_VERSION, + }; + } + const pathExecutable = yield* resolvePathExecutable; + if (pathExecutable) { + return { + status: "available", + executablePath: pathExecutable, + source: "path", + version: CLOUDFLARED_VERSION, + }; + } + return releaseAsset + ? { status: "missing", version: CLOUDFLARED_VERSION } + : { + status: "unsupported", + platform, + arch, + version: CLOUDFLARED_VERSION, + }; + }); + + const runCommand = Effect.fn("cloudflared.runCommand")(function* ( + command: string, + args: ReadonlyArray, + ) { + const child = yield* spawner.spawn( + ChildProcess.make(command, args, { + shell: false, + stdout: "ignore", + stderr: "ignore", + }), + ); + const exitCode = Number(yield* child.exitCode); + if (exitCode !== 0) { + return yield* new CloudflaredCommandError({ command, exitCode }); + } + }); + + const downloadAsset = Effect.fn("cloudflared.downloadAsset")(function* ( + asset: CloudflaredReleaseAsset, + report: (stage: RelayClientInstallProgressStage) => Effect.Effect, + ) { + yield* report("downloading"); + const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError( + (cause) => + new RelayClientInstallError({ + reason: "download_failed", + message: "Could not download the relay client.", + cause, + }), + ), + ); + const bytes = new Uint8Array( + yield* response.arrayBuffer.pipe( + Effect.mapError( + (cause) => + new RelayClientInstallError({ + reason: "download_failed", + message: "Could not read the downloaded relay client binary.", + cause, + }), + ), + ), + ); + yield* report("verifying"); + const checksum = yield* crypto.digest("SHA-256", bytes).pipe( + Effect.mapError( + (cause) => + new RelayClientInstallError({ + reason: "validation_failed", + message: "Could not verify the downloaded relay client checksum.", + cause, + }), + ), + ); + if (Encoding.encodeHex(checksum) !== asset.sha256) { + return yield* new RelayClientInstallError({ + reason: "invalid_checksum", + message: "Downloaded relay client checksum did not match the pinned release.", + }); + } + return bytes; + }); + + const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* ( + lockPath: string, + ) { + for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { + const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( + Effect.as(true), + Effect.catch((error) => + isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error), + ), + ); + if (acquired) return; + + const now = yield* Clock.currentTimeMillis; + const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); + const mtime = Option.flatMap(lockInfo, (info) => info.mtime); + if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { + yield* fileSystem.remove(lockPath, { force: true }); + continue; + } + yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); + } + return yield* new RelayClientInstallError({ + reason: "install_locked", + message: "Another relay client installation is still in progress.", + }); + }); + + const installUnlocked = Effect.fn("cloudflared.installUnlocked")(function* ( + report: (stage: RelayClientInstallProgressStage) => Effect.Effect, + ) { + yield* report("checking"); + const existing = yield* resolve; + if (existing.status === "available") return existing; + const config = yield* loadCloudflaredConfig; + if (Option.isSome(config.executableOverride)) { + return yield* new RelayClientInstallError({ + reason: "override_missing", + message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, + }); + } + if (!releaseAsset) { + return yield* new RelayClientInstallError({ + reason: "unsupported_platform", + message: `T3 Code does not provide a managed relay client binary for ${platform}-${arch}.`, + }); + } + + const managedDirectory = path.dirname(managedPath); + const lockPath = `${managedPath}.lock`; + yield* fileSystem + .makeDirectory(managedDirectory, { recursive: true }) + .pipe( + wrapInstallFailure("write_failed", "Could not create the relay client tool directory."), + ); + yield* report("waiting_for_lock"); + yield* acquireInstallLock(lockPath).pipe( + Effect.catchTag("PlatformError", (cause) => + Effect.fail( + new RelayClientInstallError({ + reason: "write_failed", + message: "Could not acquire the relay client installation lock.", + cause, + }), + ), + ), + ); + return yield* Effect.gen(function* () { + const afterLock = yield* resolve; + if (afterLock.status === "available") return afterLock; + + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + directory: managedDirectory, + prefix: ".install-", + }); + const archivePath = path.join( + tempDirectory, + releaseAsset.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform), + ); + const download = yield* downloadAsset(releaseAsset, report); + yield* report("installing"); + yield* fileSystem + .writeFile(archivePath, download) + .pipe(wrapInstallFailure("write_failed", "Could not write the relay client download.")); + + const executablePath = path.join(tempDirectory, executableFileName(platform)); + if (releaseAsset.archive === "tgz") { + yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe( + wrapInstallFailure("write_failed", "Could not extract the relay client."), + ); + } + if (platform !== "win32") { + yield* fileSystem + .chmod(executablePath, 0o755) + .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); + } + yield* report("validating"); + yield* runCommand(executablePath, ["--version"]).pipe( + wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), + ); + + const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; + yield* report("activating"); + yield* fileSystem + .rename(executablePath, stagedPath) + .pipe(wrapInstallFailure("write_failed", "Could not stage the relay client.")); + yield* fileSystem + .rename(stagedPath, managedPath) + .pipe( + wrapInstallFailure("write_failed", "Could not activate the relay client."), + Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), + ); + return { + status: "available", + executablePath: managedPath, + source: "managed", + version: CLOUDFLARED_VERSION, + } satisfies AvailableRelayClient; + }).pipe( + Effect.scoped, + Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), + Effect.catch((cause) => + cause instanceof RelayClientInstallError + ? Effect.fail(cause) + : Effect.fail( + new RelayClientInstallError({ + reason: "write_failed", + message: "Could not install the relay client.", + cause, + }), + ), + ), + ); + }); + const installWithProgress: RelayClientShape["installWithProgress"] = (report) => + installSemaphore.withPermit( + installUnlocked((stage) => + report({ + type: "progress", + stage, + }), + ), + ); + const install = installWithProgress(() => Effect.void); + + return RelayClient.of({ resolve, install, installWithProgress }); +}); + +export const layerCloudflared = (options: CloudflaredRelayClientOptions) => + Layer.effect(RelayClient, makeCloudflaredRelayClient(options)); diff --git a/packages/shared/src/relayJwt.ts b/packages/shared/src/relayJwt.ts new file mode 100644 index 00000000000..bd00023e8fb --- /dev/null +++ b/packages/shared/src/relayJwt.ts @@ -0,0 +1,69 @@ +import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose"; +import * as Data from "effect/Data"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt"; +export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt"; +export const RELAY_HEALTH_REQUEST_TYP = "t3-cloud-health+jwt"; +export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt"; +export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt"; +export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt"; + +export class RelayJwtError extends Data.TaggedError("RelayJwtError")<{ + readonly cause: unknown; +}> {} + +export function normalizeRelayIssuer(value: string): string { + return value.trim().replace(/\/+$/gu, ""); +} + +export function decodeRelayJwt(token: string): JWTPayload { + return decodeJwt(token); +} + +function normalizePem(value: string): string { + return value.replace(/\\n/gu, "\n").trim(); +} + +export function signRelayJwt(input: { + readonly privateKey: string; + readonly typ: string; + readonly payload: JWTPayload; +}): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const key = await importPKCS8(normalizePem(input.privateKey), "EdDSA"); + return new SignJWT(input.payload) + .setProtectedHeader({ alg: "EdDSA", typ: input.typ }) + .sign(key); + }, + catch: (cause) => new RelayJwtError({ cause }), + }); +} + +export function verifyRelayJwt(input: { + readonly publicKey: string; + readonly token: string; + readonly typ: string; + readonly issuer: string; + readonly audience: string; + readonly nowEpochSeconds: number; +}): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + const key = await importSPKI(normalizePem(input.publicKey), "EdDSA"); + const verified = await jwtVerify(input.token, key, { + algorithms: ["EdDSA"], + typ: input.typ, + issuer: input.issuer, + audience: input.audience, + maxTokenAge: "5 minutes", + clockTolerance: 60, + currentDate: DateTime.toDate(DateTime.makeUnsafe(input.nowEpochSeconds * 1_000)), + }); + return verified.payload; + }, + catch: (cause) => new RelayJwtError({ cause }), + }); +} diff --git a/packages/shared/src/relaySigning.test.ts b/packages/shared/src/relaySigning.test.ts new file mode 100644 index 00000000000..f847d9457d1 --- /dev/null +++ b/packages/shared/src/relaySigning.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { stableStringify } from "./relaySigning.ts"; + +describe("relaySigning", () => { + it("canonicalizes object keys recursively", () => { + expect( + stableStringify({ + z: 1, + a: { + y: true, + b: null, + }, + list: [{ c: "three", a: "one" }], + }), + ).toBe('{"a":{"b":null,"y":true},"list":[{"a":"one","c":"three"}],"z":1}'); + }); +}); diff --git a/packages/shared/src/relaySigning.ts b/packages/shared/src/relaySigning.ts new file mode 100644 index 00000000000..6b8b8cb2c7c --- /dev/null +++ b/packages/shared/src/relaySigning.ts @@ -0,0 +1,17 @@ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + if (isRecord(value)) { + return `{${Object.entries(value) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} diff --git a/packages/shared/src/relayUrl.test.ts b/packages/shared/src/relayUrl.test.ts new file mode 100644 index 00000000000..23b85686691 --- /dev/null +++ b/packages/shared/src/relayUrl.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { isSecureRelayUrl, normalizeSecureRelayUrl } from "./relayUrl.ts"; + +describe("normalizeSecureRelayUrl", () => { + it("normalizes secure relay origins", () => { + expect(normalizeSecureRelayUrl(" https://relay.example.test/// ")).toBe( + "https://relay.example.test", + ); + expect(normalizeSecureRelayUrl("https://relay.example.test:8443/")).toBe( + "https://relay.example.test:8443", + ); + }); + + it.each([ + "http://relay.example.test", + "https://user:password@relay.example.test", + "https://relay.example.test/path", + "https://relay.example.test?query=value", + "https://relay.example.test#fragment", + "not a url", + ])("rejects unsafe relay URL %s", (value) => { + expect(normalizeSecureRelayUrl(value)).toBeNull(); + expect(isSecureRelayUrl(value)).toBe(false); + }); +}); diff --git a/packages/shared/src/relayUrl.ts b/packages/shared/src/relayUrl.ts new file mode 100644 index 00000000000..4e5f397702e --- /dev/null +++ b/packages/shared/src/relayUrl.ts @@ -0,0 +1,22 @@ +export function normalizeSecureRelayUrl(value: string): string | null { + try { + const url = new URL(value.trim()); + if ( + url.protocol !== "https:" || + url.username.length > 0 || + url.password.length > 0 || + url.search.length > 0 || + url.hash.length > 0 || + !/^\/+$/u.test(url.pathname) + ) { + return null; + } + return url.origin; + } catch { + return null; + } +} + +export function isSecureRelayUrl(value: string): boolean { + return normalizeSecureRelayUrl(value) !== null; +} diff --git a/patches/alchemy@2.0.0-beta.49.patch b/patches/alchemy@2.0.0-beta.49.patch new file mode 100644 index 00000000000..8d0cebac3a6 --- /dev/null +++ b/patches/alchemy@2.0.0-beta.49.patch @@ -0,0 +1,356 @@ +diff --git a/bin/exec.js b/bin/exec.js +index 9cd9f8059..8317c57be 100644 +--- a/bin/exec.js ++++ b/bin/exec.js +@@ -366,6 +366,7 @@ const of = (resource) => { + const asOutput = (t) => isOutput(t) ? t : Effect.isEffect(t) ? new EffectExpr(VoidExpr, () => t) : new LiteralExpr(t); + const isOutput = (value) => value && (typeof value === "object" || typeof value === "function") && ExprSymbol in value; + const ExprSymbol = Symbol.for("alchemy/Expr"); ++const exprKind = (node) => node?.[ExprSymbol]?.kind ?? node?.kind; + const isExpr = (value) => value && (typeof value === "object" || typeof value === "function") && ExprSymbol in value; + var BaseExpr = class { + constructor() {} +@@ -389,7 +390,7 @@ var BaseExpr = class { + return this[inspect](); + } + }; +-const isResourceExpr = (node) => node?.kind === "ResourceExpr"; ++const isResourceExpr = (node) => exprKind(node) === "ResourceExpr"; + var ResourceExpr = class extends BaseExpr { + src; + stables; +@@ -404,7 +405,7 @@ var ResourceExpr = class extends BaseExpr { + return this.src.LogicalId; + } + }; +-const isPropExpr = (node) => node?.kind === "PropExpr"; ++const isPropExpr = (node) => exprKind(node) === "PropExpr"; + var PropExpr = class extends BaseExpr { + expr; + identifier; +@@ -419,7 +420,7 @@ var PropExpr = class extends BaseExpr { + return `${this.expr[inspect]()}.${this.identifier.toString()}`; + } + }; +-const isLiteralExpr = (node) => node?.kind === "LiteralExpr"; ++const isLiteralExpr = (node) => exprKind(node) === "LiteralExpr"; + var LiteralExpr = class extends BaseExpr { + value; + kind = "LiteralExpr"; +@@ -433,7 +434,7 @@ var LiteralExpr = class extends BaseExpr { + } + }; + const VoidExpr = new LiteralExpr(void 0); +-const isApplyExpr = (node) => node?.kind === "ApplyExpr"; ++const isApplyExpr = (node) => exprKind(node) === "ApplyExpr"; + var ApplyExpr = class extends BaseExpr { + expr; + f; +@@ -448,7 +449,7 @@ var ApplyExpr = class extends BaseExpr { + return `${this.expr[inspect]()}.map(${this.f.toString()})`; + } + }; +-const isFlatMapExpr = (node) => node?.kind === "FlatMapExpr"; ++const isFlatMapExpr = (node) => exprKind(node) === "FlatMapExpr"; + var FlatMapExpr = class extends BaseExpr { + expr; + f; +@@ -463,7 +464,7 @@ var FlatMapExpr = class extends BaseExpr { + return `${this.expr[inspect]()}.flatMap(${this.f.toString()})`; + } + }; +-const isEffectExpr = (node) => node?.kind === "EffectExpr"; ++const isEffectExpr = (node) => exprKind(node) === "EffectExpr"; + var EffectExpr = class extends BaseExpr { + expr; + f; +@@ -478,7 +479,7 @@ var EffectExpr = class extends BaseExpr { + return `${this.expr[inspect]()}.mapEffect(${this.f.toString()})`; + } + }; +-const isNamedExpr = (node) => node?.kind === "NamedExpr"; ++const isNamedExpr = (node) => exprKind(node) === "NamedExpr"; + /** + * Wraps another `Expr` and overrides its `toString()` / inspect output. + * +@@ -500,7 +501,7 @@ var NamedExpr = class extends BaseExpr { + return this.bindingName; + } + }; +-const isAllExpr = (node) => node?.kind === "AllExpr"; ++const isAllExpr = (node) => exprKind(node) === "AllExpr"; + var AllExpr = class extends BaseExpr { + outs; + kind = "AllExpr"; +@@ -513,7 +514,7 @@ var AllExpr = class extends BaseExpr { + return `all(${this.outs.map((out) => out[inspect]()).join(", ")})`; + } + }; +-const isRefExpr = (node) => node?.kind === "RefExpr"; ++const isRefExpr = (node) => exprKind(node) === "RefExpr"; + var RefExpr = class extends BaseExpr { + stack; + stage; +@@ -530,7 +531,7 @@ var RefExpr = class extends BaseExpr { + return `ref(${this.resourceId}, { stack: ${this.stack}, stage: ${this.stage} })`; + } + }; +-const isStackRefExpr = (node) => node?.kind === "StackRefExpr"; ++const isStackRefExpr = (node) => exprKind(node) === "StackRefExpr"; + /** + * A reference to the persisted output of a Stack at `(stack, stage)`. + * +diff --git a/lib/Drizzle/Schema.js b/lib/Drizzle/Schema.js +index 95aa38b5d..963ec8534 100644 +--- a/lib/Drizzle/Schema.js ++++ b/lib/Drizzle/Schema.js +@@ -99,11 +99,11 @@ export const SchemaProvider = () => Provider.effect(Schema, Effect.gen(function* + const dialect = props.dialect ?? "postgres"; + const kit = yield* loadKit(dialect); + const schemaModule = yield* loadSchemaModule(props); ++ const prevEntry = yield* readLatestSnapshot(out); + const cur = yield* Effect.tryPromise({ +- try: () => kit.generateDrizzleJson(schemaModule), ++ try: () => kit.generateDrizzleJson(schemaModule, prevEntry?.snapshot?.id), + catch: (cause) => new Error(`drizzle-kit generateDrizzleJson failed: ${cause}`), + }); +- const prevEntry = yield* readLatestSnapshot(out); + // For the initial migration, drizzle-kit needs an *empty* snapshot + // produced by `generateDrizzleJson({})`, not a bare `{}` — the + // snapshot has internal fields (`ddl`, etc.) that the differ reads. +@@ -181,4 +181,4 @@ export const SchemaProvider = () => Provider.effect(Schema, Effect.gen(function* + }), + }; + })); +-//# sourceMappingURL=Schema.js.map +\ No newline at end of file ++//# sourceMappingURL=Schema.js.map +diff --git a/lib/Output.js b/lib/Output.js +index ce26480d1..e3862cce7 100644 +--- a/lib/Output.js ++++ b/lib/Output.js +@@ -28,6 +28,7 @@ export const isOutput = (value) => value && + (typeof value === "object" || typeof value === "function") && + ExprSymbol in value; + export const ExprSymbol = Symbol.for("alchemy/Expr"); ++const exprKind = (node) => node?.[ExprSymbol]?.kind ?? node?.kind; + export const isExpr = (value) => value && + (typeof value === "object" || typeof value === "function") && + ExprSymbol in value; +@@ -57,7 +58,7 @@ export class BaseExpr { + return this[inspect](); + } + } +-export const isResourceExpr = (node) => node?.kind === "ResourceExpr"; ++export const isResourceExpr = (node) => exprKind(node) === "ResourceExpr"; + export class ResourceExpr extends BaseExpr { + src; + stables; +@@ -72,7 +73,7 @@ export class ResourceExpr extends BaseExpr { + return this.src.LogicalId; + } + } +-export const isPropExpr = (node) => node?.kind === "PropExpr"; ++export const isPropExpr = (node) => exprKind(node) === "PropExpr"; + export class PropExpr extends BaseExpr { + expr; + identifier; +@@ -88,7 +89,7 @@ export class PropExpr extends BaseExpr { + } + } + export const literal = (value) => new LiteralExpr(value); +-export const isLiteralExpr = (node) => node?.kind === "LiteralExpr"; ++export const isLiteralExpr = (node) => exprKind(node) === "LiteralExpr"; + export class LiteralExpr extends BaseExpr { + value; + kind = "LiteralExpr"; +@@ -106,7 +107,7 @@ export const map = ((...args) => args.length === 1 + ? (output) => new ApplyExpr(output, args[0]) + : new ApplyExpr(args[0], args[1])); + //Output.ApplyExpr +-export const isApplyExpr = (node) => node?.kind === "ApplyExpr"; ++export const isApplyExpr = (node) => exprKind(node) === "ApplyExpr"; + export class ApplyExpr extends BaseExpr { + expr; + f; +@@ -125,7 +126,7 @@ export const mapEffect = (fn) => (output) => new EffectExpr(output, fn); + export const flatMap = ((...args) => args.length === 1 + ? (output) => new FlatMapExpr(output, args[0]) + : new FlatMapExpr(args[0], args[1])); +-export const isFlatMapExpr = (node) => node?.kind === "FlatMapExpr"; ++export const isFlatMapExpr = (node) => exprKind(node) === "FlatMapExpr"; + export class FlatMapExpr extends BaseExpr { + expr; + f; +@@ -140,7 +141,7 @@ export class FlatMapExpr extends BaseExpr { + return `${this.expr[inspect]()}.flatMap(${this.f.toString()})`; + } + } +-export const isEffectExpr = (node) => node?.kind === "EffectExpr"; ++export const isEffectExpr = (node) => exprKind(node) === "EffectExpr"; + export class EffectExpr extends BaseExpr { + expr; + f; +@@ -155,7 +156,7 @@ export class EffectExpr extends BaseExpr { + return `${this.expr[inspect]()}.mapEffect(${this.f.toString()})`; + } + } +-export const isNamedExpr = (node) => node?.kind === "NamedExpr"; ++export const isNamedExpr = (node) => exprKind(node) === "NamedExpr"; + /** + * Wraps another `Expr` and overrides its `toString()` / inspect output. + * +@@ -179,7 +180,7 @@ export class NamedExpr extends BaseExpr { + } + export const named = (expr, name) => new NamedExpr(expr, name); + export const all = (...outs) => new AllExpr(outs); +-export const isAllExpr = (node) => node?.kind === "AllExpr"; ++export const isAllExpr = (node) => exprKind(node) === "AllExpr"; + export class AllExpr extends BaseExpr { + outs; + kind = "AllExpr"; +@@ -192,7 +193,7 @@ export class AllExpr extends BaseExpr { + return `all(${this.outs.map((out) => out[inspect]()).join(", ")})`; + } + } +-export const isRefExpr = (node) => node?.kind === "RefExpr"; ++export const isRefExpr = (node) => exprKind(node) === "RefExpr"; + export class RefExpr extends BaseExpr { + stack; + stage; +@@ -209,7 +210,7 @@ export class RefExpr extends BaseExpr { + return `ref(${this.resourceId}, { stack: ${this.stack}, stage: ${this.stage} })`; + } + } +-export const isStackRefExpr = (node) => node?.kind === "StackRefExpr"; ++export const isStackRefExpr = (node) => exprKind(node) === "StackRefExpr"; + /** + * A reference to the persisted output of a Stack at `(stack, stage)`. + * +diff --git a/src/Drizzle/Schema.ts b/src/Drizzle/Schema.ts +index 07adafc9f..601b06b24 100644 +--- a/src/Drizzle/Schema.ts ++++ b/src/Drizzle/Schema.ts +@@ -181,13 +181,17 @@ export const SchemaProvider = () => + const dialect = props.dialect ?? "postgres"; + const kit = yield* loadKit(dialect); + const schemaModule = yield* loadSchemaModule(props); ++ const prevEntry = yield* readLatestSnapshot(out); + const cur = yield* Effect.tryPromise({ +- try: () => kit.generateDrizzleJson(schemaModule), ++ try: () => ++ kit.generateDrizzleJson( ++ schemaModule, ++ (prevEntry?.snapshot as { id?: string } | undefined)?.id, ++ ), + catch: (cause) => + new Error(`drizzle-kit generateDrizzleJson failed: ${cause}`), + }); + +- const prevEntry = yield* readLatestSnapshot(out); + // For the initial migration, drizzle-kit needs an *empty* snapshot + // produced by `generateDrizzleJson({})`, not a bare `{}` — the + // snapshot has internal fields (`ddl`, etc.) that the differ reads. +diff --git a/src/Output.ts b/src/Output.ts +index 967d27d8f..46e4622e3 100644 +--- a/src/Output.ts ++++ b/src/Output.ts +@@ -72,6 +72,8 @@ export type ToOutput = [Extract] extends [never] + + export const ExprSymbol = Symbol.for("alchemy/Expr"); + ++const exprKind = (node: any): unknown => node?.[ExprSymbol]?.kind ?? node?.kind; ++ + export const isExpr = (value: any): value is Expr => + value && + (typeof value === "object" || typeof value === "function") && +@@ -145,7 +147,7 @@ export type ArrayExpr = Output & { + + export const isResourceExpr = ( + node: Expr | any, +-): node is ResourceExpr => node?.kind === "ResourceExpr"; ++): node is ResourceExpr => exprKind(node) === "ResourceExpr"; + + export class ResourceExpr extends BaseExpr { + readonly kind = "ResourceExpr"; +@@ -163,7 +165,7 @@ export class ResourceExpr extends BaseExpr { + + export const isPropExpr = ( + node: any, +-): node is PropExpr => node?.kind === "PropExpr"; ++): node is PropExpr => exprKind(node) === "PropExpr"; + + export class PropExpr< + A = any, +@@ -186,7 +188,7 @@ export class PropExpr< + export const literal = (value: A) => new LiteralExpr(value); + + export const isLiteralExpr = (node: any): node is LiteralExpr => +- node?.kind === "LiteralExpr"; ++ exprKind(node) === "LiteralExpr"; + + export class LiteralExpr extends BaseExpr { + readonly kind = "LiteralExpr"; +@@ -217,7 +219,7 @@ export const map: { + //Output.ApplyExpr + export const isApplyExpr = ( + node: Output, +-): node is ApplyExpr => node?.kind === "ApplyExpr"; ++): node is ApplyExpr => exprKind(node) === "ApplyExpr"; + + export class ApplyExpr extends BaseExpr { + readonly kind = "ApplyExpr"; +@@ -259,7 +261,7 @@ export const flatMap: { + + export const isFlatMapExpr = ( + node: any, +-): node is FlatMapExpr => node?.kind === "FlatMapExpr"; ++): node is FlatMapExpr => exprKind(node) === "FlatMapExpr"; + + export class FlatMapExpr extends BaseExpr< + B, +@@ -280,7 +282,7 @@ export class FlatMapExpr extends BaseExpr< + + export const isEffectExpr = ( + node: any, +-): node is EffectExpr => node?.kind === "EffectExpr"; ++): node is EffectExpr => exprKind(node) === "EffectExpr"; + + export class EffectExpr extends BaseExpr< + B, +@@ -301,7 +303,7 @@ export class EffectExpr extends BaseExpr< + + export const isNamedExpr = ( + node: any, +-): node is NamedExpr => node?.kind === "NamedExpr"; ++): node is NamedExpr => exprKind(node) === "NamedExpr"; + + /** + * Wraps another `Expr` and overrides its `toString()` / inspect output. +@@ -352,7 +354,7 @@ type Tuple< + + export const isAllExpr = ( + node: any, +-): node is AllExpr => node?.kind === "AllExpr"; ++): node is AllExpr => exprKind(node) === "AllExpr"; + + export class AllExpr extends BaseExpr { + readonly kind = "AllExpr"; +@@ -366,7 +368,7 @@ export class AllExpr extends BaseExpr { + } + + export const isRefExpr = (node: any): node is RefExpr => +- node?.kind === "RefExpr"; ++ exprKind(node) === "RefExpr"; + + export class RefExpr extends BaseExpr { + readonly kind = "RefExpr"; +@@ -384,7 +386,7 @@ export class RefExpr extends BaseExpr { + } + + export const isStackRefExpr = (node: any): node is StackRefExpr => +- node?.kind === "StackRefExpr"; ++ exprKind(node) === "StackRefExpr"; + + /** + * A reference to the persisted output of a Stack at `(stack, stage)`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4bab3509ea..a85a9d5c3a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,12 @@ catalogs: '@effect/tsgo': specifier: 0.11.4 version: 0.11.4 + '@noble/curves': + specifier: 1.9.1 + version: 1.9.1 + '@noble/hashes': + specifier: 1.8.0 + version: 1.8.0 '@pierre/diffs': specifier: 1.1.20 version: 1.1.20 @@ -24,6 +30,9 @@ catalogs: '@vitest/runner': specifier: ^4.1.8 version: 4.1.8 + jose: + specifier: 6.2.2 + version: 6.2.2 typescript: specifier: ~6.0.3 version: 6.0.3 @@ -35,10 +44,17 @@ catalogs: version: 2.9.0 overrides: + '@clerk/clerk-js>@base-org/account': '-' + '@clerk/clerk-js>@coinbase/wallet-sdk': '-' + '@clerk/clerk-js>@solana/wallet-adapter-base': '-' + '@clerk/clerk-js>@solana/wallet-adapter-react': '-' + '@clerk/clerk-js>@solana/wallet-standard': '-' + '@clerk/clerk-js>@wallet-standard/core': '-' '@effect/atom-react': 4.0.0-beta.73 '@effect/platform-bun': 4.0.0-beta.73 '@effect/platform-node': 4.0.0-beta.73 '@effect/platform-node-shared': 4.0.0-beta.73 + '@effect/sql-pg': 4.0.0-beta.73 '@effect/sql-sqlite-bun': 4.0.0-beta.73 '@effect/vitest': 4.0.0-beta.73 '@types/node': 24.12.4 @@ -52,6 +68,9 @@ patchedDependencies: '@pierre/diffs@1.1.20': hash: e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0 path: patches/@pierre%2Fdiffs@1.1.20.patch + alchemy@2.0.0-beta.49: + hash: 4d4f481bc380becaa0baa4cbc29660d804d94494b24ded1e40dcef2e91a706aa + path: patches/alchemy@2.0.0-beta.49.patch effect@4.0.0-beta.73: hash: a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01 path: patches/effect@4.0.0-beta.73.patch @@ -83,10 +102,10 @@ importers: version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' yaml: specifier: 'catalog:' version: 2.9.0 @@ -95,7 +114,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -123,7 +142,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -132,16 +151,16 @@ importers: version: 10.1.0 vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' apps/marketing: dependencies: astro: specifier: ^6.0.4 - version: 6.4.2(@types/node@24.12.4)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 6.4.2(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) devDependencies: '@astrojs/check': specifier: ^0.9.7 @@ -157,7 +176,10 @@ importers: dependencies: '@callstack/liquid-glass': specifier: ^0.7.1 - version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@clerk/expo': + specifier: ^3.3.0 + version: 3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.73 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(react@19.2.3)(scheduler@0.27.0) @@ -166,16 +188,22 @@ importers: version: 0.4.2 '@expo/ui': specifier: ~56.0.8 - version: 56.0.15(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) '@legendapp/list': specifier: 3.0.0-beta.44 - version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@noble/curves': + specifier: 'catalog:' + version: 1.9.1 + '@noble/hashes': + specifier: 'catalog:' + version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' version: 1.1.20(patch_hash=e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@shikijs/core': specifier: 3.23.0 version: 3.23.0 @@ -214,34 +242,37 @@ importers: version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) expo: specifier: ^56.0.0 - version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-auth-session: + specifier: ~56.0.12 + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-build-properties: specifier: ~56.0.15 version: 56.0.16(expo@56.0.8) expo-camera: specifier: ~56.0.7 - version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-clipboard: specifier: ~56.0.3 - version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-constants: specifier: ~56.0.16 - version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) + version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) expo-dev-client: specifier: ~56.0.16 - version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) + version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-file-system: specifier: ~56.0.7 - version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) + version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-glass-effect: specifier: ~56.0.4 - version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: specifier: ~56.0.3 version: 56.0.3(expo@56.0.8) @@ -250,13 +281,16 @@ importers: version: 56.0.15(expo@56.0.8) expo-linking: specifier: ~56.0.12 - version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-notifications: + specifier: ~56.0.14 + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-paste-input: specifier: ^0.1.15 - version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-router: specifier: ~56.2.7 - version: 56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51) + version: 56.2.8(c021de11d02907bd585610408f5252e8) expo-secure-store: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) @@ -265,10 +299,16 @@ importers: version: 56.0.10(expo@56.0.8)(typescript@6.0.3) expo-symbols: specifier: ~56.0.5 - version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-updates: specifier: ~56.0.17 - version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-web-browser: + specifier: ~56.0.5 + version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-widgets: + specifier: ~56.0.15 + version: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) punycode: specifier: ^2.3.1 version: 2.3.1 @@ -280,40 +320,40 @@ importers: version: 19.2.3(react@19.2.3) react-native: specifier: 0.85.3 - version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-gesture-handler: specifier: ~2.31.1 - version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-image-viewing: specifier: ^0.2.2 - version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.6 - version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: ^0.35.4 - version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: specifier: 4.3.1 - version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-safe-area-context: specifier: ~5.7.0 - version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.9 - version: 0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-svg: specifier: 15.15.4 - version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: specifier: 0.8.3 - version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) shiki: specifier: 3.23.0 version: 3.23.0 @@ -322,14 +362,17 @@ importers: version: 3.6.0 uniwind: specifier: ^1.6.2 - version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(tailwindcss@4.3.0) + version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) devDependencies: + '@effect/vitest': + specifier: 4.0.0-beta.73 + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@types/react': specifier: ~19.2.0 version: 19.2.16 babel-preset-expo: specifier: ~56.0.0 - version: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@56.0.8)(react-refresh@0.14.2) + version: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2) tailwindcss: specifier: ^4.0.0 version: 4.3.0 @@ -344,13 +387,13 @@ importers: version: 0.3.159(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.73 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) @@ -369,7 +412,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -396,16 +439,22 @@ importers: version: link:../../packages/effect-codex-app-server vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' apps/web: dependencies: '@base-ui/react': specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/clerk-js': + specifier: ^6.13.0 + version: 6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': + specifier: ^6.7.2 + version: 6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -463,6 +512,9 @@ importers: effect: specifier: 4.0.0-beta.73 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + jose: + specifier: 'catalog:' + version: 6.2.2 lexical: specifier: ^0.41.0 version: 0.41.0 @@ -490,7 +542,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.0-beta.73 + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.0.3) @@ -532,19 +587,65 @@ importers: version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' vitest-browser-react: specifier: ^2.0.5 - version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 2.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + + infra/relay: + dependencies: + '@clerk/backend': + specifier: 3.4.14 + version: 3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@effect/sql-pg': + specifier: 4.0.0-beta.73 + version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@t3tools/client-runtime': + specifier: workspace:* + version: link:../../packages/client-runtime + '@t3tools/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@t3tools/shared': + specifier: workspace:* + version: link:../../packages/shared + alchemy: + specifier: 2.0.0-beta.49 + version: 2.0.0-beta.49(patch_hash=4d4f481bc380becaa0baa4cbc29660d804d94494b24ded1e40dcef2e91a706aa)(69336a667f2376a65731251c78dce4d9) + drizzle-orm: + specifier: 1.0.0-rc.3 + version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260603.1)(@effect/sql-pg@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + effect: + specifier: 4.0.0-beta.73 + version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260601.1 + version: 4.20260603.1 + '@effect/platform-node': + specifier: 4.0.0-beta.73 + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.0-beta.73 + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + drizzle-kit: + specifier: 1.0.0-rc.3 + version: 1.0.0-rc.3 + vitest: + specifier: npm:@voidzero-dev/vite-plus-test@latest + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' oxlint-plugin-t3code: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 @@ -554,16 +655,16 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@types/bun': specifier: 'catalog:' version: 1.3.14 vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' packages/client-runtime: dependencies: @@ -579,13 +680,13 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' packages/contracts: dependencies: @@ -595,13 +696,13 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' packages/effect-acp: dependencies: @@ -611,22 +712,22 @@ importers: devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.73(@effect/platform-node@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@effect/platform-node@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@effect/platform-node': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' packages/effect-codex-app-server: dependencies: @@ -636,47 +737,56 @@ importers: devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.73(@effect/platform-node@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@effect/platform-node@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@effect/platform-node': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' packages/shared: dependencies: + '@noble/curves': + specifier: 'catalog:' + version: 1.9.1 + '@noble/hashes': + specifier: 'catalog:' + version: 1.8.0 '@t3tools/contracts': specifier: workspace:* version: link:../contracts effect: specifier: 4.0.0-beta.73 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + jose: + specifier: 'catalog:' + version: 6.2.2 devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' packages/ssh: dependencies: @@ -692,41 +802,41 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' packages/tailscale: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) effect: specifier: 4.0.0-beta.73 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' scripts: dependencies: @@ -735,7 +845,7 @@ importers: version: 0.2.141(zod@4.4.3) '@effect/platform-node': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + version: 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -748,22 +858,29 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.73 - version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + version: 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) '@types/bun': specifier: 'catalog:' version: 1.3.14 vite-plus: specifier: 'catalog:' - version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + version: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@latest - version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alcalzone/ansi-tokenize@0.2.5': + resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} + engines: {node: '>=18'} + + '@alchemy.run/node-utils@0.0.4': + resolution: {integrity: sha512-TiIhPXCTCi3tk0zmdYJJ14CNSesSfsJxXdIOP0HTSItQ1mZWLocrF7qCuEWKyW/IEFzp6kaiOf19aIA/mbCp1g==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141': resolution: {integrity: sha512-9HZ0ot6+FwOfQ1aeMqQLH4IJGMm/DcP08SysDxscVjBm6l2JjqleHohxi3zid0DurfGweqT+4x9GScJffwg55g==} cpu: [arm64] @@ -908,6 +1025,99 @@ packages: '@astrojs/yaml2ts@0.2.4': resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-cognito-identity@3.1061.0': + resolution: {integrity: sha512-U3ULArHdDdu6MOtgkg3ojVVAiT5mjKMv2hUdvu3PvLxNZXV6T3pHY4b4GCAcv9vHXqZ5ci+4IMvCwHkoY6kMzA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.17': + resolution: {integrity: sha512-r8o4h2K7j6P9ngno+8ei0aK0U/4JwDb7A2fMMxGVoSqDN8AFlIzSDeZHME9LcVLR2codyhtr1WAAg+/nmkeeMA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-cognito-identity@3.972.40': + resolution: {integrity: sha512-viZSv9qWrcfWvpnhy8FPQ5y0ee4TiCOX/xM8LSQXR+xJn0P4DV7B+XwXfOZ+Ik3+yQ/aO69qVnngEpXExAwizA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.43': + resolution: {integrity: sha512-g0XVQKzaA/4cq1vz1IvCQwYM+1Pkv01J9yHDpCTXekVuGZRDEz0wqBQ1AuYTq7FM6uik4uBGH8Tb5d9YvgeA7g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.45': + resolution: {integrity: sha512-w9PuOoKCt6+xoESvY+zlV0u3PKQ0mVL259PcsVR6a3S/uYJJHnIi4r1NxdJHEcNldUVRIciltWnFMGBR4YEm3g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.48': + resolution: {integrity: sha512-+6BQ6Lrnc+EyAGElLRW6j+Sa+RirPHnIJsobvYO6nnyK+oGKmz1ne/ieclbLWyjyDKEU3/JVJWcWY3VLFPvGtQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.47': + resolution: {integrity: sha512-Iy2ebWVgrZBH05464uJiQYu6HSSiROnwVZptthEFXx2gWjo1ORCxEAFZB5Cr2MdfrSnZ+0QUPkZ1ZpCqpkUrLQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.50': + resolution: {integrity: sha512-b05Aelq5cqAvCCDQjCYacl0XmR8QhBNSqLbsdISkQmlQBa5oPS66zYPteWcSp5LswbpoIe552EUGjluKiadBig==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.43': + resolution: {integrity: sha512-GPokLNyvTfCmuaHk+v3GKVs4ZT3cMu5kgS2a+NPkOMt96cq6fSIK0g+mZHpGS6Cd4QGrPKesANEaLUKgOskTzg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.47': + resolution: {integrity: sha512-0AzvLrzlvJs0DzbeWGvNj+bX3Uzd7VNS6vDqCOdZzBlCGKGd78uxctJSW9iK/Rt/nxiJqpTvrYQlVJ4guVM2Dw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.47': + resolution: {integrity: sha512-eksfbUErOejUAGWBAcNqaP7IX21oUOEo73d9R56k9Ua4d57qS90NEYkWJsuSGzTXMFulCu17qXJI/qGmM7hvoA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-providers@3.1061.0': + resolution: {integrity: sha512-Jf7N/lx74AV7BLvooCkqMqV4xBAyHJ+L/Hn7TbU2QSlQsVK8Ngm5R0Z9ljD4PwxCVfUUc8T8jGhY+ZaTyQjnRA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.15': + resolution: {integrity: sha512-Fpri1/PXKMKveORZ7E00VLTlWS5DkfZkW70PUE+bOnpWpAeHAQLoiDHhkzN3kNWbbSsGg64+IZYiq/EZgME3Mg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.31': + resolution: {integrity: sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1060.0': + resolution: {integrity: sha512-6NZaMKkFhpaNiwLpHi1sZaYjidL/lCJE6ME6NxwA8gv9vQna+Kr0j4OFwVoz6tANRWM3WbGz6jiPsGX/Vkjwow==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.10': + resolution: {integrity: sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.27': + resolution: {integrity: sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1330,14 +1540,189 @@ packages: resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} + '@clack/core@0.5.0': + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + '@clack/core@1.4.0': resolution: {integrity: sha512-7Wctjq6f7c1CPz8sPpkwUnz8yRgVANkpNupb81q432FjcJg4l+Sw7XANdNSdWfAKq0IHI0JTcUeK5dxs/HrGPw==} engines: {node: '>= 20.12.0'} + '@clack/prompts@0.11.0': + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@clack/prompts@1.5.0': resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} engines: {node: '>= 20.12.0'} + '@clerk/backend@3.4.14': + resolution: {integrity: sha512-0iaMT7k4wDk31QVC3HMaoeVFttblwsCECTHKNQpbRzIyD8j2gHdKEw/FNjffoyqyBqPw869IQlk1YokUlwVAqQ==} + engines: {node: '>=20.9.0'} + + '@clerk/clerk-js@6.14.0': + resolution: {integrity: sha512-xreDPw31OIk/VQj36qdgjzc4Rk2HwMar25nOu/ts2gf7PrbhU4XQdrtnt74g4fTmSMp8xeyjzHqa9adDXVjISw==} + engines: {node: '>=20.9.0'} + + '@clerk/expo@3.3.1': + resolution: {integrity: sha512-c4g64z5sgJoGYjK0NeasNwOMy9Di7cEjICq56BHSowdOuB+6UGtWBNw+yHzgS1gxi2kJgl7WQCmmXRsoZNWxAg==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@clerk/expo-passkeys': '>=0.0.6' + expo: '>=53 <57' + expo-apple-authentication: '>=7.0.0' + expo-auth-session: '>=5' + expo-constants: '>=12' + expo-crypto: '>=12' + expo-local-authentication: '>=13.5.0' + expo-secure-store: '>=12.4.0' + expo-web-browser: '>=12.5.0' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-native: '>=0.73' + peerDependenciesMeta: + '@clerk/expo-passkeys': + optional: true + expo-apple-authentication: + optional: true + expo-auth-session: + optional: true + expo-constants: + optional: true + expo-crypto: + optional: true + expo-local-authentication: + optional: true + expo-secure-store: + optional: true + expo-web-browser: + optional: true + + '@clerk/react@6.7.3': + resolution: {integrity: sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==} + engines: {node: '>=20.9.0'} + peerDependencies: + react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + + '@clerk/shared@4.15.0': + resolution: {integrity: sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==} + engines: {node: '>=20.9.0'} + peerDependencies: + react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260526.1': + resolution: {integrity: sha512-/pR3GH3gfv0PUp7DjI8v0aAIDOqFwibq4bg5xT7TZgcVdBV/cJQWckdXCMqiRtHiawLwogUX00EIOINkYJ1Zqg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260526.1': + resolution: {integrity: sha512-rcyu0iANYfaiezKh3Mcao1O4IIgVfQldxduiL5TZT1sP0NIeRY4YReSTrzPxNnXxSYaIqaqRHMcHbUM/ic4knA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260526.1': + resolution: {integrity: sha512-5EZAEnlLwa9oGJRo8Nd3iY5Wcd9ROGNNG90xNIGp8MEjj8v2jTn42NC47fCZKFdnLj3+S+vWEhu1x0GVJnALjA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260526.1': + resolution: {integrity: sha512-X/YBQXeXFeCN7QTStoWrATEBc9WKl7PIqkw/dQkjyJ72gh3rkLe0+Xkzp3wO7gtxTDQMa7NPGy1W4+sdMf8q1g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260526.1': + resolution: {integrity: sha512-R+tqpFFdcfZIljx8fIW9rj9fRTtDgfoA2yonsfAGa6e8snrmr+38mdFHtkRC0D3UyZpn/hOtmXiUBfdX2gMR7Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260603.1': + resolution: {integrity: sha512-TLeVHoBbcYv35S5TdRWUoj3IJ56BhHtrsuci+O7ithU8yz7ttNdCk6rAl1QUSGNVEWSIp54bWOuV/xmX1zu79g==} + + '@distilled.cloud/aws@0.22.4': + resolution: {integrity: sha512-qa+MnIdlRy6QmXuRwGqBNQnF2mu9MyUck2yeYkEesDpOH4eJFcjZy8nVUIhWxACfSIMvLd59FD6bkdVALzI5Dg==} + peerDependencies: + effect: 4.0.0-beta.73 + + '@distilled.cloud/axiom@0.22.4': + resolution: {integrity: sha512-CcSe8UPEPLGW/tGZ/ky5Hn5Gfd2/43bn5km6opeiyN45HrHbkvYURQeiqgcivJt2Y01Mh0lMeBkQQdfCjJIhjA==} + peerDependencies: + effect: 4.0.0-beta.73 + + '@distilled.cloud/cloudflare-rolldown-plugin@0.10.1': + resolution: {integrity: sha512-nEmEmUgbVkr9AH95/rq3cnWXbovBhXRAKW5/Ae1GwFndXBcdNy2Mx7w+AWUGuA3NSkDg6Q4W4EylPtJ3sduPqg==} + peerDependencies: + rolldown: ^1.0.1 + peerDependenciesMeta: + rolldown: + optional: true + + '@distilled.cloud/cloudflare-runtime@0.10.1': + resolution: {integrity: sha512-KxVqZo8Kld4krux9yf4doSIwBlNdgCLY2ivIKBXv7vo3pY3pH3fcmCXE/+rGjD2iGqPU7eyEp4DdmKcAJAqhNw==} + peerDependencies: + '@distilled.cloud/cloudflare': ^0.22.0 + '@effect/platform-bun': 4.0.0-beta.73 + '@effect/platform-node': 4.0.0-beta.73 + effect: 4.0.0-beta.73 + peerDependenciesMeta: + '@effect/platform-bun': + optional: true + '@effect/platform-node': + optional: true + + '@distilled.cloud/cloudflare-vite-plugin@0.10.1': + resolution: {integrity: sha512-g0F/BoxUy8fg4U/IkeHyauXSQ8YU9N6aBOhybn0WvtLBeOUrqHT7R3+jMQsdt2PGKhkHPT3TlxN9FPaAUn0YSg==} + peerDependencies: + '@distilled.cloud/cloudflare': ^0.22.0 + '@distilled.cloud/cloudflare-runtime': 0.10.1 + '@effect/platform-bun': 4.0.0-beta.73 + '@effect/platform-node': 4.0.0-beta.73 + effect: 4.0.0-beta.73 + vite: ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@effect/platform-bun': + optional: true + '@effect/platform-node': + optional: true + + '@distilled.cloud/cloudflare@0.22.4': + resolution: {integrity: sha512-hDqVoGmJlPoGCgurVFbk399gkPyu9Pr/w8D4Rv/q5twaK29Ioy5NGk2oLJrfKwaKZVeBDl7TzIxVX0wW3HQlCA==} + peerDependencies: + effect: 4.0.0-beta.73 + + '@distilled.cloud/core@0.22.4': + resolution: {integrity: sha512-OuyM6cfOzQO0+n05Mb8jHDNrFxBjw27Q+9KL/uWRknLfTa3B1KDzyYJlt14MOFubl4YywreteNxGzcoGNhFN2A==} + peerDependencies: + effect: 4.0.0-beta.73 + + '@distilled.cloud/neon@0.22.4': + resolution: {integrity: sha512-dE6h0mNTi5CUalMLcXgJmolYeCakB9bYCUHo1w/ERzwbb9MsyKLYckvRQbK1mcwKmrBdzZJWC1kaG8ZfNYDfTg==} + peerDependencies: + effect: 4.0.0-beta.73 + + '@distilled.cloud/planetscale@0.22.4': + resolution: {integrity: sha512-SrMlYTnKBtZ0p6Sug4RzsSHRUPxJtjHLUmANI7q1SGDPoWZLCCfRuDdVTFBVi9WBjxlmJtDDjpHCQoEwA8qeLQ==} + peerDependencies: + effect: 4.0.0-beta.73 + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1366,6 +1751,9 @@ packages: peerDependencies: react: '>=16.8.0' + '@drizzle-team/brocli@0.11.0': + resolution: {integrity: sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg==} + '@effect/atom-react@4.0.0-beta.73': resolution: {integrity: sha512-RjNqEMV3z3iEFRBtJ1RVuxBNFMYxhDo2iRwck3kBYjrBuXWrKIRyqnunWJdL2KX97Gqp0orxaujG7qnIA5aIEA==} peerDependencies: @@ -1398,6 +1786,11 @@ packages: effect: 4.0.0-beta.73 ioredis: ^5.7.0 + '@effect/sql-pg@4.0.0-beta.73': + resolution: {integrity: sha512-TjzcMi6iXGMgJrc/zj66LRIhYz5p8BcnAzFNOvE/xqDaBNaUa+tiW/mNs7TGg1mbOvHDPw4AQhB2ZXG4gnbvFg==} + peerDependencies: + effect: 4.0.0-beta.73 + '@effect/sql-sqlite-bun@4.0.0-beta.73': resolution: {integrity: sha512-dkTDDeBLjaKo7h1IE049Oj2BDKQOT+Kc57KbBVaSk36dRdsEmVspRMP7OGbZj3etJU3lL1GJi1BIfnb4N3uMRw==} peerDependencies: @@ -1489,156 +1882,312 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -2073,6 +2622,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-temporal/polyfill@0.5.1': + resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} + engines: {node: '>=12'} + '@legendapp/list@3.0.0-beta.44': resolution: {integrity: sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A==} peerDependencies: @@ -2159,6 +2712,63 @@ packages: peerDependencies: yjs: '>=13.5.22' + '@libsql/client@0.17.3': + resolution: {integrity: sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==} + + '@libsql/core@0.17.3': + resolution: {integrity: sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg==} + + '@libsql/darwin-arm64@0.5.29': + resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.29': + resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.10.0': + resolution: {integrity: sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm-gnueabihf@0.5.29': + resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm-musleabihf@0.5.29': + resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} + cpu: [arm] + os: [linux] + + '@libsql/linux-arm64-gnu@0.5.29': + resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.29': + resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.29': + resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.29': + resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.29': + resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} + cpu: [x64] + os: [win32] + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -2209,6 +2819,84 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodable/entities@2.1.1': + resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.10': + resolution: {integrity: sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==} + engines: {node: '>= 20'} + + '@octokit/rest@22.0.1': + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -2231,6 +2919,9 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -2838,6 +3529,12 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-android-arm64@1.0.1': + resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2850,6 +3547,12 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.1': + resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.3': resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2862,6 +3565,12 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.1': + resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.3': resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2874,6 +3583,12 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.1': + resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.3': resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2886,6 +3601,12 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2898,6 +3619,12 @@ packages: cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.3': resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2910,6 +3637,12 @@ packages: cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2922,6 +3655,12 @@ packages: cpu: [ppc64] os: [linux] + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2934,6 +3673,12 @@ packages: cpu: [s390x] os: [linux] + '@rolldown/binding-linux-s390x-gnu@1.0.1': + resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2946,6 +3691,12 @@ packages: cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2958,6 +3709,12 @@ packages: cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2970,6 +3727,12 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.1': + resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2981,6 +3744,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.3': resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2992,6 +3760,12 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.3': resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3004,6 +3778,12 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.3': resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3226,9 +4006,67 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@smithy/core@3.24.6': + resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.7': + resolution: {integrity: sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.6': + resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-config-provider@4.4.6': + resolution: {integrity: sha512-M+gG6eQ0y073mSmNB+erRXJvwpsqsN72ol2w6vcd8FEKeG7pqYK0JvzfVqONkPj2ElBB2pg+cU13I850b//Wag==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.7.6': + resolution: {integrity: sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.5.6': + resolution: {integrity: sha512-In8gYD2R66EKlGAq9QrNKVrMOGaGBD7LUNp2kUjeQ4V9zNktFIXBPmrCySr4YYo+jVeVL6CnWj26sOamcF0qIg==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.6': + resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.3': + resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.4.6': + resolution: {integrity: sha512-V6ApAGvCQnb7Wy1Sy60AQc+7UOEaNQxvAXBLdMi5Zzm66cmX0srvfAxDmg7BGuJ+9H9ez0PPWS/AeFgWxwGavA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stripe/stripe-js@5.6.0': + resolution: {integrity: sha512-w8CEY73X/7tw2KKlL3iOk679V9bWseE4GzNz3zlaYxcTjmcmWOathRb0emgo/QQ3eoNzmq68+2Y2gxluAv3xGw==} + engines: {node: '>=12.16'} + + '@swc/helpers@0.5.21': + resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -3524,6 +4362,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/aws-lambda@8.10.161': + resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3889,6 +4730,12 @@ packages: '@xterm/xterm@6.0.0': resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + '@zxcvbn-ts/core@3.0.4': + resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==} + + '@zxcvbn-ts/language-common@3.0.4': + resolution: {integrity: sha512-viSNNnRYtc7ULXzxrQIVUNwHAPSXRtoIwy/Tq4XQQdIknBzw4vz36lQLF6mvhMlTIlpjoN/Z1GFu/fwiAlUSsw==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -3932,6 +4779,37 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + alchemy@2.0.0-beta.49: + resolution: {integrity: sha512-zR4bzVyHy1kza8hd3t05wUfax318AgMVQmTKr62PtUrztxZQ8++/daIwvafXlldQVgptlusCAz/WirVDCyVbWQ==} + hasBin: true + peerDependencies: + '@effect/platform-bun': 4.0.0-beta.73 + '@effect/platform-node': 4.0.0-beta.73 + '@effect/sql-pg': 4.0.0-beta.73 + drizzle-kit: '>=1.0.0-rc.1' + drizzle-orm: '>=1.0.0-rc.1' + effect: 4.0.0-beta.73 + vite: ^8.0.7 + ws: ^8.20.0 + peerDependenciesMeta: + '@effect/platform-bun': + optional: true + '@effect/platform-node': + optional: true + '@effect/sql-pg': + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + vite: + optional: true + ws: + optional: true + + alien-signals@2.0.6: + resolution: {integrity: sha512-P3TxJSe31bUHBiblg59oU1PpaWPtmxF9GhJ/cB7OkgJ0qN/ifFSKUI25/v8ZhsT+lIG6ac8DpTOplXxORX6F3Q==} + anser@1.4.10: resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} @@ -3939,6 +4817,10 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@4.1.1: resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} engines: {node: '>=6'} @@ -3947,6 +4829,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -3959,6 +4845,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -4002,6 +4892,17 @@ packages: engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -4051,6 +4952,9 @@ packages: expo-widgets: optional: true + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -4061,6 +4965,9 @@ packages: barcode-detector@3.2.0: resolution: {integrity: sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==} + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -4069,6 +4976,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -4084,6 +4994,9 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -4103,6 +5016,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browser-tabs-lock@1.3.0: + resolution: {integrity: sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -4117,6 +5033,13 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} + engines: {node: '>=6.14.2'} + builder-util-runtime@9.5.1: resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} engines: {node: '>=12.0.0'} @@ -4151,6 +5074,12 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + capnweb@0.6.1: + resolution: {integrity: sha512-fmhV26QPd1ewf5R74h55oVZnGwIcSaRMzbfLQUy8+zOBjuTmT3KXoT8wxHvnp1m9Ht9BoUUS5ZwNLoVLfQTyBg==} + + capnweb@0.7.0: + resolution: {integrity: sha512-zO7tt5ch2tImacaR/oMd7e1dqi/fWU7hjZdvQMv6Yo3v9uUGA8cPIUQGvfQTu2c+NgyE/j/oDmMaUlf1PXyfJw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4162,6 +5091,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -4204,14 +5137,26 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + cli-cursor@2.1.0: resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} engines: {node: '>=4'} + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -4238,6 +5183,10 @@ packages: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -4307,6 +5256,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-es@1.2.3: resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} @@ -4328,6 +5281,12 @@ packages: core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -4344,6 +5303,9 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -4454,6 +5416,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -4496,6 +5462,126 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + drizzle-kit@1.0.0-rc.3: + resolution: {integrity: sha512-lwGIi6GTlEYzrtac1Szecns+zrAxjaOoNysfBCKRQOXQE7nMJ8IU2I2S6ek3FHEm1N30dQb5YrTdqV7zNH6n7A==} + hasBin: true + + drizzle-orm@1.0.0-rc.3: + resolution: {integrity: sha512-akZOa5UxapFbdBG8IDkfBRpSZJpMHaOJtGgp7oi1oHaiU8S3KN92waHo2l5aRuv1D9tMGYpv3BQFOsiGcNjLTQ==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@effect/sql-pg': 4.0.0-beta.73 + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@sinclair/typebox': '>=0.34.8' + '@sqlitecloud/drivers': '>=1.0.653' + '@tidbcloud/serverless': '*' + '@tursodatabase/database': '>=0.2.1' + '@tursodatabase/database-common': '>=0.2.1' + '@tursodatabase/database-wasm': '>=0.2.1' + '@types/better-sqlite3': '*' + '@types/mssql': ^9.1.4 + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + arktype: '>=2.0.0' + better-sqlite3: '>=9.3.0' + bun-types: '*' + effect: 4.0.0-beta.73 + expo-sqlite: '>=14.0.0' + mssql: ^11.0.1 + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + sql.js: '>=1' + sqlite3: '>=5' + typebox: '>=1.0.0' + valibot: '>=1.0.0-beta.7' + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@effect/sql-pg': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@sinclair/typebox': + optional: true + '@sqlitecloud/drivers': + optional: true + '@tidbcloud/serverless': + optional: true + '@tursodatabase/database': + optional: true + '@tursodatabase/database-common': + optional: true + '@tursodatabase/database-wasm': + optional: true + '@types/better-sqlite3': + optional: true + '@types/mssql': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + arktype: + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + effect: + optional: true + expo-sqlite: + optional: true + mssql: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -4524,6 +5610,9 @@ packages: emmet@2.4.11: resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -4554,6 +5643,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} @@ -4575,9 +5668,17 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-toolkit@1.47.0: + resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -4594,6 +5695,10 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -4627,6 +5732,11 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + expo-application@56.0.3: + resolution: {integrity: sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==} + peerDependencies: + expo: '*' + expo-asset@56.0.15: resolution: {integrity: sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ==} peerDependencies: @@ -4634,6 +5744,12 @@ packages: react: '*' react-native: '*' + expo-auth-session@56.0.13: + resolution: {integrity: sha512-LR8Suq8BHKRFBUcAKTMmZufCcDcr0sQa8rIYit1r7kshrqAy9glIUU4aqHt8tflW/ISN0x1vU+HU8AQaackM0A==} + peerDependencies: + react: '*' + react-native: '*' + expo-build-properties@56.0.16: resolution: {integrity: sha512-C3avazYP2fR8efJBBmhx8yITjIRDaIe3ULPk0YfACP61QfnWC9u3LxaDNNaiIvYfZ+CLne30W+nS5F6pdgO/8g==} peerDependencies: @@ -4767,6 +5883,13 @@ packages: peerDependencies: react-native: '*' + expo-notifications@56.0.15: + resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-paste-input@0.1.15: resolution: {integrity: sha512-wV0z1s0/Q4MB+lNiSwbMfck+1Im6sDIcgyCi5Y0aF35n5kcM4FHSOjmRfzn3UbkMlvxmYfDiHeHvIqn22qatqA==} peerDependencies: @@ -4848,6 +5971,19 @@ packages: expo-dev-client: optional: true + expo-web-browser@56.0.5: + resolution: {integrity: sha512-kaN+wcR5lHwPCH1IgrU1XyPUQvBRzdF1TMp65uAF9iUCyipqYnmrvV87eqAmrdkFFopWVgU7FcxPu1UZw+gvUQ==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-widgets@56.0.16: + resolution: {integrity: sha512-EmWVvskad2XlgCk6j9wz/OqSAeaGxjD6x6Wji99TVjjFd+sTD6W3YJbz3QW/BKTVRcll95m6XjJDCa6B9t9f9A==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo@56.0.8: resolution: {integrity: sha512-GzQi5450yrCk5JRSlm0epsmtURBErh0wS77uWLZImFdnPICuX912MaRWooR+Q1Sw/7aQjp9F+KXH+dvrqGxpeQ==} hasBin: true @@ -4899,9 +6035,16 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -4914,6 +6057,24 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + + fast-xml-parser@5.8.0: + resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} + hasBin: true + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fb-dotslash@0.5.8: resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} engines: {node: '>=20'} @@ -5006,6 +6167,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -5014,6 +6178,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -5030,6 +6198,9 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@5.0.0-beta.4: resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} engines: {node: '>=20.20.0'} @@ -5041,6 +6212,13 @@ packages: github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -5180,6 +6358,12 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + idb-keyval@6.2.1: + resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -5189,10 +6373,17 @@ packages: engines: {node: '>=16.x'} hasBin: true + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -5200,6 +6391,19 @@ packages: resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ink@6.8.0: + resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} + engines: {node: '>=20'} + peerDependencies: + '@types/react': '>=19.0.0' + react: '>=19.0.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -5252,13 +6456,30 @@ packages: engines: {node: '>=20'} hasBin: true + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -5278,6 +6499,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -5286,6 +6510,9 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isbot@5.1.40: resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} engines: {node: '>=18'} @@ -5319,9 +6546,19 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + + js-cookie@3.0.7: + resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} + engines: {node: '>=20'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -5329,6 +6566,9 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsbi@4.3.2: + resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} + jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} @@ -5356,6 +6596,9 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json-with-bigint@3.5.8: + resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -5373,6 +6616,9 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -5406,6 +6652,20 @@ packages: engines: {node: '>=16'} hasBin: true + libsodium-wrappers@0.8.4: + resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==} + + libsodium@0.8.4: + resolution: {integrity: sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==} + + libsql@0.5.29: + resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} + cpu: [x64, arm64, wasm32, arm] + os: [darwin, linux, win32] + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} @@ -5626,10 +6886,16 @@ packages: lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@2.2.0: resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} engines: {node: '>=4'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -5651,6 +6917,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lru_map@0.4.1: resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} @@ -5757,6 +7027,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + metro-babel-transformer@0.84.4: resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -5933,6 +7207,10 @@ packages: resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} engines: {node: '>=4'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -5998,6 +7276,16 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mysql2@3.22.4: + resolution: {integrity: sha512-CtXYlmL7ZamiYKbmqkamQHWJROUHSfm+f3kByzGfknw7kW51mcB2ouMUqYq1XfYxbXmnWo6RhPydx6OCqdgcmQ==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': 24.12.4 + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -6036,6 +7324,10 @@ packages: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -6083,6 +7375,9 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -6111,6 +7406,10 @@ packages: resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} engines: {node: '>=4'} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -6125,6 +7424,10 @@ packages: resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} engines: {node: '>=6'} + os-paths@7.4.0: + resolution: {integrity: sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA==} + engines: {node: '>= 4.0'} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -6177,6 +7480,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -6194,9 +7500,17 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -6223,6 +7537,56 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + + pg-cursor@2.20.0: + resolution: {integrity: sha512-HP/EbUafheaUOs7DxlG6tda/rhmsX2hCTJJJ+gCnhljGyNEs6pBHddbNuomlW3DqEhP3zYD+GqBWkYnJPIZ4tA==} + peerDependencies: + pg: ^8 + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.14.0: + resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg-types@4.1.0: + resolution: {integrity: sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==} + engines: {node: '>=10'} + + pg@8.21.0: + resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -6271,6 +7635,41 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + + postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -6296,10 +7695,16 @@ packages: resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + promise@8.3.0: resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} @@ -6332,6 +7737,9 @@ packages: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} engines: {node: '>=6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} @@ -6479,6 +7887,11 @@ packages: react: '*' react-native: '*' + react-native-url-polyfill@2.0.0: + resolution: {integrity: sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==} + peerDependencies: + react-native: '*' + react-native-worklets@0.8.3: resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==} peerDependencies: @@ -6501,6 +7914,12 @@ packages: '@types/react': optional: true + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -6543,6 +7962,9 @@ packages: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -6663,6 +8085,10 @@ packages: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -6678,6 +8104,10 @@ packages: rettime@0.10.1: resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} @@ -6687,6 +8117,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rolldown@1.0.1: + resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6701,6 +8136,12 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -6768,6 +8209,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -6837,6 +8281,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + slugify@1.6.9: resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} engines: {node: '>=8.0.0'} @@ -6867,9 +8315,21 @@ packages: resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} engines: {node: '>=6'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sql-escaper@1.3.3: + resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -6880,6 +8340,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -6906,6 +8369,17 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -6917,10 +8391,17 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} @@ -6983,6 +8464,10 @@ packages: resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} engines: {node: '>=8'} + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + terser@5.48.0: resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} engines: {node: '>=10'} @@ -7112,10 +8597,17 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.27.0: + resolution: {integrity: sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==} + engines: {node: '>=20.18.1'} + undici@8.3.0: resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -7165,6 +8657,9 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -7302,6 +8797,13 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf-8-validate@6.0.6: + resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} + engines: {node: '>=6.14.2'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -7472,6 +8974,10 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@5.0.0: + resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} + engines: {node: '>=8'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -7481,6 +8987,10 @@ packages: whatwg-url-minimum@0.1.2: resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} + whatwg-url-without-unicode@8.0.0-3: + resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==} + engines: {node: '>=10'} + which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -7490,6 +9000,15 @@ packages: engines: {node: '>= 8'} hasBin: true + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + + workerd@1.20260526.1: + resolution: {integrity: sha512-IHzymht98p10JH1zzwdCpbViAqw97HrwKl7+KfZeASFMsYSrIsAULWdPn0LRC5FTUzBpamLNyKCCKxbgXHgRHQ==} + engines: {node: '>=16'} + hasBin: true + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -7498,6 +9017,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -7529,6 +9052,18 @@ packages: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} + xdg-app-paths@8.3.0: + resolution: {integrity: sha512-mgxlWVZw0TNWHoGmXq+NC3uhCIc55dDpAlDkMQUaIAcQzysb0kxctwv//fvuW61/nAAeUBJMQ8mnZjMmuYwOcQ==} + engines: {node: '>= 4.0'} + + xdg-portable@10.6.0: + resolution: {integrity: sha512-xrcqhWDvtZ7WLmt8G4f3hHy37iK7D2idtosRgkeiSPZEPmBShp0VfmRBLWAPC6zLF48APJ21yfea+RfQMF4/Aw==} + engines: {node: '>= 4.0'} + + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xml2js@0.6.0: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} @@ -7541,6 +9076,10 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -7592,6 +9131,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -7633,6 +9175,13 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@alcalzone/ansi-tokenize@0.2.5': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + '@alchemy.run/node-utils@0.0.4': {} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141': optional: true @@ -7809,6 +9358,220 @@ snapshots: dependencies: yaml: 2.9.0 + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.10 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.10 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.10 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.10 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-cognito-identity@3.1061.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.17 + '@aws-sdk/credential-provider-node': 3.972.50 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.17': + dependencies: + '@aws-sdk/types': 3.973.10 + '@aws-sdk/xml-builder': 3.972.27 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.6 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-cognito-identity@3.972.40': + dependencies: + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.45': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.48': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/credential-provider-env': 3.972.43 + '@aws-sdk/credential-provider-http': 3.972.45 + '@aws-sdk/credential-provider-login': 3.972.47 + '@aws-sdk/credential-provider-process': 3.972.43 + '@aws-sdk/credential-provider-sso': 3.972.47 + '@aws-sdk/credential-provider-web-identity': 3.972.47 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.47': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.50': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.43 + '@aws-sdk/credential-provider-http': 3.972.45 + '@aws-sdk/credential-provider-ini': 3.972.48 + '@aws-sdk/credential-provider-process': 3.972.43 + '@aws-sdk/credential-provider-sso': 3.972.47 + '@aws-sdk/credential-provider-web-identity': 3.972.47 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.47': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/token-providers': 3.1060.0 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.47': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-providers@3.1061.0': + dependencies: + '@aws-sdk/client-cognito-identity': 3.1061.0 + '@aws-sdk/core': 3.974.17 + '@aws-sdk/credential-provider-cognito-identity': 3.972.40 + '@aws-sdk/credential-provider-env': 3.972.43 + '@aws-sdk/credential-provider-http': 3.972.45 + '@aws-sdk/credential-provider-ini': 3.972.48 + '@aws-sdk/credential-provider-login': 3.972.47 + '@aws-sdk/credential-provider-node': 3.972.50 + '@aws-sdk/credential-provider-process': 3.972.43 + '@aws-sdk/credential-provider-sso': 3.972.47 + '@aws-sdk/credential-provider-web-identity': 3.972.47 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.15': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.17 + '@aws-sdk/signature-v4-multi-region': 3.996.31 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.31': + dependencies: + '@aws-sdk/types': 3.973.10 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1060.0': + dependencies: + '@aws-sdk/core': 3.974.17 + '@aws-sdk/nested-clients': 3.997.15 + '@aws-sdk/types': 3.973.10 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.10': + dependencies: + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.27': + dependencies: + '@smithy/types': 4.14.3 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -8316,26 +10079,231 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + dependencies: + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + + '@capsizecss/unpack@4.0.0': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@0.5.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/core@1.4.0': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@0.11.0': + dependencies: + '@clack/core': 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@1.5.0': + dependencies: + '@clack/core': 1.4.0 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clerk/backend@3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + standardwebhooks: 1.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - react + - react-dom + + '@clerk/clerk-js@6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@stripe/stripe-js': 5.6.0 + '@swc/helpers': 0.5.21 + '@tanstack/query-core': 5.100.14 + '@zxcvbn-ts/core': 3.0.4 + '@zxcvbn-ts/language-common': 3.0.4 + alien-signals: 2.0.6 + browser-tabs-lock: 1.3.0 + core-js: 3.47.0 + crypto-js: 4.2.0 + dequal: 2.0.3 + transitivePeerDependencies: + - react + - react-dom + + '@clerk/clerk-js@6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@stripe/stripe-js': 5.6.0 + '@swc/helpers': 0.5.21 + '@tanstack/query-core': 5.100.14 + '@zxcvbn-ts/core': 3.0.4 + '@zxcvbn-ts/language-common': 3.0.4 + alien-signals: 2.0.6 + browser-tabs-lock: 1.3.0 + core-js: 3.47.0 + crypto-js: 4.2.0 + dequal: 2.0.3 + transitivePeerDependencies: + - react + - react-dom + + '@clerk/expo@3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + dependencies: + '@clerk/clerk-js': 6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + base-64: 1.0.0 + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + tslib: 2.8.1 + optionalDependencies: + expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-crypto: 56.0.4(expo@56.0.8) + expo-secure-store: 56.0.4(expo@56.0.8) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + + '@clerk/react@6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + tslib: 2.8.1 + + '@clerk/react@6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + tslib: 2.8.1 + + '@clerk/shared@4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.100.14 + dequal: 2.0.3 + glob-to-regexp: 0.4.1 + js-cookie: 3.0.7 + optionalDependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@clerk/shared@4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.100.14 + dequal: 2.0.3 + glob-to-regexp: 0.4.1 + js-cookie: 3.0.7 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260526.1 + + '@cloudflare/workerd-darwin-64@1.20260526.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260526.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260526.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260526.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260526.1': + optional: true + + '@cloudflare/workers-types@4.20260603.1': {} + + '@distilled.cloud/aws@0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/credential-providers': 3.1061.0 + '@aws-sdk/types': 3.973.10 + '@distilled.cloud/core': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@smithy/shared-ini-file-loader': 4.5.6 + '@smithy/types': 4.14.3 + '@smithy/util-base64': 4.4.6 + aws4fetch: 1.0.20 + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + fast-xml-parser: 5.8.0 + + '@distilled.cloud/axiom@0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': + dependencies: + '@distilled.cloud/core': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + + '@distilled.cloud/cloudflare-rolldown-plugin@0.10.1(rolldown@1.0.1)(workerd@1.20260526.1)': + dependencies: + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1) + magic-string: 0.30.21 + unenv: 2.0.0-rc.24 + optionalDependencies: + rolldown: 1.0.1 + transitivePeerDependencies: + - workerd + + '@distilled.cloud/cloudflare-runtime@0.10.1(@distilled.cloud/cloudflare@0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)))(@effect/platform-bun@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': + dependencies: + '@alchemy.run/node-utils': 0.0.4 + '@distilled.cloud/cloudflare': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + capnweb: 0.7.0 + chokidar: 4.0.3 + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + workerd: 1.20260526.1 + xdg-app-paths: 8.3.0 + optionalDependencies: + '@effect/platform-bun': 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) + + '@distilled.cloud/cloudflare-vite-plugin@0.10.1(478fb7fb281e7445addca09c80e1ab0f)': + dependencies: + '@distilled.cloud/cloudflare': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.10.1(rolldown@1.0.1)(workerd@1.20260526.1) + '@distilled.cloud/cloudflare-runtime': 0.10.1(@distilled.cloud/cloudflare@0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)))(@effect/platform-bun@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + optionalDependencies: + '@effect/platform-bun': 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - rolldown + - workerd + + '@distilled.cloud/cloudflare@0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': dependencies: - react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + '@distilled.cloud/core': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) - '@capsizecss/unpack@4.0.0': + '@distilled.cloud/core@0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': dependencies: - fontkitten: 1.0.3 + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) - '@clack/core@1.4.0': + '@distilled.cloud/neon@0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': dependencies: - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 + '@distilled.cloud/core': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) - '@clack/prompts@1.5.0': + '@distilled.cloud/planetscale@0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': dependencies: - '@clack/core': 1.4.0 - fast-string-width: 3.0.2 - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 + '@distilled.cloud/core': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: @@ -8369,6 +10337,8 @@ snapshots: react: 19.2.6 tslib: 2.8.1 + '@drizzle-team/brocli@0.11.0': {} + '@effect/atom-react@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(react@19.2.3)(scheduler@0.27.0)': dependencies: effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) @@ -8381,31 +10351,31 @@ snapshots: react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-beta.73(@effect/platform-node@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': + '@effect/openapi-generator@4.0.0-beta.73(@effect/platform-node@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': dependencies: - '@effect/platform-node': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) + '@effect/platform-node': 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) - '@effect/platform-bun@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': + '@effect/platform-bun@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@effect/platform-node-shared': 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6) effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': + '@effect/platform-node-shared@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.1 effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) - ws: 8.21.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)': + '@effect/platform-node@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@effect/platform-node-shared': 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6) effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) ioredis: 5.11.0 mime: 4.1.0 @@ -8414,6 +10384,17 @@ snapshots: - bufferutil - utf-8-validate + '@effect/sql-pg@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': + dependencies: + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + pg: 8.21.0 + pg-connection-string: 2.12.0 + pg-cursor: 2.20.0(pg@8.21.0) + pg-pool: 3.14.0(pg@8.21.0) + pg-types: 4.1.0 + transitivePeerDependencies: + - pg-native + '@effect/sql-sqlite-bun@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': dependencies: effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) @@ -8449,10 +10430,10 @@ snapshots: '@effect/tsgo-win32-arm64': 0.11.4 '@effect/tsgo-win32-x64': 0.11.4 - '@effect/vitest@4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': + '@effect/vitest@4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': dependencies: effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) - vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' '@egjs/hammerjs@2.0.17': dependencies: @@ -8513,81 +10494,159 @@ snapshots: '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -8595,7 +10654,7 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.38': {} - '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3)': + '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 56.0.9(typescript@6.0.3) @@ -8605,9 +10664,9 @@ snapshots: '@expo/image-utils': 0.10.1(typescript@6.0.3) '@expo/inline-modules': 0.0.10(typescript@6.0.3) '@expo/json-file': 10.2.0 - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - '@expo/metro': 56.0.0 - '@expo/metro-config': 56.0.13(expo@56.0.8)(typescript@6.0.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@expo/metro-config': 56.0.13(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/metro-file-map': 56.0.3 '@expo/osascript': 2.6.0 '@expo/package-manager': 1.12.1 @@ -8619,7 +10678,7 @@ snapshots: '@expo/spawn-async': 1.8.0 '@expo/ws-tunnel': 1.0.6 '@expo/xcpretty': 4.4.4 - '@react-native/dev-middleware': 0.85.3 + '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) accepts: 1.3.8 arg: 5.0.2 bplist-creator: 0.1.0 @@ -8630,7 +10689,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server: 56.0.4 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -8653,11 +10712,11 @@ snapshots: terminal-link: 2.1.1 toqr: 0.1.1 wrap-ansi: 7.0.0 - ws: 8.21.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -8719,18 +10778,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@expo/env@2.3.0': dependencies: @@ -8791,16 +10850,16 @@ snapshots: - supports-color - typescript - '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 - '@expo/metro-config@56.0.13(expo@56.0.8)(typescript@6.0.3)': + '@expo/metro-config@56.0.13(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -8808,7 +10867,7 @@ snapshots: '@expo/config': 56.0.9(typescript@6.0.3) '@expo/env': 2.3.0 '@expo/json-file': 10.2.0 - '@expo/metro': 56.0.0 + '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/require-utils': 56.1.3(typescript@6.0.3) '@expo/spawn-async': 1.8.0 '@jridgewell/gen-mapping': 0.3.13 @@ -8827,7 +10886,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -8845,26 +10904,26 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: react-dom: 19.2.3(react@19.2.3) - '@expo/metro@56.0.0': + '@expo/metro@56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: - metro: 0.84.4 + metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-babel-transformer: 0.84.4 metro-cache: 0.84.4 metro-cache-key: 0.84.4 - metro-config: 0.84.4 + metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-core: 0.84.4 metro-file-map: 0.84.4 metro-minify-terser: 0.84.4 @@ -8873,7 +10932,7 @@ snapshots: metro-source-map: 0.84.4 metro-symbolicate: 0.84.4 metro-transform-plugins: 0.84.4 - metro-transform-worker: 0.84.4 + metro-transform-worker: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -8927,14 +10986,14 @@ snapshots: '@expo/router-server@56.0.12(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo-server@56.0.4)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 react: 19.2.3 optionalDependencies: - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - expo-router: 56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -8949,18 +11008,18 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@56.0.15(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)': dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: '@babel/core': 7.29.7 react-dom: 19.2.3(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -9170,13 +11229,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + '@js-temporal/polyfill@0.5.1': + dependencies: + jsbi: 4.3.2 + + '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -9344,6 +11407,64 @@ snapshots: lexical: 0.41.0 yjs: 13.6.31 + '@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + dependencies: + '@libsql/core': 0.17.3 + '@libsql/hrana-client': 0.10.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + js-base64: 3.7.8 + libsql: 0.5.29 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.17.3': + dependencies: + js-base64: 3.7.8 + + '@libsql/darwin-arm64@0.5.29': + optional: true + + '@libsql/darwin-x64@0.5.29': + optional: true + + '@libsql/hrana-client@0.10.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + dependencies: + '@libsql/isomorphic-ws': 0.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + js-base64: 3.7.8 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-ws@0.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + dependencies: + '@types/ws': 8.18.1 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm-gnueabihf@0.5.29': + optional: true + + '@libsql/linux-arm-musleabihf@0.5.29': + optional: true + + '@libsql/linux-arm64-gnu@0.5.29': + optional: true + + '@libsql/linux-arm64-musl@0.5.29': + optional: true + + '@libsql/linux-x64-gnu@0.5.29': + optional: true + + '@libsql/linux-x64-musl@0.5.29': + optional: true + + '@libsql/win32-x64-msvc@0.5.29': + optional: true + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.23) @@ -9400,6 +11521,91 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@neon-rs/load@0.0.4': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@nodable/entities@2.1.1': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.10 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.3': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.10 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.10': + dependencies: + '@octokit/endpoint': 11.0.3 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + content-type: 2.0.0 + json-with-bigint: 3.5.8 + universal-user-agent: 7.0.3 + + '@octokit/rest@22.0.1': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -9420,6 +11626,8 @@ snapshots: '@oxc-project/types@0.127.0': optional: true + '@oxc-project/types@0.130.0': {} + '@oxc-project/types@0.133.0': {} '@oxfmt/binding-android-arm-eabi@0.52.0': @@ -9787,15 +11995,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@react-native/assets-registry@0.85.3': {} @@ -9855,13 +12063,13 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.2 - '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))': + '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: - '@react-native/dev-middleware': 0.85.3 + '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) debug: 4.4.3 invariant: 2.2.4 - metro: 0.84.4 - metro-config: 0.84.4 + metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) + metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-core: 0.84.4 semver: 7.8.1 optionalDependencies: @@ -9881,7 +12089,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/dev-middleware@0.85.3': + '@react-native/dev-middleware@0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@isaacs/ttlcache': 1.4.1 '@react-native/debugger-frontend': 0.85.3 @@ -9894,7 +12102,7 @@ snapshots: nullthrows: 1.1.1 open: 7.4.2 serve-static: 1.16.3 - ws: 7.5.11 + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -9917,94 +12125,128 @@ snapshots: dependencies: '@react-native/js-polyfills': 0.85.3 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) - metro-config: 0.84.4 + metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.85.3': {} - '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)': + '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: '@types/react': 19.2.16 '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true + '@rolldown/binding-android-arm64@1.0.1': + optional: true + '@rolldown/binding-android-arm64@1.0.3': optional: true '@rolldown/binding-darwin-arm64@1.0.0-rc.17': optional: true + '@rolldown/binding-darwin-arm64@1.0.1': + optional: true + '@rolldown/binding-darwin-arm64@1.0.3': optional: true '@rolldown/binding-darwin-x64@1.0.0-rc.17': optional: true + '@rolldown/binding-darwin-x64@1.0.1': + optional: true + '@rolldown/binding-darwin-x64@1.0.3': optional: true '@rolldown/binding-freebsd-x64@1.0.0-rc.17': optional: true + '@rolldown/binding-freebsd-x64@1.0.1': + optional: true + '@rolldown/binding-freebsd-x64@1.0.3': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.1': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.3': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.1': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.3': optional: true '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.3': optional: true '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.1': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.3': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.1': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.3': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': optional: true + '@rolldown/binding-linux-x64-musl@1.0.1': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.3': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': optional: true + '@rolldown/binding-openharmony-arm64@1.0.1': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.3': optional: true @@ -10015,6 +12257,13 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true + '@rolldown/binding-wasm32-wasi@1.0.1': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + '@rolldown/binding-wasm32-wasi@1.0.3': dependencies: '@emnapi/core': 1.10.0 @@ -10025,12 +12274,18 @@ snapshots: '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.1': + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.3': optional: true '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.1': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true @@ -10212,8 +12467,79 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@smithy/core@3.24.6': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.7': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-config-provider@4.4.6': + dependencies: + '@smithy/core': 3.24.6 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/shared-ini-file-loader@4.5.6': + dependencies: + '@smithy/core': 3.24.6 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/types@4.14.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-base64@4.4.6': + dependencies: + '@smithy/core': 3.24.6 + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@stripe/stripe-js@5.6.0': {} + + '@swc/helpers@0.5.21': + dependencies: + tslib: 2.8.1 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -10489,6 +12815,8 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/aws-lambda@8.10.161': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -10716,7 +13044,7 @@ snapshots: '@voidzero-dev/vite-plus-linux-x64-musl@0.1.24': optional: true - '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 @@ -10731,7 +13059,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - ws: 8.21.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) optionalDependencies: '@types/node': 24.12.4 transitivePeerDependencies: @@ -10820,6 +13148,12 @@ snapshots: '@xterm/xterm@6.0.0': {} + '@zxcvbn-ts/core@3.0.4': + dependencies: + fastest-levenshtein: 1.0.16 + + '@zxcvbn-ts/language-common@3.0.4': {} + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -10861,16 +13195,80 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + alchemy@2.0.0-beta.49(patch_hash=4d4f481bc380becaa0baa4cbc29660d804d94494b24ded1e40dcef2e91a706aa)(69336a667f2376a65731251c78dce4d9): + dependencies: + '@alchemy.run/node-utils': 0.0.4 + '@aws-sdk/credential-providers': 3.1061.0 + '@clack/prompts': 0.11.0 + '@distilled.cloud/aws': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@distilled.cloud/axiom': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@distilled.cloud/cloudflare': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@distilled.cloud/cloudflare-rolldown-plugin': 0.10.1(rolldown@1.0.1)(workerd@1.20260526.1) + '@distilled.cloud/cloudflare-runtime': 0.10.1(@distilled.cloud/cloudflare@0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)))(@effect/platform-bun@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@distilled.cloud/cloudflare-vite-plugin': 0.10.1(478fb7fb281e7445addca09c80e1ab0f) + '@distilled.cloud/core': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@distilled.cloud/neon': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@distilled.cloud/planetscale': 0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@effect/vitest': 4.0.0-beta.73(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@octokit/rest': 22.0.1 + '@smithy/node-config-provider': 4.4.6 + '@smithy/shared-ini-file-loader': 4.5.6 + '@smithy/types': 4.14.3 + '@types/aws-lambda': 8.10.161 + aws4fetch: 1.0.20 + capnweb: 0.6.1 + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + fast-glob: 3.3.3 + fast-xml-parser: 5.8.0 + ink: 6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) + jszip: 3.10.1 + libsodium-wrappers: 0.8.4 + magic-string: 0.30.21 + mysql2: 3.22.4(@types/node@24.12.4) + pathe: 2.0.3 + pg: 8.21.0 + picomatch: 4.0.4 + react: 19.2.6 + rolldown: 1.0.1 + undici: 7.27.0 + yaml: 2.9.0 + optionalDependencies: + '@effect/platform-bun': 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.73(bufferutil@4.1.0)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + drizzle-kit: 1.0.0-rc.3 + drizzle-orm: 1.0.0-rc.3(@cloudflare/workers-types@4.20260603.1)(@effect/sql-pg@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - '@types/node' + - '@types/react' + - bufferutil + - pg-native + - react-devtools-core + - utf-8-validate + - vitest + - workerd + + alien-signals@2.0.6: {} + anser@1.4.10: {} ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@4.1.1: {} ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@3.2.1: dependencies: color-convert: 1.9.3 @@ -10881,6 +13279,8 @@ snapshots: ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + ansis@4.3.1: {} anymatch@3.1.3: @@ -10910,7 +13310,7 @@ snapshots: assertion-error@2.0.1: {} - astro@6.4.2(@types/node@24.12.4)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): + astro@6.4.2(@types/node@24.12.4)(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0)(jiti@2.7.0)(rollup@4.61.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): dependencies: '@astrojs/compiler': 4.0.0 '@astrojs/internal-helpers': 0.10.0 @@ -10960,7 +13360,7 @@ snapshots: ultrahtml: 1.6.0 unifont: 0.7.4 unist-util-visit: 5.1.0 - unstorage: 1.17.5(ioredis@5.11.0) + unstorage: 1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0) vfile: 6.0.3 vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vitefu: 1.1.3(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) @@ -11010,6 +13410,12 @@ snapshots: - uploadthing - yaml + auto-bind@5.0.1: {} + + aws-ssl-profiles@1.1.2: {} + + aws4fetch@1.0.20: {} + axobject-query@4.1.0: {} babel-dead-code-elimination@1.0.12: @@ -11061,7 +13467,7 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-preset-expo@56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@56.0.8)(react-refresh@0.14.2): + babel-preset-expo@56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2): dependencies: '@babel/generator': 7.29.7 '@babel/helper-module-imports': 7.29.7 @@ -11108,11 +13514,14 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) transitivePeerDependencies: - '@babel/core' - supports-color + badgin@1.2.3: {} + bail@2.0.2: {} balanced-match@4.0.4: {} @@ -11123,10 +13532,14 @@ snapshots: transitivePeerDependencies: - '@types/emscripten' + base-64@1.0.0: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.33: {} + before-after-hook@4.0.0: {} + big-integer@1.6.52: {} body-parser@2.2.2: @@ -11148,6 +13561,8 @@ snapshots: boolean@3.2.0: optional: true + bowser@2.14.1: {} + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -11168,6 +13583,10 @@ snapshots: dependencies: fill-range: 7.1.1 + browser-tabs-lock@1.3.0: + dependencies: + lodash: 4.18.1 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.33 @@ -11184,6 +13603,16 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bufferutil@4.1.0: + dependencies: + node-gyp-build: 4.8.4 + optional: true + builder-util-runtime@9.5.1: dependencies: debug: 4.4.3 @@ -11223,6 +13652,10 @@ snapshots: caniuse-lite@1.0.30001793: {} + capnweb@0.6.1: {} + + capnweb@0.7.0: {} + ccount@2.0.1: {} chalk@2.4.2: @@ -11236,6 +13669,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -11281,12 +13716,23 @@ snapshots: dependencies: clsx: 2.1.1 + cli-boxes@3.0.0: {} + cli-cursor@2.1.0: dependencies: restore-cursor: 2.0.0 + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-spinners@2.9.2: {} + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.1 + cli-width@4.1.0: {} client-only@0.0.1: {} @@ -11307,6 +13753,10 @@ snapshots: cluster-key-slot@1.1.1: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -11374,6 +13824,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + cookie-es@1.2.3: {} cookie-es@3.1.1: {} @@ -11388,6 +13840,10 @@ snapshots: dependencies: browserslist: 4.28.2 + core-js@3.47.0: {} + + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -11408,6 +13864,8 @@ snapshots: dependencies: uncrypto: 0.1.3 + crypto-js@4.2.0: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -11499,6 +13957,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@2.0.2: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -11538,6 +13998,25 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + drizzle-kit@1.0.0-rc.3: + dependencies: + '@drizzle-team/brocli': 0.11.0 + '@js-temporal/polyfill': 0.5.1 + esbuild: 0.25.12 + get-tsconfig: 4.14.0 + jiti: 2.7.0 + + drizzle-orm@1.0.0-rc.3(@cloudflare/workers-types@4.20260603.1)(@effect/sql-pg@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + optionalDependencies: + '@cloudflare/workers-types': 4.20260603.1 + '@effect/sql-pg': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) + '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + bun-types: 1.3.14 + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + mysql2: 3.22.4(@types/node@24.12.4) + pg: 8.21.0 + zod: 4.4.3 + dset@3.1.4: {} dunder-proto@1.0.1: @@ -11589,6 +14068,8 @@ snapshots: '@emmetio/abbreviation': 2.3.3 '@emmetio/css-abbreviation': 2.1.8 + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} encodeurl@1.0.2: {} @@ -11610,6 +14091,8 @@ snapshots: env-paths@2.2.1: {} + environment@1.1.0: {} + error-stack-parser@2.1.4: dependencies: stackframe: 1.3.4 @@ -11626,9 +14109,40 @@ snapshots: dependencies: es-errors: 1.3.0 + es-toolkit@1.47.0: {} + es6-error@4.1.1: optional: true + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -11664,6 +14178,8 @@ snapshots: escape-string-regexp@1.0.5: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -11684,133 +14200,151 @@ snapshots: dependencies: eventsource-parser: 3.1.0 - expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): + expo-application@56.0.3(expo@56.0.8): + dependencies: + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + + expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript + expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + dependencies: + expo-application: 56.0.3(expo@56.0.8) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-crypto: 56.0.4(expo@56.0.8) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + invariant: 2.2.4 + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - expo + - supports-color + expo-build-properties@56.0.16(expo@56.0.8): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 semver: 7.8.1 - expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)): + expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color expo-crypto@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)): + expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 56.0.1(expo@56.0.8) expo-manifests: 56.0.4(expo@56.0.8) expo-updates-interface: 56.0.2(expo@56.0.8) transitivePeerDependencies: - react-native - expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)): + expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-manifests: 56.0.4(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-dev-menu-interface@56.0.1(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)): + expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu-interface: 56.0.1(expo@56.0.8) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-eas-client@56.0.1: {} - expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)): + expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) fontfaceobserver: 2.3.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-haptics@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader@56.0.3(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-picker@56.0.15(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader: 56.0.3(expo@56.0.8) expo-json-utils@56.0.0: {} expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color expo-manifests@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-json-utils: 56.0.0 expo-modules-autolinking@56.0.14(typescript@6.0.3): @@ -11823,47 +14357,61 @@ snapshots: - supports-color - typescript - expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/expo-modules-macros-plugin': 0.0.9 - expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) + expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)): + expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + + expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + dependencies: + '@expo/image-utils': 0.10.1(typescript@6.0.3) + abort-controller: 3.0.0 + badgin: 1.2.3 + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-application: 56.0.3(expo@56.0.8) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - supports-color + - typescript - expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-router@56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51): + expo-router@56.2.8(c021de11d02907bd585610408f5252e8): dependencies: - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.15(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) - expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.4 - expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 @@ -11871,18 +14419,18 @@ snapshots: react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) - react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -11894,7 +14442,7 @@ snapshots: expo-secure-store@56.0.4(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server@56.0.4: {} @@ -11902,7 +14450,7 @@ snapshots: dependencies: '@expo/config-plugins': 56.0.8(typescript@6.0.3) '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -11910,20 +14458,20 @@ snapshots: expo-structured-headers@56.0.0: {} - expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 expo-updates-interface@56.0.2(expo@56.0.8): dependencies: - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/plist': 0.7.0 @@ -11931,7 +14479,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-eas-client: 56.0.1 expo-manifests: 56.0.4(expo@56.0.8) expo-structured-headers: 56.0.0 @@ -11941,42 +14489,62 @@ snapshots: ignore: 5.3.2 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 optionalDependencies: - expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) + expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) transitivePeerDependencies: - supports-color - expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-router@56.2.8)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3): + expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + dependencies: + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + + expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): + dependencies: + '@expo/plist': 0.7.0 + '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - '@babel/core' + - '@types/react' + - '@types/react-dom' + - react-dom + - react-native-reanimated + - react-native-worklets + + expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/config': 56.0.9(typescript@6.0.3) '@expo/config-plugins': 56.0.8(typescript@6.0.3) - '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/fingerprint': 0.19.3 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) - '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - '@expo/metro': 56.0.0 - '@expo/metro-config': 56.0.13(expo@56.0.8)(typescript@6.0.3) + '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@expo/metro-config': 56.0.13(bufferutil@4.1.0)(expo@56.0.8)(typescript@6.0.3)(utf-8-validate@6.0.6) '@ungap/structured-clone': 1.3.1 - babel-preset-expo: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@56.0.8)(react-refresh@0.14.2) - expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) - expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3)) - expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + babel-preset-expo: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.16)(expo@56.0.8)(react-refresh@0.14.2) + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3) expo-modules-autolinking: 56.0.14(typescript@6.0.3) - expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - '@babel/core' @@ -12047,9 +14615,19 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: optional: true + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -12062,6 +14640,32 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.1.1 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + + fast-xml-parser@5.8.0: + dependencies: + '@nodable/entities': 2.1.1 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + xml-naming: 0.1.0 + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fb-dotslash@0.5.8: {} fb-watchman@2.0.2: @@ -12149,10 +14753,16 @@ snapshots: function-bind@1.1.2: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -12177,6 +14787,10 @@ snapshots: dependencies: pump: 3.0.4 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.4: dependencies: resolve-pkg-maps: 1.0.0 @@ -12185,6 +14799,12 @@ snapshots: github-slugger@2.0.0: {} + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -12419,18 +15039,62 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.2.1: + optional: true + + ieee754@1.2.1: {} + ignore@5.3.2: {} image-size@1.2.1: dependencies: queue: 6.0.2 + immediate@3.0.6: {} + indent-string@4.0.0: {} + indent-string@5.0.0: {} + inherits@2.0.4: {} ini@7.0.0: {} + ink@6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6): + dependencies: + '@alcalzone/ansi-tokenize': 0.2.5 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 5.2.0 + code-excerpt: 4.0.0 + es-toolkit: 1.47.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.6 + react-reconciler: 0.33.0(react@19.2.6) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 8.0.0 + stack-utils: 2.0.6 + string-width: 8.2.1 + terminal-size: 4.0.1 + type-fest: 5.7.0 + widest-line: 6.0.0 + wrap-ansi: 9.0.2 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.16 + react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + inline-style-parser@0.2.7: {} invariant@2.2.4: @@ -12476,10 +15140,22 @@ snapshots: is-docker@4.0.0: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-in-ci@2.0.0: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -12492,6 +15168,8 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 @@ -12500,6 +15178,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isbot@5.1.40: {} isexe@2.0.0: {} @@ -12537,14 +15217,22 @@ snapshots: jiti@2.7.0: {} + jose@6.2.2: {} + jose@6.2.3: {} + js-base64@3.7.8: {} + + js-cookie@3.0.7: {} + js-tokens@4.0.0: {} js-yaml@4.2.0: dependencies: argparse: 2.0.1 + jsbi@4.3.2: {} + jsc-safe-url@0.2.4: {} jsesc@3.1.0: {} @@ -12566,6 +15254,8 @@ snapshots: json-stringify-safe@5.0.1: optional: true + json-with-bigint@3.5.8: {} + json5@2.2.3: {} jsonc-parser@2.3.1: {} @@ -12582,6 +15272,13 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -12604,6 +15301,31 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + libsodium-wrappers@0.8.4: + dependencies: + libsodium: 0.8.4 + + libsodium@0.8.4: {} + + libsql@0.5.29: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.29 + '@libsql/darwin-x64': 0.5.29 + '@libsql/linux-arm-gnueabihf': 0.5.29 + '@libsql/linux-arm-musleabihf': 0.5.29 + '@libsql/linux-arm64-gnu': 0.5.29 + '@libsql/linux-arm64-musl': 0.5.29 + '@libsql/linux-x64-gnu': 0.5.29 + '@libsql/linux-x64-musl': 0.5.29 + '@libsql/win32-x64-msvc': 0.5.29 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lighthouse-logger@1.4.2: dependencies: debug: 2.6.9 @@ -12762,10 +15484,14 @@ snapshots: lodash.throttle@4.1.1: {} + lodash@4.18.1: {} + log-symbols@2.2.0: dependencies: chalk: 2.4.2 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -12782,6 +15508,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru.min@1.1.4: {} + lru_map@0.4.1: {} lucide-react@0.564.0(react@19.2.6): @@ -12988,6 +15716,8 @@ snapshots: merge-stream@2.0.0: {} + merge2@1.4.1: {} + metro-babel-transformer@0.84.4: dependencies: '@babel/core': 7.29.7 @@ -13011,12 +15741,12 @@ snapshots: transitivePeerDependencies: - supports-color - metro-config@0.84.4: + metro-config@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: connect: 3.7.0 flow-enums-runtime: 0.0.6 jest-validate: 29.7.0 - metro: 0.84.4 + metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-cache: 0.84.4 metro-core: 0.84.4 metro-runtime: 0.84.4 @@ -13096,14 +15826,14 @@ snapshots: transitivePeerDependencies: - supports-color - metro-transform-worker@0.84.4: + metro-transform-worker@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@babel/core': 7.29.7 '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 flow-enums-runtime: 0.0.6 - metro: 0.84.4 + metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-babel-transformer: 0.84.4 metro-cache: 0.84.4 metro-cache-key: 0.84.4 @@ -13116,7 +15846,7 @@ snapshots: - supports-color - utf-8-validate - metro@0.84.4: + metro@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -13141,7 +15871,7 @@ snapshots: metro-babel-transformer: 0.84.4 metro-cache: 0.84.4 metro-cache-key: 0.84.4 - metro-config: 0.84.4 + metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-core: 0.84.4 metro-file-map: 0.84.4 metro-resolver: 0.84.4 @@ -13149,13 +15879,13 @@ snapshots: metro-source-map: 0.84.4 metro-symbolicate: 0.84.4 metro-transform-plugins: 0.84.4 - metro-transform-worker: 0.84.4 + metro-transform-worker: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) mime-types: 3.0.2 nullthrows: 1.1.1 serialize-error: 2.1.0 source-map: 0.5.7 throat: 5.0.0 - ws: 7.5.11 + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) yargs: 17.7.2 transitivePeerDependencies: - bufferutil @@ -13376,6 +16106,8 @@ snapshots: mimic-fn@1.2.0: {} + mimic-fn@2.1.0: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -13445,6 +16177,22 @@ snapshots: mute-stream@2.0.0: {} + mysql2@3.22.4(@types/node@24.12.4): + dependencies: + '@types/node': 24.12.4 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + nanoid@3.3.12: {} negotiator@0.6.3: {} @@ -13470,6 +16218,9 @@ snapshots: detect-libc: 2.1.2 optional: true + node-gyp-build@4.8.4: + optional: true + node-int64@0.4.0: {} node-mock-http@1.0.4: {} @@ -13508,6 +16259,8 @@ snapshots: object-keys@1.1.1: optional: true + obuf@1.1.2: {} + obug@2.1.1: {} ofetch@1.5.1: @@ -13536,6 +16289,10 @@ snapshots: dependencies: mimic-fn: 1.2.0 + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -13558,9 +16315,13 @@ snapshots: strip-ansi: 5.2.0 wcwidth: 1.0.1 + os-paths@7.4.0: + optionalDependencies: + fsevents: 2.3.3 + outvariant@1.4.3: {} - oxfmt@0.52.0(vite-plus@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)): + oxfmt@0.52.0(vite-plus@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -13583,7 +16344,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.52.0 '@oxfmt/binding-win32-ia32-msvc': 0.52.0 '@oxfmt/binding-win32-x64-msvc': 0.52.0 - vite-plus: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + vite-plus: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-tsgolint@0.23.0: optionalDependencies: @@ -13594,7 +16355,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.23.0 '@oxlint-tsgolint/win32-x64': 0.23.0 - oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)): + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.67.0 '@oxlint/binding-android-arm64': 1.67.0 @@ -13616,7 +16377,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.67.0 '@oxlint/binding-win32-x64-msvc': 1.67.0 oxlint-tsgolint: 0.23.0 - vite-plus: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) + vite-plus: 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -13633,6 +16394,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@1.0.11: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -13662,8 +16425,12 @@ snapshots: parseurl@1.3.3: {} + patch-console@2.0.0: {} + path-browserify@1.0.1: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -13683,6 +16450,59 @@ snapshots: pend@1.2.0: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-connection-string@2.13.0: {} + + pg-cursor@2.20.0(pg@8.21.0): + dependencies: + pg: 8.21.0 + + pg-int8@1.0.1: {} + + pg-numeric@1.0.2: {} + + pg-pool@3.14.0(pg@8.21.0): + dependencies: + pg: 8.21.0 + + pg-protocol@1.14.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg-types@4.1.0: + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.4 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + + pg@8.21.0: + dependencies: + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.21.0) + pg-protocol: 1.14.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -13721,6 +16541,28 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-bytea@3.0.0: + dependencies: + obuf: 1.1.2 + + postgres-date@1.0.7: {} + + postgres-date@2.1.0: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres-interval@3.0.0: {} + + postgres-range@1.1.4: {} + prettier@3.8.3: {} pretty-cache-header@1.0.0: @@ -13743,8 +16585,12 @@ snapshots: proc-log@4.2.0: {} + process-nextick-args@2.0.1: {} + progress@2.0.3: {} + promise-limit@2.7.0: {} + promise@8.3.0: dependencies: asap: 2.0.6 @@ -13781,6 +16627,8 @@ snapshots: split-on-first: 1.1.0 strict-uri-encode: 2.0.0 + queue-microtask@1.2.3: {} + queue@6.0.2: dependencies: inherits: 2.0.4 @@ -13798,10 +16646,10 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - react-devtools-core@6.1.5: + react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: shell-quote: 1.8.4 - ws: 7.5.11 + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -13852,90 +16700,95 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) - react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) - react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) - react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) optionalDependencies: - react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) semver: 7.8.1 - react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-shiki-engine@0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-shiki-engine@0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): + react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + dependencies: + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + whatwg-url-without-unicode: 8.0.0-3 + + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -13950,20 +16803,20 @@ snapshots: '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) convert-source-map: 2.0.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) semver: 7.8.1 transitivePeerDependencies: - supports-color - react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3): + react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: '@react-native/assets-registry': 0.85.3 '@react-native/codegen': 0.85.3(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)) + '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@react-native/gradle-plugin': 0.85.3 '@react-native/js-polyfills': 0.85.3 '@react-native/normalize-colors': 0.85.3 - '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) + '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -13980,7 +16833,7 @@ snapshots: pretty-format: 29.7.0 promise: 8.3.0 react: 19.2.3 - react-devtools-core: 6.1.5 + react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) react-refresh: 0.14.2 regenerator-runtime: 0.13.11 scheduler: 0.27.0 @@ -13988,7 +16841,7 @@ snapshots: stacktrace-parser: 0.1.11 tinyglobby: 0.2.17 whatwg-fetch: 3.6.20 - ws: 7.5.11 + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) yargs: 17.7.2 optionalDependencies: '@types/react': 19.2.16 @@ -14000,6 +16853,11 @@ snapshots: - supports-color - utf-8-validate + react-reconciler@0.33.0(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + react-refresh@0.14.2: {} react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.3): @@ -14033,6 +16891,16 @@ snapshots: react@19.2.6: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -14181,6 +17049,11 @@ snapshots: onetime: 2.0.1 signal-exit: 3.0.7 + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -14208,6 +17081,8 @@ snapshots: rettime@0.10.1: {} + reusify@1.1.0: {} + roarr@2.15.4: dependencies: boolean: 3.2.0 @@ -14240,6 +17115,27 @@ snapshots: '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 optional: true + rolldown@1.0.1: + dependencies: + '@oxc-project/types': 0.130.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.1 + '@rolldown/binding-darwin-arm64': 1.0.1 + '@rolldown/binding-darwin-x64': 1.0.1 + '@rolldown/binding-freebsd-x64': 1.0.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 + '@rolldown/binding-linux-arm64-gnu': 1.0.1 + '@rolldown/binding-linux-arm64-musl': 1.0.1 + '@rolldown/binding-linux-ppc64-gnu': 1.0.1 + '@rolldown/binding-linux-s390x-gnu': 1.0.1 + '@rolldown/binding-linux-x64-gnu': 1.0.1 + '@rolldown/binding-linux-x64-musl': 1.0.1 + '@rolldown/binding-openharmony-arm64': 1.0.1 + '@rolldown/binding-wasm32-wasi': 1.0.1 + '@rolldown/binding-win32-arm64-msvc': 1.0.1 + '@rolldown/binding-win32-x64-msvc': 1.0.1 + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -14303,6 +17199,12 @@ snapshots: transitivePeerDependencies: - supports-color + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -14387,6 +17289,8 @@ snapshots: server-only@0.0.1: {} + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} sf-symbols-typescript@2.2.0: {} @@ -14505,6 +17409,11 @@ snapshots: sisteransi@1.0.5: {} + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + slugify@1.6.9: {} smol-toml@1.6.1: {} @@ -14524,9 +17433,17 @@ snapshots: split-on-first@1.1.0: {} + split2@4.2.0: {} + sprintf-js@1.1.3: optional: true + sql-escaper@1.3.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackframe@1.3.4: {} stacktrace-parser@0.1.11: @@ -14535,6 +17452,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@1.5.0: {} statuses@2.0.2: {} @@ -14553,6 +17475,21 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -14566,10 +17503,16 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 + strnum@2.3.0: {} + structured-headers@0.4.1: {} style-to-js@1.1.21: @@ -14632,6 +17575,8 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 + terminal-size@4.0.1: {} + terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -14727,8 +17672,14 @@ snapshots: undici-types@7.16.0: {} + undici@7.27.0: {} + undici@8.3.0: {} + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -14798,18 +17749,20 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universal-user-agent@7.0.3: {} + universalify@0.1.2: {} universalify@2.0.1: {} - uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3)(tailwindcss@4.3.0): + uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 culori: 4.0.2 lightningcss: 1.30.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) tailwindcss: 4.3.0 unpipe@1.0.0: {} @@ -14825,7 +17778,7 @@ snapshots: rolldown: 1.0.0-rc.17 optional: true - unstorage@1.17.5(ioredis@5.11.0): + unstorage@1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -14836,6 +17789,8 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.4 optionalDependencies: + aws4fetch: 1.0.20 + idb-keyval: 6.2.1 ioredis: 5.11.0 until-async@3.0.2: {} @@ -14878,6 +17833,13 @@ snapshots: dependencies: react: 19.2.6 + utf-8-validate@6.0.6: + dependencies: + node-gyp-build: 4.8.4 + optional: true + + util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} uuid@14.0.0: {} @@ -14912,14 +17874,14 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0): + vite-plus@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.133.0 '@oxlint/plugins': 1.61.0 '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) - '@voidzero-dev/vite-plus-test': 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) - oxfmt: 0.52.0(vite-plus@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) - oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) + '@voidzero-dev/vite-plus-test': 0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + oxfmt: 0.52.0(vite-plus@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) oxlint-tsgolint: 0.23.0 optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.1.24 @@ -14966,11 +17928,11 @@ snapshots: optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + vitest-browser-react@2.2.0(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0))(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' + vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.12.4)(@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)' optionalDependencies: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) @@ -15086,18 +18048,38 @@ snapshots: web-namespaces@2.0.1: {} + webidl-conversions@5.0.0: {} + webpack-virtual-modules@0.6.2: {} whatwg-fetch@3.6.20: {} whatwg-url-minimum@0.1.2: {} + whatwg-url-without-unicode@8.0.0-3: + dependencies: + buffer: 5.7.1 + punycode: 2.3.1 + webidl-conversions: 5.0.0 + which-pm-runs@1.1.0: {} which@2.0.2: dependencies: isexe: 2.0.0 + widest-line@6.0.0: + dependencies: + string-width: 8.2.1 + + workerd@1.20260526.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260526.1 + '@cloudflare/workerd-darwin-arm64': 1.20260526.1 + '@cloudflare/workerd-linux-64': 1.20260526.1 + '@cloudflare/workerd-linux-arm64': 1.20260526.1 + '@cloudflare/workerd-windows-64': 1.20260526.1 + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -15110,17 +18092,43 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} - ws@7.5.11: {} + ws@7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 - ws@8.21.0: {} + ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 xcode@3.0.1: dependencies: simple-plist: 1.3.1 uuid: 7.0.3 + xdg-app-paths@8.3.0: + dependencies: + xdg-portable: 10.6.0 + optionalDependencies: + fsevents: 2.3.3 + + xdg-portable@10.6.0: + dependencies: + os-paths: 7.4.0 + optionalDependencies: + fsevents: 2.3.3 + + xml-naming@0.1.0: {} + xml2js@0.6.0: dependencies: sax: 1.6.0 @@ -15130,6 +18138,8 @@ snapshots: xmlbuilder@15.1.1: {} + xtend@4.0.2: {} + xxhash-wasm@1.1.0: {} y18n@5.0.8: {} @@ -15181,6 +18191,8 @@ snapshots: yoctocolors-cjs@2.1.3: {} + yoga-layout@3.2.1: {} + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0db3d4d8293..7eaf6c72628 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "apps/*" + - "infra/*" - "oxlint-plugin-t3code" - "packages/*" - "scripts" @@ -21,24 +22,38 @@ catalog: "@effect/platform-bun": 4.0.0-beta.73 "@effect/platform-node": 4.0.0-beta.73 "@effect/platform-node-shared": 4.0.0-beta.73 + "@effect/sql-pg": 4.0.0-beta.73 "@effect/sql-sqlite-bun": 4.0.0-beta.73 "@effect/vitest": 4.0.0-beta.73 "@effect/tsgo": 0.11.4 + "@noble/curves": 1.9.1 + "@noble/hashes": 1.8.0 "@pierre/diffs": 1.1.20 "@vitest/runner": ^4.1.8 "@types/bun": ^1.3.11 "@types/node": 24.12.4 "@typescript/native-preview": 7.0.0-dev.20260527.2 + jose: 6.2.2 + tsdown: ^0.20.3 typescript: ~6.0.3 vitest: npm:@voidzero-dev/vite-plus-test@latest vite: npm:@voidzero-dev/vite-plus-core@latest vite-plus: latest yaml: ^2.9.0 overrides: + # Clerk publishes wallet auth integrations as required dependencies. T3 Code does + # not support wallet auth, so keep that unused dependency tree out of installs. + "@clerk/clerk-js>@base-org/account": "-" + "@clerk/clerk-js>@coinbase/wallet-sdk": "-" + "@clerk/clerk-js>@solana/wallet-adapter-base": "-" + "@clerk/clerk-js>@solana/wallet-adapter-react": "-" + "@clerk/clerk-js>@solana/wallet-standard": "-" + "@clerk/clerk-js>@wallet-standard/core": "-" "@effect/atom-react": "catalog:" "@effect/platform-bun": "catalog:" "@effect/platform-node": "catalog:" "@effect/platform-node-shared": "catalog:" + "@effect/sql-pg": "catalog:" "@effect/sql-sqlite-bun": "catalog:" "@effect/vitest": "catalog:" "@types/node": "catalog:" @@ -58,5 +73,6 @@ packageExtensions: "@vitest/runner": "catalog:" patchedDependencies: "@pierre/diffs@1.1.20": patches/@pierre%2Fdiffs@1.1.20.patch + alchemy@2.0.0-beta.49: patches/alchemy@2.0.0-beta.49.patch effect@4.0.0-beta.73: patches/effect@4.0.0-beta.73.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 30e9ef7f5de..2bd4e9439b4 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -649,6 +649,12 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( target: target === "dmg" ? [target, "zip"] : [target], icon: "icon.icns", category: "public.app-category.developer-tools", + protocols: [ + { + name: "T3 Code", + schemes: ["t3code"], + }, + ], }; } diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index bd814f2222f..3c53d45dcdc 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -17,6 +17,10 @@ import * as Schema from "effect/Schema"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { ChildProcess } from "effect/unstable/process"; +import { loadRepoEnv } from "./lib/public-config.ts"; + +Object.assign(process.env, loadRepoEnv()); + const BASE_SERVER_PORT = 13773; const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; diff --git a/scripts/lib/public-config.test.ts b/scripts/lib/public-config.test.ts new file mode 100644 index 00000000000..52eaf43192e --- /dev/null +++ b/scripts/lib/public-config.test.ts @@ -0,0 +1,104 @@ +// @effect-diagnostics nodeBuiltinImport:off - Tests exercise root env file precedence directly. +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { loadRepoEnv, resolvePublicConfig } from "./public-config.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("loadRepoEnv", () => { + it("does not project cloud configuration for an unconfigured clone", () => { + const env = loadRepoEnv({ baseEnv: {}, repoRoot: makeTemporaryDirectory() }); + + expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).toBeUndefined(); + expect(env.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID).toBeUndefined(); + expect(env.VITE_CLERK_PUBLISHABLE_KEY).toBeUndefined(); + expect(env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).toBeUndefined(); + expect(env.T3CODE_CLERK_JWT_TEMPLATE).toBeUndefined(); + expect(env.VITE_CLERK_JWT_TEMPLATE).toBeUndefined(); + expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined(); + expect(env.T3CODE_RELAY_URL).toBeUndefined(); + expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined(); + }); + + it("applies process, root local, and root precedence in that order", () => { + const repoRoot = makeTemporaryDirectory(); + writeFileSync( + join(repoRoot, ".env"), + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3CODE_CLERK_JWT_TEMPLATE=template_root\nT3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauth_root\nT3CODE_RELAY_URL=https://root.example.test\n", + ); + writeFileSync( + join(repoRoot, ".env.local"), + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3CODE_CLERK_JWT_TEMPLATE=template_local\nT3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauth_local\nT3CODE_RELAY_URL=https://local.example.test\n", + ); + + expect(loadRepoEnv({ baseEnv: {}, repoRoot }).T3CODE_RELAY_URL).toBe( + "https://local.example.test", + ); + expect( + loadRepoEnv({ + baseEnv: { + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", + T3CODE_CLERK_JWT_TEMPLATE: "template_ci", + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_ci", + T3CODE_RELAY_URL: "https://ci.example.test", + }, + repoRoot, + }), + ).toMatchObject({ + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_ci", + VITE_CLERK_PUBLISHABLE_KEY: "pk_ci", + EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_ci", + T3CODE_CLERK_JWT_TEMPLATE: "template_ci", + VITE_CLERK_JWT_TEMPLATE: "template_ci", + EXPO_PUBLIC_CLERK_JWT_TEMPLATE: "template_ci", + T3CODE_RELAY_URL: "https://ci.example.test", + VITE_T3CODE_RELAY_URL: "https://ci.example.test", + }); + }); + + it("accepts legacy framework aliases as root overrides", () => { + expect( + resolvePublicConfig({ + VITE_CLERK_PUBLISHABLE_KEY: "pk_legacy", + VITE_CLERK_JWT_TEMPLATE: "template_legacy", + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical", + VITE_T3CODE_RELAY_URL: "https://legacy.example.test", + }), + ).toEqual({ + clerkPublishableKey: "pk_legacy", + clerkJwtTemplate: "template_legacy", + clerkCliOAuthClientId: "oauth_canonical", + relayUrl: "https://legacy.example.test", + }); + }); + + it("projects only the configured aliases", () => { + expect( + loadRepoEnv({ + baseEnv: { + T3CODE_RELAY_URL: "https://relay.example.test", + }, + repoRoot: makeTemporaryDirectory(), + }), + ).toEqual({ + T3CODE_RELAY_URL: "https://relay.example.test", + VITE_T3CODE_RELAY_URL: "https://relay.example.test", + }); + }); +}); + +function makeTemporaryDirectory() { + const directory = mkdtempSync(join(tmpdir(), "t3code-public-config-")); + temporaryDirectories.push(directory); + return directory; +} diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts new file mode 100644 index 00000000000..42e493c9989 --- /dev/null +++ b/scripts/lib/public-config.ts @@ -0,0 +1,96 @@ +// @effect-diagnostics nodeBuiltinImport:off - Build bootstrap reads optional root env files before an Effect runtime exists. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; + +export interface T3CodePublicConfig { + readonly clerkPublishableKey: string | undefined; + readonly clerkJwtTemplate: string | undefined; + readonly clerkCliOAuthClientId: string | undefined; + readonly relayUrl: string | undefined; +} + +type Environment = Readonly>; + +const REPO_ROOT = NodePath.dirname( + NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))), +); + +export function loadRepoEnv({ + baseEnv = process.env, + repoRoot = REPO_ROOT, +}: { + readonly baseEnv?: Environment; + readonly repoRoot?: string; +} = {}): Record { + const rootEnv = readEnvFile(NodePath.join(repoRoot, ".env")); + const localEnv = readEnvFile(NodePath.join(repoRoot, ".env.local")); + const config = resolvePublicConfig(baseEnv, localEnv, rootEnv); + + return { + ...rootEnv, + ...localEnv, + ...baseEnv, + ...(config.clerkPublishableKey + ? { + T3CODE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, + VITE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, + EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, + } + : {}), + ...(config.clerkJwtTemplate + ? { + T3CODE_CLERK_JWT_TEMPLATE: config.clerkJwtTemplate, + VITE_CLERK_JWT_TEMPLATE: config.clerkJwtTemplate, + EXPO_PUBLIC_CLERK_JWT_TEMPLATE: config.clerkJwtTemplate, + } + : {}), + ...(config.clerkCliOAuthClientId + ? { + T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: config.clerkCliOAuthClientId, + } + : {}), + ...(config.relayUrl + ? { + T3CODE_RELAY_URL: config.relayUrl, + VITE_T3CODE_RELAY_URL: config.relayUrl, + } + : {}), + }; +} + +export function resolvePublicConfig(...sources: readonly Environment[]): T3CodePublicConfig { + return { + clerkPublishableKey: firstNonEmpty( + sources, + "T3CODE_CLERK_PUBLISHABLE_KEY", + "VITE_CLERK_PUBLISHABLE_KEY", + "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", + ), + clerkJwtTemplate: firstNonEmpty( + sources, + "T3CODE_CLERK_JWT_TEMPLATE", + "VITE_CLERK_JWT_TEMPLATE", + "EXPO_PUBLIC_CLERK_JWT_TEMPLATE", + ), + clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"), + relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"), + }; +} + +function firstNonEmpty(sources: readonly Environment[], ...names: readonly string[]) { + for (const source of sources) { + for (const name of names) { + const value = source[name]?.trim(); + if (value) { + return value; + } + } + } + return undefined; +} + +function readEnvFile(path: string): Record { + return NodeFS.existsSync(path) ? NodeUtil.parseEnv(NodeFS.readFileSync(path, "utf8")) : {}; +} diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 97bfe15635d..01676087cbd 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -29,6 +29,7 @@ const workspaceFiles = [ "apps/mobile/modules/t3-review-diff/package.json", "apps/mobile/modules/t3-terminal/package.json", "apps/marketing/package.json", + "infra/relay/package.json", "oxlint-plugin-t3code/package.json", "packages/client-runtime/package.json", "packages/contracts/package.json",