From 02beeaa86c7c6779c7389070b055e840cd6b3d77 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 31 May 2026 19:19:51 -0700 Subject: [PATCH 01/61] add managed relay tunnels and standards-based auth Co-authored-by: codex --- .gitignore | 6 + AGENTS.md | 18 +- README.md | 4 + apps/desktop/scripts/dev-electron.mjs | 30 +- apps/desktop/scripts/electron-launcher.mjs | 172 +- apps/desktop/src/app/DesktopApp.ts | 5 +- .../src/app/DesktopAppIdentity.test.ts | 3 + apps/desktop/src/app/DesktopCloudAuth.test.ts | 302 +++ apps/desktop/src/app/DesktopCloudAuth.ts | 330 +++ .../app/DesktopCloudAuthTokenStore.test.ts | 96 + .../src/app/DesktopCloudAuthTokenStore.ts | 155 ++ apps/desktop/src/app/DesktopConfig.ts | 1 + .../src/app/DesktopEnvironment.test.ts | 14 + apps/desktop/src/app/DesktopEnvironment.ts | 4 +- apps/desktop/src/electron/ElectronApp.test.ts | 9 + apps/desktop/src/electron/ElectronApp.ts | 17 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 12 + apps/desktop/src/ipc/channels.ts | 6 + apps/desktop/src/ipc/methods/cloudAuth.ts | 128 + apps/desktop/src/main.ts | 4 + apps/desktop/src/preload.ts | 17 + .../src/settings/DesktopSavedEnvironments.ts | 26 +- .../src/window/DesktopApplicationMenu.test.ts | 3 + apps/mobile/app.config.ts | 32 +- apps/mobile/clerk-theme.json | 39 + apps/mobile/global.css | 2 + apps/mobile/package.json | 8 + apps/mobile/src/app/_layout.tsx | 33 +- apps/mobile/src/app/connections/index.tsx | 10 +- apps/mobile/src/app/connections/new.tsx | 4 +- apps/mobile/src/app/index.tsx | 15 +- apps/mobile/src/app/settings/_layout.tsx | 41 + .../src/app/settings/environment-new.tsx | 1 + apps/mobile/src/app/settings/environments.tsx | 335 +++ apps/mobile/src/app/settings/index.tsx | 400 +++ apps/mobile/src/app/settings/waitlist.tsx | 67 + .../liveActivityController.test.ts | 256 ++ .../agent-awareness/liveActivityController.ts | 605 +++++ .../liveActivityPreferences.test.ts | 129 + .../liveActivityPreferences.ts | 39 + .../notificationNavigation.test.ts | 106 + .../agent-awareness/notificationNavigation.ts | 35 + .../agent-awareness/notificationPayload.ts | 106 + .../notificationPermissions.ts | 44 + .../agent-awareness/registrationPayload.ts | 31 + .../remoteRegistration.test.ts | 409 +++ .../agent-awareness/remoteRegistration.ts | 448 ++++ .../agent-awareness/shellLiveActivitySync.ts | 59 + .../agent-awareness/updatedAtLabel.test.ts | 16 + .../agent-awareness/updatedAtLabel.ts | 12 + .../src/features/cloud/CloudAuthProvider.tsx | 89 + .../cloud/CloudWaitlistEnrollment.tsx | 208 ++ apps/mobile/src/features/cloud/dpop.test.ts | 166 ++ apps/mobile/src/features/cloud/dpop.ts | 270 ++ .../features/cloud/linkEnvironment.test.ts | 1030 ++++++++ .../src/features/cloud/linkEnvironment.ts | 552 ++++ .../src/features/cloud/managedRelayLayer.ts | 42 + .../features/cloud/useNativeClerkAuthModal.ts | 134 + apps/mobile/src/lib/authClientMetadata.ts | 10 + apps/mobile/src/lib/connection.test.ts | 2 +- apps/mobile/src/lib/connection.ts | 48 +- apps/mobile/src/lib/runtime.ts | 23 +- apps/mobile/src/lib/storage.ts | 38 +- .../use-remote-environment-registry.test.ts | 301 +++ .../state/use-remote-environment-registry.ts | 481 ++-- apps/mobile/src/widgets/AgentActivity.tsx | 313 +++ .../OrchestrationEngineHarness.integration.ts | 7 + apps/server/src/auth/EnvironmentAuth.ts | 71 +- apps/server/src/auth/EnvironmentAuthPolicy.ts | 2 +- .../server/src/auth/PairingGrantStore.test.ts | 23 + apps/server/src/auth/PairingGrantStore.ts | 39 +- apps/server/src/auth/ServerSecretStore.ts | 15 +- apps/server/src/auth/SessionStore.ts | 9 +- apps/server/src/auth/dpop.test.ts | 33 + apps/server/src/auth/dpop.ts | 92 + apps/server/src/auth/http.ts | 47 +- apps/server/src/auth/utils.ts | 9 +- .../src/cloud/ManagedEndpointRuntime.test.ts | 244 ++ .../src/cloud/ManagedEndpointRuntime.ts | 233 ++ apps/server/src/cloud/config.ts | 17 + apps/server/src/cloud/http.test.ts | 60 + apps/server/src/cloud/http.ts | 797 ++++++ apps/server/src/httpCors.ts | 1 + .../Layers/OrchestrationReactor.test.ts | 11 + .../Layers/OrchestrationReactor.ts | 3 + .../persistence/Layers/AuthPairingLinks.ts | 11 +- apps/server/src/persistence/Migrations.ts | 2 + .../032_AuthPairingProofKeyThumbprint.ts | 16 + .../persistence/Services/AuthPairingLinks.ts | 3 + .../src/relay/AgentAwarenessRelay.test.ts | 634 +++++ apps/server/src/relay/AgentAwarenessRelay.ts | 459 ++++ apps/server/src/server.test.ts | 2274 +++++++++++++++-- apps/server/src/server.ts | 9 + apps/server/src/serverRuntimeStartup.ts | 7 +- apps/web/package.json | 4 + apps/web/src/clientPersistenceStorage.ts | 16 +- apps/web/src/cloud/desktopAuth.test.ts | 24 + apps/web/src/cloud/desktopAuth.ts | 116 + apps/web/src/cloud/desktopClerk.tsx | 286 +++ apps/web/src/cloud/dpop.test.ts | 32 + apps/web/src/cloud/dpop.ts | 188 ++ apps/web/src/cloud/linkEnvironment.test.ts | 797 ++++++ apps/web/src/cloud/linkEnvironment.ts | 605 +++++ apps/web/src/cloud/managedAuth.tsx | 22 + apps/web/src/cloud/managedRelayLayer.ts | 62 + .../src/components/settings/CloudSettings.tsx | 556 ++++ .../settings/SettingsPanels.browser.tsx | 12 + .../settings/SettingsSidebarNav.tsx | 3 + apps/web/src/environments/primary/auth.ts | 3 +- apps/web/src/environments/primary/index.ts | 6 +- .../src/environments/runtime/catalog.test.ts | 43 + apps/web/src/environments/runtime/catalog.ts | 49 + apps/web/src/environments/runtime/index.ts | 1 + .../service.addSavedEnvironment.test.ts | 178 +- .../runtime/service.savedEnvironments.test.ts | 9 +- .../service.threadSubscriptions.test.ts | 9 +- apps/web/src/environments/runtime/service.ts | 273 +- apps/web/src/hooks/useTheme.ts | 2 +- apps/web/src/hostedPairing.ts | 2 +- apps/web/src/lib/runtime.ts | 22 +- apps/web/src/localApi.test.ts | 12 + apps/web/src/main.tsx | 22 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_chat.index.tsx | 8 +- apps/web/src/routes/settings.cloud.tsx | 7 + apps/web/src/vite-env.d.ts | 1 + docs/relay-observability.md | 40 + docs/t3-cloud-clerk.md | 81 + docs/t3-code-cloud-auth-flow.html | 1820 +++++++++++++ infra/relay/alchemy.run.ts | 38 + .../20260527044716_baseline/migration.sql | 104 + .../20260527044716_baseline/snapshot.json | 1267 +++++++++ infra/relay/package.json | 32 + infra/relay/src/Config.ts | 32 + infra/relay/src/RelayCrypto.ts | 16 + infra/relay/src/agentActivityPayloads.ts | 63 + infra/relay/src/api.test.ts | 157 ++ infra/relay/src/api.ts | 1016 ++++++++ infra/relay/src/apns.test.ts | 116 + infra/relay/src/apns.ts | 274 ++ infra/relay/src/apnsDeliveryJobs.test.ts | 221 ++ infra/relay/src/apnsDeliveryJobs.ts | 196 ++ infra/relay/src/db.test.ts | 182 ++ infra/relay/src/db.ts | 50 + infra/relay/src/dpop.test.ts | 203 ++ infra/relay/src/dpop.ts | 61 + .../infra/ManagedEndpointStackConfig.test.ts | 27 + .../src/infra/ManagedEndpointStackConfig.ts | 26 + .../src/infra/RelayObservability.test.ts | 82 + infra/relay/src/infra/RelayObservability.ts | 58 + .../src/persistence/AgentActivityRows.ts | 155 ++ .../src/persistence/DeliveryAttempts.test.ts | 323 +++ .../relay/src/persistence/DeliveryAttempts.ts | 205 ++ infra/relay/src/persistence/Devices.test.ts | 160 ++ infra/relay/src/persistence/Devices.ts | 134 + .../relay/src/persistence/DpopProofs.test.ts | 95 + infra/relay/src/persistence/DpopProofs.ts | 68 + .../EnvironmentCredentials.test.ts | 148 ++ .../src/persistence/EnvironmentCredentials.ts | 176 ++ .../src/persistence/EnvironmentLinks.test.ts | 78 + .../relay/src/persistence/EnvironmentLinks.ts | 349 +++ .../src/persistence/LiveActivities.test.ts | 196 ++ infra/relay/src/persistence/LiveActivities.ts | 375 +++ infra/relay/src/persistence/json.ts | 10 + infra/relay/src/queues.ts | 7 + infra/relay/src/relayTokens.test.ts | 196 ++ infra/relay/src/relayTokens.ts | 186 ++ infra/relay/src/schema.ts | 163 ++ .../services/AgentActivityPublisher.test.ts | 627 +++++ .../src/services/AgentActivityPublisher.ts | 234 ++ .../relay/src/services/ApnsDeliveries.test.ts | 1126 ++++++++ infra/relay/src/services/ApnsDeliveries.ts | 793 ++++++ infra/relay/src/services/ApnsDeliveryQueue.ts | 144 ++ infra/relay/src/services/Auth.ts | 19 + .../src/services/EnvironmentConnector.test.ts | 583 +++++ .../src/services/EnvironmentConnector.ts | 397 +++ .../src/services/EnvironmentLinker.test.ts | 203 ++ infra/relay/src/services/EnvironmentLinker.ts | 270 ++ .../EnvironmentPublishSignatures.test.ts | 160 ++ .../services/EnvironmentPublishSignatures.ts | 176 ++ .../services/ManagedEndpointProvider.test.ts | 446 ++++ .../src/services/ManagedEndpointProvider.ts | 343 +++ .../src/services/MobileRegistrations.test.ts | 461 ++++ .../relay/src/services/MobileRegistrations.ts | 101 + infra/relay/src/telemetry.test.ts | 31 + infra/relay/src/telemetry.ts | 10 + infra/relay/src/worker.ts | 295 +++ infra/relay/tsconfig.json | 9 + packages/client-runtime/src/index.ts | 1 + .../client-runtime/src/managedRelay.test.ts | 114 + packages/client-runtime/src/managedRelay.ts | 470 ++++ packages/client-runtime/src/remote.test.ts | 139 +- packages/client-runtime/src/remote.ts | 113 +- packages/contracts/package.json | 4 + packages/contracts/src/auth.ts | 5 +- packages/contracts/src/environmentHttp.ts | 170 +- packages/contracts/src/ipc.ts | 28 + packages/contracts/src/relay.test.ts | 16 + packages/contracts/src/relay.ts | 868 +++++++ packages/shared/package.json | 27 + packages/shared/src/agentAwareness.test.ts | 128 + packages/shared/src/agentAwareness.ts | 142 + packages/shared/src/dpop.test.ts | 205 ++ packages/shared/src/dpop.ts | 200 ++ packages/shared/src/dpopCommon.ts | 20 + packages/shared/src/oauthScope.ts | 12 +- packages/shared/src/relayAuth.ts | 6 + packages/shared/src/relayJwt.ts | 69 + packages/shared/src/relaySigning.test.ts | 18 + packages/shared/src/relaySigning.ts | 17 + pnpm-workspace.yaml | 7 + scripts/build-desktop-artifact.ts | 6 + 212 files changed, 34555 insertions(+), 714 deletions(-) create mode 100644 apps/desktop/src/app/DesktopCloudAuth.test.ts create mode 100644 apps/desktop/src/app/DesktopCloudAuth.ts create mode 100644 apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts create mode 100644 apps/desktop/src/app/DesktopCloudAuthTokenStore.ts create mode 100644 apps/desktop/src/ipc/methods/cloudAuth.ts create mode 100644 apps/mobile/clerk-theme.json create mode 100644 apps/mobile/src/app/settings/_layout.tsx create mode 100644 apps/mobile/src/app/settings/environment-new.tsx create mode 100644 apps/mobile/src/app/settings/environments.tsx create mode 100644 apps/mobile/src/app/settings/index.tsx create mode 100644 apps/mobile/src/app/settings/waitlist.tsx create mode 100644 apps/mobile/src/features/agent-awareness/liveActivityController.test.ts create mode 100644 apps/mobile/src/features/agent-awareness/liveActivityController.ts create mode 100644 apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts create mode 100644 apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts create mode 100644 apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts create mode 100644 apps/mobile/src/features/agent-awareness/notificationNavigation.ts create mode 100644 apps/mobile/src/features/agent-awareness/notificationPayload.ts create mode 100644 apps/mobile/src/features/agent-awareness/notificationPermissions.ts create mode 100644 apps/mobile/src/features/agent-awareness/registrationPayload.ts create mode 100644 apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts create mode 100644 apps/mobile/src/features/agent-awareness/remoteRegistration.ts create mode 100644 apps/mobile/src/features/agent-awareness/shellLiveActivitySync.ts create mode 100644 apps/mobile/src/features/agent-awareness/updatedAtLabel.test.ts create mode 100644 apps/mobile/src/features/agent-awareness/updatedAtLabel.ts create mode 100644 apps/mobile/src/features/cloud/CloudAuthProvider.tsx create mode 100644 apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx create mode 100644 apps/mobile/src/features/cloud/dpop.test.ts create mode 100644 apps/mobile/src/features/cloud/dpop.ts create mode 100644 apps/mobile/src/features/cloud/linkEnvironment.test.ts create mode 100644 apps/mobile/src/features/cloud/linkEnvironment.ts create mode 100644 apps/mobile/src/features/cloud/managedRelayLayer.ts create mode 100644 apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts create mode 100644 apps/mobile/src/lib/authClientMetadata.ts create mode 100644 apps/mobile/src/state/use-remote-environment-registry.test.ts create mode 100644 apps/mobile/src/widgets/AgentActivity.tsx create mode 100644 apps/server/src/auth/dpop.test.ts create mode 100644 apps/server/src/auth/dpop.ts create mode 100644 apps/server/src/cloud/ManagedEndpointRuntime.test.ts create mode 100644 apps/server/src/cloud/ManagedEndpointRuntime.ts create mode 100644 apps/server/src/cloud/config.ts create mode 100644 apps/server/src/cloud/http.test.ts create mode 100644 apps/server/src/cloud/http.ts create mode 100644 apps/server/src/persistence/Migrations/032_AuthPairingProofKeyThumbprint.ts create mode 100644 apps/server/src/relay/AgentAwarenessRelay.test.ts create mode 100644 apps/server/src/relay/AgentAwarenessRelay.ts create mode 100644 apps/web/src/cloud/desktopAuth.test.ts create mode 100644 apps/web/src/cloud/desktopAuth.ts create mode 100644 apps/web/src/cloud/desktopClerk.tsx create mode 100644 apps/web/src/cloud/dpop.test.ts create mode 100644 apps/web/src/cloud/dpop.ts create mode 100644 apps/web/src/cloud/linkEnvironment.test.ts create mode 100644 apps/web/src/cloud/linkEnvironment.ts create mode 100644 apps/web/src/cloud/managedAuth.tsx create mode 100644 apps/web/src/cloud/managedRelayLayer.ts create mode 100644 apps/web/src/components/settings/CloudSettings.tsx create mode 100644 apps/web/src/routes/settings.cloud.tsx create mode 100644 docs/relay-observability.md create mode 100644 docs/t3-cloud-clerk.md create mode 100644 docs/t3-code-cloud-auth-flow.html create mode 100644 infra/relay/alchemy.run.ts create mode 100644 infra/relay/migrations/postgres/20260527044716_baseline/migration.sql create mode 100644 infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json create mode 100644 infra/relay/package.json create mode 100644 infra/relay/src/Config.ts create mode 100644 infra/relay/src/RelayCrypto.ts create mode 100644 infra/relay/src/agentActivityPayloads.ts create mode 100644 infra/relay/src/api.test.ts create mode 100644 infra/relay/src/api.ts create mode 100644 infra/relay/src/apns.test.ts create mode 100644 infra/relay/src/apns.ts create mode 100644 infra/relay/src/apnsDeliveryJobs.test.ts create mode 100644 infra/relay/src/apnsDeliveryJobs.ts create mode 100644 infra/relay/src/db.test.ts create mode 100644 infra/relay/src/db.ts create mode 100644 infra/relay/src/dpop.test.ts create mode 100644 infra/relay/src/dpop.ts create mode 100644 infra/relay/src/infra/ManagedEndpointStackConfig.test.ts create mode 100644 infra/relay/src/infra/ManagedEndpointStackConfig.ts create mode 100644 infra/relay/src/infra/RelayObservability.test.ts create mode 100644 infra/relay/src/infra/RelayObservability.ts create mode 100644 infra/relay/src/persistence/AgentActivityRows.ts create mode 100644 infra/relay/src/persistence/DeliveryAttempts.test.ts create mode 100644 infra/relay/src/persistence/DeliveryAttempts.ts create mode 100644 infra/relay/src/persistence/Devices.test.ts create mode 100644 infra/relay/src/persistence/Devices.ts create mode 100644 infra/relay/src/persistence/DpopProofs.test.ts create mode 100644 infra/relay/src/persistence/DpopProofs.ts create mode 100644 infra/relay/src/persistence/EnvironmentCredentials.test.ts create mode 100644 infra/relay/src/persistence/EnvironmentCredentials.ts create mode 100644 infra/relay/src/persistence/EnvironmentLinks.test.ts create mode 100644 infra/relay/src/persistence/EnvironmentLinks.ts create mode 100644 infra/relay/src/persistence/LiveActivities.test.ts create mode 100644 infra/relay/src/persistence/LiveActivities.ts create mode 100644 infra/relay/src/persistence/json.ts create mode 100644 infra/relay/src/queues.ts create mode 100644 infra/relay/src/relayTokens.test.ts create mode 100644 infra/relay/src/relayTokens.ts create mode 100644 infra/relay/src/schema.ts create mode 100644 infra/relay/src/services/AgentActivityPublisher.test.ts create mode 100644 infra/relay/src/services/AgentActivityPublisher.ts create mode 100644 infra/relay/src/services/ApnsDeliveries.test.ts create mode 100644 infra/relay/src/services/ApnsDeliveries.ts create mode 100644 infra/relay/src/services/ApnsDeliveryQueue.ts create mode 100644 infra/relay/src/services/Auth.ts create mode 100644 infra/relay/src/services/EnvironmentConnector.test.ts create mode 100644 infra/relay/src/services/EnvironmentConnector.ts create mode 100644 infra/relay/src/services/EnvironmentLinker.test.ts create mode 100644 infra/relay/src/services/EnvironmentLinker.ts create mode 100644 infra/relay/src/services/EnvironmentPublishSignatures.test.ts create mode 100644 infra/relay/src/services/EnvironmentPublishSignatures.ts create mode 100644 infra/relay/src/services/ManagedEndpointProvider.test.ts create mode 100644 infra/relay/src/services/ManagedEndpointProvider.ts create mode 100644 infra/relay/src/services/MobileRegistrations.test.ts create mode 100644 infra/relay/src/services/MobileRegistrations.ts create mode 100644 infra/relay/src/telemetry.test.ts create mode 100644 infra/relay/src/telemetry.ts create mode 100644 infra/relay/src/worker.ts create mode 100644 infra/relay/tsconfig.json create mode 100644 packages/client-runtime/src/managedRelay.test.ts create mode 100644 packages/client-runtime/src/managedRelay.ts create mode 100644 packages/contracts/src/relay.test.ts create mode 100644 packages/contracts/src/relay.ts create mode 100644 packages/shared/src/agentAwareness.test.ts create mode 100644 packages/shared/src/agentAwareness.ts create mode 100644 packages/shared/src/dpop.test.ts create mode 100644 packages/shared/src/dpop.ts create mode 100644 packages/shared/src/dpopCommon.ts create mode 100644 packages/shared/src/relayAuth.ts create mode 100644 packages/shared/src/relayJwt.ts create mode 100644 packages/shared/src/relaySigning.test.ts create mode 100644 packages/shared/src/relaySigning.ts 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/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..4398753c067 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: 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..d26aa599a63 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,6 +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); 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.ts b/apps/desktop/src/ipc/methods/cloudAuth.ts new file mode 100644 index 00000000000..2870732f7e7 --- /dev/null +++ b/apps/desktop/src/ipc/methods/cloudAuth.ts @@ -0,0 +1,128 @@ +import { + DesktopCloudAuthFetchInputSchema, + DesktopCloudAuthFetchResultSchema, +} from "@t3tools/contracts"; +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 { 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"; + +export class DesktopCloudAuthFetchError extends Data.TaggedError("DesktopCloudAuthFetchError")<{ + readonly reason: string; + readonly cause?: unknown; +}> { + override get message() { + return this.reason; + } +} + +const allowedClerkFrontendApiHosts = (hostname: string): boolean => + hostname.endsWith(".clerk.accounts.dev") || hostname.endsWith(".clerk.accounts.com"); + +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 request = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")(url, { + headers: input.headers, + }).pipe( + input.body === undefined ? (request) => request : HttpClientRequest.bodyText(input.body), + ); + + 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/mobile/app.config.ts b/apps/mobile/app.config.ts index 378ca1964a7..d9288d9474b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -96,6 +96,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", { @@ -120,17 +125,36 @@ 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: process.env.T3_RELAY_URL ?? null, + }, + clerk: { + publishableKey: process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? 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 6f48f529c87..4b08a338e12 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -39,10 +39,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", @@ -58,6 +61,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", @@ -70,12 +74,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", @@ -96,6 +103,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..ba6ddeea986 --- /dev/null +++ b/apps/mobile/src/app/settings/environments.tsx @@ -0,0 +1,335 @@ +import { useAuth } from "@clerk/expo"; +import { Link, Stack } from "expo-router"; +import { SymbolView } from "expo-symbols"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; +import * as Effect from "effect/Effect"; +import { useCallback, useEffect, useMemo, useRef, 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 { + cloudEnvironmentsPendingStatus, + type CloudEnvironmentRecordWithStatus, + connectCloudEnvironment, + listCloudEnvironments, + loadCloudEnvironmentStatuses, +} from "../../features/cloud/linkEnvironment"; +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 { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const getTokenRef = useRef(getToken); + getTokenRef.current = getToken; + const { + connectedEnvironments, + onReconnectEnvironment, + onRemoveEnvironmentPress, + onUpdateEnvironment, + } = useRemoteConnections(); + const { savedConnectionsById } = useRemoteEnvironmentState(); + const insets = useSafeAreaInsets(); + const hasEnvironments = connectedEnvironments.length > 0; + const [expandedId, setExpandedId] = useState(null); + const [cloudEnvironments, setCloudEnvironments] = useState< + ReadonlyArray + >([]); + const [cloudStatus, setCloudStatus] = useState<"idle" | "loading" | "error">("idle"); + const [cloudError, setCloudError] = useState(null); + const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState( + null, + ); + + const accentColor = useThemeColor("--color-icon-muted"); + const iconColor = useThemeColor("--color-icon"); + + const handleToggle = useCallback((environmentId: EnvironmentId) => { + setExpandedId((prev) => (prev === environmentId ? null : environmentId)); + }, []); + + const availableCloudEnvironments = useMemo( + () => + cloudEnvironments.filter( + (record) => savedConnectionsById[record.environment.environmentId] === undefined, + ), + [cloudEnvironments, savedConnectionsById], + ); + + const refreshCloudEnvironments = useCallback(async () => { + if (!isLoaded || !isSignedIn) { + setCloudEnvironments([]); + setCloudStatus("idle"); + setCloudError(null); + return; + } + + setCloudStatus("loading"); + setCloudError(null); + try { + const token = await getTokenRef.current(RELAY_CLERK_TOKEN_OPTIONS); + if (!token) { + setCloudEnvironments([]); + setCloudStatus("idle"); + return; + } + const environments = await mobileRuntime.runPromise( + listCloudEnvironments({ clerkToken: token }), + ); + setCloudEnvironments(cloudEnvironmentsPendingStatus(environments)); + const records = await mobileRuntime.runPromise( + loadCloudEnvironmentStatuses({ clerkToken: token, environments }), + ); + setCloudEnvironments(records); + setCloudStatus("idle"); + } catch (error) { + setCloudStatus("error"); + setCloudError( + error instanceof Error ? error.message : "Could not load T3 Cloud environments.", + ); + } + }, [isLoaded, isSignedIn]); + + useEffect(() => { + void refreshCloudEnvironments(); + }, [refreshCloudEnvironments]); + + const handleConnectCloudEnvironment = useCallback( + async (record: CloudEnvironmentRecordWithStatus) => { + setConnectingCloudEnvironmentId(record.environment.environmentId); + try { + const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS); + if (!token) { + throw new Error("Sign in to T3 Cloud before connecting."); + } + await mobileRuntime.runPromise( + connectCloudEnvironment({ + clerkToken: token, + environment: record.environment, + }).pipe(Effect.flatMap(connectSavedEnvironment)), + ); + setCloudEnvironments((records) => + records.filter( + (candidate) => candidate.environment.environmentId !== record.environment.environmentId, + ), + ); + } catch (error) { + Alert.alert( + "Connect failed", + error instanceof Error ? error.message : "Could not connect to this environment.", + ); + } finally { + setConnectingCloudEnvironmentId(null); + } + }, + [getToken], + ); + + return ( + + ( + + + + + + ), + }} + /> + + {hasEnvironments ? ( + + {connectedEnvironments.map((environment, index) => ( + + handleToggle(environment.environmentId)} + onReconnect={onReconnectEnvironment} + onRemove={onRemoveEnvironmentPress} + onUpdate={onUpdateEnvironment} + /> + + ))} + + ) : ( + + + + + + No environments connected yet.{"\n"}Tap{" "} + + to add one. + + + )} + + {isSignedIn ? ( + + + + T3 Cloud + + + {cloudStatus === "loading" ? ( + + ) : ( + + )} + + + + {availableCloudEnvironments.length > 0 ? ( + + {availableCloudEnvironments.map((record, index) => ( + handleConnectCloudEnvironment(record)} + /> + ))} + + ) : cloudStatus === "loading" ? ( + + + + Loading linked cloud environments. + + + ) : cloudStatus === "error" ? ( + + + Could not load T3 Cloud environments + + + {cloudError} + + + ) : ( + + + No additional linked cloud environments. + + + )} + + ) : null} + + + ); +} + +function CloudEnvironmentRow(props: { + readonly record: CloudEnvironmentRecordWithStatus; + readonly borderTop: boolean; + readonly isConnecting: boolean; + readonly onConnect: () => void; +}) { + const mutedColor = useThemeColor("--color-icon-muted"); + const { environment, status, statusError } = props.record; + const disabled = props.isConnecting; + const statusText = + status === null + ? (statusError ?? "Status unavailable") + : status.status === "online" + ? "Online" + : (status.error ?? "Offline"); + + return ( + + + + + + + {environment.label} + + + {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..917c8db8a2b --- /dev/null +++ b/apps/mobile/src/app/settings/index.tsx @@ -0,0 +1,400 @@ +import { useAuth, useUser, useUserProfileModal } from "@clerk/expo"; +import * as Notifications from "expo-notifications"; +import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; +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 { 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() { + 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 icon = useThemeColor("--color-icon"); + 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(RELAY_CLERK_TOKEN_OPTIONS); + if (!token) { + promptSignIn(); + setLiveActivityStatus("signed-out"); + return; + } + + await mobileRuntime.runPromise( + setLiveActivityUpdatesEnabled({ + enabled: true, + clerkToken: token, + connections, + }), + ); + 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(RELAY_CLERK_TOKEN_OPTIONS) : null; + await mobileRuntime.runPromise( + setLiveActivityUpdatesEnabled({ + enabled: false, + clerkToken: token, + connections, + }), + ); + } 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. + + + + + + + + + + + + + Version + Alpha + + + + + ); +} + +type SymbolName = ComponentProps["name"]; + +function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { + return ( + + {props.title} + + {props.children} + + + ); +} + +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..348197bfdf7 --- /dev/null +++ b/apps/mobile/src/app/settings/waitlist.tsx @@ -0,0 +1,67 @@ +import Constants from "expo-constants"; +import { Stack } from "expo-router"; +import { ScrollView, Text, View } from "react-native"; + +import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment"; +import { useNativeClerkAuthModal } from "../../features/cloud/useNativeClerkAuthModal"; +import { useThemeColor } from "../../lib/useThemeColor"; + +function hasClerkConfig(): boolean { + const clerkConfig = Constants.expoConfig?.extra?.clerk as + | { readonly publishableKey?: string | null } + | undefined; + return Boolean(clerkConfig?.publishableKey); +} + +export default function SettingsWaitlistRouteScreen() { + const { presentAuth } = useNativeClerkAuthModal(); + const foreground = String(useThemeColor("--color-foreground")); + const secondaryForeground = String(useThemeColor("--color-foreground-secondary")); + + return ( + <> + + + {hasClerkConfig() ? ( + void presentAuth()} /> + ) : ( + + + T3 Cloud is not configured + + + Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to this build to enable waitlist enrollment. + + + )} + + + ); +} 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..b0d29b877eb --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -0,0 +1,31 @@ +import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; + +import type { MobilePreferences } from "../../lib/storage"; + +export function makeRelayDeviceRegistrationRequest(input: { + readonly deviceId: 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, + 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..b3c04bd616b --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -0,0 +1,409 @@ +/// + +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", + iosMajorVersion: 18, + appVersion: "1.0.0", + pushToken: "apns-token", + pushToStartToken: "push-to-start-token", + notificationsEnabled: true, + preferences: { + liveActivitiesEnabled: false, + }, + }), + ).toEqual({ + deviceId: "device-1", + 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", + iosMajorVersion: 18, + appVersion: "1.0.0", + pushToStartToken: "push-to-start-token", + notificationsEnabled: false, + preferences: { + liveActivitiesEnabled: true, + }, + }), + ).toEqual({ + deviceId: "device-1", + 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..14bf87af66f --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -0,0 +1,448 @@ +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 { 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 relayConfig = Constants.expoConfig?.extra?.relay as + | { readonly url?: string | null } + | undefined; + const relayUrl = normalizeAgentAwarenessRelayBaseUrl(relayConfig?.url); + 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, + 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/agent-awareness/updatedAtLabel.test.ts b/apps/mobile/src/features/agent-awareness/updatedAtLabel.test.ts new file mode 100644 index 00000000000..25d234e4513 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/updatedAtLabel.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { formatAgentActivityUpdatedAtLabel } from "./updatedAtLabel"; + +describe("formatAgentActivityUpdatedAtLabel", () => { + it("formats ISO timestamps without using ambient time APIs", () => { + expect(formatAgentActivityUpdatedAtLabel("2026-05-25T00:03:00.000Z")).toBe("12:03"); + expect(formatAgentActivityUpdatedAtLabel("2026-05-25T09:45:00.000Z")).toBe("9:45"); + expect(formatAgentActivityUpdatedAtLabel("2026-05-25T13:07:00.000Z")).toBe("1:07"); + }); + + it("uses now for malformed timestamps", () => { + expect(formatAgentActivityUpdatedAtLabel("not-a-date")).toBe("now"); + expect(formatAgentActivityUpdatedAtLabel("2026-05-25T24:00:00.000Z")).toBe("now"); + }); +}); diff --git a/apps/mobile/src/features/agent-awareness/updatedAtLabel.ts b/apps/mobile/src/features/agent-awareness/updatedAtLabel.ts new file mode 100644 index 00000000000..c721c98f4f8 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/updatedAtLabel.ts @@ -0,0 +1,12 @@ +const ISO_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T(?\d{2}):(?\d{2}):/; + +export function formatAgentActivityUpdatedAtLabel(updatedAt: string): string { + const match = ISO_TIME_PATTERN.exec(updatedAt); + const hours24 = Number(match?.groups?.hours); + const minutes = match?.groups?.minutes; + if (!Number.isInteger(hours24) || hours24 < 0 || hours24 > 23 || !minutes) { + return "now"; + } + + return `${hours24 % 12 || 12}:${minutes}`; +} diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx new file mode 100644 index 00000000000..8748117a3af --- /dev/null +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -0,0 +1,89 @@ +import { ClerkProvider, useAuth } from "@clerk/expo"; +import { tokenCache } from "@clerk/expo/token-cache"; +import Constants from "expo-constants"; +import { type ReactNode, useEffect, useRef } from "react"; +import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; + +import { mobileRuntime } from "../../lib/runtime"; +import { + setAgentAwarenessRelayTokenProvider, + unregisterAgentAwarenessDeviceForCurrentUser, +} from "../agent-awareness/remoteRegistration"; +import { refreshActiveLiveActivityRemoteRegistration } from "../agent-awareness/liveActivityController"; + +function readClerkPublishableKey(): string | null { + const clerkConfig = Constants.expoConfig?.extra?.clerk as + | { readonly publishableKey?: string | null } + | undefined; + return clerkConfig?.publishableKey ?? null; +} + +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); + return; + } + + const previous = previousTokenProviderRef.current; + if (previous && previous.userId !== userId) { + void mobileRuntime + .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) + .catch(() => undefined); + } + const tokenProvider = () => getToken(RELAY_CLERK_TOKEN_OPTIONS); + previousTokenProviderRef.current = { userId, provider: tokenProvider }; + setAgentAwarenessRelayTokenProvider(tokenProvider, userId); + if (!previous || previous.userId !== userId) { + void mobileRuntime + .runPromise(refreshActiveLiveActivityRemoteRegistration()) + .catch(() => undefined); + } + }, [getToken, isLoaded, isSignedIn, userId]); + + useEffect( + () => () => { + previousTokenProviderRef.current = null; + setAgentAwarenessRelayTokenProvider(null); + }, + [], + ); + + return props.children; +} + +export function CloudAuthProvider(props: { readonly children: ReactNode }) { + const publishableKey = readClerkPublishableKey(); + + useEffect(() => { + if (!publishableKey) { + setAgentAwarenessRelayTokenProvider(null); + } + }, [publishableKey]); + + if (!publishableKey) { + 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..e2e3df4eb6a --- /dev/null +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -0,0 +1,1030 @@ +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, +} 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"); + // @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("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..e13e31bf196 --- /dev/null +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -0,0 +1,552 @@ +import Constants from "expo-constants"; +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"; + +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 { + const relayConfig = Constants.expoConfig?.extra?.relay as + | { readonly url?: string | null } + | undefined; + return normalizeRelayBaseUrl(relayConfig?.url); +} + +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, + }); + }); +} + +export function connectCloudEnvironment(input: { + readonly clerkToken: string; + readonly environment: 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.environment.environmentId, + deviceId, + }) + .pipe( + Effect.mapError( + decodedRelayClientError( + `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/connect failed`, + ), + ), + ); + if (connect.environmentId !== input.environment.environmentId) { + return yield* new CloudEnvironmentLinkError({ + message: "Relay returned credentials for a different environment.", + }); + } + yield* ensureConnectEndpointMatchesEnvironment({ + environment: input.environment, + 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, + }; + }); +} 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/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/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..a11b7269a63 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { mobileAuthClientMetadata, redactPairingCredential } from "./connection"; vi.mock("./runtime", () => ({ - mobileRemoteHttpRuntime: { + mobileRuntime: { runPromise: vi.fn(), }, })); diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index 8d87fda09c2..8500a808734 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,9 @@ 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; } export type RemoteClientConnectionState = @@ -37,14 +42,6 @@ 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 async function bootstrapRemoteConnection( input: RemoteConnectionInput, ): Promise { @@ -52,18 +49,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 +73,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..22b8b4fa55d 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,5 +1,26 @@ +import Constants from "expo-constants"; +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"; + +function configuredRelayUrl(): string { + const relay = Constants.expoConfig?.extra?.relay as { readonly url?: string | null } | undefined; + const value = relay?.url?.trim(); + return value ? value.replace(/\/+$/g, "") : "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.ts b/apps/mobile/src/lib/storage.ts index e2eb173fac6..41702b02ff0 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -9,6 +9,7 @@ import type { SavedRemoteConnection } 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 +21,7 @@ export interface CachedShellSnapshot { } export interface MobilePreferences { + readonly liveActivitiesEnabled?: boolean; readonly terminalFontSize?: number; } @@ -133,7 +135,12 @@ export async function loadSavedConnections(): Promise !!c.environmentId && !!c.bearerToken?.trim()), + Arr.filter( + (c) => + !!c.environmentId && + (!!c.bearerToken?.trim() || + (c.authenticationMethod === "dpop" && !!c.dpopAccessToken?.trim())), + ), ); } @@ -164,11 +171,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 +197,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/use-remote-environment-registry.test.ts b/apps/mobile/src/state/use-remote-environment-registry.test.ts new file mode 100644 index 00000000000..bb2ab1c9d3b --- /dev/null +++ b/apps/mobile/src/state/use-remote-environment-registry.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import { ManagedRelayDpopSigner } 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()), + }; + return { + environmentConnection, + sessionConnection, + 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(), + bootstrapRemoteConnection: vi.fn(), + clearCachedShellSnapshot: vi.fn(() => Promise.resolve()), + clearSavedConnection: vi.fn(() => Promise.resolve()), + saveConnection: vi.fn(() => Promise.resolve()), + saveCachedShellSnapshot: vi.fn(() => Promise.resolve()), + mobileRunPromise: vi.fn((_effect?: unknown) => + Promise.resolve("wss://desktop.example/ws?wsTicket=token"), + ), + removeEnvironmentSession: 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(), + }, +})); + +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", () => ({ + bootstrapRemoteConnection: mocks.bootstrapRemoteConnection, +})); + +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(() => []), + 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 } from "./use-remote-environment-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.removeEnvironmentSession.mockReturnValue(null); + mocks.mobileRunPromise.mockResolvedValue("wss://desktop.example/ws?wsTicket=token"); + mocks.createDpopProof.mockReturnValue(Effect.succeed("dpop-proof")); + mocks.resolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( + Effect.succeed("wss://desktop.example/ws?wsTicket=dpop-token"), + ); + }); + + 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("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("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..97d95609a61 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -9,11 +9,16 @@ import { createKnownEnvironment, createWsRpcClient, EnvironmentConnectionState, + ManagedRelayDpopSigner, WsTransport, + remoteEndpointUrl, + resolveRemoteDpopWebSocketConnectionUrl, resolveRemoteWebSocketConnectionUrl, } 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"; @@ -29,7 +34,7 @@ import { saveConnection, } from "../lib/storage"; import { appAtomRegistry } from "./atom-registry"; -import { mobileRemoteHttpRuntime } from "../lib/runtime"; +import { mobileRuntime } from "../lib/runtime"; import { drainEnvironmentSessions, notifyEnvironmentConnectionListeners, @@ -41,6 +46,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, @@ -153,223 +163,265 @@ 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); - } - - 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); - } -} - -export async function connectSavedEnvironment( - connection: SavedRemoteConnection, - options?: { readonly persist?: boolean }, -) { - const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); - const isCurrentAttempt = connectionAttempt.isCurrent; +): Effect.Effect { + return Effect.gen(function* () { + if (!options?.preserveConnectionAttempt) { + environmentConnectionAttempts.cancel(environmentId); + } - await disconnectEnvironment(connection.environmentId, { - preserveShellSnapshot: true, - preserveConnectionAttempt: true, + 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); + } }); - if (!isCurrentAttempt()) { - return; - } +} - if (options?.persist !== false) { - await saveConnection(connection); +export function connectSavedEnvironment( + connection: SavedRemoteConnection, + options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean }, +): Effect.Effect { + return Effect.gen(function* () { + const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); + const isCurrentAttempt = connectionAttempt.isCurrent; + + 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(connection)); + if (!isCurrentAttempt()) { + return; + } + } - const transport = new WsTransport( - () => - mobileRemoteHttpRuntime.runPromise( - resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: connection.wsBaseUrl, - httpBaseUrl: connection.httpBaseUrl, - bearerToken: connection.bearerToken, - }), - ), - { - onAttempt: () => { - if (!isCurrentAttempt()) { - return; - } + upsertSavedConnection(connection); + setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); + shellSnapshotManager.markPending({ environmentId: connection.environmentId }); + + const dpopAccessToken = + connection.authenticationMethod === "dpop" ? connection.dpopAccessToken : undefined; + const transport = new WsTransport( + () => + mobileRuntime.runPromise( + dpopAccessToken + ? Effect.gen(function* () { + const signer = yield* ManagedRelayDpopSigner; + const dpop = yield* signer.createProof({ + method: "POST", + url: remoteEndpointUrl(connection.httpBaseUrl, "/api/auth/websocket-ticket"), + accessToken: dpopAccessToken, + }); + return yield* resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: connection.wsBaseUrl, + httpBaseUrl: connection.httpBaseUrl, + accessToken: dpopAccessToken, + dpopProof: dpop, + }); + }) + : resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: connection.wsBaseUrl, + httpBaseUrl: connection.httpBaseUrl, + bearerToken: connection.bearerToken ?? "", + }), + ), + { + onAttempt: () => { + 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, + }; + }, + ); + }, + onError: (message) => { + if (isCurrentAttempt()) { + setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); + } + }, + onClose: (details) => { + 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, - }; - }); + const reason = + details.reason.trim().length > 0 + ? details.reason + : details.code === 1000 + ? null + : `Remote connection closed (${details.code}).`; + setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); + }, }, - onError: (message) => { + ); + + 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, + }, + }), + environmentId: connection.environmentId, + }, + client, + applyShellEvent: (event, environmentId) => { if (isCurrentAttempt()) { - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); + shellSnapshotManager.applyEvent({ environmentId }, event); } }, - onClose: (details) => { + syncShellSnapshot: (snapshot, environmentId) => { 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, - }, - }), - 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) => ({ + shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); + markShellSnapshotLive(environmentId); + void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined); + environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ ...runtime, - serverConfig, + connectionState: "ready", + connectionError: null, })); - } - }, - }); + }, + onShellResubscribe: (environmentId) => { + if (isCurrentAttempt()) { + shellSnapshotManager.markPending({ environmentId }); + } + }, + onConfigSnapshot: (serverConfig) => { + if (isCurrentAttempt()) { + environmentRuntimeManager.patch( + { environmentId: connection.environmentId }, + (runtime) => ({ + ...runtime, + serverConfig, + }), + ); + } + }, + }); - if (!isCurrentAttempt()) { - await environmentConnection.dispose(); - return; - } + if (!isCurrentAttempt()) { + yield* fromPromise(() => environmentConnection.dispose()); + return; + } - setEnvironmentSession(connection.environmentId, { - client, - connection: environmentConnection, - }); - terminalMetadataUnsubscribers.set( - connection.environmentId, - subscribeTerminalMetadata({ - environmentId: connection.environmentId, + setEnvironmentSession(connection.environmentId, { client, - }), - ); - terminalDebugLog("registry:terminal-metadata-subscribed", { - environmentId: connection.environmentId, - }); - notifyEnvironmentConnectionListeners(); - - try { - await withTimeout( - environmentConnection.ensureBootstrapped(), - SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS, - "Environment did not respond before the connection timeout.", + 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, + ), ); - } catch (error) { - if (isCurrentAttempt()) { - setEnvironmentConnectionStatus( - connection.environmentId, - "disconnected", - error instanceof Error ? error.message : "Failed to bootstrap remote connection.", - ); + 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, + }); + startAgentAwarenessForEnvironment(connection); + notifyEnvironmentConnectionListeners(); + }); } const environmentsSortOrder = Order.mapInput( @@ -404,8 +456,9 @@ export function useRemoteEnvironmentBootstrap() { useEffect(() => { let cancelled = false; - void loadSavedConnections() - .then((connections) => { + void (async () => { + try { + const connections = await loadSavedConnections(); if (cancelled) { return; } @@ -418,34 +471,36 @@ 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; @@ -457,6 +512,7 @@ export function useRemoteEnvironmentBootstrap() { } terminalMetadataUnsubscribers.clear(); environmentConnectionAttempts.clear(); + stopAllAgentAwareness(); environmentRuntimeManager.invalidate(); shellSnapshotManager.invalidate(); resetSourceControlDiscoveryState(); @@ -538,7 +594,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( @@ -577,7 +633,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 +658,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.tsx b/apps/mobile/src/widgets/AgentActivity.tsx new file mode 100644 index 00000000000..58c65bef2b2 --- /dev/null +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -0,0 +1,313 @@ +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"; +import { formatAgentActivityUpdatedAtLabel } from "../features/agent-awareness/updatedAtLabel"; + +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; +} + +function AgentActivity( + props: AgentActivityProps, + environment: LiveActivityEnvironment, +): LiveActivityLayout { + "widget"; + + const row0 = props.activities[0]; + const row1 = props.activities[1]; + const row2 = props.activities[2]; + const updatedAt = formatAgentActivityUpdatedAtLabel(props.updatedAt); + 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/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts new file mode 100644 index 00000000000..d3baceee737 --- /dev/null +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -0,0 +1,244 @@ +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 PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; + +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(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, 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", "--token", "token-1"], + ["tunnel", "run", "--token", "token-2"], + ]); + expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]); + expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]); + 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(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, 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(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, 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(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, 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([]); + }), + ); + + 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(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, 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", + }); + }), + ); +}); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts new file mode 100644 index 00000000000..25906ef8ed3 --- /dev/null +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -0,0 +1,233 @@ +import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; +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 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("Cloudflare managed endpoint connector stopped", { + pid: Number(connector.child.pid), + }), + ), + Effect.ignore, + ) + : Effect.void; + +export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const activeRef = yield* Ref.make(null); + let applyConfig: 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 exitCode = yield* connector.child.exitCode; + const active = yield* Ref.get(activeRef); + if (active?.child.pid !== connector.child.pid || active.configKey !== connector.configKey) { + return; + } + yield* Ref.set(activeRef, null); + yield* Effect.logWarning("Cloudflare managed endpoint connector exited; restarting", { + pid: Number(connector.child.pid), + exitCode: Number(exitCode), + tunnelId: connector.config.tunnelId, + tunnelName: connector.config.tunnelName, + }); + yield* applyConfig(connector.config); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Cloudflare managed endpoint connector supervisor failed", { cause }), + ), + ); + + applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")(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 connectorScope = yield* Scope.make("sequential"); + const child = yield* spawner + .spawn( + ChildProcess.make("cloudflared", ["tunnel", "run", "--token", config.connectorToken], { + shell: process.platform === "win32", + stderr: "ignore", + stdout: "ignore", + }), + ) + .pipe( + Effect.provideService(Scope.Scope, connectorScope), + Effect.tap(() => + Effect.logInfo("Cloudflare managed endpoint connector started", { + tunnelId: config.tunnelId, + tunnelName: config.tunnelName, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to start Cloudflare managed endpoint connector", { + 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: "Cloudflare connector did not start.", + ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), + ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), + } satisfies CloudManagedEndpointRuntimeStatus; + }); + + 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..51b05e64a58 --- /dev/null +++ b/apps/server/src/cloud/config.ts @@ -0,0 +1,17 @@ +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 encodeEndpointRuntimeConfigJson = Schema.encodeEffect( + Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), +); + +export const decodeRuntimeConfig = Schema.decodeUnknownOption( + Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), +); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts new file mode 100644 index 00000000000..c4e52f4967d --- /dev/null +++ b/apps/server/src/cloud/http.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as PlatformError from "effect/PlatformError"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { consumeCloudReplayGuards } from "./http.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); + }), + ); +}); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts new file mode 100644 index 00000000000..c50deebc269 --- /dev/null +++ b/apps/server/src/cloud/http.ts @@ -0,0 +1,797 @@ +import * as NodeCrypto from "node:crypto"; +import { + AuthRelayManageScope, + AuthStandardClientScopes, + EnvironmentCloudEndpointUnavailableError, + EnvironmentCloudLinkStateResult, + EnvironmentCloudRelayConfigResult, + EnvironmentHttpApi, + EnvironmentHttpBadRequestError, + EnvironmentHttpConflictError, + EnvironmentHttpInternalServerError, + EnvironmentHttpUnauthorizedError, +} from "@t3tools/contracts"; +import { + RelayCloudEnvironmentHealthProofPayload, + RelayCloudEnvironmentHealthRequest, + RelayCloudMintCredentialProofPayload, + RelayCloudMintCredentialRequest, + RelayEnvironmentHealthResponseProofPayload, + type RelayEnvironmentHealthResponse as RelayEnvironmentHealthResponseShape, + RelayEnvironmentConfigRequest, + 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 * 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 * 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, + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, + RELAY_URL_SECRET, +} from "./config.ts"; + +const CLOUD_LINK_PRIVATE_KEY = "cloud-link-ed25519-private-key"; +const CLOUD_LINK_PUBLIC_KEY = "cloud-link-ed25519-public-key"; +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 }))), + ); + +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 isSecureRelayUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + url.protocol === "https:" && + url.username.length === 0 && + url.password.length === 0 && + url.hash.length === 0 + ); + } catch { + return false; + } +} + +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); + +export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( + secrets: ServerSecretStore.ServerSecretStoreShape, +) { + const existingPrivate = yield* secrets.get(CLOUD_LINK_PRIVATE_KEY); + const existingPublic = yield* secrets.get(CLOUD_LINK_PUBLIC_KEY); + if (existingPrivate && existingPublic) { + return { + privateKey: bytesToString(existingPrivate), + publicKey: bytesToString(existingPublic), + }; + } + + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + yield* secrets.set(CLOUD_LINK_PRIVATE_KEY, stringToBytes(keyPair.privateKey)); + yield* secrets.set(CLOUD_LINK_PUBLIC_KEY, stringToBytes(keyPair.publicKey)); + return { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + }; +}); + +interface CloudHttpDependencies { + readonly secrets: ServerSecretStore.ServerSecretStoreShape; + readonly environment: ServerEnvironmentShape; + readonly endpointRuntime: CloudManagedEndpointRuntimeShape; + readonly environmentAuth: EnvironmentAuth.EnvironmentAuthShape; +} + +const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( + function* (dependencies: CloudHttpDependencies, request: RelayLinkProofRequest) { + yield* requireEnvironmentScope(AuthRelayManageScope); + const httpRequest = yield* HttpServerRequest.HttpServerRequest; + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); + const requestUrl = requestAbsoluteUrl(httpRequest); + if ( + requestUrl === null || + hasForwardedAuthorityHeaders(httpRequest) || + !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; + const proof = 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, + }), + ), + ); + 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 cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( + function* (dependencies: CloudHttpDependencies, payload: RelayEnvironmentConfigRequest) { + yield* requireEnvironmentScope(AuthRelayManageScope); + 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; + }, + 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 cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( + function* (dependencies: CloudHttpDependencies) { + yield* requireEnvironmentScope(AuthRelayManageScope); + const [cloudUserId, relayUrl, relayIssuer] = yield* Effect.all( + [ + dependencies.secrets.get(CLOUD_LINKED_USER_ID), + dependencies.secrets.get(RELAY_URL_SECRET), + dependencies.secrets.get(RELAY_ISSUER_SECRET), + ], + { concurrency: 3 }, + ); + const response = { + linked: cloudUserId !== null, + cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, + relayUrl: relayUrl ? bytesToString(relayUrl) : null, + relayIssuer: relayIssuer ? bytesToString(relayIssuer) : null, + } satisfies EnvironmentCloudLinkStateResult; + return response; + }, + Effect.catchTag( + "SecretStoreError", + failEnvironmentCloudInternalError("Could not read environment relay configuration."), + ), +); + +const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( + function* (dependencies: CloudHttpDependencies) { + yield* requireEnvironmentScope(AuthRelayManageScope); + 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), + ], + { concurrency: 6 }, + ); + return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; + }, + Effect.catchTag( + "SecretStoreError", + failEnvironmentCloudInternalError("Could not remove environment relay configuration."), + ), +); + +const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( + function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) { + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); + 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 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 keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); + 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 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: CloudHttpDependencies = { + secrets: yield* ServerSecretStore.ServerSecretStore, + environment: yield* ServerEnvironment, + endpointRuntime: yield* CloudManagedEndpointRuntime, + environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, + }; + return handlers + .handle("linkProof", ({ payload }) => cloudLinkProofHandler(dependencies, payload)) + .handle("relayConfig", ({ payload }) => cloudRelayConfigHandler(dependencies, payload)) + .handle("linkState", () => cloudLinkStateHandler(dependencies)) + .handle("unlink", () => cloudUnlinkHandler(dependencies)) + .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) + .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) + .handle("t3MintCredential", ({ payload }) => + cloudMintCredentialHandler(dependencies, payload), + ); + }), +); diff --git a/apps/server/src/httpCors.ts b/apps/server/src/httpCors.ts index e44486d3c4b..aeb8dbce5a5 100644 --- a/apps/server/src/httpCors.ts +++ b/apps/server/src/httpCors.ts @@ -4,6 +4,7 @@ export const browserApiCorsAllowedHeaders = [ "b3", "traceparent", "content-type", + "dpop", ] as const; export const browserApiCorsHeaders = { diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 13524ae85d4..300d1526bb9 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; +import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; describe("OrchestrationReactor", () => { 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..abb54057b1f --- /dev/null +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -0,0 +1,634 @@ +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, +} 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("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* 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* 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..1c778c2799a --- /dev/null +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -0,0 +1,459 @@ +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/http.ts"; +import { + 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); +} + +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: 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 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 relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); + if (!relayConfig) { + yield* Effect.logDebug("agent awareness relay publish skipped; relay 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("agent awareness relay publishing 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 awareness relay 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 awareness relay publish skipped; projected state unchanged", { + environmentId, + threadId, + reason: snapshot.reason, + }); + return; + } + + if (snapshot.reason === "thread-not-found") { + yield* Effect.logDebug("agent awareness relay publishing tombstone; thread not found", { + environmentId, + threadId, + }); + } else if (snapshot.reason === "project-not-found") { + yield* Effect.logDebug("agent awareness relay publishing 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 awareness relay publish failed", { + threadId, + cause: Cause.pretty(cause), + }); + }), + Effect.withSpan("AgentAwarenessRelay.publishThread"), + ); + + const publishActiveThreadsUnsafe = Effect.gen(function* () { + const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); + if (!relayConfig) { + yield* Effect.logDebug("agent awareness relay active snapshot skipped; relay 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 awareness relay active snapshot has no publishable threads"); + return true; + } + yield* Effect.logInfo("agent awareness relay publishing active 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 awareness relay standby; relay config missing"); + } else { + yield* Effect.logInfo("agent awareness relay 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 awareness relay ignored event without thread id", { + eventType: event.type, + }); + } + if (!shouldPublishAgentAwarenessEvent(event)) { + return Effect.logDebug( + "agent awareness relay ignored event without awareness changes", + { + eventType: event.type, + threadId, + }, + ); + } + return Effect.logDebug("agent awareness relay 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..bd7ae885ffa 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,12 @@ 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 { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import * as Clock from "effect/Clock"; @@ -43,13 +50,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 +127,10 @@ 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 ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -347,6 +358,7 @@ const buildAppUnderTest = (options?: { serverRuntimeStartup?: Partial; serverEnvironment?: Partial; repositoryIdentityResolver?: Partial; + cloudManagedEndpointRuntime?: Partial; }; }) => Effect.gen(function* () { @@ -741,7 +753,17 @@ const buildAppUnderTest = (options?: { ...options?.layers?.repositoryIdentityResolver, }), ), + Layer.provide( + Layer.succeed( + CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.of({ + applyConfig: () => Effect.succeed({ status: "disabled" }), + ...options?.layers?.cloudManagedEndpointRuntime, + }), + ), + ), Layer.provideMerge(makeAuthTestLayer()), + Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -799,6 +821,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 +835,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 +871,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 +1109,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 +1133,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()) @@ -940,10 +1155,11 @@ const assertBrowserApiCorsPreflightHeaders = ( "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 +1205,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 +1272,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 +1285,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 +1303,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 +1313,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 +1344,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 +1365,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 +1375,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 +1404,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 +1427,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,23 +1465,1711 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("issues authenticated one-time pairing credentials for additional clients", () => + it.effect( + "exchanges a bootstrap credential for a DPoP-bound access token without bearer downgrade", + () => + 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 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 response = yield* HttpClient.post("/api/auth/pairing-token", { + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const firstCredentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { headers: { - cookie: yield* getAuthenticatedSessionCookieHeader(), + cookie: ownerCookie, }, body: yield* HttpBody.json({}), }); - const body = (yield* response.json) as { + const firstCredential = (yield* firstCredentialResponse.json) as { readonly credential: string; - readonly expiresAt: string; }; - - assert.equal(response.status, 200); - assert.equal(typeof body.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("rejects managed relay configuration reads without relay management scope", () => + 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 _tag?: string; + readonly code?: string; + readonly requiredScope?: string; + readonly traceId?: string; + }>(response); + + assert.equal(response.status, 403); + assert.equal(body._tag, "EnvironmentScopeRequiredError"); + assert.equal(body.code, "insufficient_scope"); + assert.equal(body.requiredScope, "relay:manage"); + assert.equal(typeof body.traceId, "string"); + }).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 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 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(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("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("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); + assert.equal(typeof body.credential, "string"); assert.isTrue(body.credential.length > 0); assert.equal(typeof body.expiresAt, "string"); @@ -1538,8 +3268,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 +3332,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 +3530,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 +3627,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 +3842,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 +3888,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 +3909,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 +3957,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..06251ef3439 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -44,6 +44,7 @@ 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 { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; import { ServerSettingsLive } from "./serverSettings.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; @@ -67,6 +68,8 @@ 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 } from "./cloud/http.ts"; +import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; @@ -134,6 +137,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), ); @@ -271,6 +275,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge( + CloudManagedEndpointRuntime.layer.pipe(Layer.provide(ServerSecretStore.layer)), + ), ); const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( @@ -291,6 +299,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), 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/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..171e5cb4f82 --- /dev/null +++ b/apps/web/src/cloud/desktopAuth.test.ts @@ -0,0 +1,24 @@ +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" }]); + }); +}); diff --git a/apps/web/src/cloud/desktopAuth.ts b/apps/web/src/cloud/desktopAuth.ts new file mode 100644 index 00000000000..be7377be15e --- /dev/null +++ b/apps/web/src/cloud/desktopAuth.ts @@ -0,0 +1,116 @@ +export type DesktopCloudAuthOAuthStrategy = `oauth_${string}`; + +export interface DesktopCloudAuthOAuthOption { + readonly strategy: DesktopCloudAuthOAuthStrategy; + readonly label: string; +} + +interface ClerkOAuthProviderSetting { + readonly enabled?: unknown; + readonly authenticatable?: unknown; + readonly strategy?: unknown; + readonly name?: 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", +}; + +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) => ({ + strategy, + label: getDesktopCloudAuthOAuthStrategyLabel(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 { + strategy, + label: + typeof provider.name === "string" && provider.name.trim() + ? provider.name + : getDesktopCloudAuthOAuthStrategyLabel(strategy), + }; + }) + .filter((option): option is DesktopCloudAuthOAuthOption => option !== 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..25ed936a18d --- /dev/null +++ b/apps/web/src/cloud/desktopClerk.tsx @@ -0,0 +1,286 @@ +import { Clerk } from "@clerk/clerk-js"; +import { + buildClerkUIScriptAttributes, + clerkUIScriptUrl, + InternalClerkProvider, +} from "@clerk/react/internal"; +import type { ClerkProviderProps } from "@clerk/react"; +import React, { useEffect, useState } from "react"; + +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; + +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:" && + (url.hostname.endsWith(".clerk.accounts.dev") || url.hostname.endsWith(".clerk.accounts.com")); + +const headersToRecord = (headers: Headers): Record => { + const record: Record = {}; + headers.forEach((value, key) => { + record[key] = value; + }); + return record; +}; + +function installDesktopClerkFetchProxy(): void { + 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 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(); + + const hasKeyChanged = desktopClerk !== null && desktopClerk.publishableKey !== publishableKey; + if (hasKeyChanged) { + void clearStoredClientJwt(); + desktopClerk = null; + } + + if (desktopClerk !== null) { + return desktopClerk; + } + + const nextClerk = new Clerk(publishableKey); + if (!isNativeRequestClerk(nextClerk)) { + desktopClerk = nextClerk; + return nextClerk; + } + + const onBeforeRequest = + nextClerk.__internal_onBeforeRequest ?? nextClerk.__unstable__onBeforeRequest; + const onAfterResponse = + nextClerk.__internal_onAfterResponse ?? nextClerk.__unstable__onAfterResponse; + + 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/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..d6b2d98ebab --- /dev/null +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -0,0 +1,797 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayWebClientId } from "@t3tools/contracts/relay"; +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 { 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 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("../environments/primary", () => ({ + readPrimaryEnvironmentDescriptor: vi.fn(() => null), + readPrimaryEnvironmentTarget: vi.fn(() => null), + resolvePrimaryEnvironmentHttpUrl: vi.fn((path: string) => `http://127.0.0.1:3000${path}`), +})); + +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 requestBodyText(body: BodyInit | null | undefined): string { + return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); +} + +describe("web cloud link environment client", () => { + beforeEach(() => { + vi.restoreAllMocks(); + createProofMock.mockClear(); + vi.stubEnv("VITE_T3_RELAY_URL", "https://relay.example.test"); + getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer"); + 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("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(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", + }), + ); + 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", + }); + 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("revokes the primary cloud link before clearing local relay credentials", () => + 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 })) + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), + ); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + unlinkPrimaryEnvironmentFromCloud({ + clerkToken: "clerk-token", + }), + ); + + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "https://relay.example.test/v1/client/environment-links/env-1", + ); + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("DELETE"); + expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink"); + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + method: "POST", + credentials: "include", + }); + }), + ); + + 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({ error: "unavailable" }, { status: 503 })) + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), + ); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + unlinkPrimaryEnvironmentFromCloud({ + clerkToken: "clerk-token", + }), + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink"); + expect(fetchMock.mock.calls[1]?.[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..d4ab09c6918 --- /dev/null +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -0,0 +1,605 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { + EnvironmentCloudEndpointUnavailableError, + type EnvironmentCloudLinkStateResult, + EnvironmentHttpBadRequestError, + EnvironmentHttpConflictError, + EnvironmentHttpForbiddenError, + EnvironmentHttpInternalServerError, + EnvironmentHttpUnauthorizedError, + EnvironmentId, +} from "@t3tools/contracts"; +import { + RelayEnvironmentConnectScope, + type RelayEnvironmentLinkResponse, + RelayProtectedError, + type RelayClientEnvironmentRecord, + type RelayProtectedError as RelayProtectedErrorType, + type RelayManagedEndpointProviderKind, +} from "@t3tools/contracts/relay"; +import { + exchangeRemoteDpopAccessToken, + fetchRemoteEnvironmentDescriptor, + makeEnvironmentHttpApiClient, + ManagedRelayClient, + ManagedRelayDpopSigner, +} from "@t3tools/client-runtime"; + +import { ensureLocalApi } from "../localApi"; +import type { SavedEnvironmentRecord } from "../environments/runtime"; +import { + readPrimaryEnvironmentDescriptor, + readPrimaryEnvironmentTarget, + resolvePrimaryEnvironmentHttpUrl, +} from "../environments/primary"; + +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 normalizeRelayBaseUrl(import.meta.env.VITE_T3_RELAY_URL as string | undefined); +} + +export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +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, + }); +}; + +const withPrimaryEnvironmentCookies = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" })); + +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: "VITE_T3_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 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: "VITE_T3_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( + withPrimaryEnvironmentCookies, + Effect.mapError(environmentApiError("Could not read environment cloud link state.")), + ); + }); +} + +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 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 before local unlink.", { + cause, + }), + ), + ); + } + + const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + yield* client.cloud + .unlink({ headers: {} }) + .pipe( + withPrimaryEnvironmentCookies, + Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")), + ); + }); +} + +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: "VITE_T3_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 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 environmentClient = yield* makeEnvironmentHttpApiClient(input.environment.httpBaseUrl); + const proof = yield* environmentClient.cloud + .linkProof({ + headers: { authorization: `Bearer ${bearerToken}` }, + 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: { authorization: `Bearer ${bearerToken}` }, + 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: "VITE_T3_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 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 environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl); + 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( + withPrimaryEnvironmentCookies, + 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( + withPrimaryEnvironmentCookies, + 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..9bc3a69a3a2 --- /dev/null +++ b/apps/web/src/cloud/managedAuth.tsx @@ -0,0 +1,22 @@ +import { useAuth } from "@clerk/react"; +import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; +import { useEffect, type ReactNode } from "react"; + +let relayTokenProvider: (() => Promise) | null = null; + +export async function readManagedRelayClerkToken(): Promise { + return relayTokenProvider?.() ?? null; +} + +export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) { + const { getToken, isSignedIn } = useAuth(); + + useEffect(() => { + relayTokenProvider = isSignedIn ? () => getToken(RELAY_CLERK_TOKEN_OPTIONS) : null; + return () => { + relayTokenProvider = null; + }; + }, [getToken, isSignedIn]); + + 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/components/settings/CloudSettings.tsx b/apps/web/src/components/settings/CloudSettings.tsx new file mode 100644 index 00000000000..e5974319c10 --- /dev/null +++ b/apps/web/src/components/settings/CloudSettings.tsx @@ -0,0 +1,556 @@ +import { UserButton, Waitlist, useAuth, useClerk } from "@clerk/react"; +import { useSignIn, useSignUp } from "@clerk/react/legacy"; +import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import * as Effect from "effect/Effect"; +import { CloudIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + type DesktopCloudAuthOAuthStrategy, + resolveDesktopCloudAuthOAuthOptions, +} from "../../cloud/desktopAuth"; +import { + collectCloudLinkTargets, + connectManagedCloudEnvironment, + linkEnvironmentToCloud, + linkPrimaryEnvironmentToCloud, + listManagedCloudEnvironments, + readPrimaryCloudLinkState, + readPrimaryCloudLinkTarget, + type CloudLinkState, + unlinkPrimaryEnvironmentFromCloud, +} from "../../cloud/linkEnvironment"; +import { isElectron } from "../../env"; +import { usePrimaryEnvironmentId } from "../../environments/primary"; +import { + addManagedRelayEnvironment, + listSavedEnvironmentRecords, + useSavedEnvironmentRegistryStore, +} from "../../environments/runtime"; +import { webRuntime } from "../../lib/runtime"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +function hasClerkConfig(): boolean { + return Boolean(import.meta.env.VITE_CLERK_PUBLISHABLE_KEY); +} + +class CloudSettingsOperationError extends Error { + override readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "CloudSettingsOperationError"; + this.cause = cause; + } +} + +async function runCloudOperation(operation: () => Promise, message: string): Promise { + try { + return await operation(); + } catch (cause) { + throw new CloudSettingsOperationError(message, cause); + } +} + +function cloudErrorMessage(error: unknown, fallback: string): string { + if (error instanceof CloudSettingsOperationError) { + 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 CloudSettingsPanel() { + if (!hasClerkConfig()) { + return ( + + }> + + + + ); + } + + return ; +} + +function ConfiguredCloudSettingsPanel() { + const { isLoaded, isSignedIn } = useAuth(); + + if (!isLoaded) { + return null; + } + + return isSignedIn ? : ; +} + +function CloudWaitlistPanel() { + return ( + + + {isElectron ? ( +
+

Already approved? Sign in through the desktop app.

+ +
+ ) : null} +
+ ); +} + +function CloudSettingsPanelInner() { + const { getToken, userId } = useAuth(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const savedEnvironmentCount = useSavedEnvironmentRegistryStore( + (state) => Object.keys(state.byId).length, + ); + const [isLinking, setIsLinking] = useState(false); + const [isUnlinking, setIsUnlinking] = useState(false); + const [primaryLinkState, setPrimaryLinkState] = useState(null); + const [linkStateError, setLinkStateError] = useState(null); + const [managedEnvironments, setManagedEnvironments] = useState< + ReadonlyArray + >([]); + const [isLoadingManaged, setIsLoadingManaged] = useState(false); + const [connectingEnvironmentId, setConnectingEnvironmentId] = useState(null); + const linkableEnvironmentCount = collectCloudLinkTargets({ + primary: primaryEnvironmentId ? readPrimaryCloudLinkTarget() : null, + saved: listSavedEnvironmentRecords().filter((environment) => !environment.relayManaged), + }).length; + const linkedCloudUserId = primaryLinkState?.cloudUserId ?? null; + const hasCloudAccountMismatch = Boolean( + userId && linkedCloudUserId && linkedCloudUserId !== userId, + ); + + const refreshPrimaryLinkState = useCallback(() => { + if (!primaryEnvironmentId) { + setPrimaryLinkState(null); + setLinkStateError(null); + return; + } + void webRuntime.runPromise(readPrimaryCloudLinkState()).then( + (state) => { + setPrimaryLinkState(state); + setLinkStateError(null); + }, + (error: unknown) => { + setPrimaryLinkState(null); + setLinkStateError(cloudErrorMessage(error, "Could not read local cloud link state.")); + }, + ); + }, [primaryEnvironmentId]); + + useEffect(() => { + refreshPrimaryLinkState(); + }, [refreshPrimaryLinkState]); + + const refreshManagedEnvironments = useCallback(async () => { + setIsLoadingManaged(true); + try { + const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS); + if (!token) { + setManagedEnvironments([]); + return; + } + setManagedEnvironments( + await webRuntime.runPromise(listManagedCloudEnvironments({ clerkToken: token })), + ); + } catch (error) { + toastManager.add({ + type: "error", + title: "Cloud environments unavailable", + description: cloudErrorMessage(error, "Could not load linked environments."), + }); + } finally { + setIsLoadingManaged(false); + } + }, [getToken]); + + useEffect(() => { + void refreshManagedEnvironments(); + }, [refreshManagedEnvironments]); + + const linkEnvironments = async () => { + if (hasCloudAccountMismatch) { + toastManager.add({ + type: "error", + title: "Cloud account mismatch", + description: "This environment is linked to a different cloud account.", + }); + return; + } + setIsLinking(true); + try { + const token = await runCloudOperation( + () => getToken(RELAY_CLERK_TOKEN_OPTIONS), + "Could not get the current cloud session.", + ); + if (!token) { + return; + } + const primaryTarget = readPrimaryCloudLinkTarget(); + const savedEnvironments = listSavedEnvironmentRecords().filter( + (environment) => !environment.relayManaged, + ); + const savedEnvironmentIds = new Set(primaryTarget ? [primaryTarget.environmentId] : []); + if (primaryTarget) { + await runCloudOperation( + () => webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken: token })), + "Could not link the local environment.", + ); + } + await runCloudOperation( + () => + webRuntime.runPromise( + Effect.all( + savedEnvironments + .filter((environment) => { + if (savedEnvironmentIds.has(environment.environmentId)) { + return false; + } + savedEnvironmentIds.add(environment.environmentId); + return true; + }) + .map((environment) => linkEnvironmentToCloud({ environment, clerkToken: token })), + { concurrency: "unbounded" }, + ), + ), + "Could not link environments.", + ); + toastManager.add({ + type: "success", + title: "Environments linked", + description: "Relay notifications are enabled for linked environments.", + }); + refreshPrimaryLinkState(); + } catch (error) { + toastManager.add({ + type: "error", + title: "Cloud link failed", + description: cloudErrorMessage(error, "Could not link environments."), + }); + } finally { + setIsLinking(false); + } + }; + + const unlinkPrimaryEnvironment = async () => { + setIsUnlinking(true); + try { + const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS).catch(() => null); + await runCloudOperation( + () => webRuntime.runPromise(unlinkPrimaryEnvironmentFromCloud({ clerkToken: token })), + "Could not unlink the local environment.", + ); + refreshPrimaryLinkState(); + toastManager.add({ + type: "success", + title: "Environment unlinked", + description: "Local relay credentials and managed endpoint runtime config were removed.", + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Cloud unlink failed", + description: cloudErrorMessage(error, "Could not unlink the local environment."), + }); + } finally { + setIsUnlinking(false); + } + }; + + const connectManagedEnvironment = async (environment: RelayClientEnvironmentRecord) => { + setConnectingEnvironmentId(environment.environmentId); + try { + const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS); + if (!token) { + throw new CloudSettingsOperationError("Could not get the current cloud session."); + } + const connection = await webRuntime.runPromise( + connectManagedCloudEnvironment({ clerkToken: token, environment }), + ); + await addManagedRelayEnvironment(connection); + toastManager.add({ + type: "success", + title: "Environment connected", + description: `${connection.label} is available through its managed tunnel.`, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Managed connection failed", + description: cloudErrorMessage(error, "Could not connect to the cloud environment."), + }); + } finally { + setConnectingEnvironmentId(null); + } + }; + + return ( + + }> + } + /> + + {linkedCloudUserId ? ( + + ) : null} + + + } + /> + void refreshManagedEnvironments()} + > + Refresh + + } + /> + {managedEnvironments.map((environment) => ( + void connectManagedEnvironment(environment)} + > + {connectingEnvironmentId === environment.environmentId + ? "Connecting..." + : "Connect"} + + } + /> + ))} + + + ); +} + +function DesktopCloudSignInButton() { + 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 runCloudOperation( + () => signIn.reload({ rotatingTokenNonce }), + "Could not reload the desktop sign-in session.", + ); + sessionId = sessionId || signIn.createdSessionId; + + if (!sessionId && signIn.firstFactorVerification.status === "transferable") { + const signUpAttempt = await runCloudOperation( + () => signUp.create({ transfer: true }), + "Could not transfer the desktop sign-up session.", + ); + sessionId = signUpAttempt.createdSessionId; + } + + if (!sessionId) { + throw new CloudSettingsOperationError("Clerk did not create a desktop session."); + } + + await runCloudOperation( + () => setActive({ session: sessionId! }), + "Could not activate the desktop cloud session.", + ); + } catch (error) { + toastManager.add({ + type: "error", + title: "Cloud sign-in failed", + description: cloudErrorMessage(error, "Could not complete cloud sign-in."), + }); + } + }, + [setActive, signIn, signInLoaded, signUp, signUpLoaded], + ); + + useEffect(() => { + return () => { + clearCallbackListener(); + }; + }, [clearCallbackListener]); + + const startOAuth = 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 runCloudOperation( + () => window.desktopBridge?.createCloudAuthRequest() ?? Promise.resolve(undefined), + "Desktop auth callback is unavailable.", + ); + if (!redirectUrl) { + throw new CloudSettingsOperationError("Desktop auth callback is unavailable."); + } + + callbackCleanupRef.current = + window.desktopBridge?.onCloudAuthCallback((rawUrl) => { + clearCallbackListener(); + void completeOAuthCallback(rawUrl); + }) ?? null; + + const signInAttempt = await runCloudOperation( + () => signIn.create({ strategy, redirectUrl } as never), + "Could not create the desktop OAuth request.", + ); + const externalUrl = + signInAttempt.firstFactorVerification.externalVerificationRedirectURL?.toString(); + if (!externalUrl) { + throw new CloudSettingsOperationError( + "Clerk did not return an external OAuth redirect URL.", + ); + } + + const opened = await runCloudOperation( + () => window.desktopBridge?.openExternal(externalUrl) ?? Promise.resolve(false), + "Could not open the system browser.", + ); + if (!opened) { + throw new CloudSettingsOperationError("Could not open the system browser."); + } + } catch (error) { + clearCallbackListener(); + toastManager.add({ + type: "error", + title: "Cloud sign-in failed", + description: cloudErrorMessage(error, "Could not start cloud sign-in."), + }); + } finally { + setStartingStrategy(null); + } + }; + + const isStarting = startingStrategy !== null; + + if (oauthOptions.length === 0) { + return ( + + ); + } + + return ( +
+ {oauthOptions.map((option) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index f059354661e..0ccdf58512b 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -450,6 +450,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: diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index bec96063868..08afc9d8f63 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, @@ -26,6 +27,7 @@ export type SettingsSectionPath = | "/settings/keybindings" | "/settings/providers" | "/settings/source-control" + | "/settings/cloud" | "/settings/connections" | "/settings/archived"; @@ -38,6 +40,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { 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 }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; 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/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..33e2b0aa8c1 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) => ({ @@ -301,6 +304,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..abb885e3bb9 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -9,7 +9,17 @@ import { primaryEnvironmentHttpClientLive, } from "../environments/primary/httpClient"; -export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch)); +import { browserCryptoLayer } from "../cloud/dpop"; +import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer"; + +function configuredRelayUrl(): string { + const value = (import.meta.env.VITE_T3_RELAY_URL as string | undefined)?.trim(); + return value ? value.replace(/\/+$/g, "") : "http://relay.invalid"; +} + +const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch); + +export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer); const primaryHttpRuntime = ManagedRuntime.make( primaryEnvironmentHttpClientLive.pipe( @@ -37,3 +47,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..a665bc4d43a 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,8 @@ import "@xterm/xterm/css/xterm.css"; import "./index.css"; import { isElectron } from "./env"; +import { DesktopClerkProvider } from "./cloud/desktopClerk"; +import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; @@ -22,8 +25,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 ? ( + 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/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 98a125bdfe4..32587c0f187 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -48,13 +48,13 @@ 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. + Sign in to T3 Cloud to connect a linked environment through its managed tunnel, or + add a reachable backend manually.
-
diff --git a/apps/web/src/routes/settings.cloud.tsx b/apps/web/src/routes/settings.cloud.tsx new file mode 100644 index 00000000000..e91eb70e81c --- /dev/null +++ b/apps/web/src/routes/settings.cloud.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { CloudSettingsPanel } from "../components/settings/CloudSettings"; + +export const Route = createFileRoute("/settings/cloud")({ + component: CloudSettingsPanel, +}); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 59704466ac4..c00ade59121 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -7,6 +7,7 @@ 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 APP_VERSION: string; } diff --git a/docs/relay-observability.md b/docs/relay-observability.md new file mode 100644 index 00000000000..d6db81ba604 --- /dev/null +++ b/docs/relay-observability.md @@ -0,0 +1,40 @@ +# Relay observability + +The relay Alchemy stack owns a focused Axiom trace setup: + +- `t3-code-relay-traces`, an OpenTelemetry trace dataset for Worker requests +- `t3-code-relay-otel-ingest`, a dataset-scoped ingest token bound to the Worker +- `t3-code-relay-readonly-query`, a dataset-scoped token for scripted diagnostics +- `t3-code-relay-recent-spans`, a view of recent request and endpoint spans + +Deploy from `infra/relay` with the normal Alchemy workflow: + +```sh +bun 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'] +| 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/t3-cloud-clerk.md b/docs/t3-cloud-clerk.md new file mode 100644 index 00000000000..376688cf75f --- /dev/null +++ b/docs/t3-cloud-clerk.md @@ -0,0 +1,81 @@ +# 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 relay URL as the +audience. + +## Application Keys + +Use keys from the same Clerk instance in each location: + +| Consumer | Configuration | Value | +| ------------------------ | --------------------------------------- | ----------------------------------------------------- | +| Web and desktop renderer | `apps/web/.env` | `VITE_CLERK_PUBLISHABLE_KEY=` | +| Mobile build | `apps/mobile/.env` or build environment | `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=` | +| Relay deployment | Alchemy secret | `CLERK_SECRET_KEY=` | + +Never put `CLERK_SECRET_KEY` in a client application environment. + +## JWT Template + +In **Clerk Dashboard > JWT templates**, create a template with: + +| Setting | Value | +| ------- | ------------------------------------------------------ | +| Name | `t3-relay` | +| Claims | `{ "aud": "https://t3code-relay.ineededadomain.com" }` | + +The `aud` value must be the deployed relay public URL, with no trailing slash, and must match +`VITE_T3_RELAY_URL` and `T3_RELAY_URL`. If the relay domain changes, update all three values. + +## 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. + +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..67d01f2a75f --- /dev/null +++ b/docs/t3-code-cloud-auth-flow.html @@ -0,0 +1,1820 @@ + + + + + + T3 Code Cloud Control Plane and Managed Endpoint Flow + + + +
+
+
Draft architecture
+

T3 Code Cloud Control Plane and Managed Endpoint Flow

+

+ A concrete topology for one user with a MacBook Pro desktop environment, a headless Mac + mini environment, the T3 Code mobile app, and the hosted web UI. The control plane owns + identity, grants, provisioning, endpoint records, and connection tickets. The managed + endpoint carries both normal environment HTTP/WebSocket traffic and short signed + control-over-data-plane requests such as credential minting. +

+
+ Client -> Cloud: HTTPS + IdP JWT + Cloud -> Env: HTTPS + cloud-signed request + Env -> Cloud: env-signed response + Client -> Env: HTTPS/WSS + local T3 auth +
+
+ +
+
+

Security Invariant

+

+ A cloud user session is not environment authority. Remote access requires an authorized + IdP user, a linked environment authenticated by its registered cloud-link key, and a + one-time local bootstrap credential minted by the target T3 server. +

+
+ T3 Cloud must not proxy normal app traffic. It brokers identity, provisioning, grants, + endpoint records, and connect tickets. To connect a client, T3 Cloud sends a short-lived + signed mint request through the managed endpoint to the target local T3 server. After + token exchange, clients talk directly to + https://env_abc.tunnels.t3code.com and + wss://env_abc.tunnels.t3code.com/ws. +
+
+ +
+

Minimal Credential Set

+

1. User session
Hosted IdP session/JWT on clients.

+

+ 2. Cloud signing key
Private key controlled by T3 Cloud; public + issuer metadata installed into linked environments. +

+

+ 3. Environment cloud-link keypair
Private key stored in + Keychain/state dir; public key registered with T3 Cloud. +

+

+ 4. Local server authority
Local tokens carrying + relay:manage mint single-use bootstrap credentials. +

+

+ 5. Managed endpoint runtime credential
Endpoint runtime credential + used only to expose the environment's managed endpoint. +

+
+
+ +
+

Implementation Auth and Transport Matrix

+

+ This table is the contract to implement and validate against. The managed endpoint + transport forwards bytes; it does not authorize T3 sessions. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTransportAuth MaterialVerifierPurpose
Client -> T3 CloudHTTPSHosted IdP session/JWTT3 CloudList environments, manage grants, request connect.
T3 Cloud -> local T3 serverHTTPS through managed endpointCloud-signed mint or health request with nonce, audience, expiry, and scope.Local T3 serverAsk the linked environment to mint a one-time bootstrap credential.
Local T3 server -> T3 Cloud + HTTPS response through managed endpoint, or direct HTTPS cloud API for status. + Environment cloud-link signature.T3 CloudProve the real linked environment responded or reported status.
Client -> local T3 token exchangeHTTPS through managed endpointSingle-use bootstrap credential minted by the local T3 server.Local T3 serverMint a scoped proof-bound environment access token.
Client -> local T3 WebSocket ticket APIHTTPS through managed endpointScoped environment access token plus DPoP proof.Local T3 serverMint a short-lived ticket for one WebSocket connection.
Client -> local T3 WebSocketWSS through managed endpointwsTicket query parameter.Local T3 serverRun the existing app RPC protocol.
Endpoint runtime -> endpoint providerProvider-specific outbound tunnel connectionCloudflare tunnel token today; T3 relay connector token later.Cloudflare Tunnel or T3 RelayExpose the loopback T3 server as a stable public HTTPS/WSS endpoint.
APNs -> mobile appAPNs push transportApple push token and APNs provider auth.Apple APNs and mobile app.Wake or update mobile UI with minimal status, not full agent traffic.
+
+ Do not add an app-session authorization dependency to Cloudflare Tunnel or T3 Relay. The + endpoint provider may authenticate the connector, but local T3 auth remains the only + verifier for bootstrap credentials, scoped access tokens, WebSocket tickets, and commands. + The endpoint runtime credential authenticates the connector to the endpoint provider; it + is not a client-facing access gate for the public hostname. +
+
+ +
+
+

Overall Actor Diagram

+ Service-level auth/control/data lanes +
+
+ +
+ +
+
+ User path + The hosted IdP authenticates the human. Desktop, mobile, and hosted web present IdP + sessions to T3 Cloud for account-scoped API calls. +
+
+ Control plane + T3 Cloud stores links, grants, endpoint records, audit events, and short-lived connect + tickets. It reaches environments through signed requests over their managed endpoints, + not through a persistent per-environment control channel. +
+
+ Data plane + Cloudflare Tunnel is the first managed endpoint transport. Later T3 Relay keeps the same + httpBaseUrl/wsBaseUrl client contract. +
+
+ Local authority + The loopback T3 server remains the execution boundary and mints the one-time credential + required before remote clients can open a normal session. +
+
+
+ +
+

Credential Ownership

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CredentialStored ByPresented ToWhat It ProvesImplementation Rule
Hosted IdP session/JWTDesktop renderer, mobile app, hosted web browser.T3 Cloud control plane.The human user and account identity.Never treat this as local environment authority.
Cloud signing keyT3 Cloud secret storage or signing service.Linked local T3 servers.The request came from T3 Cloud and was authorized by cloud policy.Use short expiries, nonce/jti replay protection, audience, and scoped claims.
Environment cloud-link private keyLocal T3 server on MacBook or Mac mini.T3 Cloud.The response/status came from the linked environment. + Private key never leaves the environment; public key is registered in T3 Cloud. +
Managed endpoint runtime credentialLocal environment sidecar/runtime.Cloudflare Tunnel today; T3 Relay later.The connector may expose this loopback origin through the endpoint provider. + Do not use this as T3 app authorization; rotate on unlink or suspected compromise. +
Single-use bootstrap credentialMinted by local T3 server; returned to the requesting client via T3 Cloud.Local T3 server over managed endpoint.The client completed cloud authorization and environment minting.Single use, short TTL, bound to environment and cloud request nonce.
Environment access tokenRemote client after token exchange.Local T3 server over managed endpoint.The client has the granted environment scopes and proof key.Issued and revoked only by the local T3 server.
WebSocket ticketRemote client, briefly.Local T3 WebSocket endpoint.The WebSocket was opened by a client with a valid scoped access token.Short-lived, one connection, verified by existing T3 WebSocket auth.
APNs tokenMobile app and T3 Cloud.Apple APNs.User/device notification and Live Activity delivery target. + Account-scoped token. Environment links and preferences decide which environment + updates fan out to that user's devices. Never include full agent traffic or secrets + in push payloads. +
+
+ +
+

How To Read The Sequence Diagrams

+

+ Each participant name includes where it runs. Arrows are concrete service boundaries: + hosted IdP auth, local app-to-environment calls, T3 Cloud API calls, signed + control-over-data-plane requests, managed endpoint traffic, or APNs delivery. +

+
+ The hosted IdP never calls into a MacBook or Mac mini. An IdP arrow back to a client means + the client-side auth SDK or hosted auth page finished sign-in and the client now has an + IdP session/JWT. +
+
+ +
+

Endpoint Contracts

+

+ Names are draft, but these contracts are the behavioral boundary. All timestamps are ISO + strings. All nonces and token ids must be replay-protected until their expiry window has + passed. +

+

Cloud API: Request Remote Connect

+
POST /v1/environments/:environmentId/connect
+Authorization: Bearer <idp-jwt>
+DPoP: <client-proof-jwt>
+
+Request body
+{
+  "clientKeyThumbprint": "jkt_..."
+}
+
+Response 200
+{
+  "environmentId": "env_mbp",
+  "httpBaseUrl": "https://env_mbp.tunnels.t3code.com/",
+  "wsBaseUrl": "wss://env_mbp.tunnels.t3code.com/ws",
+  "credential": "one_time_bootstrap_...",
+  "expiresAt": "2026-05-25T12:00:30.000Z"
+}
+ +

Cloud -> Environment: Mint Credential

+
POST https://env_mbp.tunnels.t3code.com/api/t3-cloud/mint-credential
+Authorization: T3Cloud <cloud-signed-jwt>
+Content-Type: application/json
+
+Cloud-signed claims
+{
+  "iss": "https://cloud.t3code.com",
+  "aud": "t3-env:env_mbp",
+  "sub": "user_123",
+  "deviceId": "device_456",
+  "cnf": { "jkt": "jkt_..." },
+  "scope": ["environment:connect"],
+  "nonce": "nonce_...",
+  "jti": "mint_request_...",
+  "exp": 1780000030
+}
+
+Environment-signed response 200
+{
+  "environmentId": "env_mbp",
+  "nonce": "nonce_...",
+  "credential": "one_time_bootstrap_...",
+  "expiresAt": "2026-05-25T12:00:30.000Z",
+  "signature": "env_signature_..."
+}
+ +

Client -> Environment: Exchange Proof-Bound Bootstrap Grant

+
POST https://env_mbp.tunnels.t3code.com/oauth/token
+DPoP: <client-proof-jwt-for-token-url>
+Content-Type: application/x-www-form-urlencoded
+
+grant_type=urn:ietf:params:oauth:grant-type:token-exchange
+&subject_token=one_time_bootstrap_...
+&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%20orchestration:operate%20terminal:operate%20review:write
+
+Response 200
+{
+  "access_token": "env_access_...",
+  "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"
+}
+
+ The client generates an ephemeral proof key before requesting connect. T3 Cloud includes + the key thumbprint in the signed mint request, and the local T3 server binds the one-time + bootstrap credential to that thumbprint. A cloud service that can see the minted + credential still cannot redeem it without the client's private proof key. +
+ +

Client -> Environment: Existing WebSocket Flow

+
POST https://env_mbp.tunnels.t3code.com/api/auth/websocket-ticket
+Authorization: DPoP <env_access_token>
+DPoP: <client-proof-jwt-for-ticket-url>
+
+Response 200
+{ "ticket": "ws_ticket_...", "expiresAt": "2026-05-25T12:01:00.000Z" }
+
+GET wss://env_mbp.tunnels.t3code.com/ws?wsTicket=ws_ticket_...
+ +

Environment -> Cloud: Status For Notifications

+
POST /v1/environments/:environmentId/status
+Authorization: T3Environment <env-signed-jwt-or-dpop-proof>
+
+{
+  "environmentId": "env_mbp",
+  "status": "online",
+  "summary": {
+    "activeThreadId": "thread_123",
+    "state": "agent_running"
+  },
+  "issuedAt": "2026-05-25T12:00:00.000Z",
+  "nonce": "nonce_..."
+}
+
+ These contracts preserve the current app protocol. Only the cloud connect flow and the + local /api/t3-cloud/mint-credential endpoint are new. Bootstrap grants, + scoped access tokens, and WebSocket tickets stay owned by the local T3 server. +
+
+ +
+
+

Flow 1: Link MacBook Desktop Environment

+
+sequenceDiagram
+  actor User as User on MacBook
+  participant Desktop as Desktop app on MacBook
+  participant IdP as Hosted IdP
+  participant Env as T3 server env_mbp on MacBook
+  participant Cloud as T3 Cloud control plane
+  participant Endpoint as Managed endpoint platform
+
+  Note over Desktop,Env: Both run on the MacBook. The IdP never calls into the MacBook directly.
+  User->>Desktop: Click Link cloud account
+  Desktop->>IdP: Official hosted IdP sign-in flow
+  IdP-->>Desktop: IdP session and JWT
+  Desktop->>Env: Local owner-authorized link request with IdP JWT
+  Env->>Env: Get or create cloud-link keypair
+  Note right of Env: Private key stays local.
+  Env->>Cloud: Link env_mbp with IdP JWT + descriptor + public key
+  Cloud->>Cloud: Verify IdP user and store env grant/link record
+  Cloud->>Endpoint: Create managed endpoint for env_mbp
+  Endpoint-->>Cloud: Endpoint URLs and local runtime config
+  Cloud-->>Env: Link accepted with endpoint config
+  Env->>Env: Persist cloud link, cloud issuer metadata, and endpoint runtime config
+  Env->>Endpoint: Maintain managed endpoint to loopback origin
+  Cloud->>Endpoint: Optional signed health check through managed endpoint
+  Endpoint->>Env: Forward health check to loopback origin
+  Env-->>Endpoint: Signed environment health response
+  Endpoint-->>Cloud: Environment health response
+  Env-->>Desktop: Linked account and endpoint status
+          
+
+ Flow 1 has three service boundaries: the hosted IdP proves the human user, the local T3 + environment proves local machine authority, and T3 Cloud creates the cloud link plus + managed endpoint. Health and credential-mint requests use the endpoint instead of a + persistent environment control channel. +
+
+ +
+

Flow 2: Link Headless Mac Mini

+
+sequenceDiagram
+  actor Admin as User in browser
+  participant IdP as Hosted IdP
+  participant CLI as t3 cloud login on Mac mini
+  participant Env as T3 server env_mini on Mac mini
+  participant Cloud as T3 Cloud control plane
+  participant Endpoint as Managed endpoint platform
+
+  CLI->>Env: Local owner-authorized headless link request
+  Env->>Env: Get or create cloud-link keypair
+  Env-->>CLI: Environment descriptor and public cloud-link key
+  Note right of Env: Private key stays local.
+  CLI->>Cloud: Start device-link request with descriptor and public key
+  Cloud-->>CLI: Browser URL and short code
+  CLI-->>Admin: Print URL and code
+  Admin->>IdP: Open URL and sign in
+  IdP-->>Admin: IdP session and JWT
+  Admin->>Cloud: Approve device-link request with IdP JWT and code
+  Cloud->>Cloud: Verify IdP user and store env grant/link record
+  CLI->>Cloud: Poll until device-link request is approved
+  Cloud->>Endpoint: Create managed endpoint for env_mini
+  Endpoint-->>Cloud: Endpoint URLs and local runtime config
+  Cloud-->>CLI: Link accepted with endpoint config
+  CLI->>Env: Install endpoint config, cloud issuer metadata, and enable cloud link
+  Env->>Env: Persist cloud link and endpoint runtime config
+  Env->>Endpoint: Maintain managed endpoint to loopback origin
+  Cloud->>Endpoint: Optional signed health check through managed endpoint
+  Endpoint->>Env: Forward health check to loopback origin
+  Env-->>Endpoint: Signed environment health response
+  Endpoint-->>Cloud: Environment health response
+          
+
+ This is the headless equivalent of desktop setup. The end state is identical: + env_mini has a local cloud-link key, a managed endpoint connection, and a + local endpoint that can verify signed cloud requests. The browser only approves the + login; it never talks to the Mac mini directly. +
+
+ +
+

Flow 3: Remote Connect Bootstrap

+
+sequenceDiagram
+  participant Client as Mobile or hosted web client
+  participant Cloud as T3 Cloud control plane
+  participant Endpoint as Managed endpoint data plane
+  participant Env as Target T3 server on loopback
+
+  Client->>Client: Generate ephemeral proof key
+  Client->>Cloud: POST /v1/environments/:id/connect with IdP JWT + DPoP proof
+  Cloud->>Cloud: Verify IdP user, grant, endpoint record, and connect policy
+  Cloud->>Cloud: Create short-lived signed mint request bound to client key thumbprint
+  Cloud->>Endpoint: POST /api/t3-cloud/mint-credential with signed request
+  Endpoint->>Env: Forward signed mint request to loopback origin
+  Env->>Env: Verify cloud signature, audience, expiry, nonce, link state, and key binding
+  Env-->>Endpoint: Single-use proof-bound bootstrap credential and signed response
+  Endpoint-->>Cloud: Single-use bootstrap credential and signed response
+  Cloud->>Cloud: Verify environment signature and consume connect ticket
+  Cloud-->>Client: httpBaseUrl, wsBaseUrl, one-time credential
+  Client->>Endpoint: POST /oauth/token with credential, scopes, and DPoP proof
+  Endpoint->>Env: Forward request to loopback origin
+  Env->>Env: Verify proof matches credential key binding
+  Env-->>Endpoint: Scoped DPoP access token response
+  Endpoint-->>Client: Scoped DPoP access token response
+  Client->>Endpoint: POST /api/auth/websocket-ticket using DPoP access token
+  Endpoint-->>Client: Short-lived wsTicket
+  Client->>Endpoint: Open WebSocket with wsTicket
+          
+
+ T3 Cloud authorizes the account, but the environment still decides whether to mint the + credential. The request reaches the local server through the managed endpoint, so the + first implementation can infer reachability at connect time instead of maintaining a + separate cloud-side presence service. +
+
+ +
+

Flow 4: Mobile and Hosted Web Discover Environments

+
+sequenceDiagram
+  participant Client as Mobile app or hosted web UI
+  participant IdP as Hosted IdP
+  participant Cloud as T3 Cloud control plane
+  participant Endpoint as Managed endpoint data plane
+  participant Env as Target T3 server
+
+  Client->>IdP: Official hosted IdP sign-in flow
+  IdP-->>Client: IdP session and JWT
+  Client->>Cloud: GET /v1/environments with IdP JWT
+  Cloud->>Cloud: Verify user and lookup grants and endpoint records
+  Cloud-->>Client: Environment list and endpoint records
+  Client->>Cloud: POST /v1/environments/:id/connect with DPoP proof
+  Cloud->>Endpoint: Signed proof-bound mint request over managed endpoint
+  Endpoint->>Env: Forward signed mint request to loopback server
+  Env-->>Endpoint: Single-use proof-bound bootstrap credential
+  Endpoint-->>Cloud: Single-use bootstrap credential
+  Cloud-->>Client: Endpoint URLs and one-time credential
+  Client->>Endpoint: Exchange grant for scoped DPoP token and open WebSocket with ticket
+  Endpoint->>Env: Forward app protocol to loopback server
+          
+
+ Discovery stays cloud-mediated. Normal session traffic is direct to the managed endpoint + after connect, not streamed through T3 Cloud. +
+
+
+ +
+
+

Agent Traffic Over Managed Endpoint

+ App protocol stays HTTP + WebSocket +
+
+sequenceDiagram
+  participant Client as Mobile or hosted web client
+  participant Cloud as T3 Cloud control plane
+  participant Endpoint as Cloudflare Tunnel or T3 Relay edge
+  participant Env as T3 server loopback origin
+
+  Client->>Cloud: Connect request with IdP JWT + DPoP proof
+  Cloud->>Endpoint: Signed proof-bound mint request over managed endpoint
+  Endpoint->>Env: Forward signed mint request to loopback origin
+  Env-->>Endpoint: One-time proof-bound bootstrap credential
+  Endpoint-->>Cloud: One-time bootstrap credential
+  Cloud-->>Client: Endpoint URLs and one-time bootstrap credential
+  Client->>Endpoint: HTTPS token exchange with requested scopes and DPoP proof
+  Endpoint->>Env: Forward request over managed endpoint to loopback origin
+  Env-->>Endpoint: Scoped DPoP access token response
+  Endpoint-->>Client: Scoped DPoP access token established
+  Client->>Endpoint: WebSocket RPC stream
+  Endpoint->>Env: Forward WebSocket frames to existing T3 protocol
+  Env-->>Endpoint: Agent chunks, status, command results
+  Endpoint-->>Client: Agent chunks, status, command results
+        
+
+
+ Cloud before traffic + The control plane authorizes the user, checks grants, creates a short-lived signed mint + request, and sends it through the managed endpoint to the target environment. +
+
+ Data plane during traffic + Normal agent chunks and command RPC use the existing environment HTTP/WebSocket protocol + through the managed endpoint. T3 Cloud is not in this hot path. +
+
+ Environment remains the boundary + The local T3 server authenticates the bootstrap credential, mints WebSocket tickets, and + can reject commands based on local policy or approval state. +
+
+
+ +
+
+

Push Notifications and Live Activities

+ Minimal payloads, control-plane state +
+
+sequenceDiagram
+  participant Mobile as T3 mobile app
+  participant IdP as Hosted IdP
+  participant Cloud as T3 Cloud control plane
+  participant APNs as Apple APNs
+  participant Env as Local T3 server
+
+  Mobile->>IdP: Sign in with hosted IdP
+  IdP-->>Mobile: IdP session and JWT
+  Mobile->>Cloud: Register APNs and Live Activity tokens with IdP JWT
+  Cloud->>Cloud: Store token per user/device with agent-awareness preferences
+  Env->>Cloud: Publish minimal status via cloud API or webhook with cloud-link signature
+  Cloud->>Cloud: Select linked users for the publishing environment
+  Cloud->>Cloud: Store latest summary and notification state
+  Cloud->>APNs: Send minimal notification or Live Activity payload
+  APNs-->>Mobile: Deliver push or Live Activity update
+  Mobile->>Cloud: App foregrounds and requests connect
+  Cloud-->>Mobile: Endpoint URLs and bootstrap credential
+        
+
+ Push payloads should be summaries: environment id, thread id, status, counters, and a + short title if product policy allows. Full agent chunks are fetched from the environment + over the managed endpoint after authentication. +
+
+ +
+
+

Hosted Web UI Monitoring

+
+sequenceDiagram
+  participant Browser as Browser on other device
+  participant IdP as Hosted IdP
+  participant Cloud as T3 Cloud control plane
+  participant Endpoint as Managed endpoint data plane
+  participant Env as env_mbp or env_mini
+
+  Browser->>IdP: Open app.t3.codes and sign in
+  IdP-->>Browser: IdP session and JWT
+  Browser->>Cloud: GET linked environments with IdP JWT
+  Cloud-->>Browser: env_mbp and env_mini metadata
+  Browser->>Cloud: Connect to selected environment
+  Cloud->>Endpoint: Signed mint request over managed endpoint
+  Endpoint->>Env: Forward signed mint request to loopback local server
+  Env-->>Endpoint: One-time bootstrap credential
+  Endpoint-->>Cloud: One-time bootstrap credential
+  Cloud-->>Browser: Endpoint URLs and one-time credential
+  Browser->>Endpoint: Exchange bootstrap grant for scoped DPoP access token
+  Endpoint->>Env: Forward to loopback local server
+  Browser->>Endpoint: Open WebSocket RPC stream
+  Env-->>Endpoint: Live chunks and status
+  Endpoint-->>Browser: Live chunks and status
+  opt User starts an agent remotely
+    Browser->>Endpoint: Command request over existing WebSocket
+    Env->>Env: Local policy check
+    Env-->>Endpoint: Accept or reject
+    Endpoint-->>Browser: Accept or reject
+  end
+          
+
+ +
+

Account Mismatch Rule

+
+
+ Compare two identities + The environment link state says env_mbp is linked to User A. The renderer + IdP browser session may currently be User B. +
+
+ Match + If the renderer user and environment link match, show normal cloud management: refresh + endpoint, unlink, rotate, and configure. +
+
+ Mismatch + If they differ, show linked-as User A and signed-in-as User B. Allow sign out, sign in + as the linked user, or an explicit unlink/switch flow. +
+
+ Never allowed + Do not silently replace the linked account, treat the renderer cookie as environment + authority, or issue connect credentials unless that cloud user is explicitly granted. +
+
+
+ The displayed cloud account for an environment comes from the environment link state, + not from whatever IdP session currently exists in the renderer. +
+
+
+ +
+

End-to-End Setup Checklist For This Use Case

+
+
+ 1. MacBook Pro desktop + Start desktop app. User signs in with the hosted IdP. env_mbp registers its + descriptor and cloud-link public key, receives a managed endpoint, enables the managed + endpoint runtime, and accepts signed cloud mint requests through that endpoint. +
+
+ 2. Mac mini headless server + Run t3 cloud login. Complete device/browser login as the same T3 user or + org. env_mini registers its own cloud-link key, receives its own managed + endpoint, enables the managed endpoint runtime, and accepts signed cloud mint requests + through that endpoint. +
+
+ 3. MacBook remote environment entry + Add Mac mini as a remote environment using existing direct auth such as SSH, pairing, or + private network. This is for direct local/private management; the cloud link is a + separate managed endpoint record. +
+
+ 4. Mobile app + Sign into the T3 Code mobile app with the hosted IdP. Register APNs and Live Activity + tokens. The app lists env_mbp and env_mini, asks T3 Cloud to + connect, then opens HTTP/WebSocket traffic directly against the selected endpoint. +
+
+ 5. Hosted web + Sign into app.t3.codes. It lists the same linked environments and exchanges + for a direct managed-endpoint access token the same way mobile does. +
+
+
+ +
+
+

Managed Endpoint Boundary

+ Cloudflare Tunnel is a transport choice, not the product architecture +
+
+
+ Stable client contract + T3 Cloud returns httpBaseUrl, wsBaseUrl, and a one-time + credential. Clients do not know whether the endpoint is backed by Cloudflare Tunnel or a + future T3 relay. +
+
+ Cloudflare Tunnel today + Provision one tunnel per environment, configure ingress to + http://127.0.0.1:<serverPort>, create the public hostname, and give + the linked environment only the endpoint credential it needs to expose that endpoint. T3 + Cloud reaches the environment through signed HTTPS requests to that endpoint. +
+
+ Request-time reachability + V1 does not maintain authoritative online presence for each environment. The control + plane infers reachability when it attempts a signed health or credential-mint request + over the managed endpoint, then returns a concrete connect result or a timeout/failure. +
+
+ T3 Relay later + Replace the endpoint runtime with t3-relayd connect ... and keep the same + managed endpoint, control-plane, and local credential semantics. The relay can add + purpose-built stateful behavior later without changing client bootstrap semantics. +
+
+
+ +
+

Implementation Validation Checklist

+
+
+ Cloud user auth is isolated + A valid IdP JWT can list cloud environments and request connect, but cannot call local + T3 app APIs directly without a local bootstrap credential. +
+
+ Cloud-signed mint requests are strict + The local T3 server rejects mint requests with the wrong issuer, audience, environment + id, scope, expiry, nonce, replayed jti, or client key binding. +
+
+ Environment responses are authenticated + T3 Cloud rejects credential and health responses that are missing the environment + cloud-link signature or are signed by a key other than the registered environment key. +
+
+ Endpoint runtime auth is not app auth + Stealing or rotating the Cloudflare tunnel token or future relay connector token does + not create a valid T3 access token, WebSocket ticket, or environment-signed response. +
+
+ Bootstrap credentials are one-shot + The local T3 server rejects expired, replayed, wrong-environment, or wrong-nonce + bootstrap credentials, and rejects proof-bound credentials unless the bootstrap request + proves possession of the bound client key. +
+
+ Existing WebSocket protocol stays unchanged + After token exchange, remote clients use scoped access tokens and the + wsTicket flow used for remote environments. +
+
+ T3 Cloud is not in the hot path + Agent chunks, command RPC, terminal traffic, and session WebSocket frames flow from + client to managed endpoint to local T3 server, not through T3 Cloud. +
+
+ Provider swap is observable only in endpoint metadata + Replacing Cloudflare Tunnel with T3 Relay changes endpoint runtime provisioning and + provider refs, but not client token exchange, scoped authorization, or WebSocket + behavior. +
+
+
+ +
+
+

V1 Infrastructure Primitives

+ Concrete stack for the first managed endpoint implementation +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConcernV1 PrimitiveSource Of Truth?Implementation Notes
Control plane runtimeCloudflare WorkersNo, stateless runtime. + Hosts T3 Cloud APIs, verifies IdP JWTs, provisions endpoint records, signs mint + requests, receives status updates, and enqueues async work. +
Hosted user identityHosted IdP, v1 implementation: ClerkNo, external identity authority. + T3 Cloud verifies IdP JWTs and consumes IdP webhooks. Local T3 servers do not trust + hosted IdP sessions directly. +
Relational databasePlanetScale Postgres via Cloudflare HyperdriveYes. + Stores accounts, users, devices, environments, grants, cloud links, endpoint + records, public environment keys, connect requests, notification targets, status + snapshots, and audit events. +
Database connection layerCloudflare HyperdriveNo, connection/pooling layer. + Workers connect to PlanetScale Postgres through Hyperdrive. Hyperdrive is never used + as application state. +
Cloud secretsCloudflare Worker secret bindingsNo, secret source for Worker runtime. + Use for IdP secrets, IdP webhook secret, Cloudflare API token, cloud mint signing + private key, APNs provider credentials, and optional app encryption key. Do not use + Secrets Store for v1 unless multiple independently deployed Workers need shared + account-level secrets. +
Environment private keysLocal Keychain or local state directoryYes, for environment identity. + Environment cloud-link private keys remain local only. T3 Cloud stores only the + public key in PlanetScale. +
Managed endpoint data planeCloudflare TunnelNo, transport provider. + First ManagedEndpointProvider. T3 Cloud provisions tunnels, DNS + records, endpoint metadata, and one-time runtime config for the local environment. +
Future data planeT3 RelayNo, transport provider. + Implements the same provider interface and client contract: + httpBaseUrl, wsBaseUrl, and local T3 auth. +
Async jobsCloudflare Queues with dead-letter queuesNo, delivery mechanism. + Use for endpoint provisioning, endpoint reconciliation, IdP webhook processing, + unlink/rotate workflows, audit event fanout, and APNs delivery. +
Push deliveryCloudflare Queues consumer -> Apple APNsNo, delivery mechanism. + Queue APNs jobs after status updates. Payloads are minimal and must not include full + agent chunks, access tokens, bootstrap credentials, or WebSocket tickets. +
Scheduled reconciliationCloudflare Cron TriggersNo, scheduler. + Expire connect requests, reconcile pending tunnel provisioning, expire stale health + observations, audit dangling tunnel/DNS records, and clean old link codes. +
Blob/object storageCloudflare R2, optionalNo for core auth/control state. + Use only for diagnostics, exports, large redacted logs, or archived webhook + payloads. PlanetScale remains the source of truth. +
CoordinationDatabase constraints, idempotency keys, queuesPlanetScale is authoritative. + Use explicit idempotency keys for link, provisioning, connect, notification, and + rotation workflows. Keep the database as the durable coordination point for v1. +
ObservabilityWorkers Observability, Analytics Engine, PlanetScale audit rowsPlanetScale for durable audit events. + Use Analytics Engine for high-cardinality operational/product metrics. Keep durable + audit history in PlanetScale, with optional R2 exports. +
+
+ Worker secret bindings are enough for the initial cloud secrets, and + control-over-data-plane requests avoid persistent environment control sockets. +
+
+ +
+

Design Anchors

+

+ These references are the standards this shape should align with. They are not product + requirements by themselves, but they describe the secure patterns we should avoid + reinventing badly. +

+
    +
  • + RFC 8252: OAuth 2.0 for native + apps. Use browser-based user auth instead of collecting credentials inside the app. +
  • +
  • + RFC 8628: Device authorization + grant. Good fit for headless t3 cloud login. +
  • +
  • + RFC 9449: DPoP / + proof-of-possession. Useful model for making stolen bearer tokens insufficient without + the environment key. +
  • +
+
+
+ + + diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts new file mode 100644 index 00000000000..d80e3483bb8 --- /dev/null +++ b/infra/relay/alchemy.run.ts @@ -0,0 +1,38 @@ +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 * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; + +import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; +import Api from "./src/worker.ts"; + +export default Alchemy.Stack( + "T3CodeRelay", + { + // @effect-diagnostics-next-line anyUnknownInErrorContext:off layerMergeAllWithDependencies:off - Alchemy provider helpers expose framework-owned any requirements. + providers: Layer.mergeAll( + Axiom.providers(), + Cloudflare.providers(), + Drizzle.providers(), + Planetscale.providers(), + FetchHttpClient.layer, + ), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const db = yield* PlanetscaleDatabase; + const hyperdrive = yield* RelayHyperdrive; + const api = yield* Api; + + return { + databaseName: db.database.name, + hyperdriveName: hyperdrive.name, + workerName: api.workerName, + url: api.url, + }; + }), +); 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/package.json b/infra/relay/package.json new file mode 100644 index 00000000000..15ff6a5c92b --- /dev/null +++ b/infra/relay/package.json @@ -0,0 +1,32 @@ +{ + "name": "t3code-relay", + "private": true, + "type": "module", + "scripts": { + "deploy": "alchemy deploy", + "destroy": "alchemy destroy", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clerk/backend": "^3.4.14", + "@distilled.cloud/cloudflare": "^0.21.5", + "@effect/sql-pg": "catalog:", + "@t3tools/client-runtime": "workspace:*", + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "alchemy": "2.0.0-beta.45", + "drizzle-orm": "^1.0.0-rc.1", + "effect": "catalog:" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260520.0", + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "@types/pg": "^8.15.6", + "drizzle-kit": "^1.0.0-rc.1", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts new file mode 100644 index 00000000000..4cd54e86d8a --- /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 apnsDeliveryJobSigningSecret: Redacted.Redacted; + readonly cloudMintPrivateKey: Redacted.Redacted; + readonly cloudMintPublicKey: string; + readonly managedEndpointBaseDomain: string | undefined; + readonly cloudflareAccountId: string | undefined; + readonly cloudflareZoneId: string | undefined; + readonly cloudflareApiToken: Redacted.Redacted | undefined; +} + +export class RelayConfiguration extends Context.Service< + RelayConfiguration, + RelayConfigurationShape +>()("RelayConfiguration") {} diff --git a/infra/relay/src/RelayCrypto.ts b/infra/relay/src/RelayCrypto.ts new file mode 100644 index 00000000000..a0c26735978 --- /dev/null +++ b/infra/relay/src/RelayCrypto.ts @@ -0,0 +1,16 @@ +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export const layer = 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)); + }), + }), +); diff --git a/infra/relay/src/agentActivityPayloads.ts b/infra/relay/src/agentActivityPayloads.ts new file mode 100644 index 00000000000..ed3fc3f0116 --- /dev/null +++ b/infra/relay/src/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/api.test.ts b/infra/relay/src/api.test.ts new file mode 100644 index 00000000000..626f5ade9b7 --- /dev/null +++ b/infra/relay/src/api.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "@effect/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 { + isDpopAuthorizationHeader, + relayCors, + relayCorsPreflightHeaders, + relayEnvironmentAuthLayer, + relayNotFoundRoute, + traceRelayHttpRequestWith, + withoutCapturedParentSpan, +} from "./api.ts"; +import * as EnvironmentCredentials from "./persistence/EnvironmentCredentials.ts"; + +function splitHeaderTokens(value: string): ReadonlyArray { + return value.split(",").map((token) => token.trim()); +} + +describe("relay CORS", () => { + it("allows Effect trace propagation headers from browser clients", () => { + expect(splitHeaderTokens(relayCorsPreflightHeaders["access-control-allow-headers"])).toEqual([ + "authorization", + "b3", + "traceparent", + "content-type", + "dpop", + ]); + }); +}); + +describe("relay DPoP authentication", () => { + it("requires the HTTP DPoP authorization scheme", () => { + expect(isDpopAuthorizationHeader("DPoP access-token")).toBe(true); + expect(isDpopAuthorizationHeader("dpop access-token")).toBe(true); + expect(isDpopAuthorizationHeader("Bearer access-token")).toBe(false); + expect(isDpopAuthorizationHeader("access-token")).toBe(false); + }); +}); + +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.bearer(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.fn("relay.test.endpoint")(() => + Effect.succeed(HttpServerResponse.empty({ status: 204 })), + )().pipe(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("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/api.ts b/infra/relay/src/api.ts new file mode 100644 index 00000000000..08d4d331da3 --- /dev/null +++ b/infra/relay/src/api.ts @@ -0,0 +1,1016 @@ +import { 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 HttpPlatform from "effect/unstable/http/HttpPlatform"; +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 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 { verifyAndConsumeDpopProof } from "./dpop.ts"; +import * as DeliveryAttempts from "./persistence/DeliveryAttempts.ts"; +import * as AgentActivityRows from "./persistence/AgentActivityRows.ts"; +import * as Devices from "./persistence/Devices.ts"; +import * as DpopProofs from "./persistence/DpopProofs.ts"; +import * as EnvironmentCredentials from "./persistence/EnvironmentCredentials.ts"; +import * as EnvironmentLinks from "./persistence/EnvironmentLinks.ts"; +import * as LiveActivities from "./persistence/LiveActivities.ts"; +import * as RelayConfiguration from "./Config.ts"; +import * as AgentActivityPublisher from "./services/AgentActivityPublisher.ts"; +import * as EnvironmentConnector from "./services/EnvironmentConnector.ts"; +import * as EnvironmentLinker from "./services/EnvironmentLinker.ts"; +import * as EnvironmentPublishSignatures from "./services/EnvironmentPublishSignatures.ts"; +import * as MobileRegistrations from "./services/MobileRegistrations.ts"; +import { withSpanAttributes, withUserId } from "./telemetry.ts"; +import { RelayDb } from "./db.ts"; +import { + issueDpopAccessToken, + issueLinkChallengeToken, + resolveDpopAccessTokenScopes, + verifyDpopAccessToken, +} from "./relayTokens.ts"; + +export const RelayHttpPlatformLayer = 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 relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; +const relayCorsAllowedHeaders = [ + "authorization", + "b3", + "traceparent", + "content-type", + "dpop", +] as const; +const relayCorsExposedHeaders = ["x-t3-relay-auth-failure", "www-authenticate"] as const; + +const relayCorsHeaders = { + "access-control-allow-origin": "*", + "access-control-expose-headers": relayCorsExposedHeaders.join(","), +} as const; + +export 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, + ), +); + +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 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(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 { + bearer: Effect.fn("relay.auth.client.bearer")(function* (httpEffect, { credential }) { + const token = Redacted.value(credential); + const verified = yield* verifyClerkBearerToken(config, token).pipe( + Effect.tapError((error) => + Effect.logWarning("relay clerk token verification failed", { + reason: clerkVerificationFailureReason(error.cause), + }), + ), + Effect.catch(() => relayAuthInvalidError("invalid_bearer")), + ); + if (!verified.sub) { + return yield* relayAuthInvalidError("invalid_bearer"); + } + yield* Effect.annotateCurrentSpan({ + "relay.auth.mode": "clerk_bearer", + "relay.auth.subject": verified.sub, + }); + return yield* httpEffect.pipe( + withUserId(verified.sub), + Effect.provideService(RelayClientPrincipal, { + userId: verified.sub, + token, + }), + ); + }), + }; + }), +); + +export const relayEnvironmentAuthLayer = Layer.effect( + RelayEnvironmentAuth, + Effect.gen(function* () { + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + return { + bearer: Effect.fn("relay.auth.environment.bearer")(function* (httpEffect, { credential }) { + const token = Redacted.value(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 config = yield* RelayConfiguration.RelayConfiguration; + 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"); + } + // Effect beta.73 exposes arbitrary HTTP schemes but currently leaves + // the separating spaces in the decoded credential. + const token = Redacted.value(credential).trimStart(); + const now = yield* DateTime.now; + const verified = yield* verifyDpopAccessToken({ + config, + 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( + withUserId(verified.sub), + Effect.provideService(RelayClientPrincipal, { + userId: verified.sub, + token, + proofKeyThumbprint: verified.cnf.jkt, + dpopScopes: verified.scope, + }), + ); + }), + }; + }), +); + +export function isDpopAuthorizationHeader(value: string | undefined): boolean { + return /^DPoP +/iu.test(value ?? ""); +} + +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"] as const, + token_endpoint_auth_methods_supported: ["none"] as const, + dpop_signing_alg_values_supported: ["ES256"] as const, + 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"] as const, + }), + ); + }), +); + +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`) + .pipe(Effect.withSpan("relay.api.health.db_probe")); + 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 linker = yield* EnvironmentLinker.EnvironmentLinker; + const links = yield* EnvironmentLinks.EnvironmentLinks; + const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + 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( + "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* issueLinkChallengeToken({ + config, + 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; + 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; + return handlers.handle( + "exchangeDpopAccessToken", + Effect.fn("relay.api.token.exchangeDpopAccessToken")(function* (args) { + yield* appendRelayCredentialResponseHeaders; + const issuer = normalizeRelayIssuer(config.relayIssuer); + const requestedScopes = 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) { + return yield* relayAuthInvalidError("invalid_bearer"); + } + return yield* Effect.gen(function* () { + 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* issueDpopAccessToken({ + config, + 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), + }; + }).pipe(withUserId(verified.sub)); + }, 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"), +); + +type RelayCommonPersistenceError = + | Devices.DeviceRegistrationPersistenceError + | Devices.DeviceUnregistrationPersistenceError + | LiveActivities.LiveActivityRegistrationPersistenceError + | EnvironmentLinks.EnvironmentLinkUserListPersistenceError + | EnvironmentLinks.EnvironmentPublicKeyListPersistenceError + | EnvironmentLinks.EnvironmentLinkListPersistenceError + | EnvironmentLinks.EnvironmentLinkLookupPersistenceError + | EnvironmentLinks.EnvironmentLinkRevokePersistenceError + | EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError + | EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError + | DpopProofs.DpopProofReplayPersistenceError + | LiveActivities.LiveActivityTargetListPersistenceError + | AgentActivityRows.AgentActivityRowUpsertPersistenceError + | AgentActivityRows.AgentActivityRowDeletePersistenceError + | AgentActivityRows.AgentActivityRowListPersistenceError + | LiveActivities.LiveActivityDeliveryMarkPersistenceError + | DeliveryAttempts.DeliveryAttemptRecordPersistenceError; + +type MapRelayCommonApiError = + | Exclude + | (Extract extends never ? never : RelayAuthInvalidError) + | (Extract extends never ? never : RelayInternalError); + +function isRelayCommonPersistenceError(error: unknown): error is RelayCommonPersistenceError { + return ( + error instanceof Devices.DeviceRegistrationPersistenceError || + error instanceof Devices.DeviceUnregistrationPersistenceError || + error instanceof LiveActivities.LiveActivityRegistrationPersistenceError || + error instanceof EnvironmentLinks.EnvironmentLinkUserListPersistenceError || + error instanceof EnvironmentLinks.EnvironmentPublicKeyListPersistenceError || + error instanceof EnvironmentLinks.EnvironmentLinkListPersistenceError || + error instanceof EnvironmentLinks.EnvironmentLinkLookupPersistenceError || + error instanceof EnvironmentLinks.EnvironmentLinkRevokePersistenceError || + error instanceof EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError || + error instanceof EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError || + error instanceof DpopProofs.DpopProofReplayPersistenceError || + error instanceof LiveActivities.LiveActivityTargetListPersistenceError || + error instanceof AgentActivityRows.AgentActivityRowUpsertPersistenceError || + error instanceof AgentActivityRows.AgentActivityRowDeletePersistenceError || + error instanceof AgentActivityRows.AgentActivityRowListPersistenceError || + error instanceof LiveActivities.LiveActivityDeliveryMarkPersistenceError || + error instanceof DeliveryAttempts.DeliveryAttemptRecordPersistenceError + ); +} + +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.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 (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 verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationShape, token: string) { + return Effect.tryPromise({ + try: () => + verifyToken(token, { + secretKey: Redacted.value(config.clerkSecretKey), + audience: normalizeRelayIssuer(config.relayIssuer), + }), + catch: (cause) => new ClerkTokenVerificationFailed({ cause }), + }); +} + +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({}); + } + return yield* verifyAndConsumeDpopProof({ + 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({}); + } + return yield* verifyAndConsumeDpopProof({ + 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/apns.test.ts b/infra/relay/src/apns.test.ts new file mode 100644 index 00000000000..cb885cebe1b --- /dev/null +++ b/infra/relay/src/apns.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; + +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; +import { makeLiveActivityRequest, makePushNotificationRequest } from "./apns.ts"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +describe("makeLiveActivityRequest", () => { + 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("requests an update push token when remotely starting a Live Activity", () => { + const request = 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", + }, + }, + }); + }); + + it("builds a low-priority update payload", () => { + const request = 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", + }, + }, + }); + }); + + it("builds an end payload with a dismissal date", () => { + const request = 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, + }, + }); + }); + + it("builds a standard APNs alert payload with routing metadata", () => { + const request = 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", + }); + }); +}); diff --git a/infra/relay/src/apns.ts b/infra/relay/src/apns.ts new file mode 100644 index 00000000000..3d1063e623f --- /dev/null +++ b/infra/relay/src/apns.ts @@ -0,0 +1,274 @@ +import * as NodeCrypto from "node:crypto"; + +import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +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"; + +export interface ApnsLiveActivityRequest { + readonly token: string; + readonly event: ApnsLiveActivityEvent; + readonly priority: "5" | "10"; + readonly payload: unknown; +} + +export 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 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), + }), + ), +); + +function makeApnsJwt(input: { + readonly teamId: ApnsCredentials["teamId"]; + readonly keyId: ApnsCredentials["keyId"]; + readonly privateKey: ApnsCredentials["privateKey"]; + readonly issuedAtUnixSeconds: number; +}): Effect.Effect { + return Effect.try({ + try: () => { + const privateKey = Redacted.value(input.privateKey); + const header = Encoding.encodeBase64Url(JSON.stringify({ alg: "ES256", kid: input.keyId })); + const payload = Encoding.encodeBase64Url( + JSON.stringify({ iss: input.teamId, iat: input.issuedAtUnixSeconds }), + ); + const signingInput = `${header}.${payload}`; + 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 }), + }); +} + +function contentState(state: RelayAgentActivityAggregateState) { + return { + name: LIVE_ACTIVITY_NAME, + props: JSON.stringify(state), + }; +} + +interface LiveActivityRequestBase { + readonly token: string; + readonly nowEpochSeconds: number; + readonly nowIso: string; +} + +export type MakeLiveActivityRequestInput = + | (LiveActivityRequestBase & { + readonly event: "end"; + readonly state: RelayAgentActivityAggregateState | null; + }) + | (LiveActivityRequestBase & { + readonly event: "start" | "update"; + readonly state: RelayAgentActivityAggregateState; + }); + +export 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, + }, + }, + }; +} + +export 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 const sendLiveActivityRequest = Effect.fn("relay.apns.send_live_activity_request")( + function* (input: { + readonly credentials: ApnsCredentials; + readonly request: ApnsLiveActivityRequest; + readonly issuedAtUnixSeconds: number; + }) { + 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 httpClient = yield* HttpClient.HttpClient; + 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")), + }; + }, +); + +export const sendPushNotificationRequest = Effect.fn("relay.apns.send_push_notification_request")( + function* (input: { + readonly credentials: ApnsCredentials; + readonly request: ApnsPushNotificationRequest; + readonly issuedAtUnixSeconds: number; + }) { + 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 httpClient = yield* HttpClient.HttpClient; + 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")), + }; + }, +); diff --git a/infra/relay/src/apnsDeliveryJobs.test.ts b/infra/relay/src/apnsDeliveryJobs.test.ts new file mode 100644 index 00000000000..428dc3a82b6 --- /dev/null +++ b/infra/relay/src/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/apnsDeliveryJobs.ts b/infra/relay/src/apnsDeliveryJobs.ts new file mode 100644 index 00000000000..0de28197e0b --- /dev/null +++ b/infra/relay/src/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/db.test.ts b/infra/relay/src/db.test.ts new file mode 100644 index 00000000000..5a708d5479a --- /dev/null +++ b/infra/relay/src/db.test.ts @@ -0,0 +1,182 @@ +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; +import * as NodePath from "@effect/platform-node/NodePath"; +import { describe, expect, it } from "@effect/vitest"; +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 Path from "effect/Path"; + +import { relayPostgresDatabaseRegion } from "./db.ts"; +import { parseJsonString } from "./persistence/json.ts"; + +const migrationsDir = "migrations/postgres"; +const schemaFile = "src/schema.ts"; +const NodeTestServices = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer); + +class DbMigrationTestError extends Data.TaggedError("DbMigrationTestError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +interface DrizzleKitPostgresApi { + readonly generateDrizzleJson: ( + imports: Record, + prevId?: string, + schemaFilters?: ReadonlyArray, + ) => Promise; + readonly generateMigration: (prev: unknown, cur: unknown) => Promise>; +} + +const readMigrationDirectories = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(migrationsDir); + const directories = yield* Effect.all( + entries.map((entry) => + fs + .stat(path.join(migrationsDir, entry)) + .pipe(Effect.map((stat) => (stat.type === "Directory" ? entry : null))), + ), + ); + return directories.filter((entry): entry is string => entry !== null).sort(); +}); + +const readMigrationSqlFiles = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directories = yield* readMigrationDirectories; + const files = yield* Effect.all( + directories.map((directory) => + Effect.gen(function* () { + const migrationPath = path.join(migrationsDir, directory, "migration.sql"); + const exists = yield* fs.exists(migrationPath); + if (!exists) return null; + const sql = yield* fs.readFileString(migrationPath); + return { directory, sql }; + }), + ), + ); + return files.filter( + (file): file is { readonly directory: string; readonly sql: string } => file !== null, + ); +}); + +const readLatestMigrationSnapshot = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directories = yield* readMigrationDirectories; + const directoriesWithSnapshots = yield* Effect.all( + directories.map((directory) => + fs + .exists(path.join(migrationsDir, directory, "snapshot.json")) + .pipe(Effect.map((exists) => (exists ? directory : null))), + ), + ); + const latestDirectory = directoriesWithSnapshots.findLast( + (directory): directory is string => directory !== null, + ); + if (!latestDirectory) { + return null; + } + const snapshotPath = path.join(migrationsDir, latestDirectory, "snapshot.json"); + const exists = yield* fs.exists(snapshotPath); + if (!exists) { + return { + directory: latestDirectory, + snapshot: null, + }; + } + const snapshot = yield* parseJsonString(yield* fs.readFileString(snapshotPath)); + return { + directory: latestDirectory, + snapshot, + }; +}); + +const loadDrizzleKit = Effect.tryPromise({ + try: () => import("drizzle-kit/api-postgres") as Promise, + catch: (cause) => + new DbMigrationTestError({ + message: "failed to load drizzle-kit postgres api", + cause, + }), +}); + +const loadRelaySchema = Effect.gen(function* () { + const path = yield* Path.Path; + return yield* Effect.tryPromise({ + try: () => import(path.resolve(schemaFile)) as Promise>, + catch: (cause) => + new DbMigrationTestError({ + message: "failed to load relay schema", + cause, + }), + }); +}); + +describe("relay database migrations", () => { + it("pins the PlanetScale database near west coast Worker traffic", () => { + expect(relayPostgresDatabaseRegion).toEqual({ slug: "us-west" }); + }); + + it.effect("does not leave empty generated migration directories behind", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directories = yield* readMigrationDirectories; + const emptyDirectories = yield* Effect.all( + directories.map((directory) => + fs + .readDirectory(path.join(migrationsDir, directory)) + .pipe(Effect.map((entries) => (entries.length === 0 ? directory : null))), + ), + ).pipe(Effect.map((entries) => entries.filter((entry): entry is string => entry !== null))); + + expect(emptyDirectories).toEqual([]); + }).pipe(Effect.provide(NodeTestServices)), + ); + + it.effect("starts from a single baseline migration", () => + Effect.gen(function* () { + const migrations = yield* readMigrationSqlFiles; + + expect(migrations).toHaveLength(1); + expect(migrations[0]?.directory).toContain("baseline"); + expect(migrations[0]?.sql).toContain('CREATE TABLE "relay_environment_credentials"'); + expect(migrations[0]?.sql).toContain('"environment_public_key" text NOT NULL'); + }).pipe(Effect.provide(NodeTestServices)), + ); + + it.effect( + "keeps the latest migration snapshot aligned with the relay schema", + () => + Effect.gen(function* () { + const latest = yield* readLatestMigrationSnapshot; + expect(latest).not.toBeNull(); + expect(latest?.snapshot, `${latest?.directory} is missing snapshot.json`).not.toBeNull(); + + const kit = yield* loadDrizzleKit; + const relaySchema = yield* loadRelaySchema; + const currentSnapshot = yield* Effect.tryPromise({ + try: () => kit.generateDrizzleJson(relaySchema), + catch: (cause) => + new DbMigrationTestError({ + message: "failed to generate current drizzle snapshot", + cause, + }), + }); + const statements = yield* Effect.tryPromise({ + try: () => kit.generateMigration(latest?.snapshot, currentSnapshot), + catch: (cause) => + new DbMigrationTestError({ + message: "failed to diff relay schema", + cause, + }), + }); + + expect(statements).toEqual([]); + }).pipe(Effect.provide(NodeTestServices)), + 20_000, + ); +}); diff --git a/infra/relay/src/db.ts b/infra/relay/src/db.ts new file mode 100644 index 00000000000..d1a2bc912e1 --- /dev/null +++ b/infra/relay/src/db.ts @@ -0,0 +1,50 @@ +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 type { EffectPgDatabase } from "drizzle-orm/effect-postgres"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +export interface RelayDatabase extends EffectPgDatabase { + readonly $client: PgClient; +} + +export class RelayDb extends Context.Service()("RelayDb") {} + +export const relayPostgresDatabaseRegion = { slug: "us-west" } as const; + +export const RelaySchema = Drizzle.Schema("RelaySchema", { + schema: "./src/schema.ts", + out: "./migrations/postgres", + dialect: "postgres", +}); + +export const PlanetscaleDatabase = Effect.gen(function* () { + const schema = yield* RelaySchema; + const database = yield* Planetscale.PostgresDatabase("RelayPostgresDatabase", { + region: relayPostgresDatabaseRegion, + clusterSize: "PS_5", + migrationsDir: schema.out, + migrationsTable: "relay_migrations", + replicas: 0, // BUMP BEFORE GOING TO PROD + }); + + const runtimeRole = yield* Planetscale.PostgresRole("RelayPostgresRuntimeRole", { + database, + inheritedRoles: ["pg_read_all_data", "pg_write_all_data"], + }); + + return { database, runtimeRole, schema }; +}); + +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/dpop.test.ts b/infra/relay/src/dpop.test.ts new file mode 100644 index 00000000000..81b4cc8cbf0 --- /dev/null +++ b/infra/relay/src/dpop.test.ts @@ -0,0 +1,203 @@ +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 { verifyAndConsumeDpopProof } from "./dpop.ts"; +import * as DpopProofs from "./persistence/DpopProofs.ts"; + +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), + }; +} + +describe("verifyAndConsumeDpopProof", () => { + 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", + }); + const consumed = new Set(); + const dpopProofs: DpopProofs.DpopProofReplayShape = { + consume: (input) => + Effect.sync(() => { + const key = `${input.thumbprint}:${input.jti}`; + if (consumed.has(key)) { + return false; + } + consumed.add(key); + return true; + }), + pruneExpired: Effect.void, + }; + + return Effect.gen(function* () { + const first = yield* verifyAndConsumeDpopProof({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + expectedThumbprint: proof.thumbprint, + now, + }); + const replay = yield* Effect.exit( + verifyAndConsumeDpopProof({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + expectedThumbprint: proof.thumbprint, + now, + }), + ); + + expect(first).toBe(proof.thumbprint); + expect(replay._tag).toBe("Failure"); + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + }); + + 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", + }); + const dpopProofs: DpopProofs.DpopProofReplayShape = { + consume: () => Effect.succeed(true), + pruneExpired: Effect.void, + }; + + return Effect.gen(function* () { + const result = yield* Effect.exit( + verifyAndConsumeDpopProof({ + 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.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + }); + + 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 failure = new DpopProofs.DpopProofReplayPersistenceError({ + cause: "database unavailable", + }); + const dpopProofs: DpopProofs.DpopProofReplayShape = { + consume: () => Effect.fail(failure), + pruneExpired: Effect.void, + }; + + return Effect.gen(function* () { + const error = yield* Effect.flip( + verifyAndConsumeDpopProof({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/connect", + expectedThumbprint: proof.thumbprint, + now, + }), + ); + + expect(error).toBe(failure); + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + }); + + it.effect("accepts unbound DPoP proofs when they are 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", + }); + const consumed = new Set(); + const dpopProofs: DpopProofs.DpopProofReplayShape = { + consume: (input) => + Effect.sync(() => { + const key = `${input.thumbprint}:${input.jti}`; + if (consumed.has(key)) { + return false; + } + consumed.add(key); + return true; + }), + pruneExpired: Effect.void, + }; + + return Effect.gen(function* () { + const thumbprint = yield* verifyAndConsumeDpopProof({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.com/v1/environments/env/status", + expectedAccessToken: "clerk-access-token", + now, + }); + const replay = yield* Effect.exit( + verifyAndConsumeDpopProof({ + 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(replay._tag).toBe("Failure"); + }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + }); +}); diff --git a/infra/relay/src/dpop.ts b/infra/relay/src/dpop.ts new file mode 100644 index 00000000000..e34f51740bc --- /dev/null +++ b/infra/relay/src/dpop.ts @@ -0,0 +1,61 @@ +import { verifyDpopProof } from "@t3tools/shared/dpop"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; + +import * as DpopProofs from "./persistence/DpopProofs.ts"; + +export const verifyAndConsumeDpopProof = Effect.fn("relay.dpop.verify_and_consume")( + function* (input: { + readonly proof: string | undefined; + readonly method: string; + readonly url: string; + readonly expectedThumbprint?: string; + readonly expectedAccessToken?: string; + readonly now: DateTime.DateTime; + }) { + const dpopProofs = yield* DpopProofs.DpopProofReplay; + 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* dpopProofs.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; + }, +); diff --git a/infra/relay/src/infra/ManagedEndpointStackConfig.test.ts b/infra/relay/src/infra/ManagedEndpointStackConfig.test.ts new file mode 100644 index 00000000000..675b82216a8 --- /dev/null +++ b/infra/relay/src/infra/ManagedEndpointStackConfig.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { + MANAGED_ENDPOINT_PROVISIONER_TOKEN_POLICIES, + MANAGED_ENDPOINT_ZONE, +} from "./ManagedEndpointStackConfig.ts"; + +describe("ManagedEndpointStackConfig", () => { + it("restricts endpoint provisioning to the relay account and DNS zone", () => { + expect(MANAGED_ENDPOINT_PROVISIONER_TOKEN_POLICIES).toEqual([ + { + effect: "allow", + permissionGroups: ["Cloudflare Tunnel Read", "Cloudflare Tunnel Write"], + resources: { + [`com.cloudflare.api.account.${MANAGED_ENDPOINT_ZONE.accountId}`]: "*", + }, + }, + { + effect: "allow", + permissionGroups: ["DNS Read", "DNS Write"], + resources: { + [`com.cloudflare.api.account.zone.${MANAGED_ENDPOINT_ZONE.zoneId}`]: "*", + }, + }, + ]); + }); +}); diff --git a/infra/relay/src/infra/ManagedEndpointStackConfig.ts b/infra/relay/src/infra/ManagedEndpointStackConfig.ts new file mode 100644 index 00000000000..28fb23ced5f --- /dev/null +++ b/infra/relay/src/infra/ManagedEndpointStackConfig.ts @@ -0,0 +1,26 @@ +export const MANAGED_ENDPOINT_ZONE = { + name: "ineededadomain.com", + zoneId: "fcea40a6915723b0f5c4a9480eb3507b", + accountId: "1468bbd99811cdaccfbb707dc725421a", +} as const; + +export const RELAY_PUBLIC_DOMAIN = `t3code-relay.${MANAGED_ENDPOINT_ZONE.name}`; +export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`; +export const MANAGED_ENDPOINT_BASE_DOMAIN = MANAGED_ENDPOINT_ZONE.name; + +export const MANAGED_ENDPOINT_PROVISIONER_TOKEN_POLICIES = [ + { + effect: "allow" as const, + permissionGroups: ["Cloudflare Tunnel Read" as const, "Cloudflare Tunnel Write" as const], + resources: { + [`com.cloudflare.api.account.${MANAGED_ENDPOINT_ZONE.accountId}`]: "*", + }, + }, + { + effect: "allow" as const, + permissionGroups: ["DNS Read" as const, "DNS Write" as const], + resources: { + [`com.cloudflare.api.account.zone.${MANAGED_ENDPOINT_ZONE.zoneId}`]: "*", + }, + }, +]; diff --git a/infra/relay/src/infra/RelayObservability.test.ts b/infra/relay/src/infra/RelayObservability.test.ts new file mode 100644 index 00000000000..7a7b6a6809b --- /dev/null +++ b/infra/relay/src/infra/RelayObservability.test.ts @@ -0,0 +1,82 @@ +import * as Alchemy from "alchemy"; +import * as Axiom from "alchemy/Axiom"; +import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vitest"; + +import { + RELAY_AXIOM_TRACE_DATASET, + provisionRelayObservability, + relayAxiomIngestDatasetCapabilities, + relayAxiomQueryDatasetCapabilities, + relayRecentSpansQuery, + relayTraceQuery, +} from "./RelayObservability.ts"; + +describe("RelayObservability", () => { + it("scopes the ingest token only to HTTP span ingestion", () => { + expect(relayAxiomIngestDatasetCapabilities()).toEqual({ + [RELAY_AXIOM_TRACE_DATASET]: { ingest: ["create"] }, + }); + }); + + it("scopes the diagnostics query token only to HTTP spans", () => { + expect(relayAxiomQueryDatasetCapabilities()).toEqual({ + [RELAY_AXIOM_TRACE_DATASET]: { query: ["read"] }, + }); + }); + + it("builds APL queries for the trace dataset", () => { + expect(relayTraceQuery("| where name == 'GET /health'", "relay-traces-test")).toBe( + "['relay-traces-test']\n| where name == 'GET /health'", + ); + }); + + it("projects Effect HTTP span attributes through their OTLP field names", () => { + const query = relayRecentSpansQuery("relay-traces-test"); + + expect(query).toContain("['relay-traces-test']"); + expect(query).toContain("attributes.http.request.method"); + expect(query).toContain("attributes.http.response.status_code"); + expect(query).toContain("attributes.url.path"); + expect(query).toContain("attributes.http.route"); + expect(query).toContain("customAttributes = column_ifexists('attributes.custom', dynamic({}))"); + expect(query).toContain("customAttributes['user.id']"); + expect(query).not.toContain("['http.request.method']"); + }); + + it("orders token and view resources behind the trace dataset", async () => { + const stack = { + name: "RelayObservabilityTest", + stage: "test", + resources: {}, + bindings: {}, + actions: {}, + }; + + await Effect.runPromise( + provisionRelayObservability.pipe( + Effect.provideService(Alchemy.Stack, stack), + Effect.provideService(Axiom.Providers, { + kind: "ProviderCollection", + get: () => undefined, + }), + ), + ); + + const resources = stack.resources as Record; + const traces = resources.RelayTracesDataset; + + expect(traces).toBeDefined(); + for (const logicalId of [ + "RelayAxiomIngestToken", + "RelayAxiomQueryToken", + "RelayRecentSpansView", + ]) { + expect(resources[logicalId]).toBeDefined(); + expect(Object.keys(Output.resolveUpstream(resources[logicalId]!.Props))).toContain( + traces!.FQN, + ); + } + }); +}); diff --git a/infra/relay/src/infra/RelayObservability.ts b/infra/relay/src/infra/RelayObservability.ts new file mode 100644 index 00000000000..7cf943ee07d --- /dev/null +++ b/infra/relay/src/infra/RelayObservability.ts @@ -0,0 +1,58 @@ +import * as Axiom from "alchemy/Axiom"; +import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; + +export const RELAY_OBSERVABILITY_SERVICE_NAME = "t3-code-relay-worker"; +export const RELAY_OBSERVABILITY_EXPORT_INTERVAL = "1 second"; +export const RELAY_AXIOM_TRACE_DATASET = "t3-code-relay-traces"; + +export const relayTraceQuery = (query: string, dataset: string = RELAY_AXIOM_TRACE_DATASET) => + `['${dataset}']\n${query}`; + +export const relayRecentSpansQuery = (dataset: string = RELAY_AXIOM_TRACE_DATASET) => + relayTraceQuery( + "| where isnotnull(span_id) or isnotnull(trace_id)\n| 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({}))\n| extend userId = customAttributes['user.id']\n| project _time, name, trace_id, span_id, duration, requestMethod, path, statusCode, endpoint, userId\n| order by _time desc\n| limit 200", + dataset, + ); + +export const relayAxiomIngestDatasetCapabilities = ( + dataset: string = RELAY_AXIOM_TRACE_DATASET, +) => ({ + [dataset]: { ingest: ["create" as const] }, +}); + +export const relayAxiomQueryDatasetCapabilities = ( + dataset: string = RELAY_AXIOM_TRACE_DATASET, +) => ({ + [dataset]: { query: ["read" as const] }, +}); + +export const provisionRelayObservability = Effect.gen(function* () { + const traces = yield* Axiom.Dataset("RelayTracesDataset", { + name: RELAY_AXIOM_TRACE_DATASET, + kind: "otel:traces:v1", + description: "T3 Code relay Worker HTTP request spans.", + retentionDays: 30, + useRetentionPeriod: true, + }); + + const ingestToken = yield* Axiom.ApiToken("RelayAxiomIngestToken", { + name: "t3-code-relay-otel-ingest", + description: "Owned by Alchemy. Scoped OTLP ingest token for relay HTTP spans.", + datasetCapabilities: Output.map(traces.name, relayAxiomIngestDatasetCapabilities), + }); + const queryToken = yield* Axiom.ApiToken("RelayAxiomQueryToken", { + name: "t3-code-relay-readonly-query", + description: "Owned by Alchemy. Read-only query token for relay HTTP span diagnostics.", + datasetCapabilities: Output.map(traces.name, relayAxiomQueryDatasetCapabilities), + }); + + yield* Axiom.View("RelayRecentSpansView", { + name: "t3-code-relay-recent-spans", + description: "Recent relay HTTP request spans.", + datasets: [traces.name], + aplQuery: Output.map(traces.name, relayRecentSpansQuery), + }); + + return { traces, ingestToken, queryToken } as const; +}); diff --git a/infra/relay/src/persistence/AgentActivityRows.ts b/infra/relay/src/persistence/AgentActivityRows.ts new file mode 100644 index 00000000000..1f59c21e8b3 --- /dev/null +++ b/infra/relay/src/persistence/AgentActivityRows.ts @@ -0,0 +1,155 @@ +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 * 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 "../schema.ts"; +import { parseJsonString, stringifyJsonValue } from "./json.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()( + "AgentActivityRows", +) {} + +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* parseJsonString( + yield* encodeRelayAgentActivityStateJson(input.state), + ); + 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) { + yield* Effect.annotateCurrentSpan({ "user.id": input.userId }); + 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) => stringifyJsonValue(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/persistence/DeliveryAttempts.test.ts b/infra/relay/src/persistence/DeliveryAttempts.test.ts new file mode 100644 index 00000000000..baeb974cf8c --- /dev/null +++ b/infra/relay/src/persistence/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 "../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/persistence/DeliveryAttempts.ts b/infra/relay/src/persistence/DeliveryAttempts.ts new file mode 100644 index 00000000000..4ab4e3d21fb --- /dev/null +++ b/infra/relay/src/persistence/DeliveryAttempts.ts @@ -0,0 +1,205 @@ +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 "../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()( + "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 } : {}), + ...(input.userId ? { "user.id": input.userId } : {}), + }); + 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 } : {}), + ...(input.userId ? { "user.id": input.userId } : {}), + }); + 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/persistence/Devices.test.ts b/infra/relay/src/persistence/Devices.test.ts new file mode 100644 index 00000000000..55aed597302 --- /dev/null +++ b/infra/relay/src/persistence/Devices.test.ts @@ -0,0 +1,160 @@ +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 "../schema.ts"; +import * as Devices from "./Devices.ts"; + +const registration: RelayDeviceRegistrationRequest = { + deviceId: "device-1" as RelayDeviceRegistrationRequest["deviceId"], + 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))))); + }); +}); diff --git a/infra/relay/src/persistence/Devices.ts b/infra/relay/src/persistence/Devices.ts new file mode 100644 index 00000000000..a012fa60df2 --- /dev/null +++ b/infra/relay/src/persistence/Devices.ts @@ -0,0 +1,134 @@ +import type { 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 "../schema.ts"; + +export class DeviceRegistrationPersistenceError extends Data.TaggedError( + "DeviceRegistrationPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export class DeviceUnregistrationPersistenceError extends Data.TaggedError( + "DeviceUnregistrationPersistenceError", +)<{ + 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; +} + +export class Devices extends Context.Service()("Devices") {} + +const make = Effect.gen(function* () { + const db = yield* RelayDb; + + return Devices.of({ + register: Effect.fn("relay.devices.register")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "user.id": input.userId, + "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, + 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, + 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({ + "user.id": input.userId, + "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 })), + ), + }); +}); + +export const layer = Layer.effect(Devices, make); diff --git a/infra/relay/src/persistence/DpopProofs.test.ts b/infra/relay/src/persistence/DpopProofs.test.ts new file mode 100644 index 00000000000..33aa67b3fe3 --- /dev/null +++ b/infra/relay/src/persistence/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 "../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/persistence/DpopProofs.ts b/infra/relay/src/persistence/DpopProofs.ts new file mode 100644 index 00000000000..a0e296c5703 --- /dev/null +++ b/infra/relay/src/persistence/DpopProofs.ts @@ -0,0 +1,68 @@ +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 { lt } from "drizzle-orm"; + +import { RelayDb } from "../db.ts"; +import { relayDpopProofs } from "../schema.ts"; + +export class DpopProofReplayPersistenceError extends Data.TaggedError( + "DpopProofReplayPersistenceError", +)<{ + readonly cause: unknown; +}> {} + +export interface DpopProofReplayShape { + 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()( + "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 pruneExpired: DpopProofReplayShape["pruneExpired"] = Effect.fn( + "relay.dpop_proofs.prune_expired", + )(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.mapError((cause) => new DpopProofReplayPersistenceError({ cause }))); + + return DpopProofReplay.of({ + consume, + pruneExpired, + }); +}); + +export const layer = Layer.effect(DpopProofReplay, make); diff --git a/infra/relay/src/persistence/EnvironmentCredentials.test.ts b/infra/relay/src/persistence/EnvironmentCredentials.test.ts new file mode 100644 index 00000000000..315815606eb --- /dev/null +++ b/infra/relay/src/persistence/EnvironmentCredentials.test.ts @@ -0,0 +1,148 @@ +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; +import { describe, expect, it } from "@effect/vitest"; +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 { relayEnvironmentCredentials } from "../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 = { + 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.params).toEqual(["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/persistence/EnvironmentCredentials.ts b/infra/relay/src/persistence/EnvironmentCredentials.ts new file mode 100644 index 00000000000..40361236077 --- /dev/null +++ b/infra/relay/src/persistence/EnvironmentCredentials.ts @@ -0,0 +1,176 @@ +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 } from "drizzle-orm"; + +import { RelayDb } from "../db.ts"; +import { relayEnvironmentCredentials } from "../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 +>()("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), + ), + ) + .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/persistence/EnvironmentLinks.test.ts b/infra/relay/src/persistence/EnvironmentLinks.test.ts new file mode 100644 index 00000000000..500ffadb71d --- /dev/null +++ b/infra/relay/src/persistence/EnvironmentLinks.test.ts @@ -0,0 +1,78 @@ +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 "../schema.ts"; +import { + agentAwarenessDeliveryUserCondition, + EnvironmentLinks, + layer, +} from "./EnvironmentLinks.ts"; + +describe("EnvironmentLinks", () => { + it("selects users when either notifications or Live Activities are enabled", () => { + const dialect = new PgDialect(); + const condition = agentAwarenessDeliveryUserCondition("env-1"); + expect(condition).toBeDefined(); + if (!condition) { + throw new Error("Expected agent awareness delivery condition."); + } + const query = dialect.sqlToQuery(condition); + + 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]); + }); + + 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/persistence/EnvironmentLinks.ts b/infra/relay/src/persistence/EnvironmentLinks.ts new file mode 100644 index 00000000000..1d70d88d8c6 --- /dev/null +++ b/infra/relay/src/persistence/EnvironmentLinks.ts @@ -0,0 +1,349 @@ +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 "../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()( + "EnvironmentLinks", +) {} + +export function agentAwarenessDeliveryUserCondition(environmentId: string) { + return and( + eq(relayEnvironmentLinks.environmentId, environmentId), + isNull(relayEnvironmentLinks.revokedAt), + or( + eq(relayEnvironmentLinks.notificationsEnabled, true), + eq(relayEnvironmentLinks.liveActivitiesEnabled, true), + ), + ); +} + +export 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({ + "user.id": input.userId, + "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) { + yield* Effect.annotateCurrentSpan({ "user.id": input.userId }); + 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({ + "user.id": input.userId, + "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({ + "user.id": input.userId, + "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/persistence/LiveActivities.test.ts b/infra/relay/src/persistence/LiveActivities.test.ts new file mode 100644 index 00000000000..3f909fb7ba9 --- /dev/null +++ b/infra/relay/src/persistence/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 "../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/persistence/LiveActivities.ts b/infra/relay/src/persistence/LiveActivities.ts new file mode 100644 index 00000000000..a9bbd82f8f8 --- /dev/null +++ b/infra/relay/src/persistence/LiveActivities.ts @@ -0,0 +1,375 @@ +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 * 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 "../schema.ts"; +import { parseJsonString, stringifyJsonValue } from "./json.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()( + "LiveActivities", +) {} + +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({ + "user.id": input.userId, + "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) { + yield* Effect.annotateCurrentSpan({ "user.id": input.userId }); + 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: stringifyJsonValue(row.preferences_json), + last_aggregate_json: + row.last_aggregate_json === null + ? Effect.succeed(null) + : stringifyJsonValue(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({ + "user.id": input.userId, + "relay.mobile.device_id": input.deviceId, + "relay.delivery.kind": input.kind, + }); + const aggregateJson = + input.aggregate === null + ? null + : yield* parseJsonString( + yield* encodeRelayAgentActivityAggregateStateJson(input.aggregate), + ); + + 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({ + "user.id": input.userId, + "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({ + "user.id": input.userId, + "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({ + "user.id": input.userId, + "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/persistence/json.ts b/infra/relay/src/persistence/json.ts new file mode 100644 index 00000000000..9adaf36130f --- /dev/null +++ b/infra/relay/src/persistence/json.ts @@ -0,0 +1,10 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); +const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); + +export const parseJsonString = (value: string) => + decodeJsonString(value).pipe(Effect.map((decoded) => decoded as A)); + +export const stringifyJsonValue = (value: unknown) => encodeJsonValue(value); 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/relayTokens.test.ts b/infra/relay/src/relayTokens.test.ts new file mode 100644 index 00000000000..c2f61e47ff0 --- /dev/null +++ b/infra/relay/src/relayTokens.test.ts @@ -0,0 +1,196 @@ +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "vitest"; +import { signRelayJwt } from "@t3tools/shared/relayJwt"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +import * as RelayConfiguration from "./Config.ts"; +import { + issueDpopAccessToken, + issueLinkChallengeToken, + resolveDpopAccessTokenScopes, + verifyDpopAccessToken, + verifyLinkChallengeToken, +} 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"), + cloudMintPrivateKey: Redacted.make(keyPair.privateKey), + cloudMintPublicKey: keyPair.publicKey, + managedEndpointBaseDomain: undefined, + cloudflareAccountId: undefined, + cloudflareZoneId: undefined, + cloudflareApiToken: undefined, +}); + +describe("relay tokens", () => { + it("issues a user-bound environment link challenge", async () => { + const token = await Effect.runPromise( + issueLinkChallengeToken({ + config, + userId: "user_123", + request: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + jti: "challenge-1", + issuedAtEpochSeconds: 100, + expiresAtEpochSeconds: 200, + }), + ); + + expect( + await Effect.runPromise( + verifyLinkChallengeToken({ + config, + token, + userId: "user_123", + request: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + nowEpochSeconds: 150, + }), + ), + ).toMatchObject({ sub: "user_123", jti: "challenge-1" }); + expect( + await Effect.runPromise( + verifyLinkChallengeToken({ + config, + token, + userId: "attacker", + request: { + notificationsEnabled: true, + liveActivitiesEnabled: true, + managedTunnelsEnabled: true, + }, + nowEpochSeconds: 150, + }), + ), + ).toBeNull(); + }); + + it("issues and verifies DPoP access tokens bound to one proof-key thumbprint", async () => { + const token = await Effect.runPromise( + issueDpopAccessToken({ + config, + 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( + await Effect.runPromise(verifyDpopAccessToken({ config, token, nowEpochSeconds: 150 })), + ).toMatchObject({ + sub: "user_123", + cnf: { jkt: "proof-key-thumbprint" }, + client_id: "t3-mobile", + scope: ["environment:connect", "environment:status", "mobile:registration"], + }); + expect( + await Effect.runPromise(verifyDpopAccessToken({ config, token, nowEpochSeconds: 261 })), + ).toBeNull(); + }); + + it("issues tunnel-only DPoP access tokens to web public clients", async () => { + const token = await Effect.runPromise( + issueDpopAccessToken({ + config, + 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( + await Effect.runPromise(verifyDpopAccessToken({ config, token, nowEpochSeconds: 150 })), + ).toMatchObject({ + client_id: "t3-web", + scope: ["environment:connect", "environment:status"], + cnf: { jkt: "web-proof-key-thumbprint" }, + }); + }); + + it("treats requested scope as an order-independent set", () => { + expect( + resolveDpopAccessTokenScopes({ + clientId: "t3-mobile", + scope: "environment:status environment:connect environment:status", + }), + ).toEqual(["environment:status", "environment:connect"]); + }); + + it("rejects signed DPoP tokens whose scope is outside the relay policy", async () => { + const token = await Effect.runPromise( + 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( + await Effect.runPromise(verifyDpopAccessToken({ config, token, nowEpochSeconds: 150 })), + ).toBeNull(); + }); + + it("rejects mobile registration scope on a web public client token", async () => { + const token = await Effect.runPromise( + 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( + await Effect.runPromise(verifyDpopAccessToken({ config, token, nowEpochSeconds: 150 })), + ).toBeNull(); + }); +}); diff --git a/infra/relay/src/relayTokens.ts b/infra/relay/src/relayTokens.ts new file mode 100644 index 00000000000..6cef7272577 --- /dev/null +++ b/infra/relay/src/relayTokens.ts @@ -0,0 +1,186 @@ +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 } from "@t3tools/shared/relayJwt"; +import * as Effect from "effect/Effect"; +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]), +}; + +export function resolveDpopAccessTokenScopes(input: { + readonly clientId: RelayPublicClientId; + readonly scope: string; +}): ReadonlyArray | null { + return parseAllowedOAuthScope({ + value: input.scope, + allowedScopes: allowedScopesByClientId[input.clientId], + }); +} + +export function issueLinkChallengeToken(input: { + readonly config: RelayConfiguration.RelayConfigurationShape; + readonly userId: string; + readonly request: RelayEnvironmentLinkChallengeRequest; + readonly jti: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; +}) { + const issuer = normalizeRelayIssuer(input.config.relayIssuer); + return signRelayJwt({ + privateKey: Redacted.value(input.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, + }, + }); +} + +export function verifyLinkChallengeToken(input: { + readonly config: RelayConfiguration.RelayConfigurationShape; + readonly token: string; + readonly userId: string; + readonly request: RelayEnvironmentLinkChallengeRequest; + readonly nowEpochSeconds: number; +}) { + const issuer = normalizeRelayIssuer(input.config.relayIssuer); + return verifyRelayJwt({ + publicKey: input.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)), + ); +} + +export function issueDpopAccessToken(input: { + readonly config: RelayConfiguration.RelayConfigurationShape; + readonly userId: string; + readonly proofKeyThumbprint: string; + readonly jti: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; + readonly clientId: RelayPublicClientId; + readonly scopes: ReadonlyArray; +}) { + const issuer = normalizeRelayIssuer(input.config.relayIssuer); + return signRelayJwt({ + privateKey: Redacted.value(input.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 }, + }, + }); +} + +export function verifyDpopAccessToken(input: { + readonly config: RelayConfiguration.RelayConfigurationShape; + readonly token: string; + readonly nowEpochSeconds: number; +}) { + const issuer = normalizeRelayIssuer(input.config.relayIssuer); + return verifyRelayJwt({ + publicKey: input.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), + ); +} diff --git a/infra/relay/src/schema.ts b/infra/relay/src/schema.ts new file mode 100644 index 00000000000..eb465869578 --- /dev/null +++ b/infra/relay/src/schema.ts @@ -0,0 +1,163 @@ +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(), + 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 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/services/AgentActivityPublisher.test.ts b/infra/relay/src/services/AgentActivityPublisher.test.ts new file mode 100644 index 00000000000..b63b125b0b1 --- /dev/null +++ b/infra/relay/src/services/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 "../persistence/AgentActivityRows.ts"; +import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; +import * as LiveActivities from "../persistence/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/services/AgentActivityPublisher.ts b/infra/relay/src/services/AgentActivityPublisher.ts new file mode 100644 index 00000000000..e2e53f2cdd6 --- /dev/null +++ b/infra/relay/src/services/AgentActivityPublisher.ts @@ -0,0 +1,234 @@ +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 "../persistence/AgentActivityRows.ts"; +import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; +import * as LiveActivities from "../persistence/LiveActivities.ts"; +import { withUserId } from "../telemetry.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 +>()("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, + }); + }, + (effect, input) => effect.pipe(withUserId(input.userId)), + ), + 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, + }).pipe(withUserId(deliveryUser.userId)), + { 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/services/ApnsDeliveries.test.ts b/infra/relay/src/services/ApnsDeliveries.test.ts new file mode 100644 index 00000000000..e0995570893 --- /dev/null +++ b/infra/relay/src/services/ApnsDeliveries.test.ts @@ -0,0 +1,1126 @@ +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 "../persistence/DeliveryAttempts.ts"; +import * as LiveActivities from "../persistence/LiveActivities.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as ApnsDeliveries from "./ApnsDeliveries.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"), + cloudMintPrivateKey: Redacted.make("cloud-private-key"), + cloudMintPublicKey: "cloud-public-key", + managedEndpointBaseDomain: undefined, + cloudflareAccountId: undefined, + cloudflareZoneId: undefined, + cloudflareApiToken: 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(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/services/ApnsDeliveries.ts b/infra/relay/src/services/ApnsDeliveries.ts new file mode 100644 index 00000000000..db399fa24b9 --- /dev/null +++ b/infra/relay/src/services/ApnsDeliveries.ts @@ -0,0 +1,793 @@ +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 { HttpClient } from "effect/unstable/http"; + +import { + sanitizeAgentActivityAggregateState, + sanitizeApnsNotificationPayload, +} from "../agentActivityPayloads.ts"; +import * as Apns from "../apns.ts"; +import { + ApnsDeliveryJobInvalid, + type ApnsNotificationPayload, + SignedApnsDeliveryJob, + verifySignedApnsDeliveryJob, + type ApnsDeliveryJobVerificationError, +} from "../apnsDeliveryJobs.ts"; +import * as DeliveryAttempts from "../persistence/DeliveryAttempts.ts"; +import * as LiveActivities from "../persistence/LiveActivities.ts"; +import * as RelayConfiguration from "../Config.ts"; +import { withUserId } from "../telemetry.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.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( + 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()( + "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 httpClient = yield* HttpClient.HttpClient; + + 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( + { ...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.provideService(HttpClient.HttpClient, httpClient), + 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, + }; + }, + (effect, input) => effect.pipe(withUserId(input.target.user_id)), + ); + + 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.provideService(HttpClient.HttpClient, httpClient), + 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, + }; + }, + (effect, input) => effect.pipe(withUserId(input.target.user_id)), + ); + + 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({ + "user.id": payload.target.userId, + "relay.mobile.device_id": payload.target.deviceId, + "relay.delivery.kind": payload.kind, + "relay.delivery.job_id": payload.jobId, + }); + switch (payload.kind) { + case "live_activity_start": + case "live_activity_update": + if (payload.aggregate === null) { + return yield* new ApnsDeliveryJobInvalid({ + message: "Live Activity start/update jobs require an aggregate.", + }); + } + return yield* 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 yield* 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 yield* new ApnsDeliveryJobInvalid({ + message: "Push notification jobs require a notification payload.", + }); + } + return yield* sendPushNotification({ + target: { + user_id: payload.target.userId, + device_id: payload.target.deviceId, + }, + token: payload.target.token, + sourceJobId: payload.jobId, + notification: payload.notification, + }); + } + }); + + 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/services/ApnsDeliveryQueue.ts b/infra/relay/src/services/ApnsDeliveryQueue.ts new file mode 100644 index 00000000000..be7bafb9e9b --- /dev/null +++ b/infra/relay/src/services/ApnsDeliveryQueue.ts @@ -0,0 +1,144 @@ +import type { RelayDeliveryResult } from "@t3tools/contracts/relay"; +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 { + sanitizeAgentActivityAggregateState, + sanitizeApnsNotificationPayload, +} from "../agentActivityPayloads.ts"; +import { + expiresAtForJob, + makeApnsDeliveryJobPayload, + signApnsDeliveryJob, + type ApnsDeliveryJobPayload, + type SignedApnsDeliveryJob, +} from "../apnsDeliveryJobs.ts"; +import * as RelayConfiguration from "../Config.ts"; +import { withUserId } from "../telemetry.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 +>()("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()( + "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, + }; + }, + (effect, input) => effect.pipe(withUserId(input.userId)), + ), + 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, + }; + }, + (effect, input) => effect.pipe(withUserId(input.userId)), + ), + }); +}); + +export const layer = Layer.effect(ApnsDeliveryQueue, make); diff --git a/infra/relay/src/services/Auth.ts b/infra/relay/src/services/Auth.ts new file mode 100644 index 00000000000..a7f92b566bb --- /dev/null +++ b/infra/relay/src/services/Auth.ts @@ -0,0 +1,19 @@ +import * as Context from "effect/Context"; + +export interface ClientPrincipalShape { + readonly userId: string; +} + +export class ClientPrincipal extends Context.Service()( + "ClientPrincipal", +) {} + +export interface EnvironmentPrincipalShape { + readonly environmentId: string; + readonly credentialId: string; +} + +export class EnvironmentPrincipal extends Context.Service< + EnvironmentPrincipal, + EnvironmentPrincipalShape +>()("EnvironmentPrincipal") {} diff --git a/infra/relay/src/services/EnvironmentConnector.test.ts b/infra/relay/src/services/EnvironmentConnector.test.ts new file mode 100644 index 00000000000..9acb89402a3 --- /dev/null +++ b/infra/relay/src/services/EnvironmentConnector.test.ts @@ -0,0 +1,583 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; + +import type { + RelayCloudEnvironmentHealthProofPayload, + RelayCloudEnvironmentHealthRequest, + RelayCloudMintCredentialProofPayload, + RelayCloudMintCredentialRequest, + 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 TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as EnvironmentConnector from "./EnvironmentConnector.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 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"), + cloudMintPrivateKey: Redacted.make(cloudKeyPair.privateKey), + cloudMintPublicKey: cloudKeyPair.publicKey, + managedEndpointBaseDomain: undefined, + cloudflareAccountId: undefined, + cloudflareZoneId: undefined, + cloudflareApiToken: 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; + }, +) { + return EnvironmentConnector.layer.pipe( + Layer.provide(NodeCryptoLayer.layer), + Layer.provide(Layer.succeed(EnvironmentLinks.EnvironmentLinks, options?.links ?? makeLinks())), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), + ); +} + +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/", + providerKind: "manual", + }, + 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 = JSON.parse( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", + ) as RelayCloudEnvironmentHealthRequest; + 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 signed health responses with stale checkedAt timestamps", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const healthRequest = JSON.parse( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", + ) as RelayCloudEnvironmentHealthRequest; + 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 = JSON.parse( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", + ) as RelayCloudEnvironmentHealthRequest; + 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 = JSON.parse( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", + ) as RelayCloudEnvironmentHealthRequest; + 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 = JSON.parse( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", + ) as RelayCloudEnvironmentHealthRequest; + 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 = JSON.parse( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", + ) as RelayCloudMintCredentialRequest; + 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/", + }, + }); + }).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 = JSON.parse( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", + ) as RelayCloudMintCredentialRequest; + 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 = JSON.parse( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", + ) as RelayCloudMintCredentialRequest; + 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 = JSON.parse( + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", + ) as RelayCloudMintCredentialRequest; + 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/services/EnvironmentConnector.ts b/infra/relay/src/services/EnvironmentConnector.ts new file mode 100644 index 00000000000..623b5099bbe --- /dev/null +++ b/infra/relay/src/services/EnvironmentConnector.ts @@ -0,0 +1,397 @@ +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 { HttpClient } from "effect/unstable/http"; + +import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; +import * as RelayConfiguration from "../Config.ts"; +import { withUserId } from "../telemetry.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; + +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 +>()("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 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: typeof RelayEnvironmentMintResponse.Type; + 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: typeof RelayEnvironmentHealthResponse.Type; + 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 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), + ); + + 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 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 typeof RelayCloudEnvironmentHealthProofPayload.Type; + 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(link.endpoint.httpBaseUrl); + const responseOption = yield* environmentClient.cloud.health({ payload: { proof } }).pipe( + 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: link.endpoint, + status: "offline" as const, + checkedAt, + error: "Managed endpoint health request timed out.", + }; + } + if (responseOption.value._tag === "Failure") { + return { + environmentId: link.environmentId, + endpoint: link.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: link.endpoint, + status: "online" as const, + checkedAt: decoded.checkedAt, + descriptor: decoded.descriptor, + }; + }, + (effect, input) => effect.pipe(withUserId(input.userId)), + ), + 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 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 typeof RelayCloudMintCredentialProofPayload.Type; + 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(link.endpoint.httpBaseUrl); + const decoded = yield* environmentClient.cloud + .t3MintCredential({ payload: { proof } }) + .pipe( + 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: link.endpoint, + credential: decoded.credential, + expiresAt: decoded.expiresAt, + }; + }, + (effect, input) => effect.pipe(withUserId(input.userId)), + ), + }); +}); + +export const layer = Layer.effect(EnvironmentConnector, make); diff --git a/infra/relay/src/services/EnvironmentLinker.test.ts b/infra/relay/src/services/EnvironmentLinker.test.ts new file mode 100644 index 00000000000..2b3dbfee458 --- /dev/null +++ b/infra/relay/src/services/EnvironmentLinker.test.ts @@ -0,0 +1,203 @@ +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 "../persistence/DpopProofs.ts"; +import * as EnvironmentCredentials from "../persistence/EnvironmentCredentials.ts"; +import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as EnvironmentLinker from "./EnvironmentLinker.ts"; +import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; +import { issueLinkChallengeToken } from "../relayTokens.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"), + cloudMintPrivateKey: Redacted.make(relayKeyPair.privateKey), + cloudMintPublicKey: relayKeyPair.publicKey, + managedEndpointBaseDomain: undefined, + cloudflareAccountId: undefined, + cloudflareZoneId: undefined, + cloudflareApiToken: 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 challenge = yield* issueLinkChallengeToken({ + config, + 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.provide( + Layer.mergeAll( + Layer.succeed(RelayConfiguration.RelayConfiguration, config), + Layer.succeed(DpopProofs.DpopProofReplay, { + 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, { + 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/services/EnvironmentLinker.ts b/infra/relay/src/services/EnvironmentLinker.ts new file mode 100644 index 00000000000..ff12bc688c7 --- /dev/null +++ b/infra/relay/src/services/EnvironmentLinker.ts @@ -0,0 +1,270 @@ +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 "../persistence/DpopProofs.ts"; +import * as EnvironmentCredentials from "../persistence/EnvironmentCredentials.ts"; +import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; +import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; +import * as RelayConfiguration from "../Config.ts"; +import { verifyLinkChallengeToken } from "../relayTokens.ts"; +import { withUserId } from "../telemetry.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()( + "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 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* verifyLinkChallengeToken({ + config, + 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({ + 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, + }; + }, + (effect, input) => effect.pipe(withUserId(input.userId)), + ), + }); +}); + +export const layer = Layer.effect(EnvironmentLinker, make); diff --git a/infra/relay/src/services/EnvironmentPublishSignatures.test.ts b/infra/relay/src/services/EnvironmentPublishSignatures.test.ts new file mode 100644 index 00000000000..9826137074e --- /dev/null +++ b/infra/relay/src/services/EnvironmentPublishSignatures.test.ts @@ -0,0 +1,160 @@ +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 { 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 "../persistence/DpopProofs.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as EnvironmentPublishSignatures from "./EnvironmentPublishSignatures.ts"; +import { environmentPublishReplayThumbprint } 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"), + cloudMintPrivateKey: Redacted.make(keyPair.privateKey), + cloudMintPublicKey: keyPair.publicKey, + managedEndpointBaseDomain: undefined, + cloudflareAccountId: undefined, + cloudflareZoneId: undefined, + cloudflareApiToken: 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, { + 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( + yield* environmentPublishReplayThumbprint({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + }), + ); + }).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/services/EnvironmentPublishSignatures.ts b/infra/relay/src/services/EnvironmentPublishSignatures.ts new file mode 100644 index 00000000000..6c92b02b99c --- /dev/null +++ b/infra/relay/src/services/EnvironmentPublishSignatures.ts @@ -0,0 +1,176 @@ +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 "../persistence/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 +>()("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)}`; + +export function environmentPublishReplayThumbprint(input: { + readonly environmentId: string; + readonly environmentPublicKey: string; +}) { + return Crypto.Crypto.pipe( + Effect.flatMap((crypto) => + crypto.digest("SHA-256", environmentPublishReplayThumbprintData(input)), + ), + Effect.map(formatEnvironmentPublishReplayThumbprint), + ); +} + +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/services/ManagedEndpointProvider.test.ts b/infra/relay/src/services/ManagedEndpointProvider.test.ts new file mode 100644 index 00000000000..bd1bbb47658 --- /dev/null +++ b/infra/relay/src/services/ManagedEndpointProvider.test.ts @@ -0,0 +1,446 @@ +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 { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as RelayConfiguration from "../Config.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"), + cloudMintPrivateKey: Redacted.make("cloud-private-key"), + cloudMintPublicKey: "cloud-public-key", + managedEndpointBaseDomain: "t3code.test", + cloudflareAccountId: "account-id", + cloudflareZoneId: "zone-id", + cloudflareApiToken: Redacted.make("api-token"), +}); + +function decodeBody(request: HttpClientRequest.HttpClientRequest): unknown { + return request.body._tag === "Uint8Array" + ? JSON.parse(new TextDecoder().decode(request.body.body)) + : null; +} + +function expectedManagedHostname(environmentId: string): string { + const hash = NodeCrypto.createHash("sha256").update(environmentId).digest("hex").slice(0, 16); + return `tunnels-env-abc-${hash}.t3code.test`; +} + +function expectedManagedTunnelName(environmentId: string): string { + const hash = NodeCrypto.createHash("sha256").update(environmentId).digest("hex").slice(0, 16); + return `t3-code-env-abc-${hash}`; +} + +describe("ManagedEndpointProvider", () => { + it.effect("provisions a Cloudflare tunnel endpoint and connector token", () => { + const calls: Array<{ + readonly method: string; + readonly url: string; + readonly body: unknown; + readonly authorization: string | undefined; + }> = []; + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + calls.push({ + method: request.method, + url: request.url, + body: decodeBody(request), + authorization: request.headers.authorization, + }); + if (request.url.includes("/cfd_tunnel?")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: [] }, { status: 200 }), + ); + } + if (request.url.endsWith("/token")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: "connector-token" }, { status: 200 }), + ); + } + if (request.url.includes("/dns_records?")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: [] }, { status: 200 }), + ); + } + if (request.url.endsWith("/dns_records")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true }, { status: 200 }), + ); + } + if (request.url.endsWith("/configurations")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true }, { status: 200 }), + ); + } + return HttpClientResponse.fromWeb( + request, + Response.json( + { success: true, result: { id: "tunnel-id", name: "tunnel-name" } }, + { status: 200 }, + ), + ); + }); + + return Effect.gen(function* () { + const hostname = expectedManagedHostname("env_ABC"); + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const result = yield* provider.provision({ + 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: "tunnel-name", + }, + }); + expect(calls.map((call) => call.method)).toEqual([ + "GET", + "POST", + "PUT", + "GET", + "POST", + "GET", + ]); + expect(calls.every((call) => call.authorization === "Bearer api-token")).toBe(true); + expect(calls[2]?.body).toMatchObject({ + config: { + ingress: [ + { + hostname, + service: "http://127.0.0.1:3773", + }, + { service: "http_status:404" }, + ], + }, + }); + expect(calls[0]?.url).toContain( + `name=${expectedManagedTunnelName("env_ABC")}&is_deleted=false`, + ); + }).pipe( + Effect.provide( + ManagedEndpointProvider.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), + ), + ), + ); + }); + + it.effect( + "normalizes unusual environment ids before using them in Cloudflare tunnel names", + () => { + const calls: Array<{ + readonly method: string; + readonly url: string; + readonly body: unknown; + }> = []; + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + calls.push({ + method: request.method, + url: request.url, + body: decodeBody(request), + }); + if (request.url.includes("/cfd_tunnel?")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: [] }, { status: 200 }), + ); + } + if (request.url.endsWith("/token")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: "connector-token" }, { status: 200 }), + ); + } + if (request.url.includes("/dns_records?")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: [] }, { status: 200 }), + ); + } + if (request.url.endsWith("/dns_records") || request.url.endsWith("/configurations")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true }, { status: 200 }), + ); + } + return HttpClientResponse.fromWeb( + request, + Response.json( + { success: true, result: { id: "tunnel-id", name: "normalized-name" } }, + { status: 200 }, + ), + ); + }); + + return Effect.gen(function* () { + const environmentId = "ENV With Spaces/../Symbols!" + "x".repeat(80); + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + yield* provider.provision({ + environmentId, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + + const listUrl = calls[0]?.url ?? ""; + const createBody = calls[1]?.body; + const requestedName = new URL(listUrl).searchParams.get("name"); + expect(requestedName).toMatch(/^t3-code-env-with-spaces-symbols-x+-[a-f0-9]{16}$/); + expect(requestedName?.length).toBeLessThanOrEqual(89); + const configBody = calls.find((call) => call.url.endsWith("/configurations"))?.body; + expect(configBody).toMatchObject({ + config: { + ingress: [ + { + hostname: expect.stringMatching( + /^tunnels-env-with-spaces-symbols-x+-[a-f0-9]{16}\.t3code\.test$/, + ), + }, + { service: "http_status:404" }, + ], + }, + }); + const hostname = ( + configBody as + | { + readonly config?: { + readonly ingress?: readonly [{ readonly hostname?: unknown }, unknown]; + }; + } + | undefined + )?.config?.ingress?.[0]?.hostname; + expect( + typeof hostname === "string" ? hostname.split(".")[0]?.length : 0, + ).toBeLessThanOrEqual(63); + expect(createBody).toMatchObject({ + name: requestedName, + config_src: "cloudflare", + }); + }).pipe( + Effect.provide( + ManagedEndpointProvider.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), + ), + ), + ); + }, + ); + + it.effect("formats IPv6 loopback origins as valid Cloudflare ingress service URLs", () => { + const calls: Array<{ + readonly method: string; + readonly url: string; + readonly body: unknown; + }> = []; + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + calls.push({ + method: request.method, + url: request.url, + body: decodeBody(request), + }); + if (request.url.includes("/cfd_tunnel?")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: [] }, { status: 200 }), + ); + } + if (request.url.endsWith("/token")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: "connector-token" }, { status: 200 }), + ); + } + if (request.url.includes("/dns_records?")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: [] }, { status: 200 }), + ); + } + if (request.url.endsWith("/dns_records") || request.url.endsWith("/configurations")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true }, { status: 200 }), + ); + } + return HttpClientResponse.fromWeb( + request, + Response.json( + { success: true, result: { id: "tunnel-id", name: "normalized-name" } }, + { status: 200 }, + ), + ); + }); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + yield* provider.provision({ + environmentId: "env-ipv6", + origin: { localHttpHost: "::1", localHttpPort: 3773 }, + }); + + expect(calls[2]?.body).toMatchObject({ + config: { + ingress: [ + { + service: "http://[::1]:3773", + }, + { service: "http_status:404" }, + ], + }, + }); + }).pipe( + Effect.provide( + ManagedEndpointProvider.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), + ), + ), + ); + }); + + it.effect("rejects non-loopback managed endpoint origins before calling Cloudflare", () => { + const calls: Array = []; + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + calls.push(request); + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: [] }, { status: 200 }), + ); + }); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const result = yield* Effect.result( + provider.provision({ + environmentId: "env_ABC", + origin: { localHttpHost: "192.168.1.10", localHttpPort: 3773 }, + }), + ); + + expect(calls).toHaveLength(0); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); + } + }).pipe( + Effect.provide( + ManagedEndpointProvider.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), + ), + ), + ); + }); + + it.effect("rejects invalid managed endpoint origin ports before calling Cloudflare", () => { + const calls: Array = []; + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + calls.push(request); + return HttpClientResponse.fromWeb( + request, + Response.json({ success: true, result: [] }, { status: 200 }), + ); + }); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const result = yield* Effect.result( + provider.provision({ + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 65_536 }, + }), + ); + + expect(calls).toHaveLength(0); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); + } + }).pipe( + Effect.provide( + ManagedEndpointProvider.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), + ), + ), + ); + }); + + it.effect("fails provisioning when Cloudflare returns a 2xx application error", () => { + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => + HttpClientResponse.fromWeb( + request, + Response.json( + { + success: false, + result: [], + errors: [{ code: 10_000, message: "Cloudflare application failure" }], + }, + { status: 200 }, + ), + ), + ); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const error = yield* Effect.flip( + provider.provision({ + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }), + ); + + expect(error._tag).toBe("ManagedEndpointProvisioningFailed"); + expect(error.cause).toMatchObject({ + success: false, + errors: [{ message: "Cloudflare application failure" }], + }); + }).pipe( + Effect.provide( + ManagedEndpointProvider.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), + ), + ), + ); + }); +}); diff --git a/infra/relay/src/services/ManagedEndpointProvider.ts b/infra/relay/src/services/ManagedEndpointProvider.ts new file mode 100644 index 00000000000..c16413e5daa --- /dev/null +++ b/infra/relay/src/services/ManagedEndpointProvider.ts @@ -0,0 +1,343 @@ +// @effect-diagnostics nodeBuiltinImport:off + +import type { + RelayManagedEndpoint, + RelayManagedEndpointOrigin, + RelayManagedEndpointRuntimeConfig, +} from "@t3tools/contracts/relay"; +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 Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import * as RelayConfiguration from "../Config.ts"; + +export class ManagedEndpointProvisioningNotConfigured extends Data.TaggedError( + "ManagedEndpointProvisioningNotConfigured", +)<{}> {} + +export class ManagedEndpointProvisioningFailed extends Data.TaggedError( + "ManagedEndpointProvisioningFailed", +)<{ + 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 environmentId: string; + readonly origin: RelayManagedEndpointOrigin; + }) => Effect.Effect; +} + +export class ManagedEndpointProvider extends Context.Service< + ManagedEndpointProvider, + ManagedEndpointProviderShape +>()("ManagedEndpointProvider") {} + +const CloudflareTunnelCreateResponse = Schema.Struct({ + success: Schema.Boolean, + result: Schema.Struct({ + id: Schema.String, + name: Schema.String, + }), +}); + +const CloudflareTunnelListResponse = Schema.Struct({ + success: Schema.Boolean, + result: Schema.Array( + Schema.Struct({ + id: Schema.String, + name: Schema.String, + }), + ), +}); + +const CloudflareTunnelTokenResponse = Schema.Struct({ + success: Schema.Boolean, + result: Schema.String, +}); + +const CloudflareDnsRecordResponse = Schema.Struct({ + success: Schema.Boolean, +}); + +const CloudflareDnsRecordListResponse = Schema.Struct({ + success: Schema.Boolean, + result: Schema.Array( + Schema.Struct({ + id: Schema.String, + }), + ), +}); + +const requireCloudflareSettings = Effect.fnUntraced(function* ( + settings: RelayConfiguration.RelayConfigurationShape, +) { + if ( + !settings.managedEndpointBaseDomain || + !settings.cloudflareAccountId || + !settings.cloudflareZoneId || + !settings.cloudflareApiToken + ) { + return yield* new ManagedEndpointProvisioningNotConfigured(); + } + return { + accountId: settings.cloudflareAccountId, + zoneId: settings.cloudflareZoneId, + apiToken: Redacted.value(settings.cloudflareApiToken), + baseDomain: settings.managedEndpointBaseDomain, + }; +}); + +function cloudflareRequest(input: { + readonly method: "GET" | "POST" | "PUT"; + readonly url: string; + readonly apiToken: string; + readonly body?: unknown; +}): Effect.Effect { + const base = + input.method === "GET" + ? HttpClientRequest.get(input.url) + : input.method === "POST" + ? HttpClientRequest.post(input.url) + : HttpClientRequest.put(input.url); + + const request = base.pipe( + HttpClientRequest.setHeader("authorization", `Bearer ${input.apiToken}`), + HttpClientRequest.setHeader("content-type", "application/json"), + ); + return input.body === undefined + ? Effect.succeed(request) + : request.pipe( + HttpClientRequest.bodyJson(input.body), + Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + ); +} + +const MANAGED_ENDPOINT_HOST_PREFIX = "tunnels"; +const DNS_LABEL_MAX_LENGTH = 63; +const MANAGED_ENDPOINT_HASH_LENGTH = 16; +const MANAGED_ENDPOINT_SAFE_ID_LENGTH = + DNS_LABEL_MAX_LENGTH - MANAGED_ENDPOINT_HOST_PREFIX.length - 2 - MANAGED_ENDPOINT_HASH_LENGTH; + +function managedHostname(environmentId: string, baseDomain: string, hash: string): string { + const safeId = environmentId + .toLowerCase() + .replaceAll(/[^a-z0-9-]/g, "-") + .replaceAll(/-+/g, "-") + .replaceAll(/^-+|-+$/g, "") + .slice(0, MANAGED_ENDPOINT_SAFE_ID_LENGTH); + const prefix = safeId.length > 0 ? safeId : "env"; + return `${MANAGED_ENDPOINT_HOST_PREFIX}-${prefix}-${hash.slice(0, MANAGED_ENDPOINT_HASH_LENGTH)}.${baseDomain.replace(/^\.+|\.+$/g, "")}`; +} + +function managedTunnelName(environmentId: string, hash: string): string { + const safeId = environmentId + .toLowerCase() + .replaceAll(/[^a-z0-9-]/g, "-") + .replaceAll(/-+/g, "-") + .replaceAll(/^-+|-+$/g, "") + .slice(0, 64); + return `t3-code-${safeId.length > 0 ? safeId : "env"}-${hash.slice(0, 16)}`; +} + +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, "$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 + ); +} + +const make = Effect.gen(function* () { + const config = yield* RelayConfiguration.RelayConfiguration; + const httpClient = yield* HttpClient.HttpClient; + const crypto = yield* Crypto.Crypto; + + const requireCloudflareSuccess = ( + json: unknown, + ): Effect.Effect => + typeof json === "object" && + json !== null && + "success" in json && + (json as { readonly success: unknown }).success === false + ? Effect.fail(new ManagedEndpointProvisioningFailed({ cause: json })) + : Effect.void; + + const executeJson = Effect.fnUntraced(function* ( + request: HttpClientRequest.HttpClientRequest, + schema: Schema.Schema, + ) { + const response = yield* httpClient + .execute(request) + .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + if (response.status < 200 || response.status >= 300) { + return yield* new ManagedEndpointProvisioningFailed({ cause: response.status }); + } + const json = yield* response.json.pipe( + Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + ); + const isSchema = Schema.is(schema); + if (!isSchema(json)) { + return yield* new ManagedEndpointProvisioningFailed({ cause: json }); + } + yield* requireCloudflareSuccess(json); + return json; + }); + + return ManagedEndpointProvider.of({ + provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { + yield* Effect.annotateCurrentSpan({ + "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(input.environmentId)) + .pipe( + Effect.map(Encoding.encodeHex), + Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + ); + const hostname = managedHostname(input.environmentId, cf.baseDomain, environmentHash); + const tunnelName = managedTunnelName(input.environmentId, environmentHash); + const existingTunnels = yield* cloudflareRequest({ + method: "GET", + url: `https://api.cloudflare.com/client/v4/accounts/${cf.accountId}/cfd_tunnel?${new URLSearchParams( + [ + ["name", tunnelName], + ["is_deleted", "false"], + ], + ).toString()}`, + apiToken: cf.apiToken, + }).pipe(Effect.flatMap((request) => executeJson(request, CloudflareTunnelListResponse))); + const existingTunnel = existingTunnels.result.find((tunnel) => tunnel.name === tunnelName); + const tunnel = + existingTunnel ?? + (yield* cloudflareRequest({ + method: "POST", + url: `https://api.cloudflare.com/client/v4/accounts/${cf.accountId}/cfd_tunnel`, + apiToken: cf.apiToken, + body: { + name: tunnelName, + config_src: "cloudflare", + }, + }).pipe( + Effect.flatMap((request) => executeJson(request, CloudflareTunnelCreateResponse)), + Effect.map((response) => response.result), + )); + + yield* cloudflareRequest({ + method: "PUT", + url: `https://api.cloudflare.com/client/v4/accounts/${cf.accountId}/cfd_tunnel/${tunnel.id}/configurations`, + apiToken: cf.apiToken, + body: { + config: { + ingress: [ + { + hostname, + service: formatOriginService(input.origin), + }, + { service: "http_status:404" }, + ], + }, + }, + }).pipe( + Effect.flatMap((request) => + executeJson(request, Schema.Struct({ success: Schema.Boolean })), + ), + ); + + const dnsRecords = yield* cloudflareRequest({ + method: "GET", + url: `https://api.cloudflare.com/client/v4/zones/${cf.zoneId}/dns_records?${new URLSearchParams( + [ + ["type", "CNAME"], + ["name", hostname], + ], + ).toString()}`, + apiToken: cf.apiToken, + }).pipe(Effect.flatMap((request) => executeJson(request, CloudflareDnsRecordListResponse))); + const existingDnsRecordId = dnsRecords.result[0]?.id; + yield* cloudflareRequest({ + method: existingDnsRecordId ? "PUT" : "POST", + url: existingDnsRecordId + ? `https://api.cloudflare.com/client/v4/zones/${cf.zoneId}/dns_records/${existingDnsRecordId}` + : `https://api.cloudflare.com/client/v4/zones/${cf.zoneId}/dns_records`, + apiToken: cf.apiToken, + body: { + type: "CNAME", + name: hostname, + content: `${tunnel.id}.cfargotunnel.com`, + proxied: true, + }, + }).pipe(Effect.flatMap((request) => executeJson(request, CloudflareDnsRecordResponse))); + + const token = yield* cloudflareRequest({ + method: "GET", + url: `https://api.cloudflare.com/client/v4/accounts/${cf.accountId}/cfd_tunnel/${tunnel.id}/token`, + apiToken: cf.apiToken, + }).pipe(Effect.flatMap((request) => executeJson(request, CloudflareTunnelTokenResponse))); + + return { + endpoint: { + httpBaseUrl: `https://${hostname}/`, + wsBaseUrl: `wss://${hostname}/ws`, + providerKind: "cloudflare_tunnel", + }, + runtime: { + providerKind: "cloudflare_tunnel", + connectorToken: token.result, + tunnelId: tunnel.id, + tunnelName: tunnel.name, + }, + } satisfies ManagedEndpointProvisioningResult; + }), + }); +}); + +export const layer = Layer.effect(ManagedEndpointProvider, make); diff --git a/infra/relay/src/services/MobileRegistrations.test.ts b/infra/relay/src/services/MobileRegistrations.test.ts new file mode 100644 index 00000000000..47509394056 --- /dev/null +++ b/infra/relay/src/services/MobileRegistrations.test.ts @@ -0,0 +1,461 @@ +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 "../persistence/Devices.ts"; +import * as AgentActivityRows from "../persistence/AgentActivityRows.ts"; +import * as DeliveryAttempts from "../persistence/DeliveryAttempts.ts"; +import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; +import * as LiveActivities from "../persistence/LiveActivities.ts"; +import * as RelayConfiguration from "../Config.ts"; +import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; +import * as ApnsDeliveries from "./ApnsDeliveries.ts"; +import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; +import * as MobileRegistrations from "./MobileRegistrations.ts"; + +const device: RelayDeviceRegistrationRequest = { + deviceId: "device-1" as RelayDeviceRegistrationRequest["deviceId"], + 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, + ...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"), + apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"), + cloudMintPrivateKey: Redacted.make("cloud-private-key"), + cloudMintPublicKey: "cloud-public-key", + managedEndpointBaseDomain: undefined, + cloudflareAccountId: undefined, + cloudflareZoneId: undefined, + cloudflareApiToken: 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), + 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/services/MobileRegistrations.ts b/infra/relay/src/services/MobileRegistrations.ts new file mode 100644 index 00000000000..8bc0395d2ad --- /dev/null +++ b/infra/relay/src/services/MobileRegistrations.ts @@ -0,0 +1,101 @@ +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 "../persistence/Devices.ts"; +import * as LiveActivities from "../persistence/LiveActivities.ts"; +import { withUserId } from "../telemetry.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 +>()("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 }; + }, + (effect, input) => effect.pipe(withUserId(input.userId)), + ), + 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 }; + }, + (effect, input) => effect.pipe(withUserId(input.userId)), + ), + 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 }; + }, + (effect, input) => effect.pipe(withUserId(input.userId)), + ), + }); +}); + +export const layer = Layer.effect(MobileRegistrations, make); diff --git a/infra/relay/src/telemetry.test.ts b/infra/relay/src/telemetry.test.ts new file mode 100644 index 00000000000..51cd36d8387 --- /dev/null +++ b/infra/relay/src/telemetry.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Tracer from "effect/Tracer"; + +import { withUserId } from "./telemetry.ts"; + +describe("relay telemetry", () => { + it.effect("annotates the active span and descendant spans with the user id", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + + yield* Effect.succeed("ok").pipe( + Effect.withSpan("relay.test.child"), + withUserId("user-123"), + Effect.withSpan("relay.test.parent"), + Effect.provide(Layer.succeed(Tracer.Tracer, tracer)), + ); + + expect(spans.map((span) => span.name)).toEqual(["relay.test.parent", "relay.test.child"]); + expect(spans.map((span) => span.attributes.get("user.id"))).toEqual(["user-123", "user-123"]); + }), + ); +}); diff --git a/infra/relay/src/telemetry.ts b/infra/relay/src/telemetry.ts new file mode 100644 index 00000000000..c82454e5b98 --- /dev/null +++ b/infra/relay/src/telemetry.ts @@ -0,0 +1,10 @@ +import * as Effect from "effect/Effect"; + +export const withSpanAttributes = + (attributes: Record) => + (effect: Effect.Effect): Effect.Effect => + Effect.annotateCurrentSpan(attributes).pipe( + Effect.andThen(effect.pipe(Effect.annotateSpans(attributes))), + ); + +export const withUserId = (userId: string) => withSpanAttributes({ "user.id": userId }); diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts new file mode 100644 index 00000000000..4543050917c --- /dev/null +++ b/infra/relay/src/worker.ts @@ -0,0 +1,295 @@ +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 Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Stream from "effect/Stream"; +import * as Etag from "effect/unstable/http/Etag"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; +import * as OtlpSerialization from "effect/unstable/observability/OtlpSerialization"; +import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; + +import { RelayApi } from "@t3tools/contracts/relay"; + +import { + RelayHttpPlatformLayer, + clientApi, + dpopClientApi, + healthApi, + metadataApi, + mobileApi, + relayClientAuthLayer, + relayDpopClientAuthLayer, + relayCors, + relayEnvironmentAuthLayer, + relayNotFoundRoute, + serverApi, + traceRelayHttpRequestWith, + tokenApi, + withoutCapturedParentSpan, +} from "./api.ts"; +import { + MANAGED_ENDPOINT_BASE_DOMAIN, + MANAGED_ENDPOINT_PROVISIONER_TOKEN_POLICIES, + MANAGED_ENDPOINT_ZONE, + RELAY_PUBLIC_DOMAIN, + RELAY_PUBLIC_ORIGIN, +} from "./infra/ManagedEndpointStackConfig.ts"; +import { + RELAY_AXIOM_TRACE_DATASET, + RELAY_OBSERVABILITY_EXPORT_INTERVAL, + RELAY_OBSERVABILITY_SERVICE_NAME, + provisionRelayObservability, +} from "./infra/RelayObservability.ts"; +import * as DeliveryAttempts from "./persistence/DeliveryAttempts.ts"; +import * as AgentActivityRows from "./persistence/AgentActivityRows.ts"; +import * as Devices from "./persistence/Devices.ts"; +import * as DpopProofs from "./persistence/DpopProofs.ts"; +import * as EnvironmentCredentials from "./persistence/EnvironmentCredentials.ts"; +import * as EnvironmentLinks from "./persistence/EnvironmentLinks.ts"; +import * as LiveActivities from "./persistence/LiveActivities.ts"; +import { RelayDb, RelayHyperdrive } from "./db.ts"; +import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts"; +import * as RelayConfiguration from "./Config.ts"; +import * as AgentActivityPublisher from "./services/AgentActivityPublisher.ts"; +import * as ApnsDeliveryQueue from "./services/ApnsDeliveryQueue.ts"; +import * as ApnsDeliveries from "./services/ApnsDeliveries.ts"; +import * as EnvironmentConnector from "./services/EnvironmentConnector.ts"; +import * as EnvironmentLinker from "./services/EnvironmentLinker.ts"; +import * as EnvironmentPublishSignatures from "./services/EnvironmentPublishSignatures.ts"; +import * as ManagedEndpointProvider from "./services/ManagedEndpointProvider.ts"; +import * as MobileRegistrations from "./services/MobileRegistrations.ts"; +import * as RelayCrypto from "./RelayCrypto.ts"; + +const relayApiLayer = Layer.mergeAll( + healthApi, + metadataApi, + mobileApi, + clientApi, + tokenApi, + dpopClientApi, + serverApi, +); + +const makeRelayTraceLayer = (input: { + readonly tracesEndpoint: string; + readonly tracesDatasetName: string; + readonly ingestToken: Redacted.Redacted; +}) => + OtlpTracer.layer({ + url: input.tracesEndpoint, + resource: { + serviceName: RELAY_OBSERVABILITY_SERVICE_NAME, + attributes: { + "service.runtime": "cloudflare-worker", + "service.component": "relay", + }, + }, + headers: { + Authorization: `Bearer ${Redacted.value(input.ingestToken)}`, + "X-Axiom-Dataset": input.tracesDatasetName, + }, + exportInterval: RELAY_OBSERVABILITY_EXPORT_INTERVAL, + }).pipe(Layer.provide(OtlpSerialization.layerJson)); + +// Bind secrets explicitly and only read them through the runtime worker +// environment. Reading them through Config during Worker init currently +// registers a competing plaintext binding. +const apnsPrivateKeyConfig = Config.redacted("APNS_PRIVATE_KEY"); +const clerkSecretKeyConfig = Config.redacted("CLERK_SECRET_KEY"); + +export default class Api extends Cloudflare.Worker()( + "Api", + { + main: import.meta.filename, + compatibility: { + date: "2026-05-22", + flags: ["nodejs_compat"], + }, + domain: RELAY_PUBLIC_DOMAIN, + env: { + APNS_PRIVATE_KEY: apnsPrivateKeyConfig, + CLERK_SECRET_KEY: clerkSecretKeyConfig, + }, + }, + Effect.gen(function* () { + const managedEndpointProvisionerToken = yield* Cloudflare.AccountApiToken( + "ManagedEndpointProvisionerToken", + { + name: "t3-code-relay-managed-endpoint-provisioner", + policies: MANAGED_ENDPOINT_PROVISIONER_TOKEN_POLICIES, + }, + ); + const managedEndpointCloudflareApiToken = yield* managedEndpointProvisionerToken.value; + const relayHyperdrive = yield* RelayHyperdrive; + const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; + const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; + const hyperdrive = yield* Cloudflare.Hyperdrive.bind(relayHyperdrive); + const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue); + const cloudMintKeyPair = yield* Alchemy.KeyPair("CloudMintKeyPair"); + const environment = yield* Config.schema( + RelayConfiguration.ApnsEnvironment, + "APNS_ENVIRONMENT", + ).pipe(Config.withDefault("sandbox")); + 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 relayObservability = yield* provisionRelayObservability; + const axiomIngestToken = yield* relayObservability.ingestToken.token; + const axiomTracesEndpoint = yield* relayObservability.traces.otelTracesEndpoint; + const relayTraceLayer = Effect.all({ + tracesEndpoint: axiomTracesEndpoint, + ingestToken: axiomIngestToken, + }).pipe( + Effect.map((input) => + makeRelayTraceLayer({ ...input, tracesDatasetName: RELAY_AXIOM_TRACE_DATASET }), + ), + Layer.unwrap, + ); + const randomApnsDeliveryJobSigningSecret = yield* Alchemy.Random( + "ApnsDeliveryJobSigningSecret", + { bytes: 32 }, + ); + const apnsDeliveryJobSigningSecret = yield* randomApnsDeliveryJobSigningSecret.text; + const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; + const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; + const db = yield* Drizzle.postgres(hyperdrive.connectionString); + + const loadSettings = Effect.gen(function* () { + const workerEnvironment = yield* Cloudflare.WorkerEnvironment; + const apnsPrivateKey = Redacted.make(workerEnvironment.APNS_PRIVATE_KEY); + const clerkSecretKey = Redacted.make(workerEnvironment.CLERK_SECRET_KEY); + return RelayConfiguration.RelayConfiguration.of({ + relayIssuer: RELAY_PUBLIC_ORIGIN, + apns: { + environment, + teamId: apnsTeamId, + keyId: apnsKeyId, + bundleId: apnsBundleId, + privateKey: apnsPrivateKey, + }, + apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret, + clerkSecretKey, + cloudMintPrivateKey: yield* cloudMintPrivateKey, + cloudMintPublicKey: yield* cloudMintPublicKey, + managedEndpointBaseDomain: MANAGED_ENDPOINT_BASE_DOMAIN, + cloudflareAccountId: MANAGED_ENDPOINT_ZONE.accountId, + cloudflareZoneId: MANAGED_ENDPOINT_ZONE.zoneId, + cloudflareApiToken: yield* managedEndpointCloudflareApiToken, + }); + }); + + const runtimeLayer = Layer.unwrap( + Effect.gen(function* () { + const settings = yield* loadSettings; + return Layer.mergeAll( + MobileRegistrations.layer.pipe(Layer.provideMerge(AgentActivityPublisher.layer)), + EnvironmentConnector.layer, + EnvironmentLinker.layer.pipe( + Layer.provideMerge(ManagedEndpointProvider.layer), + Layer.provideMerge(DpopProofs.layer), + ), + EnvironmentPublishSignatures.layer.pipe(Layer.provideMerge(DpopProofs.layer)), + DpopProofs.layer, + ).pipe( + Layer.provide(ApnsDeliveries.layer), + Layer.provide(ApnsDeliveryQueue.layer), + Layer.provide(AgentActivityRows.layer), + Layer.provide(Devices.layer), + Layer.provide(EnvironmentCredentials.layer), + Layer.provide(EnvironmentLinks.layer), + Layer.provide(LiveActivities.layer), + Layer.provide(DeliveryAttempts.layer), + Layer.provide(Layer.succeed(RelayDb, db)), + Layer.provide( + Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { + send: (body) => + apnsDeliveryQueueSender + .send(body) + .pipe( + Effect.mapError( + (cause) => new ApnsDeliveryQueue.ApnsDeliveryQueueSendError({ cause }), + ), + ) as Effect.Effect, + }), + ), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), + Layer.provide(RelayCrypto.layer), + ); + }), + ); + + const appLayer = Layer.unwrap( + Effect.gen(function* () { + const settings = yield* loadSettings; + return relayApiLayer.pipe( + Layer.provide(runtimeLayer), + Layer.provide(relayClientAuthLayer), + Layer.provide(relayDpopClientAuthLayer), + Layer.provide(relayEnvironmentAuthLayer), + Layer.provide(EnvironmentCredentials.layer), + Layer.provide(EnvironmentLinks.layer), + Layer.provide(Layer.succeed(RelayDb, db)), + Layer.provideMerge(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), + Layer.provide(RelayCrypto.layer), + ); + }), + ); + + 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( + Effect.fn("relay.apn_delivery_queue.process_message")((message) => + ApnsDeliveries.ApnsDeliveries.pipe( + Effect.flatMap((deliveries) => deliveries.processSignedJob(message.body)), + ), + ), + ), + 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( + HttpApiBuilder.layer(RelayApi).pipe( + Layer.provide(appLayer), + Layer.provide([Etag.layerWeak, RelayHttpPlatformLayer, relayCors]), + ), + relayNotFoundRoute, + ).pipe( + HttpRouter.toHttpEffect, + withoutCapturedParentSpan, + Effect.map((httpEffect) => traceRelayHttpRequestWith(httpEffect, relayTraceLayer)), + Effect.flatten, + ); + + return { fetch }; + }).pipe( + Effect.provide( + Layer.mergeAll( + Cloudflare.HyperdriveBindingLive, + Cloudflare.CronEventSourceLive, + Cloudflare.QueueBindingLive, + Cloudflare.QueueEventSourceLive, + ), + ), + ), +) {} diff --git a/infra/relay/tsconfig.json b/infra/relay/tsconfig.json new file mode 100644 index 00000000000..67e2a2c7736 --- /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", "src/**/*.ts"] +} diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts index 51efb313ee5..7aa36d29fb5 100644 --- a/packages/client-runtime/src/index.ts +++ b/packages/client-runtime/src/index.ts @@ -26,3 +26,4 @@ export * from "./composerPathSearchState.ts"; export * from "./archivedThreadsState.ts"; export * from "./checkpointDiffState.ts"; export * from "./remote.ts"; +export * from "./managedRelay.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..4eefdb29d0f --- /dev/null +++ b/packages/client-runtime/src/managedRelay.test.ts @@ -0,0 +1,114 @@ +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) { + 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: "https://relay.example.test", + clientId: "t3-mobile", + }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); +} + +describe("ManagedRelayClient", () => { + 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)))); + }); +}); diff --git a/packages/client-runtime/src/managedRelay.ts b/packages/client-runtime/src/managedRelay.ts new file mode 100644 index 00000000000..90d41a7ede4 --- /dev/null +++ b/packages/client-runtime/src/managedRelay.ts @@ -0,0 +1,470 @@ +import { + RelayAccessTokenType, + RelayApi, + type RelayClientEnvironmentRecord, + 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 * 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 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, + }; +} + +export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { + return Layer.effect( + ManagedRelayClient, + Effect.gen(function* () { + const signer = yield* ManagedRelayDpopSigner; + const client = yield* HttpApiClient.make(RelayApi, { baseUrl: options.relayUrl }); + const cachedTokens = yield* SynchronizedRef.make>([]); + const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: options.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: options.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: options.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."), + ), + 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/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..896a218ade7 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", )<{ @@ -170,6 +178,35 @@ export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => }); }); +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 +221,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 +253,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 +304,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 +346,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/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..064d38499b9 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,29 @@ 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), +}); +export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; + export const AuthPairingLinkRevokeResult = Schema.Struct({ revoked: Schema.Boolean, }); @@ -244,6 +357,7 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") ) .add( HttpApiEndpoint.post("token", "/oauth/token", { + headers: OptionalDpopProofHeaders, payload: AuthTokenExchangeRequest, success: AuthAccessTokenResult, error: EnvironmentTokenExchangeErrors, @@ -319,7 +433,61 @@ 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("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/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..f0397c1596c --- /dev/null +++ b/packages/contracts/src/relay.ts @@ -0,0 +1,868 @@ +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, + 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 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), + proof: TrimmedNonEmptyString, +}); +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, + liveActivitiesEnabled: Schema.Boolean, + managedTunnelsEnabled: Schema.Boolean, +}); +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), + proof: RelayEnvironmentLinkProof, + notificationsEnabled: Schema.Boolean, + liveActivitiesEnabled: Schema.Boolean, + managedTunnelsEnabled: Schema.Boolean, +}); +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") {} + +export class RelayClientAuth extends HttpApiMiddleware.Service< + RelayClientAuth, + { provides: RelayClientPrincipal } +>()("RelayClientAuth", { + error: RelayAuthInvalidError, + security: { bearer: HttpApiSecurity.bearer }, +}) {} + +export class RelayEnvironmentAuth extends HttpApiMiddleware.Service< + RelayEnvironmentAuth, + { provides: RelayEnvironmentPrincipal } +>()("RelayEnvironmentAuth", { + error: [RelayAuthInvalidError, RelayInternalError], + security: { bearer: HttpApiSecurity.bearer }, +}) {} + +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), + clientKeyThumbprint: Schema.optional(TrimmedNonEmptyString), + clientProofKeyThumbprint: Schema.optional(TrimmedNonEmptyString), +}); +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, + subject_token_type: Schema.Literal(RelayJwtSubjectTokenType), + requested_token_type: Schema.Literal(RelayAccessTokenType), + resource: TrimmedNonEmptyString, + scope: TrimmedNonEmptyString, + client_id: RelayPublicClientId, +}).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, + }), +); + +export const RelayMetadataGroup = HttpApiGroup.make("metadata").add( + HttpApiEndpoint.get("authorizationServer", "/.well-known/oauth-authorization-server", { + success: RelayAuthorizationServerMetadata, + }), + HttpApiEndpoint.get("protectedResource", "/.well-known/oauth-protected-resource", { + success: RelayProtectedResourceMetadata, + }), +); + +export const RelayRegisterDeviceEndpoint = HttpApiEndpoint.post( + "registerDevice", + "/v1/mobile/devices", + { + headers: RelayDpopRequestHeaders, + payload: RelayDeviceRegistrationRequest, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }, +); + +export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post( + "registerLiveActivity", + "/v1/mobile/live-activities", + { + headers: RelayDpopRequestHeaders, + payload: RelayLiveActivityRegistrationRequest, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }, +); + +export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( + "unregisterDevice", + "/v1/mobile/devices/:deviceId", + { + headers: RelayDpopRequestHeaders, + params: RelayDeviceUnregistrationParams, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }, +); + +export const RelayMobileGroup = HttpApiGroup.make("mobile") + .add( + RelayRegisterDeviceEndpoint, + RelayRegisterLiveActivityEndpoint, + RelayUnregisterDeviceEndpoint, + ) + .middleware(RelayDpopClientAuth); + +export const RelayClientGroup = HttpApiGroup.make("client") + .add( + HttpApiEndpoint.get("listEnvironments", "/v1/environments", { + headers: RelayBearerRequestHeaders, + success: RelayListEnvironmentsResponse, + error: RelayAuthAndInternalErrors, + }), + HttpApiEndpoint.post("linkEnvironment", "/v1/client/environment-links", { + headers: RelayBearerRequestHeaders, + payload: RelayEnvironmentLinkRequest, + success: RelayEnvironmentLinkResponse, + error: RelayEnvironmentLinkErrors, + }), + HttpApiEndpoint.post( + "createEnvironmentLinkChallenge", + "/v1/client/environment-link-challenges", + { + headers: RelayBearerRequestHeaders, + payload: RelayEnvironmentLinkChallengeRequest, + success: RelayEnvironmentLinkChallengeResponse, + error: RelayAuthAndInternalErrors, + }, + ), + HttpApiEndpoint.delete("unlinkEnvironment", "/v1/client/environment-links/:environmentId", { + headers: RelayBearerRequestHeaders, + params: RelayEnvironmentUnlinkParams, + success: RelayOkResponse, + error: RelayAuthAndInternalErrors, + }), + ) + .middleware(RelayClientAuth); + +export const RelayExchangeDpopAccessTokenEndpoint = HttpApiEndpoint.post( + "exchangeDpopAccessToken", + "/v1/client/dpop-token", + { + headers: RelayDpopProofRequestHeaders, + payload: RelayDpopAccessTokenRequest, + success: RelayDpopAccessTokenResponse, + error: RelayAuthAndInternalErrors, + }, +); + +export const RelayTokenGroup = HttpApiGroup.make("token").add(RelayExchangeDpopAccessTokenEndpoint); + +export const RelayConnectEnvironmentEndpoint = HttpApiEndpoint.post( + "connectEnvironment", + "/v1/environments/:environmentId/connect", + { + headers: RelayDpopRequestHeaders, + params: Schema.Struct({ + environmentId: EnvironmentId, + }), + payload: RelayEnvironmentConnectRequest, + success: RelayEnvironmentConnectResponse, + error: RelayEnvironmentConnectErrors, + }, +); + +export const RelayGetEnvironmentStatusEndpoint = HttpApiEndpoint.post( + "getEnvironmentStatus", + "/v1/environments/:environmentId/status", + { + headers: RelayDpopRequestHeaders, + params: Schema.Struct({ + environmentId: EnvironmentId, + }), + success: RelayEnvironmentStatusResponse, + error: RelayEnvironmentConnectErrors, + }, +); + +export const RelayDpopClientGroup = HttpApiGroup.make("dpopClient") + .add(RelayConnectEnvironmentEndpoint, RelayGetEnvironmentStatusEndpoint) + .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, + }, + ), + ) + .middleware(RelayEnvironmentAuth); + +export const RelayApi = HttpApi.make("RelayApi").add( + RelayHealthGroup, + RelayMetadataGroup, + RelayMobileGroup, + RelayClientGroup, + RelayTokenGroup, + RelayDpopClientGroup, + RelayServerGroup, +); +export type RelayApi = typeof RelayApi; diff --git a/packages/shared/package.json b/packages/shared/package.json index ea4233a9d95..a447c40407d 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" @@ -83,6 +87,26 @@ "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" + }, + "./relayJwt": { + "types": "./src/relayJwt.ts", + "import": "./src/relayJwt.ts" + }, "./oauthScope": { "types": "./src/oauthScope.ts", "import": "./src/oauthScope.ts" @@ -121,8 +145,11 @@ "test": "vp test run" }, "dependencies": { + "@noble/curves": "catalog:", + "@noble/hashes": "catalog:", "@t3tools/contracts": "workspace:*", "effect": "catalog:", + "jose": "catalog:", "yaml": "catalog:" }, "devDependencies": { 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.ts b/packages/shared/src/relayAuth.ts new file mode 100644 index 00000000000..0f8c139d413 --- /dev/null +++ b/packages/shared/src/relayAuth.ts @@ -0,0 +1,6 @@ +export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay"; + +export const RELAY_CLERK_TOKEN_OPTIONS = { + template: RELAY_CLERK_JWT_TEMPLATE, + skipCache: true, +} as const; 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/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4897eb14278..644bb663dec 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "apps/*" + - "infra/*" - "oxlint-plugin-t3code" - "packages/*" - "scripts" @@ -21,14 +22,19 @@ 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 @@ -39,6 +45,7 @@ overrides: "@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:" diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f86c94e5d91..0ee2e38880e 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -717,6 +717,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"], + }, + ], }; } From 02353974fbb6ff38fd0bd793def7e91556d9c797 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 31 May 2026 23:44:49 -0700 Subject: [PATCH 02/61] refactor relay/ --- .vscode/settings.json | 1 + infra/relay/alchemy.run.ts | 2 +- infra/relay/package.json | 15 +- infra/relay/src/Config.ts | 2 +- infra/relay/src/RelayCrypto.ts | 16 - .../AgentActivityPayloads.ts} | 2 +- .../AgentActivityPublisher.test.ts | 6 +- .../AgentActivityPublisher.ts | 54 +-- .../AgentActivityRows.ts | 17 +- .../src/agentActivity/ApnsClient.test.ts | 140 ++++++ .../{apns.ts => agentActivity/ApnsClient.ts} | 116 +++-- .../ApnsDeliveries.test.ts | 8 +- .../ApnsDeliveries.ts | 450 +++++++++--------- .../ApnsDeliveryJobs.test.ts} | 2 +- .../ApnsDeliveryJobs.ts} | 0 .../ApnsDeliveryQueue.ts | 11 +- .../DeliveryAttempts.test.ts | 2 +- .../DeliveryAttempts.ts | 6 +- .../Devices.test.ts | 2 +- .../{persistence => agentActivity}/Devices.ts | 8 +- .../LiveActivities.test.ts | 2 +- .../LiveActivities.ts | 24 +- .../MobileRegistrations.test.ts | 15 +- .../MobileRegistrations.ts | 66 ++- infra/relay/src/apns.test.ts | 116 ----- .../{persistence => auth}/DpopProofs.test.ts | 2 +- infra/relay/src/auth/DpopProofs.ts | 128 +++++ .../DpopProofs.verifyAndConsume.test.ts} | 126 ++--- infra/relay/src/auth/RelayTokens.test.ts | 188 ++++++++ infra/relay/src/auth/RelayTokens.ts | 220 +++++++++ infra/relay/src/db.test.ts | 182 ------- infra/relay/src/db.ts | 19 +- infra/relay/src/dpop.ts | 61 --- .../EnvironmentConnector.test.ts | 56 +-- .../EnvironmentConnector.ts | 311 ++++++------ .../EnvironmentCredentials.test.ts | 2 +- .../EnvironmentCredentials.ts | 4 +- .../EnvironmentLinker.test.ts | 14 +- .../src/environments/EnvironmentLinker.ts | 263 ++++++++++ .../EnvironmentLinks.test.ts | 53 ++- .../EnvironmentLinks.ts | 12 +- .../EnvironmentPublishSignatures.test.ts | 18 +- .../EnvironmentPublishSignatures.ts | 16 +- .../ManagedEndpointProvider.test.ts | 0 .../ManagedEndpointProvider.ts | 2 +- .../src/{api.test.ts => http/Api.test.ts} | 42 +- infra/relay/src/{api.ts => http/Api.ts} | 192 ++++---- .../infra/ManagedEndpointStackConfig.test.ts | 27 -- .../src/infra/ManagedEndpointStackConfig.ts | 26 - .../src/infra/RelayObservability.test.ts | 82 ---- infra/relay/src/infra/RelayObservability.ts | 58 --- infra/relay/src/managedEndpointStack.ts | 34 ++ infra/relay/src/observability.ts | 72 +++ infra/relay/src/persistence/DpopProofs.ts | 68 --- infra/relay/src/persistence/json.ts | 10 - infra/relay/src/{ => persistence}/schema.ts | 0 infra/relay/src/relayTokens.test.ts | 196 -------- infra/relay/src/relayTokens.ts | 186 -------- infra/relay/src/services/Auth.ts | 19 - infra/relay/src/services/EnvironmentLinker.ts | 270 ----------- infra/relay/src/telemetry.test.ts | 31 -- infra/relay/src/telemetry.ts | 10 - infra/relay/src/worker.ts | 217 ++++----- 63 files changed, 1979 insertions(+), 2321 deletions(-) delete mode 100644 infra/relay/src/RelayCrypto.ts rename infra/relay/src/{agentActivityPayloads.ts => agentActivity/AgentActivityPayloads.ts} (96%) rename infra/relay/src/{services => agentActivity}/AgentActivityPublisher.test.ts (98%) rename infra/relay/src/{services => agentActivity}/AgentActivityPublisher.ts (84%) rename infra/relay/src/{persistence => agentActivity}/AgentActivityRows.ts (90%) create mode 100644 infra/relay/src/agentActivity/ApnsClient.test.ts rename infra/relay/src/{apns.ts => agentActivity/ApnsClient.ts} (72%) rename infra/relay/src/{services => agentActivity}/ApnsDeliveries.test.ts (99%) rename infra/relay/src/{services => agentActivity}/ApnsDeliveries.ts (69%) rename infra/relay/src/{apnsDeliveryJobs.test.ts => agentActivity/ApnsDeliveryJobs.test.ts} (99%) rename infra/relay/src/{apnsDeliveryJobs.ts => agentActivity/ApnsDeliveryJobs.ts} (100%) rename infra/relay/src/{services => agentActivity}/ApnsDeliveryQueue.ts (94%) rename infra/relay/src/{persistence => agentActivity}/DeliveryAttempts.test.ts (99%) rename infra/relay/src/{persistence => agentActivity}/DeliveryAttempts.ts (97%) rename infra/relay/src/{persistence => agentActivity}/Devices.test.ts (98%) rename infra/relay/src/{persistence => agentActivity}/Devices.ts (96%) rename infra/relay/src/{persistence => agentActivity}/LiveActivities.test.ts (99%) rename infra/relay/src/{persistence => agentActivity}/LiveActivities.ts (94%) rename infra/relay/src/{services => agentActivity}/MobileRegistrations.test.ts (96%) rename infra/relay/src/{services => agentActivity}/MobileRegistrations.ts (64%) delete mode 100644 infra/relay/src/apns.test.ts rename infra/relay/src/{persistence => auth}/DpopProofs.test.ts (98%) create mode 100644 infra/relay/src/auth/DpopProofs.ts rename infra/relay/src/{dpop.test.ts => auth/DpopProofs.verifyAndConsume.test.ts} (63%) create mode 100644 infra/relay/src/auth/RelayTokens.test.ts create mode 100644 infra/relay/src/auth/RelayTokens.ts delete mode 100644 infra/relay/src/db.test.ts delete mode 100644 infra/relay/src/dpop.ts rename infra/relay/src/{services => environments}/EnvironmentConnector.test.ts (91%) rename infra/relay/src/{services => environments}/EnvironmentConnector.ts (53%) rename infra/relay/src/{persistence => environments}/EnvironmentCredentials.test.ts (98%) rename infra/relay/src/{persistence => environments}/EnvironmentCredentials.ts (97%) rename infra/relay/src/{services => environments}/EnvironmentLinker.test.ts (94%) create mode 100644 infra/relay/src/environments/EnvironmentLinker.ts rename infra/relay/src/{persistence => environments}/EnvironmentLinks.test.ts (57%) rename infra/relay/src/{persistence => environments}/EnvironmentLinks.ts (97%) rename infra/relay/src/{services => environments}/EnvironmentPublishSignatures.test.ts (91%) rename infra/relay/src/{services => environments}/EnvironmentPublishSignatures.ts (92%) rename infra/relay/src/{services => environments}/ManagedEndpointProvider.test.ts (100%) rename infra/relay/src/{services => environments}/ManagedEndpointProvider.ts (99%) rename infra/relay/src/{api.test.ts => http/Api.test.ts} (78%) rename infra/relay/src/{api.ts => http/Api.ts} (84%) delete mode 100644 infra/relay/src/infra/ManagedEndpointStackConfig.test.ts delete mode 100644 infra/relay/src/infra/ManagedEndpointStackConfig.ts delete mode 100644 infra/relay/src/infra/RelayObservability.test.ts delete mode 100644 infra/relay/src/infra/RelayObservability.ts create mode 100644 infra/relay/src/managedEndpointStack.ts create mode 100644 infra/relay/src/observability.ts delete mode 100644 infra/relay/src/persistence/DpopProofs.ts delete mode 100644 infra/relay/src/persistence/json.ts rename infra/relay/src/{ => persistence}/schema.ts (100%) delete mode 100644 infra/relay/src/relayTokens.test.ts delete mode 100644 infra/relay/src/relayTokens.ts delete mode 100644 infra/relay/src/services/Auth.ts delete mode 100644 infra/relay/src/services/EnvironmentLinker.ts delete mode 100644 infra/relay/src/telemetry.test.ts delete mode 100644 infra/relay/src/telemetry.ts 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/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index d80e3483bb8..19a04f7bc04 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -1,3 +1,4 @@ +// @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"; @@ -13,7 +14,6 @@ import Api from "./src/worker.ts"; export default Alchemy.Stack( "T3CodeRelay", { - // @effect-diagnostics-next-line anyUnknownInErrorContext:off layerMergeAllWithDependencies:off - Alchemy provider helpers expose framework-owned any requirements. providers: Layer.mergeAll( Axiom.providers(), Cloudflare.providers(), diff --git a/infra/relay/package.json b/infra/relay/package.json index 15ff6a5c92b..968b0aa8458 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -6,27 +6,24 @@ "deploy": "alchemy deploy", "destroy": "alchemy destroy", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsgo --noEmit" }, "dependencies": { - "@clerk/backend": "^3.4.14", - "@distilled.cloud/cloudflare": "^0.21.5", + "@clerk/backend": "3.4.14", "@effect/sql-pg": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", - "alchemy": "2.0.0-beta.45", - "drizzle-orm": "^1.0.0-rc.1", + "alchemy": "2.0.0-beta.47", + "drizzle-orm": "1.0.0-rc.3", "effect": "catalog:" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20260520.0", + "@cloudflare/workers-types": "^4.20260601.1", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@types/node": "catalog:", - "@types/pg": "^8.15.6", - "drizzle-kit": "^1.0.0-rc.1", - "typescript": "catalog:", + "drizzle-kit": "1.0.0-rc.3", "vitest": "catalog:" } } diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index 4cd54e86d8a..f6c1eb596f1 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -29,4 +29,4 @@ export interface RelayConfigurationShape { export class RelayConfiguration extends Context.Service< RelayConfiguration, RelayConfigurationShape ->()("RelayConfiguration") {} +>()("t3code-relay/Config/RelayConfiguration") {} diff --git a/infra/relay/src/RelayCrypto.ts b/infra/relay/src/RelayCrypto.ts deleted file mode 100644 index a0c26735978..00000000000 --- a/infra/relay/src/RelayCrypto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as Crypto from "effect/Crypto"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -export const layer = 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)); - }), - }), -); diff --git a/infra/relay/src/agentActivityPayloads.ts b/infra/relay/src/agentActivity/AgentActivityPayloads.ts similarity index 96% rename from infra/relay/src/agentActivityPayloads.ts rename to infra/relay/src/agentActivity/AgentActivityPayloads.ts index ed3fc3f0116..d1e23da8912 100644 --- a/infra/relay/src/agentActivityPayloads.ts +++ b/infra/relay/src/agentActivity/AgentActivityPayloads.ts @@ -2,7 +2,7 @@ import type { RelayAgentActivityAggregateRow, RelayAgentActivityAggregateState, } from "@t3tools/contracts/relay"; -import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; +import type { ApnsNotificationPayload } from "./ApnsDeliveryJobs.ts"; const MAX_SUMMARY_TEXT_LENGTH = 120; const MAX_STATUS_TEXT_LENGTH = 40; diff --git a/infra/relay/src/services/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts similarity index 98% rename from infra/relay/src/services/AgentActivityPublisher.test.ts rename to infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index b63b125b0b1..5f27c2f1821 100644 --- a/infra/relay/src/services/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as AgentActivityRows from "../persistence/AgentActivityRows.ts"; -import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; -import * as LiveActivities from "../persistence/LiveActivities.ts"; +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"; diff --git a/infra/relay/src/services/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts similarity index 84% rename from infra/relay/src/services/AgentActivityPublisher.ts rename to infra/relay/src/agentActivity/AgentActivityPublisher.ts index e2e53f2cdd6..9e6370b787c 100644 --- a/infra/relay/src/services/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -9,11 +9,10 @@ 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 "../persistence/AgentActivityRows.ts"; -import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; -import * as LiveActivities from "../persistence/LiveActivities.ts"; -import { withUserId } from "../telemetry.ts"; +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 = @@ -40,7 +39,7 @@ export interface AgentActivityPublisherShape { export class AgentActivityPublisher extends Context.Service< AgentActivityPublisher, AgentActivityPublisherShape ->()("AgentActivityPublisher") {} +>()("t3code-relay/agentActivity/AgentActivityPublisher") {} const make = Effect.gen(function* () { const rows = yield* AgentActivityRows.AgentActivityRows; @@ -97,28 +96,25 @@ const make = Effect.gen(function* () { 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, - }); - }, - (effect, input) => effect.pipe(withUserId(input.userId)), - ), + )(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, @@ -150,7 +146,7 @@ const make = Effect.gen(function* () { deliveryUser, state: input.state, nowMs: now.epochMilliseconds, - }).pipe(withUserId(deliveryUser.userId)), + }), { concurrency: 4 }, ); const deliveries = deliveriesByUser.flat(); diff --git a/infra/relay/src/persistence/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts similarity index 90% rename from infra/relay/src/persistence/AgentActivityRows.ts rename to infra/relay/src/agentActivity/AgentActivityRows.ts index 1f59c21e8b3..00d3f5f7800 100644 --- a/infra/relay/src/persistence/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -4,14 +4,14 @@ 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 "../schema.ts"; -import { parseJsonString, stringifyJsonValue } from "./json.ts"; +import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; export class AgentActivityRowUpsertPersistenceError extends Data.TaggedError( "AgentActivityRowUpsertPersistenceError", @@ -47,9 +47,12 @@ export interface AgentActivityRowsShape { } export class AgentActivityRows extends Context.Service()( - "AgentActivityRows", + "t3code-relay/agentActivity/AgentActivityRows", ) {} +const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); +const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); + const encodeRelayAgentActivityStateJson = Schema.encodeEffect( Schema.fromJsonString(RelayAgentActivityStateSchema), ); @@ -69,8 +72,9 @@ const make = Effect.gen(function* () { "relay.thread_id": input.state.threadId, }); const now = yield* DateTime.now; - const stateJson = yield* parseJsonString( - yield* encodeRelayAgentActivityStateJson(input.state), + const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( + Effect.flatMap(decodeJsonString), + Effect.map(cast), ); yield* db .insert(relayAgentActivityRows) @@ -115,7 +119,6 @@ const make = Effect.gen(function* () { }), listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) { - yield* Effect.annotateCurrentSpan({ "user.id": input.userId }); return yield* db .select({ stateJson: relayAgentActivityRows.stateJson }) .from(relayAgentActivityRows) @@ -139,7 +142,7 @@ const make = Effect.gen(function* () { .orderBy(desc(relayAgentActivityRows.updatedAt)) .pipe( Effect.flatMap((rows) => - Effect.forEach(rows, (row) => stringifyJsonValue(row.stateJson), { + Effect.forEach(rows, (row) => encodeJsonValue(row.stateJson), { concurrency: "unbounded", }), ), 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/apns.ts b/infra/relay/src/agentActivity/ApnsClient.ts similarity index 72% rename from infra/relay/src/apns.ts rename to infra/relay/src/agentActivity/ApnsClient.ts index 3d1063e623f..0830a036cb4 100644 --- a/infra/relay/src/apns.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -1,9 +1,11 @@ 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"; @@ -14,8 +16,8 @@ import { type HttpBody, type HttpClientError, } from "effect/unstable/http"; -import type { ApnsCredentials } from "./Config.ts"; -import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; +import type { ApnsCredentials } from "../Config.ts"; +import type { ApnsNotificationPayload } from "./ApnsDeliveryJobs.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity"; const STALE_AFTER_SECONDS = 2 * 60; @@ -23,14 +25,14 @@ const DISMISS_AFTER_SECONDS = 5 * 60; export type ApnsLiveActivityEvent = "start" | "update" | "end"; -export interface ApnsLiveActivityRequest { +interface ApnsLiveActivityRequest { readonly token: string; readonly event: ApnsLiveActivityEvent; readonly priority: "5" | "10"; readonly payload: unknown; } -export interface ApnsPushNotificationRequest { +interface ApnsPushNotificationRequest { readonly token: string; readonly priority: "10"; readonly payload: unknown; @@ -44,6 +46,7 @@ export interface ApnsDeliveryResult { } export class ApnsSigningError extends Data.TaggedError("ApnsSigningError")<{ + readonly phase: "encoding" | "signing"; readonly cause: unknown; }> {} @@ -64,21 +67,44 @@ const decodeApnsErrorResponseJson = Schema.decodeUnknownOption( }), ), ); +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, + }), + ), +); -function makeApnsJwt(input: { +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; -}): Effect.Effect { - return Effect.try({ +}) { + 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 privateKey = Redacted.value(input.privateKey); - const header = Encoding.encodeBase64Url(JSON.stringify({ alg: "ES256", kid: input.keyId })); - const payload = Encoding.encodeBase64Url( - JSON.stringify({ iss: input.teamId, iat: input.issuedAtUnixSeconds }), - ); - const signingInput = `${header}.${payload}`; const signature = NodeCrypto.createSign("sha256") .update(signingInput) .sign({ @@ -87,9 +113,9 @@ function makeApnsJwt(input: { }); return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; }, - catch: (cause) => new ApnsSigningError({ cause }), + catch: (cause) => new ApnsSigningError({ cause, phase: "signing" }), }); -} +}); function contentState(state: RelayAgentActivityAggregateState) { return { @@ -104,7 +130,7 @@ interface LiveActivityRequestBase { readonly nowIso: string; } -export type MakeLiveActivityRequestInput = +type MakeLiveActivityRequestInput = | (LiveActivityRequestBase & { readonly event: "end"; readonly state: RelayAgentActivityAggregateState | null; @@ -114,9 +140,7 @@ export type MakeLiveActivityRequestInput = readonly state: RelayAgentActivityAggregateState; }); -export function makeLiveActivityRequest( - input: MakeLiveActivityRequestInput, -): ApnsLiveActivityRequest { +function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest { const timestamp = input.nowEpochSeconds; if (input.event === "end") { return { @@ -161,7 +185,7 @@ export function makeLiveActivityRequest( }; } -export function makePushNotificationRequest(input: { +function makePushNotificationRequest(input: { readonly token: string; readonly notification: ApnsNotificationPayload; }): ApnsPushNotificationRequest { @@ -193,12 +217,31 @@ function apnsReasonFromBody(body: string): string | undefined { }); } -export const sendLiveActivityRequest = Effect.fn("relay.apns.send_live_activity_request")( - function* (input: { +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, @@ -208,7 +251,6 @@ export const sendLiveActivityRequest = Effect.fn("relay.apns.send_live_activity_ input.credentials.environment === "production" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"; - const httpClient = yield* HttpClient.HttpClient; const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe( HttpClientRequest.setHeaders({ authorization: `bearer ${jwt}`, @@ -230,15 +272,11 @@ export const sendLiveActivityRequest = Effect.fn("relay.apns.send_live_activity_ ...(reason === undefined ? {} : { reason }), apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), }; - }, -); + }); -export const sendPushNotificationRequest = Effect.fn("relay.apns.send_push_notification_request")( - function* (input: { - readonly credentials: ApnsCredentials; - readonly request: ApnsPushNotificationRequest; - readonly issuedAtUnixSeconds: number; - }) { + 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, @@ -248,7 +286,6 @@ export const sendPushNotificationRequest = Effect.fn("relay.apns.send_push_notif input.credentials.environment === "production" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"; - const httpClient = yield* HttpClient.HttpClient; const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe( HttpClientRequest.setHeaders({ authorization: `bearer ${jwt}`, @@ -270,5 +307,14 @@ export const sendPushNotificationRequest = Effect.fn("relay.apns.send_push_notif ...(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/services/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts similarity index 99% rename from infra/relay/src/services/ApnsDeliveries.test.ts rename to infra/relay/src/agentActivity/ApnsDeliveries.test.ts index e0995570893..ea5ca898b45 100644 --- a/infra/relay/src/services/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -19,12 +19,13 @@ import { makeApnsDeliveryJobPayload, signApnsDeliveryJob, type SignedApnsDeliveryJob, -} from "../apnsDeliveryJobs.ts"; -import * as DeliveryAttempts from "../persistence/DeliveryAttempts.ts"; -import * as LiveActivities from "../persistence/LiveActivities.ts"; +} 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", @@ -158,6 +159,7 @@ function makeLayer(input: { ) => Effect.Effect; }) { return ApnsDeliveries.layer.pipe( + Layer.provide(ApnsClient.layer), Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), Layer.provide( Layer.mergeAll( diff --git a/infra/relay/src/services/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts similarity index 69% rename from infra/relay/src/services/ApnsDeliveries.ts rename to infra/relay/src/agentActivity/ApnsDeliveries.ts index db399fa24b9..6975ea09da2 100644 --- a/infra/relay/src/services/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -15,25 +15,24 @@ 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 { HttpClient } from "effect/unstable/http"; import { sanitizeAgentActivityAggregateState, sanitizeApnsNotificationPayload, -} from "../agentActivityPayloads.ts"; -import * as Apns from "../apns.ts"; +} from "./AgentActivityPayloads.ts"; +import * as Apns from "./ApnsClient.ts"; import { ApnsDeliveryJobInvalid, type ApnsNotificationPayload, SignedApnsDeliveryJob, verifySignedApnsDeliveryJob, type ApnsDeliveryJobVerificationError, -} from "../apnsDeliveryJobs.ts"; -import * as DeliveryAttempts from "../persistence/DeliveryAttempts.ts"; -import * as LiveActivities from "../persistence/LiveActivities.ts"; +} from "./ApnsDeliveryJobs.ts"; +import * as DeliveryAttempts from "./DeliveryAttempts.ts"; +import * as LiveActivities from "./LiveActivities.ts"; import * as RelayConfiguration from "../Config.ts"; -import { withUserId } from "../telemetry.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([ @@ -351,6 +350,7 @@ export type SendLiveActivityDeliveryInput = }); function makeLiveActivityDeliveryRequest( + apns: Apns.ApnsClientShape, input: SendLiveActivityDeliveryInput, now: DateTime.DateTime, ) { @@ -366,7 +366,7 @@ function makeLiveActivityDeliveryRequest( return { epochSeconds, iso: base.nowIso, - request: Apns.makeLiveActivityRequest({ + request: apns.makeLiveActivityRequest({ ...base, event: deliveryEvent(input.kind), state: input.aggregate, @@ -376,7 +376,7 @@ function makeLiveActivityDeliveryRequest( return { epochSeconds, iso: base.nowIso, - request: Apns.makeLiveActivityRequest({ + request: apns.makeLiveActivityRequest({ ...base, event: "end", state: input.aggregate, @@ -410,7 +410,7 @@ export interface ApnsDeliveriesShape { } export class ApnsDeliveries extends Context.Service()( - "ApnsDeliveries", + "t3code-relay/agentActivity/ApnsDeliveries", ) {} const make = Effect.gen(function* () { @@ -418,7 +418,7 @@ const make = Effect.gen(function* () { const liveActivities = yield* LiveActivities.LiveActivities; const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; const config = yield* RelayConfiguration.RelayConfiguration; - const httpClient = yield* HttpClient.HttpClient; + const apns = yield* Apns.ApnsClient; const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { readonly target: LiveActivityDeliveryTarget; @@ -438,55 +438,56 @@ const make = Effect.gen(function* () { 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 } : {}), + )(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, }); - const now = yield* DateTime.now; - const aggregate = - input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate); - const { epochSeconds, iso, request } = makeLiveActivityDeliveryRequest( - { ...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, + 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, - 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, + apnsReason: "Stale APNs delivery job skipped.", }); - if (!tokenIsCurrent) { - yield* attempts.completeSourceJob({ - sourceJobId: input.sourceJobId, - apnsReason: "Stale APNs delivery job skipped.", - }); - return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); - } + return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); } - const result = yield* Apns.sendLiveActivityRequest({ + } + const result = yield* apns + .sendLiveActivityRequest({ credentials: config.apns, request, issuedAtUnixSeconds: epochSeconds, - }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), + }) + .pipe( Effect.catch((error) => Effect.succeed({ ok: false, @@ -496,116 +497,114 @@ const make = Effect.gen(function* () { }), ), ); - 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 { + if (result.ok) { + yield* liveActivities.markDelivery({ + userId: input.target.user_id, 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, - }; - }, - (effect, input) => effect.pipe(withUserId(input.target.user_id)), - ); + 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({ + )(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, - notification, }); - if (input.sourceJobId) { - const claim = yield* attempts.claimSourceJob({ - userId: input.target.user_id, - environmentId: notification.environmentId, - threadId: notification.threadId, + 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, - token: input.token, + apnsReason: "Stale APNs delivery job skipped.", }); - 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, + return staleJobResult({ + deviceId: input.target.device_id, 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({ + } + const result = yield* apns + .sendPushNotificationRequest({ credentials: config.apns, request, issuedAtUnixSeconds: epochSeconds, - }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), + }) + .pipe( Effect.catch((error) => Effect.succeed({ ok: false, @@ -615,41 +614,39 @@ const make = Effect.gen(function* () { }), ), ); - 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 { + if (isPermanentApnsTokenFailure(result)) { + yield* liveActivities.invalidateDeliveryToken({ + userId: input.target.user_id, 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, - }; - }, - (effect, input) => effect.pipe(withUserId(input.target.user_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", @@ -672,56 +669,61 @@ const make = Effect.gen(function* () { return yield* payload; } yield* Effect.annotateCurrentSpan({ - "user.id": payload.target.userId, "relay.mobile.device_id": payload.target.deviceId, "relay.delivery.kind": payload.kind, "relay.delivery.job_id": payload.jobId, }); - switch (payload.kind) { - case "live_activity_start": - case "live_activity_update": - if (payload.aggregate === null) { - return yield* new ApnsDeliveryJobInvalid({ - message: "Live Activity start/update jobs require an aggregate.", + 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, }); - } - return yield* 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 yield* 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 yield* new ApnsDeliveryJobInvalid({ - message: "Push notification jobs require a notification payload.", + 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, }); - } - return yield* sendPushNotification({ - target: { - user_id: payload.target.userId, - device_id: payload.target.deviceId, - }, - token: payload.target.token, - sourceJobId: payload.jobId, - notification: payload.notification, - }); - } + 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({ diff --git a/infra/relay/src/apnsDeliveryJobs.test.ts b/infra/relay/src/agentActivity/ApnsDeliveryJobs.test.ts similarity index 99% rename from infra/relay/src/apnsDeliveryJobs.test.ts rename to infra/relay/src/agentActivity/ApnsDeliveryJobs.test.ts index 428dc3a82b6..44747264dd4 100644 --- a/infra/relay/src/apnsDeliveryJobs.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryJobs.test.ts @@ -7,7 +7,7 @@ import { makeApnsDeliveryJobPayload, signApnsDeliveryJob, verifySignedApnsDeliveryJob, -} from "./apnsDeliveryJobs.ts"; +} from "./ApnsDeliveryJobs.ts"; const secret = Redacted.make("queue-signing-secret"); const aggregate: RelayAgentActivityAggregateState = { diff --git a/infra/relay/src/apnsDeliveryJobs.ts b/infra/relay/src/agentActivity/ApnsDeliveryJobs.ts similarity index 100% rename from infra/relay/src/apnsDeliveryJobs.ts rename to infra/relay/src/agentActivity/ApnsDeliveryJobs.ts diff --git a/infra/relay/src/services/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts similarity index 94% rename from infra/relay/src/services/ApnsDeliveryQueue.ts rename to infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index be7bafb9e9b..57b6bc159d1 100644 --- a/infra/relay/src/services/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -9,16 +9,15 @@ import * as Layer from "effect/Layer"; import { sanitizeAgentActivityAggregateState, sanitizeApnsNotificationPayload, -} from "../agentActivityPayloads.ts"; +} from "./AgentActivityPayloads.ts"; import { expiresAtForJob, makeApnsDeliveryJobPayload, signApnsDeliveryJob, type ApnsDeliveryJobPayload, type SignedApnsDeliveryJob, -} from "../apnsDeliveryJobs.ts"; +} from "./ApnsDeliveryJobs.ts"; import * as RelayConfiguration from "../Config.ts"; -import { withUserId } from "../telemetry.ts"; export class ApnsDeliveryQueueSendError extends Data.TaggedError("ApnsDeliveryQueueSendError")<{ readonly cause: unknown; @@ -33,7 +32,7 @@ export interface ApnsDeliveryQueueSenderShape { export class ApnsDeliveryQueueSender extends Context.Service< ApnsDeliveryQueueSender, ApnsDeliveryQueueSenderShape ->()("ApnsDeliveryQueueSender") {} +>()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {} export interface ApnsDeliveryQueueShape { readonly enqueueLiveActivity: (input: { @@ -52,7 +51,7 @@ export interface ApnsDeliveryQueueShape { } export class ApnsDeliveryQueue extends Context.Service()( - "ApnsDeliveryQueue", + "t3code-relay/agentActivity/ApnsDeliveryQueue", ) {} const make = Effect.gen(function* () { @@ -95,7 +94,6 @@ const make = Effect.gen(function* () { apnsId: null, }; }, - (effect, input) => effect.pipe(withUserId(input.userId)), ), enqueuePushNotification: Effect.fn("relay.apns_delivery_queue.enqueue_push_notification")( function* (input) { @@ -136,7 +134,6 @@ const make = Effect.gen(function* () { apnsId: null, }; }, - (effect, input) => effect.pipe(withUserId(input.userId)), ), }); }); diff --git a/infra/relay/src/persistence/DeliveryAttempts.test.ts b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts similarity index 99% rename from infra/relay/src/persistence/DeliveryAttempts.test.ts rename to infra/relay/src/agentActivity/DeliveryAttempts.test.ts index baeb974cf8c..81abb330726 100644 --- a/infra/relay/src/persistence/DeliveryAttempts.test.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { RelayDb, type RelayDatabase } from "../db.ts"; -import { relayDeliveryAttempts } from "../schema.ts"; +import { relayDeliveryAttempts } from "../persistence/schema.ts"; import * as DeliveryAttempts from "./DeliveryAttempts.ts"; describe("DeliveryAttempts", () => { diff --git a/infra/relay/src/persistence/DeliveryAttempts.ts b/infra/relay/src/agentActivity/DeliveryAttempts.ts similarity index 97% rename from infra/relay/src/persistence/DeliveryAttempts.ts rename to infra/relay/src/agentActivity/DeliveryAttempts.ts index 4ab4e3d21fb..52c58b84a83 100644 --- a/infra/relay/src/persistence/DeliveryAttempts.ts +++ b/infra/relay/src/agentActivity/DeliveryAttempts.ts @@ -8,7 +8,7 @@ import { and, eq, isNull } from "drizzle-orm"; import * as Crypto from "effect/Crypto"; import { RelayDb } from "../db.ts"; -import { relayDeliveryAttempts } from "../schema.ts"; +import { relayDeliveryAttempts } from "../persistence/schema.ts"; export class DeliveryAttemptRecordPersistenceError extends Data.TaggedError( "DeliveryAttemptRecordPersistenceError", @@ -53,7 +53,7 @@ export interface DeliveryAttemptsShape { } export class DeliveryAttempts extends Context.Service()( - "DeliveryAttempts", + "t3code-relay/agentActivity/DeliveryAttempts", ) {} const SOURCE_JOB_CLAIM_LEASE_MINUTES = 10; @@ -105,7 +105,6 @@ const make = Effect.gen(function* () { ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), - ...(input.userId ? { "user.id": input.userId } : {}), }); const id = yield* crypto.randomUUIDv4; const createdAt = DateTime.formatIso(yield* DateTime.now); @@ -121,7 +120,6 @@ const make = Effect.gen(function* () { ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), - ...(input.userId ? { "user.id": input.userId } : {}), }); const id = yield* crypto.randomUUIDv4; const now = yield* DateTime.now; diff --git a/infra/relay/src/persistence/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts similarity index 98% rename from infra/relay/src/persistence/Devices.test.ts rename to infra/relay/src/agentActivity/Devices.test.ts index 55aed597302..06946a0d7e6 100644 --- a/infra/relay/src/persistence/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -6,7 +6,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { RelayDb, type RelayDatabase } from "../db.ts"; -import { relayLiveActivities, relayMobileDevices } from "../schema.ts"; +import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; import * as Devices from "./Devices.ts"; const registration: RelayDeviceRegistrationRequest = { diff --git a/infra/relay/src/persistence/Devices.ts b/infra/relay/src/agentActivity/Devices.ts similarity index 96% rename from infra/relay/src/persistence/Devices.ts rename to infra/relay/src/agentActivity/Devices.ts index a012fa60df2..e23f22f8254 100644 --- a/infra/relay/src/persistence/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -8,7 +8,7 @@ import { and, eq } from "drizzle-orm"; import { sql } from "drizzle-orm"; import { RelayDb } from "../db.ts"; -import { relayLiveActivities, relayMobileDevices } from "../schema.ts"; +import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; export class DeviceRegistrationPersistenceError extends Data.TaggedError( "DeviceRegistrationPersistenceError", @@ -33,7 +33,9 @@ export interface DevicesShape { }) => Effect.Effect; } -export class Devices extends Context.Service()("Devices") {} +export class Devices extends Context.Service()( + "t3code-relay/agentActivity/Devices", +) {} const make = Effect.gen(function* () { const db = yield* RelayDb; @@ -42,7 +44,6 @@ const make = Effect.gen(function* () { register: Effect.fn("relay.devices.register")( function* (input) { yield* Effect.annotateCurrentSpan({ - "user.id": input.userId, "relay.mobile.device_id": input.registration.deviceId, }); const updatedAt = DateTime.formatIso(yield* DateTime.now); @@ -101,7 +102,6 @@ const make = Effect.gen(function* () { unregister: Effect.fn("relay.devices.unregister")( function* (input) { yield* Effect.annotateCurrentSpan({ - "user.id": input.userId, "relay.mobile.device_id": input.deviceId, }); yield* Effect.all( diff --git a/infra/relay/src/persistence/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts similarity index 99% rename from infra/relay/src/persistence/LiveActivities.test.ts rename to infra/relay/src/agentActivity/LiveActivities.test.ts index 3f909fb7ba9..19a1179b305 100644 --- a/infra/relay/src/persistence/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -9,7 +9,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { RelayDb, type RelayDatabase } from "../db.ts"; -import { relayLiveActivities } from "../schema.ts"; +import { relayLiveActivities } from "../persistence/schema.ts"; import * as LiveActivities from "./LiveActivities.ts"; const aggregate: RelayAgentActivityAggregateState = { diff --git a/infra/relay/src/persistence/LiveActivities.ts b/infra/relay/src/agentActivity/LiveActivities.ts similarity index 94% rename from infra/relay/src/persistence/LiveActivities.ts rename to infra/relay/src/agentActivity/LiveActivities.ts index a9bbd82f8f8..d90e5695b7c 100644 --- a/infra/relay/src/persistence/LiveActivities.ts +++ b/infra/relay/src/agentActivity/LiveActivities.ts @@ -8,13 +8,13 @@ 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 "../schema.ts"; -import { parseJsonString, stringifyJsonValue } from "./json.ts"; +import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; export class LiveActivityRegistrationPersistenceError extends Data.TaggedError( "LiveActivityRegistrationPersistenceError", @@ -89,9 +89,12 @@ export interface LiveActivitiesShape { } export class LiveActivities extends Context.Service()( - "LiveActivities", + "t3code-relay/agentActivity/LiveActivities", ) {} +const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); +const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); + const encodeRelayAgentActivityAggregateStateJson = Schema.encodeEffect( Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), ); @@ -103,7 +106,6 @@ const make = Effect.gen(function* () { register: Effect.fn("relay.live_activities.register")( function* (input) { yield* Effect.annotateCurrentSpan({ - "user.id": input.userId, "relay.mobile.device_id": input.registration.deviceId, }); const updatedAt = DateTime.formatIso(yield* DateTime.now); @@ -151,7 +153,6 @@ const make = Effect.gen(function* () { ), listTargets: Effect.fn("relay.live_activities.list_targets")(function* (input) { - yield* Effect.annotateCurrentSpan({ "user.id": input.userId }); return yield* db .select({ device_id: relayMobileDevices.deviceId, @@ -184,11 +185,11 @@ const make = Effect.gen(function* () { rows, (row) => Effect.all({ - preferences_json: stringifyJsonValue(row.preferences_json), + preferences_json: encodeJsonValue(row.preferences_json), last_aggregate_json: row.last_aggregate_json === null ? Effect.succeed(null) - : stringifyJsonValue(row.last_aggregate_json), + : encodeJsonValue(row.last_aggregate_json), }).pipe( Effect.map((json) => ({ ...row, @@ -206,15 +207,15 @@ const make = Effect.gen(function* () { markDelivery: Effect.fn("relay.live_activities.mark_delivery")( function* (input) { yield* Effect.annotateCurrentSpan({ - "user.id": input.userId, "relay.mobile.device_id": input.deviceId, "relay.delivery.kind": input.kind, }); const aggregateJson = input.aggregate === null ? null - : yield* parseJsonString( - yield* encodeRelayAgentActivityAggregateStateJson(input.aggregate), + : yield* encodeRelayAgentActivityAggregateStateJson(input.aggregate).pipe( + Effect.flatMap(decodeJsonString), + Effect.map(cast), ); yield* db @@ -255,7 +256,6 @@ const make = Effect.gen(function* () { markStartQueued: Effect.fn("relay.live_activities.mark_start_queued")(function* (input) { yield* Effect.annotateCurrentSpan({ - "user.id": input.userId, "relay.mobile.device_id": input.deviceId, }); yield* db @@ -285,7 +285,6 @@ const make = Effect.gen(function* () { clearStartQueued: Effect.fn("relay.live_activities.clear_start_queued")(function* (input) { yield* Effect.annotateCurrentSpan({ - "user.id": input.userId, "relay.mobile.device_id": input.deviceId, }); yield* db @@ -303,7 +302,6 @@ const make = Effect.gen(function* () { invalidateDeliveryToken: Effect.fn("relay.live_activities.invalidate_delivery_token")( function* (input) { yield* Effect.annotateCurrentSpan({ - "user.id": input.userId, "relay.mobile.device_id": input.deviceId, "relay.delivery.kind": input.kind, }); diff --git a/infra/relay/src/services/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts similarity index 96% rename from infra/relay/src/services/MobileRegistrations.test.ts rename to infra/relay/src/agentActivity/MobileRegistrations.test.ts index 47509394056..b3902cb7cde 100644 --- a/infra/relay/src/services/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -2,7 +2,7 @@ import type { RelayAgentActivityState, RelayDeviceRegistrationRequest, } from "@t3tools/contracts/relay"; -import type { SignedApnsDeliveryJob } from "../apnsDeliveryJobs.ts"; +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"; @@ -10,14 +10,15 @@ import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import { FetchHttpClient } from "effect/unstable/http"; -import * as Devices from "../persistence/Devices.ts"; -import * as AgentActivityRows from "../persistence/AgentActivityRows.ts"; -import * as DeliveryAttempts from "../persistence/DeliveryAttempts.ts"; -import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; -import * as LiveActivities from "../persistence/LiveActivities.ts"; +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"; @@ -141,7 +142,7 @@ function makeRegistrationReplayLayer(input: { }) { return MobileRegistrations.layer.pipe( Layer.provide(AgentActivityPublisher.layer), - Layer.provide(ApnsDeliveries.layer), + Layer.provide(ApnsDeliveries.layer.pipe(Layer.provide(ApnsClient.layer))), Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), Layer.provide( Layer.mergeAll( diff --git a/infra/relay/src/services/MobileRegistrations.ts b/infra/relay/src/agentActivity/MobileRegistrations.ts similarity index 64% rename from infra/relay/src/services/MobileRegistrations.ts rename to infra/relay/src/agentActivity/MobileRegistrations.ts index 8bc0395d2ad..d9c013232a3 100644 --- a/infra/relay/src/services/MobileRegistrations.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.ts @@ -6,9 +6,8 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Devices from "../persistence/Devices.ts"; -import * as LiveActivities from "../persistence/LiveActivities.ts"; -import { withUserId } from "../telemetry.ts"; +import * as Devices from "./Devices.ts"; +import * as LiveActivities from "./LiveActivities.ts"; import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; export type MobileRegistrationError = @@ -34,7 +33,7 @@ export interface MobileRegistrationsShape { export class MobileRegistrations extends Context.Service< MobileRegistrations, MobileRegistrationsShape ->()("MobileRegistrations") {} +>()("t3code-relay/agentActivity/MobileRegistrations") {} const make = Effect.gen(function* () { const devices = yield* Devices.Devices; @@ -42,28 +41,25 @@ const make = Effect.gen(function* () { 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 }; - }, - (effect, input) => effect.pipe(withUserId(input.userId)), - ), + 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({ @@ -83,18 +79,14 @@ const make = Effect.gen(function* () { ); return { ok: true as const }; }, - (effect, input) => effect.pipe(withUserId(input.userId)), - ), - 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 }; - }, - (effect, input) => effect.pipe(withUserId(input.userId)), ), + 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 }; + }), }); }); diff --git a/infra/relay/src/apns.test.ts b/infra/relay/src/apns.test.ts deleted file mode 100644 index cb885cebe1b..00000000000 --- a/infra/relay/src/apns.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; - -import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; -import { makeLiveActivityRequest, makePushNotificationRequest } from "./apns.ts"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; - -describe("makeLiveActivityRequest", () => { - 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("requests an update push token when remotely starting a Live Activity", () => { - const request = 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", - }, - }, - }); - }); - - it("builds a low-priority update payload", () => { - const request = 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", - }, - }, - }); - }); - - it("builds an end payload with a dismissal date", () => { - const request = 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, - }, - }); - }); - - it("builds a standard APNs alert payload with routing metadata", () => { - const request = 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", - }); - }); -}); diff --git a/infra/relay/src/persistence/DpopProofs.test.ts b/infra/relay/src/auth/DpopProofs.test.ts similarity index 98% rename from infra/relay/src/persistence/DpopProofs.test.ts rename to infra/relay/src/auth/DpopProofs.test.ts index 33aa67b3fe3..9fae6298c9c 100644 --- a/infra/relay/src/persistence/DpopProofs.test.ts +++ b/infra/relay/src/auth/DpopProofs.test.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { RelayDb, type RelayDatabase } from "../db.ts"; -import { relayDpopProofs } from "../schema.ts"; +import { relayDpopProofs } from "../persistence/schema.ts"; import * as DpopProofs from "./DpopProofs.ts"; describe("DpopProofReplay", () => { 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/dpop.test.ts b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts similarity index 63% rename from infra/relay/src/dpop.test.ts rename to infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts index 81b4cc8cbf0..d09ee76e42c 100644 --- a/infra/relay/src/dpop.test.ts +++ b/infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts @@ -8,9 +8,19 @@ import { } from "@t3tools/shared/dpop"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; -import { verifyAndConsumeDpopProof } from "./dpop.ts"; -import * as DpopProofs from "./persistence/DpopProofs.ts"; +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; @@ -49,7 +59,43 @@ function makeDpopProof(input: { }; } -describe("verifyAndConsumeDpopProof", () => { +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({ @@ -58,30 +104,18 @@ describe("verifyAndConsumeDpopProof", () => { iat: Math.floor(now.epochMilliseconds / 1_000), jti: "proof-1", }); - const consumed = new Set(); - const dpopProofs: DpopProofs.DpopProofReplayShape = { - consume: (input) => - Effect.sync(() => { - const key = `${input.thumbprint}:${input.jti}`; - if (consumed.has(key)) { - return false; - } - consumed.add(key); - return true; - }), - pruneExpired: Effect.void, - }; return Effect.gen(function* () { - const first = yield* verifyAndConsumeDpopProof({ + 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 replay = yield* Effect.exit( - verifyAndConsumeDpopProof({ + const second = yield* Effect.exit( + replay.verifyAndConsume({ proof: proof.proof, method: "POST", url: "https://relay.example.com/v1/environments/env/connect", @@ -91,8 +125,8 @@ describe("verifyAndConsumeDpopProof", () => { ); expect(first).toBe(proof.thumbprint); - expect(replay._tag).toBe("Failure"); - }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + expect(second._tag).toBe("Failure"); + }).pipe(Effect.provide(layer(consumeEachProofOnce()))); }); it.effect("rejects proofs missing the expected access token hash", () => { @@ -103,14 +137,11 @@ describe("verifyAndConsumeDpopProof", () => { iat: Math.floor(now.epochMilliseconds / 1_000), jti: "proof-1", }); - const dpopProofs: DpopProofs.DpopProofReplayShape = { - consume: () => Effect.succeed(true), - pruneExpired: Effect.void, - }; return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; const result = yield* Effect.exit( - verifyAndConsumeDpopProof({ + replay.verifyAndConsume({ proof: proof.proof, method: "POST", url: "https://relay.example.com/v1/environments/env/connect", @@ -121,7 +152,7 @@ describe("verifyAndConsumeDpopProof", () => { ); expect(result._tag).toBe("Failure"); - }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + }).pipe(Effect.provide(layer(() => Effect.die("unexpected DPoP replay persistence")))); }); it.effect("preserves replay persistence failures", () => { @@ -132,17 +163,12 @@ describe("verifyAndConsumeDpopProof", () => { iat: Math.floor(now.epochMilliseconds / 1_000), jti: "proof-persistence-failure", }); - const failure = new DpopProofs.DpopProofReplayPersistenceError({ - cause: "database unavailable", - }); - const dpopProofs: DpopProofs.DpopProofReplayShape = { - consume: () => Effect.fail(failure), - pruneExpired: Effect.void, - }; + const cause = "database unavailable"; return Effect.gen(function* () { + const replay = yield* DpopProofs.DpopProofReplay; const error = yield* Effect.flip( - verifyAndConsumeDpopProof({ + replay.verifyAndConsume({ proof: proof.proof, method: "POST", url: "https://relay.example.com/v1/environments/env/connect", @@ -151,11 +177,11 @@ describe("verifyAndConsumeDpopProof", () => { }), ); - expect(error).toBe(failure); - }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + expect(error).toEqual(new DpopProofs.DpopProofReplayPersistenceError({ cause })); + }).pipe(Effect.provide(layer(() => Effect.fail({ _tag: cause })))); }); - it.effect("accepts unbound DPoP proofs when they are bound to the access token hash", () => { + 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", @@ -164,30 +190,18 @@ describe("verifyAndConsumeDpopProof", () => { jti: "proof-status-1", accessToken: "clerk-access-token", }); - const consumed = new Set(); - const dpopProofs: DpopProofs.DpopProofReplayShape = { - consume: (input) => - Effect.sync(() => { - const key = `${input.thumbprint}:${input.jti}`; - if (consumed.has(key)) { - return false; - } - consumed.add(key); - return true; - }), - pruneExpired: Effect.void, - }; return Effect.gen(function* () { - const thumbprint = yield* verifyAndConsumeDpopProof({ + 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 replay = yield* Effect.exit( - verifyAndConsumeDpopProof({ + const second = yield* Effect.exit( + replay.verifyAndConsume({ proof: proof.proof, method: "POST", url: "https://relay.example.com/v1/environments/env/status", @@ -197,7 +211,7 @@ describe("verifyAndConsumeDpopProof", () => { ); expect(thumbprint).toBe(proof.thumbprint); - expect(replay._tag).toBe("Failure"); - }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); + 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..0d641c9c8f2 --- /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"), + cloudMintPrivateKey: Redacted.make(keyPair.privateKey), + cloudMintPublicKey: keyPair.publicKey, + managedEndpointBaseDomain: undefined, + cloudflareAccountId: undefined, + cloudflareZoneId: undefined, + cloudflareApiToken: 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.test.ts b/infra/relay/src/db.test.ts deleted file mode 100644 index 5a708d5479a..00000000000 --- a/infra/relay/src/db.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; -import * as NodePath from "@effect/platform-node/NodePath"; -import { describe, expect, it } from "@effect/vitest"; -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 Path from "effect/Path"; - -import { relayPostgresDatabaseRegion } from "./db.ts"; -import { parseJsonString } from "./persistence/json.ts"; - -const migrationsDir = "migrations/postgres"; -const schemaFile = "src/schema.ts"; -const NodeTestServices = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer); - -class DbMigrationTestError extends Data.TaggedError("DbMigrationTestError")<{ - readonly message: string; - readonly cause: unknown; -}> {} - -interface DrizzleKitPostgresApi { - readonly generateDrizzleJson: ( - imports: Record, - prevId?: string, - schemaFilters?: ReadonlyArray, - ) => Promise; - readonly generateMigration: (prev: unknown, cur: unknown) => Promise>; -} - -const readMigrationDirectories = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const entries = yield* fs.readDirectory(migrationsDir); - const directories = yield* Effect.all( - entries.map((entry) => - fs - .stat(path.join(migrationsDir, entry)) - .pipe(Effect.map((stat) => (stat.type === "Directory" ? entry : null))), - ), - ); - return directories.filter((entry): entry is string => entry !== null).sort(); -}); - -const readMigrationSqlFiles = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directories = yield* readMigrationDirectories; - const files = yield* Effect.all( - directories.map((directory) => - Effect.gen(function* () { - const migrationPath = path.join(migrationsDir, directory, "migration.sql"); - const exists = yield* fs.exists(migrationPath); - if (!exists) return null; - const sql = yield* fs.readFileString(migrationPath); - return { directory, sql }; - }), - ), - ); - return files.filter( - (file): file is { readonly directory: string; readonly sql: string } => file !== null, - ); -}); - -const readLatestMigrationSnapshot = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directories = yield* readMigrationDirectories; - const directoriesWithSnapshots = yield* Effect.all( - directories.map((directory) => - fs - .exists(path.join(migrationsDir, directory, "snapshot.json")) - .pipe(Effect.map((exists) => (exists ? directory : null))), - ), - ); - const latestDirectory = directoriesWithSnapshots.findLast( - (directory): directory is string => directory !== null, - ); - if (!latestDirectory) { - return null; - } - const snapshotPath = path.join(migrationsDir, latestDirectory, "snapshot.json"); - const exists = yield* fs.exists(snapshotPath); - if (!exists) { - return { - directory: latestDirectory, - snapshot: null, - }; - } - const snapshot = yield* parseJsonString(yield* fs.readFileString(snapshotPath)); - return { - directory: latestDirectory, - snapshot, - }; -}); - -const loadDrizzleKit = Effect.tryPromise({ - try: () => import("drizzle-kit/api-postgres") as Promise, - catch: (cause) => - new DbMigrationTestError({ - message: "failed to load drizzle-kit postgres api", - cause, - }), -}); - -const loadRelaySchema = Effect.gen(function* () { - const path = yield* Path.Path; - return yield* Effect.tryPromise({ - try: () => import(path.resolve(schemaFile)) as Promise>, - catch: (cause) => - new DbMigrationTestError({ - message: "failed to load relay schema", - cause, - }), - }); -}); - -describe("relay database migrations", () => { - it("pins the PlanetScale database near west coast Worker traffic", () => { - expect(relayPostgresDatabaseRegion).toEqual({ slug: "us-west" }); - }); - - it.effect("does not leave empty generated migration directories behind", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directories = yield* readMigrationDirectories; - const emptyDirectories = yield* Effect.all( - directories.map((directory) => - fs - .readDirectory(path.join(migrationsDir, directory)) - .pipe(Effect.map((entries) => (entries.length === 0 ? directory : null))), - ), - ).pipe(Effect.map((entries) => entries.filter((entry): entry is string => entry !== null))); - - expect(emptyDirectories).toEqual([]); - }).pipe(Effect.provide(NodeTestServices)), - ); - - it.effect("starts from a single baseline migration", () => - Effect.gen(function* () { - const migrations = yield* readMigrationSqlFiles; - - expect(migrations).toHaveLength(1); - expect(migrations[0]?.directory).toContain("baseline"); - expect(migrations[0]?.sql).toContain('CREATE TABLE "relay_environment_credentials"'); - expect(migrations[0]?.sql).toContain('"environment_public_key" text NOT NULL'); - }).pipe(Effect.provide(NodeTestServices)), - ); - - it.effect( - "keeps the latest migration snapshot aligned with the relay schema", - () => - Effect.gen(function* () { - const latest = yield* readLatestMigrationSnapshot; - expect(latest).not.toBeNull(); - expect(latest?.snapshot, `${latest?.directory} is missing snapshot.json`).not.toBeNull(); - - const kit = yield* loadDrizzleKit; - const relaySchema = yield* loadRelaySchema; - const currentSnapshot = yield* Effect.tryPromise({ - try: () => kit.generateDrizzleJson(relaySchema), - catch: (cause) => - new DbMigrationTestError({ - message: "failed to generate current drizzle snapshot", - cause, - }), - }); - const statements = yield* Effect.tryPromise({ - try: () => kit.generateMigration(latest?.snapshot, currentSnapshot), - catch: (cause) => - new DbMigrationTestError({ - message: "failed to diff relay schema", - cause, - }), - }); - - expect(statements).toEqual([]); - }).pipe(Effect.provide(NodeTestServices)), - 20_000, - ); -}); diff --git a/infra/relay/src/db.ts b/infra/relay/src/db.ts index d1a2bc912e1..a01ec05c0a0 100644 --- a/infra/relay/src/db.ts +++ b/infra/relay/src/db.ts @@ -10,20 +10,17 @@ export interface RelayDatabase extends EffectPgDatabase { readonly $client: PgClient; } -export class RelayDb extends Context.Service()("RelayDb") {} - -export const relayPostgresDatabaseRegion = { slug: "us-west" } as const; - -export const RelaySchema = Drizzle.Schema("RelaySchema", { - schema: "./src/schema.ts", - out: "./migrations/postgres", - dialect: "postgres", -}); +export class RelayDb extends Context.Service()("t3code-relay/db/RelayDb") {} export const PlanetscaleDatabase = Effect.gen(function* () { - const schema = yield* RelaySchema; + const schema = yield* Drizzle.Schema("RelaySchema", { + schema: "./src/persistence/schema.ts", + out: "./migrations/postgres", + dialect: "postgres", + }); + const database = yield* Planetscale.PostgresDatabase("RelayPostgresDatabase", { - region: relayPostgresDatabaseRegion, + region: { slug: "us-west" }, clusterSize: "PS_5", migrationsDir: schema.out, migrationsTable: "relay_migrations", diff --git a/infra/relay/src/dpop.ts b/infra/relay/src/dpop.ts deleted file mode 100644 index e34f51740bc..00000000000 --- a/infra/relay/src/dpop.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { verifyDpopProof } from "@t3tools/shared/dpop"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; - -import * as DpopProofs from "./persistence/DpopProofs.ts"; - -export const verifyAndConsumeDpopProof = Effect.fn("relay.dpop.verify_and_consume")( - function* (input: { - readonly proof: string | undefined; - readonly method: string; - readonly url: string; - readonly expectedThumbprint?: string; - readonly expectedAccessToken?: string; - readonly now: DateTime.DateTime; - }) { - const dpopProofs = yield* DpopProofs.DpopProofReplay; - 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* dpopProofs.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; - }, -); diff --git a/infra/relay/src/services/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts similarity index 91% rename from infra/relay/src/services/EnvironmentConnector.test.ts rename to infra/relay/src/environments/EnvironmentConnector.test.ts index 9acb89402a3..935e44de8a3 100644 --- a/infra/relay/src/services/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -1,11 +1,11 @@ import * as NodeCrypto from "node:crypto"; import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; -import type { - RelayCloudEnvironmentHealthProofPayload, +import { RelayCloudEnvironmentHealthRequest, - RelayCloudMintCredentialProofPayload, RelayCloudMintCredentialRequest, + RelayCloudEnvironmentHealthProofPayload, + RelayCloudMintCredentialProofPayload, RelayEnvironmentHealthResponse, RelayEnvironmentHealthResponseProofPayload, RelayEnvironmentMintResponse, @@ -20,10 +20,11 @@ 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 "../persistence/EnvironmentLinks.ts"; +import * as EnvironmentLinks from "./EnvironmentLinks.ts"; import * as RelayConfiguration from "../Config.ts"; import * as EnvironmentConnector from "./EnvironmentConnector.ts"; @@ -42,6 +43,17 @@ const otherEnvironmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { 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: { @@ -185,9 +197,7 @@ describe("EnvironmentConnector", () => { const seenProofs: Array = []; const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { - const healthRequest = JSON.parse( - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", - ) as RelayCloudEnvironmentHealthRequest; + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); seenUrls.push(request.url); seenProofs.push(decodeRequestProof(healthRequest.proof)); return HttpClientResponse.fromWeb( @@ -225,9 +235,7 @@ describe("EnvironmentConnector", () => { it.effect("rejects signed health responses with stale checkedAt timestamps", () => { const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { - const healthRequest = JSON.parse( - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", - ) as RelayCloudEnvironmentHealthRequest; + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); return HttpClientResponse.fromWeb( request, Response.json( @@ -293,9 +301,7 @@ describe("EnvironmentConnector", () => { it.effect("rejects health responses with a mismatched top-level environment id", () => { const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { - const healthRequest = JSON.parse( - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", - ) as RelayCloudEnvironmentHealthRequest; + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); return HttpClientResponse.fromWeb( request, Response.json( @@ -326,9 +332,7 @@ describe("EnvironmentConnector", () => { it.effect("rejects health responses with an unsigned top-level descriptor mutation", () => { const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { - const healthRequest = JSON.parse( - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", - ) as RelayCloudEnvironmentHealthRequest; + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); const response = signHealthResponse(healthRequest); return HttpClientResponse.fromWeb( request, @@ -364,9 +368,7 @@ describe("EnvironmentConnector", () => { it.effect("rejects health responses when the linked environment public key is malformed", () => { const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { - const healthRequest = JSON.parse( - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", - ) as RelayCloudEnvironmentHealthRequest; + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); return HttpClientResponse.fromWeb( request, Response.json(signHealthResponse(healthRequest), { status: 200 }), @@ -402,9 +404,7 @@ describe("EnvironmentConnector", () => { const seenProofs: Array = []; const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { - const mintRequest = JSON.parse( - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", - ) as RelayCloudMintCredentialRequest; + const mintRequest = decodeMintRequestBody(requestBodyText(request)); seenUrls.push(request.url); seenProofs.push(decodeRequestProof(mintRequest.proof)); return HttpClientResponse.fromWeb( @@ -447,9 +447,7 @@ describe("EnvironmentConnector", () => { it.effect("only accepts mint responses signed by the user's linked environment key", () => { const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { - const mintRequest = JSON.parse( - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", - ) as RelayCloudMintCredentialRequest; + const mintRequest = decodeMintRequestBody(requestBodyText(request)); return HttpClientResponse.fromWeb( request, Response.json(signMintResponse(mintRequest, {}, otherEnvironmentKeyPair.privateKey), { @@ -478,9 +476,7 @@ describe("EnvironmentConnector", () => { it.effect("rejects mint responses when the linked environment public key is malformed", () => { const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { - const mintRequest = JSON.parse( - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", - ) as RelayCloudMintCredentialRequest; + const mintRequest = decodeMintRequestBody(requestBodyText(request)); return HttpClientResponse.fromWeb( request, Response.json(signMintResponse(mintRequest), { status: 200 }), @@ -515,9 +511,7 @@ describe("EnvironmentConnector", () => { it.effect("rejects environment mint responses with an overlong credential window", () => { const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { - const mintRequest = JSON.parse( - request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}", - ) as RelayCloudMintCredentialRequest; + const mintRequest = decodeMintRequestBody(requestBodyText(request)); return HttpClientResponse.fromWeb( request, Response.json( diff --git a/infra/relay/src/services/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts similarity index 53% rename from infra/relay/src/services/EnvironmentConnector.ts rename to infra/relay/src/environments/EnvironmentConnector.ts index 623b5099bbe..a7d1652c071 100644 --- a/infra/relay/src/services/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -38,9 +38,8 @@ import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; -import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; +import * as EnvironmentLinks from "./EnvironmentLinks.ts"; import * as RelayConfiguration from "../Config.ts"; -import { withUserId } from "../telemetry.ts"; export class EnvironmentConnectNotAuthorized extends Data.TaggedError( "EnvironmentConnectNotAuthorized", @@ -91,7 +90,7 @@ export interface EnvironmentConnectorShape { export class EnvironmentConnector extends Context.Service< EnvironmentConnector, EnvironmentConnectorShape ->()("EnvironmentConnector") {} +>()("t3code-relay/environments/EnvironmentConnector") {} const decodeMintResponseProof = Schema.decodeUnknownEffect( RelayEnvironmentMintResponseProofPayload, @@ -139,7 +138,7 @@ const verifyWithEnvironmentKeys = Effect.fnUntraced(function* (input: { }); function verifyEnvironmentResponse(input: { - readonly response: typeof RelayEnvironmentMintResponse.Type; + readonly response: RelayEnvironmentMintResponse; readonly environmentId: string; readonly requestNonce: string; readonly clientProofKeyThumbprint: string; @@ -172,7 +171,7 @@ function verifyEnvironmentResponse(input: { } function verifyEnvironmentHealthResponse(input: { - readonly response: typeof RelayEnvironmentHealthResponse.Type; + readonly response: RelayEnvironmentHealthResponse; readonly environmentId: string; readonly requestNonce: string; readonly requestIssuedAt: DateTime.DateTime; @@ -227,170 +226,162 @@ const make = Effect.gen(function* () { ); 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 now = yield* DateTime.now; - const expiresAt = DateTime.add(now, { minutes: 2 }); - const nonce = yield* crypto.randomUUIDv4.pipe( + 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 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 })), - ); - 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 typeof RelayCloudEnvironmentHealthProofPayload.Type; - 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(link.endpoint.httpBaseUrl); - const responseOption = yield* environmentClient.cloud.health({ payload: { proof } }).pipe( - 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: link.endpoint, - status: "offline" as const, - checkedAt, - error: "Managed endpoint health request timed out.", - }; - } - if (responseOption.value._tag === "Failure") { - return { - environmentId: link.environmentId, - endpoint: link.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 }); - } + ), + 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(link.endpoint.httpBaseUrl); + const responseOption = yield* environmentClient.cloud.health({ payload: { proof } }).pipe( + 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: link.endpoint, - status: "online" as const, - checkedAt: decoded.checkedAt, - descriptor: decoded.descriptor, + status: "offline" as const, + checkedAt, + error: "Managed endpoint health request timed out.", }; - }, - (effect, input) => effect.pipe(withUserId(input.userId)), - ), - 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 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 typeof RelayCloudMintCredentialProofPayload.Type; - 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(link.endpoint.httpBaseUrl); - const decoded = yield* environmentClient.cloud - .t3MintCredential({ payload: { proof } }) - .pipe( - 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 }); - } + } + if (responseOption.value._tag === "Failure") { return { environmentId: link.environmentId, endpoint: link.endpoint, - credential: decoded.credential, - expiresAt: decoded.expiresAt, + status: "offline" as const, + checkedAt, + error: environmentHealthRequestFailureMessage(responseOption.value.cause), }; - }, - (effect, input) => effect.pipe(withUserId(input.userId)), - ), + } + 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: link.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 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(link.endpoint.httpBaseUrl); + const decoded = yield* environmentClient.cloud.t3MintCredential({ payload: { proof } }).pipe( + 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: link.endpoint, + credential: decoded.credential, + expiresAt: decoded.expiresAt, + }; + }), }); }); diff --git a/infra/relay/src/persistence/EnvironmentCredentials.test.ts b/infra/relay/src/environments/EnvironmentCredentials.test.ts similarity index 98% rename from infra/relay/src/persistence/EnvironmentCredentials.test.ts rename to infra/relay/src/environments/EnvironmentCredentials.test.ts index 315815606eb..3135c766c04 100644 --- a/infra/relay/src/persistence/EnvironmentCredentials.test.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.test.ts @@ -5,7 +5,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { RelayDb, type RelayDatabase } from "../db.ts"; -import { relayEnvironmentCredentials } from "../schema.ts"; +import { relayEnvironmentCredentials } from "../persistence/schema.ts"; import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; describe("EnvironmentCredentials", () => { diff --git a/infra/relay/src/persistence/EnvironmentCredentials.ts b/infra/relay/src/environments/EnvironmentCredentials.ts similarity index 97% rename from infra/relay/src/persistence/EnvironmentCredentials.ts rename to infra/relay/src/environments/EnvironmentCredentials.ts index 40361236077..af091e9a8a5 100644 --- a/infra/relay/src/persistence/EnvironmentCredentials.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.ts @@ -9,7 +9,7 @@ import * as Option from "effect/Option"; import { and, eq, isNull, ne } from "drizzle-orm"; import { RelayDb } from "../db.ts"; -import { relayEnvironmentCredentials } from "../schema.ts"; +import { relayEnvironmentCredentials } from "../persistence/schema.ts"; export class EnvironmentCredentialCreatePersistenceError extends Data.TaggedError( "EnvironmentCredentialCreatePersistenceError", @@ -55,7 +55,7 @@ export interface EnvironmentCredentialsShape { export class EnvironmentCredentials extends Context.Service< EnvironmentCredentials, EnvironmentCredentialsShape ->()("EnvironmentCredentials") {} +>()("t3code-relay/environments/EnvironmentCredentials") {} const make = Effect.gen(function* () { const db = yield* RelayDb; diff --git a/infra/relay/src/services/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts similarity index 94% rename from infra/relay/src/services/EnvironmentLinker.test.ts rename to infra/relay/src/environments/EnvironmentLinker.test.ts index 2b3dbfee458..7eb5dcc6e8d 100644 --- a/infra/relay/src/services/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -11,13 +11,13 @@ import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; -import * as DpopProofs from "../persistence/DpopProofs.ts"; -import * as EnvironmentCredentials from "../persistence/EnvironmentCredentials.ts"; -import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; +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"; -import { issueLinkChallengeToken } from "../relayTokens.ts"; const relayKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, @@ -56,8 +56,8 @@ function signTestJwt(payload: object, typ: string, privateKey: string): string { const makeRequest = Effect.gen(function* () { const now = yield* DateTime.now; const expiresAt = DateTime.add(now, { minutes: 5 }); - const challenge = yield* issueLinkChallengeToken({ - config, + const relayTokens = yield* RelayTokens.RelayTokens; + const challenge = yield* relayTokens.issueLinkChallenge({ userId: "user_123", request: { notificationsEnabled: true, @@ -109,10 +109,12 @@ function testLayer(input?: { 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, }), diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts new file mode 100644 index 00000000000..59c83477dce --- /dev/null +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -0,0 +1,263 @@ +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({ + 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/persistence/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts similarity index 57% rename from infra/relay/src/persistence/EnvironmentLinks.test.ts rename to infra/relay/src/environments/EnvironmentLinks.test.ts index 500ffadb71d..b67dfb8e430 100644 --- a/infra/relay/src/persistence/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -4,29 +4,42 @@ import * as Layer from "effect/Layer"; import { PgDialect } from "drizzle-orm/pg-core"; import { RelayDb, type RelayDatabase } from "../db.ts"; -import { relayEnvironmentLinks } from "../schema.ts"; -import { - agentAwarenessDeliveryUserCondition, - EnvironmentLinks, - layer, -} from "./EnvironmentLinks.ts"; +import { relayEnvironmentLinks } from "../persistence/schema.ts"; +import { EnvironmentLinks, layer } from "./EnvironmentLinks.ts"; describe("EnvironmentLinks", () => { - it("selects users when either notifications or Live Activities are enabled", () => { - const dialect = new PgDialect(); - const condition = agentAwarenessDeliveryUserCondition("env-1"); - expect(condition).toBeDefined(); - if (!condition) { - throw new Error("Expected agent awareness delivery condition."); - } - const query = dialect.sqlToQuery(condition); + 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; - 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]); + 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", () => { diff --git a/infra/relay/src/persistence/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts similarity index 97% rename from infra/relay/src/persistence/EnvironmentLinks.ts rename to infra/relay/src/environments/EnvironmentLinks.ts index 1d70d88d8c6..b1e28d0da0a 100644 --- a/infra/relay/src/persistence/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -12,7 +12,7 @@ import * as Layer from "effect/Layer"; import { and, eq, isNull, or } from "drizzle-orm"; import { RelayDb } from "../db.ts"; -import { relayEnvironmentLinks } from "../schema.ts"; +import { relayEnvironmentLinks } from "../persistence/schema.ts"; export interface RelayLinkedEnvironmentRecord extends RelayClientEnvironmentRecord { readonly environmentPublicKey: string; @@ -97,10 +97,10 @@ export interface EnvironmentLinksShape { } export class EnvironmentLinks extends Context.Service()( - "EnvironmentLinks", + "t3code-relay/environments/EnvironmentLinks", ) {} -export function agentAwarenessDeliveryUserCondition(environmentId: string) { +function agentAwarenessDeliveryUserCondition(environmentId: string) { return and( eq(relayEnvironmentLinks.environmentId, environmentId), isNull(relayEnvironmentLinks.revokedAt), @@ -111,7 +111,7 @@ export function agentAwarenessDeliveryUserCondition(environmentId: string) { ); } -export function agentAwarenessDeliveryUserKeyCondition(input: { +function agentAwarenessDeliveryUserKeyCondition(input: { readonly environmentId: string; readonly environmentPublicKey: string; }) { @@ -128,7 +128,6 @@ const make = Effect.gen(function* () { upsert: Effect.fn("relay.environment_links.upsert")( function* (input) { yield* Effect.annotateCurrentSpan({ - "user.id": input.userId, "relay.environment_id": input.proof.environmentId, }); const now = DateTime.formatIso(yield* DateTime.now); @@ -233,7 +232,6 @@ const make = Effect.gen(function* () { }), listForUser: Effect.fn("relay.environment_links.list_for_user")(function* (input) { - yield* Effect.annotateCurrentSpan({ "user.id": input.userId }); return yield* db .select({ environmentId: relayEnvironmentLinks.environmentId, @@ -271,7 +269,6 @@ const make = Effect.gen(function* () { getForUser: Effect.fn("relay.environment_links.get_for_user")(function* (input) { yield* Effect.annotateCurrentSpan({ - "user.id": input.userId, "relay.environment_id": input.environmentId, }); return yield* db @@ -321,7 +318,6 @@ const make = Effect.gen(function* () { revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")( function* (input) { yield* Effect.annotateCurrentSpan({ - "user.id": input.userId, "relay.environment_id": input.environmentId, }); const revokedAt = DateTime.formatIso(yield* DateTime.now); diff --git a/infra/relay/src/services/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts similarity index 91% rename from infra/relay/src/services/EnvironmentPublishSignatures.test.ts rename to infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index 9826137074e..723f73f96a0 100644 --- a/infra/relay/src/services/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -6,6 +6,7 @@ import type { 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"; @@ -13,10 +14,9 @@ import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; -import * as DpopProofs from "../persistence/DpopProofs.ts"; +import * as DpopProofs from "../auth/DpopProofs.ts"; import * as RelayConfiguration from "../Config.ts"; import * as EnvironmentPublishSignatures from "./EnvironmentPublishSignatures.ts"; -import { environmentPublishReplayThumbprint } from "./EnvironmentPublishSignatures.ts"; const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, @@ -86,6 +86,8 @@ function layer(replay?: Partial) { 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, }), @@ -108,10 +110,14 @@ describe("EnvironmentPublishSignatures", () => { request, }); expect(replayThumbprint).toBe( - yield* environmentPublishReplayThumbprint({ - environmentId: state.environmentId, - environmentPublicKey: keyPair.publicKey, - }), + `env-publish:${NodeCrypto.createHash("sha256") + .update( + stableStringify({ + environmentId: state.environmentId, + environmentPublicKey: keyPair.publicKey, + }), + ) + .digest("base64url")}`, ); }).pipe( Effect.provide( diff --git a/infra/relay/src/services/EnvironmentPublishSignatures.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.ts similarity index 92% rename from infra/relay/src/services/EnvironmentPublishSignatures.ts rename to infra/relay/src/environments/EnvironmentPublishSignatures.ts index 6c92b02b99c..cca694ac512 100644 --- a/infra/relay/src/services/EnvironmentPublishSignatures.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.ts @@ -18,7 +18,7 @@ import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import * as DpopProofs from "../persistence/DpopProofs.ts"; +import * as DpopProofs from "../auth/DpopProofs.ts"; import * as RelayConfiguration from "../Config.ts"; export class EnvironmentPublishSignatureExpired extends Data.TaggedError( @@ -57,7 +57,7 @@ export interface EnvironmentPublishSignaturesShape { export class EnvironmentPublishSignatures extends Context.Service< EnvironmentPublishSignatures, EnvironmentPublishSignaturesShape ->()("EnvironmentPublishSignatures") {} +>()("t3code-relay/environments/EnvironmentPublishSignatures") {} const decodeProof = Schema.decodeUnknownEffect(RelayAgentActivityPublishProofPayload); @@ -76,18 +76,6 @@ function environmentPublishReplayThumbprintData(input: { const formatEnvironmentPublishReplayThumbprint = (digest: Uint8Array) => `env-publish:${Encoding.encodeBase64Url(digest)}`; -export function environmentPublishReplayThumbprint(input: { - readonly environmentId: string; - readonly environmentPublicKey: string; -}) { - return Crypto.Crypto.pipe( - Effect.flatMap((crypto) => - crypto.digest("SHA-256", environmentPublishReplayThumbprintData(input)), - ), - Effect.map(formatEnvironmentPublishReplayThumbprint), - ); -} - const make = Effect.gen(function* () { const proofReplay = yield* DpopProofs.DpopProofReplay; const config = yield* RelayConfiguration.RelayConfiguration; diff --git a/infra/relay/src/services/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts similarity index 100% rename from infra/relay/src/services/ManagedEndpointProvider.test.ts rename to infra/relay/src/environments/ManagedEndpointProvider.test.ts diff --git a/infra/relay/src/services/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts similarity index 99% rename from infra/relay/src/services/ManagedEndpointProvider.ts rename to infra/relay/src/environments/ManagedEndpointProvider.ts index c16413e5daa..f1ade7a30ae 100644 --- a/infra/relay/src/services/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -54,7 +54,7 @@ export interface ManagedEndpointProviderShape { export class ManagedEndpointProvider extends Context.Service< ManagedEndpointProvider, ManagedEndpointProviderShape ->()("ManagedEndpointProvider") {} +>()("t3code-relay/environments/ManagedEndpointProvider") {} const CloudflareTunnelCreateResponse = Schema.Struct({ success: Schema.Boolean, diff --git a/infra/relay/src/api.test.ts b/infra/relay/src/http/Api.test.ts similarity index 78% rename from infra/relay/src/api.test.ts rename to infra/relay/src/http/Api.test.ts index 626f5ade9b7..9eccb8d16b0 100644 --- a/infra/relay/src/api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -12,40 +12,13 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; import { - isDpopAuthorizationHeader, relayCors, - relayCorsPreflightHeaders, relayEnvironmentAuthLayer, relayNotFoundRoute, traceRelayHttpRequestWith, withoutCapturedParentSpan, -} from "./api.ts"; -import * as EnvironmentCredentials from "./persistence/EnvironmentCredentials.ts"; - -function splitHeaderTokens(value: string): ReadonlyArray { - return value.split(",").map((token) => token.trim()); -} - -describe("relay CORS", () => { - it("allows Effect trace propagation headers from browser clients", () => { - expect(splitHeaderTokens(relayCorsPreflightHeaders["access-control-allow-headers"])).toEqual([ - "authorization", - "b3", - "traceparent", - "content-type", - "dpop", - ]); - }); -}); - -describe("relay DPoP authentication", () => { - it("requires the HTTP DPoP authorization scheme", () => { - expect(isDpopAuthorizationHeader("DPoP access-token")).toBe(true); - expect(isDpopAuthorizationHeader("dpop access-token")).toBe(true); - expect(isDpopAuthorizationHeader("Bearer access-token")).toBe(false); - expect(isDpopAuthorizationHeader("access-token")).toBe(false); - }); -}); +} from "./Api.ts"; +import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; describe("relay environment authentication", () => { it.effect("preserves credential lookup persistence failures as internal errors", () => { @@ -111,11 +84,12 @@ describe("relay request tracing", () => { sampled: true, }); const endpoint = yield* withoutCapturedParentSpan( - Effect.context().pipe( - Effect.map((capturedContext: Context.Context) => - Effect.fn("relay.test.endpoint")(() => - Effect.succeed(HttpServerResponse.empty({ status: 204 })), - )().pipe(Effect.provideContext(capturedContext)), + 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)); diff --git a/infra/relay/src/api.ts b/infra/relay/src/http/Api.ts similarity index 84% rename from infra/relay/src/api.ts rename to infra/relay/src/http/Api.ts index 08d4d331da3..9c985768a23 100644 --- a/infra/relay/src/api.ts +++ b/infra/relay/src/http/Api.ts @@ -12,7 +12,6 @@ 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 HttpPlatform from "effect/unstable/http/HttpPlatform"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -48,33 +47,22 @@ import { } from "@t3tools/contracts/relay"; import { normalizeRelayIssuer } from "@t3tools/shared/relayJwt"; -import { verifyAndConsumeDpopProof } from "./dpop.ts"; -import * as DeliveryAttempts from "./persistence/DeliveryAttempts.ts"; -import * as AgentActivityRows from "./persistence/AgentActivityRows.ts"; -import * as Devices from "./persistence/Devices.ts"; -import * as DpopProofs from "./persistence/DpopProofs.ts"; -import * as EnvironmentCredentials from "./persistence/EnvironmentCredentials.ts"; -import * as EnvironmentLinks from "./persistence/EnvironmentLinks.ts"; -import * as LiveActivities from "./persistence/LiveActivities.ts"; -import * as RelayConfiguration from "./Config.ts"; -import * as AgentActivityPublisher from "./services/AgentActivityPublisher.ts"; -import * as EnvironmentConnector from "./services/EnvironmentConnector.ts"; -import * as EnvironmentLinker from "./services/EnvironmentLinker.ts"; -import * as EnvironmentPublishSignatures from "./services/EnvironmentPublishSignatures.ts"; -import * as MobileRegistrations from "./services/MobileRegistrations.ts"; -import { withSpanAttributes, withUserId } from "./telemetry.ts"; -import { RelayDb } from "./db.ts"; -import { - issueDpopAccessToken, - issueLinkChallengeToken, - resolveDpopAccessTokenScopes, - verifyDpopAccessToken, -} from "./relayTokens.ts"; - -export const RelayHttpPlatformLayer = Layer.succeed(HttpPlatform.HttpPlatform, { - fileResponse: () => Effect.die("Relay API does not serve filesystem responses"), - fileWebResponse: () => Effect.die("Relay API does not serve file responses"), -}); +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 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 = [ @@ -91,7 +79,7 @@ const relayCorsHeaders = { "access-control-expose-headers": relayCorsExposedHeaders.join(","), } as const; -export const relayCorsPreflightHeaders = { +const relayCorsPreflightHeaders = { ...relayCorsHeaders, "access-control-allow-methods": relayCorsAllowedMethods.join(","), "access-control-allow-headers": relayCorsAllowedHeaders.join(","), @@ -195,7 +183,7 @@ export const relayClientAuthLayer = Layer.effect( "relay.auth.subject": verified.sub, }); return yield* httpEffect.pipe( - withUserId(verified.sub), + withSpanAttributes({ "user.id": verified.sub }), Effect.provideService(RelayClientPrincipal, { userId: verified.sub, token, @@ -240,7 +228,7 @@ export const relayEnvironmentAuthLayer = Layer.effect( export const relayDpopClientAuthLayer = Layer.effect( RelayDpopClientAuth, Effect.gen(function* () { - const config = yield* RelayConfiguration.RelayConfiguration; + const relayTokens = yield* RelayTokens.RelayTokens; return { relayDpop: Effect.fn("relay.auth.dpop_client")(function* (httpEffect, { credential }) { yield* appendRelayDpopChallengeHeader; @@ -252,8 +240,7 @@ export const relayDpopClientAuthLayer = Layer.effect( // the separating spaces in the decoded credential. const token = Redacted.value(credential).trimStart(); const now = yield* DateTime.now; - const verified = yield* verifyDpopAccessToken({ - config, + const verified = yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), }); @@ -265,7 +252,7 @@ export const relayDpopClientAuthLayer = Layer.effect( "relay.auth.subject": verified.sub, }); return yield* httpEffect.pipe( - withUserId(verified.sub), + withSpanAttributes({ "user.id": verified.sub }), Effect.provideService(RelayClientPrincipal, { userId: verified.sub, token, @@ -278,7 +265,7 @@ export const relayDpopClientAuthLayer = Layer.effect( }), ); -export function isDpopAuthorizationHeader(value: string | undefined): boolean { +function isDpopAuthorizationHeader(value: string | undefined): boolean { return /^DPoP +/iu.test(value ?? ""); } @@ -298,9 +285,9 @@ export const metadataApi = HttpApiBuilder.group( Effect.succeed({ issuer, token_endpoint: `${issuer}/v1/client/dpop-token`, - grant_types_supported: ["urn:ietf:params:oauth:grant-type:token-exchange"] as const, - token_endpoint_auth_methods_supported: ["none"] as const, - dpop_signing_alg_values_supported: ["ES256"] as const, + 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, }), ) @@ -310,7 +297,7 @@ export const metadataApi = HttpApiBuilder.group( authorization_servers: [issuer], scopes_supported: scopes, dpop_bound_access_tokens_required: true, - dpop_signing_alg_values_supported: ["ES256"] as const, + dpop_signing_alg_values_supported: ["ES256"], }), ); }), @@ -326,9 +313,7 @@ export const healthApi = HttpApiBuilder.group( Effect.fn("relay.api.health")( function* () { const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis); - yield* db - .execute(drizzleSql`SELECT 1`) - .pipe(Effect.withSpan("relay.api.health.db_probe")); + 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, @@ -393,6 +378,7 @@ export const clientApi = HttpApiBuilder.group( 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 credentials = yield* EnvironmentCredentials.EnvironmentCredentials; @@ -486,14 +472,15 @@ export const clientApi = HttpApiBuilder.group( const jti = yield* crypto.randomUUIDv4.pipe( Effect.catch(() => relayInternalErrorResponse("internal_error")), ); - const challenge = yield* issueLinkChallengeToken({ - config, - 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"))); + 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")), ) @@ -532,12 +519,13 @@ export const tokenApi = HttpApiBuilder.group( 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 = resolveDpopAccessTokenScopes({ + const requestedScopes = relayTokens.resolveDpopAccessTokenScopes({ clientId: args.payload.client_id, scope: args.payload.scope, }); @@ -556,18 +544,17 @@ export const tokenApi = HttpApiBuilder.group( if (!verified.sub) { return yield* relayAuthInvalidError("invalid_bearer"); } - return yield* Effect.gen(function* () { - 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* issueDpopAccessToken({ - config, + 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, @@ -575,13 +562,13 @@ export const tokenApi = HttpApiBuilder.group( 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), - }; - }).pipe(withUserId(verified.sub)); + }) + .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))), + issued_token_type: RelayAccessTokenType, + token_type: "DPoP" as const, + expires_in: 300, + scope: encodeOAuthScope(requestedScopes), + }; }, mapRelayCommonApiErrors("invalid_dpop")), ); }), @@ -782,24 +769,26 @@ const currentTraceId = Effect.currentParentSpan.pipe( Effect.orElseSucceed(() => "unavailable"), ); -type RelayCommonPersistenceError = - | Devices.DeviceRegistrationPersistenceError - | Devices.DeviceUnregistrationPersistenceError - | LiveActivities.LiveActivityRegistrationPersistenceError - | EnvironmentLinks.EnvironmentLinkUserListPersistenceError - | EnvironmentLinks.EnvironmentPublicKeyListPersistenceError - | EnvironmentLinks.EnvironmentLinkListPersistenceError - | EnvironmentLinks.EnvironmentLinkLookupPersistenceError - | EnvironmentLinks.EnvironmentLinkRevokePersistenceError - | EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError - | EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError - | DpopProofs.DpopProofReplayPersistenceError - | LiveActivities.LiveActivityTargetListPersistenceError - | AgentActivityRows.AgentActivityRowUpsertPersistenceError - | AgentActivityRows.AgentActivityRowDeletePersistenceError - | AgentActivityRows.AgentActivityRowListPersistenceError - | LiveActivities.LiveActivityDeliveryMarkPersistenceError - | DeliveryAttempts.DeliveryAttemptRecordPersistenceError; +const COMMON_AUTH_INVALID_REASONS = [ + Devices.DeviceRegistrationPersistenceError, + Devices.DeviceUnregistrationPersistenceError, + LiveActivities.LiveActivityRegistrationPersistenceError, + EnvironmentLinks.EnvironmentLinkUserListPersistenceError, + EnvironmentLinks.EnvironmentPublicKeyListPersistenceError, + EnvironmentLinks.EnvironmentLinkListPersistenceError, + EnvironmentLinks.EnvironmentLinkLookupPersistenceError, + EnvironmentLinks.EnvironmentLinkRevokePersistenceError, + 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 @@ -807,25 +796,7 @@ type MapRelayCommonApiError = | (Extract extends never ? never : RelayInternalError); function isRelayCommonPersistenceError(error: unknown): error is RelayCommonPersistenceError { - return ( - error instanceof Devices.DeviceRegistrationPersistenceError || - error instanceof Devices.DeviceUnregistrationPersistenceError || - error instanceof LiveActivities.LiveActivityRegistrationPersistenceError || - error instanceof EnvironmentLinks.EnvironmentLinkUserListPersistenceError || - error instanceof EnvironmentLinks.EnvironmentPublicKeyListPersistenceError || - error instanceof EnvironmentLinks.EnvironmentLinkListPersistenceError || - error instanceof EnvironmentLinks.EnvironmentLinkLookupPersistenceError || - error instanceof EnvironmentLinks.EnvironmentLinkRevokePersistenceError || - error instanceof EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError || - error instanceof EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError || - error instanceof DpopProofs.DpopProofReplayPersistenceError || - error instanceof LiveActivities.LiveActivityTargetListPersistenceError || - error instanceof AgentActivityRows.AgentActivityRowUpsertPersistenceError || - error instanceof AgentActivityRows.AgentActivityRowDeletePersistenceError || - error instanceof AgentActivityRows.AgentActivityRowListPersistenceError || - error instanceof LiveActivities.LiveActivityDeliveryMarkPersistenceError || - error instanceof DeliveryAttempts.DeliveryAttemptRecordPersistenceError - ); + return COMMON_AUTH_INVALID_REASONS.some((ErrorType) => error instanceof ErrorType); } function relayInternalErrorResponse(reason: RelayInternalError["reason"]) { @@ -904,6 +875,7 @@ function mapErrorTags< return ( self: Effect.Effect, ): Effect.Effect | MappedTagError, R> => + // @effect-diagnostics-next-line unsafeEffectTypeAssertion:off Effect.catchTags(self, catchCases) as Effect.Effect< A, Exclude | MappedTagError, @@ -977,7 +949,8 @@ const requireDpopThumbprint = Effect.fn("relay.api.require_dpop_thumbprint")(fun if (url._tag === "None") { return yield* new HttpApiError.Unauthorized({}); } - return yield* verifyAndConsumeDpopProof({ + const dpopProofs = yield* DpopProofs.DpopProofReplay; + return yield* dpopProofs.verifyAndConsume({ proof: request.headers.dpop, method: request.method, url: url.value.href, @@ -996,7 +969,8 @@ const requireDpopProof = Effect.fn("relay.api.require_dpop_proof")(function* (op if (url._tag === "None") { return yield* new HttpApiError.Unauthorized({}); } - return yield* verifyAndConsumeDpopProof({ + const dpopProofs = yield* DpopProofs.DpopProofReplay; + return yield* dpopProofs.verifyAndConsume({ proof: request.headers.dpop, method: request.method, url: url.value.href, diff --git a/infra/relay/src/infra/ManagedEndpointStackConfig.test.ts b/infra/relay/src/infra/ManagedEndpointStackConfig.test.ts deleted file mode 100644 index 675b82216a8..00000000000 --- a/infra/relay/src/infra/ManagedEndpointStackConfig.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - MANAGED_ENDPOINT_PROVISIONER_TOKEN_POLICIES, - MANAGED_ENDPOINT_ZONE, -} from "./ManagedEndpointStackConfig.ts"; - -describe("ManagedEndpointStackConfig", () => { - it("restricts endpoint provisioning to the relay account and DNS zone", () => { - expect(MANAGED_ENDPOINT_PROVISIONER_TOKEN_POLICIES).toEqual([ - { - effect: "allow", - permissionGroups: ["Cloudflare Tunnel Read", "Cloudflare Tunnel Write"], - resources: { - [`com.cloudflare.api.account.${MANAGED_ENDPOINT_ZONE.accountId}`]: "*", - }, - }, - { - effect: "allow", - permissionGroups: ["DNS Read", "DNS Write"], - resources: { - [`com.cloudflare.api.account.zone.${MANAGED_ENDPOINT_ZONE.zoneId}`]: "*", - }, - }, - ]); - }); -}); diff --git a/infra/relay/src/infra/ManagedEndpointStackConfig.ts b/infra/relay/src/infra/ManagedEndpointStackConfig.ts deleted file mode 100644 index 28fb23ced5f..00000000000 --- a/infra/relay/src/infra/ManagedEndpointStackConfig.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const MANAGED_ENDPOINT_ZONE = { - name: "ineededadomain.com", - zoneId: "fcea40a6915723b0f5c4a9480eb3507b", - accountId: "1468bbd99811cdaccfbb707dc725421a", -} as const; - -export const RELAY_PUBLIC_DOMAIN = `t3code-relay.${MANAGED_ENDPOINT_ZONE.name}`; -export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`; -export const MANAGED_ENDPOINT_BASE_DOMAIN = MANAGED_ENDPOINT_ZONE.name; - -export const MANAGED_ENDPOINT_PROVISIONER_TOKEN_POLICIES = [ - { - effect: "allow" as const, - permissionGroups: ["Cloudflare Tunnel Read" as const, "Cloudflare Tunnel Write" as const], - resources: { - [`com.cloudflare.api.account.${MANAGED_ENDPOINT_ZONE.accountId}`]: "*", - }, - }, - { - effect: "allow" as const, - permissionGroups: ["DNS Read" as const, "DNS Write" as const], - resources: { - [`com.cloudflare.api.account.zone.${MANAGED_ENDPOINT_ZONE.zoneId}`]: "*", - }, - }, -]; diff --git a/infra/relay/src/infra/RelayObservability.test.ts b/infra/relay/src/infra/RelayObservability.test.ts deleted file mode 100644 index 7a7b6a6809b..00000000000 --- a/infra/relay/src/infra/RelayObservability.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import * as Alchemy from "alchemy"; -import * as Axiom from "alchemy/Axiom"; -import * as Output from "alchemy/Output"; -import * as Effect from "effect/Effect"; -import { describe, expect, it } from "vitest"; - -import { - RELAY_AXIOM_TRACE_DATASET, - provisionRelayObservability, - relayAxiomIngestDatasetCapabilities, - relayAxiomQueryDatasetCapabilities, - relayRecentSpansQuery, - relayTraceQuery, -} from "./RelayObservability.ts"; - -describe("RelayObservability", () => { - it("scopes the ingest token only to HTTP span ingestion", () => { - expect(relayAxiomIngestDatasetCapabilities()).toEqual({ - [RELAY_AXIOM_TRACE_DATASET]: { ingest: ["create"] }, - }); - }); - - it("scopes the diagnostics query token only to HTTP spans", () => { - expect(relayAxiomQueryDatasetCapabilities()).toEqual({ - [RELAY_AXIOM_TRACE_DATASET]: { query: ["read"] }, - }); - }); - - it("builds APL queries for the trace dataset", () => { - expect(relayTraceQuery("| where name == 'GET /health'", "relay-traces-test")).toBe( - "['relay-traces-test']\n| where name == 'GET /health'", - ); - }); - - it("projects Effect HTTP span attributes through their OTLP field names", () => { - const query = relayRecentSpansQuery("relay-traces-test"); - - expect(query).toContain("['relay-traces-test']"); - expect(query).toContain("attributes.http.request.method"); - expect(query).toContain("attributes.http.response.status_code"); - expect(query).toContain("attributes.url.path"); - expect(query).toContain("attributes.http.route"); - expect(query).toContain("customAttributes = column_ifexists('attributes.custom', dynamic({}))"); - expect(query).toContain("customAttributes['user.id']"); - expect(query).not.toContain("['http.request.method']"); - }); - - it("orders token and view resources behind the trace dataset", async () => { - const stack = { - name: "RelayObservabilityTest", - stage: "test", - resources: {}, - bindings: {}, - actions: {}, - }; - - await Effect.runPromise( - provisionRelayObservability.pipe( - Effect.provideService(Alchemy.Stack, stack), - Effect.provideService(Axiom.Providers, { - kind: "ProviderCollection", - get: () => undefined, - }), - ), - ); - - const resources = stack.resources as Record; - const traces = resources.RelayTracesDataset; - - expect(traces).toBeDefined(); - for (const logicalId of [ - "RelayAxiomIngestToken", - "RelayAxiomQueryToken", - "RelayRecentSpansView", - ]) { - expect(resources[logicalId]).toBeDefined(); - expect(Object.keys(Output.resolveUpstream(resources[logicalId]!.Props))).toContain( - traces!.FQN, - ); - } - }); -}); diff --git a/infra/relay/src/infra/RelayObservability.ts b/infra/relay/src/infra/RelayObservability.ts deleted file mode 100644 index 7cf943ee07d..00000000000 --- a/infra/relay/src/infra/RelayObservability.ts +++ /dev/null @@ -1,58 +0,0 @@ -import * as Axiom from "alchemy/Axiom"; -import * as Output from "alchemy/Output"; -import * as Effect from "effect/Effect"; - -export const RELAY_OBSERVABILITY_SERVICE_NAME = "t3-code-relay-worker"; -export const RELAY_OBSERVABILITY_EXPORT_INTERVAL = "1 second"; -export const RELAY_AXIOM_TRACE_DATASET = "t3-code-relay-traces"; - -export const relayTraceQuery = (query: string, dataset: string = RELAY_AXIOM_TRACE_DATASET) => - `['${dataset}']\n${query}`; - -export const relayRecentSpansQuery = (dataset: string = RELAY_AXIOM_TRACE_DATASET) => - relayTraceQuery( - "| where isnotnull(span_id) or isnotnull(trace_id)\n| 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({}))\n| extend userId = customAttributes['user.id']\n| project _time, name, trace_id, span_id, duration, requestMethod, path, statusCode, endpoint, userId\n| order by _time desc\n| limit 200", - dataset, - ); - -export const relayAxiomIngestDatasetCapabilities = ( - dataset: string = RELAY_AXIOM_TRACE_DATASET, -) => ({ - [dataset]: { ingest: ["create" as const] }, -}); - -export const relayAxiomQueryDatasetCapabilities = ( - dataset: string = RELAY_AXIOM_TRACE_DATASET, -) => ({ - [dataset]: { query: ["read" as const] }, -}); - -export const provisionRelayObservability = Effect.gen(function* () { - const traces = yield* Axiom.Dataset("RelayTracesDataset", { - name: RELAY_AXIOM_TRACE_DATASET, - kind: "otel:traces:v1", - description: "T3 Code relay Worker HTTP request spans.", - retentionDays: 30, - useRetentionPeriod: true, - }); - - const ingestToken = yield* Axiom.ApiToken("RelayAxiomIngestToken", { - name: "t3-code-relay-otel-ingest", - description: "Owned by Alchemy. Scoped OTLP ingest token for relay HTTP spans.", - datasetCapabilities: Output.map(traces.name, relayAxiomIngestDatasetCapabilities), - }); - const queryToken = yield* Axiom.ApiToken("RelayAxiomQueryToken", { - name: "t3-code-relay-readonly-query", - description: "Owned by Alchemy. Read-only query token for relay HTTP span diagnostics.", - datasetCapabilities: Output.map(traces.name, relayAxiomQueryDatasetCapabilities), - }); - - yield* Axiom.View("RelayRecentSpansView", { - name: "t3-code-relay-recent-spans", - description: "Recent relay HTTP request spans.", - datasets: [traces.name], - aplQuery: Output.map(traces.name, relayRecentSpansQuery), - }); - - return { traces, ingestToken, queryToken } as const; -}); diff --git a/infra/relay/src/managedEndpointStack.ts b/infra/relay/src/managedEndpointStack.ts new file mode 100644 index 00000000000..d1054a80b70 --- /dev/null +++ b/infra/relay/src/managedEndpointStack.ts @@ -0,0 +1,34 @@ +import * as Cloudflare from "alchemy/Cloudflare"; + +// This should be pulled from the Alchemy authenticated cloudflare account +export const CLOUDFLARE_ACCOUNT_ID = "1468bbd99811cdaccfbb707dc725421a"; + +// We should only need to specify one of these after Alchemy have a CloudflareZone resource: https://github.com/alchemy-run/alchemy-effect/pull/493 +export const MANAGED_ENDPOINT_ZONE_ID = "fcea40a6915723b0f5c4a9480eb3507b"; +export const MANAGED_ENDPOINT_ZONE_NAME = "ineededadomain.com"; + +export const RELAY_PUBLIC_DOMAIN = `t3code-relay.${MANAGED_ENDPOINT_ZONE_NAME}`; +export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`; + +export const ManagedEndpointProvisionerToken = Cloudflare.AccountApiToken( + "ManagedEndpointProvisionerToken", + { + name: "t3-code-relay-managed-endpoint-provisioner", + policies: [ + { + effect: "allow" as const, + permissionGroups: ["Cloudflare Tunnel Read" as const, "Cloudflare Tunnel Write" as const], + resources: { + [`com.cloudflare.api.account.${CLOUDFLARE_ACCOUNT_ID}`]: "*", + }, + }, + { + effect: "allow" as const, + permissionGroups: ["DNS Read" as const, "DNS Write" as const], + resources: { + [`com.cloudflare.api.account.zone.${MANAGED_ENDPOINT_ZONE_ID}`]: "*", + }, + }, + ], + }, +); diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts new file mode 100644 index 00000000000..f3f40357c82 --- /dev/null +++ b/infra/relay/src/observability.ts @@ -0,0 +1,72 @@ +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"; + +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 traces = yield* Axiom.Dataset("RelayTracesDataset", { + name: "t3-code-relay-traces", + kind: "otel:traces:v1", + description: "T3 Code relay Worker HTTP request spans.", + retentionDays: 30, + useRetentionPeriod: true, + }); + + const ingestToken = yield* Axiom.ApiToken("RelayAxiomIngestToken", { + name: "t3-code-relay-otel-ingest", + 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: "t3-code-relay-recent-spans", + 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/DpopProofs.ts b/infra/relay/src/persistence/DpopProofs.ts deleted file mode 100644 index a0e296c5703..00000000000 --- a/infra/relay/src/persistence/DpopProofs.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 { lt } from "drizzle-orm"; - -import { RelayDb } from "../db.ts"; -import { relayDpopProofs } from "../schema.ts"; - -export class DpopProofReplayPersistenceError extends Data.TaggedError( - "DpopProofReplayPersistenceError", -)<{ - readonly cause: unknown; -}> {} - -export interface DpopProofReplayShape { - 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()( - "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 pruneExpired: DpopProofReplayShape["pruneExpired"] = Effect.fn( - "relay.dpop_proofs.prune_expired", - )(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.mapError((cause) => new DpopProofReplayPersistenceError({ cause }))); - - return DpopProofReplay.of({ - consume, - pruneExpired, - }); -}); - -export const layer = Layer.effect(DpopProofReplay, make); diff --git a/infra/relay/src/persistence/json.ts b/infra/relay/src/persistence/json.ts deleted file mode 100644 index 9adaf36130f..00000000000 --- a/infra/relay/src/persistence/json.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); -const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); - -export const parseJsonString = (value: string) => - decodeJsonString(value).pipe(Effect.map((decoded) => decoded as A)); - -export const stringifyJsonValue = (value: unknown) => encodeJsonValue(value); diff --git a/infra/relay/src/schema.ts b/infra/relay/src/persistence/schema.ts similarity index 100% rename from infra/relay/src/schema.ts rename to infra/relay/src/persistence/schema.ts diff --git a/infra/relay/src/relayTokens.test.ts b/infra/relay/src/relayTokens.test.ts deleted file mode 100644 index c2f61e47ff0..00000000000 --- a/infra/relay/src/relayTokens.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import * as NodeCrypto from "node:crypto"; - -import { describe, expect, it } from "vitest"; -import { signRelayJwt } from "@t3tools/shared/relayJwt"; -import * as Effect from "effect/Effect"; -import * as Redacted from "effect/Redacted"; - -import * as RelayConfiguration from "./Config.ts"; -import { - issueDpopAccessToken, - issueLinkChallengeToken, - resolveDpopAccessTokenScopes, - verifyDpopAccessToken, - verifyLinkChallengeToken, -} 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"), - cloudMintPrivateKey: Redacted.make(keyPair.privateKey), - cloudMintPublicKey: keyPair.publicKey, - managedEndpointBaseDomain: undefined, - cloudflareAccountId: undefined, - cloudflareZoneId: undefined, - cloudflareApiToken: undefined, -}); - -describe("relay tokens", () => { - it("issues a user-bound environment link challenge", async () => { - const token = await Effect.runPromise( - issueLinkChallengeToken({ - config, - userId: "user_123", - request: { - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }, - jti: "challenge-1", - issuedAtEpochSeconds: 100, - expiresAtEpochSeconds: 200, - }), - ); - - expect( - await Effect.runPromise( - verifyLinkChallengeToken({ - config, - token, - userId: "user_123", - request: { - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }, - nowEpochSeconds: 150, - }), - ), - ).toMatchObject({ sub: "user_123", jti: "challenge-1" }); - expect( - await Effect.runPromise( - verifyLinkChallengeToken({ - config, - token, - userId: "attacker", - request: { - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }, - nowEpochSeconds: 150, - }), - ), - ).toBeNull(); - }); - - it("issues and verifies DPoP access tokens bound to one proof-key thumbprint", async () => { - const token = await Effect.runPromise( - issueDpopAccessToken({ - config, - 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( - await Effect.runPromise(verifyDpopAccessToken({ config, token, nowEpochSeconds: 150 })), - ).toMatchObject({ - sub: "user_123", - cnf: { jkt: "proof-key-thumbprint" }, - client_id: "t3-mobile", - scope: ["environment:connect", "environment:status", "mobile:registration"], - }); - expect( - await Effect.runPromise(verifyDpopAccessToken({ config, token, nowEpochSeconds: 261 })), - ).toBeNull(); - }); - - it("issues tunnel-only DPoP access tokens to web public clients", async () => { - const token = await Effect.runPromise( - issueDpopAccessToken({ - config, - 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( - await Effect.runPromise(verifyDpopAccessToken({ config, token, nowEpochSeconds: 150 })), - ).toMatchObject({ - client_id: "t3-web", - scope: ["environment:connect", "environment:status"], - cnf: { jkt: "web-proof-key-thumbprint" }, - }); - }); - - it("treats requested scope as an order-independent set", () => { - expect( - resolveDpopAccessTokenScopes({ - clientId: "t3-mobile", - scope: "environment:status environment:connect environment:status", - }), - ).toEqual(["environment:status", "environment:connect"]); - }); - - it("rejects signed DPoP tokens whose scope is outside the relay policy", async () => { - const token = await Effect.runPromise( - 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( - await Effect.runPromise(verifyDpopAccessToken({ config, token, nowEpochSeconds: 150 })), - ).toBeNull(); - }); - - it("rejects mobile registration scope on a web public client token", async () => { - const token = await Effect.runPromise( - 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( - await Effect.runPromise(verifyDpopAccessToken({ config, token, nowEpochSeconds: 150 })), - ).toBeNull(); - }); -}); diff --git a/infra/relay/src/relayTokens.ts b/infra/relay/src/relayTokens.ts deleted file mode 100644 index 6cef7272577..00000000000 --- a/infra/relay/src/relayTokens.ts +++ /dev/null @@ -1,186 +0,0 @@ -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 } from "@t3tools/shared/relayJwt"; -import * as Effect from "effect/Effect"; -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]), -}; - -export function resolveDpopAccessTokenScopes(input: { - readonly clientId: RelayPublicClientId; - readonly scope: string; -}): ReadonlyArray | null { - return parseAllowedOAuthScope({ - value: input.scope, - allowedScopes: allowedScopesByClientId[input.clientId], - }); -} - -export function issueLinkChallengeToken(input: { - readonly config: RelayConfiguration.RelayConfigurationShape; - readonly userId: string; - readonly request: RelayEnvironmentLinkChallengeRequest; - readonly jti: string; - readonly issuedAtEpochSeconds: number; - readonly expiresAtEpochSeconds: number; -}) { - const issuer = normalizeRelayIssuer(input.config.relayIssuer); - return signRelayJwt({ - privateKey: Redacted.value(input.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, - }, - }); -} - -export function verifyLinkChallengeToken(input: { - readonly config: RelayConfiguration.RelayConfigurationShape; - readonly token: string; - readonly userId: string; - readonly request: RelayEnvironmentLinkChallengeRequest; - readonly nowEpochSeconds: number; -}) { - const issuer = normalizeRelayIssuer(input.config.relayIssuer); - return verifyRelayJwt({ - publicKey: input.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)), - ); -} - -export function issueDpopAccessToken(input: { - readonly config: RelayConfiguration.RelayConfigurationShape; - readonly userId: string; - readonly proofKeyThumbprint: string; - readonly jti: string; - readonly issuedAtEpochSeconds: number; - readonly expiresAtEpochSeconds: number; - readonly clientId: RelayPublicClientId; - readonly scopes: ReadonlyArray; -}) { - const issuer = normalizeRelayIssuer(input.config.relayIssuer); - return signRelayJwt({ - privateKey: Redacted.value(input.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 }, - }, - }); -} - -export function verifyDpopAccessToken(input: { - readonly config: RelayConfiguration.RelayConfigurationShape; - readonly token: string; - readonly nowEpochSeconds: number; -}) { - const issuer = normalizeRelayIssuer(input.config.relayIssuer); - return verifyRelayJwt({ - publicKey: input.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), - ); -} diff --git a/infra/relay/src/services/Auth.ts b/infra/relay/src/services/Auth.ts deleted file mode 100644 index a7f92b566bb..00000000000 --- a/infra/relay/src/services/Auth.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as Context from "effect/Context"; - -export interface ClientPrincipalShape { - readonly userId: string; -} - -export class ClientPrincipal extends Context.Service()( - "ClientPrincipal", -) {} - -export interface EnvironmentPrincipalShape { - readonly environmentId: string; - readonly credentialId: string; -} - -export class EnvironmentPrincipal extends Context.Service< - EnvironmentPrincipal, - EnvironmentPrincipalShape ->()("EnvironmentPrincipal") {} diff --git a/infra/relay/src/services/EnvironmentLinker.ts b/infra/relay/src/services/EnvironmentLinker.ts deleted file mode 100644 index ff12bc688c7..00000000000 --- a/infra/relay/src/services/EnvironmentLinker.ts +++ /dev/null @@ -1,270 +0,0 @@ -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 "../persistence/DpopProofs.ts"; -import * as EnvironmentCredentials from "../persistence/EnvironmentCredentials.ts"; -import * as EnvironmentLinks from "../persistence/EnvironmentLinks.ts"; -import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; -import * as RelayConfiguration from "../Config.ts"; -import { verifyLinkChallengeToken } from "../relayTokens.ts"; -import { withUserId } from "../telemetry.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()( - "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 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* verifyLinkChallengeToken({ - config, - 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({ - 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, - }; - }, - (effect, input) => effect.pipe(withUserId(input.userId)), - ), - }); -}); - -export const layer = Layer.effect(EnvironmentLinker, make); diff --git a/infra/relay/src/telemetry.test.ts b/infra/relay/src/telemetry.test.ts deleted file mode 100644 index 51cd36d8387..00000000000 --- a/infra/relay/src/telemetry.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Tracer from "effect/Tracer"; - -import { withUserId } from "./telemetry.ts"; - -describe("relay telemetry", () => { - it.effect("annotates the active span and descendant spans with the user id", () => - Effect.gen(function* () { - const spans: Array = []; - const tracer = Tracer.make({ - span: (options) => { - const span = new Tracer.NativeSpan(options); - spans.push(span); - return span; - }, - }); - - yield* Effect.succeed("ok").pipe( - Effect.withSpan("relay.test.child"), - withUserId("user-123"), - Effect.withSpan("relay.test.parent"), - Effect.provide(Layer.succeed(Tracer.Tracer, tracer)), - ); - - expect(spans.map((span) => span.name)).toEqual(["relay.test.parent", "relay.test.child"]); - expect(spans.map((span) => span.attributes.get("user.id"))).toEqual(["user-123", "user-123"]); - }), - ); -}); diff --git a/infra/relay/src/telemetry.ts b/infra/relay/src/telemetry.ts deleted file mode 100644 index c82454e5b98..00000000000 --- a/infra/relay/src/telemetry.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as Effect from "effect/Effect"; - -export const withSpanAttributes = - (attributes: Record) => - (effect: Effect.Effect): Effect.Effect => - Effect.annotateCurrentSpan(attributes).pipe( - Effect.andThen(effect.pipe(Effect.annotateSpans(attributes))), - ); - -export const withUserId = (userId: string) => withSpanAttributes({ "user.id": userId }); diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 4543050917c..4bb50e2bd90 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -2,20 +2,18 @@ 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 Redacted from "effect/Redacted"; 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 OtlpSerialization from "effect/unstable/observability/OtlpSerialization"; -import * as OtlpTracer from "effect/unstable/observability/OtlpTracer"; import { RelayApi } from "@t3tools/contracts/relay"; import { - RelayHttpPlatformLayer, clientApi, dpopClientApi, healthApi, @@ -30,39 +28,54 @@ import { traceRelayHttpRequestWith, tokenApi, withoutCapturedParentSpan, -} from "./api.ts"; +} from "./http/Api.ts"; import { - MANAGED_ENDPOINT_BASE_DOMAIN, - MANAGED_ENDPOINT_PROVISIONER_TOKEN_POLICIES, - MANAGED_ENDPOINT_ZONE, + CLOUDFLARE_ACCOUNT_ID, + MANAGED_ENDPOINT_ZONE_ID, + MANAGED_ENDPOINT_ZONE_NAME, + ManagedEndpointProvisionerToken, RELAY_PUBLIC_DOMAIN, RELAY_PUBLIC_ORIGIN, -} from "./infra/ManagedEndpointStackConfig.ts"; -import { - RELAY_AXIOM_TRACE_DATASET, - RELAY_OBSERVABILITY_EXPORT_INTERVAL, - RELAY_OBSERVABILITY_SERVICE_NAME, - provisionRelayObservability, -} from "./infra/RelayObservability.ts"; -import * as DeliveryAttempts from "./persistence/DeliveryAttempts.ts"; -import * as AgentActivityRows from "./persistence/AgentActivityRows.ts"; -import * as Devices from "./persistence/Devices.ts"; -import * as DpopProofs from "./persistence/DpopProofs.ts"; -import * as EnvironmentCredentials from "./persistence/EnvironmentCredentials.ts"; -import * as EnvironmentLinks from "./persistence/EnvironmentLinks.ts"; -import * as LiveActivities from "./persistence/LiveActivities.ts"; +} from "./managedEndpointStack.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 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 "./services/AgentActivityPublisher.ts"; -import * as ApnsDeliveryQueue from "./services/ApnsDeliveryQueue.ts"; -import * as ApnsDeliveries from "./services/ApnsDeliveries.ts"; -import * as EnvironmentConnector from "./services/EnvironmentConnector.ts"; -import * as EnvironmentLinker from "./services/EnvironmentLinker.ts"; -import * as EnvironmentPublishSignatures from "./services/EnvironmentPublishSignatures.ts"; -import * as ManagedEndpointProvider from "./services/ManagedEndpointProvider.ts"; -import * as MobileRegistrations from "./services/MobileRegistrations.ts"; -import * as RelayCrypto from "./RelayCrypto.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, @@ -74,32 +87,10 @@ const relayApiLayer = Layer.mergeAll( serverApi, ); -const makeRelayTraceLayer = (input: { - readonly tracesEndpoint: string; - readonly tracesDatasetName: string; - readonly ingestToken: Redacted.Redacted; -}) => - OtlpTracer.layer({ - url: input.tracesEndpoint, - resource: { - serviceName: RELAY_OBSERVABILITY_SERVICE_NAME, - attributes: { - "service.runtime": "cloudflare-worker", - "service.component": "relay", - }, - }, - headers: { - Authorization: `Bearer ${Redacted.value(input.ingestToken)}`, - "X-Axiom-Dataset": input.tracesDatasetName, - }, - exportInterval: RELAY_OBSERVABILITY_EXPORT_INTERVAL, - }).pipe(Layer.provide(OtlpSerialization.layerJson)); - -// Bind secrets explicitly and only read them through the runtime worker -// environment. Reading them through Config during Worker init currently -// registers a competing plaintext binding. -const apnsPrivateKeyConfig = Config.redacted("APNS_PRIVATE_KEY"); -const clerkSecretKeyConfig = Config.redacted("CLERK_SECRET_KEY"); +const CloudMintKeyPair = Alchemy.KeyPair("CloudMintKeyPair"); +const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningSecret", { + bytes: 32, +}); export default class Api extends Cloudflare.Worker()( "Api", @@ -110,58 +101,57 @@ export default class Api extends Cloudflare.Worker()( flags: ["nodejs_compat"], }, domain: RELAY_PUBLIC_DOMAIN, - env: { - APNS_PRIVATE_KEY: apnsPrivateKeyConfig, - CLERK_SECRET_KEY: clerkSecretKeyConfig, - }, }, Effect.gen(function* () { - const managedEndpointProvisionerToken = yield* Cloudflare.AccountApiToken( - "ManagedEndpointProvisionerToken", - { - name: "t3-code-relay-managed-endpoint-provisioner", - policies: MANAGED_ENDPOINT_PROVISIONER_TOKEN_POLICIES, - }, - ); - const managedEndpointCloudflareApiToken = yield* managedEndpointProvisionerToken.value; - const relayHyperdrive = yield* RelayHyperdrive; + // + // 1. Provision Infrastructure for the Worker to use + // const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; - const hyperdrive = yield* Cloudflare.Hyperdrive.bind(relayHyperdrive); const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue); - const cloudMintKeyPair = yield* Alchemy.KeyPair("CloudMintKeyPair"); + const alchemyRuntimeContext = yield* Alchemy.RuntimeContext; + const cloudMintKeyPair = yield* CloudMintKeyPair; + const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayHyperdrive); + const managedEndpointProvisionerToken = yield* ManagedEndpointProvisionerToken; + const managedEndpointCloudflareApiToken = yield* managedEndpointProvisionerToken.value; + const randomApnsDeliveryJobSigningSecret = yield* ApnsDeliveryJobSigningSecret; + const observability = yield* RelayObservability; + + // + // 2. Create bindings + // const environment = yield* Config.schema( RelayConfiguration.ApnsEnvironment, "APNS_ENVIRONMENT", - ).pipe(Config.withDefault("sandbox")); + ); 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 relayObservability = yield* provisionRelayObservability; - const axiomIngestToken = yield* relayObservability.ingestToken.token; - const axiomTracesEndpoint = yield* relayObservability.traces.otelTracesEndpoint; - const relayTraceLayer = Effect.all({ - tracesEndpoint: axiomTracesEndpoint, - ingestToken: axiomIngestToken, - }).pipe( - Effect.map((input) => - makeRelayTraceLayer({ ...input, tracesDatasetName: RELAY_AXIOM_TRACE_DATASET }), - ), - Layer.unwrap, - ); - const randomApnsDeliveryJobSigningSecret = yield* Alchemy.Random( - "ApnsDeliveryJobSigningSecret", - { bytes: 32 }, - ); - const apnsDeliveryJobSigningSecret = yield* randomApnsDeliveryJobSigningSecret.text; + const apnsPrivateKey = yield* Config.redacted("APNS_PRIVATE_KEY"); + const apnsDeliveryJobSigningSecret = yield* randomApnsDeliveryJobSigningSecret; + + 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 cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; const db = yield* Drizzle.postgres(hyperdrive.connectionString); + const queueSender = ApnsDeliveryQueue.ApnsDeliveryQueueSender.of({ + send: (body) => + apnsDeliveryQueueSender.send(body).pipe( + Effect.mapError((cause) => new ApnsDeliveryQueue.ApnsDeliveryQueueSendError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }); + + // + // 3. Runtime layers and app construction + // const loadSettings = Effect.gen(function* () { - const workerEnvironment = yield* Cloudflare.WorkerEnvironment; - const apnsPrivateKey = Redacted.make(workerEnvironment.APNS_PRIVATE_KEY); - const clerkSecretKey = Redacted.make(workerEnvironment.CLERK_SECRET_KEY); return RelayConfiguration.RelayConfiguration.of({ relayIssuer: RELAY_PUBLIC_ORIGIN, apns: { @@ -175,16 +165,25 @@ export default class Api extends Cloudflare.Worker()( clerkSecretKey, cloudMintPrivateKey: yield* cloudMintPrivateKey, cloudMintPublicKey: yield* cloudMintPublicKey, - managedEndpointBaseDomain: MANAGED_ENDPOINT_BASE_DOMAIN, - cloudflareAccountId: MANAGED_ENDPOINT_ZONE.accountId, - cloudflareZoneId: MANAGED_ENDPOINT_ZONE.zoneId, + managedEndpointBaseDomain: MANAGED_ENDPOINT_ZONE_NAME, + cloudflareAccountId: CLOUDFLARE_ACCOUNT_ID, + cloudflareZoneId: MANAGED_ENDPOINT_ZONE_ID, cloudflareApiToken: yield* managedEndpointCloudflareApiToken, }); }); + const relayTraceLayer = Layer.unwrap( + Effect.all({ + tracesDatasetName: axiomDatasetName, + tracesEndpoint: axiomTracesEndpoint, + ingestToken: axiomIngestToken, + }).pipe(Effect.map(makeRelayTraceLayer)), + ); + const runtimeLayer = Layer.unwrap( Effect.gen(function* () { const settings = yield* loadSettings; + return Layer.mergeAll( MobileRegistrations.layer.pipe(Layer.provideMerge(AgentActivityPublisher.layer)), EnvironmentConnector.layer, @@ -195,7 +194,7 @@ export default class Api extends Cloudflare.Worker()( EnvironmentPublishSignatures.layer.pipe(Layer.provideMerge(DpopProofs.layer)), DpopProofs.layer, ).pipe( - Layer.provide(ApnsDeliveries.layer), + Layer.provide(ApnsDeliveries.layer.pipe(Layer.provide(ApnsClient.layer))), Layer.provide(ApnsDeliveryQueue.layer), Layer.provide(AgentActivityRows.layer), Layer.provide(Devices.layer), @@ -203,21 +202,11 @@ export default class Api extends Cloudflare.Worker()( Layer.provide(EnvironmentLinks.layer), Layer.provide(LiveActivities.layer), Layer.provide(DeliveryAttempts.layer), + Layer.provide(RelayTokens.layer), Layer.provide(Layer.succeed(RelayDb, db)), - Layer.provide( - Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { - send: (body) => - apnsDeliveryQueueSender - .send(body) - .pipe( - Effect.mapError( - (cause) => new ApnsDeliveryQueue.ApnsDeliveryQueueSendError({ cause }), - ), - ) as Effect.Effect, - }), - ), + Layer.provide(Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, queueSender)), Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), - Layer.provide(RelayCrypto.layer), + Layer.provide(webcryptoLayer), ); }), ); @@ -232,9 +221,10 @@ export default class Api extends Cloudflare.Worker()( Layer.provide(relayEnvironmentAuthLayer), Layer.provide(EnvironmentCredentials.layer), Layer.provide(EnvironmentLinks.layer), + Layer.provide(RelayTokens.layer), Layer.provide(Layer.succeed(RelayDb, db)), Layer.provideMerge(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), - Layer.provide(RelayCrypto.layer), + Layer.provide(webcryptoLayer), ); }), ); @@ -271,14 +261,13 @@ export default class Api extends Cloudflare.Worker()( const fetch = Layer.merge( HttpApiBuilder.layer(RelayApi).pipe( Layer.provide(appLayer), - Layer.provide([Etag.layerWeak, RelayHttpPlatformLayer, relayCors]), + Layer.provide([Etag.layerWeak, httpPlatformNotSupportedLayer, relayCors]), ), relayNotFoundRoute, ).pipe( HttpRouter.toHttpEffect, withoutCapturedParentSpan, - Effect.map((httpEffect) => traceRelayHttpRequestWith(httpEffect, relayTraceLayer)), - Effect.flatten, + Effect.flatMap((httpEffect) => traceRelayHttpRequestWith(httpEffect, relayTraceLayer)), ); return { fetch }; From f5f700f006ffcf19fc907853241253d9501ca886 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 09:50:58 -0700 Subject: [PATCH 03/61] Update relay for Alchemy beta.48 and Cloudflare bindings --- infra/relay/alchemy.run.ts | 3 + infra/relay/package.json | 2 +- infra/relay/src/Config.ts | 1 - .../src/agentActivity/ApnsDeliveries.test.ts | 1 - .../agentActivity/MobileRegistrations.test.ts | 1 - infra/relay/src/auth/RelayTokens.test.ts | 1 - .../environments/EnvironmentConnector.test.ts | 1 - .../environments/EnvironmentLinker.test.ts | 1 - .../EnvironmentPublishSignatures.test.ts | 1 - .../ManagedEndpointProvider.test.ts | 304 ++++++++---------- .../environments/ManagedEndpointProvider.ts | 263 +++++++-------- infra/relay/src/managedEndpointStack.ts | 48 ++- infra/relay/src/worker.ts | 64 +++- 13 files changed, 324 insertions(+), 367 deletions(-) diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index 19a04f7bc04..bd906adc51a 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -9,6 +9,7 @@ import * as Planetscale from "alchemy/Planetscale"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; +import { ManagedEndpointZone } from "./src/managedEndpointStack.ts"; import Api from "./src/worker.ts"; export default Alchemy.Stack( @@ -26,6 +27,7 @@ export default Alchemy.Stack( Effect.gen(function* () { const db = yield* PlanetscaleDatabase; const hyperdrive = yield* RelayHyperdrive; + const zone = yield* ManagedEndpointZone; const api = yield* Api; return { @@ -33,6 +35,7 @@ export default Alchemy.Stack( hyperdriveName: hyperdrive.name, workerName: api.workerName, url: api.url, + managedEndpointZoneId: zone.zoneId, }; }), ); diff --git a/infra/relay/package.json b/infra/relay/package.json index 968b0aa8458..d73269a63dc 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -14,7 +14,7 @@ "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", - "alchemy": "2.0.0-beta.47", + "alchemy": "2.0.0-beta.48", "drizzle-orm": "1.0.0-rc.3", "effect": "catalog:" }, diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index f6c1eb596f1..78fbe0e91d9 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -21,7 +21,6 @@ export interface RelayConfigurationShape { readonly cloudMintPrivateKey: Redacted.Redacted; readonly cloudMintPublicKey: string; readonly managedEndpointBaseDomain: string | undefined; - readonly cloudflareAccountId: string | undefined; readonly cloudflareZoneId: string | undefined; readonly cloudflareApiToken: Redacted.Redacted | undefined; } diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index ea5ca898b45..d1c820cb3d6 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -41,7 +41,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make("cloud-private-key"), cloudMintPublicKey: "cloud-public-key", managedEndpointBaseDomain: undefined, - cloudflareAccountId: undefined, cloudflareZoneId: undefined, cloudflareApiToken: undefined, }); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index b3902cb7cde..cadb049fd44 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -130,7 +130,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make("cloud-private-key"), cloudMintPublicKey: "cloud-public-key", managedEndpointBaseDomain: undefined, - cloudflareAccountId: undefined, cloudflareZoneId: undefined, cloudflareApiToken: undefined, }); diff --git a/infra/relay/src/auth/RelayTokens.test.ts b/infra/relay/src/auth/RelayTokens.test.ts index 0d641c9c8f2..07309b021b4 100644 --- a/infra/relay/src/auth/RelayTokens.test.ts +++ b/infra/relay/src/auth/RelayTokens.test.ts @@ -28,7 +28,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make(keyPair.privateKey), cloudMintPublicKey: keyPair.publicKey, managedEndpointBaseDomain: undefined, - cloudflareAccountId: undefined, cloudflareZoneId: undefined, cloudflareApiToken: undefined, }); diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 935e44de8a3..391c2711e67 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -68,7 +68,6 @@ const settings = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make(cloudKeyPair.privateKey), cloudMintPublicKey: cloudKeyPair.publicKey, managedEndpointBaseDomain: undefined, - cloudflareAccountId: undefined, cloudflareZoneId: undefined, cloudflareApiToken: undefined, }); diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index 7eb5dcc6e8d..b27e9cba4e6 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -41,7 +41,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make(relayKeyPair.privateKey), cloudMintPublicKey: relayKeyPair.publicKey, managedEndpointBaseDomain: undefined, - cloudflareAccountId: undefined, cloudflareZoneId: undefined, cloudflareApiToken: undefined, }); diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index 723f73f96a0..055da770f3c 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -36,7 +36,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make(keyPair.privateKey), cloudMintPublicKey: keyPair.publicKey, managedEndpointBaseDomain: undefined, - cloudflareAccountId: undefined, cloudflareZoneId: undefined, cloudflareApiToken: undefined, }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index bd1bbb47658..aea7c99b06a 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -24,11 +24,53 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make("cloud-private-key"), cloudMintPublicKey: "cloud-public-key", managedEndpointBaseDomain: "t3code.test", - cloudflareAccountId: "account-id", cloudflareZoneId: "zone-id", cloudflareApiToken: Redacted.make("api-token"), }); +interface TunnelCall { + readonly operation: "list" | "create" | "putConfiguration" | "getToken"; + readonly input: unknown; +} + +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"; + }), + }); +} + +function providerLayer( + execute: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, + tunnelClient = makeTunnelClient(), +) { + return ManagedEndpointProvider.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), + Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), + Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointTunnelClient, tunnelClient)), + ); +} + function decodeBody(request: HttpClientRequest.HttpClientRequest): unknown { return request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) @@ -45,8 +87,31 @@ function expectedManagedTunnelName(environmentId: string): string { return `t3-code-env-abc-${hash}`; } +function cloudflareApplicationErrorResponse(request: HttpClientRequest.HttpClientRequest) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { + success: false, + result: [], + errors: [{ code: 10_000, message: "Cloudflare application failure" }], + }, + { status: 200 }, + ), + ), + ); +} + +function cloudflareNonSuccessHttpResponse(request: HttpClientRequest.HttpClientRequest) { + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ success: false }, { status: 503 })), + ); +} + describe("ManagedEndpointProvider", () => { it.effect("provisions a Cloudflare tunnel endpoint and connector token", () => { + const tunnelCalls: TunnelCall[] = []; const calls: Array<{ readonly method: string; readonly url: string; @@ -61,18 +126,6 @@ describe("ManagedEndpointProvider", () => { body: decodeBody(request), authorization: request.headers.authorization, }); - if (request.url.includes("/cfd_tunnel?")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: [] }, { status: 200 }), - ); - } - if (request.url.endsWith("/token")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: "connector-token" }, { status: 200 }), - ); - } if (request.url.includes("/dns_records?")) { return HttpClientResponse.fromWeb( request, @@ -85,19 +138,7 @@ describe("ManagedEndpointProvider", () => { Response.json({ success: true }, { status: 200 }), ); } - if (request.url.endsWith("/configurations")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true }, { status: 200 }), - ); - } - return HttpClientResponse.fromWeb( - request, - Response.json( - { success: true, result: { id: "tunnel-id", name: "tunnel-name" } }, - { status: 200 }, - ), - ); + throw new Error(`Unexpected DNS request: ${request.method} ${request.url}`); }); return Effect.gen(function* () { @@ -118,20 +159,19 @@ describe("ManagedEndpointProvider", () => { providerKind: "cloudflare_tunnel", connectorToken: "connector-token", tunnelId: "tunnel-id", - tunnelName: "tunnel-name", + tunnelName: expectedManagedTunnelName("env_ABC"), }, }); - expect(calls.map((call) => call.method)).toEqual([ - "GET", - "POST", - "PUT", - "GET", - "POST", - "GET", - ]); + expect(calls.map((call) => call.method)).toEqual(["GET", "POST"]); expect(calls.every((call) => call.authorization === "Bearer api-token")).toBe(true); - expect(calls[2]?.body).toMatchObject({ - config: { + expect(tunnelCalls.map((call) => call.operation)).toEqual([ + "list", + "create", + "putConfiguration", + "getToken", + ]); + expect(tunnelCalls[2]?.input).toMatchObject({ + tunnelConfig: { ingress: [ { hostname, @@ -141,23 +181,17 @@ describe("ManagedEndpointProvider", () => { ], }, }); - expect(calls[0]?.url).toContain( - `name=${expectedManagedTunnelName("env_ABC")}&is_deleted=false`, - ); - }).pipe( - Effect.provide( - ManagedEndpointProvider.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), - Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), - ), - ), - ); + expect(tunnelCalls[0]?.input).toEqual({ + name: expectedManagedTunnelName("env_ABC"), + isDeleted: false, + }); + }).pipe(Effect.provide(providerLayer(execute, makeTunnelClient(tunnelCalls)))); }); it.effect( "normalizes unusual environment ids before using them in Cloudflare tunnel names", () => { + const tunnelCalls: TunnelCall[] = []; const calls: Array<{ readonly method: string; readonly url: string; @@ -170,37 +204,19 @@ describe("ManagedEndpointProvider", () => { url: request.url, body: decodeBody(request), }); - if (request.url.includes("/cfd_tunnel?")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: [] }, { status: 200 }), - ); - } - if (request.url.endsWith("/token")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: "connector-token" }, { status: 200 }), - ); - } if (request.url.includes("/dns_records?")) { return HttpClientResponse.fromWeb( request, Response.json({ success: true, result: [] }, { status: 200 }), ); } - if (request.url.endsWith("/dns_records") || request.url.endsWith("/configurations")) { + if (request.url.endsWith("/dns_records")) { return HttpClientResponse.fromWeb( request, Response.json({ success: true }, { status: 200 }), ); } - return HttpClientResponse.fromWeb( - request, - Response.json( - { success: true, result: { id: "tunnel-id", name: "normalized-name" } }, - { status: 200 }, - ), - ); + throw new Error(`Unexpected DNS request: ${request.method} ${request.url}`); }); return Effect.gen(function* () { @@ -211,53 +227,48 @@ describe("ManagedEndpointProvider", () => { origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, }); - const listUrl = calls[0]?.url ?? ""; - const createBody = calls[1]?.body; - const requestedName = new URL(listUrl).searchParams.get("name"); + const requestedName = ( + tunnelCalls.find((call) => call.operation === "list")?.input as + | { readonly name?: string } + | undefined + )?.name; expect(requestedName).toMatch(/^t3-code-env-with-spaces-symbols-x+-[a-f0-9]{16}$/); expect(requestedName?.length).toBeLessThanOrEqual(89); - const configBody = calls.find((call) => call.url.endsWith("/configurations"))?.body; + const configBody = ( + tunnelCalls.find((call) => call.operation === "putConfiguration")?.input as + | { readonly tunnelConfig?: unknown } + | undefined + )?.tunnelConfig; expect(configBody).toMatchObject({ - config: { - ingress: [ - { - hostname: expect.stringMatching( - /^tunnels-env-with-spaces-symbols-x+-[a-f0-9]{16}\.t3code\.test$/, - ), - }, - { service: "http_status:404" }, - ], - }, + ingress: [ + { + hostname: expect.stringMatching( + /^tunnels-env-with-spaces-symbols-x+-[a-f0-9]{16}\.t3code\.test$/, + ), + }, + { service: "http_status:404" }, + ], }); const hostname = ( configBody as | { - readonly config?: { - readonly ingress?: readonly [{ readonly hostname?: unknown }, unknown]; - }; + readonly ingress?: readonly [{ readonly hostname?: unknown }, unknown]; } | undefined - )?.config?.ingress?.[0]?.hostname; + )?.ingress?.[0]?.hostname; expect( typeof hostname === "string" ? hostname.split(".")[0]?.length : 0, ).toBeLessThanOrEqual(63); - expect(createBody).toMatchObject({ + expect(tunnelCalls.find((call) => call.operation === "create")?.input).toMatchObject({ name: requestedName, - config_src: "cloudflare", + configSrc: "cloudflare", }); - }).pipe( - Effect.provide( - ManagedEndpointProvider.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), - Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), - ), - ), - ); + }).pipe(Effect.provide(providerLayer(execute, makeTunnelClient(tunnelCalls)))); }, ); it.effect("formats IPv6 loopback origins as valid Cloudflare ingress service URLs", () => { + const tunnelCalls: TunnelCall[] = []; const calls: Array<{ readonly method: string; readonly url: string; @@ -270,37 +281,19 @@ describe("ManagedEndpointProvider", () => { url: request.url, body: decodeBody(request), }); - if (request.url.includes("/cfd_tunnel?")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: [] }, { status: 200 }), - ); - } - if (request.url.endsWith("/token")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: "connector-token" }, { status: 200 }), - ); - } if (request.url.includes("/dns_records?")) { return HttpClientResponse.fromWeb( request, Response.json({ success: true, result: [] }, { status: 200 }), ); } - if (request.url.endsWith("/dns_records") || request.url.endsWith("/configurations")) { + if (request.url.endsWith("/dns_records")) { return HttpClientResponse.fromWeb( request, Response.json({ success: true }, { status: 200 }), ); } - return HttpClientResponse.fromWeb( - request, - Response.json( - { success: true, result: { id: "tunnel-id", name: "normalized-name" } }, - { status: 200 }, - ), - ); + throw new Error(`Unexpected DNS request: ${request.method} ${request.url}`); }); return Effect.gen(function* () { @@ -310,8 +303,10 @@ describe("ManagedEndpointProvider", () => { origin: { localHttpHost: "::1", localHttpPort: 3773 }, }); - expect(calls[2]?.body).toMatchObject({ - config: { + expect( + tunnelCalls.find((call) => call.operation === "putConfiguration")?.input, + ).toMatchObject({ + tunnelConfig: { ingress: [ { service: "http://[::1]:3773", @@ -320,15 +315,7 @@ describe("ManagedEndpointProvider", () => { ], }, }); - }).pipe( - Effect.provide( - ManagedEndpointProvider.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), - Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), - ), - ), - ); + }).pipe(Effect.provide(providerLayer(execute, makeTunnelClient(tunnelCalls)))); }); it.effect("rejects non-loopback managed endpoint origins before calling Cloudflare", () => { @@ -356,15 +343,7 @@ describe("ManagedEndpointProvider", () => { if (result._tag === "Failure") { expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); } - }).pipe( - Effect.provide( - ManagedEndpointProvider.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), - Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), - ), - ), - ); + }).pipe(Effect.provide(providerLayer(execute))); }); it.effect("rejects invalid managed endpoint origin ports before calling Cloudflare", () => { @@ -392,33 +371,10 @@ describe("ManagedEndpointProvider", () => { if (result._tag === "Failure") { expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); } - }).pipe( - Effect.provide( - ManagedEndpointProvider.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), - Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), - ), - ), - ); + }).pipe(Effect.provide(providerLayer(execute))); }); it.effect("fails provisioning when Cloudflare returns a 2xx application error", () => { - const execute = (request: HttpClientRequest.HttpClientRequest) => - Effect.sync(() => - HttpClientResponse.fromWeb( - request, - Response.json( - { - success: false, - result: [], - errors: [{ code: 10_000, message: "Cloudflare application failure" }], - }, - { status: 200 }, - ), - ), - ); - return Effect.gen(function* () { const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; const error = yield* Effect.flip( @@ -433,14 +389,20 @@ describe("ManagedEndpointProvider", () => { success: false, errors: [{ message: "Cloudflare application failure" }], }); - }).pipe( - Effect.provide( - ManagedEndpointProvider.layer.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), - Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), - ), - ), - ); + }).pipe(Effect.provide(providerLayer(cloudflareApplicationErrorResponse))); + }); + + it.effect("fails provisioning when Cloudflare returns a non-success HTTP response", () => { + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const error = yield* Effect.flip( + provider.provision({ + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }), + ); + + expect(error._tag).toBe("ManagedEndpointProvisioningFailed"); + }).pipe(Effect.provide(providerLayer(cloudflareNonSuccessHttpResponse))); }); }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index f1ade7a30ae..131a8e4fda5 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -1,19 +1,20 @@ -// @effect-diagnostics nodeBuiltinImport:off - import type { RelayManagedEndpoint, RelayManagedEndpointOrigin, RelayManagedEndpointRuntimeConfig, } from "@t3tools/contracts/relay"; +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 Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as RelayConfiguration from "../Config.ts"; @@ -56,35 +57,54 @@ export class ManagedEndpointProvider extends Context.Service< ManagedEndpointProviderShape >()("t3code-relay/environments/ManagedEndpointProvider") {} -const CloudflareTunnelCreateResponse = Schema.Struct({ - success: Schema.Boolean, - result: Schema.Struct({ - id: Schema.String, - name: Schema.String, - }), -}); +interface ManagedEndpointTunnel { + readonly id?: string | null; + readonly name?: string | null; +} -const CloudflareTunnelListResponse = Schema.Struct({ - success: Schema.Boolean, - result: Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - }), - ), -}); +export class ManagedEndpointTunnelClientError extends Data.TaggedError( + "ManagedEndpointTunnelClientError", +)<{ + readonly cause: unknown; +}> {} -const CloudflareTunnelTokenResponse = Schema.Struct({ - success: Schema.Boolean, - result: Schema.String, -}); +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; +} + +export class ManagedEndpointTunnelClient extends Context.Service< + ManagedEndpointTunnelClient, + ManagedEndpointTunnelClientShape +>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {} const CloudflareDnsRecordResponse = Schema.Struct({ success: Schema.Boolean, + errors: Schema.optionalKey(Schema.Unknown), }); const CloudflareDnsRecordListResponse = Schema.Struct({ success: Schema.Boolean, + errors: Schema.optionalKey(Schema.Unknown), result: Schema.Array( Schema.Struct({ id: Schema.String, @@ -97,45 +117,18 @@ const requireCloudflareSettings = Effect.fnUntraced(function* ( ) { if ( !settings.managedEndpointBaseDomain || - !settings.cloudflareAccountId || !settings.cloudflareZoneId || !settings.cloudflareApiToken ) { return yield* new ManagedEndpointProvisioningNotConfigured(); } return { - accountId: settings.cloudflareAccountId, zoneId: settings.cloudflareZoneId, apiToken: Redacted.value(settings.cloudflareApiToken), baseDomain: settings.managedEndpointBaseDomain, }; }); -function cloudflareRequest(input: { - readonly method: "GET" | "POST" | "PUT"; - readonly url: string; - readonly apiToken: string; - readonly body?: unknown; -}): Effect.Effect { - const base = - input.method === "GET" - ? HttpClientRequest.get(input.url) - : input.method === "POST" - ? HttpClientRequest.post(input.url) - : HttpClientRequest.put(input.url); - - const request = base.pipe( - HttpClientRequest.setHeader("authorization", `Bearer ${input.apiToken}`), - HttpClientRequest.setHeader("content-type", "application/json"), - ); - return input.body === undefined - ? Effect.succeed(request) - : request.pipe( - HttpClientRequest.bodyJson(input.body), - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), - ); -} - const MANAGED_ENDPOINT_HOST_PREFIX = "tunnels"; const DNS_LABEL_MAX_LENGTH = 63; const MANAGED_ENDPOINT_HASH_LENGTH = 16; @@ -191,37 +184,20 @@ const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; const httpClient = yield* HttpClient.HttpClient; const crypto = yield* Crypto.Crypto; - - const requireCloudflareSuccess = ( - json: unknown, - ): Effect.Effect => - typeof json === "object" && - json !== null && - "success" in json && - (json as { readonly success: unknown }).success === false - ? Effect.fail(new ManagedEndpointProvisioningFailed({ cause: json })) - : Effect.void; - - const executeJson = Effect.fnUntraced(function* ( - request: HttpClientRequest.HttpClientRequest, - schema: Schema.Schema, - ) { - const response = yield* httpClient - .execute(request) - .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); - if (response.status < 200 || response.status >= 300) { - return yield* new ManagedEndpointProvisioningFailed({ cause: response.status }); - } - const json = yield* response.json.pipe( - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), - ); - const isSchema = Schema.is(schema); - if (!isSchema(json)) { - return yield* new ManagedEndpointProvisioningFailed({ cause: json }); - } - yield* requireCloudflareSuccess(json); - return json; - }); + const tunnels = yield* ManagedEndpointTunnelClient; + + const executeJson = + (schema: Schema.Codec) => + (request: HttpClientRequest.HttpClientRequest) => + httpClient.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), + Effect.filterMapOrFail( + (body) => (body.success ? Result.succeed(body) : Result.fail(body)), + (cause) => new ManagedEndpointProvisioningFailed({ cause }), + ), + ); return ManagedEndpointProvider.of({ provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { @@ -245,83 +221,70 @@ const make = Effect.gen(function* () { ); const hostname = managedHostname(input.environmentId, cf.baseDomain, environmentHash); const tunnelName = managedTunnelName(input.environmentId, environmentHash); - const existingTunnels = yield* cloudflareRequest({ - method: "GET", - url: `https://api.cloudflare.com/client/v4/accounts/${cf.accountId}/cfd_tunnel?${new URLSearchParams( - [ - ["name", tunnelName], - ["is_deleted", "false"], - ], - ).toString()}`, - apiToken: cf.apiToken, - }).pipe(Effect.flatMap((request) => executeJson(request, CloudflareTunnelListResponse))); - const existingTunnel = existingTunnels.result.find((tunnel) => tunnel.name === tunnelName); - const tunnel = - existingTunnel ?? - (yield* cloudflareRequest({ - method: "POST", - url: `https://api.cloudflare.com/client/v4/accounts/${cf.accountId}/cfd_tunnel`, - apiToken: cf.apiToken, - body: { - name: tunnelName, - config_src: "cloudflare", - }, - }).pipe( - Effect.flatMap((request) => executeJson(request, CloudflareTunnelCreateResponse)), - Effect.map((response) => response.result), - )); - - yield* cloudflareRequest({ - method: "PUT", - url: `https://api.cloudflare.com/client/v4/accounts/${cf.accountId}/cfd_tunnel/${tunnel.id}/configurations`, - apiToken: cf.apiToken, - body: { - config: { - ingress: [ - { - hostname, - service: formatOriginService(input.origin), - }, - { service: "http_status:404" }, - ], - }, - }, - }).pipe( - Effect.flatMap((request) => - executeJson(request, Schema.Struct({ success: Schema.Boolean })), + + 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 })), ); - const dnsRecords = yield* cloudflareRequest({ - method: "GET", - url: `https://api.cloudflare.com/client/v4/zones/${cf.zoneId}/dns_records?${new URLSearchParams( - [ - ["type", "CNAME"], - ["name", hostname], + yield* tunnels + .putConfiguration(tunnel.id, { + ingress: [ + { + hostname, + service: formatOriginService(input.origin), + }, + { service: "http_status:404" }, ], - ).toString()}`, - apiToken: cf.apiToken, - }).pipe(Effect.flatMap((request) => executeJson(request, CloudflareDnsRecordListResponse))); - const existingDnsRecordId = dnsRecords.result[0]?.id; - yield* cloudflareRequest({ - method: existingDnsRecordId ? "PUT" : "POST", - url: existingDnsRecordId - ? `https://api.cloudflare.com/client/v4/zones/${cf.zoneId}/dns_records/${existingDnsRecordId}` - : `https://api.cloudflare.com/client/v4/zones/${cf.zoneId}/dns_records`, - apiToken: cf.apiToken, - body: { + }) + .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + + const existingDnsRecordId = yield* HttpClientRequest.make("GET")( + "/zones/${cf.zoneId}/dns_records", + ).pipe( + HttpClientRequest.prependUrl("https://api.cloudflare.com/client/v4"), + HttpClientRequest.bearerToken(cf.apiToken), + HttpClientRequest.setUrlParams({ + type: "CNAME", + name: hostname, + }), + executeJson(CloudflareDnsRecordListResponse), + Effect.map((body) => Arr.head(body.result)), + Effect.map(Option.map((record) => record.id)), + ); + + const upsertDnsRequest = Option.match(existingDnsRecordId, { + onSome: (id) => HttpClientRequest.make("PUT")(`/zones/${cf.zoneId}/dns_records/${id}`), + onNone: () => HttpClientRequest.make("POST")(`/zones/${cf.zoneId}/dns_records`), + }); + + yield* upsertDnsRequest.pipe( + HttpClientRequest.prependUrl("https://api.cloudflare.com/client/v4"), + HttpClientRequest.bearerToken(cf.apiToken), + HttpClientRequest.bodyJsonUnsafe({ type: "CNAME", name: hostname, content: `${tunnel.id}.cfargotunnel.com`, proxied: true, - }, - }).pipe(Effect.flatMap((request) => executeJson(request, CloudflareDnsRecordResponse))); + }), + executeJson(CloudflareDnsRecordResponse), + ); - const token = yield* cloudflareRequest({ - method: "GET", - url: `https://api.cloudflare.com/client/v4/accounts/${cf.accountId}/cfd_tunnel/${tunnel.id}/token`, - apiToken: cf.apiToken, - }).pipe(Effect.flatMap((request) => executeJson(request, CloudflareTunnelTokenResponse))); + const connectorToken = yield* tunnels + .getToken(tunnel.id) + .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); return { endpoint: { @@ -331,7 +294,7 @@ const make = Effect.gen(function* () { }, runtime: { providerKind: "cloudflare_tunnel", - connectorToken: token.result, + connectorToken, tunnelId: tunnel.id, tunnelName: tunnel.name, }, diff --git a/infra/relay/src/managedEndpointStack.ts b/infra/relay/src/managedEndpointStack.ts index d1054a80b70..d30793831a0 100644 --- a/infra/relay/src/managedEndpointStack.ts +++ b/infra/relay/src/managedEndpointStack.ts @@ -1,34 +1,32 @@ +import { adopt } from "alchemy/AdoptPolicy"; import * as Cloudflare from "alchemy/Cloudflare"; +import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; -// This should be pulled from the Alchemy authenticated cloudflare account -export const CLOUDFLARE_ACCOUNT_ID = "1468bbd99811cdaccfbb707dc725421a"; +export const RELAY_PUBLIC_DOMAIN = "t3code-relay.ineededadomain.com"; +export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`; -// We should only need to specify one of these after Alchemy have a CloudflareZone resource: https://github.com/alchemy-run/alchemy-effect/pull/493 -export const MANAGED_ENDPOINT_ZONE_ID = "fcea40a6915723b0f5c4a9480eb3507b"; -export const MANAGED_ENDPOINT_ZONE_NAME = "ineededadomain.com"; +export const ManagedEndpointZone = Cloudflare.Zone("ManagedEndpointZone", { + name: "ineededadomain.com", +}).pipe(adopt(true)); -export const RELAY_PUBLIC_DOMAIN = `t3code-relay.${MANAGED_ENDPOINT_ZONE_NAME}`; -export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`; +export const ManagedEndpointDNSToken = Effect.gen(function* () { + const zoneId = yield* ManagedEndpointZone.pipe(Effect.map((zone) => zone.zoneId)); -export const ManagedEndpointProvisionerToken = Cloudflare.AccountApiToken( - "ManagedEndpointProvisionerToken", - { - name: "t3-code-relay-managed-endpoint-provisioner", + const dnsToken = yield* Cloudflare.AccountApiToken("ManagedEndpointDNSToken", { + name: "t3-code-relay-managed-endpoint-dns-token", policies: [ { - effect: "allow" as const, - permissionGroups: ["Cloudflare Tunnel Read" as const, "Cloudflare Tunnel Write" as const], - resources: { - [`com.cloudflare.api.account.${CLOUDFLARE_ACCOUNT_ID}`]: "*", - }, - }, - { - effect: "allow" as const, - permissionGroups: ["DNS Read" as const, "DNS Write" as const], - resources: { - [`com.cloudflare.api.account.zone.${MANAGED_ENDPOINT_ZONE_ID}`]: "*", - }, + effect: "allow", + permissionGroups: ["DNS Read", "DNS Write"], + resources: zoneId.pipe( + Output.map((id) => ({ + [`com.cloudflare.api.account.zone.${id}`]: "*", + })), + ), }, ], - }, -); + }); + + return dnsToken; +}); diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 4bb50e2bd90..e913cc48f92 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -30,10 +30,8 @@ import { withoutCapturedParentSpan, } from "./http/Api.ts"; import { - CLOUDFLARE_ACCOUNT_ID, - MANAGED_ENDPOINT_ZONE_ID, - MANAGED_ENDPOINT_ZONE_NAME, - ManagedEndpointProvisionerToken, + ManagedEndpointDNSToken, + ManagedEndpointZone, RELAY_PUBLIC_DOMAIN, RELAY_PUBLIC_ORIGIN, } from "./managedEndpointStack.ts"; @@ -112,8 +110,12 @@ export default class Api extends Cloudflare.Worker()( const alchemyRuntimeContext = yield* Alchemy.RuntimeContext; const cloudMintKeyPair = yield* CloudMintKeyPair; const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayHyperdrive); - const managedEndpointProvisionerToken = yield* ManagedEndpointProvisionerToken; + const managedEndpointZone = yield* ManagedEndpointZone; + const managedEndpointZoneId = yield* managedEndpointZone.zoneId; + const managedEndpointZoneName = yield* managedEndpointZone.name; + const managedEndpointProvisionerToken = yield* ManagedEndpointDNSToken; const managedEndpointCloudflareApiToken = yield* managedEndpointProvisionerToken.value; + const managedEndpointTunnelBinding = yield* Cloudflare.TunnelReadWrite.bind(); const randomApnsDeliveryJobSigningSecret = yield* ApnsDeliveryJobSigningSecret; const observability = yield* RelayObservability; @@ -146,6 +148,36 @@ export default class Api extends Cloudflare.Worker()( Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), }); + const managedEndpointTunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ + list: (request) => + managedEndpointTunnelBinding.list(request).pipe( + Effect.mapError( + (cause) => new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause }), + ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + create: (request) => + managedEndpointTunnelBinding.create(request).pipe( + Effect.mapError( + (cause) => new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause }), + ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + putConfiguration: (tunnelId, config) => + managedEndpointTunnelBinding.putConfiguration(tunnelId, config).pipe( + Effect.mapError( + (cause) => new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause }), + ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + getToken: (tunnelId) => + managedEndpointTunnelBinding.getToken(tunnelId).pipe( + Effect.mapError( + (cause) => new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause }), + ), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }); // // 3. Runtime layers and app construction @@ -165,9 +197,8 @@ export default class Api extends Cloudflare.Worker()( clerkSecretKey, cloudMintPrivateKey: yield* cloudMintPrivateKey, cloudMintPublicKey: yield* cloudMintPublicKey, - managedEndpointBaseDomain: MANAGED_ENDPOINT_ZONE_NAME, - cloudflareAccountId: CLOUDFLARE_ACCOUNT_ID, - cloudflareZoneId: MANAGED_ENDPOINT_ZONE_ID, + managedEndpointBaseDomain: yield* managedEndpointZoneName, + cloudflareZoneId: yield* managedEndpointZoneId, cloudflareApiToken: yield* managedEndpointCloudflareApiToken, }); }); @@ -205,6 +236,12 @@ export default class Api extends Cloudflare.Worker()( Layer.provide(RelayTokens.layer), Layer.provide(Layer.succeed(RelayDb, db)), Layer.provide(Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, queueSender)), + Layer.provide( + Layer.succeed( + ManagedEndpointProvider.ManagedEndpointTunnelClient, + managedEndpointTunnelClient, + ), + ), Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), Layer.provide(webcryptoLayer), ); @@ -273,11 +310,12 @@ export default class Api extends Cloudflare.Worker()( return { fetch }; }).pipe( Effect.provide( - Layer.mergeAll( - Cloudflare.HyperdriveBindingLive, - Cloudflare.CronEventSourceLive, - Cloudflare.QueueBindingLive, - Cloudflare.QueueEventSourceLive, + Layer.empty.pipe( + Layer.provideMerge(Cloudflare.HyperdriveBindingLive), + Layer.provideMerge(Cloudflare.CronEventSourceLive), + Layer.provideMerge(Cloudflare.QueueBindingLive), + Layer.provideMerge(Cloudflare.QueueEventSourceLive), + Layer.provideMerge(Cloudflare.TunnelReadWriteLive), ), ), ), From e55a779e5f78f3b08418f537909eae587a757e07 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 11:05:19 -0700 Subject: [PATCH 04/61] Refactor relay bindings for Alchemy Cloudflare integration --- .../src/agentActivity/ApnsDeliveryQueue.ts | 24 ++++++- .../environments/ManagedEndpointProvider.ts | 47 +++++++++++-- infra/relay/src/worker.ts | 67 +++++-------------- 3 files changed, 80 insertions(+), 58 deletions(-) diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index 57b6bc159d1..abec4d5a4c6 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -1,4 +1,5 @@ -import type { RelayDeliveryResult } from "@t3tools/contracts/relay"; +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"; @@ -6,6 +7,8 @@ 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, @@ -139,3 +142,22 @@ const make = Effect.gen(function* () { }); 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/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 131a8e4fda5..0f4f7849728 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -1,8 +1,5 @@ -import type { - RelayManagedEndpoint, - RelayManagedEndpointOrigin, - RelayManagedEndpointRuntimeConfig, -} from "@t3tools/contracts/relay"; +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"; @@ -16,6 +13,12 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import type { + RelayManagedEndpoint, + RelayManagedEndpointOrigin, + RelayManagedEndpointRuntimeConfig, +} from "@t3tools/contracts/relay"; + import * as RelayConfiguration from "../Config.ts"; export class ManagedEndpointProvisioningNotConfigured extends Data.TaggedError( @@ -304,3 +307,37 @@ const make = Effect.gen(function* () { }); export const layer = Layer.effect(ManagedEndpointProvider, make); + +export const layerCloudflareTunnels = ( + tunnelClient: Cloudflare.TunnelReadWriteClient, + alchemyRuntimeContext: Alchemy.BaseRuntimeContext, +) => + layer.pipe( + Layer.provide( + 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), + ), + }), + ), + ), + ); diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index e913cc48f92..76186723114 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -107,7 +107,7 @@ export default class Api extends Cloudflare.Worker()( const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue); - const alchemyRuntimeContext = yield* Alchemy.RuntimeContext; + const cloudMintKeyPair = yield* CloudMintKeyPair; const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayHyperdrive); const managedEndpointZone = yield* ManagedEndpointZone; @@ -141,47 +141,11 @@ export default class Api extends Cloudflare.Worker()( const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; const db = yield* Drizzle.postgres(hyperdrive.connectionString); - const queueSender = ApnsDeliveryQueue.ApnsDeliveryQueueSender.of({ - send: (body) => - apnsDeliveryQueueSender.send(body).pipe( - Effect.mapError((cause) => new ApnsDeliveryQueue.ApnsDeliveryQueueSendError({ cause })), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), - ), - }); - const managedEndpointTunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ - list: (request) => - managedEndpointTunnelBinding.list(request).pipe( - Effect.mapError( - (cause) => new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), - ), - create: (request) => - managedEndpointTunnelBinding.create(request).pipe( - Effect.mapError( - (cause) => new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), - ), - putConfiguration: (tunnelId, config) => - managedEndpointTunnelBinding.putConfiguration(tunnelId, config).pipe( - Effect.mapError( - (cause) => new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), - ), - getToken: (tunnelId) => - managedEndpointTunnelBinding.getToken(tunnelId).pipe( - Effect.mapError( - (cause) => new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause }), - ), - Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), - ), - }); // // 3. Runtime layers and app construction // + const alchemyRuntimeContext = yield* Alchemy.RuntimeContext; const loadSettings = Effect.gen(function* () { return RelayConfiguration.RelayConfiguration.of({ @@ -219,14 +183,21 @@ export default class Api extends Cloudflare.Worker()( MobileRegistrations.layer.pipe(Layer.provideMerge(AgentActivityPublisher.layer)), EnvironmentConnector.layer, EnvironmentLinker.layer.pipe( - Layer.provideMerge(ManagedEndpointProvider.layer), + Layer.provideMerge( + ManagedEndpointProvider.layerCloudflareTunnels( + managedEndpointTunnelBinding, + alchemyRuntimeContext, + ), + ), Layer.provideMerge(DpopProofs.layer), ), EnvironmentPublishSignatures.layer.pipe(Layer.provideMerge(DpopProofs.layer)), DpopProofs.layer, ).pipe( Layer.provide(ApnsDeliveries.layer.pipe(Layer.provide(ApnsClient.layer))), - Layer.provide(ApnsDeliveryQueue.layer), + Layer.provide( + ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext), + ), Layer.provide(AgentActivityRows.layer), Layer.provide(Devices.layer), Layer.provide(EnvironmentCredentials.layer), @@ -235,13 +206,6 @@ export default class Api extends Cloudflare.Worker()( Layer.provide(DeliveryAttempts.layer), Layer.provide(RelayTokens.layer), Layer.provide(Layer.succeed(RelayDb, db)), - Layer.provide(Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, queueSender)), - Layer.provide( - Layer.succeed( - ManagedEndpointProvider.ManagedEndpointTunnelClient, - managedEndpointTunnelClient, - ), - ), Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), Layer.provide(webcryptoLayer), ); @@ -276,11 +240,10 @@ export default class Api extends Cloudflare.Worker()( }).subscribe((stream) => stream.pipe( Stream.withSpan("relay.apn_delivery_queue.process_batch"), - Stream.runForEach( - Effect.fn("relay.apn_delivery_queue.process_message")((message) => - ApnsDeliveries.ApnsDeliveries.pipe( - Effect.flatMap((deliveries) => deliveries.processSignedJob(message.body)), - ), + Stream.runForEach((message) => + ApnsDeliveries.ApnsDeliveries.pipe( + Effect.flatMap((deliveries) => deliveries.processSignedJob(message.body)), + Effect.withSpan("relay.apn_delivery_queue.process_message"), ), ), Effect.provide(runtimeLayer), From 8aae1d0bef53912ed00b61c681c817f722093ff5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 11:17:22 -0700 Subject: [PATCH 05/61] Flatten relay worker layer graph and managed endpoint bindings --- infra/relay/src/managedEndpointStack.ts | 14 ++-- infra/relay/src/worker.ts | 96 ++++++++++--------------- 2 files changed, 44 insertions(+), 66 deletions(-) diff --git a/infra/relay/src/managedEndpointStack.ts b/infra/relay/src/managedEndpointStack.ts index d30793831a0..b82c10a8098 100644 --- a/infra/relay/src/managedEndpointStack.ts +++ b/infra/relay/src/managedEndpointStack.ts @@ -6,12 +6,10 @@ import * as Effect from "effect/Effect"; export const RELAY_PUBLIC_DOMAIN = "t3code-relay.ineededadomain.com"; export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`; -export const ManagedEndpointZone = Cloudflare.Zone("ManagedEndpointZone", { - name: "ineededadomain.com", -}).pipe(adopt(true)); - -export const ManagedEndpointDNSToken = Effect.gen(function* () { - const zoneId = yield* ManagedEndpointZone.pipe(Effect.map((zone) => zone.zoneId)); +export const ManagedEndpointZone = Effect.gen(function* () { + const zone = yield* Cloudflare.Zone("ManagedEndpointZone", { + name: "ineededadomain.com", + }).pipe(adopt(true)); const dnsToken = yield* Cloudflare.AccountApiToken("ManagedEndpointDNSToken", { name: "t3-code-relay-managed-endpoint-dns-token", @@ -19,7 +17,7 @@ export const ManagedEndpointDNSToken = Effect.gen(function* () { { effect: "allow", permissionGroups: ["DNS Read", "DNS Write"], - resources: zoneId.pipe( + resources: zone.zoneId.pipe( Output.map((id) => ({ [`com.cloudflare.api.account.zone.${id}`]: "*", })), @@ -28,5 +26,5 @@ export const ManagedEndpointDNSToken = Effect.gen(function* () { ], }); - return dnsToken; + return { zoneId: zone.zoneId, zoneName: zone.name, dnsToken: dnsToken.value }; }); diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 76186723114..068d315c4a1 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -30,7 +30,6 @@ import { withoutCapturedParentSpan, } from "./http/Api.ts"; import { - ManagedEndpointDNSToken, ManagedEndpointZone, RELAY_PUBLIC_DOMAIN, RELAY_PUBLIC_ORIGIN, @@ -111,10 +110,6 @@ export default class Api extends Cloudflare.Worker()( const cloudMintKeyPair = yield* CloudMintKeyPair; const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayHyperdrive); const managedEndpointZone = yield* ManagedEndpointZone; - const managedEndpointZoneId = yield* managedEndpointZone.zoneId; - const managedEndpointZoneName = yield* managedEndpointZone.name; - const managedEndpointProvisionerToken = yield* ManagedEndpointDNSToken; - const managedEndpointCloudflareApiToken = yield* managedEndpointProvisionerToken.value; const managedEndpointTunnelBinding = yield* Cloudflare.TunnelReadWrite.bind(); const randomApnsDeliveryJobSigningSecret = yield* ApnsDeliveryJobSigningSecret; const observability = yield* RelayObservability; @@ -142,6 +137,9 @@ export default class Api extends Cloudflare.Worker()( const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; const db = yield* Drizzle.postgres(hyperdrive.connectionString); + const managedEndpointZoneId = yield* managedEndpointZone.zoneId; + const managedEndpointDNSToken = yield* managedEndpointZone.dnsToken; + // // 3. Runtime layers and app construction // @@ -161,9 +159,9 @@ export default class Api extends Cloudflare.Worker()( clerkSecretKey, cloudMintPrivateKey: yield* cloudMintPrivateKey, cloudMintPublicKey: yield* cloudMintPublicKey, - managedEndpointBaseDomain: yield* managedEndpointZoneName, + managedEndpointBaseDomain: yield* managedEndpointZoneId, cloudflareZoneId: yield* managedEndpointZoneId, - cloudflareApiToken: yield* managedEndpointCloudflareApiToken, + cloudflareApiToken: yield* managedEndpointDNSToken, }); }); @@ -175,59 +173,41 @@ export default class Api extends Cloudflare.Worker()( }).pipe(Effect.map(makeRelayTraceLayer)), ); - const runtimeLayer = Layer.unwrap( - Effect.gen(function* () { - const settings = yield* loadSettings; - - return Layer.mergeAll( - MobileRegistrations.layer.pipe(Layer.provideMerge(AgentActivityPublisher.layer)), - EnvironmentConnector.layer, - EnvironmentLinker.layer.pipe( - Layer.provideMerge( - ManagedEndpointProvider.layerCloudflareTunnels( - managedEndpointTunnelBinding, - alchemyRuntimeContext, - ), - ), - Layer.provideMerge(DpopProofs.layer), - ), - EnvironmentPublishSignatures.layer.pipe(Layer.provideMerge(DpopProofs.layer)), - DpopProofs.layer, - ).pipe( - Layer.provide(ApnsDeliveries.layer.pipe(Layer.provide(ApnsClient.layer))), - Layer.provide( - ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext), - ), - Layer.provide(AgentActivityRows.layer), - Layer.provide(Devices.layer), - Layer.provide(EnvironmentCredentials.layer), - Layer.provide(EnvironmentLinks.layer), - Layer.provide(LiveActivities.layer), - Layer.provide(DeliveryAttempts.layer), - Layer.provide(RelayTokens.layer), - Layer.provide(Layer.succeed(RelayDb, db)), - Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), - Layer.provide(webcryptoLayer), - ); - }), + 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.layerCloudflareTunnels( + managedEndpointTunnelBinding, + 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(EnvironmentLinks.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 = Layer.unwrap( - Effect.gen(function* () { - const settings = yield* loadSettings; - return relayApiLayer.pipe( - Layer.provide(runtimeLayer), - Layer.provide(relayClientAuthLayer), - Layer.provide(relayDpopClientAuthLayer), - Layer.provide(relayEnvironmentAuthLayer), - Layer.provide(EnvironmentCredentials.layer), - Layer.provide(EnvironmentLinks.layer), - Layer.provide(RelayTokens.layer), - Layer.provide(Layer.succeed(RelayDb, db)), - Layer.provideMerge(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), - Layer.provide(webcryptoLayer), - ); - }), + const appLayer = relayApiLayer.pipe( + Layer.provideMerge(relayClientAuthLayer), + Layer.provideMerge(relayDpopClientAuthLayer), + Layer.provideMerge(relayEnvironmentAuthLayer), + Layer.provide(runtimeLayer), ); yield* Cloudflare.messages(apnsDeliveryQueue, { From 622b9018631804a62a1f70c9350d4e814a5bec69 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 11:17:47 -0700 Subject: [PATCH 06/61] Simplify Cloudflare zone resource mapping --- infra/relay/src/managedEndpointStack.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/infra/relay/src/managedEndpointStack.ts b/infra/relay/src/managedEndpointStack.ts index b82c10a8098..6cc7b56e508 100644 --- a/infra/relay/src/managedEndpointStack.ts +++ b/infra/relay/src/managedEndpointStack.ts @@ -17,11 +17,9 @@ export const ManagedEndpointZone = Effect.gen(function* () { { effect: "allow", permissionGroups: ["DNS Read", "DNS Write"], - resources: zone.zoneId.pipe( - Output.map((id) => ({ - [`com.cloudflare.api.account.zone.${id}`]: "*", - })), - ), + resources: Output.map(zone.zoneId, (id) => ({ + [`com.cloudflare.api.account.zone.${id}`]: "*", + })), }, ], }); From 5638662ea2df84b5d3463c5c5d4f796d7912eac4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 11:20:37 -0700 Subject: [PATCH 07/61] fmt --- infra/relay/src/worker.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 068d315c4a1..3af4810cc0e 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -105,12 +105,8 @@ export default class Api extends Cloudflare.Worker()( // const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; - const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue); - const cloudMintKeyPair = yield* CloudMintKeyPair; - const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayHyperdrive); const managedEndpointZone = yield* ManagedEndpointZone; - const managedEndpointTunnelBinding = yield* Cloudflare.TunnelReadWrite.bind(); const randomApnsDeliveryJobSigningSecret = yield* ApnsDeliveryJobSigningSecret; const observability = yield* RelayObservability; @@ -126,6 +122,7 @@ export default class Api extends Cloudflare.Worker()( 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; @@ -135,8 +132,10 @@ export default class Api extends Cloudflare.Worker()( 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 managedEndpointZoneId = yield* managedEndpointZone.zoneId; const managedEndpointDNSToken = yield* managedEndpointZone.dnsToken; From baa9f7cd8f01a20d3d2bf7d65cc15c9764d96d91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 11:48:04 -0700 Subject: [PATCH 08/61] renames --- infra/relay/src/agentActivity/AgentActivityPublisher.ts | 2 +- infra/relay/src/agentActivity/ApnsClient.ts | 2 +- infra/relay/src/agentActivity/ApnsDeliveries.test.ts | 2 +- infra/relay/src/agentActivity/ApnsDeliveries.ts | 4 ++-- infra/relay/src/agentActivity/ApnsDeliveryQueue.ts | 4 ++-- infra/relay/src/agentActivity/MobileRegistrations.test.ts | 2 +- .../{AgentActivityPayloads.ts => agentActivityPayloads.ts} | 2 +- .../{ApnsDeliveryJobs.test.ts => apnsDeliveryJobs.test.ts} | 2 +- .../{ApnsDeliveryJobs.ts => apnsDeliveryJobs.ts} | 0 9 files changed, 10 insertions(+), 10 deletions(-) rename infra/relay/src/agentActivity/{AgentActivityPayloads.ts => agentActivityPayloads.ts} (96%) rename infra/relay/src/agentActivity/{ApnsDeliveryJobs.test.ts => apnsDeliveryJobs.test.ts} (99%) rename infra/relay/src/agentActivity/{ApnsDeliveryJobs.ts => apnsDeliveryJobs.ts} (100%) diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts index 9e6370b787c..3881bc6c1a7 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts @@ -9,7 +9,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { sanitizeAgentActivityAggregateState } from "./AgentActivityPayloads.ts"; +import { sanitizeAgentActivityAggregateState } from "./agentActivityPayloads.ts"; import * as AgentActivityRows from "./AgentActivityRows.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as LiveActivities from "./LiveActivities.ts"; diff --git a/infra/relay/src/agentActivity/ApnsClient.ts b/infra/relay/src/agentActivity/ApnsClient.ts index 0830a036cb4..92ec060958d 100644 --- a/infra/relay/src/agentActivity/ApnsClient.ts +++ b/infra/relay/src/agentActivity/ApnsClient.ts @@ -17,7 +17,7 @@ import { type HttpClientError, } from "effect/unstable/http"; import type { ApnsCredentials } from "../Config.ts"; -import type { ApnsNotificationPayload } from "./ApnsDeliveryJobs.ts"; +import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity"; const STALE_AFTER_SECONDS = 2 * 60; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index d1c820cb3d6..3dcdf94e143 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -19,7 +19,7 @@ import { makeApnsDeliveryJobPayload, signApnsDeliveryJob, type SignedApnsDeliveryJob, -} from "./ApnsDeliveryJobs.ts"; +} from "./apnsDeliveryJobs.ts"; import * as DeliveryAttempts from "./DeliveryAttempts.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as RelayConfiguration from "../Config.ts"; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 6975ea09da2..d6f61b37599 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -19,7 +19,7 @@ import * as Schema from "effect/Schema"; import { sanitizeAgentActivityAggregateState, sanitizeApnsNotificationPayload, -} from "./AgentActivityPayloads.ts"; +} from "./agentActivityPayloads.ts"; import * as Apns from "./ApnsClient.ts"; import { ApnsDeliveryJobInvalid, @@ -27,7 +27,7 @@ import { SignedApnsDeliveryJob, verifySignedApnsDeliveryJob, type ApnsDeliveryJobVerificationError, -} from "./ApnsDeliveryJobs.ts"; +} from "./apnsDeliveryJobs.ts"; import * as DeliveryAttempts from "./DeliveryAttempts.ts"; import * as LiveActivities from "./LiveActivities.ts"; import * as RelayConfiguration from "../Config.ts"; diff --git a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts index abec4d5a4c6..219c0595293 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveryQueue.ts @@ -12,14 +12,14 @@ import type { RelayDeliveryResult } from "@t3tools/contracts/relay"; import { sanitizeAgentActivityAggregateState, sanitizeApnsNotificationPayload, -} from "./AgentActivityPayloads.ts"; +} from "./agentActivityPayloads.ts"; import { expiresAtForJob, makeApnsDeliveryJobPayload, signApnsDeliveryJob, type ApnsDeliveryJobPayload, type SignedApnsDeliveryJob, -} from "./ApnsDeliveryJobs.ts"; +} from "./apnsDeliveryJobs.ts"; import * as RelayConfiguration from "../Config.ts"; export class ApnsDeliveryQueueSendError extends Data.TaggedError("ApnsDeliveryQueueSendError")<{ diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index cadb049fd44..51ca6e2bf95 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -2,7 +2,7 @@ import type { RelayAgentActivityState, RelayDeviceRegistrationRequest, } from "@t3tools/contracts/relay"; -import type { SignedApnsDeliveryJob } from "./ApnsDeliveryJobs.ts"; +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"; diff --git a/infra/relay/src/agentActivity/AgentActivityPayloads.ts b/infra/relay/src/agentActivity/agentActivityPayloads.ts similarity index 96% rename from infra/relay/src/agentActivity/AgentActivityPayloads.ts rename to infra/relay/src/agentActivity/agentActivityPayloads.ts index d1e23da8912..ed3fc3f0116 100644 --- a/infra/relay/src/agentActivity/AgentActivityPayloads.ts +++ b/infra/relay/src/agentActivity/agentActivityPayloads.ts @@ -2,7 +2,7 @@ import type { RelayAgentActivityAggregateRow, RelayAgentActivityAggregateState, } from "@t3tools/contracts/relay"; -import type { ApnsNotificationPayload } from "./ApnsDeliveryJobs.ts"; +import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; const MAX_SUMMARY_TEXT_LENGTH = 120; const MAX_STATUS_TEXT_LENGTH = 40; diff --git a/infra/relay/src/agentActivity/ApnsDeliveryJobs.test.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts similarity index 99% rename from infra/relay/src/agentActivity/ApnsDeliveryJobs.test.ts rename to infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts index 44747264dd4..428dc3a82b6 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveryJobs.test.ts +++ b/infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts @@ -7,7 +7,7 @@ import { makeApnsDeliveryJobPayload, signApnsDeliveryJob, verifySignedApnsDeliveryJob, -} from "./ApnsDeliveryJobs.ts"; +} from "./apnsDeliveryJobs.ts"; const secret = Redacted.make("queue-signing-secret"); const aggregate: RelayAgentActivityAggregateState = { diff --git a/infra/relay/src/agentActivity/ApnsDeliveryJobs.ts b/infra/relay/src/agentActivity/apnsDeliveryJobs.ts similarity index 100% rename from infra/relay/src/agentActivity/ApnsDeliveryJobs.ts rename to infra/relay/src/agentActivity/apnsDeliveryJobs.ts From d03af328176e54b3fa76a7a717c25ea189b88e63 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 13:01:04 -0700 Subject: [PATCH 09/61] chore: update alchemy package to version 2.0.0-beta.49 refactor: remove cloudflare settings from RelayConfigurationShape and related tests test: clean up tests by removing cloudflare settings from configuration feat: implement ManagedEndpointDnsClient for DNS operations with Cloudflare refactor: simplify ManagedEndpointProvider by removing HTTP client dependencies fix: update ManagedEndpointProvider to use new DNS client for CNAME record management chore: adjust Api to use new DNS binding and remove deprecated cloudflare settings refactor: streamline ManagedEndpointZone creation by removing unnecessary token generation --- infra/relay/package.json | 2 +- infra/relay/src/Config.ts | 2 - .../src/agentActivity/ApnsDeliveries.test.ts | 2 - .../agentActivity/MobileRegistrations.test.ts | 2 - infra/relay/src/auth/RelayTokens.test.ts | 2 - .../environments/EnvironmentConnector.test.ts | 2 - .../environments/EnvironmentLinker.test.ts | 2 - .../EnvironmentPublishSignatures.test.ts | 2 - .../ManagedEndpointProvider.test.ts | 237 ++++++------------ .../environments/ManagedEndpointProvider.ts | 183 +++++++------- infra/relay/src/http/Api.ts | 76 +++++- infra/relay/src/managedEndpointStack.ts | 25 +- infra/relay/src/worker.ts | 12 +- 13 files changed, 246 insertions(+), 303 deletions(-) diff --git a/infra/relay/package.json b/infra/relay/package.json index d73269a63dc..996b5980e50 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -14,7 +14,7 @@ "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", - "alchemy": "2.0.0-beta.48", + "alchemy": "2.0.0-beta.49", "drizzle-orm": "1.0.0-rc.3", "effect": "catalog:" }, diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index 78fbe0e91d9..685a2f0bd3d 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -21,8 +21,6 @@ export interface RelayConfigurationShape { readonly cloudMintPrivateKey: Redacted.Redacted; readonly cloudMintPublicKey: string; readonly managedEndpointBaseDomain: string | undefined; - readonly cloudflareZoneId: string | undefined; - readonly cloudflareApiToken: Redacted.Redacted | undefined; } export class RelayConfiguration extends Context.Service< diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 3dcdf94e143..54ccee62e6d 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -41,8 +41,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make("cloud-private-key"), cloudMintPublicKey: "cloud-public-key", managedEndpointBaseDomain: undefined, - cloudflareZoneId: undefined, - cloudflareApiToken: undefined, }); const apnsSigningKeyPair = NodeCrypto.generateKeyPairSync("ec", { diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 51ca6e2bf95..faf1dca875a 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -130,8 +130,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make("cloud-private-key"), cloudMintPublicKey: "cloud-public-key", managedEndpointBaseDomain: undefined, - cloudflareZoneId: undefined, - cloudflareApiToken: undefined, }); function makeRegistrationReplayLayer(input: { diff --git a/infra/relay/src/auth/RelayTokens.test.ts b/infra/relay/src/auth/RelayTokens.test.ts index 07309b021b4..4c48dc157c8 100644 --- a/infra/relay/src/auth/RelayTokens.test.ts +++ b/infra/relay/src/auth/RelayTokens.test.ts @@ -28,8 +28,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make(keyPair.privateKey), cloudMintPublicKey: keyPair.publicKey, managedEndpointBaseDomain: undefined, - cloudflareZoneId: undefined, - cloudflareApiToken: undefined, }); const layer = RelayTokens.layer.pipe( diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 391c2711e67..3232949db0d 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -68,8 +68,6 @@ const settings = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make(cloudKeyPair.privateKey), cloudMintPublicKey: cloudKeyPair.publicKey, managedEndpointBaseDomain: undefined, - cloudflareZoneId: undefined, - cloudflareApiToken: undefined, }); function signTestJwt(payload: object, typ: string, privateKey: string): string { diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index b27e9cba4e6..7edf9a91079 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -41,8 +41,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make(relayKeyPair.privateKey), cloudMintPublicKey: relayKeyPair.publicKey, managedEndpointBaseDomain: undefined, - cloudflareZoneId: undefined, - cloudflareApiToken: undefined, }); function signTestJwt(payload: object, typ: string, privateKey: string): string { diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index 055da770f3c..fc056eff8f1 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -36,8 +36,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make(keyPair.privateKey), cloudMintPublicKey: keyPair.publicKey, managedEndpointBaseDomain: undefined, - cloudflareZoneId: undefined, - cloudflareApiToken: undefined, }); const state: RelayAgentActivityState = { environmentId: "env" as RelayAgentActivityState["environmentId"], diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index aea7c99b06a..0c2cb8869ad 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -5,7 +5,6 @@ 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 { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as RelayConfiguration from "../Config.ts"; import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; @@ -24,8 +23,6 @@ const config = RelayConfiguration.RelayConfiguration.of({ cloudMintPrivateKey: Redacted.make("cloud-private-key"), cloudMintPublicKey: "cloud-public-key", managedEndpointBaseDomain: "t3code.test", - cloudflareZoneId: "zone-id", - cloudflareApiToken: Redacted.make("api-token"), }); interface TunnelCall { @@ -33,6 +30,11 @@ interface TunnelCall { readonly input: unknown; } +interface DnsCall { + readonly operation: "listCnameRecords" | "createCnameRecord" | "updateCnameRecord"; + readonly input: unknown; +} + function makeTunnelClient(calls: TunnelCall[] = []) { return ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ list: (request) => @@ -57,26 +59,36 @@ function makeTunnelClient(calls: TunnelCall[] = []) { }); } -function providerLayer( - execute: ( - request: HttpClientRequest.HttpClientRequest, - ) => Effect.Effect, - tunnelClient = makeTunnelClient(), +function makeDnsClient( + calls: DnsCall[] = [], + records: ReadonlyArray<{ readonly id: string }> = [], ) { + return ManagedEndpointProvider.ManagedEndpointDnsClient.of({ + listCnameRecords: (hostname) => + Effect.sync(() => { + calls.push({ operation: "listCnameRecords", input: hostname }); + return records; + }), + createCnameRecord: (request) => + Effect.sync(() => { + calls.push({ operation: "createCnameRecord", input: request }); + }), + updateCnameRecord: (dnsRecordId, request) => + Effect.sync(() => { + calls.push({ operation: "updateCnameRecord", input: { dnsRecordId, request } }); + }), + }); +} + +function providerLayer(tunnelClient = makeTunnelClient(), dnsClient = makeDnsClient()) { return ManagedEndpointProvider.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), - Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointTunnelClient, tunnelClient)), + Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointDnsClient, dnsClient)), ); } -function decodeBody(request: HttpClientRequest.HttpClientRequest): unknown { - return request.body._tag === "Uint8Array" - ? JSON.parse(new TextDecoder().decode(request.body.body)) - : null; -} - function expectedManagedHostname(environmentId: string): string { const hash = NodeCrypto.createHash("sha256").update(environmentId).digest("hex").slice(0, 16); return `tunnels-env-abc-${hash}.t3code.test`; @@ -87,59 +99,10 @@ function expectedManagedTunnelName(environmentId: string): string { return `t3-code-env-abc-${hash}`; } -function cloudflareApplicationErrorResponse(request: HttpClientRequest.HttpClientRequest) { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json( - { - success: false, - result: [], - errors: [{ code: 10_000, message: "Cloudflare application failure" }], - }, - { status: 200 }, - ), - ), - ); -} - -function cloudflareNonSuccessHttpResponse(request: HttpClientRequest.HttpClientRequest) { - return Effect.succeed( - HttpClientResponse.fromWeb(request, Response.json({ success: false }, { status: 503 })), - ); -} - describe("ManagedEndpointProvider", () => { it.effect("provisions a Cloudflare tunnel endpoint and connector token", () => { const tunnelCalls: TunnelCall[] = []; - const calls: Array<{ - readonly method: string; - readonly url: string; - readonly body: unknown; - readonly authorization: string | undefined; - }> = []; - const execute = (request: HttpClientRequest.HttpClientRequest) => - Effect.sync(() => { - calls.push({ - method: request.method, - url: request.url, - body: decodeBody(request), - authorization: request.headers.authorization, - }); - if (request.url.includes("/dns_records?")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: [] }, { status: 200 }), - ); - } - if (request.url.endsWith("/dns_records")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true }, { status: 200 }), - ); - } - throw new Error(`Unexpected DNS request: ${request.method} ${request.url}`); - }); + const dnsCalls: DnsCall[] = []; return Effect.gen(function* () { const hostname = expectedManagedHostname("env_ABC"); @@ -162,8 +125,19 @@ describe("ManagedEndpointProvider", () => { tunnelName: expectedManagedTunnelName("env_ABC"), }, }); - expect(calls.map((call) => call.method)).toEqual(["GET", "POST"]); - expect(calls.every((call) => call.authorization === "Bearer api-token")).toBe(true); + expect(dnsCalls).toEqual([ + { operation: "listCnameRecords", input: hostname }, + { + operation: "createCnameRecord", + input: { + type: "CNAME", + name: hostname, + content: "tunnel-id.cfargotunnel.com", + ttl: 1, + proxied: true, + }, + }, + ]); expect(tunnelCalls.map((call) => call.operation)).toEqual([ "list", "create", @@ -185,39 +159,13 @@ describe("ManagedEndpointProvider", () => { name: expectedManagedTunnelName("env_ABC"), isDeleted: false, }); - }).pipe(Effect.provide(providerLayer(execute, makeTunnelClient(tunnelCalls)))); + }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls), makeDnsClient(dnsCalls)))); }); it.effect( "normalizes unusual environment ids before using them in Cloudflare tunnel names", () => { const tunnelCalls: TunnelCall[] = []; - const calls: Array<{ - readonly method: string; - readonly url: string; - readonly body: unknown; - }> = []; - const execute = (request: HttpClientRequest.HttpClientRequest) => - Effect.sync(() => { - calls.push({ - method: request.method, - url: request.url, - body: decodeBody(request), - }); - if (request.url.includes("/dns_records?")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: [] }, { status: 200 }), - ); - } - if (request.url.endsWith("/dns_records")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true }, { status: 200 }), - ); - } - throw new Error(`Unexpected DNS request: ${request.method} ${request.url}`); - }); return Effect.gen(function* () { const environmentId = "ENV With Spaces/../Symbols!" + "x".repeat(80); @@ -263,38 +211,12 @@ describe("ManagedEndpointProvider", () => { name: requestedName, configSrc: "cloudflare", }); - }).pipe(Effect.provide(providerLayer(execute, makeTunnelClient(tunnelCalls)))); + }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls)))); }, ); it.effect("formats IPv6 loopback origins as valid Cloudflare ingress service URLs", () => { const tunnelCalls: TunnelCall[] = []; - const calls: Array<{ - readonly method: string; - readonly url: string; - readonly body: unknown; - }> = []; - const execute = (request: HttpClientRequest.HttpClientRequest) => - Effect.sync(() => { - calls.push({ - method: request.method, - url: request.url, - body: decodeBody(request), - }); - if (request.url.includes("/dns_records?")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: [] }, { status: 200 }), - ); - } - if (request.url.endsWith("/dns_records")) { - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true }, { status: 200 }), - ); - } - throw new Error(`Unexpected DNS request: ${request.method} ${request.url}`); - }); return Effect.gen(function* () { const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; @@ -315,19 +237,11 @@ describe("ManagedEndpointProvider", () => { ], }, }); - }).pipe(Effect.provide(providerLayer(execute, makeTunnelClient(tunnelCalls)))); + }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls)))); }); it.effect("rejects non-loopback managed endpoint origins before calling Cloudflare", () => { - const calls: Array = []; - const execute = (request: HttpClientRequest.HttpClientRequest) => - Effect.sync(() => { - calls.push(request); - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: [] }, { status: 200 }), - ); - }); + const dnsCalls: DnsCall[] = []; return Effect.gen(function* () { const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; @@ -338,24 +252,16 @@ describe("ManagedEndpointProvider", () => { }), ); - expect(calls).toHaveLength(0); + expect(dnsCalls).toHaveLength(0); expect(result._tag).toBe("Failure"); if (result._tag === "Failure") { expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); } - }).pipe(Effect.provide(providerLayer(execute))); + }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls)))); }); it.effect("rejects invalid managed endpoint origin ports before calling Cloudflare", () => { - const calls: Array = []; - const execute = (request: HttpClientRequest.HttpClientRequest) => - Effect.sync(() => { - calls.push(request); - return HttpClientResponse.fromWeb( - request, - Response.json({ success: true, result: [] }, { status: 200 }), - ); - }); + const dnsCalls: DnsCall[] = []; return Effect.gen(function* () { const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; @@ -366,33 +272,45 @@ describe("ManagedEndpointProvider", () => { }), ); - expect(calls).toHaveLength(0); + expect(dnsCalls).toHaveLength(0); expect(result._tag).toBe("Failure"); if (result._tag === "Failure") { expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); } - }).pipe(Effect.provide(providerLayer(execute))); + }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls)))); }); - it.effect("fails provisioning when Cloudflare returns a 2xx application error", () => { + it.effect("updates an existing CNAME record through the DNS client", () => { + const dnsCalls: DnsCall[] = []; return Effect.gen(function* () { const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; - const error = yield* Effect.flip( - provider.provision({ - environmentId: "env_ABC", - origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, - }), - ); - - expect(error._tag).toBe("ManagedEndpointProvisioningFailed"); - expect(error.cause).toMatchObject({ - success: false, - errors: [{ message: "Cloudflare application failure" }], + yield* provider.provision({ + environmentId: "env_ABC", + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, }); - }).pipe(Effect.provide(providerLayer(cloudflareApplicationErrorResponse))); + + expect(dnsCalls.map((call) => call.operation)).toEqual([ + "listCnameRecords", + "updateCnameRecord", + ]); + expect(dnsCalls[1]?.input).toMatchObject({ dnsRecordId: "existing-record-id" }); + }).pipe( + Effect.provide( + providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls, [{ id: "existing-record-id" }])), + ), + ); }); - it.effect("fails provisioning when Cloudflare returns a non-success HTTP response", () => { + it.effect("fails provisioning when the DNS client fails", () => { + const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ + cause: "Cloudflare DNS failure", + }); + const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ + listCnameRecords: () => Effect.fail(failure), + createCnameRecord: () => Effect.die("unused"), + updateCnameRecord: () => Effect.die("unused"), + }); + return Effect.gen(function* () { const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; const error = yield* Effect.flip( @@ -403,6 +321,7 @@ describe("ManagedEndpointProvider", () => { ); expect(error._tag).toBe("ManagedEndpointProvisioningFailed"); - }).pipe(Effect.provide(providerLayer(cloudflareNonSuccessHttpResponse))); + 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 index 0f4f7849728..c2a98b8f3b2 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -8,10 +8,7 @@ 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 Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import type { RelayManagedEndpoint, @@ -100,34 +97,45 @@ export class ManagedEndpointTunnelClient extends Context.Service< ManagedEndpointTunnelClientShape >()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {} -const CloudflareDnsRecordResponse = Schema.Struct({ - success: Schema.Boolean, - errors: Schema.optionalKey(Schema.Unknown), -}); +interface ManagedEndpointCnameRecordInput { + readonly type: "CNAME"; + readonly name: string; + readonly content: string; + readonly ttl: 1; + readonly proxied: true; +} -const CloudflareDnsRecordListResponse = Schema.Struct({ - success: Schema.Boolean, - errors: Schema.optionalKey(Schema.Unknown), - result: Schema.Array( - Schema.Struct({ - id: Schema.String, - }), - ), -}); +export class ManagedEndpointDnsClientError extends Data.TaggedError( + "ManagedEndpointDnsClientError", +)<{ + readonly cause: unknown; +}> {} + +export interface ManagedEndpointDnsClientShape { + readonly listCnameRecords: ( + hostname: string, + ) => Effect.Effect, ManagedEndpointDnsClientError>; + readonly createCnameRecord: ( + request: ManagedEndpointCnameRecordInput, + ) => Effect.Effect; + readonly updateCnameRecord: ( + dnsRecordId: string, + request: ManagedEndpointCnameRecordInput, + ) => 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.cloudflareZoneId || - !settings.cloudflareApiToken - ) { + if (!settings.managedEndpointBaseDomain) { return yield* new ManagedEndpointProvisioningNotConfigured(); } return { - zoneId: settings.cloudflareZoneId, - apiToken: Redacted.value(settings.cloudflareApiToken), baseDomain: settings.managedEndpointBaseDomain, }; }); @@ -185,22 +193,9 @@ function isLoopbackOrigin(origin: RelayManagedEndpointOrigin): boolean { const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; - const httpClient = yield* HttpClient.HttpClient; const crypto = yield* Crypto.Crypto; const tunnels = yield* ManagedEndpointTunnelClient; - - const executeJson = - (schema: Schema.Codec) => - (request: HttpClientRequest.HttpClientRequest) => - httpClient.execute(request).pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), - Effect.filterMapOrFail( - (body) => (body.success ? Result.succeed(body) : Result.fail(body)), - (cause) => new ManagedEndpointProvisioningFailed({ cause }), - ), - ); + const dns = yield* ManagedEndpointDnsClient; return ManagedEndpointProvider.of({ provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { @@ -254,36 +249,24 @@ const make = Effect.gen(function* () { }) .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); - const existingDnsRecordId = yield* HttpClientRequest.make("GET")( - "/zones/${cf.zoneId}/dns_records", - ).pipe( - HttpClientRequest.prependUrl("https://api.cloudflare.com/client/v4"), - HttpClientRequest.bearerToken(cf.apiToken), - HttpClientRequest.setUrlParams({ - type: "CNAME", - name: hostname, - }), - executeJson(CloudflareDnsRecordListResponse), - Effect.map((body) => Arr.head(body.result)), + const existingDnsRecordId = yield* dns.listCnameRecords(hostname).pipe( + Effect.map(Arr.head), Effect.map(Option.map((record) => record.id)), + Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), ); - const upsertDnsRequest = Option.match(existingDnsRecordId, { - onSome: (id) => HttpClientRequest.make("PUT")(`/zones/${cf.zoneId}/dns_records/${id}`), - onNone: () => HttpClientRequest.make("POST")(`/zones/${cf.zoneId}/dns_records`), - }); + const dnsRecord = { + type: "CNAME", + name: hostname, + content: `${tunnel.id}.cfargotunnel.com`, + ttl: 1, + proxied: true, + } as const; - yield* upsertDnsRequest.pipe( - HttpClientRequest.prependUrl("https://api.cloudflare.com/client/v4"), - HttpClientRequest.bearerToken(cf.apiToken), - HttpClientRequest.bodyJsonUnsafe({ - type: "CNAME", - name: hostname, - content: `${tunnel.id}.cfargotunnel.com`, - proxied: true, - }), - executeJson(CloudflareDnsRecordResponse), - ); + yield* Option.match(existingDnsRecordId, { + onSome: (id) => dns.updateCnameRecord(id, dnsRecord), + onNone: () => dns.createCnameRecord(dnsRecord), + }).pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); const connectorToken = yield* tunnels .getToken(tunnel.id) @@ -308,36 +291,60 @@ const make = Effect.gen(function* () { export const layer = Layer.effect(ManagedEndpointProvider, make); -export const layerCloudflareTunnels = ( +export const layerCloudflareBindings = ( tunnelClient: Cloudflare.TunnelReadWriteClient, + dnsClient: Cloudflare.DnsReadWriteClient, alchemyRuntimeContext: Alchemy.BaseRuntimeContext, ) => layer.pipe( Layer.provide( - 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), - ), - }), + 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), + ), + }), + ), + Layer.succeed( + ManagedEndpointDnsClient, + ManagedEndpointDnsClient.of({ + listCnameRecords: (hostname) => + dnsClient.listDnsRecords({ type: "CNAME", name: { exact: hostname } }).pipe( + Effect.map((response) => response.result), + Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + createCnameRecord: (request) => + dnsClient.createDnsRecord(request).pipe( + Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + updateCnameRecord: (dnsRecordId, request) => + dnsClient.updateDnsRecord(dnsRecordId, request).pipe( + Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), + Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), + ), + }), + ), ), ), ); diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 9c985768a23..d007c3b211e 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -15,6 +15,7 @@ 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"; @@ -72,7 +73,11 @@ const relayCorsAllowedHeaders = [ "content-type", "dpop", ] as const; -const relayCorsExposedHeaders = ["x-t3-relay-auth-failure", "www-authenticate"] as const; +const relayCorsExposedHeaders = [ + "traceparent", + "x-t3-relay-auth-failure", + "www-authenticate", +] as const; const relayCorsHeaders = { "access-control-allow-origin": "*", @@ -104,6 +109,20 @@ const appendRelayDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_req ), ); +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< @@ -139,7 +158,9 @@ export const traceRelayHttpRequest = ( >, ) => // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first. - HttpMiddleware.tracer(httpEffect).pipe(Effect.ensuring(Effect.yieldNow)); + HttpMiddleware.tracer( + appendRelayTraceContextResponseHeader.pipe(Effect.andThen(httpEffect)), + ).pipe(Effect.ensuring(Effect.yieldNow)); export const traceRelayHttpRequestWith = ( httpEffect: Effect.Effect< @@ -166,22 +187,29 @@ export const relayClientAuthLayer = Layer.effect( const config = yield* RelayConfiguration.RelayConfiguration; return { bearer: Effect.fn("relay.auth.client.bearer")(function* (httpEffect, { credential }) { - const token = Redacted.value(credential); + const token = readHttpAuthorizationCredential(credential); const verified = yield* verifyClerkBearerToken(config, token).pipe( Effect.tapError((error) => - Effect.logWarning("relay clerk token verification failed", { - reason: clerkVerificationFailureReason(error.cause), - }), + Effect.annotateCurrentSpan( + "relay.auth.clerk_verification_failure", + clerkVerificationFailureReason(error.cause), + ), ), Effect.catch(() => relayAuthInvalidError("invalid_bearer")), ); - if (!verified.sub) { + if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.relayIssuer)) { + yield* Effect.annotateCurrentSpan({ + "relay.auth.clerk_verification_failure": !verified.sub + ? "missing_subject" + : "missing_relay_audience", + }); return yield* relayAuthInvalidError("invalid_bearer"); } yield* Effect.annotateCurrentSpan({ "relay.auth.mode": "clerk_bearer", "relay.auth.subject": verified.sub, }); + return yield* httpEffect.pipe( withSpanAttributes({ "user.id": verified.sub }), Effect.provideService(RelayClientPrincipal, { @@ -200,7 +228,7 @@ export const relayEnvironmentAuthLayer = Layer.effect( const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; return { bearer: Effect.fn("relay.auth.environment.bearer")(function* (httpEffect, { credential }) { - const token = Redacted.value(credential); + const token = readHttpAuthorizationCredential(credential); const principal = yield* credentials .authenticate(token) .pipe( @@ -236,9 +264,7 @@ export const relayDpopClientAuthLayer = Layer.effect( if (!isDpopAuthorizationHeader(request.headers.authorization)) { return yield* relayAuthInvalidError("invalid_bearer"); } - // Effect beta.73 exposes arbitrary HTTP schemes but currently leaves - // the separating spaces in the decoded credential. - const token = Redacted.value(credential).trimStart(); + const token = readHttpAuthorizationCredential(credential); const now = yield* DateTime.now; const verified = yield* relayTokens.verifyDpopAccessToken({ token, @@ -269,6 +295,11 @@ 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", @@ -541,7 +572,7 @@ export const tokenApi = HttpApiBuilder.group( const verified = yield* verifyClerkBearerToken(config, args.payload.subject_token).pipe( Effect.catch(() => relayAuthInvalidError("invalid_bearer")), ); - if (!verified.sub) { + if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.relayIssuer)) { return yield* relayAuthInvalidError("invalid_bearer"); } const proofKeyThumbprint = yield* requireDpopProof().pipe( @@ -903,6 +934,13 @@ function safeAuthFailureReason(value: string): string { } 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) { @@ -915,6 +953,14 @@ function clerkVerificationFailureReason(cause: unknown): string { return "unknown"; } +function hasExpectedClerkAudience(audience: unknown, relayIssuer: string): boolean { + const expectedAudience = normalizeRelayIssuer(relayIssuer); + 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: () => @@ -923,7 +969,11 @@ function verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationSha audience: normalizeRelayIssuer(config.relayIssuer), }), catch: (cause) => new ClerkTokenVerificationFailed({ cause }), - }); + }).pipe( + Effect.withSpan("verify_clerk_bearer_token", { + attributes: { "relay.auth.token_length": token.length }, + }), + ); } const requireDpopPrincipalScope = Effect.fn("relay.api.require_dpop_principal_scope")(function* ( diff --git a/infra/relay/src/managedEndpointStack.ts b/infra/relay/src/managedEndpointStack.ts index 6cc7b56e508..1515838cbd1 100644 --- a/infra/relay/src/managedEndpointStack.ts +++ b/infra/relay/src/managedEndpointStack.ts @@ -1,28 +1,9 @@ import { adopt } from "alchemy/AdoptPolicy"; import * as Cloudflare from "alchemy/Cloudflare"; -import * as Output from "alchemy/Output"; -import * as Effect from "effect/Effect"; export const RELAY_PUBLIC_DOMAIN = "t3code-relay.ineededadomain.com"; export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`; -export const ManagedEndpointZone = Effect.gen(function* () { - const zone = yield* Cloudflare.Zone("ManagedEndpointZone", { - name: "ineededadomain.com", - }).pipe(adopt(true)); - - const dnsToken = yield* Cloudflare.AccountApiToken("ManagedEndpointDNSToken", { - name: "t3-code-relay-managed-endpoint-dns-token", - policies: [ - { - effect: "allow", - permissionGroups: ["DNS Read", "DNS Write"], - resources: Output.map(zone.zoneId, (id) => ({ - [`com.cloudflare.api.account.zone.${id}`]: "*", - })), - }, - ], - }); - - return { zoneId: zone.zoneId, zoneName: zone.name, dnsToken: dnsToken.value }; -}); +export const ManagedEndpointZone = Cloudflare.Zone("ManagedEndpointZone", { + name: "ineededadomain.com", +}).pipe(adopt(true)); diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 3af4810cc0e..15364824a6a 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -136,8 +136,8 @@ export default class Api extends Cloudflare.Worker()( const db = yield* Drizzle.postgres(hyperdrive.connectionString); const managedEndpointTunnelBinding = yield* Cloudflare.TunnelReadWrite.bind(); - const managedEndpointZoneId = yield* managedEndpointZone.zoneId; - const managedEndpointDNSToken = yield* managedEndpointZone.dnsToken; + const managedEndpointDnsBinding = yield* Cloudflare.DnsReadWrite.bind(managedEndpointZone); + const managedEndpointZoneName = yield* managedEndpointZone.name; // // 3. Runtime layers and app construction @@ -158,9 +158,7 @@ export default class Api extends Cloudflare.Worker()( clerkSecretKey, cloudMintPrivateKey: yield* cloudMintPrivateKey, cloudMintPublicKey: yield* cloudMintPublicKey, - managedEndpointBaseDomain: yield* managedEndpointZoneId, - cloudflareZoneId: yield* managedEndpointZoneId, - cloudflareApiToken: yield* managedEndpointDNSToken, + managedEndpointBaseDomain: yield* managedEndpointZoneName, }); }); @@ -179,8 +177,9 @@ export default class Api extends Cloudflare.Worker()( Layer.provideMerge(EnvironmentLinker.layer), Layer.provideMerge(EnvironmentPublishSignatures.layer), Layer.provideMerge( - ManagedEndpointProvider.layerCloudflareTunnels( + ManagedEndpointProvider.layerCloudflareBindings( managedEndpointTunnelBinding, + managedEndpointDnsBinding, alchemyRuntimeContext, ), ), @@ -258,6 +257,7 @@ export default class Api extends Cloudflare.Worker()( Layer.provideMerge(Cloudflare.QueueBindingLive), Layer.provideMerge(Cloudflare.QueueEventSourceLive), Layer.provideMerge(Cloudflare.TunnelReadWriteLive), + Layer.provideMerge(Cloudflare.DnsReadWriteLive), ), ), ), From d613d755d1df236dc905f747cdec8e276a1279ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 13:12:23 -0700 Subject: [PATCH 10/61] rename --- infra/relay/alchemy.run.ts | 2 +- infra/relay/src/worker.ts | 6 +----- infra/relay/src/{managedEndpointStack.ts => zone.ts} | 0 3 files changed, 2 insertions(+), 6 deletions(-) rename infra/relay/src/{managedEndpointStack.ts => zone.ts} (100%) diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index bd906adc51a..4ec6b5abaae 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -9,7 +9,7 @@ import * as Planetscale from "alchemy/Planetscale"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; -import { ManagedEndpointZone } from "./src/managedEndpointStack.ts"; +import { ManagedEndpointZone } from "./src/zone.ts"; import Api from "./src/worker.ts"; export default Alchemy.Stack( diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 15364824a6a..8ba7c2e6e23 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -29,11 +29,7 @@ import { tokenApi, withoutCapturedParentSpan, } from "./http/Api.ts"; -import { - ManagedEndpointZone, - RELAY_PUBLIC_DOMAIN, - RELAY_PUBLIC_ORIGIN, -} from "./managedEndpointStack.ts"; +import { ManagedEndpointZone, RELAY_PUBLIC_DOMAIN, RELAY_PUBLIC_ORIGIN } from "./zone.ts"; import { makeRelayTraceLayer, RelayObservability } from "./observability.ts"; import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts"; import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts"; diff --git a/infra/relay/src/managedEndpointStack.ts b/infra/relay/src/zone.ts similarity index 100% rename from infra/relay/src/managedEndpointStack.ts rename to infra/relay/src/zone.ts From 15b21b6e35d7b1ca18d7e4388598c9c03f4aa8f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 15:01:24 -0700 Subject: [PATCH 11/61] feat(cloud): integrate relay environments and notification settings Co-authored-by: codex --- .../agent-awareness/registrationPayload.ts | 2 + .../remoteRegistration.test.ts | 4 + .../agent-awareness/remoteRegistration.ts | 1 + apps/server/src/cloud/config.ts | 1 + apps/server/src/cloud/environmentKeys.ts | 39 +++ apps/server/src/cloud/http.ts | 100 +++--- .../src/relay/AgentAwarenessRelay.test.ts | 9 + apps/server/src/relay/AgentAwarenessRelay.ts | 27 +- apps/server/src/server.test.ts | 60 +++- apps/web/src/cloud/linkEnvironment.test.ts | 2 + apps/web/src/cloud/linkEnvironment.ts | 44 +++ .../src/components/settings/CloudSettings.tsx | 315 +++++------------- .../settings/ConnectionsSettings.tsx | 218 +++++++++++- .../settings/SettingsPanels.browser.tsx | 9 + docs/t3-code-cloud-auth-flow.html | 2 +- infra/relay/README.md | 96 ++++++ .../20260527044716_baseline/migration.sql | 3 +- .../20260527044716_baseline/snapshot.json | 13 + infra/relay/src/agentActivity/Devices.test.ts | 60 ++++ infra/relay/src/agentActivity/Devices.ts | 49 ++- .../agentActivity/MobileRegistrations.test.ts | 2 + infra/relay/src/http/Api.ts | 9 + infra/relay/src/persistence/schema.ts | 1 + .../client-runtime/src/managedRelay.test.ts | 47 +++ packages/client-runtime/src/managedRelay.ts | 16 + packages/contracts/src/environmentHttp.ts | 14 + packages/contracts/src/relay.ts | 31 ++ 27 files changed, 883 insertions(+), 291 deletions(-) create mode 100644 apps/server/src/cloud/environmentKeys.ts create mode 100644 infra/relay/README.md diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index b0d29b877eb..44ef38df0ef 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -4,6 +4,7 @@ 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; @@ -14,6 +15,7 @@ export function makeRelayDeviceRegistrationRequest(input: { const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; return { deviceId: input.deviceId, + label: input.label, platform: "ios", iosMajorVersion: input.iosMajorVersion, appVersion: input.appVersion, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index b3c04bd616b..369309bb502 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -164,6 +164,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect( makeRelayDeviceRegistrationRequest({ deviceId: "device-1", + label: "Julius's iPhone", iosMajorVersion: 18, appVersion: "1.0.0", pushToken: "apns-token", @@ -175,6 +176,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }), ).toEqual({ deviceId: "device-1", + label: "Julius's iPhone", platform: "ios", iosMajorVersion: 18, appVersion: "1.0.0", @@ -195,6 +197,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect( makeRelayDeviceRegistrationRequest({ deviceId: "device-1", + label: "Julius's iPhone", iosMajorVersion: 18, appVersion: "1.0.0", pushToStartToken: "push-to-start-token", @@ -205,6 +208,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { }), ).toEqual({ deviceId: "device-1", + label: "Julius's iPhone", platform: "ios", iosMajorVersion: 18, appVersion: "1.0.0", diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 14bf87af66f..0ca8330da96 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -250,6 +250,7 @@ function registerDevice(input?: { yield* registerDeviceWithRelay( makeRelayDeviceRegistrationRequest({ deviceId, + label: Constants.deviceName?.trim() || "iOS device", iosMajorVersion: iosMajorVersion(), appVersion: Constants.expoConfig?.version, ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts index 51b05e64a58..f5642393abf 100644 --- a/apps/server/src/cloud/config.ts +++ b/apps/server/src/cloud/config.ts @@ -7,6 +7,7 @@ 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), diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts new file mode 100644 index 00000000000..3b355cfb1b6 --- /dev/null +++ b/apps/server/src/cloud/environmentKeys.ts @@ -0,0 +1,39 @@ +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; + +const CLOUD_LINK_PRIVATE_KEY = "cloud-link-ed25519-private-key"; +const CLOUD_LINK_PUBLIC_KEY = "cloud-link-ed25519-public-key"; + +function bytesToString(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +function stringToBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( + secrets: ServerSecretStore.ServerSecretStoreShape, +) { + const existingPrivate = yield* secrets.get(CLOUD_LINK_PRIVATE_KEY); + const existingPublic = yield* secrets.get(CLOUD_LINK_PUBLIC_KEY); + if (existingPrivate && existingPublic) { + return { + privateKey: bytesToString(existingPrivate), + publicKey: bytesToString(existingPublic), + }; + } + + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + yield* secrets.set(CLOUD_LINK_PRIVATE_KEY, stringToBytes(keyPair.privateKey)); + yield* secrets.set(CLOUD_LINK_PUBLIC_KEY, stringToBytes(keyPair.publicKey)); + return { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + }; +}); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index c50deebc269..3c274107417 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -1,6 +1,7 @@ import * as NodeCrypto from "node:crypto"; import { - AuthRelayManageScope, + AuthRelayReadScope, + AuthRelayWriteScope, AuthStandardClientScopes, EnvironmentCloudEndpointUnavailableError, EnvironmentCloudLinkStateResult, @@ -62,13 +63,13 @@ import { 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 { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; -const CLOUD_LINK_PRIVATE_KEY = "cloud-link-ed25519-private-key"; -const CLOUD_LINK_PUBLIC_KEY = "cloud-link-ed25519-public-key"; const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-"; @@ -329,30 +330,6 @@ function hasBoundedCloudProofLifetime(input: { const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentHealthProofPayload); const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload); -export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, -) { - const existingPrivate = yield* secrets.get(CLOUD_LINK_PRIVATE_KEY); - const existingPublic = yield* secrets.get(CLOUD_LINK_PUBLIC_KEY); - if (existingPrivate && existingPublic) { - return { - privateKey: bytesToString(existingPrivate), - publicKey: bytesToString(existingPublic), - }; - } - - const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { - privateKeyEncoding: { format: "pem", type: "pkcs8" }, - publicKeyEncoding: { format: "pem", type: "spki" }, - }); - yield* secrets.set(CLOUD_LINK_PRIVATE_KEY, stringToBytes(keyPair.privateKey)); - yield* secrets.set(CLOUD_LINK_PUBLIC_KEY, stringToBytes(keyPair.publicKey)); - return { - privateKey: keyPair.privateKey, - publicKey: keyPair.publicKey, - }; -}); - interface CloudHttpDependencies { readonly secrets: ServerSecretStore.ServerSecretStoreShape; readonly environment: ServerEnvironmentShape; @@ -362,7 +339,7 @@ interface CloudHttpDependencies { const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( function* (dependencies: CloudHttpDependencies, request: RelayLinkProofRequest) { - yield* requireEnvironmentScope(AuthRelayManageScope); + yield* requireEnvironmentScope(AuthRelayWriteScope); const httpRequest = yield* HttpServerRequest.HttpServerRequest; const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); const requestUrl = requestAbsoluteUrl(httpRequest); @@ -427,7 +404,7 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( function* (dependencies: CloudHttpDependencies, payload: RelayEnvironmentConfigRequest) { - yield* requireEnvironmentScope(AuthRelayManageScope); + yield* requireEnvironmentScope(AuthRelayWriteScope); yield* validateRelayConfigPayload(payload); yield* validateLinkedCloudUser({ secrets: dependencies.secrets, @@ -484,24 +461,33 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( }), ); +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(AuthRelayManageScope); - const [cloudUserId, relayUrl, relayIssuer] = yield* Effect.all( - [ - dependencies.secrets.get(CLOUD_LINKED_USER_ID), - dependencies.secrets.get(RELAY_URL_SECRET), - dependencies.secrets.get(RELAY_ISSUER_SECRET), - ], - { concurrency: 3 }, - ); - const response = { - linked: cloudUserId !== null, - cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, - relayUrl: relayUrl ? bytesToString(relayUrl) : null, - relayIssuer: relayIssuer ? bytesToString(relayIssuer) : null, - } satisfies EnvironmentCloudLinkStateResult; - return response; + yield* requireEnvironmentScope(AuthRelayReadScope); + return yield* readCloudLinkState(dependencies); }, Effect.catchTag( "SecretStoreError", @@ -511,7 +497,7 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( function* (dependencies: CloudHttpDependencies) { - yield* requireEnvironmentScope(AuthRelayManageScope); + yield* requireEnvironmentScope(AuthRelayWriteScope); const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(null); yield* Effect.all( [ @@ -521,8 +507,9 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( 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: 6 }, + { concurrency: 7 }, ); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, @@ -532,6 +519,24 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( ), ); +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 keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); @@ -788,6 +793,7 @@ export const cloudHttpApiLayer = HttpApiBuilder.group( .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 }) => diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index abb54057b1f..d2027cd3225 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -40,6 +40,7 @@ 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"; @@ -185,6 +186,12 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ); }); + 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("resolves a null publish state when a thread or project snapshot disappeared", () => { const environmentId = "env-1" as EnvironmentId; const threadId = "thread-1" as ThreadId; @@ -457,6 +464,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { 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, @@ -600,6 +608,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { 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", diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 1c778c2799a..be490c016e5 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -33,8 +33,9 @@ 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/http.ts"; +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; import { + PUBLISH_AGENT_ACTIVITY_SECRET, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, RELAY_ISSUER_SECRET, RELAY_URL_SECRET, @@ -94,6 +95,10 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n return JSON.stringify(meaningfulState); } +export function isAgentActivityPublishingEnabled(value: string | null): boolean { + return value === "true"; +} + function relayEnvironmentClient(token: string) { return HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); } @@ -254,6 +259,10 @@ const make = Effect.gen(function* () { : null; }); + const readPublishAgentActivityEnabled = readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET).pipe( + Effect.map(isAgentActivityPublishingEnabled), + ); + const makeRelayClient = (relayConfig: { readonly url: string; readonly environmentCredential: string; @@ -264,6 +273,15 @@ const make = Effect.gen(function* () { }).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 awareness relay publish skipped; publication disabled", { + threadId, + }); + return; + } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { yield* Effect.logDebug("agent awareness relay publish skipped; relay config missing", { @@ -375,6 +393,13 @@ const make = Effect.gen(function* () { ); const publishActiveThreadsUnsafe = Effect.gen(function* () { + const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( + Effect.orElseSucceed(() => false), + ); + if (!publishAgentActivity) { + yield* Effect.logDebug("agent awareness relay active snapshot skipped; publication disabled"); + return false; + } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { yield* Effect.logDebug("agent awareness relay active snapshot skipped; relay config missing"); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index bd7ae885ffa..276ea474a4e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1912,7 +1912,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("rejects managed relay configuration reads without relay management scope", () => + it.effect("allows standard clients to read managed relay configuration state", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -1928,17 +1928,57 @@ it.layer(NodeServices.layer)("server router seam", (it) => { headers: { cookie: pairedCookie }, }); const body = yield* responseJsonEffect<{ - readonly _tag?: string; - readonly code?: string; - readonly requiredScope?: string; - readonly traceId?: string; + readonly linked?: boolean; + readonly publishAgentActivity?: boolean; }>(response); - assert.equal(response.status, 403); - assert.equal(body._tag, "EnvironmentScopeRequiredError"); - assert.equal(body.code, "insufficient_scope"); - assert.equal(body.requiredScope, "relay:manage"); - assert.equal(typeof body.traceId, "string"); + assert.equal(response.status, 200); + assert.equal(body.linked, false); + assert.equal(body.publishAgentActivity, false); + }).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)), ); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index d6b2d98ebab..57a3bd58fcb 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -497,6 +497,7 @@ describe("web cloud link environment client", () => { cloudUserId: "user_123", relayUrl: "https://relay.example.test", relayIssuer: "https://issuer.example.test", + publishAgentActivity: false, }), ); vi.stubGlobal("fetch", fetchMock); @@ -507,6 +508,7 @@ describe("web cloud link environment client", () => { 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", diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index d4ab09c6918..8888d40020e 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -14,6 +14,7 @@ import { } from "@t3tools/contracts"; import { RelayEnvironmentConnectScope, + type RelayClientDeviceRecord, type RelayEnvironmentLinkResponse, RelayProtectedError, type RelayClientEnvironmentRecord, @@ -252,6 +253,32 @@ export function listManagedCloudEnvironments(input: { }); } +export function listCloudDevices(input: { + readonly clerkToken: string; +}): Effect.Effect< + ReadonlyArray, + CloudEnvironmentLinkError, + ManagedRelayClient +> { + return Effect.gen(function* () { + if (!relayUrl()) { + return yield* new CloudEnvironmentLinkError({ + message: "VITE_T3_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; @@ -378,6 +405,23 @@ export function readPrimaryCloudLinkState(): Effect.Effect< }); } +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( + withPrimaryEnvironmentCookies, + Effect.mapError(environmentApiError("Could not update environment cloud preferences.")), + ); + }); +} + export function unlinkPrimaryEnvironmentFromCloud(input: { readonly clerkToken: string | null; }): Effect.Effect { diff --git a/apps/web/src/components/settings/CloudSettings.tsx b/apps/web/src/components/settings/CloudSettings.tsx index e5974319c10..3683ff275ff 100644 --- a/apps/web/src/components/settings/CloudSettings.tsx +++ b/apps/web/src/components/settings/CloudSettings.tsx @@ -1,8 +1,8 @@ import { UserButton, Waitlist, useAuth, useClerk } from "@clerk/react"; import { useSignIn, useSignUp } from "@clerk/react/legacy"; +import { AuthRelayWriteScope } from "@t3tools/contracts"; import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; -import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; -import * as Effect from "effect/Effect"; +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; import { CloudIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -11,25 +11,16 @@ import { resolveDesktopCloudAuthOAuthOptions, } from "../../cloud/desktopAuth"; import { - collectCloudLinkTargets, - connectManagedCloudEnvironment, - linkEnvironmentToCloud, - linkPrimaryEnvironmentToCloud, - listManagedCloudEnvironments, + listCloudDevices, readPrimaryCloudLinkState, - readPrimaryCloudLinkTarget, type CloudLinkState, - unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "../../cloud/linkEnvironment"; import { isElectron } from "../../env"; -import { usePrimaryEnvironmentId } from "../../environments/primary"; -import { - addManagedRelayEnvironment, - listSavedEnvironmentRecords, - useSavedEnvironmentRegistryStore, -} from "../../environments/runtime"; +import { fetchSessionState, usePrimaryEnvironmentId } from "../../environments/primary"; import { webRuntime } from "../../lib/runtime"; import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; @@ -108,190 +99,84 @@ function CloudWaitlistPanel() { } function CloudSettingsPanelInner() { - const { getToken, userId } = useAuth(); + const { getToken } = useAuth(); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const savedEnvironmentCount = useSavedEnvironmentRegistryStore( - (state) => Object.keys(state.byId).length, - ); - const [isLinking, setIsLinking] = useState(false); - const [isUnlinking, setIsUnlinking] = useState(false); const [primaryLinkState, setPrimaryLinkState] = useState(null); - const [linkStateError, setLinkStateError] = useState(null); - const [managedEnvironments, setManagedEnvironments] = useState< - ReadonlyArray - >([]); - const [isLoadingManaged, setIsLoadingManaged] = useState(false); - const [connectingEnvironmentId, setConnectingEnvironmentId] = useState(null); - const linkableEnvironmentCount = collectCloudLinkTargets({ - primary: primaryEnvironmentId ? readPrimaryCloudLinkTarget() : null, - saved: listSavedEnvironmentRecords().filter((environment) => !environment.relayManaged), - }).length; - const linkedCloudUserId = primaryLinkState?.cloudUserId ?? null; - const hasCloudAccountMismatch = Boolean( - userId && linkedCloudUserId && linkedCloudUserId !== userId, - ); + const [devices, setDevices] = useState>([]); + const [isLoadingDevices, setIsLoadingDevices] = useState(false); + const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); + const [canManageRelay, setCanManageRelay] = useState(false); const refreshPrimaryLinkState = useCallback(() => { if (!primaryEnvironmentId) { setPrimaryLinkState(null); - setLinkStateError(null); return; } - void webRuntime.runPromise(readPrimaryCloudLinkState()).then( - (state) => { - setPrimaryLinkState(state); - setLinkStateError(null); - }, - (error: unknown) => { - setPrimaryLinkState(null); - setLinkStateError(cloudErrorMessage(error, "Could not read local cloud link state.")); - }, - ); + void webRuntime + .runPromise(readPrimaryCloudLinkState()) + .then(setPrimaryLinkState, () => setPrimaryLinkState(null)); }, [primaryEnvironmentId]); useEffect(() => { refreshPrimaryLinkState(); }, [refreshPrimaryLinkState]); - const refreshManagedEnvironments = useCallback(async () => { - setIsLoadingManaged(true); + useEffect(() => { + void fetchSessionState() + .then((session) => + setCanManageRelay( + session.authenticated && Boolean(session.scopes?.includes(AuthRelayWriteScope)), + ), + ) + .catch(() => setCanManageRelay(false)); + }, []); + + const refreshDevices = useCallback(async () => { + setIsLoadingDevices(true); try { const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS); if (!token) { - setManagedEnvironments([]); + setDevices([]); return; } - setManagedEnvironments( - await webRuntime.runPromise(listManagedCloudEnvironments({ clerkToken: token })), - ); + setDevices(await webRuntime.runPromise(listCloudDevices({ clerkToken: token }))); } catch (error) { toastManager.add({ type: "error", - title: "Cloud environments unavailable", - description: cloudErrorMessage(error, "Could not load linked environments."), + title: "Cloud devices unavailable", + description: cloudErrorMessage(error, "Could not load notification devices."), }); } finally { - setIsLoadingManaged(false); + setIsLoadingDevices(false); } }, [getToken]); useEffect(() => { - void refreshManagedEnvironments(); - }, [refreshManagedEnvironments]); - - const linkEnvironments = async () => { - if (hasCloudAccountMismatch) { - toastManager.add({ - type: "error", - title: "Cloud account mismatch", - description: "This environment is linked to a different cloud account.", - }); - return; - } - setIsLinking(true); - try { - const token = await runCloudOperation( - () => getToken(RELAY_CLERK_TOKEN_OPTIONS), - "Could not get the current cloud session.", - ); - if (!token) { - return; - } - const primaryTarget = readPrimaryCloudLinkTarget(); - const savedEnvironments = listSavedEnvironmentRecords().filter( - (environment) => !environment.relayManaged, - ); - const savedEnvironmentIds = new Set(primaryTarget ? [primaryTarget.environmentId] : []); - if (primaryTarget) { - await runCloudOperation( - () => webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken: token })), - "Could not link the local environment.", - ); - } - await runCloudOperation( - () => - webRuntime.runPromise( - Effect.all( - savedEnvironments - .filter((environment) => { - if (savedEnvironmentIds.has(environment.environmentId)) { - return false; - } - savedEnvironmentIds.add(environment.environmentId); - return true; - }) - .map((environment) => linkEnvironmentToCloud({ environment, clerkToken: token })), - { concurrency: "unbounded" }, - ), - ), - "Could not link environments.", - ); - toastManager.add({ - type: "success", - title: "Environments linked", - description: "Relay notifications are enabled for linked environments.", - }); - refreshPrimaryLinkState(); - } catch (error) { - toastManager.add({ - type: "error", - title: "Cloud link failed", - description: cloudErrorMessage(error, "Could not link environments."), - }); - } finally { - setIsLinking(false); - } - }; + void refreshDevices(); + }, [refreshDevices]); - const unlinkPrimaryEnvironment = async () => { - setIsUnlinking(true); + const updatePublishAgentActivity = async (enabled: boolean) => { + setIsUpdatingPreference(true); try { - const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS).catch(() => null); - await runCloudOperation( - () => webRuntime.runPromise(unlinkPrimaryEnvironmentFromCloud({ clerkToken: token })), - "Could not unlink the local environment.", + const state = await webRuntime.runPromise( + updatePrimaryCloudPreferences({ publishAgentActivity: enabled }), ); - refreshPrimaryLinkState(); + setPrimaryLinkState(state); toastManager.add({ type: "success", - title: "Environment unlinked", - description: "Local relay credentials and managed endpoint runtime config were removed.", + 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 unlink failed", - description: cloudErrorMessage(error, "Could not unlink the local environment."), + title: "Cloud preference update failed", + description: cloudErrorMessage(error, "Could not update cloud preferences."), }); } finally { - setIsUnlinking(false); - } - }; - - const connectManagedEnvironment = async (environment: RelayClientEnvironmentRecord) => { - setConnectingEnvironmentId(environment.environmentId); - try { - const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS); - if (!token) { - throw new CloudSettingsOperationError("Could not get the current cloud session."); - } - const connection = await webRuntime.runPromise( - connectManagedCloudEnvironment({ clerkToken: token, environment }), - ); - await addManagedRelayEnvironment(connection); - toastManager.add({ - type: "success", - title: "Environment connected", - description: `${connection.label} is available through its managed tunnel.`, - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Managed connection failed", - description: cloudErrorMessage(error, "Could not connect to the cloud environment."), - }); - } finally { - setConnectingEnvironmentId(null); + setIsUpdatingPreference(false); } }; @@ -300,86 +185,60 @@ function CloudSettingsPanelInner() { }> } /> + + - {linkedCloudUserId ? ( - - ) : null} - - - } - /> - void refreshManagedEnvironments()} - > - Refresh - + void updatePublishAgentActivity(enabled)} + /> } /> - {managedEnvironments.map((environment) => ( + + void refreshDevices()} + > + {isLoadingDevices ? "Refreshing..." : "Refresh"} + + } + > + {devices.map((device) => ( void connectManagedEnvironment(environment)} - > - {connectingEnvironmentId === environment.environmentId - ? "Connecting..." - : "Connect"} - + key={device.deviceId} + title={device.label} + description={`iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`} + status={ + device.notifications.enabled + ? device.liveActivities.enabled + ? "Notifications and Live Activities enabled" + : "Notifications enabled · Live Activities disabled" + : "Notifications disabled on device" } /> ))} + {!isLoadingDevices && devices.length === 0 ? ( + + ) : null} ); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b9aa9ce6c59..790aadbc97d 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,6 +30,8 @@ import { type EnvironmentId, } from "@t3tools/contracts"; import { WsRpcClient } from "@t3tools/client-runtime"; +import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; @@ -101,6 +104,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, addSavedEnvironment, + addManagedRelayEnvironment, connectDesktopSshEnvironment, disconnectSavedEnvironment, getPrimaryEnvironmentConnection, @@ -110,9 +114,21 @@ import { import { useUiStateStore } from "~/uiStateStore"; import { resolveServerConfigVersionMismatch } from "~/versionSkew"; import { useServerConfig } from "~/rpc/serverState"; +import { + connectManagedCloudEnvironment, + linkPrimaryEnvironmentToCloud, + listManagedCloudEnvironments, + readPrimaryCloudLinkState, + unlinkPrimaryEnvironmentFromCloud, +} from "~/cloud/linkEnvironment"; +import { webRuntime } from "~/lib/runtime"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; +function hasCloudConfig(): boolean { + return Boolean(import.meta.env.VITE_CLERK_PUBLISHABLE_KEY); +} + const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short", @@ -1582,6 +1598,197 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({ ); }); +function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { + const { getToken, isSignedIn } = useAuth(); + const [linked, setLinked] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(() => { + setIsLoading(true); + void webRuntime.runPromise(readPrimaryCloudLinkState()).then( + (state) => { + setLinked(state?.linked ?? false); + setError(null); + setIsLoading(false); + }, + (cause: unknown) => { + setError(cause instanceof Error ? cause.message : "Could not read T3 Cloud link state."); + setIsLoading(false); + }, + ); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const updateLink = async (enabled: boolean) => { + setIsLoading(true); + setError(null); + try { + const clerkToken = await getToken(RELAY_CLERK_TOKEN_OPTIONS); + 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 }), + ); + } + setLinked(enabled); + 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."; + setError(message); + toastManager.add({ + type: "error", + title: "Could not update T3 Cloud", + description: message, + }); + } finally { + setIsLoading(false); + } + }; + + return ( + void updateLink(enabled)} + /> + } + /> + ); +} + +function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { + return hasCloudConfig() ? ( + + ) : ( + } + /> + ); +} + +function ConfiguredCloudRemoteEnvironmentRows({ + savedEnvironmentIds, +}: { + readonly savedEnvironmentIds: ReadonlyArray; +}) { + const { getToken, isSignedIn } = useAuth(); + const [environments, setEnvironments] = useState>([]); + const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( + null, + ); + const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); + + useEffect(() => { + if (!isSignedIn) { + setEnvironments([]); + return; + } + let cancelled = false; + void getToken(RELAY_CLERK_TOKEN_OPTIONS) + .then((clerkToken) => + clerkToken + ? webRuntime.runPromise(listManagedCloudEnvironments({ clerkToken })) + : Promise.resolve([]), + ) + .then((next) => { + if (!cancelled) setEnvironments(next); + }) + .catch(() => { + if (!cancelled) setEnvironments([]); + }); + return () => { + cancelled = true; + }; + }, [getToken, isSignedIn]); + + const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { + setConnectingEnvironmentId(environment.environmentId); + try { + const clerkToken = await getToken(RELAY_CLERK_TOKEN_OPTIONS); + 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); + } + }; + + return environments + .filter((environment) => !savedIds.has(environment.environmentId)) + .map((environment) => ( +
+
+
+
+ +

{environment.label}

+
+

T3 Cloud

+
+ +
+
+ )); +} + +function CloudRemoteEnvironmentRows({ + savedEnvironmentIds, +}: { + readonly savedEnvironmentIds: ReadonlyArray; +}) { + return hasCloudConfig() ? ( + + ) : null; +} + export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; const primarySessionState = usePrimarySessionState(); @@ -1701,6 +1908,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"; @@ -2626,9 +2834,13 @@ export function ConnectionsSettings() { {renderNetworkAccessRow()} {renderEndpointRows("endpoint-rail")} {renderTailscaleRow()} + ) : ( - renderDisabledNetworkAccessRow() + <> + {renderDisabledNetworkAccessRow()} + + )} @@ -2810,6 +3022,7 @@ export function ConnectionsSettings() { title="Administrative access" description="Pairing links and client-session management require the access:write scope for this backend." /> + )} @@ -2889,11 +3102,12 @@ export function ConnectionsSettings() { onRemove={handleRemoveSavedBackend} /> ))} + {savedEnvironmentIds.length === 0 ? (

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

diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 0ccdf58512b..c9253f4321f 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(), diff --git a/docs/t3-code-cloud-auth-flow.html b/docs/t3-code-cloud-auth-flow.html index 67d01f2a75f..c851ade3699 100644 --- a/docs/t3-code-cloud-auth-flow.html +++ b/docs/t3-code-cloud-auth-flow.html @@ -480,7 +480,7 @@

Minimal Credential Set

4. Local server authority
Local tokens carrying - relay:manage mint single-use bootstrap credentials. + relay:write mint single-use bootstrap credentials.

5. Managed endpoint runtime credential
Endpoint runtime credential diff --git a/infra/relay/README.md b/infra/relay/README.md new file mode 100644 index 00000000000..5fe56d8154c --- /dev/null +++ b/infra/relay/README.md @@ -0,0 +1,96 @@ +# 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 +bun install +cd infra/relay +bun run test +bun run typecheck +``` + +To run a smaller test set while iterating: + +```sh +bun run test src/environments/EnvironmentLinker.test.ts +``` + +Before considering a change complete, run the repository-wide checks from the root: + +```sh +bun fmt +bun lint +bun 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 +cd infra/relay +bun run deploy +``` + +The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database +connectivity, and relay tracing resources. Runtime secrets include Clerk and APNs credentials. + +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/migrations/postgres/20260527044716_baseline/migration.sql b/infra/relay/migrations/postgres/20260527044716_baseline/migration.sql index 694885f380a..0871aaa2635 100644 --- a/infra/relay/migrations/postgres/20260527044716_baseline/migration.sql +++ b/infra/relay/migrations/postgres/20260527044716_baseline/migration.sql @@ -78,6 +78,7 @@ CREATE TABLE "relay_live_activities" ( CREATE TABLE "relay_mobile_devices" ( "user_id" varchar(255), "device_id" varchar(255), + "label" text DEFAULT 'iOS device' NOT NULL, "platform" varchar(16) NOT NULL, "ios_major_version" integer NOT NULL, "app_version" varchar(64), @@ -101,4 +102,4 @@ CREATE INDEX "idx_relay_live_activities_user" ON "relay_live_activities" ("user_ 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 +CREATE UNIQUE INDEX "idx_relay_mobile_devices_push_to_start_token" ON "relay_mobile_devices" ("push_to_start_token"); diff --git a/infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json b/infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json index aa35a4f8eee..02f9d7d1492 100644 --- a/infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json +++ b/infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json @@ -787,6 +787,19 @@ "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, diff --git a/infra/relay/src/agentActivity/Devices.test.ts b/infra/relay/src/agentActivity/Devices.test.ts index 06946a0d7e6..7a3b227703f 100644 --- a/infra/relay/src/agentActivity/Devices.test.ts +++ b/infra/relay/src/agentActivity/Devices.test.ts @@ -11,6 +11,7 @@ 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"], @@ -157,4 +158,63 @@ describe("Devices", () => { ]); }).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 index e23f22f8254..417dba80dd0 100644 --- a/infra/relay/src/agentActivity/Devices.ts +++ b/infra/relay/src/agentActivity/Devices.ts @@ -1,4 +1,7 @@ -import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; +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"; @@ -22,6 +25,10 @@ export class DeviceUnregistrationPersistenceError extends Data.TaggedError( readonly cause: unknown; }> {} +export class DeviceListPersistenceError extends Data.TaggedError("DeviceListPersistenceError")<{ + readonly cause: unknown; +}> {} + export interface DevicesShape { readonly register: (input: { readonly userId: string; @@ -31,6 +38,9 @@ export interface DevicesShape { readonly userId: string; readonly deviceId: string; }) => Effect.Effect; + readonly listForUser: (input: { + readonly userId: string; + }) => Effect.Effect, DeviceListPersistenceError>; } export class Devices extends Context.Service()( @@ -72,6 +82,7 @@ const make = Effect.gen(function* () { .values({ userId: input.userId, deviceId: registration.deviceId, + label: registration.label, platform: registration.platform, iosMajorVersion: registration.iosMajorVersion, appVersion: registration.appVersion ?? null, @@ -85,6 +96,7 @@ const make = Effect.gen(function* () { 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})`, @@ -128,6 +140,41 @@ const make = Effect.gen(function* () { }, 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 })), + ), }); }); diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index faf1dca875a..d548b925ef1 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -24,6 +24,7 @@ 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"], @@ -41,6 +42,7 @@ function makeDevices(overrides: Partial = {}): Devices.Dev return { register: () => Effect.void, unregister: () => Effect.void, + listForUser: () => Effect.succeed([]), ...overrides, }; } diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index d007c3b211e..451c198169b 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -413,6 +413,7 @@ export const clientApi = HttpApiBuilder.group( const linker = yield* EnvironmentLinker.EnvironmentLinker; const links = yield* EnvironmentLinks.EnvironmentLinks; const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; + const devices = yield* Devices.Devices; return handlers .handle( "listEnvironments", @@ -422,6 +423,13 @@ export const clientApi = HttpApiBuilder.group( 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")( @@ -803,6 +811,7 @@ const currentTraceId = Effect.currentParentSpan.pipe( const COMMON_AUTH_INVALID_REASONS = [ Devices.DeviceRegistrationPersistenceError, Devices.DeviceUnregistrationPersistenceError, + Devices.DeviceListPersistenceError, LiveActivities.LiveActivityRegistrationPersistenceError, EnvironmentLinks.EnvironmentLinkUserListPersistenceError, EnvironmentLinks.EnvironmentPublicKeyListPersistenceError, diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index eb465869578..cc77895bdca 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -20,6 +20,7 @@ export const relayMobileDevices = pgTable( { 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 }), diff --git a/packages/client-runtime/src/managedRelay.test.ts b/packages/client-runtime/src/managedRelay.test.ts index 4eefdb29d0f..412981d9581 100644 --- a/packages/client-runtime/src/managedRelay.test.ts +++ b/packages/client-runtime/src/managedRelay.test.ts @@ -111,4 +111,51 @@ describe("ManagedRelayClient", () => { }); }).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 index 90d41a7ede4..4ac2d34a62b 100644 --- a/packages/client-runtime/src/managedRelay.ts +++ b/packages/client-runtime/src/managedRelay.ts @@ -2,6 +2,7 @@ import { RelayAccessTokenType, RelayApi, type RelayClientEnvironmentRecord, + type RelayClientDeviceRecord, RelayConnectEnvironmentEndpoint, type RelayDeviceRegistrationRequest, type RelayDpopAccessTokenScope, @@ -89,6 +90,9 @@ export interface ManagedRelayClientShape { 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; @@ -330,6 +334,18 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) ), 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({ diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 064d38499b9..10c9587a0f1 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -316,9 +316,15 @@ export const EnvironmentCloudLinkStateResult = Schema.Struct({ 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, }); @@ -464,6 +470,14 @@ export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud") 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, diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index f0397c1596c..9251f22afbf 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -37,6 +37,7 @@ export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferenc export const RelayDeviceRegistrationRequest = Schema.Struct({ deviceId: TrimmedNonEmptyString, + label: TrimmedNonEmptyString, platform: RelayAgentAwarenessPlatform, iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), appVersion: Schema.optional(TrimmedNonEmptyString), @@ -46,6 +47,31 @@ export const RelayDeviceRegistrationRequest = Schema.Struct({ }); 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, @@ -769,6 +795,11 @@ export const RelayClientGroup = HttpApiGroup.make("client") success: RelayListEnvironmentsResponse, error: RelayAuthAndInternalErrors, }), + HttpApiEndpoint.get("listDevices", "/v1/client/devices", { + headers: RelayBearerRequestHeaders, + success: RelayListDevicesResponse, + error: RelayAuthAndInternalErrors, + }), HttpApiEndpoint.post("linkEnvironment", "/v1/client/environment-links", { headers: RelayBearerRequestHeaders, payload: RelayEnvironmentLinkRequest, From 4453bd0fd930bac0bfc6b972774a5afe420761c1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 17:40:46 -0700 Subject: [PATCH 12/61] refactor(cloud): retain relay queries with atoms Co-authored-by: codex --- apps/mobile/src/app/settings/environments.tsx | 111 +- apps/mobile/src/app/settings/index.tsx | 3 + .../src/features/cloud/CloudAuthProvider.tsx | 12 + .../features/cloud/linkEnvironment.test.ts | 1 + .../src/features/cloud/linkEnvironment.ts | 1 + .../src/features/cloud/managedRelayState.ts | 88 ++ .../connection/ConnectionEnvironmentRow.tsx | 102 +- apps/mobile/src/lib/connection.test.ts | 15 +- apps/mobile/src/lib/connection.ts | 7 + apps/mobile/src/state/remote-runtime-types.ts | 1 + .../state/use-remote-environment-registry.ts | 9 +- apps/server/src/http.ts | 18 +- apps/server/src/server.test.ts | 40 +- apps/web/src/cloud/linkEnvironment.ts | 16 +- apps/web/src/cloud/managedAuth.tsx | 17 +- apps/web/src/cloud/managedRelayState.ts | 83 ++ apps/web/src/cloud/primaryCloudLinkState.ts | 67 + .../src/components/settings/CloudSettings.tsx | 185 +-- .../settings/ConnectionsSettings.tsx | 243 ++-- .../settings/SettingsPanels.browser.tsx | 4 +- .../src/environments/primary/requestInit.ts | 7 + apps/web/src/lib/runtime.ts | 3 +- infra/relay/alchemy.run.ts | 2 - .../20260527044716_baseline/migration.sql | 3 +- .../20260527044716_baseline/snapshot.json | 13 - .../migration.sql | 1 + .../snapshot.json | 1280 +++++++++++++++++ infra/relay/src/db.ts | 2 +- packages/client-runtime/src/index.ts | 1 + .../src/managedRelayState.test.ts | 144 ++ .../client-runtime/src/managedRelayState.ts | 253 ++++ patches/alchemy@2.0.0-beta.49.patch | 356 +++++ pnpm-workspace.yaml | 1 + 33 files changed, 2734 insertions(+), 355 deletions(-) create mode 100644 apps/mobile/src/features/cloud/managedRelayState.ts create mode 100644 apps/web/src/cloud/managedRelayState.ts create mode 100644 apps/web/src/cloud/primaryCloudLinkState.ts create mode 100644 apps/web/src/environments/primary/requestInit.ts create mode 100644 infra/relay/migrations/postgres/20260601225421_add_mobile_device_label/migration.sql create mode 100644 infra/relay/migrations/postgres/20260601225421_add_mobile_device_label/snapshot.json create mode 100644 packages/client-runtime/src/managedRelayState.test.ts create mode 100644 packages/client-runtime/src/managedRelayState.ts create mode 100644 patches/alchemy@2.0.0-beta.49.patch diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index ba6ddeea986..d5a503bc624 100644 --- a/apps/mobile/src/app/settings/environments.tsx +++ b/apps/mobile/src/app/settings/environments.tsx @@ -2,20 +2,19 @@ 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 { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; import * as Effect from "effect/Effect"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +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 { - cloudEnvironmentsPendingStatus, - type CloudEnvironmentRecordWithStatus, - connectCloudEnvironment, - listCloudEnvironments, - loadCloudEnvironmentStatuses, -} from "../../features/cloud/linkEnvironment"; + useManagedRelayEnvironments, + useManagedRelayEnvironmentStatus, +} from "../../features/cloud/managedRelayState"; import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; import { cn } from "../../lib/cn"; import { mobileRuntime } from "../../lib/runtime"; @@ -27,9 +26,7 @@ import { } from "../../state/use-remote-environment-registry"; export default function SettingsEnvironmentsRouteScreen() { - const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const getTokenRef = useRef(getToken); - getTokenRef.current = getToken; + const { getToken, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { connectedEnvironments, onReconnectEnvironment, @@ -40,11 +37,7 @@ export default function SettingsEnvironmentsRouteScreen() { const insets = useSafeAreaInsets(); const hasEnvironments = connectedEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); - const [cloudEnvironments, setCloudEnvironments] = useState< - ReadonlyArray - >([]); - const [cloudStatus, setCloudStatus] = useState<"idle" | "loading" | "error">("idle"); - const [cloudError, setCloudError] = useState(null); + const cloudEnvironmentsState = useManagedRelayEnvironments(); const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState( null, ); @@ -58,53 +51,15 @@ export default function SettingsEnvironmentsRouteScreen() { const availableCloudEnvironments = useMemo( () => - cloudEnvironments.filter( - (record) => savedConnectionsById[record.environment.environmentId] === undefined, + (cloudEnvironmentsState.data ?? []).filter( + (environment) => savedConnectionsById[environment.environmentId] === undefined, ), - [cloudEnvironments, savedConnectionsById], + [cloudEnvironmentsState.data, savedConnectionsById], ); - const refreshCloudEnvironments = useCallback(async () => { - if (!isLoaded || !isSignedIn) { - setCloudEnvironments([]); - setCloudStatus("idle"); - setCloudError(null); - return; - } - - setCloudStatus("loading"); - setCloudError(null); - try { - const token = await getTokenRef.current(RELAY_CLERK_TOKEN_OPTIONS); - if (!token) { - setCloudEnvironments([]); - setCloudStatus("idle"); - return; - } - const environments = await mobileRuntime.runPromise( - listCloudEnvironments({ clerkToken: token }), - ); - setCloudEnvironments(cloudEnvironmentsPendingStatus(environments)); - const records = await mobileRuntime.runPromise( - loadCloudEnvironmentStatuses({ clerkToken: token, environments }), - ); - setCloudEnvironments(records); - setCloudStatus("idle"); - } catch (error) { - setCloudStatus("error"); - setCloudError( - error instanceof Error ? error.message : "Could not load T3 Cloud environments.", - ); - } - }, [isLoaded, isSignedIn]); - - useEffect(() => { - void refreshCloudEnvironments(); - }, [refreshCloudEnvironments]); - const handleConnectCloudEnvironment = useCallback( - async (record: CloudEnvironmentRecordWithStatus) => { - setConnectingCloudEnvironmentId(record.environment.environmentId); + async (environment: RelayClientEnvironmentRecord) => { + setConnectingCloudEnvironmentId(environment.environmentId); try { const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS); if (!token) { @@ -113,14 +68,9 @@ export default function SettingsEnvironmentsRouteScreen() { await mobileRuntime.runPromise( connectCloudEnvironment({ clerkToken: token, - environment: record.environment, + environment, }).pipe(Effect.flatMap(connectSavedEnvironment)), ); - setCloudEnvironments((records) => - records.filter( - (candidate) => candidate.environment.environmentId !== record.environment.environmentId, - ), - ); } catch (error) { Alert.alert( "Connect failed", @@ -214,11 +164,11 @@ export default function SettingsEnvironmentsRouteScreen() { - {cloudStatus === "loading" ? ( + {cloudEnvironmentsState.isPending ? ( ) : ( 0 ? ( - {availableCloudEnvironments.map((record, index) => ( + {availableCloudEnvironments.map((environment, index) => ( handleConnectCloudEnvironment(record)} + isConnecting={connectingCloudEnvironmentId === environment.environmentId} + onConnect={() => handleConnectCloudEnvironment(environment)} /> ))} - ) : cloudStatus === "loading" ? ( + ) : cloudEnvironmentsState.data === null ? ( - ) : cloudStatus === "error" ? ( + ) : cloudEnvironmentsState.error ? ( Could not load T3 Cloud environments - {cloudError} + {cloudEnvironmentsState.error} ) : ( @@ -277,17 +227,18 @@ export default function SettingsEnvironmentsRouteScreen() { } function CloudEnvironmentRow(props: { - readonly record: CloudEnvironmentRecordWithStatus; + readonly environment: RelayClientEnvironmentRecord; readonly borderTop: boolean; readonly isConnecting: boolean; readonly onConnect: () => void; }) { const mutedColor = useThemeColor("--color-icon-muted"); - const { environment, status, statusError } = props.record; + const statusState = useManagedRelayEnvironmentStatus(props.environment); + const status = statusState.data; const disabled = props.isConnecting; const statusText = status === null - ? (statusError ?? "Status unavailable") + ? (statusState.error ?? (statusState.isPending ? "Checking status..." : "Status unavailable")) : status.status === "online" ? "Online" : (status.error ?? "Offline"); @@ -311,10 +262,10 @@ function CloudEnvironmentRow(props: { - {environment.label} + {props.environment.label} - {environment.endpoint.httpBaseUrl} + {props.environment.endpoint.httpBaseUrl} {statusText} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 917c8db8a2b..85d09a1851d 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -13,6 +13,7 @@ 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 { mobileRuntime } from "../../lib/runtime"; import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -151,6 +152,7 @@ export default function SettingsRouteScreen() { connections, }), ); + refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); Alert.alert( "Live Activities enabled", @@ -200,6 +202,7 @@ export default function SettingsRouteScreen() { connections, }), ); + refreshManagedRelayEnvironments(); } catch { // The switch is optimistic; a future refresh reconciles relay state. } diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index 8748117a3af..aada03d5c98 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -1,10 +1,12 @@ import { ClerkProvider, useAuth } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; +import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; import Constants from "expo-constants"; import { type ReactNode, useEffect, useRef } from "react"; import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; import { mobileRuntime } from "../../lib/runtime"; +import { appAtomRegistry } from "../../state/atom-registry"; import { setAgentAwarenessRelayTokenProvider, unregisterAgentAwarenessDeviceForCurrentUser, @@ -38,6 +40,7 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { .catch(() => undefined); } setAgentAwarenessRelayTokenProvider(null); + setManagedRelaySession(appAtomRegistry, null); return; } @@ -50,6 +53,13 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { const tokenProvider = () => getToken(RELAY_CLERK_TOKEN_OPTIONS); previousTokenProviderRef.current = { userId, provider: tokenProvider }; setAgentAwarenessRelayTokenProvider(tokenProvider, userId); + setManagedRelaySession( + appAtomRegistry, + createManagedRelaySession({ + accountId: userId, + readClerkToken: tokenProvider, + }), + ); if (!previous || previous.userId !== userId) { void mobileRuntime .runPromise(refreshActiveLiveActivityRemoteRegistration()) @@ -61,6 +71,7 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { () => () => { previousTokenProviderRef.current = null; setAgentAwarenessRelayTokenProvider(null); + setManagedRelaySession(appAtomRegistry, null); }, [], ); @@ -74,6 +85,7 @@ export function CloudAuthProvider(props: { readonly children: ReactNode }) { useEffect(() => { if (!publishableKey) { setAgentAwarenessRelayTokenProvider(null); + setManagedRelaySession(appAtomRegistry, null); } }, [publishableKey]); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index e2e3df4eb6a..95a603a0081 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -825,6 +825,7 @@ describe("mobile cloud link environment client", () => { 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", diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index e13e31bf196..e4d24b132dc 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -547,6 +547,7 @@ export function connectCloudEnvironment(input: { bearerToken: null, authenticationMethod: "dpop", dpopAccessToken: bootstrap.access_token, + relayManaged: true, }; }); } 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/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 + + + )} ({ mobileRuntime: { @@ -39,4 +43,13 @@ 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); + }); }); diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index 8500a808734..8bfd34a10ef 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -24,6 +24,7 @@ export interface SavedRemoteConnection { readonly bearerToken: string | null; readonly authenticationMethod?: "bearer" | "dpop"; readonly dpopAccessToken?: string; + readonly relayManaged?: true; } export type RemoteClientConnectionState = @@ -42,6 +43,12 @@ export function redactPairingCredential(pairingUrl: string): string { } } +export function isRelayManagedConnection( + connection: Pick, +): boolean { + return connection.relayManaged === true || connection.authenticationMethod === "dpop"; +} + export async function bootstrapRemoteConnection( input: RemoteConnectionInput, ): Promise { 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.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 97d95609a61..d0900c05fb4 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -23,7 +23,11 @@ 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, +} from "../lib/connection"; import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; import { clearCachedShellSnapshot, @@ -444,6 +448,7 @@ function deriveConnectedEnvironments( environmentId: connection.environmentId, environmentLabel: connection.environmentLabel, displayUrl: connection.displayUrl, + isRelayManaged: isRelayManagedConnection(connection), connectionState: runtime?.connectionState ?? "idle", connectionError: runtime?.connectionError ?? null, }; @@ -612,7 +617,7 @@ export function useRemoteConnections() { updates: { readonly label: string; readonly displayUrl: string }, ) => { const connection = getSavedConnectionsById()[environmentId]; - if (!connection) { + if (!connection || isRelayManagedConnection(connection)) { return; } 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 = ` 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", @@ -3166,6 +3178,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).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(); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 8888d40020e..b6981e06f60 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -1,7 +1,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { HttpClient } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, type EnvironmentCloudLinkStateResult, @@ -36,6 +36,7 @@ import { readPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, } from "../environments/primary"; +import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit"; export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { const trimmed = value?.trim(); @@ -142,9 +143,6 @@ const environmentApiError = (message: string) => (cause: unknown) => { }); }; -const withPrimaryEnvironmentCookies = (effect: Effect.Effect) => - effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" })); - function endpointOrigin(httpBaseUrl: string) { const url = new URL(httpBaseUrl); return { @@ -399,7 +397,7 @@ export function readPrimaryCloudLinkState(): Effect.Effect< return yield* client.cloud .linkState({ headers: {} }) .pipe( - withPrimaryEnvironmentCookies, + withPrimaryEnvironmentRequestInit, Effect.mapError(environmentApiError("Could not read environment cloud link state.")), ); }); @@ -416,7 +414,7 @@ export function updatePrimaryCloudPreferences(input: { payload: input, }) .pipe( - withPrimaryEnvironmentCookies, + withPrimaryEnvironmentRequestInit, Effect.mapError(environmentApiError("Could not update environment cloud preferences.")), ); }); @@ -453,7 +451,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { yield* client.cloud .unlink({ headers: {} }) .pipe( - withPrimaryEnvironmentCookies, + withPrimaryEnvironmentRequestInit, Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")), ); }); @@ -605,7 +603,7 @@ export function linkPrimaryEnvironmentToCloud(input: { }, }) .pipe( - withPrimaryEnvironmentCookies, + withPrimaryEnvironmentRequestInit, Effect.mapError(environmentApiError("Could not obtain environment link proof.")), ); const link = yield* relayClient @@ -642,7 +640,7 @@ export function linkPrimaryEnvironmentToCloud(input: { }, }) .pipe( - withPrimaryEnvironmentCookies, + 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 index 9bc3a69a3a2..df4e06a97c6 100644 --- a/apps/web/src/cloud/managedAuth.tsx +++ b/apps/web/src/cloud/managedAuth.tsx @@ -1,7 +1,10 @@ import { useAuth } from "@clerk/react"; +import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; import { useEffect, type ReactNode } from "react"; +import { appAtomRegistry } from "../rpc/atomRegistry"; + let relayTokenProvider: (() => Promise) | null = null; export async function readManagedRelayClerkToken(): Promise { @@ -9,14 +12,24 @@ export async function readManagedRelayClerkToken(): Promise { } export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) { - const { getToken, isSignedIn } = useAuth(); + const { getToken, isSignedIn, userId } = useAuth(); useEffect(() => { relayTokenProvider = isSignedIn ? () => getToken(RELAY_CLERK_TOKEN_OPTIONS) : null; + setManagedRelaySession( + appAtomRegistry, + isSignedIn && userId + ? createManagedRelaySession({ + accountId: userId, + readClerkToken: () => getToken(RELAY_CLERK_TOKEN_OPTIONS), + }) + : null, + ); return () => { relayTokenProvider = null; + setManagedRelaySession(appAtomRegistry, null); }; - }, [getToken, isSignedIn]); + }, [getToken, isSignedIn, userId]); return children; } 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/components/settings/CloudSettings.tsx b/apps/web/src/components/settings/CloudSettings.tsx index 3683ff275ff..d44032d1e27 100644 --- a/apps/web/src/components/settings/CloudSettings.tsx +++ b/apps/web/src/components/settings/CloudSettings.tsx @@ -1,29 +1,58 @@ import { UserButton, Waitlist, useAuth, useClerk } from "@clerk/react"; import { useSignIn, useSignUp } from "@clerk/react/legacy"; import { AuthRelayWriteScope } from "@t3tools/contracts"; -import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; -import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; -import { CloudIcon } from "lucide-react"; +import { CloudIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { type DesktopCloudAuthOAuthStrategy, resolveDesktopCloudAuthOAuthOptions, } from "../../cloud/desktopAuth"; -import { - listCloudDevices, - readPrimaryCloudLinkState, - type CloudLinkState, - updatePrimaryCloudPreferences, -} from "../../cloud/linkEnvironment"; +import { updatePrimaryCloudPreferences } from "../../cloud/linkEnvironment"; +import { useManagedRelayDevices } from "../../cloud/managedRelayState"; +import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; import { isElectron } from "../../env"; -import { fetchSessionState, usePrimaryEnvironmentId } from "../../environments/primary"; +import { usePrimarySessionState } from "../../environments/primary"; import { webRuntime } from "../../lib/runtime"; +import { cn } from "../../lib/utils"; 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 hasClerkConfig(): boolean { return Boolean(import.meta.env.VITE_CLERK_PUBLISHABLE_KEY); } @@ -99,69 +128,30 @@ function CloudWaitlistPanel() { } function CloudSettingsPanelInner() { - const { getToken } = useAuth(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const [primaryLinkState, setPrimaryLinkState] = useState(null); - const [devices, setDevices] = useState>([]); - const [isLoadingDevices, setIsLoadingDevices] = useState(false); + const primaryLinkState = usePrimaryCloudLinkState(); + const primarySessionState = usePrimarySessionState(); + const devicesState = useManagedRelayDevices(); const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); - const [canManageRelay, setCanManageRelay] = useState(false); - - const refreshPrimaryLinkState = useCallback(() => { - if (!primaryEnvironmentId) { - setPrimaryLinkState(null); - return; - } - void webRuntime - .runPromise(readPrimaryCloudLinkState()) - .then(setPrimaryLinkState, () => setPrimaryLinkState(null)); - }, [primaryEnvironmentId]); + const devices = devicesState.data ?? []; + const canManageRelay = + primarySessionState.data?.authenticated === true && + Boolean(primarySessionState.data.scopes?.includes(AuthRelayWriteScope)); useEffect(() => { - refreshPrimaryLinkState(); - }, [refreshPrimaryLinkState]); - - useEffect(() => { - void fetchSessionState() - .then((session) => - setCanManageRelay( - session.authenticated && Boolean(session.scopes?.includes(AuthRelayWriteScope)), - ), - ) - .catch(() => setCanManageRelay(false)); - }, []); - - const refreshDevices = useCallback(async () => { - setIsLoadingDevices(true); - try { - const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS); - if (!token) { - setDevices([]); - return; - } - setDevices(await webRuntime.runPromise(listCloudDevices({ clerkToken: token }))); - } catch (error) { + if (devicesState.error) { toastManager.add({ type: "error", title: "Cloud devices unavailable", - description: cloudErrorMessage(error, "Could not load notification devices."), + description: devicesState.error, }); - } finally { - setIsLoadingDevices(false); } - }, [getToken]); - - useEffect(() => { - void refreshDevices(); - }, [refreshDevices]); + }, [devicesState.error]); const updatePublishAgentActivity = async (enabled: boolean) => { setIsUpdatingPreference(true); try { - const state = await webRuntime.runPromise( - updatePrimaryCloudPreferences({ publishAgentActivity: enabled }), - ); - setPrimaryLinkState(state); + await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled })); + primaryLinkState.refresh(); toastManager.add({ type: "success", title: enabled ? "Agent activity enabled" : "Agent activity disabled", @@ -194,13 +184,13 @@ function CloudSettingsPanelInner() { title="Publish agent activity" description="Allow this environment to send agent activity to your notification devices." status={ - !primaryLinkState?.linked ? "Link this environment from Connections first." : null + !primaryLinkState.data?.linked ? "Link this environment from Connections first." : null } control={ void updatePublishAgentActivity(enabled)} /> } @@ -209,36 +199,47 @@ function CloudSettingsPanelInner() { void refreshDevices()} - > - {isLoadingDevices ? "Refreshing..." : "Refresh"} - + + + + + } + /> + Refresh notification devices + } > - {devices.map((device) => ( - - ))} - {!isLoadingDevices && devices.length === 0 ? ( - - ) : null} + {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 790aadbc97d..07b170f173a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -69,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 { @@ -94,6 +96,7 @@ import { revokeServerClientSession, revokeServerPairingLink, isLoopbackHostname, + usePrimaryEnvironmentId, usePrimarySessionState, type ServerClientSessionRecord, type ServerPairingLinkRecord, @@ -117,10 +120,13 @@ import { useServerConfig } from "~/rpc/serverState"; import { connectManagedCloudEnvironment, linkPrimaryEnvironmentToCloud, - listManagedCloudEnvironments, - readPrimaryCloudLinkState, unlinkPrimaryEnvironmentFromCloud, } from "~/cloud/linkEnvironment"; +import { + refreshManagedRelayEnvironments, + useManagedRelayEnvironments, +} from "~/cloud/managedRelayState"; +import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; import { webRuntime } from "~/lib/runtime"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; @@ -1598,34 +1604,44 @@ 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 [linked, setLinked] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - const refresh = useCallback(() => { - setIsLoading(true); - void webRuntime.runPromise(readPrimaryCloudLinkState()).then( - (state) => { - setLinked(state?.linked ?? false); - setError(null); - setIsLoading(false); - }, - (cause: unknown) => { - setError(cause instanceof Error ? cause.message : "Could not read T3 Cloud link state."); - setIsLoading(false); - }, - ); - }, []); - - useEffect(() => { - refresh(); - }, [refresh]); + const primaryCloudLinkState = usePrimaryCloudLinkState(); + const [operationError, setOperationError] = useState(null); + const [isUpdating, setIsUpdating] = useState(false); const updateLink = async (enabled: boolean) => { - setIsLoading(true); - setError(null); + setIsUpdating(true); + setOperationError(null); try { const clerkToken = await getToken(RELAY_CLERK_TOKEN_OPTIONS); if (enabled) { @@ -1638,7 +1654,8 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b unlinkPrimaryEnvironmentFromCloud({ clerkToken: clerkToken ?? null }), ); } - setLinked(enabled); + primaryCloudLinkState.refresh(); + refreshManagedRelayEnvironments(); toastManager.add({ type: "success", title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", @@ -1648,27 +1665,32 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b }); } catch (cause) { const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; - setError(message); + setOperationError(message); toastManager.add({ type: "error", title: "Could not update T3 Cloud", description: message, }); } finally { - setIsLoading(false); + 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; return ( void updateLink(enabled)} /> } @@ -1683,47 +1705,62 @@ function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) } + control={ + + } /> ); } +function EmptyRemoteEnvironments() { + return ( + + + + + + No saved remote environments + + Click “Add environment” to pair another environment, or connect one from T3 + Cloud. + + + + ); +} + +function RemoteEnvironmentRowsSkeleton() { + return ( +
+
+
+ + +
+ +
+
+ ); +} + function ConfiguredCloudRemoteEnvironmentRows({ + primaryEnvironmentId, savedEnvironmentIds, }: { + readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironmentIds: ReadonlyArray; }) { - const { getToken, isSignedIn } = useAuth(); - const [environments, setEnvironments] = useState>([]); + const { getToken } = useAuth(); + const environmentsState = useManagedRelayEnvironments(); const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( null, ); const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); - useEffect(() => { - if (!isSignedIn) { - setEnvironments([]); - return; - } - let cancelled = false; - void getToken(RELAY_CLERK_TOKEN_OPTIONS) - .then((clerkToken) => - clerkToken - ? webRuntime.runPromise(listManagedCloudEnvironments({ clerkToken })) - : Promise.resolve([]), - ) - .then((next) => { - if (!cancelled) setEnvironments(next); - }) - .catch(() => { - if (!cancelled) setEnvironments([]); - }); - return () => { - cancelled = true; - }; - }, [getToken, isSignedIn]); - const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { setConnectingEnvironmentId(environment.environmentId); try { @@ -1752,45 +1789,65 @@ function ConfiguredCloudRemoteEnvironmentRows({ } }; - return environments - .filter((environment) => !savedIds.has(environment.environmentId)) - .map((environment) => ( -
-
-
-
- -

{environment.label}

-
-

T3 Cloud

+ 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 hasCloudConfig() ? ( - + + ) : savedEnvironmentIds.length === 0 ? ( + ) : null; } export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; + const primaryEnvironmentId = usePrimaryEnvironmentId(); const primarySessionState = usePrimarySessionState(); const currentSessionScopes = desktopBridge ? AuthAdministrativeScopes @@ -2815,7 +2872,7 @@ export function ConnectionsSettings() { {canManageLocalBackend ? ( <> - + {primaryVersionMismatch ? ( ) : ( - + ))} - - - {savedEnvironmentIds.length === 0 ? ( -
-

- No saved 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 c9253f4321f..e8f46505edc 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -574,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/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/lib/runtime.ts b/apps/web/src/lib/runtime.ts index abb885e3bb9..34f6d8264af 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -8,6 +8,7 @@ import { PrimaryEnvironmentHttpClient, primaryEnvironmentHttpClientLive, } from "../environments/primary/httpClient"; +import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; import { browserCryptoLayer } from "../cloud/dpop"; import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer"; @@ -26,7 +27,7 @@ const primaryHttpRuntime = ManagedRuntime.make( Layer.provide( Layer.mergeAll( remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), - Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }), + Layer.succeed(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), ), ), ), diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index 4ec6b5abaae..e9a3928758c 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -6,7 +6,6 @@ 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 * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; import { ManagedEndpointZone } from "./src/zone.ts"; @@ -20,7 +19,6 @@ export default Alchemy.Stack( Cloudflare.providers(), Drizzle.providers(), Planetscale.providers(), - FetchHttpClient.layer, ), state: Cloudflare.state(), }, diff --git a/infra/relay/migrations/postgres/20260527044716_baseline/migration.sql b/infra/relay/migrations/postgres/20260527044716_baseline/migration.sql index 0871aaa2635..694885f380a 100644 --- a/infra/relay/migrations/postgres/20260527044716_baseline/migration.sql +++ b/infra/relay/migrations/postgres/20260527044716_baseline/migration.sql @@ -78,7 +78,6 @@ CREATE TABLE "relay_live_activities" ( CREATE TABLE "relay_mobile_devices" ( "user_id" varchar(255), "device_id" varchar(255), - "label" text DEFAULT 'iOS device' NOT NULL, "platform" varchar(16) NOT NULL, "ios_major_version" integer NOT NULL, "app_version" varchar(64), @@ -102,4 +101,4 @@ CREATE INDEX "idx_relay_live_activities_user" ON "relay_live_activities" ("user_ 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"); +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 index 02f9d7d1492..aa35a4f8eee 100644 --- a/infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json +++ b/infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json @@ -787,19 +787,6 @@ "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, 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/src/db.ts b/infra/relay/src/db.ts index a01ec05c0a0..e4094240d9f 100644 --- a/infra/relay/src/db.ts +++ b/infra/relay/src/db.ts @@ -32,7 +32,7 @@ export const PlanetscaleDatabase = Effect.gen(function* () { inheritedRoles: ["pg_read_all_data", "pg_write_all_data"], }); - return { database, runtimeRole, schema }; + return { database, runtimeRole }; }); export const RelayHyperdrive = Effect.gen(function* () { diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts index 7aa36d29fb5..ac32e794fe4 100644 --- a/packages/client-runtime/src/index.ts +++ b/packages/client-runtime/src/index.ts @@ -27,3 +27,4 @@ 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/managedRelayState.test.ts b/packages/client-runtime/src/managedRelayState.test.ts new file mode 100644 index 00000000000..e6c51bb808d --- /dev/null +++ b/packages/client-runtime/src/managedRelayState.test.ts @@ -0,0 +1,144 @@ +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, + readManagedRelaySnapshotState, + setManagedRelaySession, +} 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("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..2bbebd155ab --- /dev/null +++ b/packages/client-runtime/src/managedRelayState.ts @@ -0,0 +1,253 @@ +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 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 session.readClerkToken().pipe( + Effect.flatMap((token) => + token + ? Effect.succeed(token) + : Effect.fail( + new ManagedRelaySessionError({ + message: "The T3 Cloud session token is unavailable.", + }), + ), + ), + ); +} + +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/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-workspace.yaml b/pnpm-workspace.yaml index 644bb663dec..ad15dce3ae3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -67,5 +67,6 @@ packageExtensions: patchedDependencies: "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch "@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 From ce7a03bb8fbe0dfb3b961b49984985dc551145b2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 18:11:06 -0700 Subject: [PATCH 13/61] fix(mobile): refresh cloud environment credentials on reconnect Co-authored-by: codex --- .../features/cloud/linkEnvironment.test.ts | 76 ++++++++++++++++ .../src/features/cloud/linkEnvironment.ts | 50 +++++++++-- apps/mobile/src/lib/connection.test.ts | 19 ++++ apps/mobile/src/lib/connection.ts | 11 +++ apps/mobile/src/lib/storage.test.ts | 72 +++++++++++++++ apps/mobile/src/lib/storage.ts | 18 ++-- .../use-remote-environment-registry.test.ts | 89 ++++++++++++++++++- .../state/use-remote-environment-registry.ts | 46 ++++++++-- .../src/managedRelayState.test.ts | 11 +++ .../client-runtime/src/managedRelayState.ts | 58 +++++++++--- 10 files changed, 411 insertions(+), 39 deletions(-) create mode 100644 apps/mobile/src/lib/storage.test.ts diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 95a603a0081..f99a5addcce 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -19,6 +19,7 @@ import { listCloudEnvironments, listCloudEnvironmentsWithStatus, normalizeRelayBaseUrl, + refreshCloudEnvironmentConnection, } from "./linkEnvironment"; vi.mock("expo-constants", () => ({ @@ -843,6 +844,81 @@ describe("mobile cloud link environment client", () => { }), ); + 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( diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index e4d24b132dc..cd364637a1c 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -465,9 +465,10 @@ export function listCloudEnvironmentsWithStatus(input: { }); } -export function connectCloudEnvironment(input: { +function connectRelayManagedEnvironment(input: { readonly clerkToken: string; - readonly environment: RelayClientEnvironmentRecord; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly expectedEnvironment?: RelayClientEnvironmentRecord; }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, @@ -485,25 +486,27 @@ export function connectCloudEnvironment(input: { .connectEnvironment({ clerkToken: input.clerkToken, scopes: [RelayEnvironmentConnectScope], - environmentId: input.environment.environmentId, + environmentId: input.environmentId, deviceId, }) .pipe( Effect.mapError( decodedRelayClientError( - `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/connect failed`, + `${relayUrl}/v1/environments/${encodeURIComponent(input.environmentId)}/connect failed`, ), ), ); - if (connect.environmentId !== input.environment.environmentId) { + if (connect.environmentId !== input.environmentId) { return yield* new CloudEnvironmentLinkError({ message: "Relay returned credentials for a different environment.", }); } - yield* ensureConnectEndpointMatchesEnvironment({ - environment: input.environment, - connect, - }); + if (input.expectedEnvironment) { + yield* ensureConnectEndpointMatchesEnvironment({ + environment: input.expectedEnvironment, + connect, + }); + } const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: connect.endpoint.httpBaseUrl, @@ -551,3 +554,32 @@ export function connectCloudEnvironment(input: { }; }); } + +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/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 95141d6cc85..68813b0b3b1 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, mobileAuthClientMetadata, redactPairingCredential, + toStableSavedRemoteConnection, } from "./connection"; vi.mock("./runtime", () => ({ @@ -52,4 +54,21 @@ describe("mobile remote connection records", () => { 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 8bfd34a10ef..aa92c6f5d58 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -49,6 +49,17 @@ export function isRelayManagedConnection( 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( input: RemoteConnectionInput, ): Promise { 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 41702b02ff0..2f9e4962c1a 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -5,7 +5,11 @@ 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"; @@ -136,22 +140,22 @@ export async function loadSavedConnections(): Promise - !!c.environmentId && - (!!c.bearerToken?.trim() || - (c.authenticationMethod === "dpop" && !!c.dpopAccessToken?.trim())), + (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 })); } diff --git a/apps/mobile/src/state/use-remote-environment-registry.test.ts b/apps/mobile/src/state/use-remote-environment-registry.test.ts index bb2ab1c9d3b..3dccf6803ec 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.test.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; import { EnvironmentId } from "@t3tools/contracts"; -import { ManagedRelayDpopSigner } from "@t3tools/client-runtime"; +import { + createManagedRelaySession, + ManagedRelayDpopSigner, + setManagedRelaySession, +} from "@t3tools/client-runtime"; import * as Effect from "effect/Effect"; import { beforeEach, vi } from "vitest"; @@ -23,10 +27,11 @@ const mocks = vi.hoisted(() => { 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(() => 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"), @@ -71,10 +76,15 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { }; }); -vi.mock("../lib/connection", () => ({ +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, @@ -142,6 +152,7 @@ vi.mock("./use-terminal-session", () => ({ })); import { connectSavedEnvironment, disconnectEnvironment } from "./use-remote-environment-registry"; +import { appAtomRegistry } from "./atom-registry"; const environmentId = EnvironmentId.make("env-mobile-test"); @@ -165,9 +176,11 @@ describe("mobile remote environment registry effects", () => { mocks.removeEnvironmentSession.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", () => @@ -235,6 +248,76 @@ describe("mobile remote environment registry effects", () => { }), ); + 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( diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index d0900c05fb4..176ba284419 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -14,6 +14,7 @@ import { remoteEndpointUrl, resolveRemoteDpopWebSocketConnectionUrl, resolveRemoteWebSocketConnectionUrl, + waitForManagedRelayClerkToken, } from "@t3tools/client-runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Arr from "effect/Array"; @@ -27,7 +28,9 @@ import { type SavedRemoteConnection, bootstrapRemoteConnection, isRelayManagedConnection, + toStableSavedRemoteConnection, } from "../lib/connection"; +import { refreshCloudEnvironmentConnection } from "../features/cloud/linkEnvironment"; import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; import { clearCachedShellSnapshot, @@ -223,6 +226,9 @@ export function connectSavedEnvironment( 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, @@ -233,32 +239,54 @@ export function connectSavedEnvironment( } if (options?.persist !== false) { - yield* fromPromise(() => saveConnection(connection)); + yield* fromPromise(() => saveConnection(toStableSavedRemoteConnection(connection))); if (!isCurrentAttempt()) { return; } } - upsertSavedConnection(connection); + upsertSavedConnection(toStableSavedRemoteConnection(connection)); setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); shellSnapshotManager.markPending({ environmentId: connection.environmentId }); - const dpopAccessToken = - connection.authenticationMethod === "dpop" ? connection.dpopAccessToken : undefined; const transport = new WsTransport( () => mobileRuntime.runPromise( - dpopAccessToken + 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(connection.httpBaseUrl, "/api/auth/websocket-ticket"), + url: remoteEndpointUrl( + activeConnection.httpBaseUrl, + "/api/auth/websocket-ticket", + ), accessToken: dpopAccessToken, }); return yield* resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: connection.wsBaseUrl, - httpBaseUrl: connection.httpBaseUrl, + wsBaseUrl: activeConnection.wsBaseUrl, + httpBaseUrl: activeConnection.httpBaseUrl, accessToken: dpopAccessToken, dpopProof: dpop, }); @@ -423,7 +451,7 @@ export function connectSavedEnvironment( terminalDebugLog("registry:terminal-metadata-subscribed", { environmentId: connection.environmentId, }); - startAgentAwarenessForEnvironment(connection); + startAgentAwarenessForEnvironment(toStableSavedRemoteConnection(activeConnection)); notifyEnvironmentConnectionListeners(); }); } diff --git a/packages/client-runtime/src/managedRelayState.test.ts b/packages/client-runtime/src/managedRelayState.test.ts index e6c51bb808d..382fcae4cac 100644 --- a/packages/client-runtime/src/managedRelayState.test.ts +++ b/packages/client-runtime/src/managedRelayState.test.ts @@ -13,8 +13,10 @@ import { ManagedRelayClient, type ManagedRelayClientShape } from "./managedRelay import { createManagedRelayQueryManager, createManagedRelaySession, + managedRelaySessionAtom, readManagedRelaySnapshotState, setManagedRelaySession, + waitForManagedRelayClerkToken, } from "./managedRelayState.ts"; let registry = AtomRegistry.make(); @@ -93,6 +95,15 @@ function setSession() { 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 }); diff --git a/packages/client-runtime/src/managedRelayState.ts b/packages/client-runtime/src/managedRelayState.ts index 2bbebd155ab..7d50f0fdb7c 100644 --- a/packages/client-runtime/src/managedRelayState.ts +++ b/packages/client-runtime/src/managedRelayState.ts @@ -67,18 +67,9 @@ export function setManagedRelaySession( registry.set(managedRelaySessionAtom, session); } -function requireClerkToken( - get: Atom.AtomContext, - accountId: string, +function readSessionClerkToken( + session: ManagedRelaySession, ): 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 session.readClerkToken().pipe( Effect.flatMap((token) => token @@ -92,6 +83,51 @@ function requireClerkToken( ); } +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; From 5f4c05caa56d4cf73ba354c407c6619a8a608761 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 18:18:38 -0700 Subject: [PATCH 14/61] chore(cloud): clarify private beta settings copy Co-authored-by: codex --- .../src/components/settings/ConnectionsSettings.tsx | 11 ++++++++--- .../src/components/settings/SettingsSidebarNav.tsx | 9 ++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 07b170f173a..92b11329283 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1680,15 +1680,20 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b : !canManageRelay ? "Your session does not have permission to manage T3 Cloud access." : null; + const linked = primaryCloudLinkState.data?.linked ?? false; return ( void updateLink(enabled)} @@ -1704,7 +1709,7 @@ function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) ) : ( ; + 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 }, + { 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 }, ]; @@ -97,6 +99,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { } /> {item.label} + {item.badgeLabel ? ( + + {item.badgeLabel} + + ) : null} ); From 4aebaca9d020237ba8f6c94f449de4880084321e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 18:32:01 -0700 Subject: [PATCH 15/61] fix(mobile): reconnect saved environments after resume Co-authored-by: codex --- .../use-remote-environment-registry.test.ts | 46 +++++++++++- .../state/use-remote-environment-registry.ts | 72 ++++++++++++++++++- .../client-runtime/src/transportError.test.ts | 8 +++ packages/client-runtime/src/transportError.ts | 1 + .../client-runtime/src/wsTransport.test.ts | 4 +- 5 files changed, 127 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/state/use-remote-environment-registry.test.ts b/apps/mobile/src/state/use-remote-environment-registry.test.ts index 3dccf6803ec..4bbf266ede1 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.test.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.test.ts @@ -15,10 +15,15 @@ const mocks = vi.hoisted(() => { }; 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 })), @@ -37,6 +42,7 @@ const mocks = vi.hoisted(() => { 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(), @@ -60,6 +66,10 @@ vi.mock("react-native", () => ({ Alert: { alert: vi.fn(), }, + AppState: { + currentState: "active", + addEventListener: vi.fn(() => ({ remove: vi.fn() })), + }, })); vi.mock("@t3tools/client-runtime", async (importOriginal) => { @@ -102,6 +112,7 @@ vi.mock("../lib/runtime", () => ({ vi.mock("./environment-session-registry", () => ({ drainEnvironmentSessions: vi.fn(() => []), + getEnvironmentSession: mocks.getEnvironmentSession, notifyEnvironmentConnectionListeners: mocks.notifyEnvironmentConnectionListeners, removeEnvironmentSession: mocks.removeEnvironmentSession, setEnvironmentSession: mocks.setEnvironmentSession, @@ -151,7 +162,11 @@ vi.mock("./use-terminal-session", () => ({ }, })); -import { connectSavedEnvironment, disconnectEnvironment } from "./use-remote-environment-registry"; +import { + connectSavedEnvironment, + disconnectEnvironment, + reconnectEnvironmentConnectionsAfterAppResume, +} from "./use-remote-environment-registry"; import { appAtomRegistry } from "./atom-registry"; const environmentId = EnvironmentId.make("env-mobile-test"); @@ -173,7 +188,10 @@ describe("mobile remote environment registry effects", () => { 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")); @@ -366,6 +384,32 @@ describe("mobile remote environment registry effects", () => { }), ); + 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({ diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 176ba284419..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, @@ -44,6 +44,7 @@ import { appAtomRegistry } from "./atom-registry"; import { mobileRuntime } from "../lib/runtime"; import { drainEnvironmentSessions, + getEnvironmentSession, notifyEnvironmentConnectionListeners, removeEnvironmentSession, setEnvironmentSession, @@ -70,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; @@ -456,6 +459,71 @@ export function connectSavedEnvironment( }); } +export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void { + const now = Date.now(); + if (now - lastAppResumeReconnectAt < APP_RESUME_RECONNECT_COOLDOWN_MS) { + return; + } + + for (const connection of Object.values(getSavedConnectionsById())) { + const session = getEnvironmentSession(connection.environmentId); + if (session?.client.isHeartbeatFresh()) { + continue; + } + + 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, @@ -488,6 +556,7 @@ function deriveConnectedEnvironments( export function useRemoteEnvironmentBootstrap() { useEffect(() => { let cancelled = false; + const unsubscribeAppResumeReconnects = subscribeAppResumeReconnects(); void (async () => { try { @@ -537,6 +606,7 @@ export function useRemoteEnvironmentBootstrap() { return () => { cancelled = true; + unsubscribeAppResumeReconnects(); for (const session of drainEnvironmentSessions()) { void session.connection.dispose(); } 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/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(); From 03e5e416d342cb752a1b79aa5ffbc57043d5d0ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 18:37:37 -0700 Subject: [PATCH 16/61] fix(mobile): keep live activity layout self-contained Co-authored-by: codex --- .../agent-awareness/updatedAtLabel.test.ts | 16 ------ .../agent-awareness/updatedAtLabel.ts | 12 ----- apps/mobile/src/widgets/AgentActivity.test.ts | 49 +++++++++++++++++++ apps/mobile/src/widgets/AgentActivity.tsx | 14 ++++-- 4 files changed, 60 insertions(+), 31 deletions(-) delete mode 100644 apps/mobile/src/features/agent-awareness/updatedAtLabel.test.ts delete mode 100644 apps/mobile/src/features/agent-awareness/updatedAtLabel.ts create mode 100644 apps/mobile/src/widgets/AgentActivity.test.ts diff --git a/apps/mobile/src/features/agent-awareness/updatedAtLabel.test.ts b/apps/mobile/src/features/agent-awareness/updatedAtLabel.test.ts deleted file mode 100644 index 25d234e4513..00000000000 --- a/apps/mobile/src/features/agent-awareness/updatedAtLabel.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { formatAgentActivityUpdatedAtLabel } from "./updatedAtLabel"; - -describe("formatAgentActivityUpdatedAtLabel", () => { - it("formats ISO timestamps without using ambient time APIs", () => { - expect(formatAgentActivityUpdatedAtLabel("2026-05-25T00:03:00.000Z")).toBe("12:03"); - expect(formatAgentActivityUpdatedAtLabel("2026-05-25T09:45:00.000Z")).toBe("9:45"); - expect(formatAgentActivityUpdatedAtLabel("2026-05-25T13:07:00.000Z")).toBe("1:07"); - }); - - it("uses now for malformed timestamps", () => { - expect(formatAgentActivityUpdatedAtLabel("not-a-date")).toBe("now"); - expect(formatAgentActivityUpdatedAtLabel("2026-05-25T24:00:00.000Z")).toBe("now"); - }); -}); diff --git a/apps/mobile/src/features/agent-awareness/updatedAtLabel.ts b/apps/mobile/src/features/agent-awareness/updatedAtLabel.ts deleted file mode 100644 index c721c98f4f8..00000000000 --- a/apps/mobile/src/features/agent-awareness/updatedAtLabel.ts +++ /dev/null @@ -1,12 +0,0 @@ -const ISO_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T(?\d{2}):(?\d{2}):/; - -export function formatAgentActivityUpdatedAtLabel(updatedAt: string): string { - const match = ISO_TIME_PATTERN.exec(updatedAt); - const hours24 = Number(match?.groups?.hours); - const minutes = match?.groups?.minutes; - if (!Number.isInteger(hours24) || hours24 < 0 || hours24 > 23 || !minutes) { - return "now"; - } - - return `${hours24 % 12 || 12}:${minutes}`; -} 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 index 58c65bef2b2..5cbd6c442f5 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -5,7 +5,6 @@ import { type LiveActivityComponent, type LiveActivityLayout, } from "expo-widgets"; -import { formatAgentActivityUpdatedAtLabel } from "../features/agent-awareness/updatedAtLabel"; type LiveActivityEnvironment = Parameters>[1]; @@ -38,7 +37,7 @@ export interface AgentActivityProps { readonly activities: ReadonlyArray; } -function AgentActivity( +export function AgentActivity( props: AgentActivityProps, environment: LiveActivityEnvironment, ): LiveActivityLayout { @@ -47,7 +46,16 @@ function AgentActivity( const row0 = props.activities[0]; const row1 = props.activities[1]; const row2 = props.activities[2]; - const updatedAt = formatAgentActivityUpdatedAtLabel(props.updatedAt); + 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"; From b9c613abab9c4281644138272ff188b640472799 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 19:47:22 -0700 Subject: [PATCH 17/61] feat(web): add desktop Clerk-compatible cloud auth UI Co-authored-by: codex --- apps/web/src/cloud/desktopAuth.test.ts | 37 ++- apps/web/src/cloud/desktopAuth.ts | 50 +++- apps/web/src/cloud/desktopClerk.tsx | 2 + .../src/components/clerk/DesktopClerkCard.tsx | 137 +++++++++++ .../clerk/DesktopClerkSignIn.browser.tsx | 71 ++++++ .../components/clerk/DesktopClerkSignIn.tsx | 150 ++++++++++++ .../components/clerk/DesktopClerkWaitlist.tsx | 106 +++++++++ .../components/clerk/useDesktopClerkSignIn.ts | 199 ++++++++++++++++ .../src/components/settings/CloudSettings.tsx | 213 +----------------- 9 files changed, 744 insertions(+), 221 deletions(-) create mode 100644 apps/web/src/components/clerk/DesktopClerkCard.tsx create mode 100644 apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx create mode 100644 apps/web/src/components/clerk/DesktopClerkSignIn.tsx create mode 100644 apps/web/src/components/clerk/DesktopClerkWaitlist.tsx create mode 100644 apps/web/src/components/clerk/useDesktopClerkSignIn.ts diff --git a/apps/web/src/cloud/desktopAuth.test.ts b/apps/web/src/cloud/desktopAuth.test.ts index 171e5cb4f82..9a9b2445179 100644 --- a/apps/web/src/cloud/desktopAuth.test.ts +++ b/apps/web/src/cloud/desktopAuth.test.ts @@ -19,6 +19,41 @@ describe("resolveDesktopCloudAuthOAuthOptions", () => { }, }, }), - ).toEqual([{ strategy: "oauth_google", label: "Google" }]); + ).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 index be7377be15e..0e2a328c30e 100644 --- a/apps/web/src/cloud/desktopAuth.ts +++ b/apps/web/src/cloud/desktopAuth.ts @@ -3,6 +3,8 @@ export type DesktopCloudAuthOAuthStrategy = `oauth_${string}`; export interface DesktopCloudAuthOAuthOption { readonly strategy: DesktopCloudAuthOAuthStrategy; readonly label: string; + readonly providerId: string; + readonly iconUrl: string | null; } interface ClerkOAuthProviderSetting { @@ -10,6 +12,7 @@ interface ClerkOAuthProviderSetting { readonly authenticatable?: unknown; readonly strategy?: unknown; readonly name?: unknown; + readonly logo_url?: unknown; } interface ClerkUserSettingsLike { @@ -41,6 +44,8 @@ const OAUTH_LABELS: Readonly> = { 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 { @@ -71,10 +76,11 @@ export function resolveDesktopCloudAuthOAuthOptions( const strategies = userSettings?.authenticatableSocialStrategies; if (Array.isArray(strategies)) { return uniqueOptions( - strategies.filter(isDesktopCloudAuthOAuthStrategy).map((strategy) => ({ - strategy, - label: getDesktopCloudAuthOAuthStrategyLabel(strategy), - })), + strategies + .filter(isDesktopCloudAuthOAuthStrategy) + .map((strategy) => + createOAuthOption(strategy, findProviderSetting(userSettings, strategy)), + ), ); } @@ -92,18 +98,40 @@ export function resolveDesktopCloudAuthOAuthOptions( ? provider.strategy : null; if (!strategy) return null; - return { - strategy, - label: - typeof provider.name === "string" && provider.name.trim() - ? provider.name - : getDesktopCloudAuthOAuthStrategyLabel(strategy), - }; + 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[] { diff --git a/apps/web/src/cloud/desktopClerk.tsx b/apps/web/src/cloud/desktopClerk.tsx index 25ed936a18d..cf023a3dda3 100644 --- a/apps/web/src/cloud/desktopClerk.tsx +++ b/apps/web/src/cloud/desktopClerk.tsx @@ -210,6 +210,8 @@ function getDesktopClerkInstance(publishableKey: string): Clerk { 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"); 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/settings/CloudSettings.tsx b/apps/web/src/components/settings/CloudSettings.tsx index d44032d1e27..40e0e73acc2 100644 --- a/apps/web/src/components/settings/CloudSettings.tsx +++ b/apps/web/src/components/settings/CloudSettings.tsx @@ -1,13 +1,8 @@ -import { UserButton, Waitlist, useAuth, useClerk } from "@clerk/react"; -import { useSignIn, useSignUp } from "@clerk/react/legacy"; +import { UserButton, Waitlist, useAuth } from "@clerk/react"; import { AuthRelayWriteScope } from "@t3tools/contracts"; import { CloudIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; -import { - type DesktopCloudAuthOAuthStrategy, - resolveDesktopCloudAuthOAuthOptions, -} from "../../cloud/desktopAuth"; import { updatePrimaryCloudPreferences } from "../../cloud/linkEnvironment"; import { useManagedRelayDevices } from "../../cloud/managedRelayState"; import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; @@ -15,6 +10,7 @@ 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"; @@ -57,32 +53,7 @@ function hasClerkConfig(): boolean { return Boolean(import.meta.env.VITE_CLERK_PUBLISHABLE_KEY); } -class CloudSettingsOperationError extends Error { - override readonly cause?: unknown; - - constructor(message: string, cause?: unknown) { - super(message); - this.name = "CloudSettingsOperationError"; - this.cause = cause; - } -} - -async function runCloudOperation(operation: () => Promise, message: string): Promise { - try { - return await operation(); - } catch (cause) { - throw new CloudSettingsOperationError(message, cause); - } -} - function cloudErrorMessage(error: unknown, fallback: string): string { - if (error instanceof CloudSettingsOperationError) { - 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; } @@ -116,13 +87,7 @@ function ConfiguredCloudSettingsPanel() { function CloudWaitlistPanel() { return ( - - {isElectron ? ( -
-

Already approved? Sign in through the desktop app.

- -
- ) : null} + {isElectron ? : }
); } @@ -244,173 +209,3 @@ function CloudSettingsPanelInner() { ); } - -function DesktopCloudSignInButton() { - 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 runCloudOperation( - () => signIn.reload({ rotatingTokenNonce }), - "Could not reload the desktop sign-in session.", - ); - sessionId = sessionId || signIn.createdSessionId; - - if (!sessionId && signIn.firstFactorVerification.status === "transferable") { - const signUpAttempt = await runCloudOperation( - () => signUp.create({ transfer: true }), - "Could not transfer the desktop sign-up session.", - ); - sessionId = signUpAttempt.createdSessionId; - } - - if (!sessionId) { - throw new CloudSettingsOperationError("Clerk did not create a desktop session."); - } - - await runCloudOperation( - () => setActive({ session: sessionId! }), - "Could not activate the desktop cloud session.", - ); - } catch (error) { - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: cloudErrorMessage(error, "Could not complete cloud sign-in."), - }); - } - }, - [setActive, signIn, signInLoaded, signUp, signUpLoaded], - ); - - useEffect(() => { - return () => { - clearCallbackListener(); - }; - }, [clearCallbackListener]); - - const startOAuth = 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 runCloudOperation( - () => window.desktopBridge?.createCloudAuthRequest() ?? Promise.resolve(undefined), - "Desktop auth callback is unavailable.", - ); - if (!redirectUrl) { - throw new CloudSettingsOperationError("Desktop auth callback is unavailable."); - } - - callbackCleanupRef.current = - window.desktopBridge?.onCloudAuthCallback((rawUrl) => { - clearCallbackListener(); - void completeOAuthCallback(rawUrl); - }) ?? null; - - const signInAttempt = await runCloudOperation( - () => signIn.create({ strategy, redirectUrl } as never), - "Could not create the desktop OAuth request.", - ); - const externalUrl = - signInAttempt.firstFactorVerification.externalVerificationRedirectURL?.toString(); - if (!externalUrl) { - throw new CloudSettingsOperationError( - "Clerk did not return an external OAuth redirect URL.", - ); - } - - const opened = await runCloudOperation( - () => window.desktopBridge?.openExternal(externalUrl) ?? Promise.resolve(false), - "Could not open the system browser.", - ); - if (!opened) { - throw new CloudSettingsOperationError("Could not open the system browser."); - } - } catch (error) { - clearCallbackListener(); - toastManager.add({ - type: "error", - title: "Cloud sign-in failed", - description: cloudErrorMessage(error, "Could not start cloud sign-in."), - }); - } finally { - setStartingStrategy(null); - } - }; - - const isStarting = startingStrategy !== null; - - if (oauthOptions.length === 0) { - return ( - - ); - } - - return ( -
- {oauthOptions.map((option) => ( - - ))} -
- ); -} From 91e8bffc28cee70d69bb7a5ef31fff358c2e888d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 1 Jun 2026 23:20:53 -0700 Subject: [PATCH 18/61] feat(cloud): manage cloudflared installation and relay defaults Co-authored-by: codex --- .env.example | 5 + .github/workflows/release.yml | 6 + README.md | 5 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 3 + apps/desktop/src/ipc/channels.ts | 2 + .../desktop/src/ipc/methods/cloudAuth.test.ts | 55 ++ apps/desktop/src/ipc/methods/cloudAuth.ts | 19 +- .../src/ipc/methods/cloudflared.test.ts | 43 ++ apps/desktop/src/ipc/methods/cloudflared.ts | 27 + apps/desktop/src/main.ts | 18 +- apps/desktop/src/preload.ts | 2 + apps/mobile/README.md | 7 + apps/mobile/app.config.ts | 11 +- apps/mobile/src/app/settings/waitlist.tsx | 2 +- .../src/cloud/ManagedEndpointRuntime.test.ts | 123 ++++- .../src/cloud/ManagedEndpointRuntime.ts | 94 +++- apps/server/src/server.ts | 13 +- apps/web/src/cloud/linkEnvironment.test.ts | 53 +- apps/web/src/cloud/linkEnvironment.ts | 64 ++- .../src/components/settings/CloudSettings.tsx | 2 +- .../settings/SettingsPanels.browser.tsx | 12 + apps/web/src/localApi.test.ts | 12 + apps/web/tsconfig.json | 2 +- apps/web/vite.config.ts | 9 + docs/t3-cloud-clerk.md | 45 +- infra/relay/.env.example | 12 + infra/relay/README.md | 7 +- infra/relay/alchemy.run.ts | 2 +- infra/relay/src/worker.ts | 24 +- infra/relay/src/zone.ts | 27 +- packages/contracts/src/ipc.ts | 22 + packages/shared/package.json | 4 + packages/shared/src/cloudflared.test.ts | 257 ++++++++++ packages/shared/src/cloudflared.ts | 477 ++++++++++++++++++ packages/shared/src/relayAuth.ts | 9 + scripts/dev-runner.ts | 4 + scripts/lib/public-config.test.ts | 74 +++ scripts/lib/public-config.ts | 78 +++ 38 files changed, 1557 insertions(+), 74 deletions(-) create mode 100644 .env.example create mode 100644 apps/desktop/src/ipc/methods/cloudAuth.test.ts create mode 100644 apps/desktop/src/ipc/methods/cloudflared.test.ts create mode 100644 apps/desktop/src/ipc/methods/cloudflared.ts create mode 100644 infra/relay/.env.example create mode 100644 packages/shared/src/cloudflared.test.ts create mode 100644 packages/shared/src/cloudflared.ts create mode 100644 scripts/lib/public-config.test.ts create mode 100644 scripts/lib/public-config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000000..7ab513c2427 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Optional root-level overrides for the checked-in public client configuration. +# Server-side secrets must never be committed here. +# +# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... +# T3_RELAY_URL=https://relay.example.com diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 603aac30e4a..ab086ae6cd2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,10 @@ permissions: contents: read id-token: none +env: + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} + T3_RELAY_URL: ${{ vars.T3_RELAY_URL }} + jobs: check_changes: name: Check for changes since last nightly @@ -648,6 +652,8 @@ 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 "T3_RELAY_URL=${T3_RELAY_URL:-}" \ --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" diff --git a/README.md b/README.md index 4398753c067..7279d63e2a8 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,11 @@ mise install vp install ``` +T3 Cloud public client configuration has checked-in development defaults, so a fresh clone works +without creating app-local `.env` files. To point web, desktop, and mobile at another Clerk/relay +deployment, copy [`.env.example`](./.env.example) to `.env` at the repository root and set the +canonical overrides 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/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index d26aa599a63..502874e5630 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -9,6 +9,7 @@ import { setCloudAuthToken, } from "./methods/cloudAuth.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { getCloudflaredStatus, installCloudflared } from "./methods/cloudflared.ts"; import { getSavedEnvironmentRegistry, getSavedEnvironmentSecret, @@ -87,6 +88,8 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(setCloudAuthToken); yield* ipc.handle(clearCloudAuthToken); yield* ipc.handle(fetchCloudAuth); + yield* ipc.handle(getCloudflaredStatus); + yield* ipc.handle(installCloudflared); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 1ded238c663..5e7d1af626e 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,8 @@ 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 GET_CLOUDFLARED_STATUS_CHANNEL = "desktop:get-cloudflared-status"; +export const INSTALL_CLOUDFLARED_CHANNEL = "desktop:install-cloudflared"; 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"; 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..51cefb9842c --- /dev/null +++ b/apps/desktop/src/ipc/methods/cloudAuth.test.ts @@ -0,0 +1,55 @@ +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 { fetchCloudAuth } from "./cloudAuth.ts"; + +function makeHttpClientLayer( + handler: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect, +) { + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => handler(request)), + ); +} + +describe("Desktop cloud auth IPC", () => { + 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)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/cloudAuth.ts b/apps/desktop/src/ipc/methods/cloudAuth.ts index 2870732f7e7..a3115da26bd 100644 --- a/apps/desktop/src/ipc/methods/cloudAuth.ts +++ b/apps/desktop/src/ipc/methods/cloudAuth.ts @@ -6,7 +6,7 @@ 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 { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http"; import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts"; import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts"; @@ -91,11 +91,20 @@ export const fetchCloudAuth = makeIpcMethod({ }), }); - const request = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")(url, { - headers: input.headers, - }).pipe( - input.body === undefined ? (request) => request : HttpClientRequest.bodyText(input.body), + 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( diff --git a/apps/desktop/src/ipc/methods/cloudflared.test.ts b/apps/desktop/src/ipc/methods/cloudflared.test.ts new file mode 100644 index 00000000000..b88b1a6a752 --- /dev/null +++ b/apps/desktop/src/ipc/methods/cloudflared.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Cloudflared from "@t3tools/shared/cloudflared"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { getCloudflaredStatus, installCloudflared } from "./cloudflared.ts"; + +const available = { + status: "available", + executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", + source: "managed", + version: Cloudflared.CLOUDFLARED_VERSION, +} as const; + +describe("Desktop cloudflared IPC", () => { + it.effect("reads status and delegates installation to the shared manager", () => + Effect.gen(function* () { + const installed: Array = []; + const layer = Layer.succeed( + Cloudflared.CloudflaredExecutable, + Cloudflared.CloudflaredExecutable.of({ + resolve: Effect.succeed({ + status: "missing", + version: Cloudflared.CLOUDFLARED_VERSION, + }), + install: Effect.sync(() => { + installed.push(true); + return available; + }), + }), + ); + + expect(yield* getCloudflaredStatus.handler(undefined).pipe(Effect.provide(layer))).toEqual({ + status: "missing", + version: Cloudflared.CLOUDFLARED_VERSION, + }); + expect(yield* installCloudflared.handler(undefined).pipe(Effect.provide(layer))).toEqual( + available, + ); + expect(installed).toEqual([true]); + }), + ); +}); diff --git a/apps/desktop/src/ipc/methods/cloudflared.ts b/apps/desktop/src/ipc/methods/cloudflared.ts new file mode 100644 index 00000000000..48c10d3a1c5 --- /dev/null +++ b/apps/desktop/src/ipc/methods/cloudflared.ts @@ -0,0 +1,27 @@ +import { DesktopCloudflaredStatusSchema } from "@t3tools/contracts"; +import * as Cloudflared from "@t3tools/shared/cloudflared"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getCloudflaredStatus = makeIpcMethod({ + channel: IpcChannels.GET_CLOUDFLARED_STATUS_CHANNEL, + payload: Schema.Undefined, + result: DesktopCloudflaredStatusSchema, + handler: Effect.fn("desktop.ipc.cloudflared.getStatus")(function* () { + const cloudflared = yield* Cloudflared.CloudflaredExecutable; + return yield* cloudflared.resolve; + }), +}); + +export const installCloudflared = makeIpcMethod({ + channel: IpcChannels.INSTALL_CLOUDFLARED_CHANNEL, + payload: Schema.Undefined, + result: DesktopCloudflaredStatusSchema, + handler: Effect.fn("desktop.ipc.cloudflared.install")(function* () { + const cloudflared = yield* Cloudflared.CloudflaredExecutable; + return yield* cloudflared.install; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9356eef441b..168fadbd1ca 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -9,6 +9,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; +import * as Cloudflared from "@t3tools/shared/cloudflared"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -94,6 +95,17 @@ const desktopSshEnvironmentLayer = Layer.unwrap( }), ); +const desktopCloudflaredLayer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return Cloudflared.layer({ + baseDir: environment.baseDir, + platform: environment.platform, + arch: environment.processArch, + }); + }), +); + const electronLayer = Layer.mergeAll( ElectronApp.layer, ElectronDialog.layer, @@ -141,7 +153,11 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopCloudAuth.layer, DesktopShellEnvironment.layer, desktopSshLayer, -).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); +).pipe( + Layer.provideMerge(DesktopUpdates.layer), + Layer.provideMerge(desktopCloudflaredLayer), + Layer.provideMerge(desktopBackendLayer), +); const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( Layer.flatMap(() => diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 84f7580cb07..e705d0b6632 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -102,6 +102,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { 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), + getCloudflaredStatus: () => ipcRenderer.invoke(IpcChannels.GET_CLOUDFLARED_STATUS_CHANNEL), + installCloudflared: () => ipcRenderer.invoke(IpcChannels.INSTALL_CLOUDFLARED_CHANNEL), onCloudAuthCallback: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => { if (typeof rawUrl !== "string") return; diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 87e144f1735..8054828bc6e 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`. +Public T3 Cloud development defaults are shared with web and desktop. Optional overrides belong 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: @@ -63,6 +67,9 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy. +For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3_RELAY_URL` +as EAS environment variables. Expo config maps the canonical values into the mobile build. + Create a PR preview dev-client build manually: ```bash diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index d9288d9474b..18aaee07142 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"; + 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, @@ -150,10 +155,10 @@ const config: ExpoConfig = { extra: { appVariant: APP_VARIANT, relay: { - url: process.env.T3_RELAY_URL ?? null, + url: repoEnv.T3_RELAY_URL ?? null, }, clerk: { - publishableKey: process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, + publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, }, eas: { projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", diff --git a/apps/mobile/src/app/settings/waitlist.tsx b/apps/mobile/src/app/settings/waitlist.tsx index 348197bfdf7..faab343470e 100644 --- a/apps/mobile/src/app/settings/waitlist.tsx +++ b/apps/mobile/src/app/settings/waitlist.tsx @@ -57,7 +57,7 @@ export default function SettingsWaitlistRouteScreen() { textAlign: "center", }} > - Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to this build to enable waitlist enrollment. + Add T3CODE_CLERK_PUBLISHABLE_KEY to this build to enable waitlist enrollment. )} diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index d3baceee737..095e02c27ca 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -1,14 +1,36 @@ 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 Cloudflared from "@t3tools/shared/cloudflared"; import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; +const cloudflaredAvailableLayer = Layer.succeed( + Cloudflared.CloudflaredExecutable, + Cloudflared.CloudflaredExecutable.of({ + resolve: Effect.succeed({ + status: "available", + executablePath: "cloudflared", + source: "path", + version: Cloudflared.CLOUDFLARED_VERSION, + }), + install: Effect.die("unused"), + }), +); + +const runtimeDependencies = (spawner: ReturnType) => + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + cloudflaredAvailableLayer, + ); + function makeHandle(input: { readonly pid: number; readonly onKill: () => void; @@ -58,7 +80,7 @@ describe("CloudManagedEndpointRuntime", () => { }), ); const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provide(runtimeDependencies(spawner)), ); yield* runtime.applyConfig({ @@ -88,6 +110,7 @@ describe("CloudManagedEndpointRuntime", () => { ]); 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(killed).toEqual([100, 101]); expect(stopped).toEqual({ status: "disabled" }); }), @@ -109,7 +132,7 @@ describe("CloudManagedEndpointRuntime", () => { }), ); const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provide(runtimeDependencies(spawner)), ); const started = yield* runtime.applyConfig({ @@ -148,7 +171,7 @@ describe("CloudManagedEndpointRuntime", () => { }), ); const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provide(runtimeDependencies(spawner)), ); const config = { providerKind: "cloudflare_tunnel" as const, @@ -195,7 +218,7 @@ describe("CloudManagedEndpointRuntime", () => { }), ); const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provide(runtimeDependencies(spawner)), ); const started = yield* runtime.applyConfig({ @@ -208,7 +231,59 @@ describe("CloudManagedEndpointRuntime", () => { expect(started).toMatchObject({ status: "running", pid: 400 }); expect(spawned).toEqual([400, 401]); - expect(killed).toEqual([]); + 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]); }), ); @@ -225,7 +300,7 @@ describe("CloudManagedEndpointRuntime", () => { ), ); const runtime = yield* makeCloudManagedEndpointRuntime.pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provide(runtimeDependencies(spawner)), ); const status = yield* runtime.applyConfig({ @@ -241,4 +316,40 @@ describe("CloudManagedEndpointRuntime", () => { }); }), ); + + it.effect("reports a missing cloudflared 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( + Cloudflared.CloudflaredExecutable, + Cloudflared.CloudflaredExecutable.of({ + resolve: Effect.succeed({ + status: "missing", + version: Cloudflared.CLOUDFLARED_VERSION, + }), + install: Effect.die("unused"), + }), + ), + ), + ), + ); + + const status = yield* runtime.applyConfig({ + providerKind: "cloudflare_tunnel", + connectorToken: "token", + }); + + expect(status).toEqual({ + status: "failed", + providerKind: "cloudflare_tunnel", + reason: "cloudflared is not installed.", + }); + expect(spawn).not.toHaveBeenCalled(); + }), + ); }); diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 25906ef8ed3..6072cb41a3e 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -1,10 +1,13 @@ import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; +import * as Cloudflared from "@t3tools/shared/cloudflared"; 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"; @@ -88,8 +91,11 @@ const stopConnector = (connector: ActiveConnector | null) => export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const cloudflared = yield* Cloudflared.CloudflaredExecutable; const activeRef = yield* Ref.make(null); - let applyConfig: CloudManagedEndpointRuntimeShape["applyConfig"]; + 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); @@ -98,26 +104,46 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { const superviseConnector = (connector: ActiveConnector) => Effect.gen(function* () { - const exitCode = yield* connector.child.exitCode; - const active = yield* Ref.get(activeRef); - if (active?.child.pid !== connector.child.pid || active.configKey !== connector.configKey) { - return; - } - yield* Ref.set(activeRef, null); - yield* Effect.logWarning("Cloudflare managed endpoint connector exited; restarting", { - pid: Number(connector.child.pid), - exitCode: Number(exitCode), - tunnelId: connector.config.tunnelId, - tunnelName: connector.config.tunnelName, - }); - yield* applyConfig(connector.config); + 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("Cloudflare managed endpoint connector 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.catch((cause) => + Effect.catchCause((cause) => Effect.logWarning("Cloudflare managed endpoint connector supervisor failed", { cause }), ), ); - applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")(function* (config) { + reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { if (!config || config.providerKind !== "cloudflare_tunnel") { yield* stopActive; return config @@ -144,14 +170,33 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { yield* stopActive; + const executable = yield* cloudflared.resolve; + if (executable.status !== "available") { + return { + status: "failed", + providerKind: "cloudflare_tunnel", + reason: + executable.status === "unsupported" + ? `Managed cloudflared is unsupported on ${executable.platform}-${executable.arch}.` + : "cloudflared 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("cloudflared", ["tunnel", "run", "--token", config.connectorToken], { - shell: process.platform === "win32", - stderr: "ignore", - stdout: "ignore", - }), + ChildProcess.make( + executable.executablePath, + ["tunnel", "run", "--token", config.connectorToken], + { + detached: false, + shell: process.platform === "win32", + stderr: "ignore", + stdout: "ignore", + }, + ), ) .pipe( Effect.provideService(Scope.Scope, connectorScope), @@ -210,6 +255,13 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { } 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, }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 06251ef3439..eedb3afef7b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -81,6 +81,7 @@ import { } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import * as NetService from "@t3tools/shared/Net"; +import * as Cloudflared from "@t3tools/shared/cloudflared"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; const PtyAdapterLive = Layer.unwrap( @@ -95,6 +96,13 @@ const PtyAdapterLive = Layer.unwrap( }), ); +const CloudflaredExecutableLive = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + return Cloudflared.layer({ baseDir: config.baseDir }); + }), +); + const HttpServerLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; @@ -277,7 +285,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( - CloudManagedEndpointRuntime.layer.pipe(Layer.provide(ServerSecretStore.layer)), + CloudManagedEndpointRuntime.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(CloudflaredExecutableLive), + ), ), ); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 57a3bd58fcb..2eccdd113f1 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import { RelayWebClientId } from "@t3tools/contracts/relay"; -import { beforeEach, vi } from "vitest"; +import { afterEach, beforeEach, vi } from "vitest"; +import type { DesktopBridge } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -15,6 +16,7 @@ import { import type { SavedEnvironmentRecord } from "../environments/runtime"; import { connectManagedCloudEnvironment, + ensureDesktopCloudflaredAvailable, linkEnvironmentToCloud, linkPrimaryEnvironmentToCloud, listManagedCloudEnvironments, @@ -96,6 +98,13 @@ function requestBodyText(body: BodyInit | null | undefined): string { } describe("web cloud link environment client", () => { + afterEach(() => { + if ("window" in globalThis) { + Reflect.deleteProperty(window, "desktopBridge"); + } + vi.unstubAllGlobals(); + }); + beforeEach(() => { vi.restoreAllMocks(); createProofMock.mockClear(); @@ -115,6 +124,48 @@ describe("web cloud link environment client", () => { expect(normalizeRelayBaseUrl(" ")).toBeNull(); }); + it("installs cloudflared after desktop confirmation", async () => { + vi.stubGlobal("window", {}); + const confirm = vi.fn().mockResolvedValue(true); + const installCloudflared = vi.fn().mockResolvedValue({ + status: "available", + executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", + source: "managed", + version: "2026.5.2", + }); + window.desktopBridge = { + confirm, + getCloudflaredStatus: vi.fn().mockResolvedValue({ + status: "missing", + version: "2026.5.2", + }), + installCloudflared, + } as unknown as DesktopBridge; + + await Effect.runPromise(ensureDesktopCloudflaredAvailable()); + + expect(confirm).toHaveBeenCalledOnce(); + expect(installCloudflared).toHaveBeenCalledOnce(); + }); + + it("does not install cloudflared when desktop confirmation is declined", async () => { + vi.stubGlobal("window", {}); + const installCloudflared = vi.fn(); + window.desktopBridge = { + confirm: vi.fn().mockResolvedValue(false), + getCloudflaredStatus: vi.fn().mockResolvedValue({ + status: "missing", + version: "2026.5.2", + }), + installCloudflared, + } as unknown as DesktopBridge; + + await expect(Effect.runPromise(ensureDesktopCloudflaredAvailable())).rejects.toMatchObject({ + message: "Cloudflare Tunnel installation was cancelled.", + }); + expect(installCloudflared).not.toHaveBeenCalled(); + }); + it.effect("lists relay-managed environments for hosted and served web clients", () => Effect.gen(function* () { const fetchMock = vi.fn().mockResolvedValueOnce( diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index b6981e06f60..746ec7cfa88 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -55,6 +55,59 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen readonly cause?: unknown; }> {} +const desktopCloudflaredBridgeError = (cause: unknown) => + new CloudEnvironmentLinkError({ + message: "Could not prepare Cloudflare Tunnel.", + cause, + }); + +export function ensureDesktopCloudflaredAvailable(): Effect.Effect< + void, + CloudEnvironmentLinkError +> { + const bridge = typeof window === "undefined" ? undefined : window.desktopBridge; + if (!bridge) return Effect.void; + + return Effect.gen(function* () { + const status = yield* Effect.tryPromise({ + try: () => bridge.getCloudflaredStatus(), + catch: desktopCloudflaredBridgeError, + }); + if (status.status === "available") return; + if (status.status === "unsupported") { + return yield* new CloudEnvironmentLinkError({ + message: `T3 Code cannot install cloudflared automatically on ${status.platform}-${status.arch}.`, + }); + } + + const confirmed = yield* Effect.tryPromise({ + try: () => + bridge.confirm( + "T3 Code needs Cloudflare Tunnel to make this environment available through T3 Cloud. Download and install cloudflared now?", + ), + catch: desktopCloudflaredBridgeError, + }); + if (!confirmed) { + return yield* new CloudEnvironmentLinkError({ + message: "Cloudflare Tunnel installation was cancelled.", + }); + } + + const installed = yield* Effect.tryPromise({ + try: () => bridge.installCloudflared(), + catch: desktopCloudflaredBridgeError, + }); + if (installed.status !== "available") { + return yield* new CloudEnvironmentLinkError({ + message: + installed.status === "unsupported" + ? `T3 Code cannot install cloudflared automatically on ${installed.platform}-${installed.arch}.` + : "cloudflared is still unavailable after installation.", + }); + } + }); +} + const isRelayProtectedError = Schema.is(RelayProtectedError); const isEnvironmentCloudApiError = Schema.is( Schema.Union([ @@ -231,7 +284,7 @@ export function listManagedCloudEnvironments(input: { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { return yield* new CloudEnvironmentLinkError({ - message: "VITE_T3_RELAY_URL is not configured.", + message: "T3_RELAY_URL is not configured.", }); } const relayClient = yield* ManagedRelayClient; @@ -261,7 +314,7 @@ export function listCloudDevices(input: { return Effect.gen(function* () { if (!relayUrl()) { return yield* new CloudEnvironmentLinkError({ - message: "VITE_T3_RELAY_URL is not configured.", + message: "T3_RELAY_URL is not configured.", }); } const relayClient = yield* ManagedRelayClient; @@ -290,7 +343,7 @@ export function connectManagedCloudEnvironment(input: { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { return yield* new CloudEnvironmentLinkError({ - message: "VITE_T3_RELAY_URL is not configured.", + message: "T3_RELAY_URL is not configured.", }); } const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl); @@ -465,7 +518,7 @@ export function linkEnvironmentToCloud(input: { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { return yield* new CloudEnvironmentLinkError({ - message: "VITE_T3_RELAY_URL is not configured.", + message: "T3_RELAY_URL is not configured.", }); } const relayClient = yield* ManagedRelayClient; @@ -560,7 +613,7 @@ export function linkPrimaryEnvironmentToCloud(input: { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { return yield* new CloudEnvironmentLinkError({ - message: "VITE_T3_RELAY_URL is not configured.", + message: "T3_RELAY_URL is not configured.", }); } const relayClient = yield* ManagedRelayClient; @@ -570,6 +623,7 @@ export function linkPrimaryEnvironmentToCloud(input: { message: "Local environment is not ready yet.", }); } + yield* ensureDesktopCloudflaredAvailable(); const challenge = yield* relayClient .createEnvironmentLinkChallenge({ diff --git a/apps/web/src/components/settings/CloudSettings.tsx b/apps/web/src/components/settings/CloudSettings.tsx index 40e0e73acc2..b9a9c44df6c 100644 --- a/apps/web/src/components/settings/CloudSettings.tsx +++ b/apps/web/src/components/settings/CloudSettings.tsx @@ -64,7 +64,7 @@ export function CloudSettingsPanel() { }> diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index e8f46505edc..15bbb032f71 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -470,6 +470,18 @@ const createDesktopBridgeStub = (overrides?: { headers: {}, body: "", }), + getCloudflaredStatus: vi.fn().mockResolvedValue({ + status: "available", + executablePath: "/usr/local/bin/cloudflared", + source: "path", + version: "2026.5.2", + }), + installCloudflared: vi.fn().mockResolvedValue({ + status: "available", + executablePath: "/usr/local/bin/cloudflared", + source: "path", + version: "2026.5.2", + }), onCloudAuthCallback: () => () => {}, onMenuAction: () => () => {}, getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index e50dbd9f5f8..2f92a5772c9 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -245,6 +245,18 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg headers: {}, body: "", }), + getCloudflaredStatus: async () => ({ + status: "available", + executablePath: "/usr/local/bin/cloudflared", + source: "path", + version: "2026.5.2", + }), + installCloudflared: async () => ({ + status: "available", + executablePath: "/usr/local/bin/cloudflared", + source: "path", + version: "2026.5.2", + }), onCloudAuthCallback: () => () => undefined, onMenuAction: () => () => undefined, getUpdateState: async () => { 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 86f3eb97250..13d4e73a749 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -8,9 +8,16 @@ 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_T3_RELAY_URL?.trim() || ""; +const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; const configuredHostedAppUrl = (() => { @@ -123,6 +130,8 @@ 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_T3_RELAY_URL": JSON.stringify(configuredRelayUrl), + "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), "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/t3-cloud-clerk.md b/docs/t3-cloud-clerk.md index 376688cf75f..450be3db590 100644 --- a/docs/t3-cloud-clerk.md +++ b/docs/t3-cloud-clerk.md @@ -6,15 +6,41 @@ audience. ## Application Keys -Use keys from the same Clerk instance in each location: +Web, desktop, and mobile use checked-in public development defaults from +`packages/shared/src/relayAuth.ts`, so a fresh clone can use T3 Cloud without creating local +environment files. These values are safe to embed in client builds: the Clerk publishable key and +relay URL are public identifiers, not secrets. -| Consumer | Configuration | Value | -| ------------------------ | --------------------------------------- | ----------------------------------------------------- | -| Web and desktop renderer | `apps/web/.env` | `VITE_CLERK_PUBLISHABLE_KEY=` | -| Mobile build | `apps/mobile/.env` or build environment | `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=` | -| Relay deployment | Alchemy secret | `CLERK_SECRET_KEY=` | +To point all clients at another Clerk/relay deployment, add a repository-root `.env` or +`.env.local` file: -Never put `CLERK_SECRET_KEY` in a client application environment. +```dotenv +T3CODE_CLERK_PUBLISHABLE_KEY= +T3_RELAY_URL=https://relay.example.com +``` + +The shared client loader projects these canonical values into the framework-specific +`VITE_CLERK_PUBLISHABLE_KEY`, `VITE_T3_RELAY_URL`, and +`EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` 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`. +4. Checked-in public development defaults. + +Release builds read `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3_RELAY_URL` from GitHub Actions repository +variables. EAS preview and production builds should define the same client-facing values in their +EAS environment. + +For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay +deployment reads `T3_RELAY_DOMAIN` and `T3_RELAY_ZONE_NAME` through Effect `Config`, with the +checked-in shared values as defaults. `bun --cwd infra/relay run deploy` invokes Alchemy from the +relay directory, so Alchemy loads `infra/relay/.env`. 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. ## JWT Template @@ -25,8 +51,9 @@ In **Clerk Dashboard > JWT templates**, create a template with: | Name | `t3-relay` | | Claims | `{ "aud": "https://t3code-relay.ineededadomain.com" }` | -The `aud` value must be the deployed relay public URL, with no trailing slash, and must match -`VITE_T3_RELAY_URL` and `T3_RELAY_URL`. If the relay domain changes, update all three values. +The `aud` value must be the deployed relay public URL, with no trailing slash. It must match the +client-facing `T3_RELAY_URL` and the HTTPS URL derived from the deployment's `T3_RELAY_DOMAIN`. If +the relay domain changes, update both values and the JWT template. ## Desktop OAuth Redirect Allowlist diff --git a/infra/relay/.env.example b/infra/relay/.env.example new file mode 100644 index 00000000000..591ceefa5b4 --- /dev/null +++ b/infra/relay/.env.example @@ -0,0 +1,12 @@ +# Relay deployment overrides. The public relay URL is derived as +# https://. +T3_RELAY_DOMAIN=relay.example.com +T3_RELAY_ZONE_NAME=example.com + +# Relay runtime secrets and APNs configuration. +CLERK_SECRET_KEY=sk_test_... +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 index 5fe56d8154c..828553020e1 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -80,12 +80,13 @@ dependencies represented at their boundary rather than mocking internal behavior The relay deploys through Alchemy: ```sh -cd infra/relay -bun run deploy +bun --cwd infra/relay run deploy ``` The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database -connectivity, and relay tracing resources. Runtime secrets include Clerk and APNs credentials. +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. See: diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index e9a3928758c..4e9bd17495f 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -25,7 +25,7 @@ export default Alchemy.Stack( Effect.gen(function* () { const db = yield* PlanetscaleDatabase; const hyperdrive = yield* RelayHyperdrive; - const zone = yield* ManagedEndpointZone; + const zone = yield* ManagedEndpointZone.pipe(Effect.orDie); const api = yield* Api; return { diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 8ba7c2e6e23..c44bff79d8d 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -29,7 +29,7 @@ import { tokenApi, withoutCapturedParentSpan, } from "./http/Api.ts"; -import { ManagedEndpointZone, RELAY_PUBLIC_DOMAIN, RELAY_PUBLIC_ORIGIN } from "./zone.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"; @@ -87,18 +87,22 @@ const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningS export default class Api extends Cloudflare.Worker()( "Api", - { - main: import.meta.filename, - compatibility: { - date: "2026-05-22", - flags: ["nodejs_compat"], - }, - domain: RELAY_PUBLIC_DOMAIN, - }, + RelayDeploymentConfig.pipe( + Config.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 } = yield* RelayDeploymentConfig; const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; const cloudMintKeyPair = yield* CloudMintKeyPair; @@ -142,7 +146,7 @@ export default class Api extends Cloudflare.Worker()( const loadSettings = Effect.gen(function* () { return RelayConfiguration.RelayConfiguration.of({ - relayIssuer: RELAY_PUBLIC_ORIGIN, + relayIssuer: relayPublicOrigin, apns: { environment, teamId: apnsTeamId, diff --git a/infra/relay/src/zone.ts b/infra/relay/src/zone.ts index 1515838cbd1..12022bb04dd 100644 --- a/infra/relay/src/zone.ts +++ b/infra/relay/src/zone.ts @@ -1,9 +1,26 @@ import { adopt } from "alchemy/AdoptPolicy"; import * as Cloudflare from "alchemy/Cloudflare"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; -export const RELAY_PUBLIC_DOMAIN = "t3code-relay.ineededadomain.com"; -export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`; +import { DEFAULT_T3_RELAY_DOMAIN, DEFAULT_T3_RELAY_ZONE_NAME } from "@t3tools/shared/relayAuth"; -export const ManagedEndpointZone = Cloudflare.Zone("ManagedEndpointZone", { - name: "ineededadomain.com", -}).pipe(adopt(true)); +export const RelayDeploymentConfig = Config.all({ + relayPublicDomain: Config.string("T3_RELAY_DOMAIN").pipe( + Config.withDefault(DEFAULT_T3_RELAY_DOMAIN), + ), + managedEndpointZoneName: Config.string("T3_RELAY_ZONE_NAME").pipe( + Config.withDefault(DEFAULT_T3_RELAY_ZONE_NAME), + ), +}).pipe( + Config.map((config) => ({ + relayPublicDomain: config.relayPublicDomain, + relayPublicOrigin: `https://${config.relayPublicDomain}`, + managedEndpointZoneName: config.managedEndpointZoneName, + })), +); + +export const ManagedEndpointZone = RelayDeploymentConfig.pipe( + Config.map(({ managedEndpointZoneName }) => managedEndpointZoneName), + Effect.flatMap((name) => Cloudflare.Zone("ManagedEndpointZone", { name }).pipe(adopt(true))), +); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 56f929f7def..1d9731dcb08 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -394,6 +394,26 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ }); export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; +export const DesktopCloudflaredStatusSchema = 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 DesktopCloudflaredStatus = typeof DesktopCloudflaredStatusSchema.Type; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -444,6 +464,8 @@ export interface DesktopBridge { setCloudAuthToken: (token: string) => Promise; clearCloudAuthToken: () => Promise; fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise; + getCloudflaredStatus: () => Promise; + installCloudflared: () => Promise; onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/shared/package.json b/packages/shared/package.json index a447c40407d..938f70cb49c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -138,6 +138,10 @@ "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" + }, + "./cloudflared": { + "types": "./src/cloudflared.ts", + "import": "./src/cloudflared.ts" } }, "scripts": { diff --git a/packages/shared/src/cloudflared.test.ts b/packages/shared/src/cloudflared.test.ts new file mode 100644 index 00000000000..cfd7340f976 --- /dev/null +++ b/packages/shared/src/cloudflared.test.ts @@ -0,0 +1,257 @@ +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 { + CloudflaredInstallError, + CLOUDFLARED_VERSION, + makeCloudflaredExecutable, + resolveManagedCloudflaredPath, +} from "./cloudflared.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("CloudflaredExecutable", () => { + 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* makeCloudflaredExecutable({ + 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* makeCloudflaredExecutable({ + baseDir, + platform: "linux", + arch: "x64", + releaseAsset: { + url: "https://example.test/cloudflared", + sha256: Encoding.encodeHex(sha256(bytes)), + archive: "binary", + }, + configProvider: emptyConfigProvider, + }); + + const installed = yield* manager.install; + 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(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* makeCloudflaredExecutable({ + 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(CloudflaredInstallError); + 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* makeCloudflaredExecutable({ + 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* makeCloudflaredExecutable({ + 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/cloudflared.ts b/packages/shared/src/cloudflared.ts new file mode 100644 index 00000000000..881d5a258a4 --- /dev/null +++ b/packages/shared/src/cloudflared.ts @@ -0,0 +1,477 @@ +import * as Clock from "effect/Clock"; +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 CloudflaredExecutableSource = "override" | "managed" | "path"; + +export type CloudflaredExecutableStatus = + | { + readonly status: "available"; + readonly executablePath: string; + readonly source: CloudflaredExecutableSource; + readonly version: string; + } + | { + readonly status: "missing"; + readonly version: string; + } + | { + readonly status: "unsupported"; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly version: string; + }; + +export type AvailableCloudflaredExecutable = Extract< + CloudflaredExecutableStatus, + { readonly status: "available" } +>; + +export class CloudflaredInstallError extends Data.TaggedError("CloudflaredInstallError")<{ + 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 CloudflaredExecutableOptions { + readonly baseDir: string; + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly releaseAsset?: CloudflaredReleaseAsset; + readonly configProvider?: () => ConfigProvider.ConfigProvider; +} + +export interface CloudflaredExecutableShape { + readonly resolve: Effect.Effect; + readonly install: Effect.Effect; +} + +export class CloudflaredExecutable extends Context.Service< + CloudflaredExecutable, + CloudflaredExecutableShape +>()("@t3tools/shared/cloudflared/CloudflaredExecutable") {} + +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: CloudflaredInstallError["reason"], + message: string, + ): (( + effect: Effect.Effect, + ) => Effect.Effect) => + (effect) => + effect.pipe( + Effect.mapError( + (cause) => + new CloudflaredInstallError({ + reason, + message, + cause, + }), + ), + ); + +export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* ( + options: CloudflaredExecutableOptions, +): Effect.fn.Return< + CloudflaredExecutableShape, + 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: CloudflaredExecutableShape["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: platform === "win32", + 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, + ) { + const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError( + (cause) => + new CloudflaredInstallError({ + reason: "download_failed", + message: "Could not download cloudflared.", + cause, + }), + ), + ); + const bytes = new Uint8Array( + yield* response.arrayBuffer.pipe( + Effect.mapError( + (cause) => + new CloudflaredInstallError({ + reason: "download_failed", + message: "Could not read the downloaded cloudflared binary.", + cause, + }), + ), + ), + ); + const checksum = yield* crypto.digest("SHA-256", bytes).pipe( + Effect.mapError( + (cause) => + new CloudflaredInstallError({ + reason: "validation_failed", + message: "Could not verify the downloaded cloudflared checksum.", + cause, + }), + ), + ); + if (Encoding.encodeHex(checksum) !== asset.sha256) { + return yield* new CloudflaredInstallError({ + reason: "invalid_checksum", + message: "Downloaded cloudflared 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 CloudflaredInstallError({ + reason: "install_locked", + message: "Another cloudflared installation is still in progress.", + }); + }); + + const installUnlocked: CloudflaredExecutableShape["install"] = Effect.gen(function* () { + const existing = yield* resolve; + if (existing.status === "available") return existing; + const config = yield* loadCloudflaredConfig; + if (Option.isSome(config.executableOverride)) { + return yield* new CloudflaredInstallError({ + reason: "override_missing", + message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, + }); + } + if (!releaseAsset) { + return yield* new CloudflaredInstallError({ + reason: "unsupported_platform", + message: `T3 Code does not provide a managed cloudflared 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 cloudflared tool directory.")); + yield* acquireInstallLock(lockPath).pipe( + Effect.catchTag("PlatformError", (cause) => + Effect.fail( + new CloudflaredInstallError({ + reason: "write_failed", + message: "Could not acquire the cloudflared 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), + ); + yield* fileSystem + .writeFile(archivePath, yield* downloadAsset(releaseAsset)) + .pipe(wrapInstallFailure("write_failed", "Could not write the cloudflared 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 cloudflared."), + ); + } + if (platform !== "win32") { + yield* fileSystem + .chmod(executablePath, 0o755) + .pipe(wrapInstallFailure("write_failed", "Could not make cloudflared executable.")); + } + yield* runCommand(executablePath, ["--version"]).pipe( + wrapInstallFailure("validation_failed", "The downloaded cloudflared binary did not run."), + ); + + const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; + yield* fileSystem + .rename(executablePath, stagedPath) + .pipe(wrapInstallFailure("write_failed", "Could not stage cloudflared.")); + yield* fileSystem + .rename(stagedPath, managedPath) + .pipe( + wrapInstallFailure("write_failed", "Could not activate cloudflared."), + Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), + ); + return { + status: "available", + executablePath: managedPath, + source: "managed", + version: CLOUDFLARED_VERSION, + } satisfies AvailableCloudflaredExecutable; + }).pipe( + Effect.scoped, + Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), + Effect.catch((cause) => + cause instanceof CloudflaredInstallError + ? Effect.fail(cause) + : Effect.fail( + new CloudflaredInstallError({ + reason: "write_failed", + message: "Could not install cloudflared.", + cause, + }), + ), + ), + ); + }); + const install = installSemaphore.withPermit(installUnlocked); + + return CloudflaredExecutable.of({ resolve, install }); +}); + +export const layer = (options: CloudflaredExecutableOptions) => + Layer.effect(CloudflaredExecutable, makeCloudflaredExecutable(options)); diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index 0f8c139d413..b2c36968adf 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -1,3 +1,12 @@ +export const DEFAULT_T3_RELAY_DOMAIN = "t3code-relay.ineededadomain.com"; + +export const DEFAULT_T3_RELAY_URL = `https://${DEFAULT_T3_RELAY_DOMAIN}`; + +export const DEFAULT_T3_RELAY_ZONE_NAME = "ineededadomain.com"; + +export const DEFAULT_T3_CLERK_PUBLISHABLE_KEY = + "pk_test_YXdhaXRlZC1tb25rZmlzaC01OC5jbGVyay5hY2NvdW50cy5kZXYk"; + export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay"; export const RELAY_CLERK_TOKEN_OPTIONS = { 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..24666c276d1 --- /dev/null +++ b/scripts/lib/public-config.test.ts @@ -0,0 +1,74 @@ +// @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 { DEFAULT_PUBLIC_CONFIG, 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("projects checked-in public defaults into Vite and Expo aliases", () => { + const env = loadRepoEnv({ baseEnv: {}, repoRoot: makeTemporaryDirectory() }); + + expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey); + expect(env.VITE_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey); + expect(env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey); + expect(env.T3_RELAY_URL).toBe(DEFAULT_PUBLIC_CONFIG.relayUrl); + expect(env.VITE_T3_RELAY_URL).toBe(DEFAULT_PUBLIC_CONFIG.relayUrl); + }); + + it("applies process, root local, root, and checked-in precedence in that order", () => { + const repoRoot = makeTemporaryDirectory(); + writeFileSync( + join(repoRoot, ".env"), + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3_RELAY_URL=https://root.example.test\n", + ); + writeFileSync( + join(repoRoot, ".env.local"), + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3_RELAY_URL=https://local.example.test\n", + ); + + expect(loadRepoEnv({ baseEnv: {}, repoRoot }).T3_RELAY_URL).toBe("https://local.example.test"); + expect( + loadRepoEnv({ + baseEnv: { + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", + T3_RELAY_URL: "https://ci.example.test", + }, + repoRoot, + }), + ).toMatchObject({ + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", + VITE_CLERK_PUBLISHABLE_KEY: "pk_ci", + EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_ci", + T3_RELAY_URL: "https://ci.example.test", + VITE_T3_RELAY_URL: "https://ci.example.test", + }); + }); + + it("accepts legacy framework aliases as root overrides", () => { + expect( + resolvePublicConfig({ + VITE_CLERK_PUBLISHABLE_KEY: "pk_legacy", + VITE_T3_RELAY_URL: "https://legacy.example.test", + }), + ).toEqual({ + clerkPublishableKey: "pk_legacy", + relayUrl: "https://legacy.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..ac6f3790a1f --- /dev/null +++ b/scripts/lib/public-config.ts @@ -0,0 +1,78 @@ +// @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"; + +import { DEFAULT_T3_CLERK_PUBLISHABLE_KEY, DEFAULT_T3_RELAY_URL } from "@t3tools/shared/relayAuth"; + +export interface T3CodePublicConfig { + readonly clerkPublishableKey: string; + readonly relayUrl: string; +} + +type Environment = Readonly>; + +const REPO_ROOT = NodePath.dirname( + NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))), +); + +// These values are intentionally public. Client builds embed them so a fresh clone can use +// T3 Cloud without local setup. Use root .env files or CI environment variables to override them. +export const DEFAULT_PUBLIC_CONFIG: T3CodePublicConfig = { + clerkPublishableKey: DEFAULT_T3_CLERK_PUBLISHABLE_KEY, + relayUrl: DEFAULT_T3_RELAY_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, + T3CODE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, + T3_RELAY_URL: config.relayUrl, + VITE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, + VITE_T3_RELAY_URL: config.relayUrl, + EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, + }; +} + +export function resolvePublicConfig(...sources: readonly Environment[]): T3CodePublicConfig { + return { + clerkPublishableKey: + firstNonEmpty( + sources, + "T3CODE_CLERK_PUBLISHABLE_KEY", + "VITE_CLERK_PUBLISHABLE_KEY", + "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", + ) ?? DEFAULT_PUBLIC_CONFIG.clerkPublishableKey, + relayUrl: + firstNonEmpty(sources, "T3_RELAY_URL", "VITE_T3_RELAY_URL") ?? DEFAULT_PUBLIC_CONFIG.relayUrl, + }; +} + +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")) : {}; +} From 6c89f75d4e02383ed974ea87a50f615907768a73 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 14:17:17 -0700 Subject: [PATCH 19/61] ci(cloud): deploy production relay from main Co-authored-by: codex --- .github/workflows/deploy-relay.yml | 61 +++++++++++++++++++++++++++ .github/workflows/release.yml | 66 +++++++++++++++++++++++++----- docs/release.md | 56 ++++++++++++++++++++++++- infra/relay/README.md | 57 ++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/deploy-relay.yml diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml new file mode 100644 index 00000000000..fdcb3e6c71b --- /dev/null +++ b/.github/workflows/deploy-relay.yml @@ -0,0 +1,61 @@ +name: Deploy T3 Cloud relay + +on: + push: + branches: + - main + workflow_dispatch: + +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 }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} + PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} + PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} + AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} + AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} + T3_RELAY_DOMAIN: ${{ vars.T3_RELAY_DOMAIN }} + T3_RELAY_ZONE_NAME: ${{ vars.T3_RELAY_ZONE_NAME }} + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} + 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 }} + APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} + ALCHEMY_TELEMETRY_DISABLED: "1" + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Deploy production relay stage + run: bun --cwd infra/relay run deploy -- --stage prod --yes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab086ae6cd2..cc901a2c588 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,10 +26,6 @@ permissions: contents: read id-token: none -env: - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} - T3_RELAY_URL: ${{ vars.T3_RELAY_URL }} - jobs: check_changes: name: Check for changes since last nightly @@ -170,12 +166,54 @@ 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 }} + relay_url: ${{ steps.public_config.outputs.relay_url }} + env: + T3_RELAY_DOMAIN: ${{ vars.T3_RELAY_DOMAIN }} + T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} + steps: + - id: public_config + name: Resolve production relay public config + shell: bash + run: | + set -euo pipefail + + required=( + T3_RELAY_DOMAIN + T3CODE_CLERK_PUBLISHABLE_KEY + ) + 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=$T3CODE_CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" + echo "relay_url=https://$T3_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 }} + T3_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} strategy: fail-fast: false matrix: @@ -426,13 +464,16 @@ 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 }} + T3_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout uses: actions/checkout@v6 @@ -581,11 +622,13 @@ 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 }} + T3_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 }} @@ -749,10 +792,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/docs/release.md b/docs/release.md index df9033218b9..37051aecb1b 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 publishable key before packaging clients. - Builds four artifacts in parallel for both channels: - macOS `arm64` DMG - macOS `x64` DMG @@ -29,6 +30,57 @@ 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`. It also +supports manual dispatch for retries. The release workflow reads the relay URL and Clerk +publishable key 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: + +- `T3_RELAY_DOMAIN` +- `T3_RELAY_ZONE_NAME` +- `T3CODE_CLERK_PUBLISHABLE_KEY` +- `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 +bun --cwd infra/relay run 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 +137,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/infra/relay/README.md b/infra/relay/README.md index 828553020e1..8d3525ed034 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -88,6 +88,63 @@ connectivity, and relay tracing resources. Copy [`infra/relay/.env.example`](./. `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 +bun --cwd infra/relay run deploy -- --stage prod +bun --cwd infra/relay run deploy -- --stage "$USER" --env-file .env.local +``` + +After a successful deploy, the wrapper updates the repository-root `.env` file with the relay URL +derived from `T3_RELAY_DOMAIN`. 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`, with a manual dispatch available for +retries. 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: + +- `T3_RELAY_DOMAIN` +- `T3_RELAY_ZONE_NAME` +- `T3CODE_CLERK_PUBLISHABLE_KEY` +- `CLERK_CLI_OAUTH_CLIENT_ID` +- `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 From 56d1cbebdbbd55e82d20938a1eb1b685559787ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 15:37:20 -0700 Subject: [PATCH 20/61] feat(cloud): isolate relay resources by stage Co-authored-by: codex --- infra/relay/.env.example | 8 +- infra/relay/README.md | 16 +- infra/relay/alchemy.run.ts | 1 + infra/relay/package.json | 2 +- infra/relay/scripts/deploy.test.ts | 93 +++++++++++ infra/relay/scripts/deploy.ts | 156 ++++++++++++++++++ infra/relay/src/Config.ts | 2 + .../src/agentActivity/ApnsDeliveries.test.ts | 2 + .../agentActivity/MobileRegistrations.test.ts | 2 + infra/relay/src/auth/RelayTokens.test.ts | 2 + infra/relay/src/db.ts | 36 +++- infra/relay/src/dbConfig.test.ts | 11 ++ infra/relay/src/dbConfig.ts | 5 + infra/relay/src/deploymentConfig.test.ts | 52 ++++++ infra/relay/src/deploymentConfig.ts | 59 +++++++ .../environments/EnvironmentConnector.test.ts | 2 + .../environments/EnvironmentLinker.test.ts | 2 + .../EnvironmentPublishSignatures.test.ts | 2 + .../ManagedEndpointProvider.test.ts | 110 ++++++------ .../environments/ManagedEndpointProvider.ts | 44 ++--- infra/relay/src/http/Api.test.ts | 79 +++++++++ infra/relay/src/http/Api.ts | 56 ++++++- infra/relay/src/worker.ts | 7 +- infra/relay/src/zone.ts | 37 +++-- 24 files changed, 658 insertions(+), 128 deletions(-) create mode 100644 infra/relay/scripts/deploy.test.ts create mode 100644 infra/relay/scripts/deploy.ts create mode 100644 infra/relay/src/dbConfig.test.ts create mode 100644 infra/relay/src/dbConfig.ts create mode 100644 infra/relay/src/deploymentConfig.test.ts create mode 100644 infra/relay/src/deploymentConfig.ts diff --git a/infra/relay/.env.example b/infra/relay/.env.example index 591ceefa5b4..a227a157068 100644 --- a/infra/relay/.env.example +++ b/infra/relay/.env.example @@ -1,7 +1,9 @@ -# Relay deployment overrides. The public relay URL is derived as -# https://. -T3_RELAY_DOMAIN=relay.example.com +# Relay deployment configuration. Production derives relay.; personal +# stages derive relay-., matching Alchemy resource naming. T3_RELAY_ZONE_NAME=example.com +# Optional explicit public relay domain override. +# T3_RELAY_DOMAIN=relay.example.com +T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... # Relay runtime secrets and APNs configuration. CLERK_SECRET_KEY=sk_test_... diff --git a/infra/relay/README.md b/infra/relay/README.md index 8d3525ed034..abeec1bc86b 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -95,12 +95,17 @@ developer stages: ```sh bun --cwd infra/relay run deploy -- --stage prod -bun --cwd infra/relay run deploy -- --stage "$USER" --env-file .env.local +bun --cwd infra/relay run deploy -- --env-file .env.local ``` -After a successful deploy, the wrapper updates the repository-root `.env` file with the relay URL -derived from `T3_RELAY_DOMAIN`. That makes subsequent source builds point at the relay that was just -deployed without copying the URL manually. +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.`. +`T3_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 @@ -125,10 +130,9 @@ The repository must define these Actions secrets shared by relay deployments: The `production` GitHub environment must define these Actions variables: -- `T3_RELAY_DOMAIN` - `T3_RELAY_ZONE_NAME` +- `T3_RELAY_DOMAIN` if overriding the derived production relay domain - `T3CODE_CLERK_PUBLISHABLE_KEY` -- `CLERK_CLI_OAUTH_CLIENT_ID` - `APNS_ENVIRONMENT` - `APNS_TEAM_ID` - `APNS_KEY_ID` diff --git a/infra/relay/alchemy.run.ts b/infra/relay/alchemy.run.ts index 4e9bd17495f..472d6b95a5c 100644 --- a/infra/relay/alchemy.run.ts +++ b/infra/relay/alchemy.run.ts @@ -30,6 +30,7 @@ export default Alchemy.Stack( return { databaseName: db.database.name, + databaseBranchName: db.branch?.name ?? "main", hyperdriveName: hyperdrive.name, workerName: api.workerName, url: api.url, diff --git a/infra/relay/package.json b/infra/relay/package.json index 996b5980e50..f31f1b76b90 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "deploy": "alchemy deploy", + "deploy": "node scripts/deploy.ts", "destroy": "alchemy destroy", "test": "vitest run", "typecheck": "tsgo --noEmit" diff --git a/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts new file mode 100644 index 00000000000..e16be4fa41f --- /dev/null +++ b/infra/relay/scripts/deploy.test.ts @@ -0,0 +1,93 @@ +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { describe, expect, it } from "vitest"; + +import { + makeDeployConfigProvider, + readEnvFileArgument, + readStageArgument, + reconcileRootEnvRelayUrl, + resolveRelayDeployDomain, +} from "./deploy.ts"; + +describe("readEnvFileArgument", () => { + it("supports separated and inline Alchemy env file flags", () => { + expect(readEnvFileArgument(["--stage", "preview", "--env-file", ".env.preview"])).toBe( + ".env.preview", + ); + expect(readEnvFileArgument(["--env-file=.env.preview"])).toBe(".env.preview"); + }); +}); + +describe("readStageArgument", () => { + it("supports separated and inline Alchemy stage flags", () => { + expect(readStageArgument(["--stage", "dev_julius"])).toBe("dev_julius"); + expect(readStageArgument(["--stage=dev_julius"])).toBe("dev_julius"); + }); +}); + +describe("resolveRelayDeployDomain", () => { + it("derives personal stage domains from the imported Cloudflare zone", () => { + expect( + resolveRelayDeployDomain({ + relayDomainOverride: Option.none(), + stage: "dev_julius", + zoneName: "example.test", + }), + ).toBe("relay-dev-julius.example.test"); + }); + + it("preserves explicit domain overrides", () => { + expect( + resolveRelayDeployDomain({ + relayDomainOverride: Option.some("relay.override.test"), + stage: "dev_julius", + zoneName: "example.test", + }), + ).toBe("relay.override.test"); + }); +}); + +describe("makeDeployConfigProvider", () => { + it("prefers injected environment values while retaining dotenv fallbacks", async () => { + const provider = makeDeployConfigProvider( + ConfigProvider.fromEnv({ env: { T3_RELAY_DOMAIN: "ci.example.test" } }), + ConfigProvider.fromEnv({ + env: { + T3_RELAY_DOMAIN: "dotenv.example.test", + T3_RELAY_ZONE_NAME: "example.test", + }, + }), + ); + const config = Config.all({ + relayDomain: Config.string("T3_RELAY_DOMAIN"), + relayZoneName: Config.string("T3_RELAY_ZONE_NAME"), + }).pipe(Effect.provide(ConfigProvider.layer(provider))); + + await expect(Effect.runPromise(config)).resolves.toEqual({ + relayDomain: "ci.example.test", + relayZoneName: "example.test", + }); + }); +}); + +describe("reconcileRootEnvRelayUrl", () => { + it("adds the relay URL to an empty root env file", () => { + expect(reconcileRootEnvRelayUrl("", "https://relay.example.test")).toBe( + "T3_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\nT3_RELAY_URL=https://old.example.test\n", + "https://relay.example.test", + ), + ).toBe( + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example\nT3_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..4441f08b863 --- /dev/null +++ b/infra/relay/scripts/deploy.ts @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import { ChildProcess } from "effect/unstable/process"; + +import { relayPublicDomainForStage } from "../src/deploymentConfig.ts"; + +export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{ + readonly message: string; +}> {} + +export function readEnvFileArgument(args: ReadonlyArray): string | undefined { + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--env-file") { + return args[index + 1]; + } + if (argument?.startsWith("--env-file=")) { + return argument.slice("--env-file=".length); + } + } + return undefined; +} + +export function readStageArgument(args: ReadonlyArray): string | undefined { + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--stage") { + return args[index + 1]; + } + if (argument?.startsWith("--stage=")) { + return argument.slice("--stage=".length); + } + } + return undefined; +} + +export function resolveRelayDeployDomain(input: { + readonly relayDomainOverride: Option.Option; + readonly stage: string; + readonly zoneName: string; +}): string { + return Option.getOrElse(input.relayDomainOverride, () => + relayPublicDomainForStage(input.stage, input.zoneName), + ); +} + +export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string { + const entry = `T3_RELAY_URL=${relayUrl}`; + if (/^T3_RELAY_URL=.*$/mu.test(contents)) { + return contents.replace(/^T3_RELAY_URL=.*$/mu, entry); + } + if (!contents) { + return `${entry}\n`; + } + return `${contents}${contents.endsWith("\n") ? "" : "\n"}${entry}\n`; +} + +export function makeDeployConfigProvider( + environmentProvider: ConfigProvider.ConfigProvider, + dotenvProvider?: ConfigProvider.ConfigProvider, +) { + return dotenvProvider + ? ConfigProvider.orElse(environmentProvider, dotenvProvider) + : environmentProvider; +} + +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* ( + args: ReadonlyArray, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* relayRoot; + const selectedEnvFile = readEnvFileArgument(args); + const envFile = selectedEnvFile ? path.resolve(root, selectedEnvFile) : path.join(root, ".env"); + if (!(yield* fs.exists(envFile))) { + return makeDeployConfigProvider(ConfigProvider.fromEnv()); + } + return makeDeployConfigProvider( + ConfigProvider.fromEnv(), + yield* ConfigProvider.fromDotEnv({ path: envFile }), + ); +}); + +const runAlchemyDeploy = Effect.fn("relay.deploy.runAlchemy")(function* ( + args: ReadonlyArray, +) { + const root = yield* relayRoot; + const child = yield* ChildProcess.make("alchemy", ["deploy", ...args], { + cwd: root, + detached: false, + stderr: "inherit", + stdin: "inherit", + stdout: "inherit", + }); + const exitCode = yield* child.exitCode; + if (exitCode !== 0) { + return yield* new RelayDeployError({ + message: `alchemy deploy exited with code ${exitCode}`, + }); + } +}); + +const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* ( + args: ReadonlyArray, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* repoRoot; + const provider = yield* loadDeployConfigProvider(args); + const config = yield* Config.all({ + relayDomainOverride: Config.nonEmptyString("T3_RELAY_DOMAIN").pipe(Config.option), + stage: Config.nonEmptyString("stage").pipe( + Config.option, + Config.map( + Option.getOrElse(() => `dev_${process.env.USER ?? process.env.USERNAME ?? "unknown"}`), + ), + ), + zoneName: Config.nonEmptyString("T3_RELAY_ZONE_NAME"), + }).pipe(Effect.provide(ConfigProvider.layer(provider))); + const relayDomain = resolveRelayDeployDomain({ + ...config, + stage: readStageArgument(args) ?? config.stage, + }); + const relayUrl = `https://${relayDomain}`; + 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 T3_RELAY_URL=${relayUrl}`); +}); + +export const deploy = Effect.fn("relay.deploy")(function* (args: ReadonlyArray) { + yield* runAlchemyDeploy(args); + yield* reconcileRootEnv(args); +}); + +if (import.meta.main) { + deploy(process.argv.slice(2)).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain); +} diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index 685a2f0bd3d..95a020cf4e9 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -17,10 +17,12 @@ export interface RelayConfigurationShape { readonly relayIssuer: string; readonly apns: ApnsCredentials; readonly clerkSecretKey: Redacted.Redacted; + readonly clerkPublishableKey: 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< diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 54ccee62e6d..9e2d418aa1f 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -38,9 +38,11 @@ const config = RelayConfiguration.RelayConfiguration.of({ }, apnsDeliveryJobSigningSecret: Redacted.make("job-signing-secret"), clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", cloudMintPrivateKey: Redacted.make("cloud-private-key"), cloudMintPublicKey: "cloud-public-key", managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, }); const apnsSigningKeyPair = NodeCrypto.generateKeyPairSync("ec", { diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index d548b925ef1..8227667273d 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -128,10 +128,12 @@ const config = RelayConfiguration.RelayConfiguration.of({ privateKey: Redacted.make("apns-private-key"), }, clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"), cloudMintPrivateKey: Redacted.make("cloud-private-key"), cloudMintPublicKey: "cloud-public-key", managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, }); function makeRegistrationReplayLayer(input: { diff --git a/infra/relay/src/auth/RelayTokens.test.ts b/infra/relay/src/auth/RelayTokens.test.ts index 4c48dc157c8..afdb69ce769 100644 --- a/infra/relay/src/auth/RelayTokens.test.ts +++ b/infra/relay/src/auth/RelayTokens.test.ts @@ -25,9 +25,11 @@ const config = RelayConfiguration.RelayConfiguration.of({ }, apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", cloudMintPrivateKey: Redacted.make(keyPair.privateKey), cloudMintPublicKey: keyPair.publicKey, managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, }); const layer = RelayTokens.layer.pipe( diff --git a/infra/relay/src/db.ts b/infra/relay/src/db.ts index e4094240d9f..8d938bb3fe4 100644 --- a/infra/relay/src/db.ts +++ b/infra/relay/src/db.ts @@ -2,10 +2,14 @@ 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; } @@ -13,26 +17,42 @@ export interface RelayDatabase extends EffectPgDatabase { 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 database = yield* Planetscale.PostgresDatabase("RelayPostgresDatabase", { - region: { slug: "us-west" }, - clusterSize: "PS_5", - migrationsDir: schema.out, - migrationsTable: "relay_migrations", - replicas: 0, // BUMP BEFORE GOING TO PROD - }); + const mode = relayDatabaseMode(stage); + const database = + mode === "shared-database" + ? yield* Planetscale.PostgresDatabase("RelayPostgresDatabase", { + 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 { database, runtimeRole }; + return { branch, database, runtimeRole }; }); export const RelayHyperdrive = Effect.gen(function* () { 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..29223472b47 --- /dev/null +++ b/infra/relay/src/deploymentConfig.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { + managedEndpointDigestInput, + managedEndpointHostname, + managedEndpointTunnelName, + relayPublicDomainForStage, + 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("managed endpoint names", () => { + it("uses the stage slug and a stable stage-scoped digest suffix", () => { + const hash = "ABCDEF0123456789ABCDEF0123456789"; + + expect(managedEndpointDigestInput("dev_julius", "env_123")).toBe("dev_julius: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$/); + }); +}); diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts new file mode 100644 index 00000000000..c298087bdbf --- /dev/null +++ b/infra/relay/src/deploymentConfig.ts @@ -0,0 +1,59 @@ +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"; + +function normalizeZoneName(zoneName: string): string { + return zoneName + .trim() + .toLowerCase() + .replace(/^\.+|\.+$/g, ""); +} + +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 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, environmentId: string): string { + return `${stage}:${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 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 index 3232949db0d..7587b744b81 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -65,9 +65,11 @@ const settings = RelayConfiguration.RelayConfiguration.of({ }, apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", cloudMintPrivateKey: Redacted.make(cloudKeyPair.privateKey), cloudMintPublicKey: cloudKeyPair.publicKey, managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, }); function signTestJwt(payload: object, typ: string, privateKey: string): string { diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index 7edf9a91079..7a8843cb05b 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -38,9 +38,11 @@ const config = RelayConfiguration.RelayConfiguration.of({ }, apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", cloudMintPrivateKey: Redacted.make(relayKeyPair.privateKey), cloudMintPublicKey: relayKeyPair.publicKey, managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, }); function signTestJwt(payload: object, typ: string, privateKey: string): string { diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index fc056eff8f1..c6f5a84034e 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -33,9 +33,11 @@ const config = RelayConfiguration.RelayConfiguration.of({ }, apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", cloudMintPrivateKey: Redacted.make(keyPair.privateKey), cloudMintPublicKey: keyPair.publicKey, managedEndpointBaseDomain: undefined, + managedEndpointNamespace: undefined, }); const state: RelayAgentActivityState = { environmentId: "env" as RelayAgentActivityState["environmentId"], diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 0c2cb8869ad..1b8bcde6721 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -20,9 +20,11 @@ const config = RelayConfiguration.RelayConfiguration.of({ }, apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), clerkSecretKey: Redacted.make("clerk-secret"), + clerkPublishableKey: "pk_test_test", cloudMintPrivateKey: Redacted.make("cloud-private-key"), cloudMintPublicKey: "cloud-public-key", managedEndpointBaseDomain: "t3code.test", + managedEndpointNamespace: "dev_julius", }); interface TunnelCall { @@ -90,13 +92,19 @@ function providerLayer(tunnelClient = makeTunnelClient(), dnsClient = makeDnsCli } function expectedManagedHostname(environmentId: string): string { - const hash = NodeCrypto.createHash("sha256").update(environmentId).digest("hex").slice(0, 16); - return `tunnels-env-abc-${hash}.t3code.test`; + const hash = NodeCrypto.createHash("sha256") + .update(`dev_julius:${environmentId}`) + .digest("hex") + .slice(0, 16); + return `tunnels-dev-julius-${hash}.t3code.test`; } function expectedManagedTunnelName(environmentId: string): string { - const hash = NodeCrypto.createHash("sha256").update(environmentId).digest("hex").slice(0, 16); - return `t3-code-env-abc-${hash}`; + const hash = NodeCrypto.createHash("sha256") + .update(`dev_julius:${environmentId}`) + .digest("hex") + .slice(0, 16); + return `t3coderelay-managedendpoint-dev-julius-${hash}`; } describe("ManagedEndpointProvider", () => { @@ -162,58 +170,52 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls), makeDnsClient(dnsCalls)))); }); - it.effect( - "normalizes unusual environment ids before using them in Cloudflare tunnel names", - () => { - const tunnelCalls: TunnelCall[] = []; + 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({ - environmentId, - origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, - }); + return Effect.gen(function* () { + const environmentId = "ENV With Spaces/../Symbols!" + "x".repeat(80); + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + yield* provider.provision({ + 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(/^t3-code-env-with-spaces-symbols-x+-[a-f0-9]{16}$/); - expect(requestedName?.length).toBeLessThanOrEqual(89); - const configBody = ( - tunnelCalls.find((call) => call.operation === "putConfiguration")?.input as - | { readonly tunnelConfig?: unknown } - | undefined - )?.tunnelConfig; - expect(configBody).toMatchObject({ - ingress: [ - { - hostname: expect.stringMatching( - /^tunnels-env-with-spaces-symbols-x+-[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)))); - }, - ); + 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[] = []; diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index c2a98b8f3b2..78ce919dc98 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -17,6 +17,11 @@ import type { } from "@t3tools/contracts/relay"; import * as RelayConfiguration from "../Config.ts"; +import { + managedEndpointDigestInput, + managedEndpointHostname, + managedEndpointTunnelName, +} from "../deploymentConfig.ts"; export class ManagedEndpointProvisioningNotConfigured extends Data.TaggedError( "ManagedEndpointProvisioningNotConfigured", @@ -132,41 +137,15 @@ export class ManagedEndpointDnsClient extends Context.Service< const requireCloudflareSettings = Effect.fnUntraced(function* ( settings: RelayConfiguration.RelayConfigurationShape, ) { - if (!settings.managedEndpointBaseDomain) { + if (!settings.managedEndpointBaseDomain || !settings.managedEndpointNamespace) { return yield* new ManagedEndpointProvisioningNotConfigured(); } return { baseDomain: settings.managedEndpointBaseDomain, + namespace: settings.managedEndpointNamespace, }; }); -const MANAGED_ENDPOINT_HOST_PREFIX = "tunnels"; -const DNS_LABEL_MAX_LENGTH = 63; -const MANAGED_ENDPOINT_HASH_LENGTH = 16; -const MANAGED_ENDPOINT_SAFE_ID_LENGTH = - DNS_LABEL_MAX_LENGTH - MANAGED_ENDPOINT_HOST_PREFIX.length - 2 - MANAGED_ENDPOINT_HASH_LENGTH; - -function managedHostname(environmentId: string, baseDomain: string, hash: string): string { - const safeId = environmentId - .toLowerCase() - .replaceAll(/[^a-z0-9-]/g, "-") - .replaceAll(/-+/g, "-") - .replaceAll(/^-+|-+$/g, "") - .slice(0, MANAGED_ENDPOINT_SAFE_ID_LENGTH); - const prefix = safeId.length > 0 ? safeId : "env"; - return `${MANAGED_ENDPOINT_HOST_PREFIX}-${prefix}-${hash.slice(0, MANAGED_ENDPOINT_HASH_LENGTH)}.${baseDomain.replace(/^\.+|\.+$/g, "")}`; -} - -function managedTunnelName(environmentId: string, hash: string): string { - const safeId = environmentId - .toLowerCase() - .replaceAll(/[^a-z0-9-]/g, "-") - .replaceAll(/-+/g, "-") - .replaceAll(/^-+|-+$/g, "") - .slice(0, 64); - return `t3-code-${safeId.length > 0 ? safeId : "env"}-${hash.slice(0, 16)}`; -} - function formatOriginService(origin: RelayManagedEndpointOrigin): string { const host = origin.localHttpHost.includes(":") ? `[${origin.localHttpHost.replace(/^\[(.*)\]$/u, "$1")}]` @@ -212,13 +191,16 @@ const make = Effect.gen(function* () { } const cf = yield* requireCloudflareSettings(config); const environmentHash = yield* crypto - .digest("SHA-256", new TextEncoder().encode(input.environmentId)) + .digest( + "SHA-256", + new TextEncoder().encode(managedEndpointDigestInput(cf.namespace, input.environmentId)), + ) .pipe( Effect.map(Encoding.encodeHex), Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), ); - const hostname = managedHostname(input.environmentId, cf.baseDomain, environmentHash); - const tunnelName = managedTunnelName(input.environmentId, environmentHash); + const hostname = managedEndpointHostname(cf.namespace, cf.baseDomain, environmentHash); + const tunnelName = managedEndpointTunnelName(cf.namespace, environmentHash); const tunnel = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe( Effect.map((tunnels) => tunnels.result), diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 9eccb8d16b0..f0a49bd34bc 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -1,4 +1,6 @@ +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"; @@ -16,10 +18,87 @@ import { 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", + 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.relayIssuer, + } as never); + + expect(yield* verifyRelayClientBearerToken(relaySettings, "session-token")).toEqual({ + sub: "user_session", + mode: "clerk_session_bearer", + }); + 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({ diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 451c198169b..4b79a858ff3 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -1,4 +1,4 @@ -import { verifyToken } from "@clerk/backend"; +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"; @@ -188,7 +188,7 @@ export const relayClientAuthLayer = Layer.effect( return { bearer: Effect.fn("relay.auth.client.bearer")(function* (httpEffect, { credential }) { const token = readHttpAuthorizationCredential(credential); - const verified = yield* verifyClerkBearerToken(config, token).pipe( + const verified = yield* verifyRelayClientBearerToken(config, token).pipe( Effect.tapError((error) => Effect.annotateCurrentSpan( "relay.auth.clerk_verification_failure", @@ -197,16 +197,14 @@ export const relayClientAuthLayer = Layer.effect( ), Effect.catch(() => relayAuthInvalidError("invalid_bearer")), ); - if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.relayIssuer)) { + if (!verified.sub) { yield* Effect.annotateCurrentSpan({ - "relay.auth.clerk_verification_failure": !verified.sub - ? "missing_subject" - : "missing_relay_audience", + "relay.auth.clerk_verification_failure": "missing_subject", }); return yield* relayAuthInvalidError("invalid_bearer"); } yield* Effect.annotateCurrentSpan({ - "relay.auth.mode": "clerk_bearer", + "relay.auth.mode": verified.mode, "relay.auth.subject": verified.sub, }); @@ -985,6 +983,50 @@ function verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationSha ); } +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.relayIssuer) + ? 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, ) { diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index c44bff79d8d..f5f07964a63 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -88,7 +88,7 @@ const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningS export default class Api extends Cloudflare.Worker()( "Api", RelayDeploymentConfig.pipe( - Config.map(({ relayPublicDomain }) => ({ + Effect.map(({ relayPublicDomain }) => ({ main: import.meta.filename, compatibility: { date: "2026-05-22", @@ -102,7 +102,7 @@ export default class Api extends Cloudflare.Worker()( // // 1. Provision Infrastructure for the Worker to use // - const { relayPublicOrigin } = yield* RelayDeploymentConfig; + const { relayPublicOrigin, stage } = yield* RelayDeploymentConfig; const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; const cloudMintKeyPair = yield* CloudMintKeyPair; @@ -129,6 +129,7 @@ export default class Api extends Cloudflare.Worker()( const axiomTracesEndpoint = yield* observability.traces.otelTracesEndpoint; const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY"); + const clerkPublishableKey = yield* Config.string("T3CODE_CLERK_PUBLISHABLE_KEY"); const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; @@ -156,9 +157,11 @@ export default class Api extends Cloudflare.Worker()( }, apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret, clerkSecretKey, + clerkPublishableKey, cloudMintPrivateKey: yield* cloudMintPrivateKey, cloudMintPublicKey: yield* cloudMintPublicKey, managedEndpointBaseDomain: yield* managedEndpointZoneName, + managedEndpointNamespace: stage, }); }); diff --git a/infra/relay/src/zone.ts b/infra/relay/src/zone.ts index 12022bb04dd..bd48cea5355 100644 --- a/infra/relay/src/zone.ts +++ b/infra/relay/src/zone.ts @@ -1,26 +1,31 @@ +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 { DEFAULT_T3_RELAY_DOMAIN, DEFAULT_T3_RELAY_ZONE_NAME } from "@t3tools/shared/relayAuth"; +import { relayPublicDomainForStage } from "./deploymentConfig.ts"; -export const RelayDeploymentConfig = Config.all({ - relayPublicDomain: Config.string("T3_RELAY_DOMAIN").pipe( - Config.withDefault(DEFAULT_T3_RELAY_DOMAIN), - ), - managedEndpointZoneName: Config.string("T3_RELAY_ZONE_NAME").pipe( - Config.withDefault(DEFAULT_T3_RELAY_ZONE_NAME), - ), -}).pipe( - Config.map((config) => ({ - relayPublicDomain: config.relayPublicDomain, - relayPublicOrigin: `https://${config.relayPublicDomain}`, - managedEndpointZoneName: config.managedEndpointZoneName, - })), -); +export const RelayDeploymentConfig = Effect.gen(function* () { + const stage = yield* Alchemy.Stage; + const managedEndpointZoneName = yield* Config.nonEmptyString("T3_RELAY_ZONE_NAME"); + const relayPublicDomainOverride = yield* Config.nonEmptyString("T3_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( - Config.map(({ managedEndpointZoneName }) => managedEndpointZoneName), + Effect.map(({ managedEndpointZoneName }) => managedEndpointZoneName), Effect.flatMap((name) => Cloudflare.Zone("ManagedEndpointZone", { name }).pipe(adopt(true))), ); From 10f269ceabd265c8a60748899f90ccd26a892153 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 15:55:24 -0700 Subject: [PATCH 21/61] ci(cloud): stop injecting CLI oauth config into relay Co-authored-by: codex --- .github/workflows/deploy-relay.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index fdcb3e6c71b..351942a1f9d 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -33,7 +33,6 @@ jobs: T3_RELAY_ZONE_NAME: ${{ vars.T3_RELAY_ZONE_NAME }} T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} APNS_TEAM_ID: ${{ vars.APNS_TEAM_ID }} APNS_KEY_ID: ${{ vars.APNS_KEY_ID }} From 8cf8c13d8456cf7ffdeb487ca5f7ebf05a894859 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 17:06:58 -0700 Subject: [PATCH 22/61] refactor(http): use context-backed API clients Co-authored-by: codex --- apps/server/src/cli/project.ts | 8 ++------ packages/client-runtime/src/remote.ts | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) 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/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts index 896a218ade7..41b4dce7312 100644 --- a/packages/client-runtime/src/remote.ts +++ b/packages/client-runtime/src/remote.ts @@ -170,12 +170,8 @@ 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( From e31b0f794fe544640c382aec581e10688d6da624 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 17:33:55 -0700 Subject: [PATCH 23/61] refactor(relay): deploy stack programmatically Co-authored-by: codex --- infra/relay/package.json | 2 +- infra/relay/scripts/deploy.test.ts | 67 ++++----- infra/relay/scripts/deploy.ts | 228 +++++++++++++++++++---------- infra/relay/tsconfig.json | 2 +- 4 files changed, 176 insertions(+), 123 deletions(-) diff --git a/infra/relay/package.json b/infra/relay/package.json index f31f1b76b90..7706a89dfe6 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "deploy": "node scripts/deploy.ts", + "deploy": "node -- scripts/deploy.ts", "destroy": "alchemy destroy", "test": "vitest run", "typecheck": "tsgo --noEmit" diff --git a/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts index e16be4fa41f..6fbb4462d5f 100644 --- a/infra/relay/scripts/deploy.test.ts +++ b/infra/relay/scripts/deploy.test.ts @@ -1,52 +1,37 @@ import * as Config from "effect/Config"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import { describe, expect, it } from "vitest"; -import { - makeDeployConfigProvider, - readEnvFileArgument, - readStageArgument, - reconcileRootEnvRelayUrl, - resolveRelayDeployDomain, -} from "./deploy.ts"; +import { hasDeployChanges, makeDeployConfigProvider, reconcileRootEnvRelayUrl } from "./deploy.ts"; -describe("readEnvFileArgument", () => { - it("supports separated and inline Alchemy env file flags", () => { - expect(readEnvFileArgument(["--stage", "preview", "--env-file", ".env.preview"])).toBe( - ".env.preview", - ); - expect(readEnvFileArgument(["--env-file=.env.preview"])).toBe(".env.preview"); - }); -}); - -describe("readStageArgument", () => { - it("supports separated and inline Alchemy stage flags", () => { - expect(readStageArgument(["--stage", "dev_julius"])).toBe("dev_julius"); - expect(readStageArgument(["--stage=dev_julius"])).toBe("dev_julius"); - }); -}); - -describe("resolveRelayDeployDomain", () => { - it("derives personal stage domains from the imported Cloudflare zone", () => { +describe("hasDeployChanges", () => { + it("detects resource, binding, and deletion changes", () => { + expect(hasDeployChanges({ resources: {}, deletions: {} } as never)).toBe(false); expect( - resolveRelayDeployDomain({ - relayDomainOverride: Option.none(), - stage: "dev_julius", - zoneName: "example.test", - }), - ).toBe("relay-dev-julius.example.test"); - }); - - it("preserves explicit domain overrides", () => { + hasDeployChanges({ + resources: { + api: { action: "create", bindings: [] }, + }, + deletions: {}, + } as never), + ).toBe(true); expect( - resolveRelayDeployDomain({ - relayDomainOverride: Option.some("relay.override.test"), - stage: "dev_julius", - zoneName: "example.test", - }), - ).toBe("relay.override.test"); + 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); }); }); diff --git a/infra/relay/scripts/deploy.ts b/infra/relay/scripts/deploy.ts index 4441f08b863..79f35fbc40e 100644 --- a/infra/relay/scripts/deploy.ts +++ b/infra/relay/scripts/deploy.ts @@ -1,57 +1,44 @@ #!/usr/bin/env node import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; -import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AdoptPolicy } from "alchemy/AdoptPolicy"; +import { AlchemyContext, AlchemyContextLive } from "alchemy/AlchemyContext"; +import { 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 { 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 { ChildProcess } from "effect/unstable/process"; +import { Command, Flag, Prompt } from "effect/unstable/cli"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; -import { relayPublicDomainForStage } from "../src/deploymentConfig.ts"; +import RelayStack from "../alchemy.run.ts"; export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{ readonly message: string; }> {} -export function readEnvFileArgument(args: ReadonlyArray): string | undefined { - for (let index = 0; index < args.length; index += 1) { - const argument = args[index]; - if (argument === "--env-file") { - return args[index + 1]; - } - if (argument?.startsWith("--env-file=")) { - return argument.slice("--env-file=".length); - } - } - return undefined; -} - -export function readStageArgument(args: ReadonlyArray): string | undefined { - for (let index = 0; index < args.length; index += 1) { - const argument = args[index]; - if (argument === "--stage") { - return args[index + 1]; - } - if (argument?.startsWith("--stage=")) { - return argument.slice("--stage=".length); - } - } - return undefined; -} - -export function resolveRelayDeployDomain(input: { - readonly relayDomainOverride: Option.Option; - readonly stage: string; - readonly zoneName: string; -}): string { - return Option.getOrElse(input.relayDomainOverride, () => - relayPublicDomainForStage(input.stage, input.zoneName), - ); +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 { @@ -74,6 +61,16 @@ export function makeDeployConfigProvider( : environmentProvider; } +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))), ); @@ -82,12 +79,12 @@ const repoRoot = Effect.service(Path.Path).pipe( ); const loadDeployConfigProvider = Effect.fn("relay.deploy.loadConfigProvider")(function* ( - args: ReadonlyArray, + envFileOverride: Option.Option, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* relayRoot; - const selectedEnvFile = readEnvFileArgument(args); + const selectedEnvFile = Option.getOrUndefined(envFileOverride); const envFile = selectedEnvFile ? path.resolve(root, selectedEnvFile) : path.join(root, ".env"); if (!(yield* fs.exists(envFile))) { return makeDeployConfigProvider(ConfigProvider.fromEnv()); @@ -98,47 +95,17 @@ const loadDeployConfigProvider = Effect.fn("relay.deploy.loadConfigProvider")(fu ); }); -const runAlchemyDeploy = Effect.fn("relay.deploy.runAlchemy")(function* ( - args: ReadonlyArray, -) { - const root = yield* relayRoot; - const child = yield* ChildProcess.make("alchemy", ["deploy", ...args], { - cwd: root, - detached: false, - stderr: "inherit", - stdin: "inherit", - stdout: "inherit", - }); - const exitCode = yield* child.exitCode; - if (exitCode !== 0) { - return yield* new RelayDeployError({ - message: `alchemy deploy exited with code ${exitCode}`, - }); - } -}); +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* ( - args: ReadonlyArray, -) { +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 provider = yield* loadDeployConfigProvider(args); - const config = yield* Config.all({ - relayDomainOverride: Config.nonEmptyString("T3_RELAY_DOMAIN").pipe(Config.option), - stage: Config.nonEmptyString("stage").pipe( - Config.option, - Config.map( - Option.getOrElse(() => `dev_${process.env.USER ?? process.env.USERNAME ?? "unknown"}`), - ), - ), - zoneName: Config.nonEmptyString("T3_RELAY_ZONE_NAME"), - }).pipe(Effect.provide(ConfigProvider.layer(provider))); - const relayDomain = resolveRelayDeployDomain({ - ...config, - stage: readStageArgument(args) ?? config.stage, - }); - const relayUrl = `https://${relayDomain}`; const rootEnvPath = path.join(root, ".env"); const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : ""; @@ -146,11 +113,112 @@ const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* ( yield* Console.log(`Updated ${rootEnvPath} with T3_RELAY_URL=${relayUrl}`); }); -export const deploy = Effect.fn("relay.deploy")(function* (args: ReadonlyArray) { - yield* runAlchemyDeploy(args); - yield* reconcileRootEnv(args); +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, +) { + return yield* Effect.gen(function* () { + const stack = yield* RelayStack; + return yield* Effect.gen(function* () { + const cli = yield* Cli; + const plan = yield* Plan.make(stack, { force: options.force }); + 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(plan); + if (output.url === undefined) { + return yield* new RelayDeployError({ + message: "Alchemy relay deploy output did not include a URL", + }); + } + return Option.some(output.url); + }).pipe(provideFreshArtifactStore, Effect.provide(stack.services)); + }).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), + ), + ), + ); +}); + +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) { - deploy(process.argv.slice(2)).pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain); + Command.run(relayDeployCommand, { version: "0.0.0" }).pipe( + Effect.provide(deployServices), + Effect.scoped, + NodeRuntime.runMain, + ); } diff --git a/infra/relay/tsconfig.json b/infra/relay/tsconfig.json index 67e2a2c7736..6c0840a678b 100644 --- a/infra/relay/tsconfig.json +++ b/infra/relay/tsconfig.json @@ -5,5 +5,5 @@ "lib": ["ES2023", "WebWorker"], "types": ["@cloudflare/workers-types", "node"] }, - "include": ["alchemy.run.ts", "src/**/*.ts"] + "include": ["alchemy.run.ts", "scripts/**/*.ts", "src/**/*.ts"] } From 73f2fdc9c58fb335097543d896a244f0de929d26 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 17:47:28 -0700 Subject: [PATCH 24/61] refactor(relay): simplify deploy wiring Co-authored-by: codex --- infra/relay/scripts/deploy.test.ts | 28 +------- infra/relay/scripts/deploy.ts | 112 +++++++++++++---------------- 2 files changed, 51 insertions(+), 89 deletions(-) diff --git a/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts index 6fbb4462d5f..a53c41139cb 100644 --- a/infra/relay/scripts/deploy.test.ts +++ b/infra/relay/scripts/deploy.test.ts @@ -1,9 +1,6 @@ -import * as Config from "effect/Config"; -import * as ConfigProvider from "effect/ConfigProvider"; -import * as Effect from "effect/Effect"; import { describe, expect, it } from "vitest"; -import { hasDeployChanges, makeDeployConfigProvider, reconcileRootEnvRelayUrl } from "./deploy.ts"; +import { hasDeployChanges, reconcileRootEnvRelayUrl } from "./deploy.ts"; describe("hasDeployChanges", () => { it("detects resource, binding, and deletion changes", () => { @@ -35,29 +32,6 @@ describe("hasDeployChanges", () => { }); }); -describe("makeDeployConfigProvider", () => { - it("prefers injected environment values while retaining dotenv fallbacks", async () => { - const provider = makeDeployConfigProvider( - ConfigProvider.fromEnv({ env: { T3_RELAY_DOMAIN: "ci.example.test" } }), - ConfigProvider.fromEnv({ - env: { - T3_RELAY_DOMAIN: "dotenv.example.test", - T3_RELAY_ZONE_NAME: "example.test", - }, - }), - ); - const config = Config.all({ - relayDomain: Config.string("T3_RELAY_DOMAIN"), - relayZoneName: Config.string("T3_RELAY_ZONE_NAME"), - }).pipe(Effect.provide(ConfigProvider.layer(provider))); - - await expect(Effect.runPromise(config)).resolves.toEqual({ - relayDomain: "ci.example.test", - relayZoneName: "example.test", - }); - }); -}); - describe("reconcileRootEnvRelayUrl", () => { it("adds the relay URL to an empty root env file", () => { expect(reconcileRootEnvRelayUrl("", "https://relay.example.test")).toBe( diff --git a/infra/relay/scripts/deploy.ts b/infra/relay/scripts/deploy.ts index 79f35fbc40e..0948fb41454 100644 --- a/infra/relay/scripts/deploy.ts +++ b/infra/relay/scripts/deploy.ts @@ -3,7 +3,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import { AdoptPolicy } from "alchemy/AdoptPolicy"; import { AlchemyContext, AlchemyContextLive } from "alchemy/AlchemyContext"; -import { apply } from "alchemy/Apply"; +import * as Apply from "alchemy/Apply"; import { provideFreshArtifactStore } from "alchemy/Artifacts"; import { AuthProviders } from "alchemy/Auth/AuthProvider"; import { CredentialsStoreLive } from "alchemy/Auth/Credentials"; @@ -11,7 +11,7 @@ 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 { Stage } from "alchemy/Stage"; +import * as Stage from "alchemy/Stage"; import { TelemetryLive } from "alchemy/Telemetry/Layer"; import { PlatformServices } from "alchemy/Util/PlatformServices"; import * as Config from "effect/Config"; @@ -52,15 +52,6 @@ export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): st return `${contents}${contents.endsWith("\n") ? "" : "\n"}${entry}\n`; } -export function makeDeployConfigProvider( - environmentProvider: ConfigProvider.ConfigProvider, - dotenvProvider?: ConfigProvider.ConfigProvider, -) { - return dotenvProvider - ? ConfigProvider.orElse(environmentProvider, dotenvProvider) - : environmentProvider; -} - export function hasDeployChanges(plan: Plan.Plan): boolean { return ( Object.keys(plan.deletions).length > 0 || @@ -81,18 +72,11 @@ const repoRoot = Effect.service(Path.Path).pipe( const loadDeployConfigProvider = Effect.fn("relay.deploy.loadConfigProvider")(function* ( envFileOverride: Option.Option, ) { - const fs = yield* FileSystem.FileSystem; 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"); - if (!(yield* fs.exists(envFile))) { - return makeDeployConfigProvider(ConfigProvider.fromEnv()); - } - return makeDeployConfigProvider( - ConfigProvider.fromEnv(), - yield* ConfigProvider.fromDotEnv({ path: envFile }), - ); + return yield* ConfigProvider.fromDotEnv({ path: envFile }); }); const relayDeployStage = Config.nonEmptyString("stage").pipe( @@ -122,55 +106,59 @@ const deployServices = Layer.mergeAll( LoggingCli, ); -const runRelayDeploy = Effect.fn("relay.deploy.run")(function* ( - options: RelayDeployOptions, - configProvider: ConfigProvider.ConfigProvider, - stage: string, -) { - return yield* Effect.gen(function* () { +const runRelayDeploy = Effect.fn("relay.deploy.run")( + function* ( + options: RelayDeployOptions, + _configProvider: ConfigProvider.ConfigProvider, + _stage: string, + ) { const stack = yield* RelayStack; - return yield* Effect.gen(function* () { - const cli = yield* Cli; - const plan = yield* Plan.make(stack, { force: options.force }); - if (options.dryRun) { - yield* cli.displayPlan(plan); + + 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(); } - 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(plan); - if (output.url === undefined) { - return yield* new RelayDeployError({ - message: "Alchemy relay deploy output did not include a URL", - }); - } - return Option.some(output.url); - }).pipe(provideFreshArtifactStore, Effect.provide(stack.services)); - }).pipe( - Effect.provide( - Layer.mergeAll( - Layer.effect( - AlchemyContext, - AlchemyContext.pipe(Effect.map((context) => ({ ...context, adopt: options.adopt }))), + } + 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), ), - Layer.succeed(AdoptPolicy, options.adopt), - Layer.succeed(AuthProviders, {}), - ConfigProvider.layer(configProvider), - Layer.succeed(Stage, stage), ), + provideFreshArtifactStore, ), - ); -}); +); export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployOptions) { const configProvider = yield* loadDeployConfigProvider(options.envFile); From c2d004a20e4bf66fcc7f10a8d1e658c8ef57342d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 17:51:00 -0700 Subject: [PATCH 25/61] docs(relay): clarify deployment environment variables Co-authored-by: codex --- infra/relay/.env.example | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/infra/relay/.env.example b/infra/relay/.env.example index a227a157068..0e5f015c258 100644 --- a/infra/relay/.env.example +++ b/infra/relay/.env.example @@ -1,12 +1,20 @@ -# Relay deployment configuration. Production derives relay.; personal -# stages derive relay-., matching Alchemy resource naming. +# Required: Relay domain +# Use the DNS zone managed in your Cloudflare account. Production deploys use +# relay.; personal stages use relay-.. T3_RELAY_ZONE_NAME=example.com -# Optional explicit public relay domain override. + +# Optional: Relay domain override +# Set this only when the derived relay hostname should not be used. # T3_RELAY_DOMAIN=relay.example.com -T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... -# Relay runtime secrets and APNs configuration. +# Required: Clerk +# Get both values from the Clerk Dashboard under API keys. +T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... CLERK_SECRET_KEY=sk_test_... + +# 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=... From 4600dd719f3472430b019ba8e67fff59db96f882 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 18:07:27 -0700 Subject: [PATCH 26/61] feat(cloud): make relay public config opt-in Co-authored-by: codex --- .env.example | 11 +- README.md | 7 +- apps/mobile/README.md | 4 +- apps/mobile/src/app/settings/environments.tsx | 209 +++++++++--------- apps/mobile/src/app/settings/index.tsx | 78 +++++-- apps/mobile/src/app/settings/waitlist.tsx | 51 +---- .../agent-awareness/remoteRegistration.ts | 6 +- .../src/features/cloud/CloudAuthProvider.tsx | 17 +- .../src/features/cloud/linkEnvironment.ts | 7 +- .../src/features/cloud/publicConfig.test.ts | 32 +++ .../mobile/src/features/cloud/publicConfig.ts | 27 +++ apps/mobile/src/lib/runtime.ts | 6 +- apps/web/src/cloud/linkEnvironment.ts | 3 +- apps/web/src/cloud/publicConfig.test.ts | 21 ++ apps/web/src/cloud/publicConfig.ts | 24 ++ .../src/components/settings/CloudSettings.tsx | 18 +- .../settings/ConnectionsSettings.tsx | 32 +-- .../settings/SettingsSidebarNav.tsx | 5 +- apps/web/src/lib/runtime.ts | 4 +- apps/web/src/main.tsx | 3 +- apps/web/src/routes/_chat.index.tsx | 15 +- apps/web/src/routes/settings.cloud.tsx | 8 +- docs/t3-cloud-clerk.md | 44 ++-- packages/shared/src/relayAuth.ts | 9 - scripts/lib/public-config.test.ts | 30 ++- scripts/lib/public-config.ts | 47 ++-- 26 files changed, 410 insertions(+), 308 deletions(-) create mode 100644 apps/mobile/src/features/cloud/publicConfig.test.ts create mode 100644 apps/mobile/src/features/cloud/publicConfig.ts create mode 100644 apps/web/src/cloud/publicConfig.test.ts create mode 100644 apps/web/src/cloud/publicConfig.ts diff --git a/.env.example b/.env.example index 7ab513c2427..83dd4c39bb3 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ -# Optional root-level overrides for the checked-in public client configuration. -# Server-side secrets must never be committed here. -# +# 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 this from the Clerk Dashboard under API keys. # T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... + +# Get this from your relay deployment. `infra/relay` deploys update it automatically. # T3_RELAY_URL=https://relay.example.com diff --git a/README.md b/README.md index 7279d63e2a8..5856f7e1b29 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,9 @@ mise install vp install ``` -T3 Cloud public client configuration has checked-in development defaults, so a fresh clone works -without creating app-local `.env` files. To point web, desktop, and mobile at another Clerk/relay -deployment, copy [`.env.example`](./.env.example) to `.env` at the repository root and set the -canonical overrides there. +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. diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 8054828bc6e..fefd9491a3c 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -16,8 +16,8 @@ This app has three variants: Run commands from `apps/mobile`. -Public T3 Cloud development defaults are shared with web and desktop. Optional overrides belong in -the repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See +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 diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index d5a503bc624..220f10ba597 100644 --- a/apps/mobile/src/app/settings/environments.tsx +++ b/apps/mobile/src/app/settings/environments.tsx @@ -11,6 +11,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment"; +import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; import { useManagedRelayEnvironments, useManagedRelayEnvironmentStatus, @@ -26,22 +27,15 @@ import { } from "../../state/use-remote-environment-registry"; export default function SettingsEnvironmentsRouteScreen() { - const { getToken, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { connectedEnvironments, onReconnectEnvironment, onRemoveEnvironmentPress, onUpdateEnvironment, } = useRemoteConnections(); - const { savedConnectionsById } = useRemoteEnvironmentState(); const insets = useSafeAreaInsets(); const hasEnvironments = connectedEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); - const cloudEnvironmentsState = useManagedRelayEnvironments(); - const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState( - null, - ); - const accentColor = useThemeColor("--color-icon-muted"); const iconColor = useThemeColor("--color-icon"); @@ -49,40 +43,6 @@ export default function SettingsEnvironmentsRouteScreen() { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); }, []); - 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(RELAY_CLERK_TOKEN_OPTIONS); - 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], - ); - return ( )} - {isSignedIn ? ( - - - - 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. - - - )} - - ) : null} + {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(RELAY_CLERK_TOKEN_OPTIONS); + 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; diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index 85d09a1851d..d3f472e5905 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -14,6 +14,7 @@ import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/li import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; +import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; import { mobileRuntime } from "../../lib/runtime"; import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -23,6 +24,44 @@ 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 }); @@ -32,7 +71,6 @@ export default function SettingsRouteScreen() { const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); - const icon = useThemeColor("--color-icon"); const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); const environmentCount = connections.length; const accountLabel = useMemo(() => { @@ -289,19 +327,7 @@ export default function SettingsRouteScreen() { /> - - - - Version - Alpha - - + ); @@ -323,6 +349,26 @@ function SettingsSection(props: { readonly title: string; readonly children: Rea ); } +function AppSettingsSection() { + const icon = useThemeColor("--color-icon"); + + return ( + + + + Version + Alpha + + + ); +} + function SettingsRow(props: { readonly disabled?: boolean; readonly icon: SymbolName; @@ -335,7 +381,7 @@ function SettingsRow(props: { const chevron = useThemeColor("--color-chevron"); const content = ( @@ -386,7 +432,7 @@ function SettingsSwitchRow(props: { return ( diff --git a/apps/mobile/src/app/settings/waitlist.tsx b/apps/mobile/src/app/settings/waitlist.tsx index faab343470e..891a7f4ce28 100644 --- a/apps/mobile/src/app/settings/waitlist.tsx +++ b/apps/mobile/src/app/settings/waitlist.tsx @@ -1,22 +1,20 @@ -import Constants from "expo-constants"; -import { Stack } from "expo-router"; -import { ScrollView, Text, View } from "react-native"; +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"; -import { useThemeColor } from "../../lib/useThemeColor"; -function hasClerkConfig(): boolean { - const clerkConfig = Constants.expoConfig?.extra?.clerk as - | { readonly publishableKey?: string | null } - | undefined; - return Boolean(clerkConfig?.publishableKey); +export default function SettingsWaitlistRouteScreen() { + return hasCloudPublicConfig() ? ( + + ) : ( + + ); } -export default function SettingsWaitlistRouteScreen() { +function ConfiguredSettingsWaitlistRouteScreen() { const { presentAuth } = useNativeClerkAuthModal(); - const foreground = String(useThemeColor("--color-foreground")); - const secondaryForeground = String(useThemeColor("--color-foreground-secondary")); return ( <> @@ -33,34 +31,7 @@ export default function SettingsWaitlistRouteScreen() { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - {hasClerkConfig() ? ( - void presentAuth()} /> - ) : ( - - - T3 Cloud is not configured - - - Add T3CODE_CLERK_PUBLISHABLE_KEY to this build to enable waitlist enrollment. - - - )} + void presentAuth()} /> ); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 0ca8330da96..558cc9ec7ac 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -18,6 +18,7 @@ import { loadPreferences, } from "../../lib/storage"; import type { AgentActivityProps } from "../../widgets/AgentActivity"; +import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; const environmentConnections = new Map(); @@ -38,10 +39,7 @@ export function normalizeAgentAwarenessRelayBaseUrl( } function readRelayConfig(): { readonly url: string } | null { - const relayConfig = Constants.expoConfig?.extra?.relay as - | { readonly url?: string | null } - | undefined; - const relayUrl = normalizeAgentAwarenessRelayBaseUrl(relayConfig?.url); + const relayUrl = resolveCloudPublicConfig().relayUrl; if (!relayUrl) { logRegistrationDebug("relay registration skipped; relay config missing"); return null; diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index aada03d5c98..b0a2c4b0ca5 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -1,7 +1,6 @@ import { ClerkProvider, useAuth } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; -import Constants from "expo-constants"; import { type ReactNode, useEffect, useRef } from "react"; import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; @@ -12,13 +11,7 @@ import { unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; import { refreshActiveLiveActivityRemoteRegistration } from "../agent-awareness/liveActivityController"; - -function readClerkPublishableKey(): string | null { - const clerkConfig = Constants.expoConfig?.extra?.clerk as - | { readonly publishableKey?: string | null } - | undefined; - return clerkConfig?.publishableKey ?? null; -} +import { resolveCloudPublicConfig } from "./publicConfig"; function CloudAuthBridge(props: { readonly children: ReactNode }) { const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); @@ -80,16 +73,16 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { } export function CloudAuthProvider(props: { readonly children: ReactNode }) { - const publishableKey = readClerkPublishableKey(); + const { clerkPublishableKey: publishableKey, relayUrl } = resolveCloudPublicConfig(); useEffect(() => { - if (!publishableKey) { + if (!publishableKey || !relayUrl) { setAgentAwarenessRelayTokenProvider(null); setManagedRelaySession(appAtomRegistry, null); } - }, [publishableKey]); + }, [publishableKey, relayUrl]); - if (!publishableKey) { + if (!publishableKey || !relayUrl) { return props.children; } diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index cd364637a1c..61ebad26237 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -1,4 +1,3 @@ -import Constants from "expo-constants"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -35,6 +34,7 @@ import { 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, @@ -50,10 +50,7 @@ export function normalizeRelayBaseUrl(value: string | null | undefined): string } function readRelayUrl(): string | null { - const relayConfig = Constants.expoConfig?.extra?.relay as - | { readonly url?: string | null } - | undefined; - return normalizeRelayBaseUrl(relayConfig?.url); + return resolveCloudPublicConfig().relayUrl; } export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ 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..11b3ee2a660 --- /dev/null +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -0,0 +1,32 @@ +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, + relayUrl: null, + }); + }); + + it("normalizes statically injected cloud configuration", () => { + expect( + resolveCloudPublicConfig({ + clerk: { publishableKey: " pk_test_example " }, + relay: { url: " https://relay.example.test/// " }, + }), + ).toEqual({ + clerkPublishableKey: "pk_test_example", + relayUrl: "https://relay.example.test", + }); + }); +}); diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts new file mode 100644 index 00000000000..1a18bdec7f2 --- /dev/null +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -0,0 +1,27 @@ +import Constants from "expo-constants"; + +type ExpoExtra = Readonly> | undefined; + +export interface CloudPublicConfig { + readonly clerkPublishableKey: 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 } | undefined; + const relay = extra?.relay as { readonly url?: unknown } | undefined; + + return { + clerkPublishableKey: trimNonEmpty(clerk?.publishableKey), + relayUrl: trimNonEmpty(relay?.url)?.replace(/\/+$/u, "") ?? null, + } satisfies CloudPublicConfig; +} + +export function hasCloudPublicConfig(): boolean { + const config = resolveCloudPublicConfig(); + return Boolean(config.clerkPublishableKey && config.relayUrl); +} diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index 22b8b4fa55d..80083869d75 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,4 +1,3 @@ -import Constants from "expo-constants"; import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; @@ -6,11 +5,10 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime"; import { mobileCryptoLayer } from "../features/cloud/dpop"; import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer"; +import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; function configuredRelayUrl(): string { - const relay = Constants.expoConfig?.extra?.relay as { readonly url?: string | null } | undefined; - const value = relay?.url?.trim(); - return value ? value.replace(/\/+$/g, "") : "http://relay.invalid"; + return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; } const mobileHttpClientLayer = remoteHttpClientLayer(fetch); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 746ec7cfa88..ec133b65894 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -37,6 +37,7 @@ import { resolvePrimaryEnvironmentHttpUrl, } from "../environments/primary"; import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit"; +import { resolveCloudPublicConfig } from "./publicConfig"; export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { const trimmed = value?.trim(); @@ -47,7 +48,7 @@ export function normalizeRelayBaseUrl(value: string | null | undefined): string } function relayUrl(): string | null { - return normalizeRelayBaseUrl(import.meta.env.VITE_T3_RELAY_URL as string | undefined); + return resolveCloudPublicConfig().relayUrl; } export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts new file mode 100644 index 00000000000..7ccf90b6a7f --- /dev/null +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -0,0 +1,21 @@ +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_T3_RELAY_URL", ""); + expect(hasCloudPublicConfig()).toBe(false); + + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example"); + expect(hasCloudPublicConfig()).toBe(false); + + vi.stubEnv("VITE_T3_RELAY_URL", "https://relay.example.test"); + expect(hasCloudPublicConfig()).toBe(true); + }); +}); diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts new file mode 100644 index 00000000000..bac4deb1ba0 --- /dev/null +++ b/apps/web/src/cloud/publicConfig.ts @@ -0,0 +1,24 @@ +export interface CloudPublicConfig { + readonly clerkPublishableKey: 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, + ), + relayUrl: + trimNonEmpty(import.meta.env.VITE_T3_RELAY_URL as string | undefined)?.replace(/\/+$/u, "") ?? + null, + }; +} + +export function hasCloudPublicConfig(): boolean { + const config = resolveCloudPublicConfig(); + return Boolean(config.clerkPublishableKey && config.relayUrl); +} diff --git a/apps/web/src/components/settings/CloudSettings.tsx b/apps/web/src/components/settings/CloudSettings.tsx index b9a9c44df6c..c2a1541b120 100644 --- a/apps/web/src/components/settings/CloudSettings.tsx +++ b/apps/web/src/components/settings/CloudSettings.tsx @@ -4,6 +4,7 @@ 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"; @@ -49,27 +50,12 @@ function EmptyNotificationDevices() { ); } -function hasClerkConfig(): boolean { - return Boolean(import.meta.env.VITE_CLERK_PUBLISHABLE_KEY); -} - function cloudErrorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } export function CloudSettingsPanel() { - if (!hasClerkConfig()) { - return ( - - }> - - - - ); - } + if (!hasCloudPublicConfig()) return null; return ; } diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 92b11329283..3666fc2f2d1 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -128,13 +128,10 @@ import { } from "~/cloud/managedRelayState"; import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; import { webRuntime } from "~/lib/runtime"; +import { hasCloudPublicConfig } from "~/cloud/publicConfig"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; -function hasCloudConfig(): boolean { - return Boolean(import.meta.env.VITE_CLERK_PUBLISHABLE_KEY); -} - const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short", @@ -1704,24 +1701,10 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b } function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { - return hasCloudConfig() ? ( - - ) : ( - - } - /> - ); + return hasCloudPublicConfig() ? : null; } -function EmptyRemoteEnvironments() { +function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) { return ( @@ -1730,8 +1713,9 @@ function EmptyRemoteEnvironments() { No saved remote environments - Click “Add environment” to pair another environment, or connect one from T3 - Cloud. + {cloudEnabled + ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." + : "Click “Add environment” to pair another environment."} @@ -1840,13 +1824,13 @@ function CloudRemoteEnvironmentRows({ readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironmentIds: ReadonlyArray; }) { - return hasCloudConfig() ? ( + return hasCloudPublicConfig() ? ( ) : savedEnvironmentIds.length === 0 ? ( - + ) : null; } diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 0904ad3b7b5..c0238a4651b 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -22,6 +22,7 @@ import { useSidebar, } from "../ui/sidebar"; import { Badge } from "../ui/badge"; +import { hasCloudPublicConfig } from "../../cloud/publicConfig"; export type SettingsSectionPath = | "/settings/general" @@ -76,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 ( diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 34f6d8264af..60510f7ee84 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -12,10 +12,10 @@ import { primaryEnvironmentRequestInit } from "../environments/primary/requestIn import { browserCryptoLayer } from "../cloud/dpop"; import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer"; +import { resolveCloudPublicConfig } from "../cloud/publicConfig"; function configuredRelayUrl(): string { - const value = (import.meta.env.VITE_T3_RELAY_URL as string | undefined)?.trim(); - return value ? value.replace(/\/+$/g, "") : "http://relay.invalid"; + return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; } const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a665bc4d43a..f791f998d74 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -10,6 +10,7 @@ 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"; @@ -32,7 +33,7 @@ const app = ; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - {clerkPublishableKey ? ( + {clerkPublishableKey && hasCloudPublicConfig() ? ( isElectron ? ( {app} diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 32587c0f187..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 - Sign in to T3 Cloud to connect a linked environment through its managed tunnel, or - add a reachable backend manually. + {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 index e91eb70e81c..a2854e9719b 100644 --- a/apps/web/src/routes/settings.cloud.tsx +++ b/apps/web/src/routes/settings.cloud.tsx @@ -1,7 +1,13 @@ -import { createFileRoute } from "@tanstack/react-router"; +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/docs/t3-cloud-clerk.md b/docs/t3-cloud-clerk.md index 450be3db590..13fa0074063 100644 --- a/docs/t3-cloud-clerk.md +++ b/docs/t3-cloud-clerk.md @@ -6,13 +6,8 @@ audience. ## Application Keys -Web, desktop, and mobile use checked-in public development defaults from -`packages/shared/src/relayAuth.ts`, so a fresh clone can use T3 Cloud without creating local -environment files. These values are safe to embed in client builds: the Clerk publishable key and -relay URL are public identifiers, not secrets. - -To point all clients at another Clerk/relay deployment, add a repository-root `.env` or -`.env.local` file: +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= @@ -29,27 +24,36 @@ Configuration precedence is: 1. Process or CI environment variables. 2. Repository-root `.env.local`. 3. Repository-root `.env`. -4. Checked-in public development defaults. -Release builds read `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3_RELAY_URL` from GitHub Actions repository -variables. EAS preview and production builds should define the same client-facing values in their -EAS environment. +The Clerk publishable key and relay URL are public identifiers, not secrets. Web, desktop, and +mobile builds statically inject them during their build step. A built artifact does not need an +environment file at runtime. CI release builds should set `T3CODE_CLERK_PUBLISHABLE_KEY` and +`T3_RELAY_URL` before building. EAS preview and production builds should define the same +client-facing values in their EAS environment. + +When either public value is absent, cloud UI is omitted. For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay -deployment reads `T3_RELAY_DOMAIN` and `T3_RELAY_ZONE_NAME` through Effect `Config`, with the -checked-in shared values as defaults. `bun --cwd infra/relay run deploy` invokes Alchemy from the -relay directory, so Alchemy loads `infra/relay/.env`. 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. +deployment reads `T3_RELAY_DOMAIN`, `T3_RELAY_ZONE_NAME`, and +`T3CODE_CLERK_PUBLISHABLE_KEY` through Effect `Config`. There are no checked-in deployment defaults. +`bun --cwd infra/relay run 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 `T3_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 +preview or developer stage. ## JWT Template In **Clerk Dashboard > JWT templates**, create a template with: -| Setting | Value | -| ------- | ------------------------------------------------------ | -| Name | `t3-relay` | -| Claims | `{ "aud": "https://t3code-relay.ineededadomain.com" }` | +| Setting | Value | +| ------- | ---------------------------------------- | +| Name | `t3-relay` | +| Claims | `{ "aud": "https://relay.example.com" }` | The `aud` value must be the deployed relay public URL, with no trailing slash. It must match the client-facing `T3_RELAY_URL` and the HTTPS URL derived from the deployment's `T3_RELAY_DOMAIN`. If diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index b2c36968adf..0f8c139d413 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -1,12 +1,3 @@ -export const DEFAULT_T3_RELAY_DOMAIN = "t3code-relay.ineededadomain.com"; - -export const DEFAULT_T3_RELAY_URL = `https://${DEFAULT_T3_RELAY_DOMAIN}`; - -export const DEFAULT_T3_RELAY_ZONE_NAME = "ineededadomain.com"; - -export const DEFAULT_T3_CLERK_PUBLISHABLE_KEY = - "pk_test_YXdhaXRlZC1tb25rZmlzaC01OC5jbGVyay5hY2NvdW50cy5kZXYk"; - export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay"; export const RELAY_CLERK_TOKEN_OPTIONS = { diff --git a/scripts/lib/public-config.test.ts b/scripts/lib/public-config.test.ts index 24666c276d1..fea7a7ad3bf 100644 --- a/scripts/lib/public-config.test.ts +++ b/scripts/lib/public-config.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { DEFAULT_PUBLIC_CONFIG, loadRepoEnv, resolvePublicConfig } from "./public-config.ts"; +import { loadRepoEnv, resolvePublicConfig } from "./public-config.ts"; const temporaryDirectories: string[] = []; @@ -15,17 +15,17 @@ afterEach(() => { }); describe("loadRepoEnv", () => { - it("projects checked-in public defaults into Vite and Expo aliases", () => { + it("does not project cloud configuration for an unconfigured clone", () => { const env = loadRepoEnv({ baseEnv: {}, repoRoot: makeTemporaryDirectory() }); - expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey); - expect(env.VITE_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey); - expect(env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey); - expect(env.T3_RELAY_URL).toBe(DEFAULT_PUBLIC_CONFIG.relayUrl); - expect(env.VITE_T3_RELAY_URL).toBe(DEFAULT_PUBLIC_CONFIG.relayUrl); + expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).toBeUndefined(); + expect(env.VITE_CLERK_PUBLISHABLE_KEY).toBeUndefined(); + expect(env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).toBeUndefined(); + expect(env.T3_RELAY_URL).toBeUndefined(); + expect(env.VITE_T3_RELAY_URL).toBeUndefined(); }); - it("applies process, root local, root, and checked-in precedence in that order", () => { + it("applies process, root local, and root precedence in that order", () => { const repoRoot = makeTemporaryDirectory(); writeFileSync( join(repoRoot, ".env"), @@ -65,6 +65,20 @@ describe("loadRepoEnv", () => { relayUrl: "https://legacy.example.test", }); }); + + it("projects only the configured aliases", () => { + expect( + loadRepoEnv({ + baseEnv: { + T3_RELAY_URL: "https://relay.example.test", + }, + repoRoot: makeTemporaryDirectory(), + }), + ).toEqual({ + T3_RELAY_URL: "https://relay.example.test", + VITE_T3_RELAY_URL: "https://relay.example.test", + }); + }); }); function makeTemporaryDirectory() { diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index ac6f3790a1f..9eb3522128c 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -4,11 +4,9 @@ import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import * as NodeUtil from "node:util"; -import { DEFAULT_T3_CLERK_PUBLISHABLE_KEY, DEFAULT_T3_RELAY_URL } from "@t3tools/shared/relayAuth"; - export interface T3CodePublicConfig { - readonly clerkPublishableKey: string; - readonly relayUrl: string; + readonly clerkPublishableKey: string | undefined; + readonly relayUrl: string | undefined; } type Environment = Readonly>; @@ -17,13 +15,6 @@ const REPO_ROOT = NodePath.dirname( NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))), ); -// These values are intentionally public. Client builds embed them so a fresh clone can use -// T3 Cloud without local setup. Use root .env files or CI environment variables to override them. -export const DEFAULT_PUBLIC_CONFIG: T3CodePublicConfig = { - clerkPublishableKey: DEFAULT_T3_CLERK_PUBLISHABLE_KEY, - relayUrl: DEFAULT_T3_RELAY_URL, -}; - export function loadRepoEnv({ baseEnv = process.env, repoRoot = REPO_ROOT, @@ -39,25 +30,31 @@ export function loadRepoEnv({ ...rootEnv, ...localEnv, ...baseEnv, - T3CODE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, - T3_RELAY_URL: config.relayUrl, - VITE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, - VITE_T3_RELAY_URL: config.relayUrl, - EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, + ...(config.clerkPublishableKey + ? { + T3CODE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, + VITE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, + EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, + } + : {}), + ...(config.relayUrl + ? { + T3_RELAY_URL: config.relayUrl, + VITE_T3_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", - ) ?? DEFAULT_PUBLIC_CONFIG.clerkPublishableKey, - relayUrl: - firstNonEmpty(sources, "T3_RELAY_URL", "VITE_T3_RELAY_URL") ?? DEFAULT_PUBLIC_CONFIG.relayUrl, + clerkPublishableKey: firstNonEmpty( + sources, + "T3CODE_CLERK_PUBLISHABLE_KEY", + "VITE_CLERK_PUBLISHABLE_KEY", + "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", + ), + relayUrl: firstNonEmpty(sources, "T3_RELAY_URL", "VITE_T3_RELAY_URL"), }; } From 52f056158b716321faa4a9c5a2898c4afb9201b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 18:17:53 -0700 Subject: [PATCH 27/61] refactor(cloud): standardize relay environment prefix Co-authored-by: codex --- .env.example | 2 +- .github/workflows/deploy-relay.yml | 4 ++-- .github/workflows/release.yml | 14 ++++++------ apps/mobile/README.md | 2 +- apps/mobile/app.config.ts | 2 +- apps/web/src/cloud/linkEnvironment.test.ts | 2 +- apps/web/src/cloud/linkEnvironment.ts | 10 ++++----- apps/web/src/cloud/publicConfig.test.ts | 4 ++-- apps/web/src/cloud/publicConfig.ts | 6 +++-- apps/web/vite.config.ts | 4 ++-- docs/release.md | 4 ++-- docs/t3-cloud-clerk.md | 12 +++++----- infra/relay/.env.example | 4 ++-- infra/relay/README.md | 8 +++---- infra/relay/scripts/deploy.test.ts | 6 ++--- infra/relay/scripts/deploy.ts | 8 +++---- infra/relay/src/zone.ts | 4 ++-- scripts/lib/public-config.test.ts | 26 ++++++++++++---------- scripts/lib/public-config.ts | 6 ++--- 19 files changed, 66 insertions(+), 62 deletions(-) diff --git a/.env.example b/.env.example index 83dd4c39bb3..4db2b2d4fa3 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,4 @@ # T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... # Get this from your relay deployment. `infra/relay` deploys update it automatically. -# T3_RELAY_URL=https://relay.example.com +# T3CODE_RELAY_URL=https://relay.example.com diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 351942a1f9d..1ce9b219867 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -29,8 +29,8 @@ jobs: PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} - T3_RELAY_DOMAIN: ${{ vars.T3_RELAY_DOMAIN }} - T3_RELAY_ZONE_NAME: ${{ vars.T3_RELAY_ZONE_NAME }} + T3CODE_RELAY_DOMAIN: ${{ vars.T3CODE_RELAY_DOMAIN }} + T3CODE_RELAY_ZONE_NAME: ${{ vars.T3CODE_RELAY_ZONE_NAME }} T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc901a2c588..50700d77aee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -178,7 +178,7 @@ jobs: clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} relay_url: ${{ steps.public_config.outputs.relay_url }} env: - T3_RELAY_DOMAIN: ${{ vars.T3_RELAY_DOMAIN }} + T3CODE_RELAY_DOMAIN: ${{ vars.T3CODE_RELAY_DOMAIN }} T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} steps: - id: public_config @@ -188,7 +188,7 @@ jobs: set -euo pipefail required=( - T3_RELAY_DOMAIN + T3CODE_RELAY_DOMAIN T3CODE_CLERK_PUBLISHABLE_KEY ) missing=() @@ -203,7 +203,7 @@ jobs: fi echo "clerk_publishable_key=$T3CODE_CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" - echo "relay_url=https://$T3_RELAY_DOMAIN" >> "$GITHUB_OUTPUT" + echo "relay_url=https://$T3CODE_RELAY_DOMAIN" >> "$GITHUB_OUTPUT" build: name: Build ${{ matrix.label }} @@ -213,7 +213,7 @@ jobs: timeout-minutes: 30 env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} + T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} strategy: fail-fast: false matrix: @@ -473,7 +473,7 @@ jobs: id-token: write env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} + T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout uses: actions/checkout@v6 @@ -628,7 +628,7 @@ jobs: timeout-minutes: 10 env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} - T3_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} + 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 }} @@ -696,7 +696,7 @@ jobs: "${vercel_scope_args[@]}" \ --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ - --build-env "T3_RELAY_URL=${T3_RELAY_URL:-}" \ + --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ --build-env "VITE_HOSTED_APP_URL=$router_url" \ --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" diff --git a/apps/mobile/README.md b/apps/mobile/README.md index fefd9491a3c..37318c49b4e 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -67,7 +67,7 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy. -For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3_RELAY_URL` +For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3CODE_RELAY_URL` as EAS environment variables. Expo config maps the canonical values into the mobile build. Create a PR preview dev-client build manually: diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 18aaee07142..7266ff04019 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -155,7 +155,7 @@ const config: ExpoConfig = { extra: { appVariant: APP_VARIANT, relay: { - url: repoEnv.T3_RELAY_URL ?? null, + url: repoEnv.T3CODE_RELAY_URL ?? null, }, clerk: { publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 2eccdd113f1..fe331ec81dd 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -108,7 +108,7 @@ describe("web cloud link environment client", () => { beforeEach(() => { vi.restoreAllMocks(); createProofMock.mockClear(); - vi.stubEnv("VITE_T3_RELAY_URL", "https://relay.example.test"); + vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer"); vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue(null); vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue(null); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index ec133b65894..9542b4195e9 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -285,7 +285,7 @@ export function listManagedCloudEnvironments(input: { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { return yield* new CloudEnvironmentLinkError({ - message: "T3_RELAY_URL is not configured.", + message: "T3CODE_RELAY_URL is not configured.", }); } const relayClient = yield* ManagedRelayClient; @@ -315,7 +315,7 @@ export function listCloudDevices(input: { return Effect.gen(function* () { if (!relayUrl()) { return yield* new CloudEnvironmentLinkError({ - message: "T3_RELAY_URL is not configured.", + message: "T3CODE_RELAY_URL is not configured.", }); } const relayClient = yield* ManagedRelayClient; @@ -344,7 +344,7 @@ export function connectManagedCloudEnvironment(input: { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { return yield* new CloudEnvironmentLinkError({ - message: "T3_RELAY_URL is not configured.", + message: "T3CODE_RELAY_URL is not configured.", }); } const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl); @@ -519,7 +519,7 @@ export function linkEnvironmentToCloud(input: { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { return yield* new CloudEnvironmentLinkError({ - message: "T3_RELAY_URL is not configured.", + message: "T3CODE_RELAY_URL is not configured.", }); } const relayClient = yield* ManagedRelayClient; @@ -614,7 +614,7 @@ export function linkPrimaryEnvironmentToCloud(input: { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { return yield* new CloudEnvironmentLinkError({ - message: "T3_RELAY_URL is not configured.", + message: "T3CODE_RELAY_URL is not configured.", }); } const relayClient = yield* ManagedRelayClient; diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts index 7ccf90b6a7f..c40026ee068 100644 --- a/apps/web/src/cloud/publicConfig.test.ts +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -9,13 +9,13 @@ afterEach(() => { describe("hasCloudPublicConfig", () => { it("requires both public cloud values", () => { vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", ""); - vi.stubEnv("VITE_T3_RELAY_URL", ""); + 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_T3_RELAY_URL", "https://relay.example.test"); + vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); expect(hasCloudPublicConfig()).toBe(true); }); }); diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index bac4deb1ba0..1df9696209b 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -13,8 +13,10 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined, ), relayUrl: - trimNonEmpty(import.meta.env.VITE_T3_RELAY_URL as string | undefined)?.replace(/\/+$/u, "") ?? - null, + trimNonEmpty(import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined)?.replace( + /\/+$/u, + "", + ) ?? null, }; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 13d4e73a749..381dac5889c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,7 +16,7 @@ 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_T3_RELAY_URL?.trim() || ""; +const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; @@ -130,7 +130,7 @@ 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_T3_RELAY_URL": JSON.stringify(configuredRelayUrl), + "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), diff --git a/docs/release.md b/docs/release.md index 37051aecb1b..34cbbef2c4d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -56,8 +56,8 @@ Required repository secrets shared by relay deployments: Required `production` environment variables: -- `T3_RELAY_DOMAIN` -- `T3_RELAY_ZONE_NAME` +- `T3CODE_RELAY_DOMAIN` +- `T3CODE_RELAY_ZONE_NAME` - `T3CODE_CLERK_PUBLISHABLE_KEY` - `CLERK_CLI_OAUTH_CLIENT_ID` - `APNS_ENVIRONMENT` diff --git a/docs/t3-cloud-clerk.md b/docs/t3-cloud-clerk.md index 13fa0074063..7f07a1460c2 100644 --- a/docs/t3-cloud-clerk.md +++ b/docs/t3-cloud-clerk.md @@ -11,11 +11,11 @@ or `.env.local` file: ```dotenv T3CODE_CLERK_PUBLISHABLE_KEY= -T3_RELAY_URL=https://relay.example.com +T3CODE_RELAY_URL=https://relay.example.com ``` The shared client loader projects these canonical values into the framework-specific -`VITE_CLERK_PUBLISHABLE_KEY`, `VITE_T3_RELAY_URL`, and +`VITE_CLERK_PUBLISHABLE_KEY`, `VITE_T3CODE_RELAY_URL`, and `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` aliases. Existing aliases remain accepted as overrides for compatibility, but new client configuration should use the canonical names. @@ -28,17 +28,17 @@ Configuration precedence is: The Clerk publishable key and relay URL are public identifiers, not secrets. Web, desktop, and mobile builds statically inject them during their build step. A built artifact does not need an environment file at runtime. CI release builds should set `T3CODE_CLERK_PUBLISHABLE_KEY` and -`T3_RELAY_URL` before building. EAS preview and production builds should define the same +`T3CODE_RELAY_URL` before building. EAS preview and production builds should define the same client-facing values in their EAS environment. When either public value is absent, cloud UI is omitted. For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay -deployment reads `T3_RELAY_DOMAIN`, `T3_RELAY_ZONE_NAME`, and +deployment reads `T3CODE_RELAY_DOMAIN`, `T3CODE_RELAY_ZONE_NAME`, and `T3CODE_CLERK_PUBLISHABLE_KEY` through Effect `Config`. There are no checked-in deployment defaults. `bun --cwd infra/relay run 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 `T3_RELAY_DOMAIN`. The relay still requires +with the HTTPS relay URL derived from `T3CODE_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. @@ -56,7 +56,7 @@ In **Clerk Dashboard > JWT templates**, create a template with: | Claims | `{ "aud": "https://relay.example.com" }` | The `aud` value must be the deployed relay public URL, with no trailing slash. It must match the -client-facing `T3_RELAY_URL` and the HTTPS URL derived from the deployment's `T3_RELAY_DOMAIN`. If +client-facing `T3CODE_RELAY_URL` and the HTTPS URL derived from the deployment's `T3CODE_RELAY_DOMAIN`. If the relay domain changes, update both values and the JWT template. ## Desktop OAuth Redirect Allowlist diff --git a/infra/relay/.env.example b/infra/relay/.env.example index 0e5f015c258..7df5c58f2e2 100644 --- a/infra/relay/.env.example +++ b/infra/relay/.env.example @@ -1,11 +1,11 @@ # Required: Relay domain # Use the DNS zone managed in your Cloudflare account. Production deploys use # relay.; personal stages use relay-.. -T3_RELAY_ZONE_NAME=example.com +T3CODE_RELAY_ZONE_NAME=example.com # Optional: Relay domain override # Set this only when the derived relay hostname should not be used. -# T3_RELAY_DOMAIN=relay.example.com +# T3CODE_RELAY_DOMAIN=relay.example.com # Required: Clerk # Get both values from the Clerk Dashboard under API keys. diff --git a/infra/relay/README.md b/infra/relay/README.md index abeec1bc86b..8271348928b 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -100,8 +100,8 @@ bun --cwd infra/relay run 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.`. -`T3_RELAY_DOMAIN` remains available as an explicit override. +`relay.` and `dev_julius` uses `relay-dev-julius.`. +`T3CODE_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 @@ -130,8 +130,8 @@ The repository must define these Actions secrets shared by relay deployments: The `production` GitHub environment must define these Actions variables: -- `T3_RELAY_ZONE_NAME` -- `T3_RELAY_DOMAIN` if overriding the derived production relay domain +- `T3CODE_RELAY_ZONE_NAME` +- `T3CODE_RELAY_DOMAIN` if overriding the derived production relay domain - `T3CODE_CLERK_PUBLISHABLE_KEY` - `APNS_ENVIRONMENT` - `APNS_TEAM_ID` diff --git a/infra/relay/scripts/deploy.test.ts b/infra/relay/scripts/deploy.test.ts index a53c41139cb..3adc2468a81 100644 --- a/infra/relay/scripts/deploy.test.ts +++ b/infra/relay/scripts/deploy.test.ts @@ -35,18 +35,18 @@ describe("hasDeployChanges", () => { describe("reconcileRootEnvRelayUrl", () => { it("adds the relay URL to an empty root env file", () => { expect(reconcileRootEnvRelayUrl("", "https://relay.example.test")).toBe( - "T3_RELAY_URL=https://relay.example.test\n", + "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\nT3_RELAY_URL=https://old.example.test\n", + "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\nT3_RELAY_URL=https://relay.example.test\n", + "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 index 0948fb41454..8b5f713d9a8 100644 --- a/infra/relay/scripts/deploy.ts +++ b/infra/relay/scripts/deploy.ts @@ -42,9 +42,9 @@ export interface RelayDeployOptions { } export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string { - const entry = `T3_RELAY_URL=${relayUrl}`; - if (/^T3_RELAY_URL=.*$/mu.test(contents)) { - return contents.replace(/^T3_RELAY_URL=.*$/mu, entry); + 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`; @@ -94,7 +94,7 @@ const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (r const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : ""; yield* fs.writeFileString(rootEnvPath, reconcileRootEnvRelayUrl(contents, relayUrl)); - yield* Console.log(`Updated ${rootEnvPath} with T3_RELAY_URL=${relayUrl}`); + yield* Console.log(`Updated ${rootEnvPath} with T3CODE_RELAY_URL=${relayUrl}`); }); const deployServices = Layer.mergeAll( diff --git a/infra/relay/src/zone.ts b/infra/relay/src/zone.ts index bd48cea5355..f8063622b65 100644 --- a/infra/relay/src/zone.ts +++ b/infra/relay/src/zone.ts @@ -9,8 +9,8 @@ import { relayPublicDomainForStage } from "./deploymentConfig.ts"; export const RelayDeploymentConfig = Effect.gen(function* () { const stage = yield* Alchemy.Stage; - const managedEndpointZoneName = yield* Config.nonEmptyString("T3_RELAY_ZONE_NAME"); - const relayPublicDomainOverride = yield* Config.nonEmptyString("T3_RELAY_DOMAIN").pipe( + const managedEndpointZoneName = yield* Config.nonEmptyString("T3CODE_RELAY_ZONE_NAME"); + const relayPublicDomainOverride = yield* Config.nonEmptyString("T3CODE_RELAY_DOMAIN").pipe( Config.option, ); const relayPublicDomain = Option.getOrElse(relayPublicDomainOverride, () => diff --git a/scripts/lib/public-config.test.ts b/scripts/lib/public-config.test.ts index fea7a7ad3bf..f5af24a5009 100644 --- a/scripts/lib/public-config.test.ts +++ b/scripts/lib/public-config.test.ts @@ -21,27 +21,29 @@ describe("loadRepoEnv", () => { expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).toBeUndefined(); expect(env.VITE_CLERK_PUBLISHABLE_KEY).toBeUndefined(); expect(env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).toBeUndefined(); - expect(env.T3_RELAY_URL).toBeUndefined(); - expect(env.VITE_T3_RELAY_URL).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\nT3_RELAY_URL=https://root.example.test\n", + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3CODE_RELAY_URL=https://root.example.test\n", ); writeFileSync( join(repoRoot, ".env.local"), - "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3_RELAY_URL=https://local.example.test\n", + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3CODE_RELAY_URL=https://local.example.test\n", ); - expect(loadRepoEnv({ baseEnv: {}, repoRoot }).T3_RELAY_URL).toBe("https://local.example.test"); + expect(loadRepoEnv({ baseEnv: {}, repoRoot }).T3CODE_RELAY_URL).toBe( + "https://local.example.test", + ); expect( loadRepoEnv({ baseEnv: { T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", - T3_RELAY_URL: "https://ci.example.test", + T3CODE_RELAY_URL: "https://ci.example.test", }, repoRoot, }), @@ -49,8 +51,8 @@ describe("loadRepoEnv", () => { T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", VITE_CLERK_PUBLISHABLE_KEY: "pk_ci", EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_ci", - T3_RELAY_URL: "https://ci.example.test", - VITE_T3_RELAY_URL: "https://ci.example.test", + T3CODE_RELAY_URL: "https://ci.example.test", + VITE_T3CODE_RELAY_URL: "https://ci.example.test", }); }); @@ -58,7 +60,7 @@ describe("loadRepoEnv", () => { expect( resolvePublicConfig({ VITE_CLERK_PUBLISHABLE_KEY: "pk_legacy", - VITE_T3_RELAY_URL: "https://legacy.example.test", + VITE_T3CODE_RELAY_URL: "https://legacy.example.test", }), ).toEqual({ clerkPublishableKey: "pk_legacy", @@ -70,13 +72,13 @@ describe("loadRepoEnv", () => { expect( loadRepoEnv({ baseEnv: { - T3_RELAY_URL: "https://relay.example.test", + T3CODE_RELAY_URL: "https://relay.example.test", }, repoRoot: makeTemporaryDirectory(), }), ).toEqual({ - T3_RELAY_URL: "https://relay.example.test", - VITE_T3_RELAY_URL: "https://relay.example.test", + T3CODE_RELAY_URL: "https://relay.example.test", + VITE_T3CODE_RELAY_URL: "https://relay.example.test", }); }); }); diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index 9eb3522128c..ea3819bf844 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -39,8 +39,8 @@ export function loadRepoEnv({ : {}), ...(config.relayUrl ? { - T3_RELAY_URL: config.relayUrl, - VITE_T3_RELAY_URL: config.relayUrl, + T3CODE_RELAY_URL: config.relayUrl, + VITE_T3CODE_RELAY_URL: config.relayUrl, } : {}), }; @@ -54,7 +54,7 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP "VITE_CLERK_PUBLISHABLE_KEY", "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", ), - relayUrl: firstNonEmpty(sources, "T3_RELAY_URL", "VITE_T3_RELAY_URL"), + relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"), }; } From cc24a990479fda80e0fa28e8a4414c752c8b292e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 18:24:23 -0700 Subject: [PATCH 28/61] docs(cloud): use canonical CLI OAuth variable Co-authored-by: codex --- docs/release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release.md b/docs/release.md index 34cbbef2c4d..c07fe8b0c79 100644 --- a/docs/release.md +++ b/docs/release.md @@ -59,7 +59,7 @@ Required `production` environment variables: - `T3CODE_RELAY_DOMAIN` - `T3CODE_RELAY_ZONE_NAME` - `T3CODE_CLERK_PUBLISHABLE_KEY` -- `CLERK_CLI_OAUTH_CLIENT_ID` +- `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` - `APNS_ENVIRONMENT` - `APNS_TEAM_ID` - `APNS_KEY_ID` From c48c5604579b68d6543de3c895f522e701340a67 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 18:29:48 -0700 Subject: [PATCH 29/61] refactor(cloud): keep relay infra config unprefixed Co-authored-by: codex --- .github/workflows/deploy-relay.yml | 6 ++-- .github/workflows/release.yml | 12 +++---- docs/release.md | 8 ++--- docs/t3-cloud-clerk.md | 8 ++--- infra/relay/.env.example | 6 ++-- infra/relay/README.md | 10 +++--- infra/relay/src/worker.ts | 2 +- infra/relay/src/zone.test.ts | 50 ++++++++++++++++++++++++++++++ infra/relay/src/zone.ts | 4 +-- 9 files changed, 78 insertions(+), 28 deletions(-) create mode 100644 infra/relay/src/zone.test.ts diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 1ce9b219867..eec2c100f72 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -29,9 +29,9 @@ jobs: PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} - T3CODE_RELAY_DOMAIN: ${{ vars.T3CODE_RELAY_DOMAIN }} - T3CODE_RELAY_ZONE_NAME: ${{ vars.T3CODE_RELAY_ZONE_NAME }} - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} + RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} + RELAY_ZONE_NAME: ${{ vars.RELAY_ZONE_NAME }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} APNS_TEAM_ID: ${{ vars.APNS_TEAM_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50700d77aee..2f3171518d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -178,8 +178,8 @@ jobs: clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} relay_url: ${{ steps.public_config.outputs.relay_url }} env: - T3CODE_RELAY_DOMAIN: ${{ vars.T3CODE_RELAY_DOMAIN }} - T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} + RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} + CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} steps: - id: public_config name: Resolve production relay public config @@ -188,8 +188,8 @@ jobs: set -euo pipefail required=( - T3CODE_RELAY_DOMAIN - T3CODE_CLERK_PUBLISHABLE_KEY + RELAY_DOMAIN + CLERK_PUBLISHABLE_KEY ) missing=() for name in "${required[@]}"; do @@ -202,8 +202,8 @@ jobs: exit 1 fi - echo "clerk_publishable_key=$T3CODE_CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" - echo "relay_url=https://$T3CODE_RELAY_DOMAIN" >> "$GITHUB_OUTPUT" + echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" + echo "relay_url=https://$RELAY_DOMAIN" >> "$GITHUB_OUTPUT" build: name: Build ${{ matrix.label }} diff --git a/docs/release.md b/docs/release.md index c07fe8b0c79..4a4674f51e4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -56,10 +56,10 @@ Required repository secrets shared by relay deployments: Required `production` environment variables: -- `T3CODE_RELAY_DOMAIN` -- `T3CODE_RELAY_ZONE_NAME` -- `T3CODE_CLERK_PUBLISHABLE_KEY` -- `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` +- `RELAY_DOMAIN` +- `RELAY_ZONE_NAME` +- `CLERK_PUBLISHABLE_KEY` +- `CLERK_CLI_OAUTH_CLIENT_ID` - `APNS_ENVIRONMENT` - `APNS_TEAM_ID` - `APNS_KEY_ID` diff --git a/docs/t3-cloud-clerk.md b/docs/t3-cloud-clerk.md index 7f07a1460c2..518fcbf613f 100644 --- a/docs/t3-cloud-clerk.md +++ b/docs/t3-cloud-clerk.md @@ -34,11 +34,11 @@ client-facing values in their EAS environment. When either public value is absent, cloud UI is omitted. For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay -deployment reads `T3CODE_RELAY_DOMAIN`, `T3CODE_RELAY_ZONE_NAME`, and -`T3CODE_CLERK_PUBLISHABLE_KEY` through Effect `Config`. There are no checked-in deployment defaults. +deployment reads `RELAY_DOMAIN`, `RELAY_ZONE_NAME`, and `CLERK_PUBLISHABLE_KEY` through Effect +`Config`. There are no checked-in deployment defaults. `bun --cwd infra/relay run 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 `T3CODE_RELAY_DOMAIN`. The relay still requires +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. @@ -56,7 +56,7 @@ In **Clerk Dashboard > JWT templates**, create a template with: | Claims | `{ "aud": "https://relay.example.com" }` | The `aud` value must be the deployed relay public URL, with no trailing slash. It must match the -client-facing `T3CODE_RELAY_URL` and the HTTPS URL derived from the deployment's `T3CODE_RELAY_DOMAIN`. If +client-facing `T3CODE_RELAY_URL` and the HTTPS URL derived from the deployment's `RELAY_DOMAIN`. If the relay domain changes, update both values and the JWT template. ## Desktop OAuth Redirect Allowlist diff --git a/infra/relay/.env.example b/infra/relay/.env.example index 7df5c58f2e2..66a73eaf9f9 100644 --- a/infra/relay/.env.example +++ b/infra/relay/.env.example @@ -1,15 +1,15 @@ # Required: Relay domain # Use the DNS zone managed in your Cloudflare account. Production deploys use # relay.; personal stages use relay-.. -T3CODE_RELAY_ZONE_NAME=example.com +RELAY_ZONE_NAME=example.com # Optional: Relay domain override # Set this only when the derived relay hostname should not be used. -# T3CODE_RELAY_DOMAIN=relay.example.com +# RELAY_DOMAIN=relay.example.com # Required: Clerk # Get both values from the Clerk Dashboard under API keys. -T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... +CLERK_PUBLISHABLE_KEY=pk_test_... CLERK_SECRET_KEY=sk_test_... # Required: Apple Push Notification service diff --git a/infra/relay/README.md b/infra/relay/README.md index 8271348928b..21414063f6c 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -100,8 +100,8 @@ bun --cwd infra/relay run 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.`. -`T3CODE_RELAY_DOMAIN` remains available as an explicit override. +`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 @@ -130,9 +130,9 @@ The repository must define these Actions secrets shared by relay deployments: The `production` GitHub environment must define these Actions variables: -- `T3CODE_RELAY_ZONE_NAME` -- `T3CODE_RELAY_DOMAIN` if overriding the derived production relay domain -- `T3CODE_CLERK_PUBLISHABLE_KEY` +- `RELAY_ZONE_NAME` +- `RELAY_DOMAIN` if overriding the derived production relay domain +- `CLERK_PUBLISHABLE_KEY` - `APNS_ENVIRONMENT` - `APNS_TEAM_ID` - `APNS_KEY_ID` diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index f5f07964a63..712e822e688 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -129,7 +129,7 @@ export default class Api extends Cloudflare.Worker()( const axiomTracesEndpoint = yield* observability.traces.otelTracesEndpoint; const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY"); - const clerkPublishableKey = yield* Config.string("T3CODE_CLERK_PUBLISHABLE_KEY"); + const clerkPublishableKey = yield* Config.string("CLERK_PUBLISHABLE_KEY"); const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; diff --git a/infra/relay/src/zone.test.ts b/infra/relay/src/zone.test.ts new file mode 100644 index 00000000000..f1da429e43a --- /dev/null +++ b/infra/relay/src/zone.test.ts @@ -0,0 +1,50 @@ +import * as Alchemy from "alchemy"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { describe, expect, it } from "vitest"; + +import { RelayDeploymentConfig } from "./zone.ts"; + +function resolveRelayDeploymentConfig(env: Record, stage = "prod") { + return Effect.runPromise( + RelayDeploymentConfig.pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(Alchemy.Stage, stage), + ConfigProvider.layer(ConfigProvider.fromEnv({ env })), + ), + ), + ), + ); +} + +describe("RelayDeploymentConfig", () => { + it("derives the relay domain from the unprefixed zone name", async () => { + await expect(resolveRelayDeploymentConfig({ RELAY_ZONE_NAME: "example.com" })).resolves.toEqual( + { + stage: "prod", + relayPublicDomain: "relay.example.com", + relayPublicOrigin: "https://relay.example.com", + managedEndpointZoneName: "example.com", + }, + ); + }); + + it("uses the unprefixed relay domain override", async () => { + await expect( + resolveRelayDeploymentConfig( + { + RELAY_ZONE_NAME: "example.com", + RELAY_DOMAIN: "relay.custom.example", + }, + "dev_julius", + ), + ).resolves.toEqual({ + stage: "dev_julius", + relayPublicDomain: "relay.custom.example", + relayPublicOrigin: "https://relay.custom.example", + managedEndpointZoneName: "example.com", + }); + }); +}); diff --git a/infra/relay/src/zone.ts b/infra/relay/src/zone.ts index f8063622b65..9a6e7a220e3 100644 --- a/infra/relay/src/zone.ts +++ b/infra/relay/src/zone.ts @@ -9,8 +9,8 @@ import { relayPublicDomainForStage } from "./deploymentConfig.ts"; export const RelayDeploymentConfig = Effect.gen(function* () { const stage = yield* Alchemy.Stage; - const managedEndpointZoneName = yield* Config.nonEmptyString("T3CODE_RELAY_ZONE_NAME"); - const relayPublicDomainOverride = yield* Config.nonEmptyString("T3CODE_RELAY_DOMAIN").pipe( + const managedEndpointZoneName = yield* Config.nonEmptyString("RELAY_ZONE_NAME"); + const relayPublicDomainOverride = yield* Config.nonEmptyString("RELAY_DOMAIN").pipe( Config.option, ); const relayPublicDomain = Option.getOrElse(relayPublicDomainOverride, () => From 4bace7cd6243d547e82808c6da1c576c353c5abb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 18:34:03 -0700 Subject: [PATCH 30/61] test(cloud): remove rename-only relay config coverage Co-authored-by: codex --- infra/relay/src/zone.test.ts | 50 ------------------------------------ 1 file changed, 50 deletions(-) delete mode 100644 infra/relay/src/zone.test.ts diff --git a/infra/relay/src/zone.test.ts b/infra/relay/src/zone.test.ts deleted file mode 100644 index f1da429e43a..00000000000 --- a/infra/relay/src/zone.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as Alchemy from "alchemy"; -import * as ConfigProvider from "effect/ConfigProvider"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { describe, expect, it } from "vitest"; - -import { RelayDeploymentConfig } from "./zone.ts"; - -function resolveRelayDeploymentConfig(env: Record, stage = "prod") { - return Effect.runPromise( - RelayDeploymentConfig.pipe( - Effect.provide( - Layer.mergeAll( - Layer.succeed(Alchemy.Stage, stage), - ConfigProvider.layer(ConfigProvider.fromEnv({ env })), - ), - ), - ), - ); -} - -describe("RelayDeploymentConfig", () => { - it("derives the relay domain from the unprefixed zone name", async () => { - await expect(resolveRelayDeploymentConfig({ RELAY_ZONE_NAME: "example.com" })).resolves.toEqual( - { - stage: "prod", - relayPublicDomain: "relay.example.com", - relayPublicOrigin: "https://relay.example.com", - managedEndpointZoneName: "example.com", - }, - ); - }); - - it("uses the unprefixed relay domain override", async () => { - await expect( - resolveRelayDeploymentConfig( - { - RELAY_ZONE_NAME: "example.com", - RELAY_DOMAIN: "relay.custom.example", - }, - "dev_julius", - ), - ).resolves.toEqual({ - stage: "dev_julius", - relayPublicDomain: "relay.custom.example", - relayPublicOrigin: "https://relay.custom.example", - managedEndpointZoneName: "example.com", - }); - }); -}); From 69d706ff8d3936f870bbb504f3c443d44a97ced8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 18:52:30 -0700 Subject: [PATCH 31/61] fix(relay): scope Axiom resources by stage Co-authored-by: codex --- docs/relay-observability.md | 4 +++- infra/relay/src/deploymentConfig.test.ts | 10 ++++++++++ infra/relay/src/deploymentConfig.ts | 4 ++++ infra/relay/src/observability.ts | 10 +++++++--- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/relay-observability.md b/docs/relay-observability.md index d6db81ba604..2b17fa29771 100644 --- a/docs/relay-observability.md +++ b/docs/relay-observability.md @@ -4,9 +4,11 @@ The relay Alchemy stack owns a focused Axiom trace setup: - `t3-code-relay-traces`, an OpenTelemetry trace dataset for Worker requests - `t3-code-relay-otel-ingest`, a dataset-scoped ingest token bound to the Worker -- `t3-code-relay-readonly-query`, a dataset-scoped token for scripted diagnostics - `t3-code-relay-recent-spans`, a view of recent request and endpoint spans +These are the production names. Non-production Alchemy stages append their sanitized stage name, +for example `t3-code-relay-traces-dev-julius`. + Deploy from `infra/relay` with the normal Alchemy workflow: ```sh diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index 29223472b47..05704454be5 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -5,6 +5,7 @@ import { managedEndpointHostname, managedEndpointTunnelName, relayPublicDomainForStage, + relayResourceNameForStage, relayStageSlug, } from "./deploymentConfig.ts"; @@ -26,6 +27,15 @@ describe("relayPublicDomainForStage", () => { }); }); +describe("relayResourceNameForStage", () => { + it("preserves production names and isolates personal stages", () => { + expect(relayResourceNameForStage("t3-code-relay-traces", "prod")).toBe("t3-code-relay-traces"); + 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"; diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts index c298087bdbf..95c9705beea 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -33,6 +33,10 @@ export function relayStageSlug(stage: string): string { .replace(/^-+|-+$/g, ""); } +export function relayResourceNameForStage(name: string, stage: string): string { + return stage === "prod" ? name : `${name}-${relayStageSlug(stage)}`; +} + export function relayPublicDomainForStage(stage: string, zoneName: string): string { const stageSlug = relayStageSlug(stage); const relayLabel = stage === "prod" ? "relay" : `relay-${stageSlug}`; diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index f3f40357c82..f9d46686392 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -1,3 +1,4 @@ +import * as Alchemy from "alchemy"; import * as Axiom from "alchemy/Axiom"; import * as Output from "alchemy/Output"; import * as Layer from "effect/Layer"; @@ -5,6 +6,8 @@ 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}']`, @@ -17,8 +20,9 @@ const relayRecentSpansQuery = (dataset: string) => ].join("\n"); export const RelayObservability = Effect.gen(function* () { + const stage = yield* Alchemy.Stage; const traces = yield* Axiom.Dataset("RelayTracesDataset", { - name: "t3-code-relay-traces", + name: relayResourceNameForStage("t3-code-relay-traces", stage), kind: "otel:traces:v1", description: "T3 Code relay Worker HTTP request spans.", retentionDays: 30, @@ -26,7 +30,7 @@ export const RelayObservability = Effect.gen(function* () { }); const ingestToken = yield* Axiom.ApiToken("RelayAxiomIngestToken", { - name: "t3-code-relay-otel-ingest", + 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] }, @@ -34,7 +38,7 @@ export const RelayObservability = Effect.gen(function* () { }); yield* Axiom.View("RelayRecentSpansView", { - name: "t3-code-relay-recent-spans", + name: relayResourceNameForStage("t3-code-relay-recent-spans", stage), description: "Recent relay HTTP request spans.", datasets: [traces.name], aplQuery: Output.map(traces.name, relayRecentSpansQuery), From 6fd38f88a785f5dbe9a03482ff0c3ecd3e9a9660 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 18:56:13 -0700 Subject: [PATCH 32/61] fix(relay): suffix production Axiom resource names Co-authored-by: codex --- docs/relay-observability.md | 12 ++++++------ infra/relay/src/deploymentConfig.test.ts | 6 ++++-- infra/relay/src/deploymentConfig.ts | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/relay-observability.md b/docs/relay-observability.md index 2b17fa29771..9a096853adf 100644 --- a/docs/relay-observability.md +++ b/docs/relay-observability.md @@ -2,12 +2,12 @@ The relay Alchemy stack owns a focused Axiom trace setup: -- `t3-code-relay-traces`, an OpenTelemetry trace dataset for Worker requests -- `t3-code-relay-otel-ingest`, a dataset-scoped ingest token bound to the Worker -- `t3-code-relay-recent-spans`, a view of recent request and endpoint spans +- `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 -These are the production names. Non-production Alchemy stages append their sanitized stage name, -for example `t3-code-relay-traces-dev-julius`. +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: @@ -23,7 +23,7 @@ Effect's OpenTelemetry exporter stores semantic HTTP attributes below the `attri For example: ```apl -['t3-code-relay-traces'] +['t3-code-relay-traces-prod'] | where name startswith 'http.server' | project _time, name, trace_id, duration, ['attributes.http.request.method'], diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index 05704454be5..5aadb277c84 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -28,8 +28,10 @@ describe("relayPublicDomainForStage", () => { }); describe("relayResourceNameForStage", () => { - it("preserves production names and isolates personal stages", () => { - expect(relayResourceNameForStage("t3-code-relay-traces", "prod")).toBe("t3-code-relay-traces"); + 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", ); diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts index 95c9705beea..e8498214b0a 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -34,7 +34,7 @@ export function relayStageSlug(stage: string): string { } export function relayResourceNameForStage(name: string, stage: string): string { - return stage === "prod" ? name : `${name}-${relayStageSlug(stage)}`; + return `${name}-${relayStageSlug(stage)}`; } export function relayPublicDomainForStage(stage: string, zoneName: string): string { From f9361628986d796219a64c2c2a472a60e24a4254 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 19:06:08 -0700 Subject: [PATCH 33/61] fix(relay): read stage from worker stack context Co-authored-by: codex --- infra/relay/src/observability.ts | 2 +- infra/relay/src/zone.test.ts | 36 ++++++++++++++++++++++++++++++++ infra/relay/src/zone.ts | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 infra/relay/src/zone.test.ts diff --git a/infra/relay/src/observability.ts b/infra/relay/src/observability.ts index f9d46686392..b54567d1f0a 100644 --- a/infra/relay/src/observability.ts +++ b/infra/relay/src/observability.ts @@ -20,7 +20,7 @@ const relayRecentSpansQuery = (dataset: string) => ].join("\n"); export const RelayObservability = Effect.gen(function* () { - const stage = yield* Alchemy.Stage; + const { stage } = yield* Alchemy.Stack; const traces = yield* Axiom.Dataset("RelayTracesDataset", { name: relayResourceNameForStage("t3-code-relay-traces", stage), kind: "otel:traces:v1", 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 index 9a6e7a220e3..cb547e17c31 100644 --- a/infra/relay/src/zone.ts +++ b/infra/relay/src/zone.ts @@ -8,7 +8,7 @@ import * as Option from "effect/Option"; import { relayPublicDomainForStage } from "./deploymentConfig.ts"; export const RelayDeploymentConfig = Effect.gen(function* () { - const stage = yield* Alchemy.Stage; + const { stage } = yield* Alchemy.Stack; const managedEndpointZoneName = yield* Config.nonEmptyString("RELAY_ZONE_NAME"); const relayPublicDomainOverride = yield* Config.nonEmptyString("RELAY_DOMAIN").pipe( Config.option, From 8d2256abcde865fc2ffdb78124c42ef782d81d70 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 19:07:32 -0700 Subject: [PATCH 34/61] fix(relay): name singleton database explicitly Co-authored-by: codex --- infra/relay/src/db.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/infra/relay/src/db.ts b/infra/relay/src/db.ts index 8d938bb3fe4..1a778293736 100644 --- a/infra/relay/src/db.ts +++ b/infra/relay/src/db.ts @@ -28,6 +28,7 @@ export const PlanetscaleDatabase = Effect.gen(function* () { const database = mode === "shared-database" ? yield* Planetscale.PostgresDatabase("RelayPostgresDatabase", { + name: "t3coderelay", region: { slug: "us-west" }, clusterSize: "PS_5", migrationsDir: schema.out, From 846b3edba940a42d9f361ca1e6cb4b149f6b665f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 19:16:00 -0700 Subject: [PATCH 35/61] fix(relay): keep managed endpoint zone prod-owned Co-authored-by: codex --- infra/relay/src/deploymentConfig.test.ts | 8 +++++++ infra/relay/src/deploymentConfig.ts | 5 +++++ infra/relay/src/zone.ts | 27 +++++++++++++++++++++--- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index 5aadb277c84..0ae4f6054a8 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -4,6 +4,7 @@ import { managedEndpointDigestInput, managedEndpointHostname, managedEndpointTunnelName, + relayOwnsManagedEndpointZone, relayPublicDomainForStage, relayResourceNameForStage, relayStageSlug, @@ -27,6 +28,13 @@ describe("relayPublicDomainForStage", () => { }); }); +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( diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts index e8498214b0a..2ff61f45194 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -2,6 +2,7 @@ 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 @@ -37,6 +38,10 @@ 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}`; diff --git a/infra/relay/src/zone.ts b/infra/relay/src/zone.ts index cb547e17c31..a626e94f72a 100644 --- a/infra/relay/src/zone.ts +++ b/infra/relay/src/zone.ts @@ -5,7 +5,19 @@ import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import { relayPublicDomainForStage } from "./deploymentConfig.ts"; +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; @@ -26,6 +38,15 @@ export const RelayDeploymentConfig = Effect.gen(function* () { }); export const ManagedEndpointZone = RelayDeploymentConfig.pipe( - Effect.map(({ managedEndpointZoneName }) => managedEndpointZoneName), - Effect.flatMap((name) => Cloudflare.Zone("ManagedEndpointZone", { name }).pipe(adopt(true))), + 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")), + ), + ), ); From bb99d264b4d041e232f54912c873361622fcaa8b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 19:47:00 -0700 Subject: [PATCH 36/61] fix(relay): use stable Clerk JWT audience Co-authored-by: codex --- .env.example | 3 +- .github/workflows/deploy-relay.yml | 1 + .github/workflows/release.yml | 8 ++++ apps/mobile/README.md | 3 +- apps/mobile/app.config.ts | 1 + apps/mobile/src/app/settings/environments.tsx | 8 ++-- apps/mobile/src/app/settings/index.tsx | 10 +++-- .../src/features/cloud/CloudAuthProvider.tsx | 5 +-- .../src/features/cloud/publicConfig.test.ts | 4 +- .../mobile/src/features/cloud/publicConfig.ts | 17 ++++++- apps/web/src/cloud/managedAuth.tsx | 6 +-- apps/web/src/cloud/publicConfig.test.ts | 4 ++ apps/web/src/cloud/publicConfig.ts | 14 +++++- .../settings/ConnectionsSettings.tsx | 6 +-- apps/web/src/vite-env.d.ts | 1 + apps/web/vite.config.ts | 2 + docs/release.md | 6 ++- docs/t3-cloud-clerk.md | 45 ++++++++++--------- infra/relay/.env.example | 4 +- infra/relay/README.md | 2 + infra/relay/src/Config.ts | 1 + .../src/agentActivity/ApnsDeliveries.test.ts | 1 + .../agentActivity/MobileRegistrations.test.ts | 1 + infra/relay/src/auth/RelayTokens.test.ts | 1 + .../environments/EnvironmentConnector.test.ts | 1 + .../environments/EnvironmentLinker.test.ts | 1 + .../EnvironmentPublishSignatures.test.ts | 1 + .../ManagedEndpointProvider.test.ts | 1 + infra/relay/src/http/Api.test.ts | 7 ++- infra/relay/src/http/Api.ts | 9 ++-- infra/relay/src/worker.ts | 2 + packages/shared/src/relayAuth.ts | 12 ++--- scripts/lib/public-config.test.ts | 13 +++++- scripts/lib/public-config.ts | 14 ++++++ 34 files changed, 155 insertions(+), 60 deletions(-) diff --git a/.env.example b/.env.example index 4db2b2d4fa3..0a8ce8bfc1d 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,9 @@ # Release builds inject their public values at build time. Do not add server-side # secrets to this file. -# Get this from the Clerk Dashboard under API keys. +# Get these from the Clerk Dashboard under API keys and JWT templates. # T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... +# T3CODE_CLERK_JWT_TEMPLATE=t3-relay # 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 index eec2c100f72..18a76142c94 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -32,6 +32,7 @@ jobs: 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 }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} APNS_TEAM_ID: ${{ vars.APNS_TEAM_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f3171518d8..150a26948b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,10 +176,12 @@ jobs: name: production outputs: clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} + clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} 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 }} steps: - id: public_config name: Resolve production relay public config @@ -190,6 +192,7 @@ jobs: required=( RELAY_DOMAIN CLERK_PUBLISHABLE_KEY + CLERK_JWT_TEMPLATE ) missing=() for name in "${required[@]}"; do @@ -203,6 +206,7 @@ jobs: fi echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" + echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" echo "relay_url=https://$RELAY_DOMAIN" >> "$GITHUB_OUTPUT" build: @@ -213,6 +217,7 @@ jobs: 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_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} strategy: fail-fast: false @@ -473,6 +478,7 @@ jobs: 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_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} steps: - name: Checkout @@ -628,6 +634,7 @@ jobs: 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 }} @@ -696,6 +703,7 @@ jobs: "${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" diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 37318c49b4e..f5be27c6c3f 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -67,7 +67,8 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy. -For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3CODE_RELAY_URL` +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 PR preview dev-client build manually: diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 7266ff04019..c208bcd26a7 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -159,6 +159,7 @@ const config: ExpoConfig = { }, 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/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index 220f10ba597..0a8e8969724 100644 --- a/apps/mobile/src/app/settings/environments.tsx +++ b/apps/mobile/src/app/settings/environments.tsx @@ -3,7 +3,6 @@ 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 { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; import * as Effect from "effect/Effect"; import { useCallback, useMemo, useState } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; @@ -11,7 +10,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment"; -import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; +import { + hasCloudPublicConfig, + resolveRelayClerkTokenOptions, +} from "../../features/cloud/publicConfig"; import { useManagedRelayEnvironments, useManagedRelayEnvironmentStatus, @@ -142,7 +144,7 @@ function ConfiguredCloudEnvironmentRows() { async (environment: RelayClientEnvironmentRecord) => { setConnectingCloudEnvironmentId(environment.environmentId); try { - const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS); + const token = await getToken(resolveRelayClerkTokenOptions()); if (!token) { throw new Error("Sign in to T3 Cloud before connecting."); } diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index d3f472e5905..b25de626867 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -1,6 +1,5 @@ import { useAuth, useUser, useUserProfileModal } from "@clerk/expo"; import * as Notifications from "expo-notifications"; -import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; import { Link, Stack, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; import * as Effect from "effect/Effect"; @@ -14,7 +13,10 @@ import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/li import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; -import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; +import { + hasCloudPublicConfig, + resolveRelayClerkTokenOptions, +} from "../../features/cloud/publicConfig"; import { mobileRuntime } from "../../lib/runtime"; import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -176,7 +178,7 @@ function ConfiguredSettingsRouteScreen() { setLiveActivityStatus("linking"); try { - const token = await getToken(RELAY_CLERK_TOKEN_OPTIONS); + const token = await getToken(resolveRelayClerkTokenOptions()); if (!token) { promptSignIn(); setLiveActivityStatus("signed-out"); @@ -232,7 +234,7 @@ function ConfiguredSettingsRouteScreen() { setLiveActivityStatus("disabled"); void (async () => { try { - const token = isSignedIn ? await getToken(RELAY_CLERK_TOKEN_OPTIONS) : null; + const token = isSignedIn ? await getToken(resolveRelayClerkTokenOptions()) : null; await mobileRuntime.runPromise( setLiveActivityUpdatesEnabled({ enabled: false, diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index b0a2c4b0ca5..470bd130e90 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -2,7 +2,6 @@ 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 { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; import { mobileRuntime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -11,7 +10,7 @@ import { unregisterAgentAwarenessDeviceForCurrentUser, } from "../agent-awareness/remoteRegistration"; import { refreshActiveLiveActivityRemoteRegistration } from "../agent-awareness/liveActivityController"; -import { resolveCloudPublicConfig } from "./publicConfig"; +import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; function CloudAuthBridge(props: { readonly children: ReactNode }) { const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); @@ -43,7 +42,7 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) .catch(() => undefined); } - const tokenProvider = () => getToken(RELAY_CLERK_TOKEN_OPTIONS); + const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); previousTokenProviderRef.current = { userId, provider: tokenProvider }; setAgentAwarenessRelayTokenProvider(tokenProvider, userId); setManagedRelaySession( diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index 11b3ee2a660..26c1561bf03 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -14,6 +14,7 @@ describe("resolveCloudPublicConfig", () => { it("returns no cloud configuration for an unconfigured build", () => { expect(resolveCloudPublicConfig({})).toEqual({ clerkPublishableKey: null, + clerkJwtTemplate: null, relayUrl: null, }); }); @@ -21,11 +22,12 @@ describe("resolveCloudPublicConfig", () => { it("normalizes statically injected cloud configuration", () => { expect( resolveCloudPublicConfig({ - clerk: { publishableKey: " pk_test_example " }, + 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", }); }); diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts index 1a18bdec7f2..d775f061016 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -1,9 +1,11 @@ import Constants from "expo-constants"; +import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; type ExpoExtra = Readonly> | undefined; export interface CloudPublicConfig { readonly clerkPublishableKey: string | null; + readonly clerkJwtTemplate: string | null; readonly relayUrl: string | null; } @@ -12,16 +14,27 @@ function trimNonEmpty(value: unknown): string | null { } export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { - const clerk = extra?.clerk as { readonly publishableKey?: unknown } | undefined; + 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: trimNonEmpty(relay?.url)?.replace(/\/+$/u, "") ?? null, } satisfies CloudPublicConfig; } export function hasCloudPublicConfig(): boolean { const config = resolveCloudPublicConfig(); - return Boolean(config.clerkPublishableKey && config.relayUrl); + 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/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx index df4e06a97c6..b00c445f08d 100644 --- a/apps/web/src/cloud/managedAuth.tsx +++ b/apps/web/src/cloud/managedAuth.tsx @@ -1,9 +1,9 @@ import { useAuth } from "@clerk/react"; import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; -import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; import { useEffect, type ReactNode } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { resolveRelayClerkTokenOptions } from "./publicConfig"; let relayTokenProvider: (() => Promise) | null = null; @@ -15,13 +15,13 @@ export function ManagedRelayAuthProvider({ children }: { readonly children: Reac const { getToken, isSignedIn, userId } = useAuth(); useEffect(() => { - relayTokenProvider = isSignedIn ? () => getToken(RELAY_CLERK_TOKEN_OPTIONS) : null; + relayTokenProvider = isSignedIn ? () => getToken(resolveRelayClerkTokenOptions()) : null; setManagedRelaySession( appAtomRegistry, isSignedIn && userId ? createManagedRelaySession({ accountId: userId, - readClerkToken: () => getToken(RELAY_CLERK_TOKEN_OPTIONS), + readClerkToken: () => getToken(resolveRelayClerkTokenOptions()), }) : null, ); diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts index c40026ee068..32a902c47e0 100644 --- a/apps/web/src/cloud/publicConfig.test.ts +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -9,12 +9,16 @@ afterEach(() => { 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); }); diff --git a/apps/web/src/cloud/publicConfig.ts b/apps/web/src/cloud/publicConfig.ts index 1df9696209b..fe3d5b5e63b 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -1,5 +1,8 @@ +import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; + export interface CloudPublicConfig { readonly clerkPublishableKey: string | null; + readonly clerkJwtTemplate: string | null; readonly relayUrl: string | null; } @@ -12,6 +15,7 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { 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: trimNonEmpty(import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined)?.replace( /\/+$/u, @@ -22,5 +26,13 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { export function hasCloudPublicConfig(): boolean { const config = resolveCloudPublicConfig(); - return Boolean(config.clerkPublishableKey && config.relayUrl); + 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/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 3666fc2f2d1..9dab7b61fac 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -30,7 +30,6 @@ import { type EnvironmentId, } from "@t3tools/contracts"; import { WsRpcClient } from "@t3tools/client-runtime"; -import { RELAY_CLERK_TOKEN_OPTIONS } from "@t3tools/shared/relayAuth"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; @@ -38,6 +37,7 @@ 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, @@ -1640,7 +1640,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b setIsUpdating(true); setOperationError(null); try { - const clerkToken = await getToken(RELAY_CLERK_TOKEN_OPTIONS); + const clerkToken = await getToken(resolveRelayClerkTokenOptions()); if (enabled) { if (!clerkToken) { throw new Error("Sign in from T3 Cloud settings before linking this environment."); @@ -1753,7 +1753,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { setConnectingEnvironmentId(environment.environmentId); try { - const clerkToken = await getToken(RELAY_CLERK_TOKEN_OPTIONS); + const clerkToken = await getToken(resolveRelayClerkTokenOptions()); if (!clerkToken) { throw new Error("Sign in from T3 Cloud settings before connecting this environment."); } diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index c00ade59121..0c06dbb9ddd 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -8,6 +8,7 @@ interface ImportMetaEnv { 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/vite.config.ts b/apps/web/vite.config.ts index 381dac5889c..cc15820edf4 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -18,6 +18,7 @@ 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 = (() => { @@ -132,6 +133,7 @@ export default defineConfig(() => { "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/release.md b/docs/release.md index 4a4674f51e4..082fa08e037 100644 --- a/docs/release.md +++ b/docs/release.md @@ -10,7 +10,7 @@ This document covers the unified release workflow for stable and nightly desktop - 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 publishable key before packaging clients. +- 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 @@ -38,7 +38,7 @@ release channels. `.github/workflows/deploy-relay.yml` deploys Alchemy stage `prod` on every push to `main`. It also supports manual dispatch for retries. The release workflow reads the relay URL and Clerk -publishable key from the existing `production` GitHub Actions environment before building +client configuration from the existing `production` GitHub Actions environment before building desktop, CLI, or hosted web artifacts. Required repository variables shared by relay deployments: @@ -59,6 +59,8 @@ 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` diff --git a/docs/t3-cloud-clerk.md b/docs/t3-cloud-clerk.md index 518fcbf613f..10a05a7ee3b 100644 --- a/docs/t3-cloud-clerk.md +++ b/docs/t3-cloud-clerk.md @@ -1,8 +1,8 @@ # 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 relay URL as the -audience. +Clerk JWTs only when they are generated from the `t3-relay` template with the shared +`t3-code-relay` audience. ## Application Keys @@ -11,13 +11,13 @@ or `.env.local` file: ```dotenv T3CODE_CLERK_PUBLISHABLE_KEY= +T3CODE_CLERK_JWT_TEMPLATE= T3CODE_RELAY_URL=https://relay.example.com ``` -The shared client loader projects these canonical values into the framework-specific -`VITE_CLERK_PUBLISHABLE_KEY`, `VITE_T3CODE_RELAY_URL`, and -`EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` aliases. Existing aliases remain accepted as overrides for -compatibility, but new client configuration should use the canonical names. +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: @@ -25,17 +25,18 @@ Configuration precedence is: 2. Repository-root `.env.local`. 3. Repository-root `.env`. -The Clerk publishable key and relay URL are public identifiers, not secrets. Web, desktop, and -mobile builds statically inject them during their build step. A built artifact does not need an -environment file at runtime. CI release builds should set `T3CODE_CLERK_PUBLISHABLE_KEY` and -`T3CODE_RELAY_URL` before building. EAS preview and production builds should define the same -client-facing values in their EAS environment. +The Clerk publishable key, JWT template name, and relay URL are public identifiers, not secrets. +Web, desktop, and mobile builds statically inject them 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`, and `T3CODE_RELAY_URL` before building. +EAS preview and production builds should define the same client-facing values in their EAS +environment. -When either public value is absent, cloud UI is omitted. +When any client-facing public value is absent, cloud UI is omitted. For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay -deployment reads `RELAY_DOMAIN`, `RELAY_ZONE_NAME`, and `CLERK_PUBLISHABLE_KEY` through Effect -`Config`. There are no checked-in deployment defaults. +deployment reads `RELAY_DOMAIN`, `RELAY_ZONE_NAME`, `CLERK_PUBLISHABLE_KEY`, and +`CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in deployment defaults. `bun --cwd infra/relay run 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 @@ -50,14 +51,16 @@ preview or developer stage. In **Clerk Dashboard > JWT templates**, create a template with: -| Setting | Value | -| ------- | ---------------------------------------- | -| Name | `t3-relay` | -| Claims | `{ "aud": "https://relay.example.com" }` | +| Setting | Value | +| ------- | ---------------------------- | +| Name | `t3-relay` | +| Claims | `{ "aud": "t3-code-relay" }` | -The `aud` value must be the deployed relay public URL, with no trailing slash. It must match the -client-facing `T3CODE_RELAY_URL` and the HTTPS URL derived from the deployment's `RELAY_DOMAIN`. If -the relay domain changes, update both values and the JWT template. +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 diff --git a/infra/relay/.env.example b/infra/relay/.env.example index 66a73eaf9f9..c63cf71985c 100644 --- a/infra/relay/.env.example +++ b/infra/relay/.env.example @@ -8,9 +8,11 @@ RELAY_ZONE_NAME=example.com # RELAY_DOMAIN=relay.example.com # Required: Clerk -# Get both values from the Clerk Dashboard under API keys. +# 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 diff --git a/infra/relay/README.md b/infra/relay/README.md index 21414063f6c..26d57a275ac 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -133,6 +133,8 @@ 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` diff --git a/infra/relay/src/Config.ts b/infra/relay/src/Config.ts index 95a020cf4e9..23f3ba061b1 100644 --- a/infra/relay/src/Config.ts +++ b/infra/relay/src/Config.ts @@ -18,6 +18,7 @@ export interface RelayConfigurationShape { readonly apns: ApnsCredentials; readonly clerkSecretKey: Redacted.Redacted; readonly clerkPublishableKey: string; + readonly clerkJwtAudience: string; readonly apnsDeliveryJobSigningSecret: Redacted.Redacted; readonly cloudMintPrivateKey: Redacted.Redacted; readonly cloudMintPublicKey: string; diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts index 9e2d418aa1f..0dfee1fb0cd 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts @@ -39,6 +39,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ 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, diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 8227667273d..74cf905523b 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -129,6 +129,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ }, 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", diff --git a/infra/relay/src/auth/RelayTokens.test.ts b/infra/relay/src/auth/RelayTokens.test.ts index afdb69ce769..171981834c8 100644 --- a/infra/relay/src/auth/RelayTokens.test.ts +++ b/infra/relay/src/auth/RelayTokens.test.ts @@ -26,6 +26,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ 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, diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 7587b744b81..553491d881d 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -66,6 +66,7 @@ const settings = RelayConfiguration.RelayConfiguration.of({ 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: undefined, diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index 7a8843cb05b..cbf2b89fddd 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -39,6 +39,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ 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, diff --git a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts index c6f5a84034e..a74ce670cfb 100644 --- a/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts +++ b/infra/relay/src/environments/EnvironmentPublishSignatures.test.ts @@ -34,6 +34,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ 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, diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 1b8bcde6721..8466c9b9196 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -21,6 +21,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ 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", diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index f0a49bd34bc..2822d30b1e0 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -40,6 +40,7 @@ const relaySettings: RelayConfiguration.RelayConfigurationShape = { }, 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", @@ -52,13 +53,17 @@ describe("relay client authentication", () => { Effect.gen(function* () { vi.mocked(verifyToken).mockResolvedValue({ sub: "user_session", - aud: relaySettings.relayIssuer, + 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( diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 4b79a858ff3..fada5a307c1 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -578,7 +578,7 @@ export const tokenApi = HttpApiBuilder.group( const verified = yield* verifyClerkBearerToken(config, args.payload.subject_token).pipe( Effect.catch(() => relayAuthInvalidError("invalid_bearer")), ); - if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.relayIssuer)) { + if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience)) { return yield* relayAuthInvalidError("invalid_bearer"); } const proofKeyThumbprint = yield* requireDpopProof().pipe( @@ -960,8 +960,7 @@ function clerkVerificationFailureReason(cause: unknown): string { return "unknown"; } -function hasExpectedClerkAudience(audience: unknown, relayIssuer: string): boolean { - const expectedAudience = normalizeRelayIssuer(relayIssuer); +function hasExpectedClerkAudience(audience: unknown, expectedAudience: string): boolean { return typeof audience === "string" ? audience === expectedAudience : Array.isArray(audience) && @@ -973,7 +972,7 @@ function verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationSha try: () => verifyToken(token, { secretKey: Redacted.value(config.clerkSecretKey), - audience: normalizeRelayIssuer(config.relayIssuer), + audience: config.clerkJwtAudience, }), catch: (cause) => new ClerkTokenVerificationFailed({ cause }), }).pipe( @@ -1015,7 +1014,7 @@ export function verifyRelayClientBearerToken( ) { return verifyClerkBearerToken(config, token).pipe( Effect.flatMap((verified) => - verified.sub && hasExpectedClerkAudience(verified.aud, config.relayIssuer) + 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" })), ), diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 712e822e688..9e2ac934c35 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -130,6 +130,7 @@ export default class Api extends Cloudflare.Worker()( 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; @@ -158,6 +159,7 @@ export default class Api extends Cloudflare.Worker()( apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret, clerkSecretKey, clerkPublishableKey, + clerkJwtAudience, cloudMintPrivateKey: yield* cloudMintPrivateKey, cloudMintPublicKey: yield* cloudMintPublicKey, managedEndpointBaseDomain: yield* managedEndpointZoneName, diff --git a/packages/shared/src/relayAuth.ts b/packages/shared/src/relayAuth.ts index 0f8c139d413..eb65da93987 100644 --- a/packages/shared/src/relayAuth.ts +++ b/packages/shared/src/relayAuth.ts @@ -1,6 +1,6 @@ -export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay"; - -export const RELAY_CLERK_TOKEN_OPTIONS = { - template: RELAY_CLERK_JWT_TEMPLATE, - skipCache: true, -} as const; +export function relayClerkTokenOptions(template: string) { + return { + template, + skipCache: true, + } as const; +} diff --git a/scripts/lib/public-config.test.ts b/scripts/lib/public-config.test.ts index f5af24a5009..40cd29b3c1f 100644 --- a/scripts/lib/public-config.test.ts +++ b/scripts/lib/public-config.test.ts @@ -21,6 +21,9 @@ describe("loadRepoEnv", () => { expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).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(); }); @@ -29,11 +32,11 @@ describe("loadRepoEnv", () => { const repoRoot = makeTemporaryDirectory(); writeFileSync( join(repoRoot, ".env"), - "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3CODE_RELAY_URL=https://root.example.test\n", + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3CODE_CLERK_JWT_TEMPLATE=template_root\nT3CODE_RELAY_URL=https://root.example.test\n", ); writeFileSync( join(repoRoot, ".env.local"), - "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3CODE_RELAY_URL=https://local.example.test\n", + "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3CODE_CLERK_JWT_TEMPLATE=template_local\nT3CODE_RELAY_URL=https://local.example.test\n", ); expect(loadRepoEnv({ baseEnv: {}, repoRoot }).T3CODE_RELAY_URL).toBe( @@ -43,6 +46,7 @@ describe("loadRepoEnv", () => { loadRepoEnv({ baseEnv: { T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", + T3CODE_CLERK_JWT_TEMPLATE: "template_ci", T3CODE_RELAY_URL: "https://ci.example.test", }, repoRoot, @@ -51,6 +55,9 @@ describe("loadRepoEnv", () => { T3CODE_CLERK_PUBLISHABLE_KEY: "pk_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", }); @@ -60,10 +67,12 @@ describe("loadRepoEnv", () => { expect( resolvePublicConfig({ VITE_CLERK_PUBLISHABLE_KEY: "pk_legacy", + VITE_CLERK_JWT_TEMPLATE: "template_legacy", VITE_T3CODE_RELAY_URL: "https://legacy.example.test", }), ).toEqual({ clerkPublishableKey: "pk_legacy", + clerkJwtTemplate: "template_legacy", relayUrl: "https://legacy.example.test", }); }); diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index ea3819bf844..8176a9cae20 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -6,6 +6,7 @@ import * as NodeUtil from "node:util"; export interface T3CodePublicConfig { readonly clerkPublishableKey: string | undefined; + readonly clerkJwtTemplate: string | undefined; readonly relayUrl: string | undefined; } @@ -37,6 +38,13 @@ export function loadRepoEnv({ 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.relayUrl ? { T3CODE_RELAY_URL: config.relayUrl, @@ -54,6 +62,12 @@ export function resolvePublicConfig(...sources: readonly Environment[]): T3CodeP "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", + ), relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"), }; } From 944e73ff9a4b62a2baa21eba9ebe3591573b63b5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 20:07:02 -0700 Subject: [PATCH 37/61] refactor(cloud): name managed connector relay client Co-authored-by: codex --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 6 +- apps/desktop/src/ipc/channels.ts | 4 +- apps/desktop/src/ipc/methods/cloudflared.ts | 27 ----- ...loudflared.test.ts => relayClient.test.ts} | 20 ++-- apps/desktop/src/ipc/methods/relayClient.ts | 27 +++++ apps/desktop/src/main.ts | 8 +- apps/desktop/src/preload.ts | 4 +- .../src/cloud/ManagedEndpointRuntime.test.ts | 22 ++-- .../src/cloud/ManagedEndpointRuntime.ts | 10 +- apps/server/src/server.ts | 8 +- apps/web/src/cloud/linkEnvironment.test.ts | 28 ++--- apps/web/src/cloud/linkEnvironment.ts | 28 ++--- .../settings/SettingsPanels.browser.tsx | 4 +- apps/web/src/localApi.test.ts | 4 +- packages/contracts/src/ipc.ts | 8 +- packages/shared/package.json | 6 +- ...loudflared.test.ts => relayClient.test.ts} | 20 ++-- .../src/{cloudflared.ts => relayClient.ts} | 104 +++++++++--------- 18 files changed, 168 insertions(+), 170 deletions(-) delete mode 100644 apps/desktop/src/ipc/methods/cloudflared.ts rename apps/desktop/src/ipc/methods/{cloudflared.test.ts => relayClient.test.ts} (61%) create mode 100644 apps/desktop/src/ipc/methods/relayClient.ts rename packages/shared/src/{cloudflared.test.ts => relayClient.test.ts} (94%) rename packages/shared/src/{cloudflared.ts => relayClient.ts} (82%) diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 502874e5630..98e34bba59d 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -9,7 +9,7 @@ import { setCloudAuthToken, } from "./methods/cloudAuth.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; -import { getCloudflaredStatus, installCloudflared } from "./methods/cloudflared.ts"; +import { getRelayClientStatus, installRelayClient } from "./methods/relayClient.ts"; import { getSavedEnvironmentRegistry, getSavedEnvironmentSecret, @@ -88,8 +88,8 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(setCloudAuthToken); yield* ipc.handle(clearCloudAuthToken); yield* ipc.handle(fetchCloudAuth); - yield* ipc.handle(getCloudflaredStatus); - yield* ipc.handle(installCloudflared); + yield* ipc.handle(getRelayClientStatus); + yield* ipc.handle(installRelayClient); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5e7d1af626e..8332cc464c7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,8 +8,8 @@ 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 GET_CLOUDFLARED_STATUS_CHANNEL = "desktop:get-cloudflared-status"; -export const INSTALL_CLOUDFLARED_CHANNEL = "desktop:install-cloudflared"; +export const GET_RELAY_CLIENT_STATUS_CHANNEL = "desktop:get-relay-client-status"; +export const INSTALL_RELAY_CLIENT_CHANNEL = "desktop:install-relay-client"; 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"; diff --git a/apps/desktop/src/ipc/methods/cloudflared.ts b/apps/desktop/src/ipc/methods/cloudflared.ts deleted file mode 100644 index 48c10d3a1c5..00000000000 --- a/apps/desktop/src/ipc/methods/cloudflared.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { DesktopCloudflaredStatusSchema } from "@t3tools/contracts"; -import * as Cloudflared from "@t3tools/shared/cloudflared"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; - -export const getCloudflaredStatus = makeIpcMethod({ - channel: IpcChannels.GET_CLOUDFLARED_STATUS_CHANNEL, - payload: Schema.Undefined, - result: DesktopCloudflaredStatusSchema, - handler: Effect.fn("desktop.ipc.cloudflared.getStatus")(function* () { - const cloudflared = yield* Cloudflared.CloudflaredExecutable; - return yield* cloudflared.resolve; - }), -}); - -export const installCloudflared = makeIpcMethod({ - channel: IpcChannels.INSTALL_CLOUDFLARED_CHANNEL, - payload: Schema.Undefined, - result: DesktopCloudflaredStatusSchema, - handler: Effect.fn("desktop.ipc.cloudflared.install")(function* () { - const cloudflared = yield* Cloudflared.CloudflaredExecutable; - return yield* cloudflared.install; - }), -}); diff --git a/apps/desktop/src/ipc/methods/cloudflared.test.ts b/apps/desktop/src/ipc/methods/relayClient.test.ts similarity index 61% rename from apps/desktop/src/ipc/methods/cloudflared.test.ts rename to apps/desktop/src/ipc/methods/relayClient.test.ts index b88b1a6a752..73e0da4ed2b 100644 --- a/apps/desktop/src/ipc/methods/cloudflared.test.ts +++ b/apps/desktop/src/ipc/methods/relayClient.test.ts @@ -1,27 +1,27 @@ import { describe, expect, it } from "@effect/vitest"; -import * as Cloudflared from "@t3tools/shared/cloudflared"; +import * as RelayClient from "@t3tools/shared/relayClient"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { getCloudflaredStatus, installCloudflared } from "./cloudflared.ts"; +import { getRelayClientStatus, installRelayClient } from "./relayClient.ts"; const available = { status: "available", executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", source: "managed", - version: Cloudflared.CLOUDFLARED_VERSION, + version: RelayClient.CLOUDFLARED_VERSION, } as const; -describe("Desktop cloudflared IPC", () => { +describe("Desktop relay client IPC", () => { it.effect("reads status and delegates installation to the shared manager", () => Effect.gen(function* () { const installed: Array = []; const layer = Layer.succeed( - Cloudflared.CloudflaredExecutable, - Cloudflared.CloudflaredExecutable.of({ + RelayClient.RelayClient, + RelayClient.RelayClient.of({ resolve: Effect.succeed({ status: "missing", - version: Cloudflared.CLOUDFLARED_VERSION, + version: RelayClient.CLOUDFLARED_VERSION, }), install: Effect.sync(() => { installed.push(true); @@ -30,11 +30,11 @@ describe("Desktop cloudflared IPC", () => { }), ); - expect(yield* getCloudflaredStatus.handler(undefined).pipe(Effect.provide(layer))).toEqual({ + expect(yield* getRelayClientStatus.handler(undefined).pipe(Effect.provide(layer))).toEqual({ status: "missing", - version: Cloudflared.CLOUDFLARED_VERSION, + version: RelayClient.CLOUDFLARED_VERSION, }); - expect(yield* installCloudflared.handler(undefined).pipe(Effect.provide(layer))).toEqual( + expect(yield* installRelayClient.handler(undefined).pipe(Effect.provide(layer))).toEqual( available, ); expect(installed).toEqual([true]); diff --git a/apps/desktop/src/ipc/methods/relayClient.ts b/apps/desktop/src/ipc/methods/relayClient.ts new file mode 100644 index 00000000000..dafd20700f4 --- /dev/null +++ b/apps/desktop/src/ipc/methods/relayClient.ts @@ -0,0 +1,27 @@ +import { DesktopRelayClientStatusSchema } from "@t3tools/contracts"; +import * as RelayClient from "@t3tools/shared/relayClient"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getRelayClientStatus = makeIpcMethod({ + channel: IpcChannels.GET_RELAY_CLIENT_STATUS_CHANNEL, + payload: Schema.Undefined, + result: DesktopRelayClientStatusSchema, + handler: Effect.fn("desktop.ipc.relayClient.getStatus")(function* () { + const relayClient = yield* RelayClient.RelayClient; + return yield* relayClient.resolve; + }), +}); + +export const installRelayClient = makeIpcMethod({ + channel: IpcChannels.INSTALL_RELAY_CLIENT_CHANNEL, + payload: Schema.Undefined, + result: DesktopRelayClientStatusSchema, + handler: Effect.fn("desktop.ipc.relayClient.install")(function* () { + const relayClient = yield* RelayClient.RelayClient; + return yield* relayClient.install; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 168fadbd1ca..e8177355e91 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -9,7 +9,7 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; -import * as Cloudflared from "@t3tools/shared/cloudflared"; +import * as RelayClient from "@t3tools/shared/relayClient"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -95,10 +95,10 @@ const desktopSshEnvironmentLayer = Layer.unwrap( }), ); -const desktopCloudflaredLayer = Layer.unwrap( +const desktopRelayClientLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - return Cloudflared.layer({ + return RelayClient.layerCloudflared({ baseDir: environment.baseDir, platform: environment.platform, arch: environment.processArch, @@ -155,7 +155,7 @@ const desktopApplicationLayer = Layer.mergeAll( desktopSshLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), - Layer.provideMerge(desktopCloudflaredLayer), + Layer.provideMerge(desktopRelayClientLayer), Layer.provideMerge(desktopBackendLayer), ); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e705d0b6632..1dde53c27a7 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -102,8 +102,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { 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), - getCloudflaredStatus: () => ipcRenderer.invoke(IpcChannels.GET_CLOUDFLARED_STATUS_CHANNEL), - installCloudflared: () => ipcRenderer.invoke(IpcChannels.INSTALL_CLOUDFLARED_CHANNEL), + getRelayClientStatus: () => ipcRenderer.invoke(IpcChannels.GET_RELAY_CLIENT_STATUS_CHANNEL), + installRelayClient: () => ipcRenderer.invoke(IpcChannels.INSTALL_RELAY_CLIENT_CHANNEL), onCloudAuthCallback: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => { if (typeof rawUrl !== "string") return; diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 095e02c27ca..03e6e2c0d29 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -8,18 +8,18 @@ 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 Cloudflared from "@t3tools/shared/cloudflared"; +import * as RelayClient from "@t3tools/shared/relayClient"; import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; -const cloudflaredAvailableLayer = Layer.succeed( - Cloudflared.CloudflaredExecutable, - Cloudflared.CloudflaredExecutable.of({ +const relayClientAvailableLayer = Layer.succeed( + RelayClient.RelayClient, + RelayClient.RelayClient.of({ resolve: Effect.succeed({ status: "available", executablePath: "cloudflared", source: "path", - version: Cloudflared.CLOUDFLARED_VERSION, + version: RelayClient.CLOUDFLARED_VERSION, }), install: Effect.die("unused"), }), @@ -28,7 +28,7 @@ const cloudflaredAvailableLayer = Layer.succeed( const runtimeDependencies = (spawner: ReturnType) => Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - cloudflaredAvailableLayer, + relayClientAvailableLayer, ); function makeHandle(input: { @@ -317,7 +317,7 @@ describe("CloudManagedEndpointRuntime", () => { }), ); - it.effect("reports a missing cloudflared executable without spawning", () => + it.effect("reports a missing relay client executable without spawning", () => Effect.gen(function* () { const spawn = vi.fn(); const spawner = ChildProcessSpawner.make(spawn); @@ -326,11 +326,11 @@ describe("CloudManagedEndpointRuntime", () => { Layer.mergeAll( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed( - Cloudflared.CloudflaredExecutable, - Cloudflared.CloudflaredExecutable.of({ + RelayClient.RelayClient, + RelayClient.RelayClient.of({ resolve: Effect.succeed({ status: "missing", - version: Cloudflared.CLOUDFLARED_VERSION, + version: RelayClient.CLOUDFLARED_VERSION, }), install: Effect.die("unused"), }), @@ -347,7 +347,7 @@ describe("CloudManagedEndpointRuntime", () => { expect(status).toEqual({ status: "failed", providerKind: "cloudflare_tunnel", - reason: "cloudflared is not installed.", + 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 index 6072cb41a3e..8e7cb7f2980 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -1,5 +1,5 @@ import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; -import * as Cloudflared from "@t3tools/shared/cloudflared"; +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"; @@ -91,7 +91,7 @@ const stopConnector = (connector: ActiveConnector | null) => export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const cloudflared = yield* Cloudflared.CloudflaredExecutable; + const relayClient = yield* RelayClient.RelayClient; const activeRef = yield* Ref.make(null); const desiredConfigRef = yield* Ref.make(null); const reconcileSemaphore = yield* Semaphore.make(1); @@ -170,15 +170,15 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { yield* stopActive; - const executable = yield* cloudflared.resolve; + const executable = yield* relayClient.resolve; if (executable.status !== "available") { return { status: "failed", providerKind: "cloudflare_tunnel", reason: executable.status === "unsupported" - ? `Managed cloudflared is unsupported on ${executable.platform}-${executable.arch}.` - : "cloudflared is not installed.", + ? `Managed 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; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index eedb3afef7b..9249099c8f7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -81,7 +81,7 @@ import { } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import * as NetService from "@t3tools/shared/Net"; -import * as Cloudflared from "@t3tools/shared/cloudflared"; +import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; const PtyAdapterLive = Layer.unwrap( @@ -96,10 +96,10 @@ const PtyAdapterLive = Layer.unwrap( }), ); -const CloudflaredExecutableLive = Layer.unwrap( +const RelayClientLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig; - return Cloudflared.layer({ baseDir: config.baseDir }); + return RelayClient.layerCloudflared({ baseDir: config.baseDir }); }), ); @@ -287,7 +287,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge( CloudManagedEndpointRuntime.layer.pipe( Layer.provide(ServerSecretStore.layer), - Layer.provide(CloudflaredExecutableLive), + Layer.provide(RelayClientLive), ), ), ); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index fe331ec81dd..fa04b06eb08 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -16,7 +16,7 @@ import { import type { SavedEnvironmentRecord } from "../environments/runtime"; import { connectManagedCloudEnvironment, - ensureDesktopCloudflaredAvailable, + ensureDesktopRelayClientAvailable, linkEnvironmentToCloud, linkPrimaryEnvironmentToCloud, listManagedCloudEnvironments, @@ -124,10 +124,10 @@ describe("web cloud link environment client", () => { expect(normalizeRelayBaseUrl(" ")).toBeNull(); }); - it("installs cloudflared after desktop confirmation", async () => { + it("installs the relay client after desktop confirmation", async () => { vi.stubGlobal("window", {}); const confirm = vi.fn().mockResolvedValue(true); - const installCloudflared = vi.fn().mockResolvedValue({ + const installRelayClient = vi.fn().mockResolvedValue({ status: "available", executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", source: "managed", @@ -135,35 +135,35 @@ describe("web cloud link environment client", () => { }); window.desktopBridge = { confirm, - getCloudflaredStatus: vi.fn().mockResolvedValue({ + getRelayClientStatus: vi.fn().mockResolvedValue({ status: "missing", version: "2026.5.2", }), - installCloudflared, + installRelayClient, } as unknown as DesktopBridge; - await Effect.runPromise(ensureDesktopCloudflaredAvailable()); + await Effect.runPromise(ensureDesktopRelayClientAvailable()); expect(confirm).toHaveBeenCalledOnce(); - expect(installCloudflared).toHaveBeenCalledOnce(); + expect(installRelayClient).toHaveBeenCalledOnce(); }); - it("does not install cloudflared when desktop confirmation is declined", async () => { + it("does not install the relay client when desktop confirmation is declined", async () => { vi.stubGlobal("window", {}); - const installCloudflared = vi.fn(); + const installRelayClient = vi.fn(); window.desktopBridge = { confirm: vi.fn().mockResolvedValue(false), - getCloudflaredStatus: vi.fn().mockResolvedValue({ + getRelayClientStatus: vi.fn().mockResolvedValue({ status: "missing", version: "2026.5.2", }), - installCloudflared, + installRelayClient, } as unknown as DesktopBridge; - await expect(Effect.runPromise(ensureDesktopCloudflaredAvailable())).rejects.toMatchObject({ - message: "Cloudflare Tunnel installation was cancelled.", + await expect(Effect.runPromise(ensureDesktopRelayClientAvailable())).rejects.toMatchObject({ + message: "Relay client installation was cancelled.", }); - expect(installCloudflared).not.toHaveBeenCalled(); + expect(installRelayClient).not.toHaveBeenCalled(); }); it.effect("lists relay-managed environments for hosted and served web clients", () => diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 9542b4195e9..81548d34ffb 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -56,13 +56,13 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen readonly cause?: unknown; }> {} -const desktopCloudflaredBridgeError = (cause: unknown) => +const desktopRelayClientBridgeError = (cause: unknown) => new CloudEnvironmentLinkError({ - message: "Could not prepare Cloudflare Tunnel.", + message: "Could not prepare the relay client.", cause, }); -export function ensureDesktopCloudflaredAvailable(): Effect.Effect< +export function ensureDesktopRelayClientAvailable(): Effect.Effect< void, CloudEnvironmentLinkError > { @@ -71,39 +71,39 @@ export function ensureDesktopCloudflaredAvailable(): Effect.Effect< return Effect.gen(function* () { const status = yield* Effect.tryPromise({ - try: () => bridge.getCloudflaredStatus(), - catch: desktopCloudflaredBridgeError, + try: () => bridge.getRelayClientStatus(), + catch: desktopRelayClientBridgeError, }); if (status.status === "available") return; if (status.status === "unsupported") { return yield* new CloudEnvironmentLinkError({ - message: `T3 Code cannot install cloudflared automatically on ${status.platform}-${status.arch}.`, + message: `T3 Code cannot install the relay client automatically on ${status.platform}-${status.arch}.`, }); } const confirmed = yield* Effect.tryPromise({ try: () => bridge.confirm( - "T3 Code needs Cloudflare Tunnel to make this environment available through T3 Cloud. Download and install cloudflared now?", + "T3 Code needs the relay client to make this environment available through T3 Cloud. Download and install it now?", ), - catch: desktopCloudflaredBridgeError, + catch: desktopRelayClientBridgeError, }); if (!confirmed) { return yield* new CloudEnvironmentLinkError({ - message: "Cloudflare Tunnel installation was cancelled.", + message: "Relay client installation was cancelled.", }); } const installed = yield* Effect.tryPromise({ - try: () => bridge.installCloudflared(), - catch: desktopCloudflaredBridgeError, + try: () => bridge.installRelayClient(), + catch: desktopRelayClientBridgeError, }); if (installed.status !== "available") { return yield* new CloudEnvironmentLinkError({ message: installed.status === "unsupported" - ? `T3 Code cannot install cloudflared automatically on ${installed.platform}-${installed.arch}.` - : "cloudflared is still unavailable after installation.", + ? `T3 Code cannot install the relay client automatically on ${installed.platform}-${installed.arch}.` + : "The relay client is still unavailable after installation.", }); } }); @@ -624,7 +624,7 @@ export function linkPrimaryEnvironmentToCloud(input: { message: "Local environment is not ready yet.", }); } - yield* ensureDesktopCloudflaredAvailable(); + yield* ensureDesktopRelayClientAvailable(); const challenge = yield* relayClient .createEnvironmentLinkChallenge({ diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 15bbb032f71..bed17319a72 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -470,13 +470,13 @@ const createDesktopBridgeStub = (overrides?: { headers: {}, body: "", }), - getCloudflaredStatus: vi.fn().mockResolvedValue({ + getRelayClientStatus: vi.fn().mockResolvedValue({ status: "available", executablePath: "/usr/local/bin/cloudflared", source: "path", version: "2026.5.2", }), - installCloudflared: vi.fn().mockResolvedValue({ + installRelayClient: vi.fn().mockResolvedValue({ status: "available", executablePath: "/usr/local/bin/cloudflared", source: "path", diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 2f92a5772c9..9b98055d5bf 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -245,13 +245,13 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg headers: {}, body: "", }), - getCloudflaredStatus: async () => ({ + getRelayClientStatus: async () => ({ status: "available", executablePath: "/usr/local/bin/cloudflared", source: "path", version: "2026.5.2", }), - installCloudflared: async () => ({ + installRelayClient: async () => ({ status: "available", executablePath: "/usr/local/bin/cloudflared", source: "path", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1d9731dcb08..c7a4e6b5dca 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -394,7 +394,7 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ }); export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; -export const DesktopCloudflaredStatusSchema = Schema.Union([ +export const DesktopRelayClientStatusSchema = Schema.Union([ Schema.Struct({ status: Schema.Literal("available"), executablePath: Schema.String, @@ -412,7 +412,7 @@ export const DesktopCloudflaredStatusSchema = Schema.Union([ version: Schema.String, }), ]); -export type DesktopCloudflaredStatus = typeof DesktopCloudflaredStatusSchema.Type; +export type DesktopRelayClientStatus = typeof DesktopRelayClientStatusSchema.Type; export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; @@ -464,8 +464,8 @@ export interface DesktopBridge { setCloudAuthToken: (token: string) => Promise; clearCloudAuthToken: () => Promise; fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise; - getCloudflaredStatus: () => Promise; - installCloudflared: () => Promise; + getRelayClientStatus: () => Promise; + installRelayClient: () => Promise; onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/shared/package.json b/packages/shared/package.json index 938f70cb49c..2f825f9166d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -139,9 +139,9 @@ "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" }, - "./cloudflared": { - "types": "./src/cloudflared.ts", - "import": "./src/cloudflared.ts" + "./relayClient": { + "types": "./src/relayClient.ts", + "import": "./src/relayClient.ts" } }, "scripts": { diff --git a/packages/shared/src/cloudflared.test.ts b/packages/shared/src/relayClient.test.ts similarity index 94% rename from packages/shared/src/cloudflared.test.ts rename to packages/shared/src/relayClient.test.ts index cfd7340f976..1acdbea754b 100644 --- a/packages/shared/src/cloudflared.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -12,11 +12,11 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { - CloudflaredInstallError, + RelayClientInstallError, CLOUDFLARED_VERSION, - makeCloudflaredExecutable, + makeCloudflaredRelayClient, resolveManagedCloudflaredPath, -} from "./cloudflared.ts"; +} from "./relayClient.ts"; const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} }); @@ -57,7 +57,7 @@ const makeSpawnerLayer = (commands: Array) => ), ); -describe("CloudflaredExecutable", () => { +describe("RelayClient", () => { it.effect("resolves explicit overrides before managed and PATH executables", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -67,7 +67,7 @@ describe("CloudflaredExecutable", () => { const overridePath = `${baseDir}/override-cloudflared`; yield* fileSystem.writeFileString(overridePath, "override"); yield* fileSystem.chmod(overridePath, 0o755); - const manager = yield* makeCloudflaredExecutable({ + const manager = yield* makeCloudflaredRelayClient({ baseDir, platform: "linux", arch: "x64", @@ -105,7 +105,7 @@ describe("CloudflaredExecutable", () => { prefix: "t3-cloudflared-test-", }); const bytes = new TextEncoder().encode("test-cloudflared-binary"); - const manager = yield* makeCloudflaredExecutable({ + const manager = yield* makeCloudflaredRelayClient({ baseDir, platform: "linux", arch: "x64", @@ -151,7 +151,7 @@ describe("CloudflaredExecutable", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-", }); - const manager = yield* makeCloudflaredExecutable({ + const manager = yield* makeCloudflaredRelayClient({ baseDir, platform: "linux", arch: "x64", @@ -164,7 +164,7 @@ describe("CloudflaredExecutable", () => { }); const error = yield* manager.install.pipe(Effect.flip); - expect(error).toBeInstanceOf(CloudflaredInstallError); + expect(error).toBeInstanceOf(RelayClientInstallError); expect(error.reason).toBe("invalid_checksum"); }).pipe( Effect.scoped, @@ -186,7 +186,7 @@ describe("CloudflaredExecutable", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-", }); - const manager = yield* makeCloudflaredExecutable({ + const manager = yield* makeCloudflaredRelayClient({ baseDir, platform: "linux", arch: "x64", @@ -220,7 +220,7 @@ describe("CloudflaredExecutable", () => { const binDir = `${baseDir}/bin`; const executablePath = `${binDir}/cloudflared`; let path = ""; - const manager = yield* makeCloudflaredExecutable({ + const manager = yield* makeCloudflaredRelayClient({ baseDir, platform: "linux", arch: "x64", diff --git a/packages/shared/src/cloudflared.ts b/packages/shared/src/relayClient.ts similarity index 82% rename from packages/shared/src/cloudflared.ts rename to packages/shared/src/relayClient.ts index 881d5a258a4..7271f894ec6 100644 --- a/packages/shared/src/cloudflared.ts +++ b/packages/shared/src/relayClient.ts @@ -18,13 +18,13 @@ 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 CloudflaredExecutableSource = "override" | "managed" | "path"; +export type RelayClientExecutableSource = "override" | "managed" | "path"; -export type CloudflaredExecutableStatus = +export type RelayClientStatus = | { readonly status: "available"; readonly executablePath: string; - readonly source: CloudflaredExecutableSource; + readonly source: RelayClientExecutableSource; readonly version: string; } | { @@ -38,12 +38,9 @@ export type CloudflaredExecutableStatus = readonly version: string; }; -export type AvailableCloudflaredExecutable = Extract< - CloudflaredExecutableStatus, - { readonly status: "available" } ->; +export type AvailableRelayClient = Extract; -export class CloudflaredInstallError extends Data.TaggedError("CloudflaredInstallError")<{ +export class RelayClientInstallError extends Data.TaggedError("RelayClientInstallError")<{ readonly reason: | "download_failed" | "invalid_checksum" @@ -117,7 +114,7 @@ const CloudflaredConfig = Config.all({ path: trimmedString("PATH"), }); -export interface CloudflaredExecutableOptions { +export interface CloudflaredRelayClientOptions { readonly baseDir: string; readonly platform?: NodeJS.Platform; readonly arch?: string; @@ -125,15 +122,14 @@ export interface CloudflaredExecutableOptions { readonly configProvider?: () => ConfigProvider.ConfigProvider; } -export interface CloudflaredExecutableShape { - readonly resolve: Effect.Effect; - readonly install: Effect.Effect; +export interface RelayClientShape { + readonly resolve: Effect.Effect; + readonly install: Effect.Effect; } -export class CloudflaredExecutable extends Context.Service< - CloudflaredExecutable, - CloudflaredExecutableShape ->()("@t3tools/shared/cloudflared/CloudflaredExecutable") {} +export class RelayClient extends Context.Service()( + "@t3tools/shared/relayClient", +) {} function executableFileName(platform: NodeJS.Platform): string { return platform === "win32" ? "cloudflared.exe" : "cloudflared"; @@ -168,16 +164,16 @@ function isAlreadyExists(error: PlatformError.PlatformError): boolean { const wrapInstallFailure = ( - reason: CloudflaredInstallError["reason"], + reason: RelayClientInstallError["reason"], message: string, ): (( effect: Effect.Effect, - ) => Effect.Effect) => + ) => Effect.Effect) => (effect) => effect.pipe( Effect.mapError( (cause) => - new CloudflaredInstallError({ + new RelayClientInstallError({ reason, message, cause, @@ -185,10 +181,10 @@ const wrapInstallFailure = ), ); -export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* ( - options: CloudflaredExecutableOptions, +export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function* ( + options: CloudflaredRelayClientOptions, ): Effect.fn.Return< - CloudflaredExecutableShape, + RelayClientShape, never, | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto @@ -244,7 +240,7 @@ export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* return null; }); - const resolve: CloudflaredExecutableShape["resolve"] = Effect.gen(function* () { + const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { const config = yield* loadCloudflaredConfig; if (Option.isSome(config.executableOverride)) { return (yield* isExecutableFile(config.executableOverride.value)) @@ -307,9 +303,9 @@ export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.mapError( (cause) => - new CloudflaredInstallError({ + new RelayClientInstallError({ reason: "download_failed", - message: "Could not download cloudflared.", + message: "Could not download the relay client.", cause, }), ), @@ -318,9 +314,9 @@ export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* yield* response.arrayBuffer.pipe( Effect.mapError( (cause) => - new CloudflaredInstallError({ + new RelayClientInstallError({ reason: "download_failed", - message: "Could not read the downloaded cloudflared binary.", + message: "Could not read the downloaded relay client binary.", cause, }), ), @@ -329,17 +325,17 @@ export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* const checksum = yield* crypto.digest("SHA-256", bytes).pipe( Effect.mapError( (cause) => - new CloudflaredInstallError({ + new RelayClientInstallError({ reason: "validation_failed", - message: "Could not verify the downloaded cloudflared checksum.", + message: "Could not verify the downloaded relay client checksum.", cause, }), ), ); if (Encoding.encodeHex(checksum) !== asset.sha256) { - return yield* new CloudflaredInstallError({ + return yield* new RelayClientInstallError({ reason: "invalid_checksum", - message: "Downloaded cloudflared checksum did not match the pinned release.", + message: "Downloaded relay client checksum did not match the pinned release.", }); } return bytes; @@ -366,26 +362,26 @@ export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* } yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); } - return yield* new CloudflaredInstallError({ + return yield* new RelayClientInstallError({ reason: "install_locked", - message: "Another cloudflared installation is still in progress.", + message: "Another relay client installation is still in progress.", }); }); - const installUnlocked: CloudflaredExecutableShape["install"] = Effect.gen(function* () { + const installUnlocked: RelayClientShape["install"] = Effect.gen(function* () { const existing = yield* resolve; if (existing.status === "available") return existing; const config = yield* loadCloudflaredConfig; if (Option.isSome(config.executableOverride)) { - return yield* new CloudflaredInstallError({ + return yield* new RelayClientInstallError({ reason: "override_missing", message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, }); } if (!releaseAsset) { - return yield* new CloudflaredInstallError({ + return yield* new RelayClientInstallError({ reason: "unsupported_platform", - message: `T3 Code does not provide a managed cloudflared binary for ${platform}-${arch}.`, + message: `T3 Code does not provide a managed relay client binary for ${platform}-${arch}.`, }); } @@ -393,13 +389,15 @@ export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* const lockPath = `${managedPath}.lock`; yield* fileSystem .makeDirectory(managedDirectory, { recursive: true }) - .pipe(wrapInstallFailure("write_failed", "Could not create the cloudflared tool directory.")); + .pipe( + wrapInstallFailure("write_failed", "Could not create the relay client tool directory."), + ); yield* acquireInstallLock(lockPath).pipe( Effect.catchTag("PlatformError", (cause) => Effect.fail( - new CloudflaredInstallError({ + new RelayClientInstallError({ reason: "write_failed", - message: "Could not acquire the cloudflared installation lock.", + message: "Could not acquire the relay client installation lock.", cause, }), ), @@ -419,31 +417,31 @@ export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* ); yield* fileSystem .writeFile(archivePath, yield* downloadAsset(releaseAsset)) - .pipe(wrapInstallFailure("write_failed", "Could not write the cloudflared 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 cloudflared."), + wrapInstallFailure("write_failed", "Could not extract the relay client."), ); } if (platform !== "win32") { yield* fileSystem .chmod(executablePath, 0o755) - .pipe(wrapInstallFailure("write_failed", "Could not make cloudflared executable.")); + .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); } yield* runCommand(executablePath, ["--version"]).pipe( - wrapInstallFailure("validation_failed", "The downloaded cloudflared binary did not run."), + wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), ); const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; yield* fileSystem .rename(executablePath, stagedPath) - .pipe(wrapInstallFailure("write_failed", "Could not stage cloudflared.")); + .pipe(wrapInstallFailure("write_failed", "Could not stage the relay client.")); yield* fileSystem .rename(stagedPath, managedPath) .pipe( - wrapInstallFailure("write_failed", "Could not activate cloudflared."), + wrapInstallFailure("write_failed", "Could not activate the relay client."), Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), ); return { @@ -451,17 +449,17 @@ export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* executablePath: managedPath, source: "managed", version: CLOUDFLARED_VERSION, - } satisfies AvailableCloudflaredExecutable; + } satisfies AvailableRelayClient; }).pipe( Effect.scoped, Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), Effect.catch((cause) => - cause instanceof CloudflaredInstallError + cause instanceof RelayClientInstallError ? Effect.fail(cause) : Effect.fail( - new CloudflaredInstallError({ + new RelayClientInstallError({ reason: "write_failed", - message: "Could not install cloudflared.", + message: "Could not install the relay client.", cause, }), ), @@ -470,8 +468,8 @@ export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* }); const install = installSemaphore.withPermit(installUnlocked); - return CloudflaredExecutable.of({ resolve, install }); + return RelayClient.of({ resolve, install }); }); -export const layer = (options: CloudflaredExecutableOptions) => - Layer.effect(CloudflaredExecutable, makeCloudflaredExecutable(options)); +export const layerCloudflared = (options: CloudflaredRelayClientOptions) => + Layer.effect(RelayClient, makeCloudflaredRelayClient(options)); From 5bb5be63ac4773c133cee919a99d278e2d423d78 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 20:34:38 -0700 Subject: [PATCH 38/61] feat(cloud): stream relay client installation over rpc Co-authored-by: codex --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 - apps/desktop/src/ipc/channels.ts | 2 - .../src/ipc/methods/relayClient.test.ts | 43 ------- apps/desktop/src/ipc/methods/relayClient.ts | 27 ---- apps/desktop/src/main.ts | 18 +-- apps/desktop/src/preload.ts | 2 - .../src/cloud/ManagedEndpointRuntime.test.ts | 2 + apps/server/src/server.test.ts | 62 ++++++++++ apps/server/src/server.ts | 15 ++- apps/server/src/ws.ts | 40 ++++++ apps/web/src/cloud/linkEnvironment.test.ts | 115 ++++++++++++------ apps/web/src/cloud/linkEnvironment.ts | 57 +++++---- .../settings/SettingsPanels.browser.tsx | 12 -- .../service.threadSubscriptions.test.ts | 4 + apps/web/src/localApi.test.ts | 12 -- packages/client-runtime/src/wsRpcClient.ts | 28 +++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 22 ---- packages/contracts/src/relayClient.ts | 63 ++++++++++ packages/contracts/src/rpc.ts | 24 ++++ packages/shared/src/relayClient.test.ts | 18 ++- packages/shared/src/relayClient.ts | 35 +++++- 22 files changed, 391 insertions(+), 215 deletions(-) delete mode 100644 apps/desktop/src/ipc/methods/relayClient.test.ts delete mode 100644 apps/desktop/src/ipc/methods/relayClient.ts create mode 100644 packages/contracts/src/relayClient.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 98e34bba59d..40f84054878 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -9,7 +9,6 @@ import { setCloudAuthToken, } from "./methods/cloudAuth.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; -import { getRelayClientStatus, installRelayClient } from "./methods/relayClient.ts"; import { getSavedEnvironmentRegistry, getSavedEnvironmentSecret, @@ -88,9 +87,6 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(setCloudAuthToken); yield* ipc.handle(clearCloudAuthToken); yield* ipc.handle(fetchCloudAuth); - yield* ipc.handle(getRelayClientStatus); - yield* ipc.handle(installRelayClient); - 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 8332cc464c7..1ded238c663 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,8 +8,6 @@ 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 GET_RELAY_CLIENT_STATUS_CHANNEL = "desktop:get-relay-client-status"; -export const INSTALL_RELAY_CLIENT_CHANNEL = "desktop:install-relay-client"; 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"; diff --git a/apps/desktop/src/ipc/methods/relayClient.test.ts b/apps/desktop/src/ipc/methods/relayClient.test.ts deleted file mode 100644 index 73e0da4ed2b..00000000000 --- a/apps/desktop/src/ipc/methods/relayClient.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as RelayClient from "@t3tools/shared/relayClient"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { getRelayClientStatus, installRelayClient } from "./relayClient.ts"; - -const available = { - status: "available", - executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", - source: "managed", - version: RelayClient.CLOUDFLARED_VERSION, -} as const; - -describe("Desktop relay client IPC", () => { - it.effect("reads status and delegates installation to the shared manager", () => - Effect.gen(function* () { - const installed: Array = []; - const layer = Layer.succeed( - RelayClient.RelayClient, - RelayClient.RelayClient.of({ - resolve: Effect.succeed({ - status: "missing", - version: RelayClient.CLOUDFLARED_VERSION, - }), - install: Effect.sync(() => { - installed.push(true); - return available; - }), - }), - ); - - expect(yield* getRelayClientStatus.handler(undefined).pipe(Effect.provide(layer))).toEqual({ - status: "missing", - version: RelayClient.CLOUDFLARED_VERSION, - }); - expect(yield* installRelayClient.handler(undefined).pipe(Effect.provide(layer))).toEqual( - available, - ); - expect(installed).toEqual([true]); - }), - ); -}); diff --git a/apps/desktop/src/ipc/methods/relayClient.ts b/apps/desktop/src/ipc/methods/relayClient.ts deleted file mode 100644 index dafd20700f4..00000000000 --- a/apps/desktop/src/ipc/methods/relayClient.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { DesktopRelayClientStatusSchema } from "@t3tools/contracts"; -import * as RelayClient from "@t3tools/shared/relayClient"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; - -import * as IpcChannels from "../channels.ts"; -import { makeIpcMethod } from "../DesktopIpc.ts"; - -export const getRelayClientStatus = makeIpcMethod({ - channel: IpcChannels.GET_RELAY_CLIENT_STATUS_CHANNEL, - payload: Schema.Undefined, - result: DesktopRelayClientStatusSchema, - handler: Effect.fn("desktop.ipc.relayClient.getStatus")(function* () { - const relayClient = yield* RelayClient.RelayClient; - return yield* relayClient.resolve; - }), -}); - -export const installRelayClient = makeIpcMethod({ - channel: IpcChannels.INSTALL_RELAY_CLIENT_CHANNEL, - payload: Schema.Undefined, - result: DesktopRelayClientStatusSchema, - handler: Effect.fn("desktop.ipc.relayClient.install")(function* () { - const relayClient = yield* RelayClient.RelayClient; - return yield* relayClient.install; - }), -}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e8177355e91..9356eef441b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -9,7 +9,6 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; -import * as RelayClient from "@t3tools/shared/relayClient"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -95,17 +94,6 @@ const desktopSshEnvironmentLayer = Layer.unwrap( }), ); -const desktopRelayClientLayer = Layer.unwrap( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - return RelayClient.layerCloudflared({ - baseDir: environment.baseDir, - platform: environment.platform, - arch: environment.processArch, - }); - }), -); - const electronLayer = Layer.mergeAll( ElectronApp.layer, ElectronDialog.layer, @@ -153,11 +141,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopCloudAuth.layer, DesktopShellEnvironment.layer, desktopSshLayer, -).pipe( - Layer.provideMerge(DesktopUpdates.layer), - Layer.provideMerge(desktopRelayClientLayer), - Layer.provideMerge(desktopBackendLayer), -); +).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( Layer.flatMap(() => diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1dde53c27a7..84f7580cb07 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -102,8 +102,6 @@ contextBridge.exposeInMainWorld("desktopBridge", { 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), - getRelayClientStatus: () => ipcRenderer.invoke(IpcChannels.GET_RELAY_CLIENT_STATUS_CHANNEL), - installRelayClient: () => ipcRenderer.invoke(IpcChannels.INSTALL_RELAY_CLIENT_CHANNEL), onCloudAuthCallback: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => { if (typeof rawUrl !== "string") return; diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 03e6e2c0d29..10358c79a88 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -22,6 +22,7 @@ const relayClientAvailableLayer = Layer.succeed( version: RelayClient.CLOUDFLARED_VERSION, }), install: Effect.die("unused"), + installWithProgress: () => Effect.die("unused"), }), ); @@ -333,6 +334,7 @@ describe("CloudManagedEndpointRuntime", () => { version: RelayClient.CLOUDFLARED_VERSION, }), install: Effect.die("unused"), + installWithProgress: () => Effect.die("unused"), }), ), ), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c85ca112a19..29f103a50e0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -35,6 +35,7 @@ import { 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"; @@ -359,6 +360,7 @@ const buildAppUnderTest = (options?: { serverEnvironment?: Partial; repositoryIdentityResolver?: Partial; cloudManagedEndpointRuntime?: Partial; + relayClient?: Partial; }; }) => Effect.gen(function* () { @@ -762,6 +764,20 @@ const buildAppUnderTest = (options?: { }), ), ), + 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.provideMerge(makeAuthTestLayer()), Layer.provideMerge(ServerSecretStore.layer), Layer.provide(workspaceAndProjectServicesLayer), @@ -1950,6 +1966,52 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).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(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9249099c8f7..9dc2cd7d25e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -243,6 +243,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), @@ -284,12 +292,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge( - CloudManagedEndpointRuntime.layer.pipe( - Layer.provide(ServerSecretStore.layer), - Layer.provide(RelayClientLive), - ), - ), + Layer.provideMerge(CloudManagedEndpointRuntimeLive), ); const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( 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/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index fa04b06eb08..2cb80fac1a3 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -1,7 +1,6 @@ import { EnvironmentId } from "@t3tools/contracts"; import { RelayWebClientId } from "@t3tools/contracts/relay"; import { afterEach, beforeEach, vi } from "vitest"; -import type { DesktopBridge } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -16,7 +15,6 @@ import { import type { SavedEnvironmentRecord } from "../environments/runtime"; import { connectManagedCloudEnvironment, - ensureDesktopRelayClientAvailable, linkEnvironmentToCloud, linkPrimaryEnvironmentToCloud, listManagedCloudEnvironments, @@ -31,6 +29,17 @@ import { } from "../environments/primary"; const getSavedEnvironmentSecretMock = vi.fn(); +const confirmRelayClientInstallMock = 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 }) => @@ -61,6 +70,9 @@ const withCloudServices = ( vi.mock("../localApi", () => ({ ensureLocalApi: () => ({ + dialogs: { + confirm: confirmRelayClientInstallMock, + }, persistence: { getSavedEnvironmentSecret: getSavedEnvironmentSecretMock, }, @@ -73,6 +85,11 @@ vi.mock("../environments/primary", () => ({ 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", @@ -93,6 +110,15 @@ function validChallenge() { }; } +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 ?? ""); } @@ -107,9 +133,13 @@ describe("web cloud link environment client", () => { beforeEach(() => { vi.restoreAllMocks(); + vi.clearAllMocks(); createProofMock.mockClear(); vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer"); + confirmRelayClientInstallMock.mockResolvedValue(true); + getRelayClientStatusMock.mockResolvedValue(availableRelayClient()); + installRelayClientMock.mockResolvedValue(availableRelayClient()); vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue(null); vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue(null); vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( @@ -124,47 +154,51 @@ describe("web cloud link environment client", () => { expect(normalizeRelayBaseUrl(" ")).toBeNull(); }); - it("installs the relay client after desktop confirmation", async () => { - vi.stubGlobal("window", {}); - const confirm = vi.fn().mockResolvedValue(true); - const installRelayClient = vi.fn().mockResolvedValue({ - status: "available", - executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", - source: "managed", - version: "2026.5.2", - }); - window.desktopBridge = { - confirm, - getRelayClientStatus: vi.fn().mockResolvedValue({ - status: "missing", - version: "2026.5.2", - }), - installRelayClient, - } as unknown as DesktopBridge; - - await Effect.runPromise(ensureDesktopRelayClientAvailable()); + 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); - expect(confirm).toHaveBeenCalledOnce(); - expect(installRelayClient).toHaveBeenCalledOnce(); - }); + yield* withCloudServices( + linkPrimaryEnvironmentToCloud({ + clerkToken: "clerk-token", + }), + ).pipe(Effect.flip); - it("does not install the relay client when desktop confirmation is declined", async () => { - vi.stubGlobal("window", {}); - const installRelayClient = vi.fn(); - window.desktopBridge = { - confirm: vi.fn().mockResolvedValue(false), - getRelayClientStatus: vi.fn().mockResolvedValue({ - status: "missing", - version: "2026.5.2", + expect(confirmRelayClientInstallMock).toHaveBeenCalledOnce(); + expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); + expect(installRelayClientMock).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", + ); }), - installRelayClient, - } as unknown as DesktopBridge; - - await expect(Effect.runPromise(ensureDesktopRelayClientAvailable())).rejects.toMatchObject({ - message: "Relay client installation was cancelled.", - }); - expect(installRelayClient).not.toHaveBeenCalled(); - }); + ); it.effect("lists relay-managed environments for hosted and served web clients", () => Effect.gen(function* () { @@ -453,6 +487,7 @@ describe("web cloud link environment client", () => { }), ); + expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); expect(String(fetchMock.mock.calls[0]?.[0])).toBe( "https://relay.example.test/v1/client/environment-link-challenges", ); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 81548d34ffb..21fdf52f43c 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -27,10 +27,15 @@ import { makeEnvironmentHttpApiClient, ManagedRelayClient, ManagedRelayDpopSigner, + type WsRpcClient, } from "@t3tools/client-runtime"; import { ensureLocalApi } from "../localApi"; -import type { SavedEnvironmentRecord } from "../environments/runtime"; +import { + getPrimaryEnvironmentConnection, + readEnvironmentConnection, + type SavedEnvironmentRecord, +} from "../environments/runtime"; import { readPrimaryEnvironmentDescriptor, readPrimaryEnvironmentTarget, @@ -56,23 +61,22 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen readonly cause?: unknown; }> {} -const desktopRelayClientBridgeError = (cause: unknown) => +const relayClientRpcError = (message: string) => (cause: unknown) => new CloudEnvironmentLinkError({ - message: "Could not prepare the relay client.", + message, cause, }); -export function ensureDesktopRelayClientAvailable(): Effect.Effect< - void, - CloudEnvironmentLinkError -> { - const bridge = typeof window === "undefined" ? undefined : window.desktopBridge; - if (!bridge) return Effect.void; +const RELAY_CLIENT_INSTALL_PROMPT = + "T3 Code needs the relay client to make this environment available through T3 Cloud. Download and install it now?"; +function ensureRelayClientAvailable( + client: WsRpcClient, +): Effect.Effect { return Effect.gen(function* () { const status = yield* Effect.tryPromise({ - try: () => bridge.getRelayClientStatus(), - catch: desktopRelayClientBridgeError, + try: () => client.cloud.getRelayClientStatus(), + catch: relayClientRpcError("Could not check relay client availability."), }); if (status.status === "available") return; if (status.status === "unsupported") { @@ -82,11 +86,8 @@ export function ensureDesktopRelayClientAvailable(): Effect.Effect< } const confirmed = yield* Effect.tryPromise({ - try: () => - bridge.confirm( - "T3 Code needs the relay client to make this environment available through T3 Cloud. Download and install it now?", - ), - catch: desktopRelayClientBridgeError, + try: () => ensureLocalApi().dialogs.confirm(RELAY_CLIENT_INSTALL_PROMPT), + catch: relayClientRpcError("Could not confirm relay client installation."), }); if (!confirmed) { return yield* new CloudEnvironmentLinkError({ @@ -95,8 +96,8 @@ export function ensureDesktopRelayClientAvailable(): Effect.Effect< } const installed = yield* Effect.tryPromise({ - try: () => bridge.installRelayClient(), - catch: desktopRelayClientBridgeError, + try: () => client.cloud.installRelayClient(), + catch: relayClientRpcError("Could not install the relay client."), }); if (installed.status !== "available") { return yield* new CloudEnvironmentLinkError({ @@ -538,6 +539,17 @@ export function linkEnvironmentToCloud(input: { }); } + 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, @@ -554,10 +566,9 @@ export function linkEnvironmentToCloud(input: { ), ), ); - const environmentClient = yield* makeEnvironmentHttpApiClient(input.environment.httpBaseUrl); const proof = yield* environmentClient.cloud .linkProof({ - headers: { authorization: `Bearer ${bearerToken}` }, + headers, payload: { challenge: challenge.challenge, relayIssuer: configuredRelayUrl, @@ -593,7 +604,7 @@ export function linkEnvironmentToCloud(input: { yield* environmentClient.cloud .relayConfig({ - headers: { authorization: `Bearer ${bearerToken}` }, + headers, payload: { relayUrl: configuredRelayUrl, relayIssuer: link.relayIssuer, @@ -624,7 +635,8 @@ export function linkPrimaryEnvironmentToCloud(input: { message: "Local environment is not ready yet.", }); } - yield* ensureDesktopRelayClientAvailable(); + const environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl); + yield* ensureRelayClientAvailable(getPrimaryEnvironmentConnection().client); const challenge = yield* relayClient .createEnvironmentLinkChallenge({ @@ -642,7 +654,6 @@ export function linkPrimaryEnvironmentToCloud(input: { ), ), ); - const environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl); const proof = yield* environmentClient.cloud .linkProof({ headers: {}, diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index bed17319a72..e8f46505edc 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -470,18 +470,6 @@ const createDesktopBridgeStub = (overrides?: { headers: {}, body: "", }), - getRelayClientStatus: vi.fn().mockResolvedValue({ - status: "available", - executablePath: "/usr/local/bin/cloudflared", - source: "path", - version: "2026.5.2", - }), - installRelayClient: vi.fn().mockResolvedValue({ - status: "available", - executablePath: "/usr/local/bin/cloudflared", - source: "path", - version: "2026.5.2", - }), onCloudAuthCallback: () => () => {}, onMenuAction: () => () => {}, getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 33e2b0aa8c1..675a4868032 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -81,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(), diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 9b98055d5bf..e50dbd9f5f8 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -245,18 +245,6 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg headers: {}, body: "", }), - getRelayClientStatus: async () => ({ - status: "available", - executablePath: "/usr/local/bin/cloudflared", - source: "path", - version: "2026.5.2", - }), - installRelayClient: async () => ({ - status: "available", - executablePath: "/usr/local/bin/cloudflared", - source: "path", - version: "2026.5.2", - }), onCloudAuthCallback: () => () => undefined, onMenuAction: () => () => undefined, getUpdateState: async () => { 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/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 c7a4e6b5dca..56f929f7def 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -394,26 +394,6 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ }); export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; -export const DesktopRelayClientStatusSchema = 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 DesktopRelayClientStatus = typeof DesktopRelayClientStatusSchema.Type; - export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -464,8 +444,6 @@ export interface DesktopBridge { setCloudAuthToken: (token: string) => Promise; clearCloudAuthToken: () => Promise; fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise; - getRelayClientStatus: () => Promise; - installRelayClient: () => Promise; onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; 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/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 1acdbea754b..7e552194dae 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -117,7 +117,14 @@ describe("RelayClient", () => { configProvider: emptyConfigProvider, }); - const installed = yield* manager.install; + 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", @@ -132,6 +139,15 @@ describe("RelayClient", () => { 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, diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 7271f894ec6..a73ec183176 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -1,4 +1,8 @@ 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"; @@ -125,6 +129,9 @@ export interface CloudflaredRelayClientOptions { 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()( @@ -298,7 +305,9 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function 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( @@ -322,6 +331,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ), ), ); + yield* report("verifying"); const checksum = yield* crypto.digest("SHA-256", bytes).pipe( Effect.mapError( (cause) => @@ -368,7 +378,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function }); }); - const installUnlocked: RelayClientShape["install"] = Effect.gen(function* () { + 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; @@ -392,6 +405,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function .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( @@ -415,8 +429,10 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function tempDirectory, releaseAsset.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform), ); + const download = yield* downloadAsset(releaseAsset, report); + yield* report("installing"); yield* fileSystem - .writeFile(archivePath, yield* downloadAsset(releaseAsset)) + .writeFile(archivePath, download) .pipe(wrapInstallFailure("write_failed", "Could not write the relay client download.")); const executablePath = path.join(tempDirectory, executableFileName(platform)); @@ -430,11 +446,13 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function .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.")); @@ -466,9 +484,18 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ), ); }); - const install = installSemaphore.withPermit(installUnlocked); + const installWithProgress: RelayClientShape["installWithProgress"] = (report) => + installSemaphore.withPermit( + installUnlocked((stage) => + report({ + type: "progress", + stage, + }), + ), + ); + const install = installWithProgress(() => Effect.void); - return RelayClient.of({ resolve, install }); + return RelayClient.of({ resolve, install, installWithProgress }); }); export const layerCloudflared = (options: CloudflaredRelayClientOptions) => From 631f93a04ea66442149a92efaf3be87c5c89b919 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 20:45:42 -0700 Subject: [PATCH 39/61] feat(cloud): add relay client install dialog Co-authored-by: codex --- apps/web/src/cloud/linkEnvironment.test.ts | 30 +++- apps/web/src/cloud/linkEnvironment.ts | 14 +- .../cloud/relayClientInstallDialog.test.ts | 50 +++++++ .../web/src/cloud/relayClientInstallDialog.ts | 78 +++++++++++ .../RelayClientInstallDialog.browser.tsx | 46 +++++++ .../cloud/RelayClientInstallDialog.tsx | 129 ++++++++++++++++++ apps/web/src/routes/__root.tsx | 2 + 7 files changed, 337 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/cloud/relayClientInstallDialog.test.ts create mode 100644 apps/web/src/cloud/relayClientInstallDialog.ts create mode 100644 apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx create mode 100644 apps/web/src/components/cloud/RelayClientInstallDialog.tsx diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 2cb80fac1a3..a937151e52d 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -29,7 +29,11 @@ import { } from "../environments/primary"; const getSavedEnvironmentSecretMock = vi.fn(); -const confirmRelayClientInstallMock = 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 = { @@ -70,15 +74,18 @@ const withCloudServices = ( vi.mock("../localApi", () => ({ ensureLocalApi: () => ({ - dialogs: { - confirm: confirmRelayClientInstallMock, - }, 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), @@ -137,7 +144,7 @@ describe("web cloud link environment client", () => { createProofMock.mockClear(); vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer"); - confirmRelayClientInstallMock.mockResolvedValue(true); + relayClientInstallDialogHarness.requestConfirmation.mockResolvedValue(true); getRelayClientStatusMock.mockResolvedValue(availableRelayClient()); installRelayClientMock.mockResolvedValue(availableRelayClient()); vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue(null); @@ -181,6 +188,10 @@ describe("web cloud link environment client", () => { .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({ @@ -188,9 +199,16 @@ describe("web cloud link environment client", () => { }), ).pipe(Effect.flip); - expect(confirmRelayClientInstallMock).toHaveBeenCalledOnce(); + 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]!, ); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 21fdf52f43c..4d34c854a44 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -43,6 +43,11 @@ import { } 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(); @@ -67,9 +72,6 @@ const relayClientRpcError = (message: string) => (cause: unknown) => cause, }); -const RELAY_CLIENT_INSTALL_PROMPT = - "T3 Code needs the relay client to make this environment available through T3 Cloud. Download and install it now?"; - function ensureRelayClientAvailable( client: WsRpcClient, ): Effect.Effect { @@ -86,7 +88,7 @@ function ensureRelayClientAvailable( } const confirmed = yield* Effect.tryPromise({ - try: () => ensureLocalApi().dialogs.confirm(RELAY_CLIENT_INSTALL_PROMPT), + try: () => requestRelayClientInstallConfirmation(status.version), catch: relayClientRpcError("Could not confirm relay client installation."), }); if (!confirmed) { @@ -96,9 +98,9 @@ function ensureRelayClientAvailable( } const installed = yield* Effect.tryPromise({ - try: () => client.cloud.installRelayClient(), + 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: diff --git a/apps/web/src/cloud/relayClientInstallDialog.test.ts b/apps/web/src/cloud/relayClientInstallDialog.test.ts new file mode 100644 index 00000000000..b93a14e4158 --- /dev/null +++ b/apps/web/src/cloud/relayClientInstallDialog.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + 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: "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: "idle" }); + }); +}); diff --git a/apps/web/src/cloud/relayClientInstallDialog.ts b/apps/web/src/cloud/relayClientInstallDialog.ts new file mode 100644 index 00000000000..4c0b251b502 --- /dev/null +++ b/apps/web/src/cloud/relayClientInstallDialog.ts @@ -0,0 +1,78 @@ +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; + }; + +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" } : idleState, + ); + 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; + publish(idleState); +} + +export function resetRelayClientInstallDialogForTests(): void { + finishRelayClientInstall(); + listeners.clear(); +} 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..a454861fd54 --- /dev/null +++ b/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx @@ -0,0 +1,46 @@ +import "../../index.css"; + +import { page } from "vitest/browser"; +import { beforeEach, describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; + +import { + finishRelayClientInstall, + 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").first()) + .toHaveTextContent("Downloading relay client"); + await expect + .element(page.getByText("Downloading relay client").first()) + .toHaveAttribute("class", expect.stringContaining("font-medium")); + + finishRelayClientInstall(); + await expect + .element(page.getByRole("heading", { name: "Installing relay client" })) + .not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx new file mode 100644 index 00000000000..fabe857c915 --- /dev/null +++ b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx @@ -0,0 +1,129 @@ +import { CircleCheckIcon, CircleIcon, DownloadIcon } from "lucide-react"; +import { useSyncExternalStore } from "react"; +import type { RelayClientInstallProgressStage } from "@t3tools/contracts"; + +import { + readRelayClientInstallDialogState, + respondToRelayClientInstallConfirmation, + subscribeRelayClientInstallDialog, +} from "../../cloud/relayClientInstallDialog"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Spinner } from "../ui/spinner"; + +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 isConfirming = state.status === "confirming"; + const isInstalling = state.status === "installing"; + const activeStepIndex = isInstalling + ? installSteps.findIndex(({ stage }) => stage === state.stage) + : -1; + + return ( + { + if (!open && isConfirming) { + respondToRelayClientInstallConfirmation(false); + } + }} + > + + +
+ +
+ + {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 ? ( +
    + {installSteps.map((step, index) => { + const isComplete = activeStepIndex > index; + const isActive = activeStepIndex === index; + return ( +
  1. + {isComplete ? ( + + ) : isActive ? ( + + ) : ( + + )} + + {step.label} + +
  2. + ); + })} +
  3. + {activeStepIndex >= 0 + ? installSteps[activeStepIndex]?.label + : "Starting installation"} +
  4. +
+ ) : ( +
+

Managed relay client

+

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

+
+ )} +
+ {isConfirming ? ( + + + + + ) : null} +
+
+ ); +} 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} From 5af1a917df75b7218fb1b581db630b5e4cfc5ee1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 20:51:30 -0700 Subject: [PATCH 40/61] refactor(cloud): simplify relay install progress Co-authored-by: codex --- .../cloud/relayClientInstallDialog.test.ts | 20 ++++++ .../web/src/cloud/relayClientInstallDialog.ts | 28 +++++++- .../RelayClientInstallDialog.browser.tsx | 11 +-- .../cloud/RelayClientInstallDialog.tsx | 72 +++++++++---------- 4 files changed, 84 insertions(+), 47 deletions(-) diff --git a/apps/web/src/cloud/relayClientInstallDialog.test.ts b/apps/web/src/cloud/relayClientInstallDialog.test.ts index b93a14e4158..85f3cbe57b6 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.test.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + completeRelayClientInstallDialogClose, finishRelayClientInstall, readRelayClientInstallDialogState, reportRelayClientInstallProgress, @@ -37,6 +38,16 @@ describe("relay client install dialog coordinator", () => { }); finishRelayClientInstall(); + expect(readRelayClientInstallDialogState()).toEqual({ + status: "closing", + view: { + status: "installing", + version: "2026.5.2", + stage: "downloading", + }, + }); + + completeRelayClientInstallDialogClose(); expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); }); @@ -45,6 +56,15 @@ describe("relay client install dialog coordinator", () => { 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 index 4c0b251b502..908890ad1f5 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.ts @@ -10,6 +10,16 @@ export type RelayClientInstallDialogState = 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" }; @@ -54,7 +64,9 @@ export function respondToRelayClientInstallConfirmation(confirmed: boolean): voi const resolve = resolveConfirmation; resolveConfirmation = null; publish( - confirmed ? { status: "installing", version: state.version, stage: "checking" } : idleState, + confirmed + ? { status: "installing", version: state.version, stage: "checking" } + : { status: "closing", view: state }, ); resolve(confirmed); } @@ -69,10 +81,20 @@ export function reportRelayClientInstallProgress(event: RelayClientInstallProgre export function finishRelayClientInstall(): void { resolveConfirmation?.(false); resolveConfirmation = null; - publish(idleState); + 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 { - finishRelayClientInstall(); + resolveConfirmation?.(false); + resolveConfirmation = null; + publish(idleState); listeners.clear(); } diff --git a/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx b/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx index a454861fd54..d58b561841f 100644 --- a/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx +++ b/apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx @@ -6,6 +6,7 @@ import { render } from "vitest-browser-react"; import { finishRelayClientInstall, + readRelayClientInstallDialogState, reportRelayClientInstallProgress, requestRelayClientInstallConfirmation, resetRelayClientInstallDialogForTests, @@ -31,16 +32,16 @@ describe("RelayClientInstallDialog", () => { .toBeInTheDocument(); reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); + await expect.element(page.getByText("Downloading relay client")).toBeInTheDocument(); await expect - .element(page.getByText("Downloading relay client").first()) - .toHaveTextContent("Downloading relay client"); - await expect - .element(page.getByText("Downloading relay client").first()) - .toHaveAttribute("class", expect.stringContaining("font-medium")); + .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 index fabe857c915..1ccdf4b10c9 100644 --- a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx +++ b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx @@ -1,8 +1,9 @@ -import { CircleCheckIcon, CircleIcon, DownloadIcon } from "lucide-react"; +import { DownloadIcon } from "lucide-react"; import { useSyncExternalStore } from "react"; import type { RelayClientInstallProgressStage } from "@t3tools/contracts"; import { + completeRelayClientInstallDialogClose, readRelayClientInstallDialogState, respondToRelayClientInstallConfirmation, subscribeRelayClientInstallDialog, @@ -17,8 +18,6 @@ import { DialogPopup, DialogTitle, } from "../ui/dialog"; -import { Spinner } from "../ui/spinner"; - const installSteps: ReadonlyArray<{ readonly stage: RelayClientInstallProgressStage; readonly label: string; @@ -38,20 +37,27 @@ export function RelayClientInstallDialog() { readRelayClientInstallDialogState, readRelayClientInstallDialogState, ); - const isConfirming = state.status === "confirming"; - const isInstalling = state.status === "installing"; + 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 === state.stage) + ? installSteps.findIndex(({ stage }) => stage === view.stage) : -1; + const activeStep = installSteps[activeStepIndex]; return ( { if (!open && isConfirming) { respondToRelayClientInstallConfirmation(false); } }} + onOpenChangeComplete={(open) => { + if (!open) { + completeRelayClientInstallDialogClose(); + } + }} > @@ -69,43 +75,31 @@ export function RelayClientInstallDialog() { {isInstalling ? ( -
    - {installSteps.map((step, index) => { - const isComplete = activeStepIndex > index; - const isActive = activeStepIndex === index; - return ( -
  1. - {isComplete ? ( - - ) : isActive ? ( - - ) : ( - - )} - - {step.label} - -
  2. - ); - })} -
  3. - {activeStepIndex >= 0 - ? installSteps[activeStepIndex]?.label - : "Starting installation"} -
  4. -
+
+
+

+ {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{" "} - {state.status === "confirming" ? state.version : ""} locally. + {view.status === "confirming" ? view.version : ""} locally.

)} From 0fb87aad6abe2794068718e8b42468ecf01555f7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 21:00:47 -0700 Subject: [PATCH 41/61] fix(relay): reconcile managed endpoint provisioning Persist per-user environment allocations and checkpoint Cloudflare resources so retries converge partial provisioning state. Co-authored-by: codex --- .../migration.sql | 15 + .../snapshot.json | 1453 +++++++++++++++++ infra/relay/src/deploymentConfig.test.ts | 4 +- infra/relay/src/deploymentConfig.ts | 8 +- .../src/environments/EnvironmentLinker.ts | 1 + .../ManagedEndpointAllocations.ts | 158 ++ .../ManagedEndpointProvider.test.ts | 283 +++- .../environments/ManagedEndpointProvider.ts | 133 +- infra/relay/src/persistence/schema.ts | 20 + infra/relay/src/worker.ts | 8 +- 10 files changed, 2033 insertions(+), 50 deletions(-) create mode 100644 infra/relay/migrations/postgres/20260603035812_managed_endpoint_allocations/migration.sql create mode 100644 infra/relay/migrations/postgres/20260603035812_managed_endpoint_allocations/snapshot.json create mode 100644 infra/relay/src/environments/ManagedEndpointAllocations.ts 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/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index 0ae4f6054a8..c7dc7b3506d 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -50,7 +50,9 @@ describe("managed endpoint names", () => { it("uses the stage slug and a stable stage-scoped digest suffix", () => { const hash = "ABCDEF0123456789ABCDEF0123456789"; - expect(managedEndpointDigestInput("dev_julius", "env_123")).toBe("dev_julius:env_123"); + 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", ); diff --git a/infra/relay/src/deploymentConfig.ts b/infra/relay/src/deploymentConfig.ts index 2ff61f45194..413201f277a 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -51,8 +51,12 @@ export function relayPublicDomainForStage(stage: string, zoneName: string): stri return `${relayLabel}.${normalizeZoneName(zoneName)}`; } -export function managedEndpointDigestInput(stage: string, environmentId: string): string { - return `${stage}:${environmentId}`; +export function managedEndpointDigestInput( + stage: string, + userId: string, + environmentId: string, +): string { + return `${stage}:${userId}:${environmentId}`; } export function managedEndpointHostname(stage: string, baseDomain: string, hash: string): string { diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts index 59c83477dce..853ea41cbda 100644 --- a/infra/relay/src/environments/EnvironmentLinker.ts +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -234,6 +234,7 @@ const make = Effect.gen(function* () { } const provisioned = input.request.managedTunnelsEnabled ? yield* managedEndpointProvider.provision({ + userId: input.userId, environmentId: verified.environmentId, origin: verified.origin, }) diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts new file mode 100644 index 00000000000..13521c6b152 --- /dev/null +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -0,0 +1,158 @@ +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 { 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 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 reserve: ( + input: ReserveManagedEndpointAllocationInput, + ) => Effect.Effect; + readonly recordTunnel: ( + input: RecordManagedEndpointTunnelInput, + ) => Effect.Effect; + readonly recordDns: ( + input: RecordManagedEndpointDnsInput, + ) => Effect.Effect; + readonly markReady: ( + 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({ + 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)), + }); +}); + +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 index 8466c9b9196..b6a3f2f3fea 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -7,6 +7,7 @@ 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({ @@ -34,10 +35,19 @@ interface TunnelCall { } interface DnsCall { - readonly operation: "listCnameRecords" | "createCnameRecord" | "updateCnameRecord"; + readonly operation: "listRecords" | "createRecord" | "updateRecord" | "deleteRecord"; readonly input: unknown; } +interface AllocationCall { + readonly operation: "reserve" | "recordTunnel" | "recordDns" | "markReady"; + 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) => @@ -62,47 +72,134 @@ function makeTunnelClient(calls: TunnelCall[] = []) { }); } +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"; + }), + }); +} + function makeDnsClient( calls: DnsCall[] = [], records: ReadonlyArray<{ readonly id: string }> = [], ) { + let currentRecords = [...records]; return ManagedEndpointProvider.ManagedEndpointDnsClient.of({ - listCnameRecords: (hostname) => + 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.sync(() => { + calls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); + }), + 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({ + reserve: (input) => Effect.sync(() => { - calls.push({ operation: "listCnameRecords", input: hostname }); - return records; + 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; }), - createCnameRecord: (request) => + recordTunnel: (input) => Effect.sync(() => { - calls.push({ operation: "createCnameRecord", input: request }); + calls.push({ operation: "recordTunnel", input }); + const allocation = allocations.get(allocationKey(input)); + if (allocation !== undefined) { + allocations.set(allocationKey(input), { ...allocation, tunnelId: input.tunnelId }); + } }), - updateCnameRecord: (dnsRecordId, request) => + recordDns: (input) => Effect.sync(() => { - calls.push({ operation: "updateCnameRecord", input: { dnsRecordId, request } }); + 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", + }); + } }), }); } -function providerLayer(tunnelClient = makeTunnelClient(), dnsClient = makeDnsClient()) { +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): string { +function expectedManagedHostname(environmentId: string, userId = "user_ABC"): string { const hash = NodeCrypto.createHash("sha256") - .update(`dev_julius:${environmentId}`) + .update(`dev_julius:${userId}:${environmentId}`) .digest("hex") .slice(0, 16); return `tunnels-dev-julius-${hash}.t3code.test`; } -function expectedManagedTunnelName(environmentId: string): string { +function expectedManagedTunnelName(environmentId: string, userId = "user_ABC"): string { const hash = NodeCrypto.createHash("sha256") - .update(`dev_julius:${environmentId}`) + .update(`dev_julius:${userId}:${environmentId}`) .digest("hex") .slice(0, 16); return `t3coderelay-managedendpoint-dev-julius-${hash}`; @@ -112,11 +209,13 @@ 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 }, }); @@ -135,9 +234,9 @@ describe("ManagedEndpointProvider", () => { }, }); expect(dnsCalls).toEqual([ - { operation: "listCnameRecords", input: hostname }, + { operation: "listRecords", input: hostname }, { - operation: "createCnameRecord", + operation: "createRecord", input: { type: "CNAME", name: hostname, @@ -168,7 +267,21 @@ describe("ManagedEndpointProvider", () => { name: expectedManagedTunnelName("env_ABC"), isDeleted: false, }); - }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls), makeDnsClient(dnsCalls)))); + 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", () => { @@ -178,6 +291,7 @@ describe("ManagedEndpointProvider", () => { 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 }, }); @@ -224,6 +338,7 @@ describe("ManagedEndpointProvider", () => { return Effect.gen(function* () { const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; yield* provider.provision({ + userId: "user_ABC", environmentId: "env-ipv6", origin: { localHttpHost: "::1", localHttpPort: 3773 }, }); @@ -250,6 +365,7 @@ describe("ManagedEndpointProvider", () => { 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 }, }), @@ -270,6 +386,7 @@ describe("ManagedEndpointProvider", () => { 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 }, }), @@ -283,19 +400,17 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls)))); }); - it.effect("updates an existing CNAME record through the DNS client", () => { + 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([ - "listCnameRecords", - "updateCnameRecord", - ]); + expect(dnsCalls.map((call) => call.operation)).toEqual(["listRecords", "updateRecord"]); expect(dnsCalls[1]?.input).toMatchObject({ dnsRecordId: "existing-record-id" }); }).pipe( Effect.provide( @@ -304,20 +419,140 @@ describe("ManagedEndpointProvider", () => { ); }); + 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", + "listRecords", + "updateRecord", + ]); + expect(allocationCalls.map((call) => call.operation)).toEqual([ + "reserve", + "recordTunnel", + "recordDns", + "markReady", + "reserve", + "recordTunnel", + "recordDns", + "markReady", + ]); + }).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({ - listCnameRecords: () => Effect.fail(failure), - createCnameRecord: () => Effect.die("unused"), - updateCnameRecord: () => Effect.die("unused"), + 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 }, }), diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 78ce919dc98..2106d89f47b 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -22,6 +22,7 @@ import { managedEndpointHostname, managedEndpointTunnelName, } from "../deploymentConfig.ts"; +import { ManagedEndpointAllocations } from "./ManagedEndpointAllocations.ts"; export class ManagedEndpointProvisioningNotConfigured extends Data.TaggedError( "ManagedEndpointProvisioningNotConfigured", @@ -52,6 +53,7 @@ export interface ManagedEndpointProvisioningResult { export interface ManagedEndpointProviderShape { readonly provision: (input: { + readonly userId: string; readonly environmentId: string; readonly origin: RelayManagedEndpointOrigin; }) => Effect.Effect; @@ -117,16 +119,19 @@ export class ManagedEndpointDnsClientError extends Data.TaggedError( }> {} export interface ManagedEndpointDnsClientShape { - readonly listCnameRecords: ( + readonly listRecords: ( hostname: string, ) => Effect.Effect, ManagedEndpointDnsClientError>; - readonly createCnameRecord: ( + readonly createRecord: ( request: ManagedEndpointCnameRecordInput, - ) => Effect.Effect; - readonly updateCnameRecord: ( + ) => 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< @@ -175,10 +180,61 @@ const make = Effect.gen(function* () { 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, + ) { + 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) => + dns.listRecords(hostname).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({ 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, @@ -193,14 +249,23 @@ const make = Effect.gen(function* () { const environmentHash = yield* crypto .digest( "SHA-256", - new TextEncoder().encode(managedEndpointDigestInput(cf.namespace, input.environmentId)), + new TextEncoder().encode( + managedEndpointDigestInput(cf.namespace, input.userId, input.environmentId), + ), ) .pipe( Effect.map(Encoding.encodeHex), Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), ); - const hostname = managedEndpointHostname(cf.namespace, cf.baseDomain, environmentHash); - const tunnelName = managedEndpointTunnelName(cf.namespace, environmentHash); + 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), @@ -218,6 +283,13 @@ const make = Effect.gen(function* () { ), 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, { @@ -231,12 +303,6 @@ const make = Effect.gen(function* () { }) .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); - const existingDnsRecordId = yield* dns.listCnameRecords(hostname).pipe( - Effect.map(Arr.head), - Effect.map(Option.map((record) => record.id)), - Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), - ); - const dnsRecord = { type: "CNAME", name: hostname, @@ -245,14 +311,26 @@ const make = Effect.gen(function* () { proxied: true, } as const; - yield* Option.match(existingDnsRecordId, { - onSome: (id) => dns.updateCnameRecord(id, dnsRecord), - onNone: () => dns.createCnameRecord(dnsRecord), - }).pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); + 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: { @@ -309,22 +387,33 @@ export const layerCloudflareBindings = ( Layer.succeed( ManagedEndpointDnsClient, ManagedEndpointDnsClient.of({ - listCnameRecords: (hostname) => - dnsClient.listDnsRecords({ type: "CNAME", name: { exact: hostname } }).pipe( - Effect.map((response) => response.result), + listRecords: (hostname) => + dnsClient.listDnsRecords({ name: { exact: hostname } }).pipe( + Effect.map((response) => + response.result.filter( + (record): record is typeof record & { readonly id: string } => + typeof record.id === "string", + ), + ), Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), - createCnameRecord: (request) => + createRecord: (request) => dnsClient.createDnsRecord(request).pipe( + Effect.map((response) => ({ id: response.id })), Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), ), - updateCnameRecord: (dnsRecordId, request) => + 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/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index cc77895bdca..ab3d2dfd97a 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -83,6 +83,26 @@ export const relayEnvironmentLinks = pgTable( ], ); +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", { diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 9e2ac934c35..43aa0ab4578 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -38,6 +38,7 @@ 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"; @@ -197,7 +198,12 @@ export default class Api extends Cloudflare.Worker()( Layer.provideMerge(AgentActivityRows.layer), Layer.provideMerge(Devices.layer), Layer.provideMerge(EnvironmentCredentials.layer), - Layer.provideMerge(EnvironmentLinks.layer), + Layer.provideMerge( + Layer.mergeAll( + EnvironmentLinks.layer, + ManagedEndpointAllocations.ManagedEndpointAllocations.layer, + ), + ), Layer.provideMerge(LiveActivities.layer), Layer.provideMerge(DeliveryAttempts.layer), Layer.provideMerge(RelayTokens.layer), From 861f73346e5654b2e5d60f6f80a3522e045f40c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 21:39:34 -0700 Subject: [PATCH 42/61] fix(relay): clean up managed endpoints on unlink Co-authored-by: codex --- apps/web/src/cloud/linkEnvironment.test.ts | 28 +-- apps/web/src/cloud/linkEnvironment.ts | 18 +- .../environments/EnvironmentLinker.test.ts | 1 + .../ManagedEndpointAllocations.ts | 24 +++ .../ManagedEndpointProvider.test.ts | 203 +++++++++++++++++- .../environments/ManagedEndpointProvider.ts | 86 +++++++- infra/relay/src/http/Api.ts | 8 + 7 files changed, 338 insertions(+), 30 deletions(-) diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index a937151e52d..6b7143b1864 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -624,7 +624,7 @@ describe("web cloud link environment client", () => { }), ); - it.effect("revokes the primary cloud link before clearing local relay credentials", () => + it.effect("clears local relay credentials before revoking the primary cloud link", () => Effect.gen(function* () { vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ environmentId: EnvironmentId.make("env-1"), @@ -642,10 +642,10 @@ describe("web cloud link environment client", () => { }); const fetchMock = vi .fn() - .mockResolvedValueOnce(Response.json({ ok: true })) .mockResolvedValueOnce( Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ); + ) + .mockResolvedValueOnce(Response.json({ ok: true })); vi.stubGlobal("fetch", fetchMock); yield* withCloudServices( @@ -654,16 +654,16 @@ describe("web cloud link environment client", () => { }), ); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-links/env-1", - ); - expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("DELETE"); - expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - expect(String(fetchMock.mock.calls[1]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink"); - expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + 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"); }), ); @@ -685,10 +685,10 @@ describe("web cloud link environment client", () => { }); const fetchMock = vi .fn() - .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })) .mockResolvedValueOnce( Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ); + ) + .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })); vi.stubGlobal("fetch", fetchMock); yield* withCloudServices( @@ -698,8 +698,8 @@ describe("web cloud link environment client", () => { ); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(String(fetchMock.mock.calls[1]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink"); - expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + 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", }); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 4d34c854a44..f68849e2765 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -487,6 +487,14 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { 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; @@ -497,20 +505,12 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { }) .pipe( Effect.catch((cause) => - Effect.logWarning("Could not revoke cloud environment link before local unlink.", { + Effect.logWarning("Could not revoke cloud environment link after local unlink.", { cause, }), ), ); } - - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); - yield* client.cloud - .unlink({ headers: {} }) - .pipe( - withPrimaryEnvironmentRequestInit, - Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")), - ); }); } diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index cbf2b89fddd..dce364bffac 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -133,6 +133,7 @@ function testLayer(input?: { revokeForEnvironmentPublicKey: () => Effect.succeed(false), }), Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { + deprovision: () => Effect.void, provision: () => Effect.succeed({ endpoint: { diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index 13521c6b152..c813b7c9874 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -43,6 +43,9 @@ interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey { } export interface ManagedEndpointAllocationsShape { + readonly get: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; readonly reserve: ( input: ReserveManagedEndpointAllocationInput, ) => Effect.Effect; @@ -55,6 +58,9 @@ export interface ManagedEndpointAllocationsShape { readonly markReady: ( input: ManagedEndpointAllocationKey, ) => Effect.Effect; + readonly remove: ( + input: ManagedEndpointAllocationKey, + ) => Effect.Effect; } const allocationSelection = { @@ -82,6 +88,19 @@ 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, ) { @@ -147,6 +166,11 @@ const make = Effect.gen(function* () { }) .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)), }); }); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index b6a3f2f3fea..5d82711745c 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -30,7 +30,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ }); interface TunnelCall { - readonly operation: "list" | "create" | "putConfiguration" | "getToken"; + readonly operation: "list" | "create" | "putConfiguration" | "getToken" | "delete"; readonly input: unknown; } @@ -40,7 +40,7 @@ interface DnsCall { } interface AllocationCall { - readonly operation: "reserve" | "recordTunnel" | "recordDns" | "markReady"; + readonly operation: "get" | "reserve" | "recordTunnel" | "recordDns" | "markReady" | "remove"; readonly input: unknown; } @@ -69,6 +69,10 @@ function makeTunnelClient(calls: TunnelCall[] = []) { calls.push({ operation: "getToken", input: tunnelId }); return "connector-token"; }), + delete: (tunnelId) => + Effect.sync(() => { + calls.push({ operation: "delete", input: tunnelId }); + }), }); } @@ -95,6 +99,11 @@ function makePersistentTunnelClient(calls: TunnelCall[] = []) { calls.push({ operation: "getToken", input: tunnelId }); return "connector-token"; }), + delete: (tunnelId) => + Effect.sync(() => { + calls.push({ operation: "delete", input: tunnelId }); + tunnel = null; + }), }); } @@ -117,8 +126,13 @@ function makeDnsClient( return record; }), updateRecord: (dnsRecordId, request) => - Effect.sync(() => { + 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(() => { @@ -131,6 +145,11 @@ function makeDnsClient( 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 }); @@ -170,6 +189,11 @@ function makeAllocations(calls: AllocationCall[] = []) { }); } }), + remove: (input) => + Effect.sync(() => { + calls.push({ operation: "remove", input }); + allocations.delete(allocationKey(input)); + }), }); } @@ -451,7 +475,6 @@ describe("ManagedEndpointProvider", () => { expect(dnsCalls.map((call) => call.operation)).toEqual([ "listRecords", "createRecord", - "listRecords", "updateRecord", ]); expect(allocationCalls.map((call) => call.operation)).toEqual([ @@ -467,6 +490,178 @@ describe("ManagedEndpointProvider", () => { }).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[] = []; diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 2106d89f47b..9804ba0a1ed 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -34,6 +34,12 @@ export class ManagedEndpointProvisioningFailed extends Data.TaggedError( readonly cause: unknown; }> {} +export class ManagedEndpointDeprovisioningFailed extends Data.TaggedError( + "ManagedEndpointDeprovisioningFailed", +)<{ + readonly cause: unknown; +}> {} + export class ManagedEndpointOriginNotAllowed extends Data.TaggedError( "ManagedEndpointOriginNotAllowed", )<{ @@ -57,6 +63,10 @@ export interface ManagedEndpointProviderShape { 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< @@ -97,6 +107,7 @@ export interface ManagedEndpointTunnelClientShape { }, ) => Effect.Effect; readonly getToken: (tunnelId: string) => Effect.Effect; + readonly delete: (tunnelId: string) => Effect.Effect; } export class ManagedEndpointTunnelClient extends Context.Service< @@ -162,6 +173,7 @@ function normalizeHostname(hostname: string): string { return hostname .trim() .toLowerCase() + .replace(/\.$/u, "") .replace(/^\[(.*)\]$/u, "$1"); } @@ -175,6 +187,25 @@ function isLoopbackOrigin(origin: RelayManagedEndpointOrigin): boolean { ); } +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; @@ -205,6 +236,17 @@ const make = Effect.gen(function* () { 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, @@ -217,7 +259,14 @@ const make = Effect.gen(function* () { return yield* dns.createRecord(dnsRecord).pipe( Effect.map((record) => record.id), Effect.catch((createError) => - dns.listRecords(hostname).pipe( + 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) @@ -232,6 +281,31 @@ const make = Effect.gen(function* () { }); 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, @@ -382,17 +456,23 @@ export const layerCloudflareBindings = ( 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({ name: { exact: hostname } }).pipe( + dnsClient.listDnsRecords({ search: hostname }).pipe( Effect.map((response) => response.result.filter( (record): record is typeof record & { readonly id: string } => - typeof record.id === "string", + typeof record.id === "string" && + normalizeHostname(record.name) === normalizeHostname(hostname), ), ), Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index fada5a307c1..cbb9f4e92ec 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -60,6 +60,7 @@ 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 EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; import { withSpanAttributes } from "../observability.ts"; @@ -410,6 +411,7 @@ export const clientApi = HttpApiBuilder.group( 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 @@ -526,6 +528,12 @@ export const clientApi = HttpApiBuilder.group( 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, From 92673bce0f2789eb51be5e47f6758565b875fed8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 21:52:10 -0700 Subject: [PATCH 43/61] refactor(cloud): use relay client runtime wording Co-authored-by: codex --- apps/server/src/cloud/ManagedEndpointRuntime.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 8e7cb7f2980..0dbf4f9259d 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -81,7 +81,7 @@ const stopConnector = (connector: ActiveConnector | null) => connector ? Scope.close(connector.scope, Exit.void).pipe( Effect.tap(() => - Effect.logInfo("Cloudflare managed endpoint connector stopped", { + Effect.logInfo("Relay client stopped", { pid: Number(connector.child.pid), }), ), @@ -126,7 +126,7 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { return; } - yield* Effect.logWarning("Cloudflare managed endpoint connector exited; restarting", { + yield* Effect.logWarning("Relay client exited; restarting", { pid: Number(connector.child.pid), ...(Result.isSuccess(result) ? { exitCode: Number(result.success) } @@ -138,9 +138,7 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { }), ); }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Cloudflare managed endpoint connector supervisor failed", { cause }), - ), + Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), ); reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { @@ -177,7 +175,7 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { providerKind: "cloudflare_tunnel", reason: executable.status === "unsupported" - ? `Managed relay client is unsupported on ${executable.platform}-${executable.arch}.` + ? `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 } : {}), @@ -201,13 +199,13 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { .pipe( Effect.provideService(Scope.Scope, connectorScope), Effect.tap(() => - Effect.logInfo("Cloudflare managed endpoint connector started", { + Effect.logInfo("Relay client started", { tunnelId: config.tunnelId, tunnelName: config.tunnelName, }), ), Effect.catch((cause) => - Effect.logWarning("Failed to start Cloudflare managed endpoint connector", { + Effect.logWarning("Failed to start relay client", { cause, tunnelId: config.tunnelId, tunnelName: config.tunnelName, @@ -249,7 +247,7 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { return { status: "failed", providerKind: "cloudflare_tunnel", - reason: "Cloudflare connector did not start.", + reason: "Relay client did not start.", ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), } satisfies CloudManagedEndpointRuntimeStatus; From 0644077a52c13ac62733983e2750d854a6d35036 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 21:54:55 -0700 Subject: [PATCH 44/61] refactor(cloud): clarify agent activity publishing logs Co-authored-by: codex --- apps/server/src/relay/AgentAwarenessRelay.ts | 34 ++++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index be490c016e5..aef8870c12b 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -277,14 +277,14 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); if (!publishAgentActivity) { - yield* Effect.logDebug("agent awareness relay publish skipped; publication disabled", { + 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 awareness relay publish skipped; relay config missing", { + yield* Effect.logDebug("agent activity publish skipped; T3 Cloud config missing", { threadId, }); return; @@ -307,7 +307,7 @@ const make = Effect.gen(function* () { jti: yield* crypto.randomUUIDv4, }); - yield* Effect.logInfo("agent awareness relay publishing thread", { + yield* Effect.logInfo("publishing agent activity for thread", { environmentId, threadId, projectId: input.projectId, @@ -327,7 +327,7 @@ const make = Effect.gen(function* () { }, }); - yield* Effect.logInfo("agent awareness relay publish completed", { + yield* Effect.logInfo("agent activity publish completed", { environmentId, threadId, ok: response.ok, @@ -348,7 +348,7 @@ const make = Effect.gen(function* () { const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); if (publishedStateByThread.get(threadId) === publishIdentity) { - yield* Effect.logDebug("agent awareness relay publish skipped; projected state unchanged", { + yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { environmentId, threadId, reason: snapshot.reason, @@ -357,12 +357,12 @@ const make = Effect.gen(function* () { } if (snapshot.reason === "thread-not-found") { - yield* Effect.logDebug("agent awareness relay publishing tombstone; 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("agent awareness relay publishing tombstone; project not found", { + yield* Effect.logDebug("publishing agent activity tombstone; project not found", { environmentId, threadId, projectId: snapshot.projectId, @@ -384,7 +384,7 @@ const make = Effect.gen(function* () { const publishThread: AgentAwarenessRelayShape["publishThread"] = (threadId) => publishThreadUnsafe(threadId).pipe( Effect.catchCause((cause) => { - return Effect.logWarning("agent awareness relay publish failed", { + return Effect.logWarning("agent activity publish failed", { threadId, cause: Cause.pretty(cause), }); @@ -397,12 +397,12 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => false), ); if (!publishAgentActivity) { - yield* Effect.logDebug("agent awareness relay active snapshot skipped; publication disabled"); + 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 awareness relay active snapshot skipped; relay config missing"); + yield* Effect.logDebug("agent activity snapshot skipped; T3 Cloud config missing"); return false; } const environmentId = yield* serverEnvironment.getEnvironmentId; @@ -413,10 +413,10 @@ const make = Effect.gen(function* () { threads: snapshot.threads, }); if (activeThreadIds.length === 0) { - yield* Effect.logDebug("agent awareness relay active snapshot has no publishable threads"); + yield* Effect.logDebug("agent activity snapshot has no publishable threads"); return true; } - yield* Effect.logInfo("agent awareness relay publishing active snapshot", { + yield* Effect.logInfo("publishing active agent activity snapshot", { count: activeThreadIds.length, }); yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true }); @@ -440,9 +440,9 @@ const make = Effect.gen(function* () { function* () { const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { - yield* Effect.logInfo("agent awareness relay standby; relay config missing"); + yield* Effect.logInfo("agent activity publishing standby; T3 Cloud config missing"); } else { - yield* Effect.logInfo("agent awareness relay enabled", { + yield* Effect.logInfo("agent activity publishing enabled", { relayUrl: relayConfig.url, }); } @@ -453,20 +453,20 @@ const make = Effect.gen(function* () { Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { const threadId = eventThreadId(event); if (threadId === null) { - return Effect.logDebug("agent awareness relay ignored event without thread id", { + return Effect.logDebug("agent activity publishing ignored event without thread id", { eventType: event.type, }); } if (!shouldPublishAgentAwarenessEvent(event)) { return Effect.logDebug( - "agent awareness relay ignored event without awareness changes", + "agent activity publishing ignored event without activity changes", { eventType: event.type, threadId, }, ); } - return Effect.logDebug("agent awareness relay queued thread publish", { + return Effect.logDebug("agent activity publishing queued thread publish", { eventType: event.type, threadId, }).pipe(Effect.andThen(worker.enqueue(threadId))); From 0ccb1efdcee12fb88dc1a97210e7bbac64922bdb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 22:04:00 -0700 Subject: [PATCH 45/61] fix(cloud): spawn relay client without shell Co-authored-by: codex --- .../src/cloud/ManagedEndpointRuntime.test.ts | 9 +++++++-- .../server/src/cloud/ManagedEndpointRuntime.ts | 18 +++++++++--------- packages/shared/src/relayClient.ts | 2 +- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 10358c79a88..16cf946cf05 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -106,12 +106,17 @@ describe("CloudManagedEndpointRuntime", () => { expect(spawned.map((command) => command.command)).toEqual(["cloudflared", "cloudflared"]); expect(spawned.map((command) => command.args)).toEqual([ - ["tunnel", "run", "--token", "token-1"], - ["tunnel", "run", "--token", "token-2"], + ["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" }); }), diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 0dbf4f9259d..65656292ebc 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -185,16 +185,16 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { const connectorScope = yield* Scope.make("sequential"); const child = yield* spawner .spawn( - ChildProcess.make( - executable.executablePath, - ["tunnel", "run", "--token", config.connectorToken], - { - detached: false, - shell: process.platform === "win32", - stderr: "ignore", - stdout: "ignore", + 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), diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index a73ec183176..35d002466e9 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -292,7 +292,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ) { const child = yield* spawner.spawn( ChildProcess.make(command, args, { - shell: platform === "win32", + shell: false, stdout: "ignore", stderr: "ignore", }), From 2e9e633d9ae054a1b41c81ea5ecd20f13e2bdc96 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 22:19:22 -0700 Subject: [PATCH 46/61] fix(relay): restrict connector egress to managed endpoints Co-authored-by: codex --- infra/relay/src/deploymentConfig.test.ts | 19 +++ infra/relay/src/deploymentConfig.ts | 36 +++++ .../environments/EnvironmentConnector.test.ts | 151 +++++++++++++++++- .../src/environments/EnvironmentConnector.ts | 58 ++++++- .../ManagedEndpointAllocations.ts | 18 +++ .../environments/ManagedEndpointProvider.ts | 7 +- infra/relay/src/http/Api.ts | 2 + 7 files changed, 274 insertions(+), 17 deletions(-) diff --git a/infra/relay/src/deploymentConfig.test.ts b/infra/relay/src/deploymentConfig.test.ts index c7dc7b3506d..1b69d55058f 100644 --- a/infra/relay/src/deploymentConfig.test.ts +++ b/infra/relay/src/deploymentConfig.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { managedEndpointDigestInput, + managedEndpointForHostname, managedEndpointHostname, + isManagedEndpointHostname, managedEndpointTunnelName, relayOwnsManagedEndpointZone, relayPublicDomainForStage, @@ -71,4 +73,21 @@ describe("managed endpoint names", () => { 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 index 413201f277a..253fcaf3453 100644 --- a/infra/relay/src/deploymentConfig.ts +++ b/infra/relay/src/deploymentConfig.ts @@ -1,3 +1,5 @@ +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"; @@ -11,6 +13,21 @@ function normalizeZoneName(zoneName: string): string { .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); } @@ -67,6 +84,25 @@ export function managedEndpointHostname(stage: string, baseDomain: string, 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 index 553491d881d..505d1f31d00 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -27,6 +27,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab 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" }, @@ -69,7 +70,7 @@ const settings = RelayConfiguration.RelayConfiguration.of({ clerkJwtAudience: "t3-code-relay", cloudMintPrivateKey: Redacted.make(cloudKeyPair.privateKey), cloudMintPublicKey: cloudKeyPair.publicKey, - managedEndpointBaseDomain: undefined, + managedEndpointBaseDomain: "example.test", managedEndpointNamespace: undefined, }); @@ -155,16 +156,44 @@ function connectorTestLayer( ) => 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 { @@ -180,8 +209,8 @@ function makeLinks( label: "Connector Test Environment", endpoint: { httpBaseUrl: "https://env.example.test/", - wsBaseUrl: "wss://env.example.test/", - providerKind: "manual", + wsBaseUrl: "wss://env.example.test/ws", + providerKind: "cloudflare_tunnel", }, linkedAt: "2026-05-25T00:00:00.000Z", environmentPublicKey: environmentKeyPair.publicKey, @@ -232,6 +261,120 @@ describe("EnvironmentConnector", () => { }).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(() => { @@ -438,7 +581,7 @@ describe("EnvironmentConnector", () => { credential: "pairing_credential", endpoint: { httpBaseUrl: "https://env.example.test/", - wsBaseUrl: "wss://env.example.test/", + wsBaseUrl: "wss://env.example.test/ws", }, }); }).pipe(Effect.provide(connectorTestLayer(execute))); diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index a7d1652c071..d31cf499e44 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -36,9 +36,10 @@ 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 { HttpClient } from "effect/unstable/http"; +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( @@ -69,7 +70,8 @@ export type EnvironmentConnectorError = | EnvironmentMintRequestFailed | EnvironmentMintRequestTimedOut | EnvironmentMintResponseInvalid - | EnvironmentLinks.EnvironmentLinkLookupPersistenceError; + | EnvironmentLinks.EnvironmentLinkLookupPersistenceError + | ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError; export const ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS = 10_000; const ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS = 60 * 1_000; @@ -114,6 +116,9 @@ function environmentHealthRequestFailureMessage(cause: unknown): string { : "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; @@ -216,6 +221,7 @@ function verifyEnvironmentHealthResponse(input: { 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; @@ -224,6 +230,38 @@ const make = Effect.gen(function* () { 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) { @@ -235,6 +273,7 @@ const make = Effect.gen(function* () { 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( @@ -259,8 +298,9 @@ const make = Effect.gen(function* () { payload, }).pipe(Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause }))); const checkedAt = DateTime.formatIso(now); - const environmentClient = yield* makeEnvironmentClient(link.endpoint.httpBaseUrl); + 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 }), @@ -270,7 +310,7 @@ const make = Effect.gen(function* () { if (Option.isNone(responseOption)) { return { environmentId: link.environmentId, - endpoint: link.endpoint, + endpoint, status: "offline" as const, checkedAt, error: "Managed endpoint health request timed out.", @@ -279,7 +319,7 @@ const make = Effect.gen(function* () { if (responseOption.value._tag === "Failure") { return { environmentId: link.environmentId, - endpoint: link.endpoint, + endpoint, status: "offline" as const, checkedAt, error: environmentHealthRequestFailureMessage(responseOption.value.cause), @@ -300,7 +340,7 @@ const make = Effect.gen(function* () { } return { environmentId: link.environmentId, - endpoint: link.endpoint, + endpoint, status: "online" as const, checkedAt: decoded.checkedAt, descriptor: decoded.descriptor, @@ -320,6 +360,7 @@ const make = Effect.gen(function* () { 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( @@ -346,8 +387,9 @@ const make = Effect.gen(function* () { typ: RELAY_MINT_REQUEST_TYP, payload, }).pipe(Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause }))); - const environmentClient = yield* makeEnvironmentClient(link.endpoint.httpBaseUrl); + 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( @@ -377,7 +419,7 @@ const make = Effect.gen(function* () { } return { environmentId: link.environmentId, - endpoint: link.endpoint, + endpoint, credential: decoded.credential, expiresAt: decoded.expiresAt, }; diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index c813b7c9874..236414e9552 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -1,3 +1,4 @@ +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"; @@ -6,6 +7,7 @@ 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 { @@ -18,6 +20,22 @@ export interface ManagedEndpointAllocation { 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", )<{ diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 9804ba0a1ed..068beccff00 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -19,6 +19,7 @@ import type { import * as RelayConfiguration from "../Config.ts"; import { managedEndpointDigestInput, + managedEndpointForHostname, managedEndpointHostname, managedEndpointTunnelName, } from "../deploymentConfig.ts"; @@ -407,11 +408,7 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); return { - endpoint: { - httpBaseUrl: `https://${hostname}/`, - wsBaseUrl: `wss://${hostname}/ws`, - providerKind: "cloudflare_tunnel", - }, + endpoint: managedEndpointForHostname(hostname), runtime: { providerKind: "cloudflare_tunnel", connectorToken, diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index cbb9f4e92ec..36910886626 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -61,6 +61,7 @@ import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher 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"; @@ -824,6 +825,7 @@ const COMMON_AUTH_INVALID_REASONS = [ EnvironmentLinks.EnvironmentLinkListPersistenceError, EnvironmentLinks.EnvironmentLinkLookupPersistenceError, EnvironmentLinks.EnvironmentLinkRevokePersistenceError, + ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError, EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError, EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError, DpopProofs.DpopProofReplayPersistenceError, From c7e4e2fc563484a66991cb79a4dd5a30366e6d11 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 22:32:06 -0700 Subject: [PATCH 47/61] ci(relay): scope production deploy secrets Co-authored-by: codex --- .github/workflows/deploy-relay.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 18a76142c94..7df4d93362a 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -4,7 +4,6 @@ on: push: branches: - main - workflow_dispatch: permissions: contents: read @@ -23,22 +22,16 @@ jobs: name: production env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} - PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} - AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} 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 }} - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} 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 }} - APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} ALCHEMY_TELEMETRY_DISABLED: "1" steps: - name: Checkout @@ -59,3 +52,10 @@ jobs: - name: Deploy production relay stage run: bun --cwd infra/relay run 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 }} From 24f5a82a5dd3b768272158cbfe5069e896507d6f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 22:43:31 -0700 Subject: [PATCH 48/61] fix(cloud): validate relay URL origins Co-authored-by: codex --- .../src/features/cloud/publicConfig.test.ts | 13 ++++++++ .../mobile/src/features/cloud/publicConfig.ts | 3 +- apps/server/src/cloud/http.ts | 15 +-------- apps/server/src/server.test.ts | 10 ++++++ apps/web/src/cloud/publicConfig.test.ts | 8 +++++ apps/web/src/cloud/publicConfig.ts | 9 +++--- .../client-runtime/src/managedRelay.test.ts | 28 ++++++++++++++-- packages/client-runtime/src/managedRelay.ts | 32 ++++++++++++++++--- packages/shared/package.json | 4 +++ packages/shared/src/relayUrl.test.ts | 26 +++++++++++++++ packages/shared/src/relayUrl.ts | 22 +++++++++++++ 11 files changed, 144 insertions(+), 26 deletions(-) create mode 100644 packages/shared/src/relayUrl.test.ts create mode 100644 packages/shared/src/relayUrl.ts diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index 26c1561bf03..df9aefdc43a 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -31,4 +31,17 @@ describe("resolveCloudPublicConfig", () => { 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 index d775f061016..fcd0ceb2728 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -1,5 +1,6 @@ import Constants from "expo-constants"; import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; type ExpoExtra = Readonly> | undefined; @@ -22,7 +23,7 @@ export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig return { clerkPublishableKey: trimNonEmpty(clerk?.publishableKey), clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate), - relayUrl: trimNonEmpty(relay?.url)?.replace(/\/+$/u, "") ?? null, + relayUrl: normalizeSecureRelayUrl(trimNonEmpty(relay?.url) ?? ""), } satisfies CloudPublicConfig; } diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 3c274107417..1df23d68599 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -37,6 +37,7 @@ import { 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"; @@ -155,20 +156,6 @@ function validateCloudMintPublicKey( ); } -function isSecureRelayUrl(value: string): boolean { - try { - const url = new URL(value); - return ( - url.protocol === "https:" && - url.username.length === 0 && - url.password.length === 0 && - url.hash.length === 0 - ); - } catch { - return false; - } -} - function validateRelayConfigPayload( payload: RelayEnvironmentConfigRequest, ): Effect.Effect { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 29f103a50e0..8b1f9972319 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2127,6 +2127,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { 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", @@ -2138,6 +2143,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const insecureRelayIssuerBody = yield* responseJsonEffect<{ readonly message?: string }>( insecureRelayIssuer, ); + const nonOriginRelayUrlBody = yield* responseJsonEffect<{ readonly message?: string }>( + nonOriginRelayUrl, + ); const emptyCredentialBody = yield* responseJsonEffect<{ readonly message?: string }>( emptyCredential, ); @@ -2149,6 +2157,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { 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)), diff --git a/apps/web/src/cloud/publicConfig.test.ts b/apps/web/src/cloud/publicConfig.test.ts index 32a902c47e0..a19814beb3a 100644 --- a/apps/web/src/cloud/publicConfig.test.ts +++ b/apps/web/src/cloud/publicConfig.test.ts @@ -22,4 +22,12 @@ describe("hasCloudPublicConfig", () => { 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 index fe3d5b5e63b..291f1830ca3 100644 --- a/apps/web/src/cloud/publicConfig.ts +++ b/apps/web/src/cloud/publicConfig.ts @@ -1,4 +1,5 @@ import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; export interface CloudPublicConfig { readonly clerkPublishableKey: string | null; @@ -16,11 +17,9 @@ export function resolveCloudPublicConfig(): CloudPublicConfig { import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined, ), clerkJwtTemplate: trimNonEmpty(import.meta.env.VITE_CLERK_JWT_TEMPLATE as string | undefined), - relayUrl: - trimNonEmpty(import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined)?.replace( - /\/+$/u, - "", - ) ?? null, + relayUrl: normalizeSecureRelayUrl( + (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "", + ), }; } diff --git a/packages/client-runtime/src/managedRelay.test.ts b/packages/client-runtime/src/managedRelay.test.ts index 412981d9581..e340f12f620 100644 --- a/packages/client-runtime/src/managedRelay.test.ts +++ b/packages/client-runtime/src/managedRelay.test.ts @@ -16,7 +16,10 @@ import { } from "./managedRelay.ts"; import { remoteHttpClientLayer } from "./remote.ts"; -function managedRelayTestLayer(fetchFn: typeof globalThis.fetch) { +function managedRelayTestLayer( + fetchFn: typeof globalThis.fetch, + relayUrl = "https://relay.example.test", +) { const httpClientLayer = remoteHttpClientLayer(fetchFn); const signerLayer = Layer.succeed( ManagedRelayDpopSigner, @@ -26,12 +29,33 @@ function managedRelayTestLayer(fetchFn: typeof globalThis.fetch) { }), ); return managedRelayClientLayer({ - relayUrl: "https://relay.example.test", + 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) => { diff --git a/packages/client-runtime/src/managedRelay.ts b/packages/client-runtime/src/managedRelay.ts index 4ac2d34a62b..a3bc973c64c 100644 --- a/packages/client-runtime/src/managedRelay.ts +++ b/packages/client-runtime/src/managedRelay.ts @@ -26,6 +26,7 @@ import { 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"; @@ -183,14 +184,37 @@ function dpopHeaders(authorization: ManagedRelayAuthorization) { }; } +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: options.relayUrl }); + const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); const cachedTokens = yield* SynchronizedRef.make>([]); - const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: options.relayUrl }); + const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); type DpopProofTarget = Pick; const dpopProofTargets = { @@ -257,7 +281,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) subject_token: input.clerkToken, subject_token_type: RelayJwtSubjectTokenType, requested_token_type: RelayAccessTokenType, - resource: options.relayUrl, + resource: relayUrl, scope: encodeOAuthScope(input.scopes), client_id: options.clientId, }, @@ -325,7 +349,7 @@ export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) }); return ManagedRelayClient.of({ - relayUrl: options.relayUrl, + relayUrl, listEnvironments: (input) => client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe( Effect.map((response) => response.environments), diff --git a/packages/shared/package.json b/packages/shared/package.json index 2f825f9166d..f5407e27094 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -103,6 +103,10 @@ "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" 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; +} From 8292f8ae94da9c451975df0f237474021c7748fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 22:57:42 -0700 Subject: [PATCH 49/61] feat(relay): expose Scalar API docs Co-authored-by: codex --- infra/relay/src/worker.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 43aa0ab4578..76e561241ce 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -10,6 +10,7 @@ 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"; @@ -248,10 +249,12 @@ export default class Api extends Cloudflare.Worker()( ); const fetch = Layer.merge( - HttpApiBuilder.layer(RelayApi).pipe( - Layer.provide(appLayer), - Layer.provide([Etag.layerWeak, httpPlatformNotSupportedLayer, relayCors]), - ), + Layer.mergeAll( + HttpApiBuilder.layer(RelayApi, { openapiPath: "/openapi.json" }).pipe( + Layer.provide(appLayer), + ), + HttpApiScalar.layer(RelayApi, { path: "/docs" }), + ).pipe(Layer.provide([Etag.layerWeak, httpPlatformNotSupportedLayer, relayCors])), relayNotFoundRoute, ).pipe( HttpRouter.toHttpEffect, From 1a3bd373999d88b7e7f21d91b25904cb94f5e573 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 23:06:53 -0700 Subject: [PATCH 50/61] fix(relay): harden cloud lifecycle security Co-authored-by: codex --- apps/server/src/cloud/environmentKeys.test.ts | 87 ++++++++++ apps/server/src/cloud/environmentKeys.ts | 73 +++++++- apps/server/src/cloud/http.ts | 4 +- .../src/relay/AgentAwarenessRelay.test.ts | 19 +++ apps/server/src/relay/AgentAwarenessRelay.ts | 29 +++- .../EnvironmentCredentials.test.ts | 14 +- .../environments/EnvironmentCredentials.ts | 16 +- packages/contracts/src/relay.ts | 161 ++++++++++++------ 8 files changed, 332 insertions(+), 71 deletions(-) create mode 100644 apps/server/src/cloud/environmentKeys.test.ts 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 index 3b355cfb1b6..beef4729992 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -1,11 +1,23 @@ 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); } @@ -14,26 +26,75 @@ 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 { + 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" }, }); - yield* secrets.set(CLOUD_LINK_PRIVATE_KEY, stringToBytes(keyPair.privateKey)); - yield* secrets.set(CLOUD_LINK_PUBLIC_KEY, stringToBytes(keyPair.publicKey)); - return { + return yield* persistEnvironmentKeyPair(secrets, { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey, - }; + }); }); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 1df23d68599..ffadc86dfb1 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -526,7 +526,6 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) { - const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( Effect.flatMap((bytes) => bytes @@ -593,6 +592,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }); } + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); const descriptor = yield* dependencies.environment.getDescriptor; const responseExpiresAt = DateTime.add(now, { minutes: 5 }); const responsePayload = { @@ -643,7 +643,6 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) { - const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( Effect.flatMap((bytes) => bytes @@ -711,6 +710,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }); } + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); const issued = yield* dependencies.environmentAuth.createPairingLink({ scopes: AuthStandardClientScopes, subject: "cloud-connect", diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d2027cd3225..bbfbd236ad0 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -192,6 +192,25 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { 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; diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index aef8870c12b..6266a39b798 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -99,6 +99,23 @@ 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}`)); } @@ -203,11 +220,13 @@ export function resolveAgentAwarenessRelayPublishSnapshot(input: { } return { projectId: input.thread.value.projectId, - state: projectThreadAwareness({ - environmentId: input.environmentId, - project: input.project.value, - thread: input.thread.value, - }), + state: sanitizeRelayAgentActivityState( + projectThreadAwareness({ + environmentId: input.environmentId, + project: input.project.value, + thread: input.thread.value, + }), + ), reason: "snapshot", }; } diff --git a/infra/relay/src/environments/EnvironmentCredentials.test.ts b/infra/relay/src/environments/EnvironmentCredentials.test.ts index 3135c766c04..9282564e985 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.test.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.test.ts @@ -1,6 +1,6 @@ import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; import { describe, expect, it } from "@effect/vitest"; -import { PgDialect } from "drizzle-orm/pg-core"; +import { PgDialect, QueryBuilder } from "drizzle-orm/pg-core"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -98,6 +98,7 @@ describe("EnvironmentCredentials", () => { const updateValues: Array> = []; const whereConditions: Array = []; const fakeDb = { + select: (fields: Parameters[0]) => new QueryBuilder().select(fields), update: (table: unknown) => { expect(table).toBe(relayEnvironmentCredentials); return { @@ -135,7 +136,16 @@ describe("EnvironmentCredentials", () => { 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.params).toEqual(["env_test", "environment-public-key"]); + 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( diff --git a/infra/relay/src/environments/EnvironmentCredentials.ts b/infra/relay/src/environments/EnvironmentCredentials.ts index af091e9a8a5..9acde2eef6c 100644 --- a/infra/relay/src/environments/EnvironmentCredentials.ts +++ b/infra/relay/src/environments/EnvironmentCredentials.ts @@ -6,10 +6,10 @@ 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 } from "drizzle-orm"; +import { and, eq, isNull, ne, notExists } from "drizzle-orm"; import { RelayDb } from "../db.ts"; -import { relayEnvironmentCredentials } from "../persistence/schema.ts"; +import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts"; export class EnvironmentCredentialCreatePersistenceError extends Data.TaggedError( "EnvironmentCredentialCreatePersistenceError", @@ -161,6 +161,18 @@ const make = Effect.gen(function* () { 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({ diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 9251f22afbf..31a15d2f640 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -188,9 +188,13 @@ export type RelayAgentActivityPublishProofPayload = export type RelayAgentActivityPublishProof = string; export const RelayAgentActivityPublishRequest = Schema.Struct({ - state: Schema.NullOr(RelayAgentActivityState), - proof: TrimmedNonEmptyString, -}); + 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([ @@ -215,10 +219,16 @@ export const RelayEnvironmentLinkProof = TrimmedNonEmptyString; export type RelayEnvironmentLinkProof = typeof RelayEnvironmentLinkProof.Type; export const RelayEnvironmentLinkChallengeRequest = Schema.Struct({ - notificationsEnabled: Schema.Boolean, - liveActivitiesEnabled: Schema.Boolean, - managedTunnelsEnabled: Schema.Boolean, -}); + 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({ @@ -229,12 +239,18 @@ export type RelayEnvironmentLinkChallengeResponse = typeof RelayEnvironmentLinkChallengeResponse.Type; export const RelayEnvironmentLinkRequest = Schema.Struct({ - deviceId: Schema.optional(TrimmedNonEmptyString), - proof: RelayEnvironmentLinkProof, + 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({ @@ -519,10 +535,22 @@ export const RelayListEnvironmentsResponse = Schema.Struct({ export type RelayListEnvironmentsResponse = typeof RelayListEnvironmentsResponse.Type; export const RelayEnvironmentConnectRequest = Schema.Struct({ - deviceId: Schema.optional(TrimmedNonEmptyString), - clientKeyThumbprint: Schema.optional(TrimmedNonEmptyString), - clientProofKeyThumbprint: Schema.optional(TrimmedNonEmptyString), -}); + 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; @@ -546,13 +574,21 @@ export const RelayWebClientId = "t3-web" as const; export const RelayDpopAccessTokenRequest = Schema.Struct({ grant_type: Schema.Literal(RelayDpopTokenExchangeGrantType), - subject_token: TrimmedNonEmptyString, + 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, - scope: TrimmedNonEmptyString, + 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, -}).pipe(HttpApiSchema.asFormUrlEncoded()); +}) + .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({ @@ -731,21 +767,25 @@ export const RelayHealthResponse = Schema.Struct({ }); export type RelayHealthResponse = typeof RelayHealthResponse.Type; -export const RelayHealthGroup = HttpApiGroup.make("health").add( - HttpApiEndpoint.get("health", "/health", { - success: RelayHealthResponse, - error: RelayInternalError, - }), -); +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, - }), - HttpApiEndpoint.get("protectedResource", "/.well-known/oauth-protected-resource", { - success: RelayProtectedResourceMetadata, - }), -); +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", @@ -756,7 +796,7 @@ export const RelayRegisterDeviceEndpoint = HttpApiEndpoint.post( success: RelayOkResponse, error: RelayAuthAndInternalErrors, }, -); +).annotate(OpenApi.Summary, "Register or update a mobile device"); export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post( "registerLiveActivity", @@ -767,7 +807,7 @@ export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post( success: RelayOkResponse, error: RelayAuthAndInternalErrors, }, -); +).annotate(OpenApi.Summary, "Register a Live Activity push token"); export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( "unregisterDevice", @@ -778,7 +818,7 @@ export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( success: RelayOkResponse, error: RelayAuthAndInternalErrors, }, -); +).annotate(OpenApi.Summary, "Unregister a mobile device"); export const RelayMobileGroup = HttpApiGroup.make("mobile") .add( @@ -786,6 +826,7 @@ export const RelayMobileGroup = HttpApiGroup.make("mobile") RelayRegisterLiveActivityEndpoint, RelayUnregisterDeviceEndpoint, ) + .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") .middleware(RelayDpopClientAuth); export const RelayClientGroup = HttpApiGroup.make("client") @@ -794,18 +835,18 @@ export const RelayClientGroup = HttpApiGroup.make("client") 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", @@ -815,14 +856,15 @@ export const RelayClientGroup = HttpApiGroup.make("client") 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( @@ -834,9 +876,11 @@ export const RelayExchangeDpopAccessTokenEndpoint = HttpApiEndpoint.post( success: RelayDpopAccessTokenResponse, error: RelayAuthAndInternalErrors, }, -); +).annotate(OpenApi.Summary, "Exchange a Clerk token for a DPoP access token"); -export const RelayTokenGroup = HttpApiGroup.make("token").add(RelayExchangeDpopAccessTokenEndpoint); +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", @@ -850,7 +894,7 @@ export const RelayConnectEnvironmentEndpoint = HttpApiEndpoint.post( success: RelayEnvironmentConnectResponse, error: RelayEnvironmentConnectErrors, }, -); +).annotate(OpenApi.Summary, "Connect to an environment"); export const RelayGetEnvironmentStatusEndpoint = HttpApiEndpoint.post( "getEnvironmentStatus", @@ -863,10 +907,11 @@ export const RelayGetEnvironmentStatusEndpoint = HttpApiEndpoint.post( 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") @@ -883,17 +928,25 @@ export const RelayServerGroup = HttpApiGroup.make("server") 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, -); +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; From 34b6cff75327566dd6f8fa9d9ff70787d6428970 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 23:12:36 -0700 Subject: [PATCH 51/61] docs(relay): describe endpoint authentication schemes Co-authored-by: codex --- infra/relay/src/http/Api.test.ts | 2 +- infra/relay/src/http/Api.ts | 7 +++++-- packages/contracts/src/relay.ts | 25 ++++++++++++++++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 2822d30b1e0..6725fcba999 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -118,7 +118,7 @@ describe("relay environment authentication", () => { return Effect.gen(function* () { const auth = yield* RelayEnvironmentAuth; const error = yield* Effect.flip( - auth.bearer(Effect.succeed(HttpServerResponse.empty()), { + auth.environmentBearer(Effect.succeed(HttpServerResponse.empty()), { credential: Redacted.make("environment-credential"), endpoint: {} as never, group: {} as never, diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 36910886626..802910d9201 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -188,7 +188,7 @@ export const relayClientAuthLayer = Layer.effect( Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; return { - bearer: Effect.fn("relay.auth.client.bearer")(function* (httpEffect, { credential }) { + clientBearer: Effect.fn("relay.auth.client.bearer")(function* (httpEffect, { credential }) { const token = readHttpAuthorizationCredential(credential); const verified = yield* verifyRelayClientBearerToken(config, token).pipe( Effect.tapError((error) => @@ -227,7 +227,10 @@ export const relayEnvironmentAuthLayer = Layer.effect( Effect.gen(function* () { const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; return { - bearer: Effect.fn("relay.auth.environment.bearer")(function* (httpEffect, { credential }) { + environmentBearer: Effect.fn("relay.auth.environment.bearer")(function* ( + httpEffect, + { credential }, + ) { const token = readHttpAuthorizationCredential(credential); const principal = yield* credentials .authenticate(token) diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 31a15d2f640..2c1931333fc 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -490,20 +490,34 @@ export class RelayEnvironmentPrincipal extends Context.Service< RelayEnvironmentPrincipalShape >()("@t3tools/contracts/relay/RelayEnvironmentPrincipal") {} +const RelayClientBearerAuthorization = HttpApiSecurity.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: { bearer: HttpApiSecurity.bearer }, + security: { clientBearer: RelayClientBearerAuthorization }, }) {} +const RelayEnvironmentBearerAuthorization = HttpApiSecurity.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: { bearer: HttpApiSecurity.bearer }, + security: { environmentBearer: RelayEnvironmentBearerAuthorization }, }) {} const RelayDpopAuthorization = HttpApiSecurity.http({ scheme: "DPoP" }).pipe( @@ -876,7 +890,12 @@ export const RelayExchangeDpopAccessTokenEndpoint = HttpApiEndpoint.post( success: RelayDpopAccessTokenResponse, error: RelayAuthAndInternalErrors, }, -).annotate(OpenApi.Summary, "Exchange a Clerk token for a DPoP access token"); +) + .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) From e4609b0090e30cbafae01b453eeb3f0cc25c4e54 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 23:21:18 -0700 Subject: [PATCH 52/61] docs(relay): emit lowercase bearer schemes Co-authored-by: codex --- packages/contracts/src/relay.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 2c1931333fc..11b30ac3eee 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -490,7 +490,7 @@ export class RelayEnvironmentPrincipal extends Context.Service< RelayEnvironmentPrincipalShape >()("@t3tools/contracts/relay/RelayEnvironmentPrincipal") {} -const RelayClientBearerAuthorization = HttpApiSecurity.bearer.pipe( +const RelayClientBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe( HttpApiSecurity.annotate( OpenApi.Description, "Clerk session or OAuth bearer token for the signed-in T3 Cloud user.", @@ -505,7 +505,7 @@ export class RelayClientAuth extends HttpApiMiddleware.Service< security: { clientBearer: RelayClientBearerAuthorization }, }) {} -const RelayEnvironmentBearerAuthorization = HttpApiSecurity.bearer.pipe( +const RelayEnvironmentBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe( HttpApiSecurity.annotate( OpenApi.Description, "Relay-issued environment credential installed when the environment is linked.", From 11cd7247ab4f2d859c870c91e10dc764906a6634 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 2 Jun 2026 23:24:49 -0700 Subject: [PATCH 53/61] docs(relay): redirect root to API docs Co-authored-by: codex --- infra/relay/src/http/Api.test.ts | 17 +++++++++++++++++ infra/relay/src/http/Api.ts | 6 ++++++ infra/relay/src/worker.ts | 2 ++ 3 files changed, 25 insertions(+) diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 6725fcba999..0f25e4632c4 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -15,6 +15,7 @@ import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; import { relayCors, + relayDocsRedirectRoute, relayEnvironmentAuthLayer, relayNotFoundRoute, traceRelayHttpRequestWith, @@ -198,6 +199,22 @@ describe("relay request tracing", () => { }); 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( diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 802910d9201..3a9e59a6719 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -152,6 +152,12 @@ 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, diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index 76e561241ce..8f00cc452f5 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -23,6 +23,7 @@ import { relayClientAuthLayer, relayDpopClientAuthLayer, relayCors, + relayDocsRedirectRoute, relayEnvironmentAuthLayer, relayNotFoundRoute, serverApi, @@ -254,6 +255,7 @@ export default class Api extends Cloudflare.Worker()( Layer.provide(appLayer), ), HttpApiScalar.layer(RelayApi, { path: "/docs" }), + relayDocsRedirectRoute, ).pipe(Layer.provide([Etag.layerWeak, httpPlatformNotSupportedLayer, relayCors])), relayNotFoundRoute, ).pipe( From 5ad99dc6f63b68a992d5c0c517a7103511c1116b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 3 Jun 2026 16:27:08 -0700 Subject: [PATCH 54/61] chore(relay): refresh pnpm lockfile Co-authored-by: codex --- pnpm-lock.yaml | 5114 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 4697 insertions(+), 417 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad8b3370798..9eafba039d3 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 @@ -36,6 +45,7 @@ overrides: '@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 @@ -53,6 +63,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 @@ -87,16 +100,16 @@ 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)' apps/desktop: 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 @@ -124,7 +137,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 @@ -136,16 +149,16 @@ importers: version: 26.8.1(electron-builder-squirrel-windows@26.8.1) 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 @@ -161,7 +174,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(@types/react@19.2.16)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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) '@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) @@ -170,16 +186,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(961c4aa6f32829b318e3c87ef20ad401) '@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -218,34 +240,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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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) @@ -254,13 +279,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(5bfdf39b8f760e4dc2d5c3acffc97310) expo-secure-store: specifier: ~56.0.4 version: 56.0.4(expo@56.0.8) @@ -269,10 +297,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(961c4aa6f32829b318e3c87ef20ad401) punycode: specifier: ^2.3.1 version: 2.3.1 @@ -284,40 +318,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -326,14 +360,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -348,13 +385,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)) @@ -373,7 +410,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 @@ -400,16 +437,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(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bs58@6.0.0)(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3)(use-sync-external-store@1.6.0(react@19.2.6))(utf-8-validate@6.0.6)(zod@4.4.3) + '@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) @@ -430,7 +473,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 3.0.0-beta.44 - version: 3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -467,6 +510,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 @@ -494,7 +540,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) @@ -536,19 +585,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)(4aa68e39aff0906aebb4b8ef5e74bb2f) + drizzle-orm: + specifier: 1.0.0-rc.3 + version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.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.20260604.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 @@ -558,16 +653,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: @@ -583,13 +678,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: @@ -599,13 +694,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: @@ -615,22 +710,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: @@ -640,50 +735,59 @@ 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 yaml: specifier: ^2.9.0 version: 2.9.0 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: @@ -699,41 +803,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: @@ -742,7 +846,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 @@ -758,16 +862,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: @@ -777,6 +881,16 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + + '@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] @@ -921,6 +1035,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'} @@ -1312,6 +1519,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@base-org/account@2.0.1': + resolution: {integrity: sha512-tySVNx+vd6XEynZL0uvB10uKiwnAfThr8AbKTwILVG86mPbLAhEOInQIk+uDnvpTvfdUhC1Bi5T/46JvFoLZQQ==} + '@base-ui/react@1.5.0': resolution: {integrity: sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A==} engines: {node: '>=14.0.0'} @@ -1349,18 +1559,196 @@ 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.20260604.1': + resolution: {integrity: sha512-nVTydUwPcz9WfwZ/6xAmqs8uUVeGbDVmyPvSYrYAo4CQTyi0122SoE1Suw8RvqnN60XXHPChAjDqSIjnr/mbmg==} + + '@coinbase/wallet-sdk@4.3.7': + resolution: {integrity: sha512-z6e5XDw6EF06RqkeyEa+qD0dZ2ZbLci99vx3zwDY//XO8X7166tqKJrR2XlQnzVmtcUuJtCd5fCvr9Cu6zzX7w==} + '@develar/schema-utils@2.6.5': resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} + '@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: @@ -1389,6 +1777,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: @@ -1421,6 +1812,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: @@ -1548,156 +1944,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.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + '@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'} @@ -2136,6 +2688,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: @@ -2222,6 +2778,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] + '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} @@ -2280,6 +2893,96 @@ 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/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.1': + resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 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==} @@ -2302,6 +3005,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==} @@ -2815,6 +3521,11 @@ packages: '@types/react': optional: true + '@react-native-async-storage/async-storage@1.24.0': + resolution: {integrity: sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.60 <1.0 + '@react-native-masked-view/masked-view@0.3.2': resolution: {integrity: sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==} peerDependencies: @@ -2909,6 +3620,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} @@ -2921,6 +3638,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} @@ -2933,6 +3656,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} @@ -2945,6 +3674,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} @@ -2957,6 +3692,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} @@ -2969,6 +3710,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} @@ -2981,6 +3728,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} @@ -2993,6 +3746,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} @@ -3005,6 +3764,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} @@ -3017,6 +3782,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} @@ -3029,6 +3800,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} @@ -3041,6 +3818,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} @@ -3052,6 +3835,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} @@ -3063,6 +3851,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} @@ -3075,6 +3869,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} @@ -3238,6 +4038,15 @@ packages: cpu: [x64] os: [win32] + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@shikijs/core@3.23.0': resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} @@ -3297,9 +4106,206 @@ 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'} + + '@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.8': + resolution: {integrity: sha512-W9DbsFvl5lSOe7KT3dJX4tjbxfYIoOtOTJpvLMgkojyRU0UKChQ4vHvbOZQ3GkUJ8wOIS4qdrM0Yytd1Vy+YQQ==} + peerDependencies: + '@solana/web3.js': ^1.98.4 + + '@solana-mobile/mobile-wallet-adapter-protocol@2.2.8': + resolution: {integrity: sha512-c3FQsrM7nV62DqVaHGKtr2osE2w5gS3/wjy8ILF0zczS/s1mERX+JTmf+UHd8xgESmEj/IM7q+U2Qhrmac1PdA==} + peerDependencies: + react-native: '>0.74' + + '@solana-mobile/wallet-adapter-mobile@2.2.8': + resolution: {integrity: sha512-ZbXY3/0+UnnyS0hvArpO1b1pYzaQAiVIp+HBUm11aLEkE5+ISvHTRPr/bCEUXZfPkez/1n9zH3H0leK25Lj6Nw==} + peerDependencies: + '@solana/web3.js': ^1.98.4 + react-native: '>0.74' + + '@solana-mobile/wallet-standard-mobile@0.5.2': + resolution: {integrity: sha512-orEGv4N/Ttd0umwfWUzGcEnVc9eJDTgRSEcDitvFWpIf2D968h6128L0rq9/y7sUw88Gvu/lU0euoC2ASEijqQ==} + + '@solana/buffer-layout@4.0.1': + resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} + engines: {node: '>=5.10'} + + '@solana/codecs-core@2.3.0': + resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-core@6.9.0': + resolution: {integrity: sha512-F2BmLecG/1nTtnjyD509NsEc254pxJKa2bpvotymv1lL1WfEn3zchcZ9SMIiLyL4G6J8b9F3OKIq2YSZho2AOQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-numbers@2.3.0': + resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-numbers@6.9.0': + resolution: {integrity: sha512-XMI0FOHV2h7yPAllxWCX8z+J1msidNjXzN1mRjH5KR6C+vfzyKa2xWHve0bNSV/bjVAhqqhc7dQCpBKuF4+ScQ==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-strings@6.9.0': + resolution: {integrity: sha512-PTqYQxMsmdfEEq29bV1AnALD4FjFEsSxOj1fYNqooOSTEQEpUoYEQtsd55/kBsnIKltXbvYwXYXBusm19n1sQA==} + engines: {node: '>=20.18.0'} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: '>=5.4.0' + peerDependenciesMeta: + fastestsmallesttextencoderdecoder: + optional: true + typescript: + optional: true + + '@solana/errors@2.3.0': + resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.3.3' + + '@solana/errors@6.9.0': + resolution: {integrity: sha512-7i+b07KMnkbHvFlz7uWade3jvyc22UmVm8o9taxPK8YV3JNM/NkS8oQFvMac2MIaLPAlEs7I8MHyVLUal1yY4g==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/wallet-adapter-base@0.9.27': + resolution: {integrity: sha512-kXjeNfNFVs/NE9GPmysBRKQ/nf+foSaq3kfVSeMcO/iVgigyRmB551OjU3WyAolLG/1jeEfKLqF9fKwMCRkUqg==} + engines: {node: '>=20'} + peerDependencies: + '@solana/web3.js': ^1.98.0 + + '@solana/wallet-adapter-react@0.15.39': + resolution: {integrity: sha512-WXtlo88ith5m22qB+qiGw301/Zb9r5pYr4QdXWmlXnRNqwST5MGmJWhG+/RVrzc+OG7kSb3z1gkVNv+2X/Y0Gg==} + engines: {node: '>=20'} + peerDependencies: + '@solana/web3.js': ^1.98.0 + react: '*' + + '@solana/wallet-standard-chains@1.1.1': + resolution: {integrity: sha512-Us3TgL4eMVoVWhuC4UrePlYnpWN+lwteCBlhZDUhFZBJ5UMGh94mYPXno3Ho7+iHPYRtuCi/ePvPcYBqCGuBOw==} + engines: {node: '>=16'} + + '@solana/wallet-standard-core@1.1.2': + resolution: {integrity: sha512-FaSmnVsIHkHhYlH8XX0Y4TYS+ebM+scW7ZeDkdXo3GiKge61Z34MfBPinZSUMV08hCtzxxqH2ydeU9+q/KDrLA==} + engines: {node: '>=16'} + + '@solana/wallet-standard-features@1.3.0': + resolution: {integrity: sha512-ZhpZtD+4VArf6RPitsVExvgkF+nGghd1rzPjd97GmBximpnt1rsUxMOEyoIEuH3XBxPyNB6Us7ha7RHWQR+abg==} + engines: {node: '>=16'} + + '@solana/wallet-standard-util@1.1.2': + resolution: {integrity: sha512-rUXFNP4OY81Ddq7qOjQV4Kmkozx4wjYAxljvyrqPx8Ycz0FYChG/hQVWqvgpK3sPsEaO/7ABG1NOACsyAKWNOA==} + engines: {node: '>=16'} + + '@solana/wallet-standard-wallet-adapter-base@1.1.4': + resolution: {integrity: sha512-Q2Rie9YaidyFA4UxcUIxUsvynW+/gE2noj/Wmk+IOwDwlVrJUAXCvFaCNsPDSyKoiYEKxkSnlG13OA1v08G4iw==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.98.0 + bs58: ^6.0.0 + + '@solana/wallet-standard-wallet-adapter-react@1.1.4': + resolution: {integrity: sha512-xa4KVmPgB7bTiWo4U7lg0N6dVUtt2I2WhEnKlIv0jdihNvtyhOjCKMjucWet6KAVhir6I/mSWrJk1U9SvVvhCg==} + engines: {node: '>=16'} + peerDependencies: + '@solana/wallet-adapter-base': '*' + react: '*' + + '@solana/wallet-standard-wallet-adapter@1.1.4': + resolution: {integrity: sha512-YSBrxwov4irg2hx9gcmM4VTew3ofNnkqsXQ42JwcS6ykF1P1ecVY8JCbrv75Nwe6UodnqeoZRbN7n/p3awtjNQ==} + engines: {node: '>=16'} + + '@solana/wallet-standard@1.1.4': + resolution: {integrity: sha512-NF+MI5tOxyvfTU4A+O5idh/gJFmjm52bMwsPpFGRSL79GECSN0XLmpVOO/jqTKJgac2uIeYDpQw/eMaQuWuUXw==} + engines: {node: '>=16'} + + '@solana/web3.js@1.98.4': + resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} + + '@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==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -3595,6 +4601,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==} @@ -3616,6 +4625,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -3693,9 +4705,15 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + '@types/ws@7.4.7': + resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -3955,6 +4973,31 @@ packages: '@vscode/l10n@0.0.18': resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + '@wallet-standard/app@1.1.1': + resolution: {integrity: sha512-WDGwoByhP5gwHH01r5EaLgQdLVkACPCdOMQhmhn8rsm10h/siSgTorShzBxrn0ExSPof+Lu+C3TfgqBrPa1xoQ==} + engines: {node: '>=22'} + + '@wallet-standard/base@1.1.1': + resolution: {integrity: sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==} + engines: {node: '>=22'} + + '@wallet-standard/core@1.1.1': + resolution: {integrity: sha512-5Xmjc6+Oe0hcPfVc5n8F77NVLwx1JVAoCVgQpLyv/43/bhtIif+Gx3WUrDlaSDoM8i2kA2xd6YoFbHCxs+e0zA==} + engines: {node: '>=16'} + + '@wallet-standard/errors@0.1.2': + resolution: {integrity: sha512-oEzKUqJefKby6wcIvaJgrSEe/uNn/rnqkJ0P/85K+h0i5Tdo9E3L22VWq/j5K1e8hHMnZd6LgaIr8m/Wn7X/Ng==} + engines: {node: '>=22'} + hasBin: true + + '@wallet-standard/features@1.1.1': + resolution: {integrity: sha512-aCWYmVeSCGViyEU5k7GMoW8zxE4Gs+C1s1Pp2XLesvSNlnZ4PMES9HUnTB3hl0b3RVj7C61yze3IWyrncqg4MA==} + engines: {node: '>=22'} + + '@wallet-standard/wallet@1.1.1': + resolution: {integrity: sha512-8WiRPaKk/wNNRZhB2eVhpR/JW7/aqTCMoZhgVUCujuzDmxxmGvsosMxdCG4NAdYkoyozAHCX8/xLtlWUn5mNdQ==} + engines: {node: '>=22'} + '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -3969,10 +5012,38 @@ 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==} + abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} + abitype@1.2.3: + resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + + abitype@1.2.4: + resolution: {integrity: sha512-dpKH+N27vRjarMVTFFkeY445VTKftzGWpL0FiT7xmVmzQRKazZexzC5uHG0f6XKsVLAuUlndnbGau6lRejClxg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -3994,6 +5065,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -4021,6 +5096,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==} @@ -4028,6 +5134,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'} @@ -4036,6 +5146,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'} @@ -4048,6 +5162,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'} @@ -4123,6 +5241,17 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + 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'} @@ -4172,6 +5301,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==} @@ -4185,6 +5317,15 @@ packages: barcode-detector@3.2.0: resolution: {integrity: sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==} + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + + base-x@3.0.11: + resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -4193,10 +5334,16 @@ 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'} + bn.js@5.2.3: + resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -4208,6 +5355,12 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + borsh@0.7.0: + resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -4233,11 +5386,20 @@ 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} hasBin: true + bs58@4.0.1: + resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -4250,6 +5412,13 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + 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'} @@ -4280,6 +5449,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -4287,6 +5460,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==} @@ -4298,6 +5477,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==} @@ -4351,10 +5534,18 @@ 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'} @@ -4363,6 +5554,10 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} + 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'} @@ -4370,6 +5565,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -4381,6 +5579,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -4389,6 +5591,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==} @@ -4424,6 +5630,14 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -4477,6 +5691,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==} @@ -4498,6 +5716,9 @@ 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.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -4523,6 +5744,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==} @@ -4581,6 +5805,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -4614,6 +5842,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delay@5.0.0: + resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} + engines: {node: '>=10'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -4637,6 +5869,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'} @@ -4657,6 +5893,9 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -4699,6 +5938,126 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + 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'} @@ -4747,6 +6106,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==} @@ -4777,6 +6139,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -4805,9 +6171,23 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.47.0: + resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + + es6-promisify@5.0.0: + resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + + 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'} @@ -4824,6 +6204,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'} @@ -4846,6 +6230,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -4857,6 +6244,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: @@ -4864,6 +6256,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: @@ -4997,6 +6395,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: @@ -5078,6 +6483,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 @@ -5126,6 +6544,10 @@ packages: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} + eyes@0.1.8: + resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} + engines: {node: '> 0.1.90'} + fast-check@4.8.0: resolution: {integrity: sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==} engines: {node: '>=12.17.0'} @@ -5133,9 +6555,19 @@ 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-stable-stringify@1.0.0: + resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -5148,6 +6580,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'} @@ -5193,6 +6643,10 @@ packages: find-my-way-ts@0.1.6: resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + flattie@1.1.1: resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} engines: {node: '>=8'} @@ -5262,6 +6716,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'} @@ -5270,6 +6727,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'} @@ -5286,6 +6747,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'} @@ -5297,6 +6761,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} @@ -5448,6 +6919,9 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -5461,6 +6935,9 @@ 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==} @@ -5473,10 +6950,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'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -5488,6 +6972,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==} @@ -5540,13 +7037,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'} @@ -5559,6 +7073,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -5566,6 +7084,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'} @@ -5574,6 +7095,9 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isbinaryfile@4.0.10: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} @@ -5597,14 +7121,29 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} + isomorphic-ws@4.0.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + isows@1.0.7: + resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} + peerDependencies: + ws: '*' + jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} hasBin: true + jayson@4.3.0: + resolution: {integrity: sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==} + engines: {node: '>=8'} + hasBin: true + jest-get-type@29.6.3: resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5628,9 +7167,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==} @@ -5638,6 +7187,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==} @@ -5665,6 +7217,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'} @@ -5682,6 +7237,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==} @@ -5715,6 +7273,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==} @@ -5922,6 +7494,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} @@ -5942,6 +7518,9 @@ packages: 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==} @@ -5967,6 +7546,10 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + 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==} @@ -6070,9 +7653,17 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + 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} @@ -6254,6 +7845,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'} @@ -6341,6 +7936,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} @@ -6381,6 +7986,15 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-forge@1.4.0: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} @@ -6389,6 +8003,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-gyp@12.3.0: resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -6446,6 +8064,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==} @@ -6474,6 +8095,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==} @@ -6488,9 +8113,29 @@ 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==} + ox@0.14.27: + resolution: {integrity: sha512-+xhLHo/f+f4BH121/1Pomm/1vgBBda1wYiFpTvjSo8o5OcEj76Pf1hGPJiepoYMTQoTm2SKdSBvWkFWk5l07PA==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + ox@0.6.9: + resolution: {integrity: sha512-wi5ShvzE4eOcTwQVsIPdFr+8ycyX+5le/96iAJutaZAvCes1J0+RvpEPg5QDPDiaR0XQQAvZVl7AwqQcINuUug==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + oxfmt@0.52.0: resolution: {integrity: sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6525,6 +8170,10 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -6533,6 +8182,10 @@ packages: resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} engines: {node: '>=20'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-queue@9.3.0: resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} engines: {node: '>=20'} @@ -6541,9 +8194,16 @@ packages: resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} engines: {node: '>=20'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + 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==} @@ -6561,9 +8221,21 @@ 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-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -6598,6 +8270,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==} @@ -6642,6 +8364,10 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -6650,11 +8376,52 @@ 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==} + postject@1.0.0-alpha.6: resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} engines: {node: '>=14.0.0'} hasBin: true + preact@10.24.2: + resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} + + preact@10.29.2: + resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -6684,10 +8451,16 @@ packages: resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} engines: {node: ^20.17.0 || >=22.9.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-retry@2.0.1: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} @@ -6719,6 +8492,11 @@ packages: pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -6727,6 +8505,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==} @@ -6874,6 +8655,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: @@ -6896,6 +8682,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'} @@ -6942,6 +8734,9 @@ packages: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true + 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'} @@ -7034,6 +8829,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -7066,6 +8864,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==} @@ -7085,6 +8887,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'} + rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -7099,6 +8905,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} @@ -7113,6 +8924,15 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rpc-websockets@9.3.9: + resolution: {integrity: sha512-2iQDaTB4g5fDB2ihrTFSJSibCEuxaRi1q7qTW7ZO9/M5/TC+ToHA4D9/ffNLEbAoHNNrcdeP05oATNk44SKZXA==} + + 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==} @@ -7187,6 +9007,12 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -7264,6 +9090,10 @@ packages: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} + 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'} @@ -7298,9 +9128,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==} @@ -7311,6 +9153,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} @@ -7330,6 +9175,12 @@ packages: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -7341,6 +9192,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==} @@ -7352,10 +9214,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==} @@ -7369,6 +9238,10 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} + superstruct@2.0.2: + resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} + engines: {node: '>=14.0.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -7429,11 +9302,18 @@ 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'} hasBin: true + text-encoding-utf-8@1.0.2: + resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} @@ -7513,6 +9393,9 @@ packages: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -7575,10 +9458,17 @@ packages: resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} engines: {node: '>=18.17'} + 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'} @@ -7628,6 +9518,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'} @@ -7765,9 +9658,16 @@ 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'} + utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + 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'} @@ -7781,6 +9681,11 @@ packages: deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -7808,6 +9713,14 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + viem@2.52.0: + resolution: {integrity: sha512-py2QPYe9e1f4DmPJCsXF7zHmyZ0PkJrBxdQZ5dvNXvzy3UzWkUn7dNfC0TMeNm6Qv1tKw3b6qXXExpx6L0oMbw==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + vite-plus@0.1.24: resolution: {integrity: sha512-b3fr6WtCiEhetjuzW/4KcEMOAMuZxoxZATWaXKmPzOLf1upG+pzKJOFZTb94D6wiPBlwcjxoaUtF7C3uAN+VjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7942,6 +9855,13 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + 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==} @@ -7951,6 +9871,16 @@ 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'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -7970,6 +9900,15 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} 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'} @@ -7978,6 +9917,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==} @@ -7993,6 +9936,18 @@ packages: utf-8-validate: optional: true + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -8009,6 +9964,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'} @@ -8021,9 +9988,16 @@ 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==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -8047,6 +10021,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -8055,6 +10033,10 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -8078,6 +10060,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: @@ -8107,6 +10092,24 @@ packages: use-sync-external-store: optional: true + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -8121,6 +10124,15 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@adraffy/ens-normalize@1.11.1': {} + + '@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 @@ -8297,68 +10309,282 @@ snapshots: dependencies: yaml: 2.9.0 - '@babel/code-frame@7.29.7': + '@aws-crypto/crc32@5.2.0': dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.10 + tslib: 2.8.1 - '@babel/compat-data@7.29.7': {} + '@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 - '@babel/core@7.29.7': + '@aws-crypto/sha256-js@5.2.0': dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.10 + tslib: 2.8.1 - '@babel/generator@7.29.7': + '@aws-crypto/supports-web-crypto@5.2.0': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 + tslib: 2.8.1 - '@babel/helper-annotate-as-pure@7.29.7': + '@aws-crypto/util@5.2.0': dependencies: - '@babel/types': 7.29.7 + '@aws-sdk/types': 3.973.10 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 - '@babel/helper-compilation-targets@7.29.7': + '@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: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 + '@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 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + '@aws-sdk/credential-provider-cognito-identity@3.972.40': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color + '@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 - '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + '@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 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 @@ -8792,6 +11018,46 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@base-org/account@2.0.1(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': + dependencies: + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@6.0.3)(zod@4.4.3) + preact: 10.24.2 + viem: 2.52.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3) + zustand: 5.0.3(@types/react@19.2.16)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + + '@base-org/account@2.0.1(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(typescript@6.0.3)(use-sync-external-store@1.6.0(react@19.2.6))(utf-8-validate@6.0.6)(zod@4.4.3)': + dependencies: + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@6.0.3)(zod@4.4.3) + preact: 10.24.2 + viem: 2.52.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3) + zustand: 5.0.3(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + '@base-ui/react@1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.7 @@ -8815,20 +11081,31 @@ 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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -8836,11 +11113,263 @@ snapshots: 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(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bs58@6.0.0)(bufferutil@4.1.0)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3)(use-sync-external-store@1.6.0(react@19.2.6))(utf-8-validate@6.0.6)(zod@4.4.3)': + dependencies: + '@base-org/account': 2.0.1(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(typescript@6.0.3)(use-sync-external-store@1.6.0(react@19.2.6))(utf-8-validate@6.0.6)(zod@4.4.3) + '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@coinbase/wallet-sdk': 4.3.7(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3) + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) + '@solana/wallet-adapter-react': 0.15.39(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3) + '@solana/wallet-standard': 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)(react@19.2.6) + '@stripe/stripe-js': 5.6.0 + '@swc/helpers': 0.5.21 + '@tanstack/query-core': 5.100.14 + '@wallet-standard/core': 1.1.1 + '@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: + - '@solana/web3.js' + - '@types/react' + - bs58 + - bufferutil + - fastestsmallesttextencoderdecoder + - immer + - react + - react-dom + - react-native + - typescript + - use-sync-external-store + - utf-8-validate + - zod + + '@clerk/clerk-js@6.14.0(@types/react@19.2.16)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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: + '@base-org/account': 2.0.1(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@coinbase/wallet-sdk': 4.3.7(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3) + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) + '@solana/wallet-adapter-react': 0.15.39(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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) + '@solana/wallet-standard': 1.1.4(@solana/wallet-adapter-base@0.9.27)(react@19.2.3) + '@stripe/stripe-js': 5.6.0 + '@swc/helpers': 0.5.21 + '@tanstack/query-core': 5.100.14 + '@wallet-standard/core': 1.1.1 + '@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: + - '@solana/web3.js' + - '@types/react' + - bs58 + - bufferutil + - fastestsmallesttextencoderdecoder + - immer + - react + - react-dom + - react-native + - typescript + - use-sync-external-store + - utf-8-validate + - zod + + '@clerk/expo@3.3.1(@types/react@19.2.16)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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: + '@clerk/clerk-js': 6.14.0(@types/react@19.2.16)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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) + '@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + transitivePeerDependencies: + - '@solana/web3.js' + - '@types/react' + - bs58 + - bufferutil + - fastestsmallesttextencoderdecoder + - immer + - typescript + - use-sync-external-store + - utf-8-validate + - zod + + '@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.20260604.1': {} + + '@coinbase/wallet-sdk@4.3.7(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3)': + dependencies: + '@noble/hashes': 1.8.0 + clsx: 1.2.1 + eventemitter3: 5.0.4 + preact: 10.29.2 + viem: 2.52.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + '@develar/schema-utils@2.6.5': dependencies: ajv: 6.15.0 ajv-keywords: 3.5.2(ajv@6.15.0) + '@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: + '@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/core@0.22.4(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': + dependencies: + effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) + + '@distilled.cloud/neon@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/planetscale@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) + '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: react: 19.2.6 @@ -8873,6 +11402,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) @@ -8885,31 +11416,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 @@ -8918,6 +11449,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) @@ -8953,10 +11495,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: @@ -9096,81 +11638,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 @@ -9178,7 +11798,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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) @@ -9188,9 +11808,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(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(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 @@ -9202,7 +11822,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 @@ -9213,7 +11833,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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -9236,11 +11856,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(5bfdf39b8f760e4dc2d5c3acffc97310) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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' @@ -9302,18 +11922,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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: @@ -9374,16 +11994,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(expo@56.0.8)(typescript@6.0.3)': + '@expo/metro-config@56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(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 @@ -9391,7 +12011,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 @@ -9410,7 +12030,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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -9428,26 +12048,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -9456,7 +12076,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 @@ -9510,14 +12130,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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(5bfdf39b8f760e4dc2d5c3acffc97310) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -9532,18 +12152,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(961c4aa6f32829b318e3c87ef20ad401)': 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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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' @@ -9757,20 +12377,25 @@ 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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)': + '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) optionalDependencies: react-dom: 19.2.6(react@19.2.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) '@lexical/clipboard@0.41.0': dependencies: @@ -9931,6 +12556,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 + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 @@ -9966,39 +12649,132 @@ snapshots: transitivePeerDependencies: - supports-color - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': - optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@neon-rs/load@0.0.4': {} + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.4.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 - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': - optional: true + '@octokit/openapi-types@27.0.0': {} - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': - optional: true + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': - optional: true + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': - optional: true + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': - optional: true + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 - '@mswjs/interceptors@0.41.9': + '@octokit/request@10.0.10': dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.3 - strict-event-emitter: 0.5.1 + '@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 - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@octokit/rest@22.0.1': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true + '@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': {} @@ -10020,6 +12796,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': @@ -10387,15 +13165,27 @@ 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-async-storage/async-storage@1.24.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))': + dependencies: + merge-options: 3.0.4 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + optional: true + + '@react-native-async-storage/async-storage@1.24.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))': + dependencies: + merge-options: 3.0.4 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + + '@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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': {} @@ -10455,17 +13245,17 @@ 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))(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: - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -10481,7 +13271,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 @@ -10494,7 +13284,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 @@ -10513,11 +13303,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.3(@babel/core@7.29.7)': + '@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/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' @@ -10527,84 +13317,129 @@ snapshots: '@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 + + '@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(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 @@ -10615,6 +13450,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 @@ -10625,12 +13467,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 @@ -10732,6 +13580,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.61.0': optional: true + '@scure/base@1.2.6': {} + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@shikijs/core@3.23.0': dependencies: '@shikijs/types': 3.23.0 @@ -10787,32 +13648,428 @@ snapshots: dependencies: '@shikijs/types': 3.23.0 - '@shikijs/themes@4.1.0': + '@shikijs/themes@4.1.0': + dependencies: + '@shikijs/types': 4.1.0 + + '@shikijs/transformers@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/types@4.1.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@sinclair/typebox@0.27.10': {} + + '@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 + + '@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.8(@solana/web3.js@1.98.4(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@6.0.3)': + dependencies: + '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6) + bs58: 6.0.0 + js-base64: 3.7.8 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - react-native + - typescript + + '@solana-mobile/mobile-wallet-adapter-protocol-web3js@2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(typescript@6.0.3)': + dependencies: + '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(typescript@6.0.3) + bs58: 6.0.0 + js-base64: 3.7.8 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - react-native + - typescript + + '@solana-mobile/mobile-wallet-adapter-protocol@2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(typescript@6.0.3)': + dependencies: + '@solana/codecs-strings': 6.9.0(typescript@6.0.3) + '@solana/wallet-standard-features': 1.3.0 + '@solana/wallet-standard-util': 1.1.2 + '@wallet-standard/core': 1.1.1 + js-base64: 3.7.8 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana-mobile/mobile-wallet-adapter-protocol@2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@6.0.3)': + dependencies: + '@solana/codecs-strings': 6.9.0(typescript@6.0.3) + '@solana/wallet-standard-features': 1.3.0 + '@solana/wallet-standard-util': 1.1.2 + '@wallet-standard/core': 1.1.1 + js-base64: 3.7.8 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana-mobile/wallet-adapter-mobile@2.2.8(@solana/web3.js@1.98.4(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@6.0.3)': + dependencies: + '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana-mobile/mobile-wallet-adapter-protocol-web3js': 2.2.8(@solana/web3.js@1.98.4(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana-mobile/wallet-standard-mobile': 0.5.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) + '@solana/wallet-standard-features': 1.3.0 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@wallet-standard/core': 1.1.1 + bs58: 6.0.0 + js-base64: 3.7.8 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + tslib: 2.8.1 + optionalDependencies: + '@react-native-async-storage/async-storage': 1.24.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana-mobile/wallet-adapter-mobile@2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(typescript@6.0.3)': + dependencies: + '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana-mobile/mobile-wallet-adapter-protocol-web3js': 2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana-mobile/wallet-standard-mobile': 0.5.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) + '@solana/wallet-standard-features': 1.3.0 + '@wallet-standard/core': 1.1.1 + bs58: 6.0.0 + js-base64: 3.7.8 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + tslib: 2.8.1 + optionalDependencies: + '@react-native-async-storage/async-storage': 1.24.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana-mobile/wallet-standard-mobile@0.5.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(typescript@6.0.3)': + dependencies: + '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana/wallet-standard-chains': 1.1.1 + '@solana/wallet-standard-features': 1.3.0 + '@wallet-standard/base': 1.1.1 + '@wallet-standard/features': 1.1.1 + '@wallet-standard/wallet': 1.1.1 + bs58: 6.0.0 + js-base64: 3.7.8 + qrcode: 1.5.4 + tslib: 2.8.1 + optionalDependencies: + '@react-native-async-storage/async-storage': 1.24.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - react-native + - typescript + + '@solana-mobile/wallet-standard-mobile@0.5.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@6.0.3)': + dependencies: + '@solana-mobile/mobile-wallet-adapter-protocol': 2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana/wallet-standard-chains': 1.1.1 + '@solana/wallet-standard-features': 1.3.0 + '@wallet-standard/base': 1.1.1 + '@wallet-standard/features': 1.1.1 + '@wallet-standard/wallet': 1.1.1 + bs58: 6.0.0 + js-base64: 3.7.8 + qrcode: 1.5.4 + tslib: 2.8.1 + optionalDependencies: + '@react-native-async-storage/async-storage': 1.24.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - react-native + - typescript + + '@solana/buffer-layout@4.0.1': + dependencies: + buffer: 6.0.3 + + '@solana/codecs-core@2.3.0(typescript@6.0.3)': + dependencies: + '@solana/errors': 2.3.0(typescript@6.0.3) + typescript: 6.0.3 + + '@solana/codecs-core@6.9.0(typescript@6.0.3)': + dependencies: + '@solana/errors': 6.9.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/codecs-numbers@2.3.0(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 2.3.0(typescript@6.0.3) + '@solana/errors': 2.3.0(typescript@6.0.3) + typescript: 6.0.3 + + '@solana/codecs-numbers@6.9.0(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 6.9.0(typescript@6.0.3) + '@solana/errors': 6.9.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/codecs-strings@6.9.0(typescript@6.0.3)': + dependencies: + '@solana/codecs-core': 6.9.0(typescript@6.0.3) + '@solana/codecs-numbers': 6.9.0(typescript@6.0.3) + '@solana/errors': 6.9.0(typescript@6.0.3) + optionalDependencies: + typescript: 6.0.3 + + '@solana/errors@2.3.0(typescript@6.0.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + typescript: 6.0.3 + + '@solana/errors@6.9.0(typescript@6.0.3)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + optionalDependencies: + typescript: 6.0.3 + + '@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))': + dependencies: + '@solana/wallet-standard-features': 1.3.0 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@wallet-standard/base': 1.1.1 + '@wallet-standard/features': 1.1.1 + eventemitter3: 5.0.4 + + '@solana/wallet-adapter-react@0.15.39(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3)': + dependencies: + '@solana-mobile/wallet-adapter-mobile': 2.2.8(@solana/web3.js@1.98.4(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) + '@solana/wallet-standard-wallet-adapter-react': 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)(react@19.2.6) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6) + react: 19.2.6 + transitivePeerDependencies: + - bs58 + - fastestsmallesttextencoderdecoder + - react-native + - typescript + + '@solana/wallet-adapter-react@0.15.39(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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: + '@solana-mobile/wallet-adapter-mobile': 2.2.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(typescript@6.0.3) + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) + '@solana/wallet-standard-wallet-adapter-react': 1.1.4(@solana/wallet-adapter-base@0.9.27)(react@19.2.3) + react: 19.2.3 + transitivePeerDependencies: + - bs58 + - fastestsmallesttextencoderdecoder + - react-native + - typescript + + '@solana/wallet-standard-chains@1.1.1': + dependencies: + '@wallet-standard/base': 1.1.1 + + '@solana/wallet-standard-core@1.1.2': + dependencies: + '@solana/wallet-standard-chains': 1.1.1 + '@solana/wallet-standard-features': 1.3.0 + '@solana/wallet-standard-util': 1.1.2 + + '@solana/wallet-standard-features@1.3.0': + dependencies: + '@wallet-standard/base': 1.1.1 + '@wallet-standard/features': 1.1.1 + + '@solana/wallet-standard-util@1.1.2': + dependencies: + '@noble/curves': 1.9.7 + '@solana/wallet-standard-chains': 1.1.1 + '@solana/wallet-standard-features': 1.3.0 + + '@solana/wallet-standard-wallet-adapter-base@1.1.4(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)': + dependencies: + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) + '@solana/wallet-standard-chains': 1.1.1 + '@solana/wallet-standard-features': 1.3.0 + '@solana/wallet-standard-util': 1.1.2 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@wallet-standard/app': 1.1.1 + '@wallet-standard/base': 1.1.1 + '@wallet-standard/features': 1.1.1 + '@wallet-standard/wallet': 1.1.1 + bs58: 6.0.0 + + '@solana/wallet-standard-wallet-adapter-react@1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)(react@19.2.6)': + dependencies: + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) + '@solana/wallet-standard-wallet-adapter-base': 1.1.4(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0) + '@wallet-standard/app': 1.1.1 + '@wallet-standard/base': 1.1.1 + react: 19.2.6 + transitivePeerDependencies: + - '@solana/web3.js' + - bs58 + + '@solana/wallet-standard-wallet-adapter-react@1.1.4(@solana/wallet-adapter-base@0.9.27)(react@19.2.3)': + dependencies: + '@solana/wallet-adapter-base': 0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)) + '@solana/wallet-standard-wallet-adapter-base': 1.1.4(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0) + '@wallet-standard/app': 1.1.1 + '@wallet-standard/base': 1.1.1 + react: 19.2.3 + transitivePeerDependencies: + - '@solana/web3.js' + - bs58 + + '@solana/wallet-standard-wallet-adapter@1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)(react@19.2.6)': + dependencies: + '@solana/wallet-standard-wallet-adapter-base': 1.1.4(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0) + '@solana/wallet-standard-wallet-adapter-react': 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)(react@19.2.6) + transitivePeerDependencies: + - '@solana/wallet-adapter-base' + - '@solana/web3.js' + - bs58 + - react + + '@solana/wallet-standard-wallet-adapter@1.1.4(@solana/wallet-adapter-base@0.9.27)(react@19.2.3)': + dependencies: + '@solana/wallet-standard-wallet-adapter-base': 1.1.4(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0) + '@solana/wallet-standard-wallet-adapter-react': 1.1.4(@solana/wallet-adapter-base@0.9.27)(react@19.2.3) + transitivePeerDependencies: + - '@solana/wallet-adapter-base' + - '@solana/web3.js' + - bs58 + - react + + '@solana/wallet-standard@1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)(react@19.2.6)': dependencies: - '@shikijs/types': 4.1.0 + '@solana/wallet-standard-core': 1.1.2 + '@solana/wallet-standard-wallet-adapter': 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6))(bs58@6.0.0)(react@19.2.6) + transitivePeerDependencies: + - '@solana/wallet-adapter-base' + - '@solana/web3.js' + - bs58 + - react - '@shikijs/transformers@3.23.0': + '@solana/wallet-standard@1.1.4(@solana/wallet-adapter-base@0.9.27)(react@19.2.3)': dependencies: - '@shikijs/core': 3.23.0 - '@shikijs/types': 3.23.0 + '@solana/wallet-standard-core': 1.1.2 + '@solana/wallet-standard-wallet-adapter': 1.1.4(@solana/wallet-adapter-base@0.9.27)(react@19.2.3) + transitivePeerDependencies: + - '@solana/wallet-adapter-base' + - '@solana/web3.js' + - bs58 + - react - '@shikijs/types@3.23.0': + '@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@babel/runtime': 7.29.7 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@solana/buffer-layout': 4.0.1 + '@solana/codecs-numbers': 2.3.0(typescript@6.0.3) + agentkeepalive: 4.6.0 + bn.js: 5.2.3 + borsh: 0.7.0 + bs58: 4.0.1 + buffer: 6.0.3 + fast-stable-stringify: 1.0.0 + jayson: 4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + node-fetch: 2.7.0 + rpc-websockets: 9.3.9 + superstruct: 2.0.2 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate - '@shikijs/types@4.1.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@stablelib/base64@1.0.1': {} - '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} - '@sinclair/typebox@0.27.10': {} + '@stripe/stripe-js@5.6.0': {} - '@sindresorhus/is@4.6.0': {} + '@swc/helpers@0.5.21': + dependencies: + tslib: 2.8.1 - '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 '@szmarczak/http-timer@4.0.6': dependencies: @@ -11089,6 +14346,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 @@ -11126,6 +14385,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.4 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -11208,9 +14471,15 @@ snapshots: '@types/unist@3.0.3': {} + '@types/uuid@10.0.0': {} + '@types/verror@1.10.11': optional: true + '@types/ws@7.4.7': + dependencies: + '@types/node': 24.12.4 + '@types/ws@8.18.1': dependencies: '@types/node': 24.12.4 @@ -11329,7 +14598,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 @@ -11344,7 +14613,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: @@ -11425,6 +14694,33 @@ snapshots: '@vscode/l10n@0.0.18': {} + '@wallet-standard/app@1.1.1': + dependencies: + '@wallet-standard/base': 1.1.1 + + '@wallet-standard/base@1.1.1': {} + + '@wallet-standard/core@1.1.1': + dependencies: + '@wallet-standard/app': 1.1.1 + '@wallet-standard/base': 1.1.1 + '@wallet-standard/errors': 0.1.2 + '@wallet-standard/features': 1.1.1 + '@wallet-standard/wallet': 1.1.1 + + '@wallet-standard/errors@0.1.2': + dependencies: + chalk: 5.6.2 + commander: 13.1.0 + + '@wallet-standard/features@1.1.1': + dependencies: + '@wallet-standard/base': 1.1.1 + + '@wallet-standard/wallet@1.1.1': + dependencies: + '@wallet-standard/base': 1.1.1 + '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.10': {} @@ -11433,8 +14729,24 @@ 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': {} + abbrev@4.0.0: {} + abitype@1.2.3(typescript@6.0.3)(zod@4.4.3): + optionalDependencies: + typescript: 6.0.3 + zod: 4.4.3 + + abitype@1.2.4(typescript@6.0.3)(zod@4.4.3): + optionalDependencies: + typescript: 6.0.3 + zod: 4.4.3 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -11453,6 +14765,10 @@ snapshots: agent-base@7.1.4: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -11479,16 +14795,80 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + alchemy@2.0.0-beta.49(patch_hash=4d4f481bc380becaa0baa4cbc29660d804d94494b24ded1e40dcef2e91a706aa)(4aa68e39aff0906aebb4b8ef5e74bb2f): + 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.20260604.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 @@ -11499,6 +14879,8 @@ snapshots: ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + ansis@4.3.1: {} anymatch@3.1.3: @@ -11579,7 +14961,7 @@ snapshots: astral-regex@2.0.0: optional: true - 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 @@ -11629,7 +15011,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)) @@ -11687,6 +15069,12 @@ snapshots: at-least-node@1.0.0: {} + 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: @@ -11738,7 +15126,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 @@ -11785,11 +15173,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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(961c4aa6f32829b318e3c87ef20ad401) transitivePeerDependencies: - '@babel/core' - supports-color + badgin@1.2.3: {} + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -11802,12 +15193,24 @@ snapshots: transitivePeerDependencies: - '@types/emscripten' + base-64@1.0.0: {} + + base-x@3.0.11: + dependencies: + safe-buffer: 5.2.1 + + base-x@5.0.1: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.33: {} + before-after-hook@4.0.0: {} + big-integer@1.6.52: {} + bn.js@5.2.3: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -11827,6 +15230,14 @@ snapshots: boolean@3.2.0: optional: true + borsh@0.7.0: + dependencies: + bn.js: 5.2.3 + bs58: 4.0.1 + text-encoding-utf-8: 1.0.2 + + bowser@2.14.1: {} + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -11856,6 +15267,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 @@ -11864,6 +15279,14 @@ snapshots: node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) + bs58@4.0.1: + dependencies: + base-x: 3.0.11 + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + bser@2.1.1: dependencies: node-int64: 0.4.0 @@ -11876,6 +15299,15 @@ snapshots: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + + buffer@6.0.3: + 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: @@ -11934,10 +15366,16 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + camelcase@5.3.1: {} + camelcase@6.3.0: {} caniuse-lite@1.0.30001793: {} + capnweb@0.6.1: {} + + capnweb@0.7.0: {} + ccount@2.0.1: {} chalk@2.4.2: @@ -11951,6 +15389,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: {} @@ -12002,10 +15442,16 @@ 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@2.1.0: @@ -12014,10 +15460,21 @@ snapshots: string-width: 4.2.3 optional: true + 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: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -12030,10 +15487,16 @@ snapshots: clone@1.0.4: {} + clsx@1.2.1: {} + clsx@2.1.1: {} 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 @@ -12066,6 +15529,10 @@ snapshots: commander@12.1.0: {} + commander@13.1.0: {} + + commander@14.0.3: {} + commander@2.20.3: {} commander@5.1.0: {} @@ -12114,6 +15581,8 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + cookie-es@1.2.3: {} cookie-es@3.1.1: {} @@ -12128,8 +15597,9 @@ snapshots: dependencies: browserslist: 4.28.2 - core-util-is@1.0.2: - optional: true + core-js@3.47.0: {} + + core-util-is@1.0.2: {} cors@2.8.6: dependencies: @@ -12159,6 +15629,8 @@ snapshots: dependencies: uncrypto: 0.1.3 + crypto-js@4.2.0: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -12206,6 +15678,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -12240,6 +15714,8 @@ snapshots: defu@6.1.7: {} + delay@5.0.0: {} + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -12252,6 +15728,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@2.0.2: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -12267,6 +15745,8 @@ snapshots: diff@8.0.3: {} + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 @@ -12327,6 +15807,25 @@ snapshots: dotenv@16.6.1: {} + 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.20260604.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.20260604.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: @@ -12432,6 +15931,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: {} @@ -12453,6 +15954,8 @@ snapshots: env-paths@2.2.1: {} + environment@1.1.0: {} + err-code@2.0.3: {} error-stack-parser@2.1.4: @@ -12478,9 +15981,46 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + es-toolkit@1.47.0: {} + es6-error@4.1.1: optional: true + es6-promise@4.2.8: {} + + es6-promisify@5.0.0: + dependencies: + es6-promise: 4.2.8 + + 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 @@ -12516,6 +16056,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: {} @@ -12528,6 +16070,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.1: {} + eventemitter3@5.0.4: {} eventsource-parser@3.1.0: {} @@ -12536,133 +16080,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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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): @@ -12675,47 +16237,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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - 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-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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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: - react-native: 0.85.3(@babel/core@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/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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(5bfdf39b8f760e4dc2d5c3acffc97310): 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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(961c4aa6f32829b318e3c87ef20ad401) '@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -12723,18 +16299,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(0e9729601f58a7a7ae26c76fe6017455) + 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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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' @@ -12746,7 +16322,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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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: {} @@ -12754,7 +16330,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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -12762,20 +16338,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -12783,7 +16359,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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -12793,42 +16369,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + + expo-widgets@56.0.16(961c4aa6f32829b318e3c87ef20ad401): + dependencies: + '@expo/plist': 0.7.0 + '@expo/ui': 56.0.15(961c4aa6f32829b318e3c87ef20ad401) + 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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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' @@ -12896,14 +16492,28 @@ snapshots: extsprintf@1.4.1: optional: true + eyes@0.1.8: {} + fast-check@4.8.0: dependencies: pure-rand: 8.4.0 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: {} + fast-sha256@1.3.0: {} + + fast-stable-stringify@1.0.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -12916,6 +16526,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: @@ -12967,6 +16603,11 @@ snapshots: find-my-way-ts@0.1.6: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + flattie@1.1.1: {} flow-enums-runtime@0.0.6: {} @@ -13036,10 +16677,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 @@ -13064,6 +16711,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 @@ -13072,6 +16723,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 @@ -13326,6 +16983,10 @@ snapshots: transitivePeerDependencies: - supports-color + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + iconv-corefoundation@1.1.7: dependencies: cli-truncate: 2.1.0 @@ -13340,8 +17001,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: - optional: true + idb-keyval@6.2.1: {} + + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -13349,8 +17011,12 @@ snapshots: dependencies: queue: 6.0.2 + immediate@3.0.6: {} + indent-string@4.0.0: {} + indent-string@5.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -13360,6 +17026,41 @@ snapshots: 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: @@ -13405,10 +17106,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 @@ -13417,10 +17130,15 @@ snapshots: is-number@7.0.0: {} + is-plain-obj@2.1.0: + optional: true + is-plain-obj@4.1.0: {} is-promise@4.0.0: {} + is-property@1.0.2: {} + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 @@ -13429,6 +17147,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isbinaryfile@4.0.10: {} isbinaryfile@5.0.7: {} @@ -13441,14 +17161,40 @@ snapshots: isexe@4.0.0: {} + isomorphic-ws@4.0.1(ws@7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + dependencies: + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) + isomorphic.js@0.2.5: {} + isows@1.0.7(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + dependencies: + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + jake@10.9.4: dependencies: async: 3.2.6 filelist: 1.0.6 picocolors: 1.1.1 + jayson@4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.12.4 + '@types/ws': 7.4.7 + commander: 2.20.3 + delay: 5.0.0 + es6-promisify: 5.0.0 + eyes: 0.1.8 + isomorphic-ws: 4.0.1(ws@7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + json-stringify-safe: 5.0.1 + stream-json: 1.9.1 + uuid: 8.3.2 + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + jest-get-type@29.6.3: {} jest-util@29.7.0: @@ -13480,14 +17226,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: {} @@ -13505,8 +17259,9 @@ snapshots: json-schema-typed@8.0.2: {} - json-stringify-safe@5.0.1: - optional: true + json-stringify-safe@5.0.1: {} + + json-with-bigint@3.5.8: {} json5@2.2.3: {} @@ -13524,6 +17279,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 @@ -13546,6 +17308,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 @@ -13696,6 +17483,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash.debounce@4.0.8: {} lodash.escaperegexp@4.1.2: {} @@ -13710,6 +17501,8 @@ snapshots: dependencies: chalk: 2.4.2 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -13730,6 +17523,8 @@ snapshots: dependencies: yallist: 4.0.0 + lru.min@1.1.4: {} + lru_map@0.4.1: {} lucide-react@0.564.0(react@19.2.6): @@ -13934,8 +17729,15 @@ snapshots: merge-descriptors@2.0.0: {} + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + optional: true + merge-stream@2.0.0: {} + merge2@1.4.1: {} + metro-babel-transformer@0.84.4: dependencies: '@babel/core': 7.29.7 @@ -13959,12 +17761,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 @@ -14044,14 +17846,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 @@ -14064,7 +17866,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 @@ -14089,7 +17891,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 @@ -14097,13 +17899,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 @@ -14326,6 +18128,8 @@ snapshots: mimic-fn@1.2.0: {} + mimic-fn@2.1.0: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -14417,6 +18221,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: {} @@ -14446,6 +18266,10 @@ snapshots: node-fetch-native@1.6.7: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-forge@1.4.0: {} node-gyp-build-optional-packages@5.2.2: @@ -14453,6 +18277,9 @@ snapshots: detect-libc: 2.1.2 optional: true + node-gyp-build@4.8.4: + optional: true + node-gyp@12.3.0: dependencies: env-paths: 2.2.1 @@ -14508,6 +18335,8 @@ snapshots: object-keys@1.1.1: optional: true + obuf@1.1.2: {} + obug@2.1.1: {} ofetch@1.5.1: @@ -14536,6 +18365,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: @@ -14558,9 +18391,42 @@ 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)): + ox@0.14.27(typescript@6.0.3)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + + ox@0.6.9(typescript@6.0.3)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.4(typescript@6.0.3)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + + 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: @@ -14583,7 +18449,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: @@ -14594,7 +18460,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 @@ -14616,10 +18482,14 @@ 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: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -14628,6 +18498,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-queue@9.3.0: dependencies: eventemitter3: 5.0.4 @@ -14635,8 +18509,12 @@ snapshots: p-timeout@7.0.1: {} + p-try@2.2.0: {} + package-manager-detector@1.6.0: {} + pako@1.0.11: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -14666,8 +18544,14 @@ snapshots: parseurl@1.3.3: {} + patch-console@2.0.0: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} + + path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -14691,6 +18575,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: {} @@ -14727,6 +18664,8 @@ snapshots: pngjs@3.4.0: {} + pngjs@5.0.0: {} + pngjs@7.0.0: {} postcss@8.5.15: @@ -14735,11 +18674,37 @@ 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: {} + postject@1.0.0-alpha.6: dependencies: commander: 9.5.0 optional: true + preact@10.24.2: {} + + preact@10.29.2: {} + prettier@3.8.3: {} pretty-cache-header@1.0.0: @@ -14764,8 +18729,12 @@ snapshots: proc-log@6.1.0: {} + process-nextick-args@2.0.1: {} + progress@2.0.3: {} + promise-limit@2.7.0: {} + promise-retry@2.0.1: dependencies: err-code: 2.0.3 @@ -14802,6 +18771,12 @@ snapshots: pure-rand@8.4.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -14813,6 +18788,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 @@ -14830,10 +18807,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 @@ -14884,90 +18861,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(0e9729601f58a7a7ae26c76fe6017455): 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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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) @@ -14979,23 +18961,23 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) 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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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))(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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 @@ -15012,7 +18994,52 @@ 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 + semver: 7.8.1 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.17 + whatwg-fetch: 3.6.20 + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.16 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-plugin-syntax-hermes-parser: 0.33.3 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + hermes-compiler: 250829098.0.10 + invariant: 2.2.4 + memoize-one: 5.2.1 + metro-runtime: 0.84.4 + metro-source-map: 0.84.4 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.6 + 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 @@ -15020,7 +19047,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 @@ -15032,6 +19059,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): @@ -15071,6 +19103,16 @@ snapshots: transitivePeerDependencies: - supports-color + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.2 + 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: {} @@ -15193,6 +19235,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -15223,6 +19267,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 @@ -15252,6 +19301,8 @@ snapshots: rettime@0.10.1: {} + reusify@1.1.0: {} + rimraf@2.6.3: dependencies: glob: 7.2.3 @@ -15288,6 +19339,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 @@ -15351,6 +19423,25 @@ snapshots: transitivePeerDependencies: - supports-color + rpc-websockets@9.3.9: + dependencies: + '@swc/helpers': 0.5.23 + '@types/uuid': 10.0.0 + '@types/ws': 8.18.1 + buffer: 6.0.3 + eventemitter3: 5.0.4 + uuid: 14.0.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 + + 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: {} @@ -15441,6 +19532,10 @@ snapshots: server-only@0.0.1: {} + set-blocking@2.0.0: {} + + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} sf-symbols-typescript@2.2.0: {} @@ -15570,6 +19665,11 @@ snapshots: is-fullwidth-code-point: 3.0.0 optional: true + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + slugify@1.6.9: {} smart-buffer@4.2.0: @@ -15592,9 +19692,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: @@ -15603,6 +19711,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + stat-mode@1.0.0: {} statuses@1.5.0: {} @@ -15613,6 +19726,12 @@ snapshots: stream-buffers@2.2.0: {} + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + strict-event-emitter@0.5.1: {} strict-uri-encode@2.0.0: {} @@ -15623,6 +19742,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 @@ -15636,10 +19770,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: @@ -15656,6 +19796,8 @@ snapshots: transitivePeerDependencies: - supports-color + superstruct@2.0.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -15720,6 +19862,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 @@ -15727,6 +19871,8 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-encoding-utf-8@1.0.2: {} + throat@5.0.0: {} timestring@6.0.0: {} @@ -15784,6 +19930,8 @@ snapshots: dependencies: tldts: 7.4.2 + tr46@0.0.3: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -15831,8 +19979,14 @@ snapshots: undici@6.26.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: @@ -15902,18 +20056,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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@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: {} @@ -15929,7 +20085,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 @@ -15940,6 +20096,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: {} @@ -15981,14 +20139,23 @@ snapshots: dependencies: react: 19.2.6 + utf-8-validate@6.0.6: + dependencies: + node-gyp-build: 4.8.4 + optional: true + utf8-byte-length@1.0.5: {} + util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} uuid@14.0.0: {} uuid@7.0.3: {} + uuid@8.3.2: {} + validate-npm-package-name@5.0.1: {} vary@1.1.2: {} @@ -16024,14 +20191,31 @@ 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): + viem@2.52.0(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@6.0.6)(zod@4.4.3): + dependencies: + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@6.0.3)(zod@4.4.3) + isows: 1.0.7(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + ox: 0.14.27(typescript@6.0.3)(zod@4.4.3) + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + 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 @@ -16078,11 +20262,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) @@ -16198,12 +20382,29 @@ snapshots: web-namespaces@2.0.1: {} + webidl-conversions@3.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 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-module@2.0.1: {} + which-pm-runs@1.1.0: {} which@2.0.2: @@ -16218,6 +20419,18 @@ snapshots: dependencies: isexe: 4.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 @@ -16230,17 +20443,48 @@ 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.20.1(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 @@ -16250,8 +20494,12 @@ snapshots: xmlbuilder@15.1.1: {} + xtend@4.0.2: {} + xxhash-wasm@1.1.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -16276,10 +20524,29 @@ snapshots: yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 @@ -16305,6 +20572,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 @@ -16319,6 +20588,17 @@ snapshots: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) + zustand@5.0.3(@types/react@19.2.16)(react@19.2.3): + optionalDependencies: + '@types/react': 19.2.16 + react: 19.2.3 + + zustand@5.0.3(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): + optionalDependencies: + '@types/react': 19.2.16 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + zwitch@2.0.4: {} zxing-wasm@3.1.0(@types/emscripten@1.41.5): From ee87e7ff1bceb958b74eba1f43824c6951e6106c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 3 Jun 2026 16:35:39 -0700 Subject: [PATCH 55/61] chore(relay): migrate deployment workflow to vite plus Co-authored-by: codex --- .github/workflows/deploy-relay.yml | 15 ++++++--------- docs/relay-observability.md | 2 +- docs/release.md | 9 ++++----- docs/t3-cloud-clerk.md | 2 +- infra/relay/README.md | 23 +++++++++++------------ 5 files changed, 23 insertions(+), 28 deletions(-) diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 7df4d93362a..dd27fd2a79f 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -37,21 +37,18 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version-file: package.json - - - name: Setup Node - uses: actions/setup-node@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: bun install --frozen-lockfile + run: vp install --frozen-lockfile - name: Deploy production relay stage - run: bun --cwd infra/relay run deploy -- --stage prod --yes + 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 }} diff --git a/docs/relay-observability.md b/docs/relay-observability.md index 9a096853adf..dafad2155af 100644 --- a/docs/relay-observability.md +++ b/docs/relay-observability.md @@ -12,7 +12,7 @@ Alchemy stages append their sanitized stage name to isolate resources, for examp Deploy from `infra/relay` with the normal Alchemy workflow: ```sh -bun run deploy +vp run deploy ``` Alchemy resolves Axiom deployment credentials through its provider. At runtime, the Worker diff --git a/docs/release.md b/docs/release.md index 082fa08e037..a90c67ee60a 100644 --- a/docs/release.md +++ b/docs/release.md @@ -36,10 +36,9 @@ The relay is a shared control plane versioned separately from client releases. S 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`. It also -supports manual dispatch for retries. 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. +`.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: @@ -80,7 +79,7 @@ database. Local personal stages provision isolated branches from it and are neve Developers deploy personal stages locally rather than through pull-request automation: ```sh -bun --cwd infra/relay run deploy -- --stage "$USER" --env-file .env.local +vp run --filter t3code-relay deploy -- --stage "$USER" --env-file .env.local ``` ## Hosted web app release deployment diff --git a/docs/t3-cloud-clerk.md b/docs/t3-cloud-clerk.md index 10a05a7ee3b..96ade9a2133 100644 --- a/docs/t3-cloud-clerk.md +++ b/docs/t3-cloud-clerk.md @@ -37,7 +37,7 @@ When any client-facing public value is absent, cloud UI is omitted. 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. -`bun --cwd infra/relay run deploy` invokes Alchemy from the relay directory, so Alchemy loads +`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 diff --git a/infra/relay/README.md b/infra/relay/README.md index 26d57a275ac..823a1ffe526 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -52,24 +52,23 @@ calls live in Install dependencies from the repository root, then run relay-focused checks from this directory: ```sh -bun install +vp install cd infra/relay -bun run test -bun run typecheck +vp test run +vp run typecheck ``` To run a smaller test set while iterating: ```sh -bun run test src/environments/EnvironmentLinker.test.ts +vp test run src/environments/EnvironmentLinker.test.ts ``` Before considering a change complete, run the repository-wide checks from the root: ```sh -bun fmt -bun lint -bun typecheck +vp check +vp run typecheck ``` Backend changes should include tests. Prefer testing the real business logic with external @@ -80,7 +79,7 @@ dependencies represented at their boundary rather than mocking internal behavior The relay deploys through Alchemy: ```sh -bun --cwd infra/relay run deploy +vp run --filter t3code-relay deploy ``` The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database @@ -94,8 +93,8 @@ PlanetScale branch and runtime role for local development, so deploy `prod` befo developer stages: ```sh -bun --cwd infra/relay run deploy -- --stage prod -bun --cwd infra/relay run deploy -- --env-file .env.local +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 @@ -110,8 +109,8 @@ 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`, with a manual dispatch available for -retries. Stable and nightly release builds both resolve their static public config from the same +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`. From 3a81842415a278c783252dc2206411b9539b4e7f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 3 Jun 2026 16:46:21 -0700 Subject: [PATCH 56/61] fix(release): include relay in smoke lockfile fixture Co-authored-by: codex --- scripts/release-smoke.ts | 1 + 1 file changed, 1 insertion(+) 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", From 076cfe339d81b2efae5d2a4d967d894776629108 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 3 Jun 2026 16:53:52 -0700 Subject: [PATCH 57/61] fix(relay): run tests through vite plus Co-authored-by: codex --- infra/relay/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/relay/package.json b/infra/relay/package.json index 7706a89dfe6..5e1f2c1c903 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -5,7 +5,7 @@ "scripts": { "deploy": "node -- scripts/deploy.ts", "destroy": "alchemy destroy", - "test": "vitest run", + "test": "vp test run", "typecheck": "tsgo --noEmit" }, "dependencies": { From c6bd783e434624da1c7593273d3b724ff54be4b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 3 Jun 2026 17:01:13 -0700 Subject: [PATCH 58/61] feat(cloud): add headless cloud CLI control plane (#2905) Co-authored-by: codex --- .env.example | 3 +- .github/workflows/release.yml | 6 + .../desktop/src/ipc/methods/cloudAuth.test.ts | 52 +- apps/desktop/src/ipc/methods/cloudAuth.ts | 21 +- apps/desktop/src/window/DesktopWindow.test.ts | 80 +- apps/desktop/src/window/DesktopWindow.ts | 34 +- apps/desktop/tsconfig.json | 2 +- apps/desktop/vite.config.ts | 10 + apps/mobile/app.config.ts | 2 +- apps/server/src/bin.test.ts | 125 +- apps/server/src/bin.ts | 42 +- apps/server/src/cli/cloud.test.ts | 102 + apps/server/src/cli/cloud.ts | 425 ++++ apps/server/src/cloud/CliState.test.ts | 58 + apps/server/src/cloud/CliState.ts | 49 + apps/server/src/cloud/CliTokenManager.ts | 236 ++ apps/server/src/cloud/http.test.ts | 61 +- apps/server/src/cloud/http.ts | 332 ++- apps/server/src/cloud/publicConfig.test.ts | 85 + apps/server/src/cloud/publicConfig.ts | 110 + apps/server/src/server.test.ts | 24 + apps/server/src/server.ts | 34 +- apps/server/vite.config.ts | 11 + apps/web/src/cloud/desktopClerk.tsx | 40 +- .../desktopClerkExternalAccounts.test.ts | 78 + .../src/cloud/desktopClerkExternalAccounts.ts | 112 + docs/t3-cloud-clerk.md | 74 +- docs/t3-code-cloud-auth-flow.html | 2039 ++++++----------- packages/shared/src/relayAuth.test.ts | 26 + packages/shared/src/relayAuth.ts | 24 + scripts/lib/public-config.test.ts | 9 +- scripts/lib/public-config.ts | 7 + 32 files changed, 2835 insertions(+), 1478 deletions(-) create mode 100644 apps/server/src/cli/cloud.test.ts create mode 100644 apps/server/src/cli/cloud.ts create mode 100644 apps/server/src/cloud/CliState.test.ts create mode 100644 apps/server/src/cloud/CliState.ts create mode 100644 apps/server/src/cloud/CliTokenManager.ts create mode 100644 apps/server/src/cloud/publicConfig.test.ts create mode 100644 apps/server/src/cloud/publicConfig.ts create mode 100644 apps/web/src/cloud/desktopClerkExternalAccounts.test.ts create mode 100644 apps/web/src/cloud/desktopClerkExternalAccounts.ts create mode 100644 packages/shared/src/relayAuth.test.ts diff --git a/.env.example b/.env.example index 0a8ce8bfc1d..20fc3186b8f 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,10 @@ # 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 and JWT templates. +# 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/release.yml b/.github/workflows/release.yml index 150a26948b0..ec10d2c9dfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,11 +177,13 @@ jobs: 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 @@ -193,6 +195,7 @@ jobs: RELAY_DOMAIN CLERK_PUBLISHABLE_KEY CLERK_JWT_TEMPLATE + CLERK_CLI_OAUTH_CLIENT_ID ) missing=() for name in "${required[@]}"; do @@ -207,6 +210,7 @@ jobs: 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: @@ -218,6 +222,7 @@ jobs: 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 @@ -479,6 +484,7 @@ jobs: 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 diff --git a/apps/desktop/src/ipc/methods/cloudAuth.test.ts b/apps/desktop/src/ipc/methods/cloudAuth.test.ts index 51cefb9842c..74187715730 100644 --- a/apps/desktop/src/ipc/methods/cloudAuth.test.ts +++ b/apps/desktop/src/ipc/methods/cloudAuth.test.ts @@ -2,8 +2,14 @@ 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 } from "./cloudAuth.ts"; +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: ( @@ -17,6 +23,14 @@ function makeHttpClientLayer( } 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; @@ -52,4 +66,40 @@ describe("Desktop cloud auth IPC", () => { } }).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 index a3115da26bd..3c9ee264fc5 100644 --- a/apps/desktop/src/ipc/methods/cloudAuth.ts +++ b/apps/desktop/src/ipc/methods/cloudAuth.ts @@ -2,6 +2,10 @@ 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"; @@ -13,6 +17,8 @@ import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStor 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; @@ -22,10 +28,21 @@ export class DesktopCloudAuthFetchError extends Data.TaggedError("DesktopCloudAu } } +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 => - hostname.endsWith(".clerk.accounts.dev") || hostname.endsWith(".clerk.accounts.com"); + isAllowedClerkFrontendApiHostname(hostname, configuredClerkFrontendApiHostname()); -function validateClerkFrontendApiUrl(rawUrl: string): URL { +export function validateClerkFrontendApiUrl(rawUrl: string): URL { const url = new URL(rawUrl); if (url.protocol !== "https:" || !allowedClerkFrontendApiHosts(url.hostname)) { throw new DesktopCloudAuthFetchError({ 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 07b26a3054f..d42d2230946 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: { @@ -32,6 +40,7 @@ export default defineConfig({ outDir: "dist-electron", sourcemap: true, outExtensions: () => ({ js: ".cjs" }), + define: publicConfigDefine, entry: ["src/main.ts"], clean: true, deps: { @@ -44,6 +53,7 @@ export default defineConfig({ outDir: "dist-electron", sourcemap: true, outExtensions: () => ({ js: ".cjs" }), + define: publicConfigDefine, entry: ["src/preload.ts"], }, ], diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index c208bcd26a7..e6f6e3cfb63 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,6 +1,6 @@ import type { ExpoConfig } from "expo/config"; -import { loadRepoEnv } from "../../scripts/lib/public-config"; +import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; type AppVariant = "development" | "preview" | "production"; 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/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/http.test.ts b/apps/server/src/cloud/http.test.ts index c4e52f4967d..2b7e7d249ef 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -1,9 +1,19 @@ +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 { consumeCloudReplayGuards } from "./http.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({ @@ -58,3 +68,52 @@ describe("consumeCloudReplayGuards", () => { }), ); }); + +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 index ffadc86dfb1..38203dae37e 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -20,6 +20,8 @@ import { RelayEnvironmentHealthResponseProofPayload, type RelayEnvironmentHealthResponse as RelayEnvironmentHealthResponseShape, RelayEnvironmentConfigRequest, + RelayEnvironmentLinkChallengeResponse, + RelayEnvironmentLinkResponse, RelayEnvironmentMintResponseProofPayload, type RelayEnvironmentMintResponse as RelayEnvironmentMintResponseShape, RelayEnvironmentLinkProof, @@ -46,6 +48,7 @@ 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"; @@ -69,6 +72,9 @@ import { 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-"; @@ -95,6 +101,15 @@ const failEnvironmentCloudInternalError = 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); } @@ -322,59 +337,83 @@ interface CloudHttpDependencies { 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 keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); const requestUrl = requestAbsoluteUrl(httpRequest); - if ( - requestUrl === null || - hasForwardedAuthorityHeaders(httpRequest) || - !providerKindMatchesRequestedLinkScopes(request) || - !isAllowedEndpointOrigin({ - origin: request.origin, - requestUrl, - }) - ) { + if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { 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; - const proof = 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 proof = yield* makeCloudLinkProof(dependencies, request, requestUrl); yield* appendCloudCredentialResponseHeaders; return proof satisfies RelayEnvironmentLinkProof; }, @@ -389,51 +428,55 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( }), ); -const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( - function* (dependencies: CloudHttpDependencies, payload: RelayEnvironmentConfigRequest) { - yield* requireEnvironmentScope(AuthRelayWriteScope); - yield* validateRelayConfigPayload(payload); - yield* validateLinkedCloudUser({ - secrets: dependencies.secrets, - cloudUserId: payload.cloudUserId, +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* 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(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_MINT_PUBLIC_KEY, - stringToBytes(payload.cloudMintPublicKey), + CLOUD_ENDPOINT_RUNTIME_CONFIG, + stringToBytes(endpointRuntimeJson), ); - 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; + } 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), @@ -448,6 +491,121 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( }), ); +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, ) { @@ -498,6 +656,7 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( ], { concurrency: 7 }, ); + yield* CliState.setCliDesiredCloudLink(false); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, Effect.catchTag( @@ -769,12 +928,7 @@ export const cloudHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "cloud", Effect.fnUntraced(function* (handlers) { - const dependencies: CloudHttpDependencies = { - secrets: yield* ServerSecretStore.ServerSecretStore, - environment: yield* ServerEnvironment, - endpointRuntime: yield* CloudManagedEndpointRuntime, - environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, - }; + const dependencies = yield* cloudHttpDependencies; return handlers .handle("linkProof", ({ payload }) => cloudLinkProofHandler(dependencies, payload)) .handle("relayConfig", ({ payload }) => cloudRelayConfigHandler(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/server.test.ts b/apps/server/src/server.test.ts index 8b1f9972319..d061578ca68 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -132,6 +132,7 @@ 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"; @@ -361,6 +362,7 @@ const buildAppUnderTest = (options?: { repositoryIdentityResolver?: Partial; cloudManagedEndpointRuntime?: Partial; relayClient?: Partial; + cloudCliTokenManager?: Partial; }; }) => Effect.gen(function* () { @@ -778,6 +780,15 @@ const buildAppUnderTest = (options?: { }), ), ), + 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), @@ -2269,6 +2280,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).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 = []; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9dc2cd7d25e..98bef90bb2e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -45,6 +45,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma 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"; @@ -68,8 +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 } from "./cloud/http.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"; @@ -292,7 +295,12 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), - Layer.provideMerge(CloudManagedEndpointRuntimeLive), + Layer.provideMerge( + Layer.mergeAll( + CloudCliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), + CloudManagedEndpointRuntimeLive, + ), + ), ); const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( @@ -410,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, { @@ -418,6 +447,7 @@ export const makeServerLayer = Layer.unwrap( httpListeningLayer, runtimeStateLayer, tailscaleServeLayer, + cloudDesiredLinkReconcileLayer, ); return serverApplicationLayer.pipe( diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index ab62643a5e1..9c0b8cbb64b 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, @@ -30,6 +32,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/src/cloud/desktopClerk.tsx b/apps/web/src/cloud/desktopClerk.tsx index cf023a3dda3..68179f5cf03 100644 --- a/apps/web/src/cloud/desktopClerk.tsx +++ b/apps/web/src/cloud/desktopClerk.tsx @@ -5,8 +5,17 @@ import { 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 { @@ -54,6 +63,8 @@ interface DesktopClerkProviderProps { 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; @@ -82,7 +93,7 @@ const clearStoredClientJwt = (): Promise => const isClerkFrontendApiUrl = (url: URL): boolean => url.protocol === "https:" && - (url.hostname.endsWith(".clerk.accounts.dev") || url.hostname.endsWith(".clerk.accounts.com")); + isAllowedClerkFrontendApiHostname(url.hostname, desktopClerkFrontendApiHostname); const headersToRecord = (headers: Headers): Record => { const record: Record = {}; @@ -92,7 +103,8 @@ const headersToRecord = (headers: Headers): Record => { return record; }; -function installDesktopClerkFetchProxy(): void { +function installDesktopClerkFetchProxy(publishableKey: string): void { + desktopClerkFrontendApiHostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey); if (desktopClerkFetchInstalled) return; const bridge = window.desktopBridge; if (!bridge) return; @@ -125,6 +137,25 @@ function installDesktopClerkFetchProxy(): void { 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); @@ -187,11 +218,13 @@ function loadDesktopClerkUi(publishableKey: string): Promise } function getDesktopClerkInstance(publishableKey: string): Clerk { - installDesktopClerkFetchProxy(); + installDesktopClerkFetchProxy(publishableKey); const hasKeyChanged = desktopClerk !== null && desktopClerk.publishableKey !== publishableKey; if (hasKeyChanged) { void clearStoredClientJwt(); + desktopClerkExternalAccountCleanup?.(); + desktopClerkExternalAccountCleanup = null; desktopClerk = null; } @@ -200,6 +233,7 @@ function getDesktopClerkInstance(publishableKey: string): Clerk { } const nextClerk = new Clerk(publishableKey); + installDesktopClerkExternalAccounts(nextClerk); if (!isNativeRequestClerk(nextClerk)) { desktopClerk = nextClerk; return nextClerk; 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/docs/t3-cloud-clerk.md b/docs/t3-cloud-clerk.md index 96ade9a2133..09c92150585 100644 --- a/docs/t3-cloud-clerk.md +++ b/docs/t3-cloud-clerk.md @@ -12,6 +12,7 @@ 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 ``` @@ -25,14 +26,19 @@ Configuration precedence is: 2. Repository-root `.env.local`. 3. Repository-root `.env`. -The Clerk publishable key, JWT template name, and relay URL are public identifiers, not secrets. -Web, desktop, and mobile builds statically inject them 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`, and `T3CODE_RELAY_URL` before building. -EAS preview and production builds should define the same client-facing values in their EAS +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 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 @@ -45,7 +51,55 @@ 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 -preview or developer stage. +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 @@ -74,7 +128,11 @@ 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. +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. diff --git a/docs/t3-code-cloud-auth-flow.html b/docs/t3-code-cloud-auth-flow.html index c851ade3699..8b9f5ac40ab 100644 --- a/docs/t3-code-cloud-auth-flow.html +++ b/docs/t3-code-cloud-auth-flow.html @@ -3,7 +3,7 @@ - T3 Code Cloud Control Plane and Managed Endpoint Flow + T3 Code Cloud Architecture