| author | |
| committer | |
| log | 5ae77c0d63ece1a3e3fa5a70bc7717aa7c342756 |
| tree | 4896f566fd801e6f350aa1ba010e10c0b6584a2c |
| parent | 300f7fd11d61704617119634a99563e16c0e6913 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>339 files changed, 49266 insertions(+), 2353 deletions(-)
.env.example created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | # Optional: T3 Cloud source builds | ||
| 2 | # Leave these unset to disable optional T3 Cloud features in local source builds. | ||
| 3 | # Release builds inject their public values at build time. Do not add server-side | ||
| 4 | # secrets to this file. | ||
| 5 | |||
| 6 | # Get these from the Clerk Dashboard under API keys, JWT templates, and OAuth applications. | ||
| 7 | # T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... | ||
| 8 | # T3CODE_CLERK_JWT_TEMPLATE=t3-relay | ||
| 9 | # T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_... | ||
| 10 | |||
| 11 | # Get this from your relay deployment. `infra/relay` deploys update it automatically. | ||
| 12 | # T3CODE_RELAY_URL=https://relay.example.com | ||
.github/workflows/deploy-relay.yml created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | name: Deploy T3 Cloud relay | ||
| 2 | |||
| 3 | on: | ||
| 4 | push: | ||
| 5 | branches: | ||
| 6 | - main | ||
| 7 | |||
| 8 | permissions: | ||
| 9 | contents: read | ||
| 10 | id-token: none | ||
| 11 | |||
| 12 | concurrency: | ||
| 13 | group: relay-production | ||
| 14 | cancel-in-progress: false | ||
| 15 | |||
| 16 | jobs: | ||
| 17 | deploy_relay: | ||
| 18 | name: Deploy production relay | ||
| 19 | runs-on: blacksmith-8vcpu-ubuntu-2404 | ||
| 20 | timeout-minutes: 15 | ||
| 21 | environment: | ||
| 22 | name: production | ||
| 23 | env: | ||
| 24 | CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} | ||
| 25 | PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }} | ||
| 26 | AXIOM_ORG_ID: ${{ vars.AXIOM_ORG_ID }} | ||
| 27 | RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} | ||
| 28 | RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} | ||
| 29 | RELAY_TUNNEL_ZONE_NAME: ${{ vars.RELAY_TUNNEL_ZONE_NAME }} | ||
| 30 | CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} | ||
| 31 | CLERK_JWT_AUDIENCE: ${{ vars.CLERK_JWT_AUDIENCE }} | ||
| 32 | APNS_ENVIRONMENT: ${{ vars.APNS_ENVIRONMENT }} | ||
| 33 | APNS_TEAM_ID: ${{ vars.APNS_TEAM_ID }} | ||
| 34 | APNS_KEY_ID: ${{ vars.APNS_KEY_ID }} | ||
| 35 | APNS_BUNDLE_ID: ${{ vars.APNS_BUNDLE_ID }} | ||
| 36 | ALCHEMY_TELEMETRY_DISABLED: "1" | ||
| 37 | steps: | ||
| 38 | - name: Checkout | ||
| 39 | uses: actions/checkout@v6 | ||
| 40 | |||
| 41 | - name: Setup Vite+ | ||
| 42 | uses: voidzero-dev/setup-vp@v1 | ||
| 43 | with: | ||
| 44 | node-version-file: package.json | ||
| 45 | cache: true | ||
| 46 | run-install: false | ||
| 47 | |||
| 48 | - name: Install dependencies | ||
| 49 | run: vp install --frozen-lockfile | ||
| 50 | |||
| 51 | - name: Deploy production relay stage | ||
| 52 | run: vp run --filter t3code-relay deploy -- --stage prod --yes | ||
| 53 | env: | ||
| 54 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| 55 | PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} | ||
| 56 | PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} | ||
| 57 | AXIOM_TOKEN: ${{ secrets.AXIOM_TOKEN }} | ||
| 58 | CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} | ||
| 59 | APNS_PRIVATE_KEY: ${{ secrets.APNS_PRIVATE_KEY }} | ||
.github/workflows/release.yml+76-7| ... | @@ -166,12 +166,69 @@ jobs: | ... | @@ -166,12 +166,69 @@ jobs: |
| 166 | --current-tag "${{ steps.release_meta.outputs.tag }}" \ | 166 | --current-tag "${{ steps.release_meta.outputs.tag }}" \ |
| 167 | --github-output | 167 | --github-output |
| 168 | 168 | ||
| 169 | build: | 169 | relay_public_config: |
| 170 | name: Build ${{ matrix.label }} | 170 | name: Resolve T3 Cloud public config |
| 171 | needs: preflight | 171 | needs: preflight |
| 172 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} | 172 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} |
| 173 | runs-on: blacksmith-8vcpu-ubuntu-2404 | ||
| 174 | timeout-minutes: 5 | ||
| 175 | environment: | ||
| 176 | name: production | ||
| 177 | outputs: | ||
| 178 | clerk_publishable_key: ${{ steps.public_config.outputs.clerk_publishable_key }} | ||
| 179 | clerk_jwt_template: ${{ steps.public_config.outputs.clerk_jwt_template }} | ||
| 180 | clerk_cli_oauth_client_id: ${{ steps.public_config.outputs.clerk_cli_oauth_client_id }} | ||
| 181 | relay_url: ${{ steps.public_config.outputs.relay_url }} | ||
| 182 | env: | ||
| 183 | RELAY_DOMAIN: ${{ vars.RELAY_DOMAIN }} | ||
| 184 | RELAY_API_ZONE_NAME: ${{ vars.RELAY_API_ZONE_NAME }} | ||
| 185 | CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }} | ||
| 186 | CLERK_JWT_TEMPLATE: ${{ vars.CLERK_JWT_TEMPLATE }} | ||
| 187 | CLERK_CLI_OAUTH_CLIENT_ID: ${{ vars.CLERK_CLI_OAUTH_CLIENT_ID }} | ||
| 188 | steps: | ||
| 189 | - id: public_config | ||
| 190 | name: Resolve production relay public config | ||
| 191 | shell: bash | ||
| 192 | run: | | ||
| 193 | set -euo pipefail | ||
| 194 | |||
| 195 | relay_domain="${RELAY_DOMAIN:-}" | ||
| 196 | if [[ -z "$relay_domain" && -n "${RELAY_API_ZONE_NAME:-}" ]]; then | ||
| 197 | relay_domain="relay.$RELAY_API_ZONE_NAME" | ||
| 198 | fi | ||
| 199 | required=( | ||
| 200 | relay_domain | ||
| 201 | CLERK_PUBLISHABLE_KEY | ||
| 202 | CLERK_JWT_TEMPLATE | ||
| 203 | CLERK_CLI_OAUTH_CLIENT_ID | ||
| 204 | ) | ||
| 205 | missing=() | ||
| 206 | for name in "${required[@]}"; do | ||
| 207 | if [[ -z "${!name:-}" ]]; then | ||
| 208 | missing+=("$name") | ||
| 209 | fi | ||
| 210 | done | ||
| 211 | if (( ${#missing[@]} > 0 )); then | ||
| 212 | printf 'Missing required relay deployment configuration: %s\n' "${missing[*]}" >&2 | ||
| 213 | exit 1 | ||
| 214 | fi | ||
| 215 | |||
| 216 | echo "clerk_publishable_key=$CLERK_PUBLISHABLE_KEY" >> "$GITHUB_OUTPUT" | ||
| 217 | echo "clerk_jwt_template=$CLERK_JWT_TEMPLATE" >> "$GITHUB_OUTPUT" | ||
| 218 | echo "clerk_cli_oauth_client_id=$CLERK_CLI_OAUTH_CLIENT_ID" >> "$GITHUB_OUTPUT" | ||
| 219 | echo "relay_url=https://$relay_domain" >> "$GITHUB_OUTPUT" | ||
| 220 | |||
| 221 | build: | ||
| 222 | name: Build ${{ matrix.label }} | ||
| 223 | needs: [preflight, relay_public_config] | ||
| 224 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' }} | ||
| 173 | runs-on: ${{ matrix.runner }} | 225 | runs-on: ${{ matrix.runner }} |
| 174 | timeout-minutes: 30 | 226 | timeout-minutes: 30 |
| 227 | env: | ||
| 228 | T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} | ||
| 229 | T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} | ||
| 230 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} | ||
| 231 | T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} | ||
| 175 | strategy: | 232 | strategy: |
| 176 | fail-fast: false | 233 | fail-fast: false |
| 177 | matrix: | 234 | matrix: |
| ... | @@ -422,13 +479,18 @@ jobs: | ... | @@ -422,13 +479,18 @@ jobs: |
| 422 | 479 | ||
| 423 | publish_cli: | 480 | publish_cli: |
| 424 | name: Publish CLI to npm | 481 | name: Publish CLI to npm |
| 425 | needs: [preflight, build] | 482 | needs: [preflight, relay_public_config, build] |
| 426 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' }} | 483 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} |
| 427 | runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 | 484 | runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 |
| 428 | timeout-minutes: 10 | 485 | timeout-minutes: 10 |
| 429 | permissions: | 486 | permissions: |
| 430 | contents: read | 487 | contents: read |
| 431 | id-token: write | 488 | id-token: write |
| 489 | env: | ||
| 490 | T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} | ||
| 491 | T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} | ||
| 492 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} | ||
| 493 | T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} | ||
| 432 | steps: | 494 | steps: |
| 433 | - name: Checkout | 495 | - name: Checkout |
| 434 | uses: actions/checkout@v6 | 496 | uses: actions/checkout@v6 |
| ... | @@ -577,11 +639,14 @@ jobs: | ... | @@ -577,11 +639,14 @@ jobs: |
| 577 | 639 | ||
| 578 | deploy_web: | 640 | deploy_web: |
| 579 | name: Deploy hosted web app | 641 | name: Deploy hosted web app |
| 580 | needs: [preflight, release] | 642 | needs: [preflight, relay_public_config, release] |
| 581 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} | 643 | if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} |
| 582 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 644 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 583 | timeout-minutes: 10 | 645 | timeout-minutes: 10 |
| 584 | env: | 646 | env: |
| 647 | T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} | ||
| 648 | T3CODE_CLERK_JWT_TEMPLATE: ${{ needs.relay_public_config.outputs.clerk_jwt_template }} | ||
| 649 | T3CODE_RELAY_URL: ${{ needs.relay_public_config.outputs.relay_url }} | ||
| 585 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} | 650 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} |
| 586 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | 651 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} |
| 587 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | 652 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} |
| ... | @@ -648,6 +713,9 @@ jobs: | ... | @@ -648,6 +713,9 @@ jobs: |
| 648 | --token "$VERCEL_TOKEN" \ | 713 | --token "$VERCEL_TOKEN" \ |
| 649 | "${vercel_scope_args[@]}" \ | 714 | "${vercel_scope_args[@]}" \ |
| 650 | --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ | 715 | --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ |
| 716 | --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \ | ||
| 717 | --build-env "T3CODE_CLERK_JWT_TEMPLATE=${T3CODE_CLERK_JWT_TEMPLATE:-}" \ | ||
| 718 | --build-env "T3CODE_RELAY_URL=${T3CODE_RELAY_URL:-}" \ | ||
| 651 | --build-env "VITE_HOSTED_APP_URL=$router_url" \ | 719 | --build-env "VITE_HOSTED_APP_URL=$router_url" \ |
| 652 | --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" | 720 | --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" |
| 653 | )" | 721 | )" |
| ... | @@ -744,10 +812,11 @@ jobs: | ... | @@ -744,10 +812,11 @@ jobs: |
| 744 | if: | | 812 | if: | |
| 745 | always() && !cancelled() && | 813 | always() && !cancelled() && |
| 746 | needs.preflight.result == 'success' && | 814 | needs.preflight.result == 'success' && |
| 815 | needs.relay_public_config.result == 'success' && | ||
| 747 | needs.release.result == 'success' && | 816 | needs.release.result == 'success' && |
| 748 | needs.deploy_web.result == 'success' && | 817 | needs.deploy_web.result == 'success' && |
| 749 | (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') | 818 | (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') |
| 750 | needs: [preflight, release, deploy_web, finalize] | 819 | needs: [preflight, relay_public_config, release, deploy_web, finalize] |
| 751 | runs-on: blacksmith-8vcpu-ubuntu-2404 | 820 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 752 | timeout-minutes: 10 | 821 | timeout-minutes: 10 |
| 753 | steps: | 822 | steps: |
.gitignore+6| ... | @@ -5,6 +5,7 @@ node_modules | ... | @@ -5,6 +5,7 @@ node_modules |
| 5 | *.log | 5 | *.log |
| 6 | *.tsbuildinfo | 6 | *.tsbuildinfo |
| 7 | apps/*/dist | 7 | apps/*/dist |
| 8 | infra/*/dist | ||
| 8 | .astro | 9 | .astro |
| 9 | packages/*/dist | 10 | packages/*/dist |
| 10 | .env | 11 | .env |
| ... | @@ -26,3 +27,8 @@ squashfs-root/ | ... | @@ -26,3 +27,8 @@ squashfs-root/ |
| 26 | .gstack/ | 27 | .gstack/ |
| 27 | dist-electron/ | 28 | dist-electron/ |
| 28 | .electron-runtime/ | 29 | .electron-runtime/ |
| 30 | node_modules/ | ||
| 31 | .alchemy/ | ||
| 32 | *.log | ||
| 33 | .env* | ||
| 34 | !.env.example |
.vscode/settings.json+1| ... | @@ -6,6 +6,7 @@ | ... | @@ -6,6 +6,7 @@ |
| 6 | }, | 6 | }, |
| 7 | "oxc.unusedDisableDirectives": "warn", | 7 | "oxc.unusedDisableDirectives": "warn", |
| 8 | "js/ts.tsdk.path": "node_modules/typescript/lib", | 8 | "js/ts.tsdk.path": "node_modules/typescript/lib", |
| 9 | "typescript.native-preview.tsdk": "node_modules/@typescript/native-preview", | ||
| 9 | "typescript.preferences.autoImportFileExcludePatterns": [".repos/**"], | 10 | "typescript.preferences.autoImportFileExcludePatterns": [".repos/**"], |
| 10 | "javascript.preferences.autoImportFileExcludePatterns": [".repos/**"], | 11 | "javascript.preferences.autoImportFileExcludePatterns": [".repos/**"], |
| 11 | "files.watcherExclude": { | 12 | "files.watcherExclude": { |
AGENTS.md+2-16| ... | @@ -29,22 +29,8 @@ Long term maintainability is a core priority. If you add new functionality, firs | ... | @@ -29,22 +29,8 @@ Long term maintainability is a core priority. If you add new functionality, firs |
| 29 | - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. | 29 | - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. |
| 30 | - `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. | 30 | - `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. |
| 31 | - `packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. Keep this package schema-only — no runtime logic. | 31 | - `packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. Keep this package schema-only — no runtime logic. |
| 32 | - `packages/shared`: Shared runtime utilities consumed by both server and web. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. | 32 | - `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. |
| 33 | 33 | - `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile. | |
| 34 | ## Codex App Server (Important) | ||
| 35 | |||
| 36 | T3 Code is currently Codex-first. The server starts `codex app-server` (JSON-RPC over stdio) per provider session, then streams structured events to the browser through WebSocket push messages. | ||
| 37 | |||
| 38 | How we use it in this codebase: | ||
| 39 | |||
| 40 | - Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`. | ||
| 41 | - Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`. | ||
| 42 | - WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`. | ||
| 43 | - Web app consumes orchestration domain events via WebSocket push on channel `orchestration.domainEvent` (provider runtime activity is projected into orchestration events server-side). | ||
| 44 | |||
| 45 | Docs: | ||
| 46 | |||
| 47 | - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server | ||
| 48 | 34 | ||
| 49 | ## Reference Repos | 35 | ## Reference Repos |
| 50 | 36 |
README.md+8| ... | @@ -48,6 +48,10 @@ We are not accepting contributions yet. | ... | @@ -48,6 +48,10 @@ We are not accepting contributions yet. |
| 48 | 48 | ||
| 49 | Observability guide: [docs/observability.md](./docs/observability.md) | 49 | Observability guide: [docs/observability.md](./docs/observability.md) |
| 50 | 50 | ||
| 51 | Relay observability: [docs/relay-observability.md](./docs/relay-observability.md) | ||
| 52 | |||
| 53 | T3 Cloud Clerk setup: [docs/t3-cloud-clerk.md](./docs/t3-cloud-clerk.md) | ||
| 54 | |||
| 51 | ## If you REALLY want to contribute still.... read this first | 55 | ## If you REALLY want to contribute still.... read this first |
| 52 | 56 | ||
| 53 | Before local development, prepare the environment and install dependencies: | 57 | Before local development, prepare the environment and install dependencies: |
| ... | @@ -58,6 +62,10 @@ mise install | ... | @@ -58,6 +62,10 @@ mise install |
| 58 | vp install | 62 | vp install |
| 59 | ``` | 63 | ``` |
| 60 | 64 | ||
| 65 | T3 Cloud is optional and disabled in a fresh clone. To enable it for web, desktop, and mobile source | ||
| 66 | builds, copy [`.env.example`](./.env.example) to `.env` at the repository root and set the canonical | ||
| 67 | public configuration there. | ||
| 68 | |||
| 61 | Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. | 69 | Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. |
| 62 | 70 | ||
| 63 | Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). | 71 | Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). |
apps/desktop/scripts/dev-electron.mjs+20-10| ... | @@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process"; | ... | @@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process"; |
| 2 | import { watch } from "node:fs"; | 2 | import { watch } from "node:fs"; |
| 3 | import { join } from "node:path"; | 3 | import { join } from "node:path"; |
| 4 | 4 | ||
| 5 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; | 5 | import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs"; |
| 6 | import { waitForResources } from "./wait-for-resources.mjs"; | 6 | import { waitForResources } from "./wait-for-resources.mjs"; |
| 7 | 7 | ||
| 8 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); | 8 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); |
| ... | @@ -28,6 +28,7 @@ const watchedDirectories = [ | ... | @@ -28,6 +28,7 @@ const watchedDirectories = [ |
| 28 | const forcedShutdownTimeoutMs = 1_500; | 28 | const forcedShutdownTimeoutMs = 1_500; |
| 29 | const restartDebounceMs = 120; | 29 | const restartDebounceMs = 120; |
| 30 | const childTreeGracePeriodMs = 1_200; | 30 | const childTreeGracePeriodMs = 1_200; |
| 31 | const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); | ||
| 31 | 32 | ||
| 32 | await waitForResources({ | 33 | await waitForResources({ |
| 33 | baseDir: desktopDir, | 34 | baseDir: desktopDir, |
| ... | @@ -38,6 +39,11 @@ await waitForResources({ | ... | @@ -38,6 +39,11 @@ await waitForResources({ |
| 38 | 39 | ||
| 39 | const childEnv = { ...process.env }; | 40 | const childEnv = { ...process.env }; |
| 40 | delete childEnv.ELECTRON_RUN_AS_NODE; | 41 | delete childEnv.ELECTRON_RUN_AS_NODE; |
| 42 | const devProtocolClient = resolveDevProtocolClient(); | ||
| 43 | if (devProtocolClient) { | ||
| 44 | childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId; | ||
| 45 | childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1"; | ||
| 46 | } | ||
| 41 | 47 | ||
| 42 | let shuttingDown = false; | 48 | let shuttingDown = false; |
| 43 | let restartTimer = null; | 49 | let restartTimer = null; |
| ... | @@ -67,15 +73,17 @@ function startApp() { | ... | @@ -67,15 +73,17 @@ function startApp() { |
| 67 | return; | 73 | return; |
| 68 | } | 74 | } |
| 69 | 75 | ||
| 70 | const app = spawn( | 76 | const electronArgs = remoteDebuggingPort |
| 71 | resolveElectronPath(), | 77 | ? [`--remote-debugging-port=${remoteDebuggingPort}`] |
| 72 | [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"], | 78 | : []; |
| 73 | { | 79 | const launchArgs = devProtocolClient |
| 74 | cwd: desktopDir, | 80 | ? electronArgs |
| 75 | env: childEnv, | 81 | : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; |
| 76 | stdio: "inherit", | 82 | const app = spawn(resolveElectronPath(), launchArgs, { |
| 77 | }, | 83 | cwd: desktopDir, |
| 78 | ); | 84 | env: childEnv, |
| 85 | stdio: "inherit", | ||
| 86 | }); | ||
| 79 | 87 | ||
| 80 | currentApp = app; | 88 | currentApp = app; |
| 81 | 89 | ||
| ... | @@ -125,6 +133,7 @@ async function stopApp() { | ... | @@ -125,6 +133,7 @@ async function stopApp() { |
| 125 | app.once("exit", finish); | 133 | app.once("exit", finish); |
| 126 | app.kill("SIGTERM"); | 134 | app.kill("SIGTERM"); |
| 127 | killChildTreeByPid(app.pid, "TERM"); | 135 | killChildTreeByPid(app.pid, "TERM"); |
| 136 | cleanupStaleDevApps(); | ||
| 128 | 137 | ||
| 129 | setTimeout(() => { | 138 | setTimeout(() => { |
| 130 | if (settled) { | 139 | if (settled) { |
| ... | @@ -133,6 +142,7 @@ async function stopApp() { | ... | @@ -133,6 +142,7 @@ async function stopApp() { |
| 133 | 142 | ||
| 134 | app.kill("SIGKILL"); | 143 | app.kill("SIGKILL"); |
| 135 | killChildTreeByPid(app.pid, "KILL"); | 144 | killChildTreeByPid(app.pid, "KILL"); |
| 145 | cleanupStaleDevApps(); | ||
| 136 | finish(); | 146 | finish(); |
| 137 | }, forcedShutdownTimeoutMs).unref(); | 147 | }, forcedShutdownTimeoutMs).unref(); |
| 138 | }); | 148 | }); |
apps/desktop/scripts/electron-launcher.mjs+162-10| ... | @@ -3,6 +3,7 @@ | ... | @@ -3,6 +3,7 @@ |
| 3 | import { spawnSync } from "node:child_process"; | 3 | import { spawnSync } from "node:child_process"; |
| 4 | import { | 4 | import { |
| 5 | copyFileSync, | 5 | copyFileSync, |
| 6 | chmodSync, | ||
| 6 | cpSync, | 7 | cpSync, |
| 7 | existsSync, | 8 | existsSync, |
| 8 | mkdirSync, | 9 | mkdirSync, |
| ... | @@ -13,21 +14,34 @@ import { | ... | @@ -13,21 +14,34 @@ import { |
| 13 | writeFileSync, | 14 | writeFileSync, |
| 14 | } from "node:fs"; | 15 | } from "node:fs"; |
| 15 | import { createRequire } from "node:module"; | 16 | import { createRequire } from "node:module"; |
| 16 | import { dirname, join, resolve } from "node:path"; | 17 | import { basename, dirname, join, resolve } from "node:path"; |
| 17 | import { fileURLToPath } from "node:url"; | 18 | import { fileURLToPath } from "node:url"; |
| 18 | import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; | 19 | import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs"; |
| 19 | 20 | ||
| 20 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | 21 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 21 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | ||
| 22 | const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; | ||
| 23 | const LAUNCHER_VERSION = 2; | ||
| 24 | |||
| 25 | const __dirname = dirname(fileURLToPath(import.meta.url)); | 22 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 26 | export const desktopDir = resolve(__dirname, ".."); | 23 | export const desktopDir = resolve(__dirname, ".."); |
| 27 | const repoRoot = resolve(desktopDir, "..", ".."); | 24 | const repoRoot = resolve(desktopDir, "..", ".."); |
| 25 | const devBundleIdSuffix = basename(repoRoot) | ||
| 26 | .toLowerCase() | ||
| 27 | .replaceAll(/[^a-z0-9]+/g, ""); | ||
| 28 | export const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | ||
| 29 | export const APP_BUNDLE_ID = isDevelopment | ||
| 30 | ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` | ||
| 31 | : "com.t3tools.t3code"; | ||
| 32 | const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; | ||
| 33 | const LAUNCHER_VERSION = 10; | ||
| 28 | const defaultIconPath = join(desktopDir, "resources", "icon.icns"); | 34 | const defaultIconPath = join(desktopDir, "resources", "icon.icns"); |
| 29 | const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); | 35 | const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); |
| 30 | 36 | ||
| 37 | function resolveDevelopmentProtocolCallbackPort() { | ||
| 38 | const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10); | ||
| 39 | if (Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort < 65535) { | ||
| 40 | return configuredPort + 1; | ||
| 41 | } | ||
| 42 | return 13774; | ||
| 43 | } | ||
| 44 | |||
| 31 | function setPlistString(plistPath, key, value) { | 45 | function setPlistString(plistPath, key, value) { |
| 32 | const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { | 46 | const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { |
| 33 | encoding: "utf8", | 47 | encoding: "utf8", |
| ... | @@ -47,6 +61,26 @@ function setPlistString(plistPath, key, value) { | ... | @@ -47,6 +61,26 @@ function setPlistString(plistPath, key, value) { |
| 47 | throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); | 61 | throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); |
| 48 | } | 62 | } |
| 49 | 63 | ||
| 64 | function setPlistJson(plistPath, key, value) { | ||
| 65 | const serialized = JSON.stringify(value); | ||
| 66 | const replaceResult = spawnSync("plutil", ["-replace", key, "-json", serialized, plistPath], { | ||
| 67 | encoding: "utf8", | ||
| 68 | }); | ||
| 69 | if (replaceResult.status === 0) { | ||
| 70 | return; | ||
| 71 | } | ||
| 72 | |||
| 73 | const insertResult = spawnSync("plutil", ["-insert", key, "-json", serialized, plistPath], { | ||
| 74 | encoding: "utf8", | ||
| 75 | }); | ||
| 76 | if (insertResult.status === 0) { | ||
| 77 | return; | ||
| 78 | } | ||
| 79 | |||
| 80 | const details = [replaceResult.stderr, insertResult.stderr].filter(Boolean).join("\n"); | ||
| 81 | throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); | ||
| 82 | } | ||
| 83 | |||
| 50 | function runChecked(command, args) { | 84 | function runChecked(command, args) { |
| 51 | const result = spawnSync(command, args, { encoding: "utf8" }); | 85 | const result = spawnSync(command, args, { encoding: "utf8" }); |
| 52 | if (result.status === 0) { | 86 | if (result.status === 0) { |
| ... | @@ -57,6 +91,71 @@ function runChecked(command, args) { | ... | @@ -57,6 +91,71 @@ function runChecked(command, args) { |
| 57 | throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); | 91 | throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); |
| 58 | } | 92 | } |
| 59 | 93 | ||
| 94 | function shellSingleQuote(value) { | ||
| 95 | return `'${value.replaceAll("'", "'\\''")}'`; | ||
| 96 | } | ||
| 97 | |||
| 98 | function writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath) { | ||
| 99 | const mainEntryPath = join(desktopDir, "dist-electron", "main.cjs"); | ||
| 100 | const protocolCallbackUrl = `http://127.0.0.1:${resolveDevelopmentProtocolCallbackPort()}/auth/callback`; | ||
| 101 | const envEntries = [ | ||
| 102 | ["VITE_DEV_SERVER_URL", process.env.VITE_DEV_SERVER_URL], | ||
| 103 | ["T3CODE_PORT", process.env.T3CODE_PORT], | ||
| 104 | ["T3CODE_HOME", process.env.T3CODE_HOME], | ||
| 105 | ["T3CODE_COMMIT_HASH", process.env.T3CODE_COMMIT_HASH], | ||
| 106 | ["T3CODE_OTLP_TRACES_URL", process.env.T3CODE_OTLP_TRACES_URL], | ||
| 107 | ["T3CODE_OTLP_EXPORT_INTERVAL_MS", process.env.T3CODE_OTLP_EXPORT_INTERVAL_MS], | ||
| 108 | ["T3CODE_DESKTOP_APP_USER_MODEL_ID", APP_BUNDLE_ID], | ||
| 109 | ["T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED", "1"], | ||
| 110 | ["T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL", protocolCallbackUrl], | ||
| 111 | ].filter((entry) => typeof entry[1] === "string" && entry[1].trim().length > 0); | ||
| 112 | writeFileSync( | ||
| 113 | targetBinaryPath, | ||
| 114 | [ | ||
| 115 | "#!/bin/sh", | ||
| 116 | ...envEntries.map(([name, value]) => `export ${name}=${shellSingleQuote(value)}`), | ||
| 117 | 'for arg in "$@"; do', | ||
| 118 | ' case "$arg" in', | ||
| 119 | " t3code-dev://auth/callback*)", | ||
| 120 | ' if [ -n "$T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL" ]; then', | ||
| 121 | ' /usr/bin/curl -fsS --max-time 2 -X POST --data-binary "$arg" "$T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL" >/dev/null 2>&1 && exit 0', | ||
| 122 | " fi", | ||
| 123 | " ;;", | ||
| 124 | " esac", | ||
| 125 | "done", | ||
| 126 | `exec ${shellSingleQuote(electronBinaryPath)} --t3code-dev-root=${shellSingleQuote(desktopDir)} ${shellSingleQuote(mainEntryPath)} "$@"`, | ||
| 127 | "", | ||
| 128 | ].join("\n"), | ||
| 129 | ); | ||
| 130 | chmodSync(targetBinaryPath, 0o755); | ||
| 131 | } | ||
| 132 | |||
| 133 | function registerMacLauncherBundle(appBundlePath) { | ||
| 134 | runChecked( | ||
| 135 | "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", | ||
| 136 | ["-f", appBundlePath], | ||
| 137 | ); | ||
| 138 | |||
| 139 | if (!isDevelopment) { | ||
| 140 | return; | ||
| 141 | } | ||
| 142 | |||
| 143 | for (const scheme of APP_PROTOCOL_SCHEMES) { | ||
| 144 | runChecked("osascript", [ | ||
| 145 | "-l", | ||
| 146 | "JavaScript", | ||
| 147 | "-e", | ||
| 148 | [ | ||
| 149 | 'ObjC.import("CoreServices");', | ||
| 150 | `const scheme = $.NSString.alloc.initWithUTF8String(${JSON.stringify(scheme)});`, | ||
| 151 | `const bundle = $.NSString.alloc.initWithUTF8String(${JSON.stringify(APP_BUNDLE_ID)});`, | ||
| 152 | "const status = $.LSSetDefaultHandlerForURLScheme(scheme, bundle);", | ||
| 153 | "if (status !== 0) throw new Error(`LSSetDefaultHandlerForURLScheme failed: ${status}`);", | ||
| 154 | ].join(" "), | ||
| 155 | ]); | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 60 | function ensureDevelopmentIconIcns(runtimeDir) { | 159 | function ensureDevelopmentIconIcns(runtimeDir) { |
| 61 | const generatedIconPath = join(runtimeDir, "icon-dev.icns"); | 160 | const generatedIconPath = join(runtimeDir, "icon-dev.icns"); |
| 62 | mkdirSync(runtimeDir, { recursive: true }); | 161 | mkdirSync(runtimeDir, { recursive: true }); |
| ... | @@ -115,12 +214,49 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { | ... | @@ -115,12 +214,49 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { |
| 115 | setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); | 214 | setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); |
| 116 | setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); | 215 | setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); |
| 117 | setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); | 216 | setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); |
| 217 | setPlistJson(infoPlistPath, "CFBundleURLTypes", [ | ||
| 218 | { | ||
| 219 | CFBundleURLName: APP_BUNDLE_ID, | ||
| 220 | CFBundleURLSchemes: APP_PROTOCOL_SCHEMES, | ||
| 221 | }, | ||
| 222 | ]); | ||
| 118 | 223 | ||
| 119 | const resourcesDir = join(appBundlePath, "Contents", "Resources"); | 224 | const resourcesDir = join(appBundlePath, "Contents", "Resources"); |
| 120 | copyFileSync(iconPath, join(resourcesDir, "icon.icns")); | 225 | copyFileSync(iconPath, join(resourcesDir, "icon.icns")); |
| 121 | copyFileSync(iconPath, join(resourcesDir, "electron.icns")); | 226 | copyFileSync(iconPath, join(resourcesDir, "electron.icns")); |
| 122 | } | 227 | } |
| 123 | 228 | ||
| 229 | function patchHelperBundleInfoPlists(appBundlePath) { | ||
| 230 | const helperBundleNames = [ | ||
| 231 | ["Electron Helper.app", "helper", `${APP_DISPLAY_NAME} Helper`], | ||
| 232 | ["Electron Helper (GPU).app", "helper.gpu", `${APP_DISPLAY_NAME} Helper (GPU)`], | ||
| 233 | ["Electron Helper (Plugin).app", "helper.plugin", `${APP_DISPLAY_NAME} Helper (Plugin)`], | ||
| 234 | ["Electron Helper (Renderer).app", "helper.renderer", `${APP_DISPLAY_NAME} Helper (Renderer)`], | ||
| 235 | ]; | ||
| 236 | |||
| 237 | for (const [bundleName, bundleIdentifierSuffix, bundleDisplayName] of helperBundleNames) { | ||
| 238 | const infoPlistPath = join( | ||
| 239 | appBundlePath, | ||
| 240 | "Contents", | ||
| 241 | "Frameworks", | ||
| 242 | bundleName, | ||
| 243 | "Contents", | ||
| 244 | "Info.plist", | ||
| 245 | ); | ||
| 246 | if (!existsSync(infoPlistPath)) { | ||
| 247 | continue; | ||
| 248 | } | ||
| 249 | |||
| 250 | setPlistString(infoPlistPath, "CFBundleDisplayName", bundleDisplayName); | ||
| 251 | setPlistString(infoPlistPath, "CFBundleName", bundleDisplayName); | ||
| 252 | setPlistString( | ||
| 253 | infoPlistPath, | ||
| 254 | "CFBundleIdentifier", | ||
| 255 | `${APP_BUNDLE_ID}.${bundleIdentifierSuffix}`, | ||
| 256 | ); | ||
| 257 | } | ||
| 258 | } | ||
| 259 | |||
| 124 | function readJson(path) { | 260 | function readJson(path) { |
| 125 | try { | 261 | try { |
| 126 | return JSON.parse(readFileSync(path, "utf8")); | 262 | return JSON.parse(readFileSync(path, "utf8")); |
| ... | @@ -144,6 +280,8 @@ function buildMacLauncher(electronBinaryPath) { | ... | @@ -144,6 +280,8 @@ function buildMacLauncher(electronBinaryPath) { |
| 144 | sourceAppBundlePath, | 280 | sourceAppBundlePath, |
| 145 | sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, | 281 | sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, |
| 146 | iconMtimeMs: statSync(iconPath).mtimeMs, | 282 | iconMtimeMs: statSync(iconPath).mtimeMs, |
| 283 | appBundleId: APP_BUNDLE_ID, | ||
| 284 | appProtocolSchemes: APP_PROTOCOL_SCHEMES, | ||
| 147 | }; | 285 | }; |
| 148 | 286 | ||
| 149 | const currentMetadata = readJson(metadataPath); | 287 | const currentMetadata = readJson(metadataPath); |
| ... | @@ -152,13 +290,19 @@ function buildMacLauncher(electronBinaryPath) { | ... | @@ -152,13 +290,19 @@ function buildMacLauncher(electronBinaryPath) { |
| 152 | currentMetadata && | 290 | currentMetadata && |
| 153 | JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) | 291 | JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) |
| 154 | ) { | 292 | ) { |
| 293 | registerMacLauncherBundle(targetAppBundlePath); | ||
| 155 | return targetBinaryPath; | 294 | return targetBinaryPath; |
| 156 | } | 295 | } |
| 157 | 296 | ||
| 158 | rmSync(targetAppBundlePath, { recursive: true, force: true }); | 297 | rmSync(targetAppBundlePath, { recursive: true, force: true }); |
| 159 | cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); | 298 | cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); |
| 160 | patchMainBundleInfoPlist(targetAppBundlePath, iconPath); | 299 | patchMainBundleInfoPlist(targetAppBundlePath, iconPath); |
| 300 | patchHelperBundleInfoPlists(targetAppBundlePath); | ||
| 301 | if (isDevelopment) { | ||
| 302 | writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); | ||
| 303 | } | ||
| 161 | writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); | 304 | writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); |
| 305 | registerMacLauncherBundle(targetAppBundlePath); | ||
| 162 | 306 | ||
| 163 | return targetBinaryPath; | 307 | return targetBinaryPath; |
| 164 | } | 308 | } |
| ... | @@ -173,11 +317,19 @@ export function resolveElectronPath() { | ... | @@ -173,11 +317,19 @@ export function resolveElectronPath() { |
| 173 | return electronBinaryPath; | 317 | return electronBinaryPath; |
| 174 | } | 318 | } |
| 175 | 319 | ||
| 176 | // Dev launches do not need a renamed app bundle badly enough to risk breaking | 320 | return buildMacLauncher(electronBinaryPath); |
| 177 | // Electron helper resource lookup on macOS. | 321 | } |
| 178 | if (isDevelopment) { | 322 | |
| 179 | return electronBinaryPath; | 323 | export function resolveDevProtocolClient() { |
| 324 | if (process.platform !== "darwin" || !isDevelopment) { | ||
| 325 | return null; | ||
| 180 | } | 326 | } |
| 181 | 327 | ||
| 182 | return buildMacLauncher(electronBinaryPath); | 328 | const require = createRequire(import.meta.url); |
| 329 | const electronBinaryPath = require("electron"); | ||
| 330 | const launcherBinaryPath = buildMacLauncher(electronBinaryPath); | ||
| 331 | return { | ||
| 332 | appBundlePath: resolve(launcherBinaryPath, "..", "..", ".."), | ||
| 333 | appBundleId: APP_BUNDLE_ID, | ||
| 334 | }; | ||
| 183 | } | 335 | } |
apps/desktop/src/app/DesktopApp.ts+4-1| ... | @@ -1,16 +1,17 @@ | ... | @@ -1,16 +1,17 @@ |
| 1 | import * as Cause from "effect/Cause"; | 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Crypto from "effect/Crypto"; | ||
| 3 | import * as Data from "effect/Data"; | 2 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 5 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 6 | import * as Ref from "effect/Ref"; | 5 | import * as Ref from "effect/Ref"; |
| 7 | 6 | ||
| 8 | import * as NetService from "@t3tools/shared/Net"; | 7 | import * as NetService from "@t3tools/shared/Net"; |
| 8 | import * as Crypto from "effect/Crypto"; | ||
| 9 | import * as ElectronApp from "../electron/ElectronApp.ts"; | 9 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 10 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; | 10 | import * as ElectronDialog from "../electron/ElectronDialog.ts"; |
| 11 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; | 11 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; |
| 12 | import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; | 12 | import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; |
| 13 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; | 13 | import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; |
| 14 | import * as DesktopCloudAuth from "./DesktopCloudAuth.ts"; | ||
| 14 | import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; | 15 | import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; |
| 15 | import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; | 16 | import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; |
| 16 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 17 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| ... | @@ -190,6 +191,7 @@ const startup = Effect.gen(function* () { | ... | @@ -190,6 +191,7 @@ const startup = Effect.gen(function* () { |
| 190 | const electronApp = yield* ElectronApp.ElectronApp; | 191 | const electronApp = yield* ElectronApp.ElectronApp; |
| 191 | const electronProtocol = yield* ElectronProtocol.ElectronProtocol; | 192 | const electronProtocol = yield* ElectronProtocol.ElectronProtocol; |
| 192 | const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; | 193 | const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; |
| 194 | const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; | ||
| 193 | const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; | 195 | const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; |
| 194 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | 196 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 195 | const updates = yield* DesktopUpdates.DesktopUpdates; | 197 | const updates = yield* DesktopUpdates.DesktopUpdates; |
| ... | @@ -207,6 +209,7 @@ const startup = Effect.gen(function* () { | ... | @@ -207,6 +209,7 @@ const startup = Effect.gen(function* () { |
| 207 | 209 | ||
| 208 | yield* appIdentity.configure; | 210 | yield* appIdentity.configure; |
| 209 | yield* lifecycle.register; | 211 | yield* lifecycle.register; |
| 212 | yield* cloudAuth.configure; | ||
| 210 | 213 | ||
| 211 | yield* electronApp.whenReady.pipe( | 214 | yield* electronApp.whenReady.pipe( |
| 212 | Effect.withSpan("desktop.electron.whenReady"), | 215 | Effect.withSpan("desktop.electron.whenReady"), |
apps/desktop/src/app/DesktopAppIdentity.test.ts+3| ... | @@ -53,6 +53,9 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => | ... | @@ -53,6 +53,9 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => |
| 53 | calls.setAboutPanelOptions.push(options); | 53 | calls.setAboutPanelOptions.push(options); |
| 54 | }), | 54 | }), |
| 55 | setAppUserModelId: () => Effect.void, | 55 | setAppUserModelId: () => Effect.void, |
| 56 | requestSingleInstanceLock: Effect.succeed(true), | ||
| 57 | isDefaultProtocolClient: () => Effect.succeed(false), | ||
| 58 | setAsDefaultProtocolClient: () => Effect.succeed(true), | ||
| 56 | setDesktopName: () => Effect.void, | 59 | setDesktopName: () => Effect.void, |
| 57 | setDockIcon: (iconPath) => | 60 | setDockIcon: (iconPath) => |
| 58 | Effect.sync(() => { | 61 | Effect.sync(() => { |
apps/desktop/src/app/DesktopCloudAuth.test.ts created+302| ... | @@ -0,0 +1,302 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | |||
| 7 | import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| 8 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | ||
| 9 | import * as IpcChannels from "../ipc/channels.ts"; | ||
| 10 | import * as DesktopCloudAuth from "./DesktopCloudAuth.ts"; | ||
| 11 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 12 | |||
| 13 | interface CloudAuthHarness { | ||
| 14 | readonly app: ElectronApp.ElectronAppShape; | ||
| 15 | readonly window: ElectronWindow.ElectronWindowShape; | ||
| 16 | readonly listeners: Map<string, readonly ((...args: readonly unknown[]) => void)[]>; | ||
| 17 | readonly protocolRegistrations: { | ||
| 18 | readonly protocol: string; | ||
| 19 | readonly path?: string; | ||
| 20 | readonly args?: readonly string[]; | ||
| 21 | }[]; | ||
| 22 | readonly sends: { readonly channel: string; readonly args: readonly unknown[] }[]; | ||
| 23 | readonly reveals: unknown[]; | ||
| 24 | readonly layer: Layer.Layer< | ||
| 25 | | DesktopCloudAuth.DesktopCloudAuth | ||
| 26 | | DesktopEnvironment.DesktopEnvironment | ||
| 27 | | ElectronApp.ElectronApp | ||
| 28 | | ElectronWindow.ElectronWindow | ||
| 29 | >; | ||
| 30 | } | ||
| 31 | |||
| 32 | function makeHarness(input: { readonly isDevelopment: boolean }): CloudAuthHarness { | ||
| 33 | const listeners = new Map<string, ((...args: readonly unknown[]) => void)[]>(); | ||
| 34 | const protocolRegistrations: CloudAuthHarness["protocolRegistrations"] = []; | ||
| 35 | const sends: CloudAuthHarness["sends"] = []; | ||
| 36 | const reveals: unknown[] = []; | ||
| 37 | const mainWindow = { id: "main-window" }; | ||
| 38 | |||
| 39 | const app = ElectronApp.ElectronApp.of({ | ||
| 40 | metadata: Effect.succeed({ | ||
| 41 | appVersion: "0.0.0-test", | ||
| 42 | appPath: "/tmp/t3-code-test", | ||
| 43 | isPackaged: !input.isDevelopment, | ||
| 44 | resourcesPath: "/tmp/t3-code-test/resources", | ||
| 45 | runningUnderArm64Translation: false, | ||
| 46 | }), | ||
| 47 | name: Effect.succeed("T3 Code"), | ||
| 48 | whenReady: Effect.void, | ||
| 49 | quit: Effect.void, | ||
| 50 | exit: () => Effect.void, | ||
| 51 | relaunch: () => Effect.void, | ||
| 52 | setPath: () => Effect.void, | ||
| 53 | setName: () => Effect.void, | ||
| 54 | setAboutPanelOptions: () => Effect.void, | ||
| 55 | setAppUserModelId: () => Effect.void, | ||
| 56 | requestSingleInstanceLock: Effect.succeed(true), | ||
| 57 | isDefaultProtocolClient: () => Effect.succeed(false), | ||
| 58 | setAsDefaultProtocolClient: (protocol, path, args) => | ||
| 59 | Effect.sync(() => { | ||
| 60 | protocolRegistrations.push({ | ||
| 61 | protocol, | ||
| 62 | ...(path === undefined ? {} : { path }), | ||
| 63 | ...(args === undefined ? {} : { args }), | ||
| 64 | }); | ||
| 65 | return true; | ||
| 66 | }), | ||
| 67 | setDesktopName: () => Effect.void, | ||
| 68 | setDockIcon: () => Effect.void, | ||
| 69 | appendCommandLineSwitch: () => Effect.void, | ||
| 70 | on: (eventName, listener) => | ||
| 71 | Effect.sync(() => { | ||
| 72 | const erasedListener = listener as (...args: readonly unknown[]) => void; | ||
| 73 | listeners.set(eventName, [...(listeners.get(eventName) ?? []), erasedListener]); | ||
| 74 | }), | ||
| 75 | }); | ||
| 76 | |||
| 77 | const window = ElectronWindow.ElectronWindow.of({ | ||
| 78 | create: () => Effect.die("not used"), | ||
| 79 | main: Effect.succeed(Option.some(mainWindow as never)), | ||
| 80 | currentMainOrFirst: Effect.succeed(Option.some(mainWindow as never)), | ||
| 81 | focusedMainOrFirst: Effect.succeed(Option.some(mainWindow as never)), | ||
| 82 | setMain: () => Effect.void, | ||
| 83 | clearMain: () => Effect.void, | ||
| 84 | reveal: (target) => | ||
| 85 | Effect.sync(() => { | ||
| 86 | reveals.push(target); | ||
| 87 | }), | ||
| 88 | sendAll: (channel, ...args) => | ||
| 89 | Effect.sync(() => { | ||
| 90 | sends.push({ channel, args }); | ||
| 91 | }), | ||
| 92 | destroyAll: Effect.void, | ||
| 93 | syncAllAppearance: () => Effect.void, | ||
| 94 | }); | ||
| 95 | |||
| 96 | const environment = DesktopEnvironment.DesktopEnvironment.of({ | ||
| 97 | isDevelopment: input.isDevelopment, | ||
| 98 | } as DesktopEnvironment.DesktopEnvironmentShape); | ||
| 99 | const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment); | ||
| 100 | |||
| 101 | return { | ||
| 102 | app, | ||
| 103 | window, | ||
| 104 | listeners, | ||
| 105 | protocolRegistrations, | ||
| 106 | sends, | ||
| 107 | reveals, | ||
| 108 | layer: Layer.mergeAll( | ||
| 109 | DesktopCloudAuth.layer.pipe( | ||
| 110 | Layer.provideMerge(environmentLayer), | ||
| 111 | Layer.provide(NodeServices.layer), | ||
| 112 | ), | ||
| 113 | Layer.succeed(ElectronApp.ElectronApp, app), | ||
| 114 | Layer.succeed(ElectronWindow.ElectronWindow, window), | ||
| 115 | ), | ||
| 116 | }; | ||
| 117 | } | ||
| 118 | |||
| 119 | function emitAppEvent( | ||
| 120 | harness: CloudAuthHarness, | ||
| 121 | eventName: string, | ||
| 122 | ...args: readonly unknown[] | ||
| 123 | ): void { | ||
| 124 | for (const listener of harness.listeners.get(eventName) ?? []) { | ||
| 125 | listener(...args); | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | const flushCloudAuthDispatch = Effect.promise(() => Promise.resolve()); | ||
| 130 | |||
| 131 | describe("DesktopCloudAuth", () => { | ||
| 132 | it("uses separate callback schemes for packaged and development builds", () => { | ||
| 133 | assert.equal( | ||
| 134 | DesktopCloudAuth.resolveCloudAuthCallbackScheme({ isDevelopment: false }), | ||
| 135 | "t3code", | ||
| 136 | ); | ||
| 137 | assert.equal( | ||
| 138 | DesktopCloudAuth.resolveCloudAuthCallbackScheme({ isDevelopment: true }), | ||
| 139 | "t3code-dev", | ||
| 140 | ); | ||
| 141 | }); | ||
| 142 | |||
| 143 | it("builds a native callback URL with request state", () => { | ||
| 144 | assert.equal( | ||
| 145 | DesktopCloudAuth.buildCloudAuthCallbackUrl({ | ||
| 146 | scheme: "t3code", | ||
| 147 | state: "state-1", | ||
| 148 | }), | ||
| 149 | "t3code://auth/callback?t3_state=state-1", | ||
| 150 | ); | ||
| 151 | }); | ||
| 152 | |||
| 153 | it("accepts only the expected scheme, host, path, and state", () => { | ||
| 154 | assert.isNotNull( | ||
| 155 | DesktopCloudAuth.parseCloudAuthCallbackUrl({ | ||
| 156 | rawUrl: "t3code://auth/callback?rotating_token_nonce=nonce&t3_state=state-1", | ||
| 157 | scheme: "t3code", | ||
| 158 | state: "state-1", | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | assert.isNull( | ||
| 162 | DesktopCloudAuth.parseCloudAuthCallbackUrl({ | ||
| 163 | rawUrl: "t3code://auth/callback?rotating_token_nonce=nonce&t3_state=wrong", | ||
| 164 | scheme: "t3code", | ||
| 165 | state: "state-1", | ||
| 166 | }), | ||
| 167 | ); | ||
| 168 | assert.isNull( | ||
| 169 | DesktopCloudAuth.parseCloudAuthCallbackUrl({ | ||
| 170 | rawUrl: "https://example.com/callback?rotating_token_nonce=nonce&t3_state=state-1", | ||
| 171 | scheme: "t3code", | ||
| 172 | state: "state-1", | ||
| 173 | }), | ||
| 174 | ); | ||
| 175 | }); | ||
| 176 | |||
| 177 | it("builds a native development callback URL with request state", () => { | ||
| 178 | assert.equal( | ||
| 179 | DesktopCloudAuth.buildCloudAuthCallbackUrl({ | ||
| 180 | scheme: "t3code-dev", | ||
| 181 | state: "state-1", | ||
| 182 | }), | ||
| 183 | "t3code-dev://auth/callback?t3_state=state-1", | ||
| 184 | ); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it.effect("registers the development protocol client and dispatches matching callbacks", () => { | ||
| 188 | const harness = makeHarness({ isDevelopment: true }); | ||
| 189 | |||
| 190 | return Effect.gen(function* () { | ||
| 191 | const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; | ||
| 192 | yield* cloudAuth.configure; | ||
| 193 | const redirectUrl = yield* cloudAuth.createRequest; | ||
| 194 | const callbackUrl = new URL(redirectUrl); | ||
| 195 | callbackUrl.searchParams.set("rotating_token_nonce", "nonce-1"); | ||
| 196 | |||
| 197 | let prevented = false; | ||
| 198 | emitAppEvent( | ||
| 199 | harness, | ||
| 200 | "open-url", | ||
| 201 | { preventDefault: () => (prevented = true) }, | ||
| 202 | callbackUrl.toString(), | ||
| 203 | ); | ||
| 204 | yield* flushCloudAuthDispatch; | ||
| 205 | |||
| 206 | assert.isTrue(prevented); | ||
| 207 | assert.deepEqual( | ||
| 208 | harness.protocolRegistrations.map((registration) => registration.protocol), | ||
| 209 | ["t3code-dev"], | ||
| 210 | ); | ||
| 211 | assert.isString(harness.protocolRegistrations[0]?.path); | ||
| 212 | assert.isArray(harness.protocolRegistrations[0]?.args); | ||
| 213 | assert.deepEqual(harness.sends, [ | ||
| 214 | { | ||
| 215 | channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, | ||
| 216 | args: [callbackUrl.toString()], | ||
| 217 | }, | ||
| 218 | ]); | ||
| 219 | assert.lengthOf(harness.reveals, 1); | ||
| 220 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 221 | }); | ||
| 222 | |||
| 223 | it.effect("rejects mismatched callback state and only consumes the pending request once", () => { | ||
| 224 | const harness = makeHarness({ isDevelopment: false }); | ||
| 225 | |||
| 226 | return Effect.gen(function* () { | ||
| 227 | const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; | ||
| 228 | yield* cloudAuth.configure; | ||
| 229 | const redirectUrl = yield* cloudAuth.createRequest; | ||
| 230 | const validCallback = new URL(redirectUrl); | ||
| 231 | validCallback.searchParams.set("rotating_token_nonce", "nonce-1"); | ||
| 232 | const invalidCallback = new URL(validCallback); | ||
| 233 | invalidCallback.searchParams.set(DesktopCloudAuth.CLOUD_AUTH_CALLBACK_STATE_PARAM, "wrong"); | ||
| 234 | |||
| 235 | emitAppEvent( | ||
| 236 | harness, | ||
| 237 | "open-url", | ||
| 238 | { preventDefault: () => undefined }, | ||
| 239 | invalidCallback.toString(), | ||
| 240 | ); | ||
| 241 | yield* flushCloudAuthDispatch; | ||
| 242 | assert.deepEqual(harness.sends, []); | ||
| 243 | |||
| 244 | emitAppEvent( | ||
| 245 | harness, | ||
| 246 | "open-url", | ||
| 247 | { preventDefault: () => undefined }, | ||
| 248 | validCallback.toString(), | ||
| 249 | ); | ||
| 250 | yield* flushCloudAuthDispatch; | ||
| 251 | emitAppEvent( | ||
| 252 | harness, | ||
| 253 | "open-url", | ||
| 254 | { preventDefault: () => undefined }, | ||
| 255 | validCallback.toString(), | ||
| 256 | ); | ||
| 257 | yield* flushCloudAuthDispatch; | ||
| 258 | |||
| 259 | assert.deepEqual( | ||
| 260 | harness.protocolRegistrations.map((registration) => registration.protocol), | ||
| 261 | ["t3code"], | ||
| 262 | ); | ||
| 263 | assert.deepEqual(harness.sends, [ | ||
| 264 | { | ||
| 265 | channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, | ||
| 266 | args: [validCallback.toString()], | ||
| 267 | }, | ||
| 268 | ]); | ||
| 269 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 270 | }); | ||
| 271 | |||
| 272 | it.effect( | ||
| 273 | "routes second-instance callbacks and reveals the window for non-callback launches", | ||
| 274 | () => { | ||
| 275 | const harness = makeHarness({ isDevelopment: true }); | ||
| 276 | |||
| 277 | return Effect.gen(function* () { | ||
| 278 | const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; | ||
| 279 | yield* cloudAuth.configure; | ||
| 280 | const redirectUrl = yield* cloudAuth.createRequest; | ||
| 281 | const callbackUrl = new URL(redirectUrl); | ||
| 282 | callbackUrl.searchParams.set("rotating_token_nonce", "nonce-1"); | ||
| 283 | |||
| 284 | emitAppEvent(harness, "second-instance", {}, ["electron", callbackUrl.toString()]); | ||
| 285 | yield* flushCloudAuthDispatch; | ||
| 286 | |||
| 287 | const revealCountAfterCallback = harness.reveals.length; | ||
| 288 | emitAppEvent(harness, "second-instance", {}, ["electron", "--opened-from-dock"]); | ||
| 289 | yield* flushCloudAuthDispatch; | ||
| 290 | |||
| 291 | assert.deepEqual(harness.sends, [ | ||
| 292 | { | ||
| 293 | channel: IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, | ||
| 294 | args: [callbackUrl.toString()], | ||
| 295 | }, | ||
| 296 | ]); | ||
| 297 | assert.equal(revealCountAfterCallback, 1); | ||
| 298 | assert.equal(harness.reveals.length, 2); | ||
| 299 | }).pipe(Effect.provide(harness.layer), Effect.scoped); | ||
| 300 | }, | ||
| 301 | ); | ||
| 302 | }); | ||
apps/desktop/src/app/DesktopCloudAuth.ts created+330| ... | @@ -0,0 +1,330 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Crypto from "effect/Crypto"; | ||
| 3 | import * as Data from "effect/Data"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Scope from "effect/Scope"; | ||
| 8 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | ||
| 9 | |||
| 10 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | ||
| 11 | import * as ElectronApp from "../electron/ElectronApp.ts"; | ||
| 12 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | ||
| 13 | import * as IpcChannels from "../ipc/channels.ts"; | ||
| 14 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 15 | import type * as Electron from "electron"; | ||
| 16 | |||
| 17 | export const CLOUD_AUTH_CALLBACK_HOST = "auth"; | ||
| 18 | export const CLOUD_AUTH_CALLBACK_PATHNAME = "/callback"; | ||
| 19 | export const CLOUD_AUTH_CALLBACK_STATE_PARAM = "t3_state"; | ||
| 20 | export const CLOUD_AUTH_CALLBACK_SCHEME = "t3code"; | ||
| 21 | export const DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME = "t3code-dev"; | ||
| 22 | |||
| 23 | const CLOUD_AUTH_REQUEST_TIMEOUT_MS = 5 * 60 * 1000; | ||
| 24 | |||
| 25 | export class DesktopCloudAuthCallbackServerError extends Data.TaggedError( | ||
| 26 | "DesktopCloudAuthCallbackServerError", | ||
| 27 | )<{ | ||
| 28 | readonly cause: unknown; | ||
| 29 | }> { | ||
| 30 | override get message() { | ||
| 31 | return "Failed to start the desktop cloud auth callback server."; | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | interface PendingCloudAuthRequest { | ||
| 36 | readonly state: string; | ||
| 37 | readonly redirectUrl: string; | ||
| 38 | readonly close: () => void; | ||
| 39 | } | ||
| 40 | |||
| 41 | export interface DesktopCloudAuthShape { | ||
| 42 | readonly createRequest: Effect.Effect<string, DesktopCloudAuthCallbackServerError>; | ||
| 43 | readonly configure: Effect.Effect< | ||
| 44 | void, | ||
| 45 | never, | ||
| 46 | ElectronApp.ElectronApp | ElectronWindow.ElectronWindow | Scope.Scope | ||
| 47 | >; | ||
| 48 | } | ||
| 49 | |||
| 50 | export class DesktopCloudAuth extends Context.Service<DesktopCloudAuth, DesktopCloudAuthShape>()( | ||
| 51 | "@t3tools/desktop/app/DesktopCloudAuth", | ||
| 52 | ) {} | ||
| 53 | |||
| 54 | export function resolveCloudAuthCallbackScheme(input: { readonly isDevelopment: boolean }): string { | ||
| 55 | return input.isDevelopment ? DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME : CLOUD_AUTH_CALLBACK_SCHEME; | ||
| 56 | } | ||
| 57 | |||
| 58 | export function buildCloudAuthCallbackUrl(input: { | ||
| 59 | readonly scheme: string; | ||
| 60 | readonly state: string; | ||
| 61 | }): string { | ||
| 62 | const url = new URL( | ||
| 63 | `${input.scheme}://${CLOUD_AUTH_CALLBACK_HOST}${CLOUD_AUTH_CALLBACK_PATHNAME}`, | ||
| 64 | ); | ||
| 65 | url.searchParams.set(CLOUD_AUTH_CALLBACK_STATE_PARAM, input.state); | ||
| 66 | return url.toString(); | ||
| 67 | } | ||
| 68 | |||
| 69 | export function parseCloudAuthCallbackUrl(input: { | ||
| 70 | readonly rawUrl: unknown; | ||
| 71 | readonly scheme: string; | ||
| 72 | readonly state: string; | ||
| 73 | }): URL | null { | ||
| 74 | if (typeof input.rawUrl !== "string") { | ||
| 75 | return null; | ||
| 76 | } | ||
| 77 | |||
| 78 | try { | ||
| 79 | const url = new URL(input.rawUrl); | ||
| 80 | if (url.protocol !== `${input.scheme}:`) return null; | ||
| 81 | if (url.hostname !== CLOUD_AUTH_CALLBACK_HOST) return null; | ||
| 82 | if (url.pathname !== CLOUD_AUTH_CALLBACK_PATHNAME) return null; | ||
| 83 | if (url.searchParams.get(CLOUD_AUTH_CALLBACK_STATE_PARAM) !== input.state) return null; | ||
| 84 | return url; | ||
| 85 | } catch { | ||
| 86 | return null; | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | export function findCloudAuthCallbackUrl(input: { | ||
| 91 | readonly values: readonly unknown[]; | ||
| 92 | readonly scheme: string; | ||
| 93 | readonly state: string; | ||
| 94 | }): URL | null { | ||
| 95 | for (const value of input.values) { | ||
| 96 | const url = parseCloudAuthCallbackUrl({ | ||
| 97 | rawUrl: value, | ||
| 98 | scheme: input.scheme, | ||
| 99 | state: input.state, | ||
| 100 | }); | ||
| 101 | if (url) return url; | ||
| 102 | } | ||
| 103 | return null; | ||
| 104 | } | ||
| 105 | |||
| 106 | export function resolveProtocolClientLaunchArgs(input: { | ||
| 107 | readonly argv: readonly string[]; | ||
| 108 | }): readonly string[] { | ||
| 109 | return input.argv.slice(1); | ||
| 110 | } | ||
| 111 | |||
| 112 | function resolveConfiguredProtocolClient(): { | ||
| 113 | readonly path: string; | ||
| 114 | readonly args: readonly string[]; | ||
| 115 | } | null { | ||
| 116 | const path = process.env.T3CODE_DESKTOP_PROTOCOL_CLIENT_PATH?.trim(); | ||
| 117 | if (!path) return null; | ||
| 118 | |||
| 119 | return { | ||
| 120 | path, | ||
| 121 | args: (process.env.T3CODE_DESKTOP_PROTOCOL_CLIENT_ARGS ?? "") | ||
| 122 | .split("\n") | ||
| 123 | .map((arg) => arg.trim()) | ||
| 124 | .filter((arg) => arg.length > 0), | ||
| 125 | }; | ||
| 126 | } | ||
| 127 | |||
| 128 | function isProtocolRegistrationManagedExternally(): boolean { | ||
| 129 | return process.env.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED?.trim() === "1"; | ||
| 130 | } | ||
| 131 | |||
| 132 | function resolveProtocolCallbackForwardUrl(): URL | null { | ||
| 133 | const rawUrl = process.env.T3CODE_DESKTOP_PROTOCOL_CALLBACK_URL?.trim(); | ||
| 134 | if (!rawUrl) return null; | ||
| 135 | |||
| 136 | try { | ||
| 137 | const url = new URL(rawUrl); | ||
| 138 | if (url.protocol !== "http:") return null; | ||
| 139 | if (url.hostname !== "127.0.0.1") return null; | ||
| 140 | if (url.pathname !== "/auth/callback") return null; | ||
| 141 | if (!url.port) return null; | ||
| 142 | return url; | ||
| 143 | } catch { | ||
| 144 | return null; | ||
| 145 | } | ||
| 146 | } | ||
| 147 | |||
| 148 | const closeCloudAuthRequest = (request: PendingCloudAuthRequest | null): null => { | ||
| 149 | request?.close(); | ||
| 150 | return null; | ||
| 151 | }; | ||
| 152 | |||
| 153 | function createCloudAuthRequestTimeout(onExpire: () => void): ReturnType<typeof setTimeout> { | ||
| 154 | // @effect-diagnostics-next-line globalTimers:off - Auth request expiry is tied to an Electron callback server, not fiber scheduling. | ||
| 155 | return setTimeout(onExpire, CLOUD_AUTH_REQUEST_TIMEOUT_MS); | ||
| 156 | } | ||
| 157 | |||
| 158 | function ignoreCloudAuthCallback(_rawUrl: string) {} | ||
| 159 | |||
| 160 | function startProtocolCallbackForwardServer( | ||
| 161 | callbackUrl: URL, | ||
| 162 | dispatch: (rawUrl: string) => void, | ||
| 163 | ): Effect.Effect<void, never, Scope.Scope> { | ||
| 164 | const port = Number.parseInt(callbackUrl.port, 10); | ||
| 165 | const routesLayer = HttpRouter.add( | ||
| 166 | "POST", | ||
| 167 | "/auth/callback", | ||
| 168 | Effect.gen(function* () { | ||
| 169 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 170 | const rawUrl = yield* request.text; | ||
| 171 | yield* Effect.sync(() => { | ||
| 172 | dispatch(rawUrl); | ||
| 173 | }); | ||
| 174 | return HttpServerResponse.empty({ status: 204 }); | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | |||
| 178 | return Effect.gen(function* () { | ||
| 179 | const NodeHttp = yield* Effect.promise(() => import("node:http")); | ||
| 180 | const serverLayer = NodeHttpServer.layer(NodeHttp.createServer, { | ||
| 181 | host: callbackUrl.hostname, | ||
| 182 | port, | ||
| 183 | }); | ||
| 184 | yield* Layer.launch(HttpRouter.serve(routesLayer).pipe(Layer.provideMerge(serverLayer))).pipe( | ||
| 185 | Effect.forkScoped, | ||
| 186 | ); | ||
| 187 | }); | ||
| 188 | } | ||
| 189 | |||
| 190 | const make = Effect.gen(function* () { | ||
| 191 | const crypto = yield* Crypto.Crypto; | ||
| 192 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 193 | let pendingAuthRequest: PendingCloudAuthRequest | null = null; | ||
| 194 | let dispatchCloudAuthCallback: (rawUrl: string) => void = ignoreCloudAuthCallback; | ||
| 195 | const makeCloudAuthRequestState = Effect.gen(function* () { | ||
| 196 | const [left, right] = yield* Effect.all([crypto.randomUUIDv4, crypto.randomUUIDv4]); | ||
| 197 | return `${left}${right}`.replaceAll("-", ""); | ||
| 198 | }); | ||
| 199 | |||
| 200 | return DesktopCloudAuth.of({ | ||
| 201 | createRequest: Effect.gen(function* () { | ||
| 202 | const scheme = resolveCloudAuthCallbackScheme({ | ||
| 203 | isDevelopment: environment.isDevelopment, | ||
| 204 | }); | ||
| 205 | const state = yield* makeCloudAuthRequestState.pipe( | ||
| 206 | Effect.mapError((cause) => new DesktopCloudAuthCallbackServerError({ cause })), | ||
| 207 | ); | ||
| 208 | |||
| 209 | pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); | ||
| 210 | |||
| 211 | const redirectUrl = buildCloudAuthCallbackUrl({ scheme, state }); | ||
| 212 | const timeout = createCloudAuthRequestTimeout(() => { | ||
| 213 | pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); | ||
| 214 | }); | ||
| 215 | pendingAuthRequest = { | ||
| 216 | state, | ||
| 217 | redirectUrl, | ||
| 218 | close: () => clearTimeout(timeout), | ||
| 219 | }; | ||
| 220 | return redirectUrl; | ||
| 221 | }), | ||
| 222 | configure: Effect.gen(function* () { | ||
| 223 | const electronApp = yield* ElectronApp.ElectronApp; | ||
| 224 | const electronWindow = yield* ElectronWindow.ElectronWindow; | ||
| 225 | const scope = yield* Scope.Scope; | ||
| 226 | const context = yield* Effect.context<ElectronWindow.ElectronWindow>(); | ||
| 227 | const runPromise = Effect.runPromiseWith(context); | ||
| 228 | const scheme = resolveCloudAuthCallbackScheme({ | ||
| 229 | isDevelopment: environment.isDevelopment, | ||
| 230 | }); | ||
| 231 | |||
| 232 | yield* Scope.addFinalizer( | ||
| 233 | scope, | ||
| 234 | Effect.sync(() => { | ||
| 235 | pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); | ||
| 236 | }), | ||
| 237 | ); | ||
| 238 | |||
| 239 | if (isProtocolRegistrationManagedExternally()) { | ||
| 240 | // Development macOS launchers set the default URL handler before the stock Electron | ||
| 241 | // process starts so LaunchServices binds the scheme to the worktree-specific app bundle. | ||
| 242 | } else if (environment.isDevelopment) { | ||
| 243 | const configuredClient = resolveConfiguredProtocolClient(); | ||
| 244 | if (configuredClient) { | ||
| 245 | yield* electronApp.setAsDefaultProtocolClient( | ||
| 246 | scheme, | ||
| 247 | configuredClient.path, | ||
| 248 | configuredClient.args, | ||
| 249 | ); | ||
| 250 | } else { | ||
| 251 | yield* electronApp.setAsDefaultProtocolClient( | ||
| 252 | scheme, | ||
| 253 | process.execPath, | ||
| 254 | resolveProtocolClientLaunchArgs({ argv: process.argv }), | ||
| 255 | ); | ||
| 256 | } | ||
| 257 | } else { | ||
| 258 | yield* electronApp.setAsDefaultProtocolClient(scheme); | ||
| 259 | } | ||
| 260 | |||
| 261 | dispatchCloudAuthCallback = (rawUrl: string) => { | ||
| 262 | const pending = pendingAuthRequest; | ||
| 263 | const callbackUrl = pending | ||
| 264 | ? parseCloudAuthCallbackUrl({ rawUrl, scheme, state: pending.state }) | ||
| 265 | : null; | ||
| 266 | if (!callbackUrl) { | ||
| 267 | return; | ||
| 268 | } | ||
| 269 | |||
| 270 | pendingAuthRequest = closeCloudAuthRequest(pendingAuthRequest); | ||
| 271 | void runPromise( | ||
| 272 | Effect.gen(function* () { | ||
| 273 | yield* electronWindow.sendAll( | ||
| 274 | IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, | ||
| 275 | callbackUrl.toString(), | ||
| 276 | ); | ||
| 277 | const mainWindow = yield* electronWindow.currentMainOrFirst; | ||
| 278 | if (Option.isSome(mainWindow)) { | ||
| 279 | yield* electronWindow.reveal(mainWindow.value); | ||
| 280 | } | ||
| 281 | }), | ||
| 282 | ); | ||
| 283 | }; | ||
| 284 | |||
| 285 | const protocolCallbackForwardUrl = resolveProtocolCallbackForwardUrl(); | ||
| 286 | if (environment.isDevelopment && protocolCallbackForwardUrl) { | ||
| 287 | yield* startProtocolCallbackForwardServer( | ||
| 288 | protocolCallbackForwardUrl, | ||
| 289 | dispatchCloudAuthCallback, | ||
| 290 | ); | ||
| 291 | } | ||
| 292 | |||
| 293 | const hasInstanceLock = yield* electronApp.requestSingleInstanceLock; | ||
| 294 | if (!hasInstanceLock) { | ||
| 295 | return yield* electronApp.quit; | ||
| 296 | } | ||
| 297 | |||
| 298 | yield* electronApp.on<[Electron.Event, string]>("open-url", (event, rawUrl) => { | ||
| 299 | event.preventDefault?.(); | ||
| 300 | dispatchCloudAuthCallback(rawUrl); | ||
| 301 | }); | ||
| 302 | |||
| 303 | yield* electronApp.on<[Electron.Event, readonly string[]]>( | ||
| 304 | "second-instance", | ||
| 305 | (_event, argv) => { | ||
| 306 | const values = resolveProtocolClientLaunchArgs({ argv }); | ||
| 307 | const pending = pendingAuthRequest; | ||
| 308 | const callbackUrl = pending | ||
| 309 | ? findCloudAuthCallbackUrl({ values, scheme, state: pending.state }) | ||
| 310 | : null; | ||
| 311 | if (callbackUrl) { | ||
| 312 | dispatchCloudAuthCallback(callbackUrl.toString()); | ||
| 313 | return; | ||
| 314 | } | ||
| 315 | |||
| 316 | void runPromise( | ||
| 317 | Effect.gen(function* () { | ||
| 318 | const mainWindow = yield* electronWindow.currentMainOrFirst; | ||
| 319 | if (Option.isSome(mainWindow)) { | ||
| 320 | yield* electronWindow.reveal(mainWindow.value); | ||
| 321 | } | ||
| 322 | }), | ||
| 323 | ); | ||
| 324 | }, | ||
| 325 | ); | ||
| 326 | }).pipe(Effect.withSpan("desktop.cloudAuth.configure")), | ||
| 327 | }); | ||
| 328 | }); | ||
| 329 | |||
| 330 | export const layer = Layer.effect(DesktopCloudAuth, make); | ||
apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts created+96| ... | @@ -0,0 +1,96 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | |||
| 8 | import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | ||
| 9 | import * as DesktopConfig from "./DesktopConfig.ts"; | ||
| 10 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 11 | import * as DesktopCloudAuthTokenStore from "./DesktopCloudAuthTokenStore.ts"; | ||
| 12 | |||
| 13 | const textDecoder = new TextDecoder(); | ||
| 14 | const textEncoder = new TextEncoder(); | ||
| 15 | |||
| 16 | function makeSafeStorageLayer(input: { readonly available: boolean }) { | ||
| 17 | return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { | ||
| 18 | isEncryptionAvailable: Effect.succeed(input.available), | ||
| 19 | encryptString: (value) => Effect.succeed(textEncoder.encode(`enc:${value}`)), | ||
| 20 | decryptString: (value) => { | ||
| 21 | const decoded = textDecoder.decode(value); | ||
| 22 | if (!decoded.startsWith("enc:")) { | ||
| 23 | return Effect.fail( | ||
| 24 | new ElectronSafeStorage.ElectronSafeStorageDecryptError({ | ||
| 25 | cause: new Error("invalid encrypted token"), | ||
| 26 | }), | ||
| 27 | ); | ||
| 28 | } | ||
| 29 | return Effect.succeed(decoded.slice("enc:".length)); | ||
| 30 | }, | ||
| 31 | } satisfies ElectronSafeStorage.ElectronSafeStorageShape); | ||
| 32 | } | ||
| 33 | |||
| 34 | function makeLayer(baseDir: string, input?: { readonly encryptionAvailable?: boolean }) { | ||
| 35 | const environmentLayer = DesktopEnvironment.layer({ | ||
| 36 | dirname: "/repo/apps/desktop/src", | ||
| 37 | homeDirectory: baseDir, | ||
| 38 | platform: "darwin", | ||
| 39 | processArch: "x64", | ||
| 40 | appVersion: "1.2.3", | ||
| 41 | appPath: "/repo", | ||
| 42 | isPackaged: true, | ||
| 43 | resourcesPath: "/missing/resources", | ||
| 44 | runningUnderArm64Translation: false, | ||
| 45 | }).pipe( | ||
| 46 | Layer.provide( | ||
| 47 | Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), | ||
| 48 | ), | ||
| 49 | ); | ||
| 50 | |||
| 51 | return DesktopCloudAuthTokenStore.layer.pipe( | ||
| 52 | Layer.provideMerge(environmentLayer), | ||
| 53 | Layer.provideMerge(makeSafeStorageLayer({ available: input?.encryptionAvailable ?? true })), | ||
| 54 | Layer.provideMerge(NodeServices.layer), | ||
| 55 | ); | ||
| 56 | } | ||
| 57 | |||
| 58 | const withTokenStore = <A, E, R>( | ||
| 59 | effect: Effect.Effect<A, E, R | DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore>, | ||
| 60 | input?: { readonly encryptionAvailable?: boolean }, | ||
| 61 | ) => | ||
| 62 | Effect.gen(function* () { | ||
| 63 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 64 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 65 | prefix: "t3-desktop-cloud-auth-token-test-", | ||
| 66 | }); | ||
| 67 | return yield* effect.pipe(Effect.provide(makeLayer(baseDir, input))); | ||
| 68 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); | ||
| 69 | |||
| 70 | describe("DesktopCloudAuthTokenStore", () => { | ||
| 71 | it.effect("persists, reads, and clears the encrypted Clerk client JWT", () => | ||
| 72 | withTokenStore( | ||
| 73 | Effect.gen(function* () { | ||
| 74 | const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; | ||
| 75 | |||
| 76 | assert.isTrue(yield* tokenStore.set("__client=test.jwt")); | ||
| 77 | assert.deepStrictEqual(yield* tokenStore.get, Option.some("__client=test.jwt")); | ||
| 78 | |||
| 79 | yield* tokenStore.clear; | ||
| 80 | assert.deepStrictEqual(yield* tokenStore.get, Option.none()); | ||
| 81 | }), | ||
| 82 | ), | ||
| 83 | ); | ||
| 84 | |||
| 85 | it.effect("does not persist a token when Electron safe storage is unavailable", () => | ||
| 86 | withTokenStore( | ||
| 87 | Effect.gen(function* () { | ||
| 88 | const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; | ||
| 89 | |||
| 90 | assert.isFalse(yield* tokenStore.set("__client=test.jwt")); | ||
| 91 | assert.deepStrictEqual(yield* tokenStore.get, Option.none()); | ||
| 92 | }), | ||
| 93 | { encryptionAvailable: false }, | ||
| 94 | ), | ||
| 95 | ); | ||
| 96 | }); | ||
apps/desktop/src/app/DesktopCloudAuthTokenStore.ts created+155| ... | @@ -0,0 +1,155 @@ | ||
| 1 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Crypto from "effect/Crypto"; | ||
| 4 | import * as Data from "effect/Data"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Encoding from "effect/Encoding"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Path from "effect/Path"; | ||
| 11 | import * as PlatformError from "effect/PlatformError"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | |||
| 14 | import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; | ||
| 15 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 16 | |||
| 17 | interface CloudAuthTokenDocument { | ||
| 18 | readonly version: number; | ||
| 19 | readonly encryptedClientJwt: string; | ||
| 20 | } | ||
| 21 | |||
| 22 | const CloudAuthTokenDocumentSchema = Schema.Struct({ | ||
| 23 | version: Schema.Number, | ||
| 24 | encryptedClientJwt: Schema.String, | ||
| 25 | }); | ||
| 26 | |||
| 27 | const CloudAuthTokenDocumentJson = fromLenientJson(CloudAuthTokenDocumentSchema); | ||
| 28 | const decodeCloudAuthTokenDocumentJson = Schema.decodeEffect(CloudAuthTokenDocumentJson); | ||
| 29 | const encodeCloudAuthTokenDocumentJson = Schema.encodeEffect(CloudAuthTokenDocumentJson); | ||
| 30 | |||
| 31 | export class DesktopCloudAuthTokenStoreWriteError extends Data.TaggedError( | ||
| 32 | "DesktopCloudAuthTokenStoreWriteError", | ||
| 33 | )<{ | ||
| 34 | readonly cause: PlatformError.PlatformError | Schema.SchemaError; | ||
| 35 | }> { | ||
| 36 | override get message() { | ||
| 37 | return `Failed to write desktop cloud auth token: ${this.cause.message}`; | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | export class DesktopCloudAuthTokenStoreDecodeError extends Data.TaggedError( | ||
| 42 | "DesktopCloudAuthTokenStoreDecodeError", | ||
| 43 | )<{ | ||
| 44 | readonly cause: Encoding.EncodingError; | ||
| 45 | }> { | ||
| 46 | override get message() { | ||
| 47 | return "Failed to decode desktop cloud auth token."; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | export interface DesktopCloudAuthTokenStoreShape { | ||
| 52 | readonly get: Effect.Effect< | ||
| 53 | Option.Option<string>, | ||
| 54 | | DesktopCloudAuthTokenStoreDecodeError | ||
| 55 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ||
| 56 | | ElectronSafeStorage.ElectronSafeStorageDecryptError | ||
| 57 | >; | ||
| 58 | readonly set: ( | ||
| 59 | token: string, | ||
| 60 | ) => Effect.Effect< | ||
| 61 | boolean, | ||
| 62 | | DesktopCloudAuthTokenStoreWriteError | ||
| 63 | | ElectronSafeStorage.ElectronSafeStorageAvailabilityError | ||
| 64 | | ElectronSafeStorage.ElectronSafeStorageEncryptError | ||
| 65 | >; | ||
| 66 | readonly clear: Effect.Effect<void>; | ||
| 67 | } | ||
| 68 | |||
| 69 | export class DesktopCloudAuthTokenStore extends Context.Service< | ||
| 70 | DesktopCloudAuthTokenStore, | ||
| 71 | DesktopCloudAuthTokenStoreShape | ||
| 72 | >()("@t3tools/desktop/app/DesktopCloudAuthTokenStore") {} | ||
| 73 | |||
| 74 | function decodeSecretBytes( | ||
| 75 | encoded: string, | ||
| 76 | ): Effect.Effect<Uint8Array, DesktopCloudAuthTokenStoreDecodeError> { | ||
| 77 | return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( | ||
| 78 | Effect.mapError((cause) => new DesktopCloudAuthTokenStoreDecodeError({ cause })), | ||
| 79 | ); | ||
| 80 | } | ||
| 81 | |||
| 82 | const readDocument = ( | ||
| 83 | fileSystem: FileSystem.FileSystem, | ||
| 84 | tokenPath: string, | ||
| 85 | ): Effect.Effect<Option.Option<CloudAuthTokenDocument>> => | ||
| 86 | fileSystem.readFileString(tokenPath).pipe( | ||
| 87 | Effect.option, | ||
| 88 | Effect.flatMap( | ||
| 89 | Option.match({ | ||
| 90 | onNone: () => Effect.succeed(Option.none<CloudAuthTokenDocument>()), | ||
| 91 | onSome: (raw) => decodeCloudAuthTokenDocumentJson(raw).pipe(Effect.option), | ||
| 92 | }), | ||
| 93 | ), | ||
| 94 | ); | ||
| 95 | |||
| 96 | const writeDocument = Effect.fn("desktop.cloudAuthTokenStore.writeDocument")(function* (input: { | ||
| 97 | readonly fileSystem: FileSystem.FileSystem; | ||
| 98 | readonly path: Path.Path; | ||
| 99 | readonly tokenPath: string; | ||
| 100 | readonly document: CloudAuthTokenDocument; | ||
| 101 | readonly suffix: string; | ||
| 102 | }): Effect.fn.Return<void, PlatformError.PlatformError | Schema.SchemaError> { | ||
| 103 | const directory = input.path.dirname(input.tokenPath); | ||
| 104 | const tempPath = `${input.tokenPath}.${process.pid}.${input.suffix}.tmp`; | ||
| 105 | const encoded = yield* encodeCloudAuthTokenDocumentJson(input.document); | ||
| 106 | yield* input.fileSystem.makeDirectory(directory, { recursive: true }); | ||
| 107 | yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); | ||
| 108 | yield* input.fileSystem.rename(tempPath, input.tokenPath); | ||
| 109 | }); | ||
| 110 | |||
| 111 | export const layer = Layer.effect( | ||
| 112 | DesktopCloudAuthTokenStore, | ||
| 113 | Effect.gen(function* () { | ||
| 114 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 115 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 116 | const path = yield* Path.Path; | ||
| 117 | const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; | ||
| 118 | const crypto = yield* Crypto.Crypto; | ||
| 119 | const tokenPath = path.join(environment.stateDir, "cloud-auth-token.json"); | ||
| 120 | |||
| 121 | return DesktopCloudAuthTokenStore.of({ | ||
| 122 | get: Effect.gen(function* () { | ||
| 123 | const document = yield* readDocument(fileSystem, tokenPath); | ||
| 124 | if (Option.isNone(document) || !(yield* safeStorage.isEncryptionAvailable)) { | ||
| 125 | return Option.none<string>(); | ||
| 126 | } | ||
| 127 | |||
| 128 | const secretBytes = yield* decodeSecretBytes(document.value.encryptedClientJwt); | ||
| 129 | return Option.some(yield* safeStorage.decryptString(secretBytes)); | ||
| 130 | }).pipe(Effect.withSpan("desktop.cloudAuthTokenStore.get")), | ||
| 131 | set: Effect.fn("desktop.cloudAuthTokenStore.set")(function* (token) { | ||
| 132 | if (!(yield* safeStorage.isEncryptionAvailable)) { | ||
| 133 | return false; | ||
| 134 | } | ||
| 135 | |||
| 136 | const encryptedClientJwt = Encoding.encodeBase64(yield* safeStorage.encryptString(token)); | ||
| 137 | const suffix = (yield* crypto.randomUUIDv4.pipe( | ||
| 138 | Effect.mapError((cause) => new DesktopCloudAuthTokenStoreWriteError({ cause })), | ||
| 139 | )).replace(/-/g, ""); | ||
| 140 | yield* writeDocument({ | ||
| 141 | fileSystem, | ||
| 142 | path, | ||
| 143 | tokenPath, | ||
| 144 | document: { version: 1, encryptedClientJwt }, | ||
| 145 | suffix, | ||
| 146 | }).pipe(Effect.mapError((cause) => new DesktopCloudAuthTokenStoreWriteError({ cause }))); | ||
| 147 | return true; | ||
| 148 | }), | ||
| 149 | clear: fileSystem.remove(tokenPath, { force: true }).pipe( | ||
| 150 | Effect.catch(() => Effect.void), | ||
| 151 | Effect.withSpan("desktop.cloudAuthTokenStore.clear"), | ||
| 152 | ), | ||
| 153 | }); | ||
| 154 | }), | ||
| 155 | ); | ||
apps/desktop/src/app/DesktopConfig.ts+1| ... | @@ -37,6 +37,7 @@ export const DesktopConfig = Config.all({ | ... | @@ -37,6 +37,7 @@ export const DesktopConfig = Config.all({ |
| 37 | xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), | 37 | xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), |
| 38 | t3Home: trimmedString("T3CODE_HOME"), | 38 | t3Home: trimmedString("T3CODE_HOME"), |
| 39 | devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), | 39 | devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), |
| 40 | appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"), | ||
| 40 | devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), | 41 | devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"), |
| 41 | configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option), | 42 | configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option), |
| 42 | commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), | 43 | commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"), |
apps/desktop/src/app/DesktopEnvironment.test.ts+14| ... | @@ -93,6 +93,20 @@ describe("DesktopEnvironment", () => { | ... | @@ -93,6 +93,20 @@ describe("DesktopEnvironment", () => { |
| 93 | }), | 93 | }), |
| 94 | ); | 94 | ); |
| 95 | 95 | ||
| 96 | it.effect("uses a configured app user model id override", () => | ||
| 97 | Effect.gen(function* () { | ||
| 98 | const environment = yield* makeEnvironment( | ||
| 99 | {}, | ||
| 100 | { | ||
| 101 | T3CODE_DESKTOP_APP_USER_MODEL_ID: " com.t3tools.t3code.dev.local ", | ||
| 102 | VITE_DEV_SERVER_URL: "http://localhost:5173", | ||
| 103 | }, | ||
| 104 | ); | ||
| 105 | |||
| 106 | assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev.local"); | ||
| 107 | }), | ||
| 108 | ); | ||
| 109 | |||
| 96 | it.effect("resolves picker defaults without nullish sentinels", () => | 110 | it.effect("resolves picker defaults without nullish sentinels", () => |
| 97 | Effect.gen(function* () { | 111 | Effect.gen(function* () { |
| 98 | const environment = yield* makeEnvironment(); | 112 | const environment = yield* makeEnvironment(); |
apps/desktop/src/app/DesktopEnvironment.ts+3-1| ... | @@ -199,7 +199,9 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( | ... | @@ -199,7 +199,9 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( |
| 199 | otlpExportIntervalMs: config.otlpExportIntervalMs, | 199 | otlpExportIntervalMs: config.otlpExportIntervalMs, |
| 200 | branding, | 200 | branding, |
| 201 | displayName, | 201 | displayName, |
| 202 | appUserModelId: isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", | 202 | appUserModelId: Option.getOrElse(config.appUserModelIdOverride, () => |
| 203 | isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code", | ||
| 204 | ), | ||
| 203 | linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", | 205 | linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", |
| 204 | linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", | 206 | linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", |
| 205 | userDataDirName, | 207 | userDataDirName, |
apps/desktop/src/electron/ElectronApp.test.ts+9| ... | @@ -7,12 +7,15 @@ const { | ... | @@ -7,12 +7,15 @@ const { |
| 7 | exitMock, | 7 | exitMock, |
| 8 | getAppPathMock, | 8 | getAppPathMock, |
| 9 | getVersionMock, | 9 | getVersionMock, |
| 10 | isDefaultProtocolClientMock, | ||
| 10 | onMock, | 11 | onMock, |
| 11 | quitMock, | 12 | quitMock, |
| 12 | relaunchMock, | 13 | relaunchMock, |
| 13 | removeListenerMock, | 14 | removeListenerMock, |
| 15 | requestSingleInstanceLockMock, | ||
| 14 | setAboutPanelOptionsMock, | 16 | setAboutPanelOptionsMock, |
| 15 | setAppUserModelIdMock, | 17 | setAppUserModelIdMock, |
| 18 | setAsDefaultProtocolClientMock, | ||
| 16 | setDesktopNameMock, | 19 | setDesktopNameMock, |
| 17 | setDockIconMock, | 20 | setDockIconMock, |
| 18 | setNameMock, | 21 | setNameMock, |
| ... | @@ -23,12 +26,15 @@ const { | ... | @@ -23,12 +26,15 @@ const { |
| 23 | exitMock: vi.fn(), | 26 | exitMock: vi.fn(), |
| 24 | getAppPathMock: vi.fn(() => "/app"), | 27 | getAppPathMock: vi.fn(() => "/app"), |
| 25 | getVersionMock: vi.fn(() => "1.2.3"), | 28 | getVersionMock: vi.fn(() => "1.2.3"), |
| 29 | isDefaultProtocolClientMock: vi.fn(() => false), | ||
| 26 | onMock: vi.fn(), | 30 | onMock: vi.fn(), |
| 27 | quitMock: vi.fn(), | 31 | quitMock: vi.fn(), |
| 28 | relaunchMock: vi.fn(), | 32 | relaunchMock: vi.fn(), |
| 29 | removeListenerMock: vi.fn(), | 33 | removeListenerMock: vi.fn(), |
| 34 | requestSingleInstanceLockMock: vi.fn(() => true), | ||
| 30 | setAboutPanelOptionsMock: vi.fn(), | 35 | setAboutPanelOptionsMock: vi.fn(), |
| 31 | setAppUserModelIdMock: vi.fn(), | 36 | setAppUserModelIdMock: vi.fn(), |
| 37 | setAsDefaultProtocolClientMock: vi.fn(() => true), | ||
| 32 | setDesktopNameMock: vi.fn(), | 38 | setDesktopNameMock: vi.fn(), |
| 33 | setDockIconMock: vi.fn(), | 39 | setDockIconMock: vi.fn(), |
| 34 | setNameMock: vi.fn(), | 40 | setNameMock: vi.fn(), |
| ... | @@ -46,14 +52,17 @@ vi.mock("electron", () => ({ | ... | @@ -46,14 +52,17 @@ vi.mock("electron", () => ({ |
| 46 | }, | 52 | }, |
| 47 | getAppPath: getAppPathMock, | 53 | getAppPath: getAppPathMock, |
| 48 | getVersion: getVersionMock, | 54 | getVersion: getVersionMock, |
| 55 | isDefaultProtocolClient: isDefaultProtocolClientMock, | ||
| 49 | isPackaged: true, | 56 | isPackaged: true, |
| 50 | name: "T3 Code", | 57 | name: "T3 Code", |
| 51 | on: onMock, | 58 | on: onMock, |
| 52 | quit: quitMock, | 59 | quit: quitMock, |
| 53 | relaunch: relaunchMock, | 60 | relaunch: relaunchMock, |
| 54 | removeListener: removeListenerMock, | 61 | removeListener: removeListenerMock, |
| 62 | requestSingleInstanceLock: requestSingleInstanceLockMock, | ||
| 55 | runningUnderARM64Translation: false, | 63 | runningUnderARM64Translation: false, |
| 56 | setAboutPanelOptions: setAboutPanelOptionsMock, | 64 | setAboutPanelOptions: setAboutPanelOptionsMock, |
| 65 | setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, | ||
| 57 | setAppUserModelId: setAppUserModelIdMock, | 66 | setAppUserModelId: setAppUserModelIdMock, |
| 58 | setDesktopName: setDesktopNameMock, | 67 | setDesktopName: setDesktopNameMock, |
| 59 | setName: setNameMock, | 68 | setName: setNameMock, |
apps/desktop/src/electron/ElectronApp.ts+17| ... | @@ -29,6 +29,13 @@ export interface ElectronAppShape { | ... | @@ -29,6 +29,13 @@ export interface ElectronAppShape { |
| 29 | options: Electron.AboutPanelOptionsOptions, | 29 | options: Electron.AboutPanelOptionsOptions, |
| 30 | ) => Effect.Effect<void>; | 30 | ) => Effect.Effect<void>; |
| 31 | readonly setAppUserModelId: (id: string) => Effect.Effect<void>; | 31 | readonly setAppUserModelId: (id: string) => Effect.Effect<void>; |
| 32 | readonly requestSingleInstanceLock: Effect.Effect<boolean>; | ||
| 33 | readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect<boolean>; | ||
| 34 | readonly setAsDefaultProtocolClient: ( | ||
| 35 | protocol: string, | ||
| 36 | path?: string, | ||
| 37 | args?: readonly string[], | ||
| 38 | ) => Effect.Effect<boolean>; | ||
| 32 | readonly setDesktopName: (desktopName: string) => Effect.Effect<void>; | 39 | readonly setDesktopName: (desktopName: string) => Effect.Effect<void>; |
| 33 | readonly setDockIcon: (iconPath: string) => Effect.Effect<void>; | 40 | readonly setDockIcon: (iconPath: string) => Effect.Effect<void>; |
| 34 | readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>; | 41 | readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>; |
| ... | @@ -93,6 +100,16 @@ const make = ElectronApp.of({ | ... | @@ -93,6 +100,16 @@ const make = ElectronApp.of({ |
| 93 | Effect.sync(() => { | 100 | Effect.sync(() => { |
| 94 | Electron.app.setAppUserModelId(id); | 101 | Electron.app.setAppUserModelId(id); |
| 95 | }), | 102 | }), |
| 103 | requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), | ||
| 104 | isDefaultProtocolClient: (protocol) => | ||
| 105 | Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), | ||
| 106 | setAsDefaultProtocolClient: (protocol, path, args) => | ||
| 107 | Effect.sync(() => { | ||
| 108 | if (path === undefined) { | ||
| 109 | return Electron.app.setAsDefaultProtocolClient(protocol); | ||
| 110 | } | ||
| 111 | return Electron.app.setAsDefaultProtocolClient(protocol, path, [...(args ?? [])]); | ||
| 112 | }), | ||
| 96 | setDesktopName: (desktopName) => | 113 | setDesktopName: (desktopName) => |
| 97 | Effect.sync(() => { | 114 | Effect.sync(() => { |
| 98 | const linuxApp = Electron.app as Electron.App & { | 115 | const linuxApp = Electron.app as Electron.App & { |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+12-1| ... | @@ -1,6 +1,13 @@ | ... | @@ -1,6 +1,13 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | ||
| 3 | import * as DesktopIpc from "./DesktopIpc.ts"; | 3 | import * as DesktopIpc from "./DesktopIpc.ts"; |
| 4 | import { | ||
| 5 | clearCloudAuthToken, | ||
| 6 | createCloudAuthRequest, | ||
| 7 | fetchCloudAuth, | ||
| 8 | getCloudAuthToken, | ||
| 9 | setCloudAuthToken, | ||
| 10 | } from "./methods/cloudAuth.ts"; | ||
| 4 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; | 11 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; |
| 5 | import { | 12 | import { |
| 6 | getSavedEnvironmentRegistry, | 13 | getSavedEnvironmentRegistry, |
| ... | @@ -75,7 +82,11 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { | ... | @@ -75,7 +82,11 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { |
| 75 | yield* ipc.handle(setTheme); | 82 | yield* ipc.handle(setTheme); |
| 76 | yield* ipc.handle(showContextMenu); | 83 | yield* ipc.handle(showContextMenu); |
| 77 | yield* ipc.handle(openExternal); | 84 | yield* ipc.handle(openExternal); |
| 78 | 85 | yield* ipc.handle(createCloudAuthRequest); | |
| 86 | yield* ipc.handle(getCloudAuthToken); | ||
| 87 | yield* ipc.handle(setCloudAuthToken); | ||
| 88 | yield* ipc.handle(clearCloudAuthToken); | ||
| 89 | yield* ipc.handle(fetchCloudAuth); | ||
| 79 | yield* ipc.handle(getUpdateState); | 90 | yield* ipc.handle(getUpdateState); |
| 80 | yield* ipc.handle(setUpdateChannel); | 91 | yield* ipc.handle(setUpdateChannel); |
| 81 | yield* ipc.handle(downloadUpdate); | 92 | yield* ipc.handle(downloadUpdate); |
apps/desktop/src/ipc/channels.ts+6| ... | @@ -3,6 +3,12 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; | ... | @@ -3,6 +3,12 @@ export const CONFIRM_CHANNEL = "desktop:confirm"; |
| 3 | export const SET_THEME_CHANNEL = "desktop:set-theme"; | 3 | export const SET_THEME_CHANNEL = "desktop:set-theme"; |
| 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | 5 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 6 | export const CREATE_CLOUD_AUTH_REQUEST_CHANNEL = "desktop:create-cloud-auth-request"; | ||
| 7 | export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token"; | ||
| 8 | export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token"; | ||
| 9 | export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token"; | ||
| 10 | export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth"; | ||
| 11 | export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback"; | ||
| 6 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 12 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 7 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; | 13 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 8 | export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | 14 | export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
apps/desktop/src/ipc/methods/cloudAuth.test.ts created+105| ... | @@ -0,0 +1,105 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 5 | import { afterEach } from "vitest"; | ||
| 6 | |||
| 7 | import { fetchCloudAuth, validateClerkFrontendApiUrl } from "./cloudAuth.ts"; | ||
| 8 | |||
| 9 | const originalClerkPublishableKey = process.env.T3CODE_CLERK_PUBLISHABLE_KEY; | ||
| 10 | |||
| 11 | const clerkPublishableKey = (hostname: string): string => | ||
| 12 | `pk_test_${Buffer.from(`${hostname}$`).toString("base64")}`; | ||
| 13 | |||
| 14 | function makeHttpClientLayer( | ||
| 15 | handler: ( | ||
| 16 | request: HttpClientRequest.HttpClientRequest, | ||
| 17 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse, never>, | ||
| 18 | ) { | ||
| 19 | return Layer.succeed( | ||
| 20 | HttpClient.HttpClient, | ||
| 21 | HttpClient.make((request) => handler(request)), | ||
| 22 | ); | ||
| 23 | } | ||
| 24 | |||
| 25 | describe("Desktop cloud auth IPC", () => { | ||
| 26 | afterEach(() => { | ||
| 27 | if (originalClerkPublishableKey === undefined) { | ||
| 28 | delete process.env.T3CODE_CLERK_PUBLISHABLE_KEY; | ||
| 29 | } else { | ||
| 30 | process.env.T3CODE_CLERK_PUBLISHABLE_KEY = originalClerkPublishableKey; | ||
| 31 | } | ||
| 32 | }); | ||
| 33 | |||
| 34 | it.effect("preserves Clerk's URL-encoded OAuth form content type", () => { | ||
| 35 | const body = "strategy=oauth_google&redirect_url=t3code%3A%2F%2Fauth%2Fcallback"; | ||
| 36 | let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null; | ||
| 37 | const layer = makeHttpClientLayer((request) => | ||
| 38 | Effect.sync(() => { | ||
| 39 | forwardedRequest = request; | ||
| 40 | return HttpClientResponse.fromWeb( | ||
| 41 | request, | ||
| 42 | Response.json({ response: { object: "sign_in_attempt" } }), | ||
| 43 | ); | ||
| 44 | }), | ||
| 45 | ); | ||
| 46 | |||
| 47 | return Effect.gen(function* () { | ||
| 48 | yield* fetchCloudAuth.handler({ | ||
| 49 | url: "https://example.clerk.accounts.dev/v1/client/sign_ins", | ||
| 50 | method: "POST", | ||
| 51 | headers: { | ||
| 52 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", | ||
| 53 | "x-mobile": "1", | ||
| 54 | }, | ||
| 55 | body, | ||
| 56 | }); | ||
| 57 | |||
| 58 | assert(forwardedRequest !== null); | ||
| 59 | assert.equal( | ||
| 60 | forwardedRequest.headers["content-type"], | ||
| 61 | "application/x-www-form-urlencoded;charset=UTF-8", | ||
| 62 | ); | ||
| 63 | assert.equal(forwardedRequest.body._tag, "Uint8Array"); | ||
| 64 | if (forwardedRequest.body._tag === "Uint8Array") { | ||
| 65 | assert.equal(new TextDecoder().decode(forwardedRequest.body.body), body); | ||
| 66 | } | ||
| 67 | }).pipe(Effect.provide(layer)); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it.effect( | ||
| 71 | "allows the custom Clerk Frontend API host encoded by the configured publishable key", | ||
| 72 | () => { | ||
| 73 | process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes"); | ||
| 74 | let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null; | ||
| 75 | const layer = makeHttpClientLayer((request) => | ||
| 76 | Effect.sync(() => { | ||
| 77 | forwardedRequest = request; | ||
| 78 | return HttpClientResponse.fromWeb( | ||
| 79 | request, | ||
| 80 | Response.json({ response: { object: "client" } }), | ||
| 81 | ); | ||
| 82 | }), | ||
| 83 | ); | ||
| 84 | |||
| 85 | return Effect.gen(function* () { | ||
| 86 | yield* fetchCloudAuth.handler({ | ||
| 87 | url: "https://clerk.t3.codes/v1/client", | ||
| 88 | method: "GET", | ||
| 89 | headers: {}, | ||
| 90 | }); | ||
| 91 | |||
| 92 | assert(forwardedRequest !== null); | ||
| 93 | assert.equal(forwardedRequest.url.toString(), "https://clerk.t3.codes/v1/client"); | ||
| 94 | }).pipe(Effect.provide(layer)); | ||
| 95 | }, | ||
| 96 | ); | ||
| 97 | |||
| 98 | it("rejects arbitrary HTTPS hosts that are not configured Clerk Frontend API hosts", () => { | ||
| 99 | process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes"); | ||
| 100 | assert.throws( | ||
| 101 | () => validateClerkFrontendApiUrl("https://attacker.example/v1/client"), | ||
| 102 | /restricted to Clerk Frontend API HTTPS hosts/u, | ||
| 103 | ); | ||
| 104 | }); | ||
| 105 | }); | ||
apps/desktop/src/ipc/methods/cloudAuth.ts created+154| ... | @@ -0,0 +1,154 @@ | ||
| 1 | import { | ||
| 2 | DesktopCloudAuthFetchInputSchema, | ||
| 3 | DesktopCloudAuthFetchResultSchema, | ||
| 4 | } from "@t3tools/contracts"; | ||
| 5 | import { | ||
| 6 | clerkFrontendApiHostnameFromPublishableKey, | ||
| 7 | isAllowedClerkFrontendApiHostname, | ||
| 8 | } from "@t3tools/shared/relayAuth"; | ||
| 9 | import * as Data from "effect/Data"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 14 | |||
| 15 | import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts"; | ||
| 16 | import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts"; | ||
| 17 | import * as IpcChannels from "../channels.ts"; | ||
| 18 | import { makeIpcMethod } from "../DesktopIpc.ts"; | ||
| 19 | |||
| 20 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; | ||
| 21 | |||
| 22 | export class DesktopCloudAuthFetchError extends Data.TaggedError("DesktopCloudAuthFetchError")<{ | ||
| 23 | readonly reason: string; | ||
| 24 | readonly cause?: unknown; | ||
| 25 | }> { | ||
| 26 | override get message() { | ||
| 27 | return this.reason; | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | function configuredClerkFrontendApiHostname(): string | null { | ||
| 32 | const publishableKey = | ||
| 33 | process.env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() || | ||
| 34 | (typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" | ||
| 35 | ? "" | ||
| 36 | : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__.trim()); | ||
| 37 | if (!publishableKey) return null; | ||
| 38 | |||
| 39 | return clerkFrontendApiHostnameFromPublishableKey(publishableKey); | ||
| 40 | } | ||
| 41 | |||
| 42 | const allowedClerkFrontendApiHosts = (hostname: string): boolean => | ||
| 43 | isAllowedClerkFrontendApiHostname(hostname, configuredClerkFrontendApiHostname()); | ||
| 44 | |||
| 45 | export function validateClerkFrontendApiUrl(rawUrl: string): URL { | ||
| 46 | const url = new URL(rawUrl); | ||
| 47 | if (url.protocol !== "https:" || !allowedClerkFrontendApiHosts(url.hostname)) { | ||
| 48 | throw new DesktopCloudAuthFetchError({ | ||
| 49 | reason: "Desktop cloud auth fetch is restricted to Clerk Frontend API HTTPS hosts.", | ||
| 50 | }); | ||
| 51 | } | ||
| 52 | return url; | ||
| 53 | } | ||
| 54 | |||
| 55 | export const createCloudAuthRequest = makeIpcMethod({ | ||
| 56 | channel: IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL, | ||
| 57 | payload: Schema.Void, | ||
| 58 | result: Schema.String, | ||
| 59 | handler: Effect.fn("desktop.ipc.cloudAuth.createRequest")(function* () { | ||
| 60 | const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth; | ||
| 61 | return yield* cloudAuth.createRequest; | ||
| 62 | }), | ||
| 63 | }); | ||
| 64 | |||
| 65 | export const getCloudAuthToken = makeIpcMethod({ | ||
| 66 | channel: IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL, | ||
| 67 | payload: Schema.Void, | ||
| 68 | result: Schema.NullOr(Schema.String), | ||
| 69 | handler: Effect.fn("desktop.ipc.cloudAuth.getToken")(function* () { | ||
| 70 | const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; | ||
| 71 | return Option.getOrNull(yield* tokenStore.get); | ||
| 72 | }), | ||
| 73 | }); | ||
| 74 | |||
| 75 | export const setCloudAuthToken = makeIpcMethod({ | ||
| 76 | channel: IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, | ||
| 77 | payload: Schema.String, | ||
| 78 | result: Schema.Boolean, | ||
| 79 | handler: Effect.fn("desktop.ipc.cloudAuth.setToken")(function* (token) { | ||
| 80 | const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; | ||
| 81 | return yield* tokenStore.set(token); | ||
| 82 | }), | ||
| 83 | }); | ||
| 84 | |||
| 85 | export const clearCloudAuthToken = makeIpcMethod({ | ||
| 86 | channel: IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL, | ||
| 87 | payload: Schema.Void, | ||
| 88 | result: Schema.Void, | ||
| 89 | handler: Effect.fn("desktop.ipc.cloudAuth.clearToken")(function* () { | ||
| 90 | const tokenStore = yield* DesktopCloudAuthTokenStore.DesktopCloudAuthTokenStore; | ||
| 91 | yield* tokenStore.clear; | ||
| 92 | }), | ||
| 93 | }); | ||
| 94 | |||
| 95 | export const fetchCloudAuth = makeIpcMethod({ | ||
| 96 | channel: IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, | ||
| 97 | payload: DesktopCloudAuthFetchInputSchema, | ||
| 98 | result: DesktopCloudAuthFetchResultSchema, | ||
| 99 | handler: Effect.fn("desktop.ipc.cloudAuth.fetch")(function* (input) { | ||
| 100 | const url = yield* Effect.try({ | ||
| 101 | try: () => validateClerkFrontendApiUrl(input.url), | ||
| 102 | catch: (cause) => | ||
| 103 | cause instanceof DesktopCloudAuthFetchError | ||
| 104 | ? cause | ||
| 105 | : new DesktopCloudAuthFetchError({ | ||
| 106 | reason: "Desktop cloud auth fetch received an invalid URL.", | ||
| 107 | cause, | ||
| 108 | }), | ||
| 109 | }); | ||
| 110 | |||
| 111 | const requestWithoutBody = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")( | ||
| 112 | url, | ||
| 113 | { | ||
| 114 | headers: input.headers, | ||
| 115 | }, | ||
| 116 | ); | ||
| 117 | const request = | ||
| 118 | input.body === undefined | ||
| 119 | ? requestWithoutBody | ||
| 120 | : HttpClientRequest.bodyText( | ||
| 121 | requestWithoutBody, | ||
| 122 | input.body, | ||
| 123 | Option.getOrUndefined(Headers.get(requestWithoutBody.headers, "content-type")), | ||
| 124 | ); | ||
| 125 | |||
| 126 | const response = yield* HttpClient.execute(request).pipe( | ||
| 127 | Effect.mapError( | ||
| 128 | (cause) => | ||
| 129 | new DesktopCloudAuthFetchError({ | ||
| 130 | reason: "Desktop cloud auth fetch failed.", | ||
| 131 | cause, | ||
| 132 | }), | ||
| 133 | ), | ||
| 134 | ); | ||
| 135 | |||
| 136 | const body = yield* response.text.pipe( | ||
| 137 | Effect.mapError( | ||
| 138 | (cause) => | ||
| 139 | new DesktopCloudAuthFetchError({ | ||
| 140 | reason: "Desktop cloud auth fetch response could not be read.", | ||
| 141 | cause, | ||
| 142 | }), | ||
| 143 | ), | ||
| 144 | ); | ||
| 145 | |||
| 146 | return { | ||
| 147 | ok: response.status >= 200 && response.status < 300, | ||
| 148 | status: response.status, | ||
| 149 | statusText: "", | ||
| 150 | headers: response.headers, | ||
| 151 | body, | ||
| 152 | }; | ||
| 153 | }), | ||
| 154 | }); | ||
apps/desktop/src/main.ts+4| ... | @@ -26,6 +26,8 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; | ... | @@ -26,6 +26,8 @@ import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; |
| 26 | import * as ElectronWindow from "./electron/ElectronWindow.ts"; | 26 | import * as ElectronWindow from "./electron/ElectronWindow.ts"; |
| 27 | import * as DesktopApp from "./app/DesktopApp.ts"; | 27 | import * as DesktopApp from "./app/DesktopApp.ts"; |
| 28 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; | 28 | import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; |
| 29 | import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; | ||
| 30 | import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; | ||
| 29 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; | 31 | import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; |
| 30 | import * as DesktopAssets from "./app/DesktopAssets.ts"; | 32 | import * as DesktopAssets from "./app/DesktopAssets.ts"; |
| 31 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; | 33 | import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; |
| ... | @@ -111,6 +113,7 @@ const desktopFoundationLayer = Layer.mergeAll( | ... | @@ -111,6 +113,7 @@ const desktopFoundationLayer = Layer.mergeAll( |
| 111 | DesktopAppSettings.layer, | 113 | DesktopAppSettings.layer, |
| 112 | DesktopClientSettings.layer, | 114 | DesktopClientSettings.layer, |
| 113 | DesktopSavedEnvironments.layer, | 115 | DesktopSavedEnvironments.layer, |
| 116 | DesktopCloudAuthTokenStore.layer, | ||
| 114 | DesktopAssets.layer, | 117 | DesktopAssets.layer, |
| 115 | DesktopObservability.layer, | 118 | DesktopObservability.layer, |
| 116 | ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); | 119 | ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); |
| ... | @@ -135,6 +138,7 @@ const desktopBackendLayer = DesktopBackendManager.layer.pipe( | ... | @@ -135,6 +138,7 @@ const desktopBackendLayer = DesktopBackendManager.layer.pipe( |
| 135 | const desktopApplicationLayer = Layer.mergeAll( | 138 | const desktopApplicationLayer = Layer.mergeAll( |
| 136 | DesktopLifecycle.layer, | 139 | DesktopLifecycle.layer, |
| 137 | DesktopApplicationMenu.layer, | 140 | DesktopApplicationMenu.layer, |
| 141 | DesktopCloudAuth.layer, | ||
| 138 | DesktopShellEnvironment.layer, | 142 | DesktopShellEnvironment.layer, |
| 139 | desktopSshLayer, | 143 | desktopSshLayer, |
| 140 | ).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); | 144 | ).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer)); |
apps/desktop/src/preload.ts+17| ... | @@ -96,6 +96,23 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -96,6 +96,23 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 96 | ...(position === undefined ? {} : { position }), | 96 | ...(position === undefined ? {} : { position }), |
| 97 | }), | 97 | }), |
| 98 | openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), | 98 | openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), |
| 99 | createCloudAuthRequest: () => ipcRenderer.invoke(IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL), | ||
| 100 | getCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.GET_CLOUD_AUTH_TOKEN_CHANNEL), | ||
| 101 | setCloudAuthToken: (token: string) => | ||
| 102 | ipcRenderer.invoke(IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, token), | ||
| 103 | clearCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL), | ||
| 104 | fetchCloudAuth: (input) => ipcRenderer.invoke(IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, input), | ||
| 105 | onCloudAuthCallback: (listener) => { | ||
| 106 | const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => { | ||
| 107 | if (typeof rawUrl !== "string") return; | ||
| 108 | listener(rawUrl); | ||
| 109 | }; | ||
| 110 | |||
| 111 | ipcRenderer.on(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener); | ||
| 112 | return () => { | ||
| 113 | ipcRenderer.removeListener(IpcChannels.CLOUD_AUTH_CALLBACK_CHANNEL, wrappedListener); | ||
| 114 | }; | ||
| 115 | }, | ||
| 99 | onMenuAction: (listener) => { | 116 | onMenuAction: (listener) => { |
| 100 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { | 117 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { |
| 101 | if (typeof action !== "string") return; | 118 | if (typeof action !== "string") return; |
apps/desktop/src/settings/DesktopSavedEnvironments.ts+12-14| ... | @@ -53,6 +53,7 @@ const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ | ... | @@ -53,6 +53,7 @@ const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({ |
| 53 | createdAt: Schema.String, | 53 | createdAt: Schema.String, |
| 54 | lastConnectedAt: Schema.NullOr(Schema.String), | 54 | lastConnectedAt: Schema.NullOr(Schema.String), |
| 55 | desktopSsh: Schema.optionalKey(DesktopSshTargetSchema), | 55 | desktopSsh: Schema.optionalKey(DesktopSshTargetSchema), |
| 56 | relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })), | ||
| 56 | encryptedBearerToken: Schema.optionalKey(Schema.String), | 57 | encryptedBearerToken: Schema.optionalKey(Schema.String), |
| 57 | }); | 58 | }); |
| 58 | 59 | ||
| ... | @@ -134,7 +135,11 @@ function toPersistedSavedEnvironmentRecord( | ... | @@ -134,7 +135,11 @@ function toPersistedSavedEnvironmentRecord( |
| 134 | createdAt: record.createdAt, | 135 | createdAt: record.createdAt, |
| 135 | lastConnectedAt: record.lastConnectedAt, | 136 | lastConnectedAt: record.lastConnectedAt, |
| 136 | }; | 137 | }; |
| 137 | return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | 138 | return { |
| 139 | ...nextRecord, | ||
| 140 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 141 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 142 | }; | ||
| 138 | } | 143 | } |
| 139 | 144 | ||
| 140 | function toSavedEnvironmentStorageRecord( | 145 | function toSavedEnvironmentStorageRecord( |
| ... | @@ -149,20 +154,13 @@ function toSavedEnvironmentStorageRecord( | ... | @@ -149,20 +154,13 @@ function toSavedEnvironmentStorageRecord( |
| 149 | createdAt: record.createdAt, | 154 | createdAt: record.createdAt, |
| 150 | lastConnectedAt: record.lastConnectedAt, | 155 | lastConnectedAt: record.lastConnectedAt, |
| 151 | }; | 156 | }; |
| 152 | const desktopSsh = record.desktopSsh; | 157 | const metadata = { |
| 153 | if (desktopSsh) { | 158 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), |
| 154 | return Option.match(encryptedBearerToken, { | 159 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), |
| 155 | onNone: () => ({ ...nextRecord, desktopSsh }), | 160 | }; |
| 156 | onSome: (value) => ({ | ||
| 157 | ...nextRecord, | ||
| 158 | desktopSsh, | ||
| 159 | encryptedBearerToken: value, | ||
| 160 | }), | ||
| 161 | }); | ||
| 162 | } | ||
| 163 | return Option.match(encryptedBearerToken, { | 161 | return Option.match(encryptedBearerToken, { |
| 164 | onNone: () => nextRecord, | 162 | onNone: () => ({ ...nextRecord, ...metadata }), |
| 165 | onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }), | 163 | onSome: (value) => ({ ...nextRecord, ...metadata, encryptedBearerToken: value }), |
| 166 | }); | 164 | }); |
| 167 | } | 165 | } |
| 168 | 166 |
apps/desktop/src/window/DesktopApplicationMenu.test.ts+3| ... | @@ -39,6 +39,9 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { | ... | @@ -39,6 +39,9 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { |
| 39 | setName: () => Effect.void, | 39 | setName: () => Effect.void, |
| 40 | setAboutPanelOptions: () => Effect.void, | 40 | setAboutPanelOptions: () => Effect.void, |
| 41 | setAppUserModelId: () => Effect.void, | 41 | setAppUserModelId: () => Effect.void, |
| 42 | requestSingleInstanceLock: Effect.succeed(true), | ||
| 43 | isDefaultProtocolClient: () => Effect.succeed(false), | ||
| 44 | setAsDefaultProtocolClient: () => Effect.succeed(true), | ||
| 42 | setDesktopName: () => Effect.void, | 45 | setDesktopName: () => Effect.void, |
| 43 | setDockIcon: () => Effect.void, | 46 | setDockIcon: () => Effect.void, |
| 44 | appendCommandLineSwitch: () => Effect.void, | 47 | appendCommandLineSwitch: () => Effect.void, |
apps/desktop/src/window/DesktopWindow.test.ts+73-7| ... | @@ -32,10 +32,13 @@ const environmentInput = { | ... | @@ -32,10 +32,13 @@ const environmentInput = { |
| 32 | } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; | 32 | } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; |
| 33 | 33 | ||
| 34 | function makeFakeBrowserWindow() { | 34 | function makeFakeBrowserWindow() { |
| 35 | const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>(); | ||
| 35 | const webContents = { | 36 | const webContents = { |
| 36 | copyImageAt: vi.fn(), | 37 | copyImageAt: vi.fn(), |
| 37 | isLoadingMainFrame: vi.fn(() => false), | 38 | isLoadingMainFrame: vi.fn(() => false), |
| 38 | on: vi.fn(), | 39 | on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { |
| 40 | webContentsListeners.set(eventName, listener); | ||
| 41 | }), | ||
| 39 | once: vi.fn(), | 42 | once: vi.fn(), |
| 40 | openDevTools: vi.fn(), | 43 | openDevTools: vi.fn(), |
| 41 | replaceMisspelling: vi.fn(), | 44 | replaceMisspelling: vi.fn(), |
| ... | @@ -63,6 +66,7 @@ function makeFakeBrowserWindow() { | ... | @@ -63,6 +66,7 @@ function makeFakeBrowserWindow() { |
| 63 | window: window as unknown as Electron.BrowserWindow, | 66 | window: window as unknown as Electron.BrowserWindow, |
| 64 | loadURL: window.loadURL, | 67 | loadURL: window.loadURL, |
| 65 | openDevTools: webContents.openDevTools, | 68 | openDevTools: webContents.openDevTools, |
| 69 | webContentsListeners, | ||
| 66 | }; | 70 | }; |
| 67 | } | 71 | } |
| 68 | 72 | ||
| ... | @@ -96,11 +100,6 @@ const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { | ... | @@ -96,11 +100,6 @@ const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { |
| 96 | showContextMenu: () => Effect.succeed(Option.none()), | 100 | showContextMenu: () => Effect.succeed(Option.none()), |
| 97 | } satisfies ElectronMenu.ElectronMenuShape); | 101 | } satisfies ElectronMenu.ElectronMenuShape); |
| 98 | 102 | ||
| 99 | const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, { | ||
| 100 | openExternal: () => Effect.succeed(true), | ||
| 101 | copyText: () => Effect.void, | ||
| 102 | } satisfies ElectronShell.ElectronShellShape); | ||
| 103 | |||
| 104 | const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { | 103 | const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { |
| 105 | shouldUseDarkColors: Effect.succeed(false), | 104 | shouldUseDarkColors: Effect.succeed(false), |
| 106 | setSource: () => Effect.void, | 105 | setSource: () => Effect.void, |
| ... | @@ -123,6 +122,7 @@ function makeTestLayer(input: { | ... | @@ -123,6 +122,7 @@ function makeTestLayer(input: { |
| 123 | readonly window: Electron.BrowserWindow; | 122 | readonly window: Electron.BrowserWindow; |
| 124 | readonly createCount: Ref.Ref<number>; | 123 | readonly createCount: Ref.Ref<number>; |
| 125 | readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>; | 124 | readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>; |
| 125 | readonly openedExternalUrls?: unknown[]; | ||
| 126 | }) { | 126 | }) { |
| 127 | const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { | 127 | const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { |
| 128 | create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), | 128 | create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)), |
| ... | @@ -145,7 +145,14 @@ function makeTestLayer(input: { | ... | @@ -145,7 +145,14 @@ function makeTestLayer(input: { |
| 145 | desktopServerExposureLayer, | 145 | desktopServerExposureLayer, |
| 146 | DesktopState.layer, | 146 | DesktopState.layer, |
| 147 | electronMenuLayer, | 147 | electronMenuLayer, |
| 148 | electronShellLayer, | 148 | Layer.succeed(ElectronShell.ElectronShell, { |
| 149 | openExternal: (url) => | ||
| 150 | Effect.sync(() => { | ||
| 151 | input.openedExternalUrls?.push(url); | ||
| 152 | return true; | ||
| 153 | }), | ||
| 154 | copyText: () => Effect.void, | ||
| 155 | } satisfies ElectronShell.ElectronShellShape), | ||
| 149 | electronThemeLayer, | 156 | electronThemeLayer, |
| 150 | electronWindowLayer, | 157 | electronWindowLayer, |
| 151 | ), | 158 | ), |
| ... | @@ -154,6 +161,27 @@ function makeTestLayer(input: { | ... | @@ -154,6 +161,27 @@ function makeTestLayer(input: { |
| 154 | } | 161 | } |
| 155 | 162 | ||
| 156 | describe("DesktopWindow", () => { | 163 | describe("DesktopWindow", () => { |
| 164 | it("recognizes only same-origin renderer navigations", () => { | ||
| 165 | assert.isTrue( | ||
| 166 | DesktopWindow.isSameOriginRendererNavigation({ | ||
| 167 | applicationUrl: "http://127.0.0.1:3773/", | ||
| 168 | navigationUrl: "http://127.0.0.1:3773/settings/cloud", | ||
| 169 | }), | ||
| 170 | ); | ||
| 171 | assert.isFalse( | ||
| 172 | DesktopWindow.isSameOriginRendererNavigation({ | ||
| 173 | applicationUrl: "http://127.0.0.1:3773/", | ||
| 174 | navigationUrl: "https://accounts.microsoft.com/oauth", | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | assert.isFalse( | ||
| 178 | DesktopWindow.isSameOriginRendererNavigation({ | ||
| 179 | applicationUrl: "http://127.0.0.1:3773/", | ||
| 180 | navigationUrl: "not a url", | ||
| 181 | }), | ||
| 182 | ); | ||
| 183 | }); | ||
| 184 | |||
| 157 | it.effect("does not open a development window until the backend is ready", () => | 185 | it.effect("does not open a development window until the backend is ready", () => |
| 158 | Effect.gen(function* () { | 186 | Effect.gen(function* () { |
| 159 | const fakeWindow = makeFakeBrowserWindow(); | 187 | const fakeWindow = makeFakeBrowserWindow(); |
| ... | @@ -177,4 +205,42 @@ describe("DesktopWindow", () => { | ... | @@ -177,4 +205,42 @@ describe("DesktopWindow", () => { |
| 177 | }).pipe(Effect.provide(layer)); | 205 | }).pipe(Effect.provide(layer)); |
| 178 | }), | 206 | }), |
| 179 | ); | 207 | ); |
| 208 | |||
| 209 | it.effect("opens safe off-origin renderer navigations in the system browser", () => | ||
| 210 | Effect.gen(function* () { | ||
| 211 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 212 | const createCount = yield* Ref.make(0); | ||
| 213 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 214 | const openedExternalUrls: unknown[] = []; | ||
| 215 | const layer = makeTestLayer({ | ||
| 216 | window: fakeWindow.window, | ||
| 217 | createCount, | ||
| 218 | mainWindow, | ||
| 219 | openedExternalUrls, | ||
| 220 | }); | ||
| 221 | |||
| 222 | yield* Effect.gen(function* () { | ||
| 223 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 224 | yield* desktopWindow.handleBackendReady; | ||
| 225 | |||
| 226 | const willNavigate = fakeWindow.webContentsListeners.get("will-navigate"); | ||
| 227 | if (!willNavigate) { | ||
| 228 | return yield* Effect.die("will-navigate listener was not registered"); | ||
| 229 | } | ||
| 230 | let prevented = false; | ||
| 231 | willNavigate( | ||
| 232 | { | ||
| 233 | preventDefault: () => { | ||
| 234 | prevented = true; | ||
| 235 | }, | ||
| 236 | }, | ||
| 237 | "https://accounts.microsoft.com/oauth", | ||
| 238 | ); | ||
| 239 | yield* Effect.promise(() => Promise.resolve()); | ||
| 240 | |||
| 241 | assert.isTrue(prevented); | ||
| 242 | assert.deepEqual(openedExternalUrls, ["https://accounts.microsoft.com/oauth"]); | ||
| 243 | }).pipe(Effect.provide(layer)); | ||
| 244 | }), | ||
| 245 | ); | ||
| 180 | }); | 246 | }); |
apps/desktop/src/window/DesktopWindow.ts+31-3| ... | @@ -92,6 +92,17 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { | ... | @@ -92,6 +92,17 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { |
| 92 | return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; | 92 | return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; |
| 93 | } | 93 | } |
| 94 | 94 | ||
| 95 | export function isSameOriginRendererNavigation(input: { | ||
| 96 | readonly applicationUrl: string; | ||
| 97 | readonly navigationUrl: string; | ||
| 98 | }): boolean { | ||
| 99 | try { | ||
| 100 | return new URL(input.applicationUrl).origin === new URL(input.navigationUrl).origin; | ||
| 101 | } catch { | ||
| 102 | return false; | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 95 | function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { | 106 | function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions { |
| 96 | if (process.platform === "darwin") { | 107 | if (process.platform === "darwin") { |
| 97 | return { | 108 | return { |
| ... | @@ -159,6 +170,9 @@ const make = Effect.gen(function* () { | ... | @@ -159,6 +170,9 @@ const make = Effect.gen(function* () { |
| 159 | const createWindow = Effect.fn("desktop.window.createWindow")(function* ( | 170 | const createWindow = Effect.fn("desktop.window.createWindow")(function* ( |
| 160 | backendHttpUrl: URL, | 171 | backendHttpUrl: URL, |
| 161 | ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> { | 172 | ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> { |
| 173 | const applicationUrl = environment.isDevelopment | ||
| 174 | ? yield* resolveDesktopDevServerUrl(environment) | ||
| 175 | : backendHttpUrl.href; | ||
| 162 | const iconPaths = yield* assets.iconPaths; | 176 | const iconPaths = yield* assets.iconPaths; |
| 163 | const iconOption = getIconOption(iconPaths); | 177 | const iconOption = getIconOption(iconPaths); |
| 164 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; | 178 | const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; |
| ... | @@ -235,6 +249,21 @@ const make = Effect.gen(function* () { | ... | @@ -235,6 +249,21 @@ const make = Effect.gen(function* () { |
| 235 | } | 249 | } |
| 236 | return { action: "deny" }; | 250 | return { action: "deny" }; |
| 237 | }); | 251 | }); |
| 252 | window.webContents.on("will-navigate", (event, url) => { | ||
| 253 | if ( | ||
| 254 | isSameOriginRendererNavigation({ | ||
| 255 | applicationUrl, | ||
| 256 | navigationUrl: url, | ||
| 257 | }) | ||
| 258 | ) { | ||
| 259 | return; | ||
| 260 | } | ||
| 261 | |||
| 262 | event.preventDefault(); | ||
| 263 | if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { | ||
| 264 | void runPromise(electronShell.openExternal(url)); | ||
| 265 | } | ||
| 266 | }); | ||
| 238 | 267 | ||
| 239 | window.on("page-title-updated", (event) => { | 268 | window.on("page-title-updated", (event) => { |
| 240 | event.preventDefault(); | 269 | event.preventDefault(); |
| ... | @@ -276,11 +305,10 @@ const make = Effect.gen(function* () { | ... | @@ -276,11 +305,10 @@ const make = Effect.gen(function* () { |
| 276 | }); | 305 | }); |
| 277 | 306 | ||
| 278 | if (environment.isDevelopment) { | 307 | if (environment.isDevelopment) { |
| 279 | const devServerUrl = yield* resolveDesktopDevServerUrl(environment); | 308 | void window.loadURL(applicationUrl); |
| 280 | void window.loadURL(devServerUrl); | ||
| 281 | window.webContents.openDevTools({ mode: "detach" }); | 309 | window.webContents.openDevTools({ mode: "detach" }); |
| 282 | } else { | 310 | } else { |
| 283 | void window.loadURL(backendHttpUrl.href); | 311 | void window.loadURL(applicationUrl); |
| 284 | } | 312 | } |
| 285 | 313 | ||
| 286 | window.on("closed", () => { | 314 | window.on("closed", () => { |
apps/desktop/tsconfig.json+1-1| ... | @@ -5,5 +5,5 @@ | ... | @@ -5,5 +5,5 @@ |
| 5 | "types": ["node", "electron"], | 5 | "types": ["node", "electron"], |
| 6 | "lib": ["ESNext", "DOM", "esnext.disposable"] | 6 | "lib": ["ESNext", "DOM", "esnext.disposable"] |
| 7 | }, | 7 | }, |
| 8 | "include": ["src", "vite.config.ts"] | 8 | "include": ["src", "vite.config.ts", "../../scripts/lib"] |
| 9 | } | 9 | } |
apps/desktop/vite.config.ts+10| ... | @@ -1,6 +1,14 @@ | ... | @@ -1,6 +1,14 @@ |
| 1 | import { defineConfig } from "vite-plus"; | 1 | import { defineConfig } from "vite-plus"; |
| 2 | 2 | ||
| 3 | import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; | ||
| 4 | |||
| 5 | const repoEnv = loadRepoEnv(); | ||
| 3 | const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1"; | 6 | const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1"; |
| 7 | const publicConfigDefine = { | ||
| 8 | __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( | ||
| 9 | repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "", | ||
| 10 | ), | ||
| 11 | }; | ||
| 4 | 12 | ||
| 5 | export default defineConfig({ | 13 | export default defineConfig({ |
| 6 | run: { | 14 | run: { |
| ... | @@ -32,6 +40,7 @@ export default defineConfig({ | ... | @@ -32,6 +40,7 @@ export default defineConfig({ |
| 32 | outDir: "dist-electron", | 40 | outDir: "dist-electron", |
| 33 | sourcemap: true, | 41 | sourcemap: true, |
| 34 | outExtensions: () => ({ js: ".cjs" }), | 42 | outExtensions: () => ({ js: ".cjs" }), |
| 43 | define: publicConfigDefine, | ||
| 35 | entry: ["src/main.ts"], | 44 | entry: ["src/main.ts"], |
| 36 | clean: true, | 45 | clean: true, |
| 37 | deps: { | 46 | deps: { |
| ... | @@ -44,6 +53,7 @@ export default defineConfig({ | ... | @@ -44,6 +53,7 @@ export default defineConfig({ |
| 44 | outDir: "dist-electron", | 53 | outDir: "dist-electron", |
| 45 | sourcemap: true, | 54 | sourcemap: true, |
| 46 | outExtensions: () => ({ js: ".cjs" }), | 55 | outExtensions: () => ({ js: ".cjs" }), |
| 56 | define: publicConfigDefine, | ||
| 47 | entry: ["src/preload.ts"], | 57 | entry: ["src/preload.ts"], |
| 48 | }, | 58 | }, |
| 49 | ], | 59 | ], |
apps/mobile/README.md+8| ... | @@ -16,6 +16,10 @@ This app has three variants: | ... | @@ -16,6 +16,10 @@ This app has three variants: |
| 16 | 16 | ||
| 17 | Run commands from `apps/mobile`. | 17 | Run commands from `apps/mobile`. |
| 18 | 18 | ||
| 19 | T3 Cloud is optional and disabled in a fresh clone. Public configuration belongs in the | ||
| 20 | repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See | ||
| 21 | [`../../.env.example`](../../.env.example). | ||
| 22 | |||
| 19 | ## Development | 23 | ## Development |
| 20 | 24 | ||
| 21 | Start Metro for the dev client: | 25 | Start Metro for the dev client: |
| ... | @@ -63,6 +67,10 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. | ... | @@ -63,6 +67,10 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. |
| 63 | 67 | ||
| 64 | 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. | 68 | 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. |
| 65 | 69 | ||
| 70 | For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY`, | ||
| 71 | `T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL` | ||
| 72 | as EAS environment variables. Expo config maps the canonical values into the mobile build. | ||
| 73 | |||
| 66 | Create a PR preview dev-client build manually: | 74 | Create a PR preview dev-client build manually: |
| 67 | 75 | ||
| 68 | ```bash | 76 | ```bash |
apps/mobile/app.config.ts+35-5| ... | @@ -1,8 +1,13 @@ | ... | @@ -1,8 +1,13 @@ |
| 1 | import type { ExpoConfig } from "expo/config"; | 1 | import type { ExpoConfig } from "expo/config"; |
| 2 | 2 | ||
| 3 | import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; | ||
| 4 | |||
| 3 | type AppVariant = "development" | "preview" | "production"; | 5 | type AppVariant = "development" | "preview" | "production"; |
| 4 | 6 | ||
| 5 | const APP_VARIANT = resolveAppVariant(process.env.APP_VARIANT); | 7 | const repoEnv = loadRepoEnv(); |
| 8 | Object.assign(process.env, repoEnv); | ||
| 9 | |||
| 10 | const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT); | ||
| 6 | 11 | ||
| 7 | const VARIANT_CONFIG: Record< | 12 | const VARIANT_CONFIG: Record< |
| 8 | AppVariant, | 13 | AppVariant, |
| ... | @@ -96,6 +101,11 @@ const config: ExpoConfig = { | ... | @@ -96,6 +101,11 @@ const config: ExpoConfig = { |
| 96 | favicon: "./assets/favicon.png", | 101 | favicon: "./assets/favicon.png", |
| 97 | }, | 102 | }, |
| 98 | plugins: [ | 103 | plugins: [ |
| 104 | "expo-router", | ||
| 105 | "expo-font", | ||
| 106 | "expo-secure-store", | ||
| 107 | ["@clerk/expo", { theme: "./clerk-theme.json" }], | ||
| 108 | "expo-web-browser", | ||
| 99 | [ | 109 | [ |
| 100 | "expo-camera", | 110 | "expo-camera", |
| 101 | { | 111 | { |
| ... | @@ -120,17 +130,37 @@ const config: ExpoConfig = { | ... | @@ -120,17 +130,37 @@ const config: ExpoConfig = { |
| 120 | "expo-build-properties", | 130 | "expo-build-properties", |
| 121 | { | 131 | { |
| 122 | ios: { | 132 | ios: { |
| 123 | deploymentTarget: "16.4", | 133 | deploymentTarget: "18.0", |
| 124 | }, | 134 | }, |
| 125 | }, | 135 | }, |
| 126 | ], | 136 | ], |
| 127 | "expo-secure-store", | 137 | [ |
| 128 | "expo-router", | 138 | "expo-widgets", |
| 129 | "expo-font", | 139 | { |
| 140 | bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, | ||
| 141 | groupIdentifier: `group.${variant.iosBundleIdentifier}`, | ||
| 142 | enablePushNotifications: true, | ||
| 143 | widgets: [ | ||
| 144 | { | ||
| 145 | name: "AgentActivity", | ||
| 146 | displayName: "Agent Activity", | ||
| 147 | description: "Shows the current state of active T3 Code agents.", | ||
| 148 | supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], | ||
| 149 | }, | ||
| 150 | ], | ||
| 151 | }, | ||
| 152 | ], | ||
| 130 | "./plugins/withAndroidCleartextTraffic.cjs", | 153 | "./plugins/withAndroidCleartextTraffic.cjs", |
| 131 | ], | 154 | ], |
| 132 | extra: { | 155 | extra: { |
| 133 | appVariant: APP_VARIANT, | 156 | appVariant: APP_VARIANT, |
| 157 | relay: { | ||
| 158 | url: repoEnv.T3CODE_RELAY_URL ?? null, | ||
| 159 | }, | ||
| 160 | clerk: { | ||
| 161 | publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null, | ||
| 162 | jwtTemplate: repoEnv.EXPO_PUBLIC_CLERK_JWT_TEMPLATE ?? null, | ||
| 163 | }, | ||
| 134 | eas: { | 164 | eas: { |
| 135 | projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", | 165 | projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", |
| 136 | }, | 166 | }, |
apps/mobile/clerk-theme.json created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | { | ||
| 2 | "colors": { | ||
| 3 | "primary": "#262626", | ||
| 4 | "background": "#F2F2F7", | ||
| 5 | "input": "#FFFFFF", | ||
| 6 | "danger": "#DC2626", | ||
| 7 | "success": "#059669", | ||
| 8 | "warning": "#D97706", | ||
| 9 | "foreground": "#262626", | ||
| 10 | "mutedForeground": "#737373", | ||
| 11 | "primaryForeground": "#FFFFFF", | ||
| 12 | "inputForeground": "#262626", | ||
| 13 | "neutral": "#F5F5F5", | ||
| 14 | "border": "#E5E5EA", | ||
| 15 | "ring": "#A3A3A3", | ||
| 16 | "muted": "#F5F5F5", | ||
| 17 | "shadow": "#000000" | ||
| 18 | }, | ||
| 19 | "darkColors": { | ||
| 20 | "primary": "#F5F5F5", | ||
| 21 | "background": "#0E0E0E", | ||
| 22 | "input": "#171717", | ||
| 23 | "danger": "#FCA5A5", | ||
| 24 | "success": "#34D399", | ||
| 25 | "warning": "#FBBF24", | ||
| 26 | "foreground": "#F5F5F5", | ||
| 27 | "mutedForeground": "#A3A3A3", | ||
| 28 | "primaryForeground": "#0A0A0A", | ||
| 29 | "inputForeground": "#F5F5F5", | ||
| 30 | "neutral": "#1C1C1C", | ||
| 31 | "border": "#2A2A2A", | ||
| 32 | "ring": "#525252", | ||
| 33 | "muted": "#1C1C1C", | ||
| 34 | "shadow": "#000000" | ||
| 35 | }, | ||
| 36 | "design": { | ||
| 37 | "borderRadius": 18 | ||
| 38 | } | ||
| 39 | } | ||
apps/mobile/global.css+2| ... | @@ -38,6 +38,7 @@ | ... | @@ -38,6 +38,7 @@ |
| 38 | --color-secondary: #ffffff; | 38 | --color-secondary: #ffffff; |
| 39 | --color-secondary-foreground: #262626; | 39 | --color-secondary-foreground: #262626; |
| 40 | --color-secondary-border: rgba(0, 0, 0, 0.08); | 40 | --color-secondary-border: rgba(0, 0, 0, 0.08); |
| 41 | --color-switch-active: #34c759; | ||
| 41 | 42 | ||
| 42 | /* Danger */ | 43 | /* Danger */ |
| 43 | --color-danger: #fef2f2; | 44 | --color-danger: #fef2f2; |
| ... | @@ -125,6 +126,7 @@ | ... | @@ -125,6 +126,7 @@ |
| 125 | --color-secondary: rgba(255, 255, 255, 0.04); | 126 | --color-secondary: rgba(255, 255, 255, 0.04); |
| 126 | --color-secondary-foreground: #f5f5f5; | 127 | --color-secondary-foreground: #f5f5f5; |
| 127 | --color-secondary-border: rgba(255, 255, 255, 0.06); | 128 | --color-secondary-border: rgba(255, 255, 255, 0.06); |
| 129 | --color-switch-active: #30d158; | ||
| 128 | 130 | ||
| 129 | /* Danger */ | 131 | /* Danger */ |
| 130 | --color-danger: rgba(239, 68, 68, 0.14); | 132 | --color-danger: rgba(239, 68, 68, 0.14); |
apps/mobile/package.json+8| ... | @@ -39,10 +39,13 @@ | ... | @@ -39,10 +39,13 @@ |
| 39 | }, | 39 | }, |
| 40 | "dependencies": { | 40 | "dependencies": { |
| 41 | "@callstack/liquid-glass": "^0.7.1", | 41 | "@callstack/liquid-glass": "^0.7.1", |
| 42 | "@clerk/expo": "^3.3.0", | ||
| 42 | "@effect/atom-react": "catalog:", | 43 | "@effect/atom-react": "catalog:", |
| 43 | "@expo-google-fonts/dm-sans": "^0.4.2", | 44 | "@expo-google-fonts/dm-sans": "^0.4.2", |
| 44 | "@expo/ui": "~56.0.8", | 45 | "@expo/ui": "~56.0.8", |
| 45 | "@legendapp/list": "3.0.0-beta.44", | 46 | "@legendapp/list": "3.0.0-beta.44", |
| 47 | "@noble/curves": "catalog:", | ||
| 48 | "@noble/hashes": "catalog:", | ||
| 46 | "@pierre/diffs": "catalog:", | 49 | "@pierre/diffs": "catalog:", |
| 47 | "@react-native-menu/menu": "^2.0.0", | 50 | "@react-native-menu/menu": "^2.0.0", |
| 48 | "@shikijs/core": "3.23.0", | 51 | "@shikijs/core": "3.23.0", |
| ... | @@ -58,6 +61,7 @@ | ... | @@ -58,6 +61,7 @@ |
| 58 | "diff": "8.0.3", | 61 | "diff": "8.0.3", |
| 59 | "effect": "catalog:", | 62 | "effect": "catalog:", |
| 60 | "expo": "^56.0.0", | 63 | "expo": "^56.0.0", |
| 64 | "expo-auth-session": "~56.0.12", | ||
| 61 | "expo-build-properties": "~56.0.15", | 65 | "expo-build-properties": "~56.0.15", |
| 62 | "expo-camera": "~56.0.7", | 66 | "expo-camera": "~56.0.7", |
| 63 | "expo-clipboard": "~56.0.3", | 67 | "expo-clipboard": "~56.0.3", |
| ... | @@ -70,12 +74,15 @@ | ... | @@ -70,12 +74,15 @@ |
| 70 | "expo-haptics": "~56.0.3", | 74 | "expo-haptics": "~56.0.3", |
| 71 | "expo-image-picker": "~56.0.14", | 75 | "expo-image-picker": "~56.0.14", |
| 72 | "expo-linking": "~56.0.12", | 76 | "expo-linking": "~56.0.12", |
| 77 | "expo-notifications": "~56.0.14", | ||
| 73 | "expo-paste-input": "^0.1.15", | 78 | "expo-paste-input": "^0.1.15", |
| 74 | "expo-router": "~56.2.7", | 79 | "expo-router": "~56.2.7", |
| 75 | "expo-secure-store": "~56.0.4", | 80 | "expo-secure-store": "~56.0.4", |
| 76 | "expo-splash-screen": "~56.0.10", | 81 | "expo-splash-screen": "~56.0.10", |
| 77 | "expo-symbols": "~56.0.5", | 82 | "expo-symbols": "~56.0.5", |
| 78 | "expo-updates": "~56.0.17", | 83 | "expo-updates": "~56.0.17", |
| 84 | "expo-web-browser": "~56.0.5", | ||
| 85 | "expo-widgets": "~56.0.15", | ||
| 79 | "punycode": "^2.3.1", | 86 | "punycode": "^2.3.1", |
| 80 | "react": "19.2.3", | 87 | "react": "19.2.3", |
| 81 | "react-dom": "19.2.3", | 88 | "react-dom": "19.2.3", |
| ... | @@ -96,6 +103,7 @@ | ... | @@ -96,6 +103,7 @@ |
| 96 | "uniwind": "^1.6.2" | 103 | "uniwind": "^1.6.2" |
| 97 | }, | 104 | }, |
| 98 | "devDependencies": { | 105 | "devDependencies": { |
| 106 | "@effect/vitest": "catalog:", | ||
| 99 | "@types/react": "~19.2.0", | 107 | "@types/react": "~19.2.0", |
| 100 | "babel-preset-expo": "~56.0.0", | 108 | "babel-preset-expo": "~56.0.0", |
| 101 | "tailwindcss": "^4.0.0", | 109 | "tailwindcss": "^4.0.0", |
apps/mobile/src/app/_layout.tsx+26-11| ... | @@ -10,7 +10,7 @@ import { StatusBar, useColorScheme } from "react-native"; | ... | @@ -10,7 +10,7 @@ import { StatusBar, useColorScheme } from "react-native"; |
| 10 | import { GestureHandlerRootView } from "react-native-gesture-handler"; | 10 | import { GestureHandlerRootView } from "react-native-gesture-handler"; |
| 11 | import { KeyboardProvider } from "react-native-keyboard-controller"; | 11 | import { KeyboardProvider } from "react-native-keyboard-controller"; |
| 12 | import { SafeAreaProvider } from "react-native-safe-area-context"; | 12 | import { SafeAreaProvider } from "react-native-safe-area-context"; |
| 13 | import { useCSSVariable, useResolveClassNames } from "uniwind"; | 13 | import { useResolveClassNames } from "uniwind"; |
| 14 | 14 | ||
| 15 | import { LoadingScreen } from "../components/LoadingScreen"; | 15 | import { LoadingScreen } from "../components/LoadingScreen"; |
| 16 | 16 | ||
| ... | @@ -20,12 +20,15 @@ import { | ... | @@ -20,12 +20,15 @@ import { |
| 20 | } from "../state/use-remote-environment-registry"; | 20 | } from "../state/use-remote-environment-registry"; |
| 21 | import { RegistryContext } from "@effect/atom-react"; | 21 | import { RegistryContext } from "@effect/atom-react"; |
| 22 | import { appAtomRegistry } from "../state/atom-registry"; | 22 | import { appAtomRegistry } from "../state/atom-registry"; |
| 23 | import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; | ||
| 24 | import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; | ||
| 23 | 25 | ||
| 24 | function AppNavigator() { | 26 | function AppNavigator() { |
| 25 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); | 27 | const { isLoadingSavedConnection } = useRemoteEnvironmentState(); |
| 26 | const colorScheme = useColorScheme(); | 28 | const colorScheme = useColorScheme(); |
| 27 | const statusBarBg = useCSSVariable("--color-status-bar"); | 29 | const statusBarBg = colorScheme === "dark" ? "#0a0a0a" : "#f2f2f7"; |
| 28 | const sheetStyle = useResolveClassNames("bg-sheet"); | 30 | const sheetStyle = useResolveClassNames("bg-sheet"); |
| 31 | useAgentNotificationNavigation(); | ||
| 29 | 32 | ||
| 30 | const newTaskScreenOptions = { | 33 | const newTaskScreenOptions = { |
| 31 | contentStyle: sheetStyle, | 34 | contentStyle: sheetStyle, |
| ... | @@ -45,6 +48,11 @@ function AppNavigator() { | ... | @@ -45,6 +48,11 @@ function AppNavigator() { |
| 45 | sheetGrabberVisible: true, | 48 | sheetGrabberVisible: true, |
| 46 | }; | 49 | }; |
| 47 | 50 | ||
| 51 | const settingsSheetScreenOptions = { | ||
| 52 | ...connectionSheetScreenOptions, | ||
| 53 | sheetAllowedDetents: [0.7], | ||
| 54 | }; | ||
| 55 | |||
| 48 | if (isLoadingSavedConnection) { | 56 | if (isLoadingSavedConnection) { |
| 49 | return <LoadingScreen message="Loading remote workspace…" />; | 57 | return <LoadingScreen message="Loading remote workspace…" />; |
| 50 | } | 58 | } |
| ... | @@ -66,6 +74,7 @@ function AppNavigator() { | ... | @@ -66,6 +74,7 @@ function AppNavigator() { |
| 66 | headerShadowVisible: false, | 74 | headerShadowVisible: false, |
| 67 | }} | 75 | }} |
| 68 | /> | 76 | /> |
| 77 | <Stack.Screen name="settings" options={settingsSheetScreenOptions} /> | ||
| 69 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> | 78 | <Stack.Screen name="connections" options={connectionSheetScreenOptions} /> |
| 70 | <Stack.Screen name="new" options={newTaskScreenOptions} /> | 79 | <Stack.Screen name="new" options={newTaskScreenOptions} /> |
| 71 | <Stack.Screen | 80 | <Stack.Screen |
| ... | @@ -91,14 +100,20 @@ export default function RootLayout() { | ... | @@ -91,14 +100,20 @@ export default function RootLayout() { |
| 91 | useRemoteEnvironmentBootstrap(); | 100 | useRemoteEnvironmentBootstrap(); |
| 92 | 101 | ||
| 93 | return ( | 102 | return ( |
| 94 | <RegistryContext value={appAtomRegistry}> | 103 | <RegistryContext.Provider value={appAtomRegistry}> |
| 95 | <GestureHandlerRootView style={{ flex: 1 }}> | 104 | <CloudAuthProvider> |
| 96 | <KeyboardProvider statusBarTranslucent> | 105 | <GestureHandlerRootView style={{ flex: 1 }}> |
| 97 | <SafeAreaProvider> | 106 | <KeyboardProvider statusBarTranslucent> |
| 98 | {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />} | 107 | <SafeAreaProvider> |
| 99 | </SafeAreaProvider> | 108 | {fontsLoaded ? ( |
| 100 | </KeyboardProvider> | 109 | <AppNavigator /> |
| 101 | </GestureHandlerRootView> | 110 | ) : ( |
| 102 | </RegistryContext> | 111 | <LoadingScreen message="Loading remote workspace…" /> |
| 112 | )} | ||
| 113 | </SafeAreaProvider> | ||
| 114 | </KeyboardProvider> | ||
| 115 | </GestureHandlerRootView> | ||
| 116 | </CloudAuthProvider> | ||
| 117 | </RegistryContext.Provider> | ||
| 103 | ); | 118 | ); |
| 104 | } | 119 | } |
apps/mobile/src/app/connections/_layout.tsx+4-4| ... | @@ -1,16 +1,16 @@ | ... | @@ -1,16 +1,16 @@ |
| 1 | import Stack from "expo-router/stack"; | 1 | import Stack from "expo-router/stack"; |
| 2 | import { useColorScheme } from "react-native"; | ||
| 2 | import { useResolveClassNames } from "uniwind"; | 3 | import { useResolveClassNames } from "uniwind"; |
| 3 | 4 | ||
| 4 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 5 | |||
| 6 | export const unstable_settings = { | 5 | export const unstable_settings = { |
| 7 | anchor: "index", | 6 | anchor: "index", |
| 8 | }; | 7 | }; |
| 9 | 8 | ||
| 10 | export default function ConnectionsLayout() { | 9 | export default function ConnectionsLayout() { |
| 11 | const contentStyle = useResolveClassNames("bg-sheet"); | 10 | const contentStyle = useResolveClassNames("bg-sheet"); |
| 12 | const connSheetBg = String(useThemeColor("--color-sheet")); | 11 | const isDark = useColorScheme() === "dark"; |
| 13 | const headerTint = String(useThemeColor("--color-icon")); | 12 | const connSheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; |
| 13 | const headerTint = isDark ? "#f5f5f5" : "#262626"; | ||
| 14 | 14 | ||
| 15 | return ( | 15 | return ( |
| 16 | <Stack | 16 | <Stack |
apps/mobile/src/app/connections/index.tsx+10-16| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import { Link, Stack } from "expo-router"; | 1 | import { Stack, useRouter } from "expo-router"; |
| 2 | import { SymbolView } from "expo-symbols"; | 2 | import { SymbolView } from "expo-symbols"; |
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 4 | import { useCallback, useState } from "react"; | 4 | import { useCallback, useState } from "react"; |
| 5 | import { Pressable, ScrollView, View } from "react-native"; | 5 | import { ScrollView, View } from "react-native"; |
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | 7 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 8 | 8 | ||
| ... | @@ -18,11 +18,11 @@ export default function ConnectionsRouteScreen() { | ... | @@ -18,11 +18,11 @@ export default function ConnectionsRouteScreen() { |
| 18 | onRemoveEnvironmentPress, | 18 | onRemoveEnvironmentPress, |
| 19 | onUpdateEnvironment, | 19 | onUpdateEnvironment, |
| 20 | } = useRemoteConnections(); | 20 | } = useRemoteConnections(); |
| 21 | const router = useRouter(); | ||
| 21 | const insets = useSafeAreaInsets(); | 22 | const insets = useSafeAreaInsets(); |
| 22 | const hasEnvironments = connectedEnvironments.length > 0; | 23 | const hasEnvironments = connectedEnvironments.length > 0; |
| 23 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | 24 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); |
| 24 | 25 | ||
| 25 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 26 | const accentColor = useThemeColor("--color-icon-muted"); | 26 | const accentColor = useThemeColor("--color-icon-muted"); |
| 27 | 27 | ||
| 28 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | 28 | const handleToggle = useCallback((environmentId: EnvironmentId) => { |
| ... | @@ -34,21 +34,15 @@ export default function ConnectionsRouteScreen() { | ... | @@ -34,21 +34,15 @@ export default function ConnectionsRouteScreen() { |
| 34 | <Stack.Screen | 34 | <Stack.Screen |
| 35 | options={{ | 35 | options={{ |
| 36 | title: "Environments", | 36 | title: "Environments", |
| 37 | headerRight: () => ( | ||
| 38 | <Link href="/connections/new" asChild> | ||
| 39 | <Pressable className="h-10 w-10 items-center justify-center rounded-full bg-primary active:opacity-70"> | ||
| 40 | <SymbolView | ||
| 41 | name="plus" | ||
| 42 | size={18} | ||
| 43 | tintColor={primaryFg} | ||
| 44 | type="monochrome" | ||
| 45 | weight="semibold" | ||
| 46 | /> | ||
| 47 | </Pressable> | ||
| 48 | </Link> | ||
| 49 | ), | ||
| 50 | }} | 37 | }} |
| 51 | /> | 38 | /> |
| 39 | <Stack.Toolbar placement="right"> | ||
| 40 | <Stack.Toolbar.Button | ||
| 41 | icon="plus" | ||
| 42 | onPress={() => router.push("/connections/new")} | ||
| 43 | separateBackground | ||
| 44 | /> | ||
| 45 | </Stack.Toolbar> | ||
| 52 | <ScrollView | 46 | <ScrollView |
| 53 | contentInsetAdjustmentBehavior="automatic" | 47 | contentInsetAdjustmentBehavior="automatic" |
| 54 | showsVerticalScrollIndicator={false} | 48 | showsVerticalScrollIndicator={false} |
apps/mobile/src/app/connections/new.tsx+14-23| ... | @@ -1,8 +1,7 @@ | ... | @@ -1,8 +1,7 @@ |
| 1 | import { CameraView, useCameraPermissions } from "expo-camera"; | 1 | import { CameraView, useCameraPermissions } from "expo-camera"; |
| 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 2 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 3 | import { SymbolView } from "expo-symbols"; | ||
| 4 | import { useCallback, useEffect, useState } from "react"; | 3 | import { useCallback, useEffect, useState } from "react"; |
| 5 | import { Alert, Pressable, ScrollView, View } from "react-native"; | 4 | import { Alert, ScrollView, View } from "react-native"; |
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | 6 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 8 | 7 | ||
| ... | @@ -31,7 +30,6 @@ export default function ConnectionsNewRouteScreen() { | ... | @@ -31,7 +30,6 @@ export default function ConnectionsNewRouteScreen() { |
| 31 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); | 30 | const [cameraPermission, requestCameraPermission] = useCameraPermissions(); |
| 32 | const [scannerLocked, setScannerLocked] = useState(false); | 31 | const [scannerLocked, setScannerLocked] = useState(false); |
| 33 | 32 | ||
| 34 | const textColor = useThemeColor("--color-icon"); | ||
| 35 | const placeholderColor = useThemeColor("--color-placeholder"); | 33 | const placeholderColor = useThemeColor("--color-placeholder"); |
| 36 | 34 | ||
| 37 | const connectDisabled = isSubmitting || hostInput.trim().length === 0; | 35 | const connectDisabled = isSubmitting || hostInput.trim().length === 0; |
| ... | @@ -128,28 +126,21 @@ export default function ConnectionsNewRouteScreen() { | ... | @@ -128,28 +126,21 @@ export default function ConnectionsNewRouteScreen() { |
| 128 | <Stack.Screen | 126 | <Stack.Screen |
| 129 | options={{ | 127 | options={{ |
| 130 | title: showScanner ? "Scan QR Code" : "Add Environment", | 128 | title: showScanner ? "Scan QR Code" : "Add Environment", |
| 131 | headerRight: () => ( | ||
| 132 | <Pressable | ||
| 133 | className="h-10 w-10 items-center justify-center rounded-full border border-border bg-secondary" | ||
| 134 | onPress={() => { | ||
| 135 | if (showScanner) { | ||
| 136 | closeScanner(); | ||
| 137 | } else { | ||
| 138 | void openScanner(); | ||
| 139 | } | ||
| 140 | }} | ||
| 141 | > | ||
| 142 | <SymbolView | ||
| 143 | name={showScanner ? "xmark" : "qrcode.viewfinder"} | ||
| 144 | size={showScanner ? 14 : 18} | ||
| 145 | tintColor={textColor} | ||
| 146 | type="monochrome" | ||
| 147 | weight="semibold" | ||
| 148 | /> | ||
| 149 | </Pressable> | ||
| 150 | ), | ||
| 151 | }} | 129 | }} |
| 152 | /> | 130 | /> |
| 131 | <Stack.Toolbar placement="right"> | ||
| 132 | <Stack.Toolbar.Button | ||
| 133 | icon={showScanner ? "xmark" : "qrcode.viewfinder"} | ||
| 134 | onPress={() => { | ||
| 135 | if (showScanner) { | ||
| 136 | closeScanner(); | ||
| 137 | } else { | ||
| 138 | void openScanner(); | ||
| 139 | } | ||
| 140 | }} | ||
| 141 | separateBackground | ||
| 142 | /> | ||
| 143 | </Stack.Toolbar> | ||
| 153 | 144 | ||
| 154 | <ScrollView | 145 | <ScrollView |
| 155 | contentInsetAdjustmentBehavior="automatic" | 146 | contentInsetAdjustmentBehavior="automatic" |
apps/mobile/src/app/index.tsx+6-12| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import { Stack, useRouter } from "expo-router"; | 1 | import { Stack, useRouter } from "expo-router"; |
| 2 | import { useState } from "react"; | 2 | import { useState } from "react"; |
| 3 | import { Text as RNText, View, useColorScheme } from "react-native"; | 3 | import { Text as RNText, View, useColorScheme } from "react-native"; |
| 4 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 5 | 4 | ||
| 6 | import { buildThreadRoutePath } from "../lib/routes"; | 5 | import { buildThreadRoutePath } from "../lib/routes"; |
| 7 | import { useRemoteCatalog } from "../state/use-remote-catalog"; | 6 | import { useRemoteCatalog } from "../state/use-remote-catalog"; |
| ... | @@ -17,7 +16,7 @@ export default function HomeRouteScreen() { | ... | @@ -17,7 +16,7 @@ export default function HomeRouteScreen() { |
| 17 | const [searchQuery, setSearchQuery] = useState(""); | 16 | const [searchQuery, setSearchQuery] = useState(""); |
| 18 | 17 | ||
| 19 | const isDark = useColorScheme() === "dark"; | 18 | const isDark = useColorScheme() === "dark"; |
| 20 | const iconColor = String(useThemeColor("--color-icon")); | 19 | const iconColor = isDark ? "#f5f5f5" : "#262626"; |
| 21 | 20 | ||
| 22 | return ( | 21 | return ( |
| 23 | <> | 22 | <> |
| ... | @@ -78,16 +77,11 @@ export default function HomeRouteScreen() { | ... | @@ -78,16 +77,11 @@ export default function HomeRouteScreen() { |
| 78 | </Stack.Toolbar> | 77 | </Stack.Toolbar> |
| 79 | 78 | ||
| 80 | <Stack.Toolbar placement="right"> | 79 | <Stack.Toolbar placement="right"> |
| 81 | <Stack.Toolbar.Menu icon="ellipsis.circle" separateBackground> | 80 | <Stack.Toolbar.Button |
| 82 | <Stack.Toolbar.Label>More</Stack.Toolbar.Label> | 81 | icon="gearshape" |
| 83 | <Stack.Toolbar.MenuAction | 82 | onPress={() => router.push("/settings")} |
| 84 | icon="desktopcomputer" | 83 | separateBackground |
| 85 | onPress={() => router.push("/connections")} | 84 | /> |
| 86 | subtitle="Manage connected hosts" | ||
| 87 | > | ||
| 88 | <Stack.Toolbar.Label>Environments</Stack.Toolbar.Label> | ||
| 89 | </Stack.Toolbar.MenuAction> | ||
| 90 | </Stack.Toolbar.Menu> | ||
| 91 | </Stack.Toolbar> | 85 | </Stack.Toolbar> |
| 92 | 86 | ||
| 93 | {/* Bottom toolbar: search + compose, visually split like iMessage */} | 87 | {/* Bottom toolbar: search + compose, visually split like iMessage */} |
apps/mobile/src/app/new/_layout.tsx+26-4| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import Stack from "expo-router/stack"; | 1 | import Stack from "expo-router/stack"; |
| 2 | import { useColorScheme } from "react-native"; | ||
| 2 | import { useResolveClassNames } from "uniwind"; | 3 | import { useResolveClassNames } from "uniwind"; |
| 3 | 4 | ||
| 4 | import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; | 5 | import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; |
| ... | @@ -9,20 +10,41 @@ export const unstable_settings = { | ... | @@ -9,20 +10,41 @@ export const unstable_settings = { |
| 9 | 10 | ||
| 10 | export default function NewTaskLayout() { | 11 | export default function NewTaskLayout() { |
| 11 | const sheetStyle = useResolveClassNames("bg-sheet"); | 12 | const sheetStyle = useResolveClassNames("bg-sheet"); |
| 13 | const isDark = useColorScheme() === "dark"; | ||
| 14 | const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; | ||
| 15 | const headerTint = isDark ? "#f5f5f5" : "#262626"; | ||
| 12 | 16 | ||
| 13 | return ( | 17 | return ( |
| 14 | <NewTaskFlowProvider> | 18 | <NewTaskFlowProvider> |
| 15 | <Stack | 19 | <Stack |
| 16 | screenOptions={{ | 20 | screenOptions={{ |
| 17 | contentStyle: sheetStyle, | 21 | contentStyle: sheetStyle, |
| 22 | headerBackButtonDisplayMode: "minimal", | ||
| 23 | headerLargeTitle: false, | ||
| 24 | headerShadowVisible: false, | ||
| 25 | headerStyle: { backgroundColor: sheetBg }, | ||
| 26 | headerTintColor: headerTint, | ||
| 27 | headerTitleStyle: { fontFamily: "DMSans_700Bold" }, | ||
| 18 | }} | 28 | }} |
| 19 | > | 29 | > |
| 20 | <Stack.Screen name="index" options={{ animation: "none" }} /> | 30 | <Stack.Screen name="index" options={{ animation: "none", title: "Choose project" }} /> |
| 21 | <Stack.Screen | 31 | <Stack.Screen |
| 22 | name="add-project" | 32 | name="add-project/index" |
| 23 | options={{ animation: "slide_from_right", headerShown: false }} | 33 | options={{ animation: "slide_from_right", title: "New project" }} |
| 24 | /> | 34 | /> |
| 25 | <Stack.Screen name="draft" options={{ animation: "slide_from_right" }} /> | 35 | <Stack.Screen |
| 36 | name="add-project/repository" | ||
| 37 | options={{ animation: "slide_from_right", title: "Repository" }} | ||
| 38 | /> | ||
| 39 | <Stack.Screen | ||
| 40 | name="add-project/destination" | ||
| 41 | options={{ animation: "slide_from_right", title: "Clone destination" }} | ||
| 42 | /> | ||
| 43 | <Stack.Screen | ||
| 44 | name="add-project/local" | ||
| 45 | options={{ animation: "slide_from_right", title: "Local folder" }} | ||
| 46 | /> | ||
| 47 | <Stack.Screen name="draft" options={{ animation: "slide_from_right", title: "New task" }} /> | ||
| 26 | </Stack> | 48 | </Stack> |
| 27 | </NewTaskFlowProvider> | 49 | </NewTaskFlowProvider> |
| 28 | ); | 50 | ); |
apps/mobile/src/app/new/add-project/_layout.tsx deleted-17| ... | @@ -1,17 +0,0 @@ | ||
| 1 | import { Stack } from "expo-router"; | ||
| 2 | import { useThemeColor } from "../../../lib/useThemeColor"; | ||
| 3 | |||
| 4 | export default function AddProjectLayout() { | ||
| 5 | const sheetStyle = { | ||
| 6 | backgroundColor: useThemeColor("--color-sheet"), | ||
| 7 | }; | ||
| 8 | |||
| 9 | return ( | ||
| 10 | <Stack screenOptions={{ headerShown: false, contentStyle: sheetStyle }}> | ||
| 11 | <Stack.Screen name="index" options={{ animation: "slide_from_right" }} /> | ||
| 12 | <Stack.Screen name="repository" options={{ animation: "slide_from_right" }} /> | ||
| 13 | <Stack.Screen name="destination" options={{ animation: "slide_from_right" }} /> | ||
| 14 | <Stack.Screen name="local" options={{ animation: "slide_from_right" }} /> | ||
| 15 | </Stack> | ||
| 16 | ); | ||
| 17 | } | ||
apps/mobile/src/app/new/add-project/destination.tsx+1-8| ... | @@ -1,12 +1,5 @@ | ... | @@ -1,12 +1,5 @@ |
| 1 | import { Stack } from "expo-router"; | ||
| 2 | |||
| 3 | import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen"; | 1 | import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen"; |
| 4 | 2 | ||
| 5 | export default function AddProjectDestinationRoute() { | 3 | export default function AddProjectDestinationRoute() { |
| 6 | return ( | 4 | return <AddProjectDestinationScreen />; |
| 7 | <> | ||
| 8 | <Stack.Screen options={{ headerShown: false }} /> | ||
| 9 | <AddProjectDestinationScreen /> | ||
| 10 | </> | ||
| 11 | ); | ||
| 12 | } | 5 | } |
apps/mobile/src/app/new/add-project/index.tsx+1-8| ... | @@ -1,12 +1,5 @@ | ... | @@ -1,12 +1,5 @@ |
| 1 | import { Stack } from "expo-router"; | ||
| 2 | |||
| 3 | import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen"; | 1 | import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen"; |
| 4 | 2 | ||
| 5 | export default function AddProjectRoute() { | 3 | export default function AddProjectRoute() { |
| 6 | return ( | 4 | return <AddProjectSourceScreen />; |
| 7 | <> | ||
| 8 | <Stack.Screen options={{ headerShown: false }} /> | ||
| 9 | <AddProjectSourceScreen /> | ||
| 10 | </> | ||
| 11 | ); | ||
| 12 | } | 5 | } |
apps/mobile/src/app/new/add-project/local.tsx+1-8| ... | @@ -1,12 +1,5 @@ | ... | @@ -1,12 +1,5 @@ |
| 1 | import { Stack } from "expo-router"; | ||
| 2 | |||
| 3 | import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen"; | 1 | import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen"; |
| 4 | 2 | ||
| 5 | export default function AddProjectLocalRoute() { | 3 | export default function AddProjectLocalRoute() { |
| 6 | return ( | 4 | return <AddProjectLocalFolderScreen />; |
| 7 | <> | ||
| 8 | <Stack.Screen options={{ headerShown: false }} /> | ||
| 9 | <AddProjectLocalFolderScreen /> | ||
| 10 | </> | ||
| 11 | ); | ||
| 12 | } | 5 | } |
apps/mobile/src/app/new/add-project/repository.tsx+13-2| ... | @@ -1,11 +1,22 @@ | ... | @@ -1,11 +1,22 @@ |
| 1 | import { Stack } from "expo-router"; | 1 | import { Stack, useLocalSearchParams } from "expo-router"; |
| 2 | import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime"; | ||
| 2 | 3 | ||
| 3 | import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen"; | 4 | import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen"; |
| 4 | 5 | ||
| 5 | export default function AddProjectRepositoryRoute() { | 6 | export default function AddProjectRepositoryRoute() { |
| 7 | const params = useLocalSearchParams<{ source?: string | string[] }>(); | ||
| 8 | const source = Array.isArray(params.source) ? params.source[0] : params.source; | ||
| 9 | const title = | ||
| 10 | source === "github" || | ||
| 11 | source === "gitlab" || | ||
| 12 | source === "bitbucket" || | ||
| 13 | source === "azure-devops" | ||
| 14 | ? addProjectRemoteSourceLabel(source) | ||
| 15 | : "Git URL"; | ||
| 16 | |||
| 6 | return ( | 17 | return ( |
| 7 | <> | 18 | <> |
| 8 | <Stack.Screen options={{ headerShown: false }} /> | 19 | <Stack.Screen options={{ title }} /> |
| 9 | <AddProjectRepositoryScreen /> | 20 | <AddProjectRepositoryScreen /> |
| 10 | </> | 21 | </> |
| 11 | ); | 22 | ); |
apps/mobile/src/app/new/draft.tsx+6-1| ... | @@ -6,11 +6,16 @@ export default function NewTaskDraftRoute() { | ... | @@ -6,11 +6,16 @@ export default function NewTaskDraftRoute() { |
| 6 | const params = useLocalSearchParams<{ | 6 | const params = useLocalSearchParams<{ |
| 7 | environmentId?: string | string[]; | 7 | environmentId?: string | string[]; |
| 8 | projectId?: string | string[]; | 8 | projectId?: string | string[]; |
| 9 | title?: string | string[]; | ||
| 9 | }>(); | 10 | }>(); |
| 10 | 11 | ||
| 11 | return ( | 12 | return ( |
| 12 | <> | 13 | <> |
| 13 | <Stack.Screen options={{ headerShown: false }} /> | 14 | <Stack.Screen |
| 15 | options={{ | ||
| 16 | title: Array.isArray(params.title) ? params.title[0] : (params.title ?? "New task"), | ||
| 17 | }} | ||
| 18 | /> | ||
| 14 | <NewTaskDraftScreen | 19 | <NewTaskDraftScreen |
| 15 | initialProjectRef={{ | 20 | initialProjectRef={{ |
| 16 | environmentId: Array.isArray(params.environmentId) | 21 | environmentId: Array.isArray(params.environmentId) |
apps/mobile/src/app/new/index.tsx+28-42| ... | @@ -8,7 +8,6 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -8,7 +8,6 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 8 | 8 | ||
| 9 | import { AppText as Text } from "../../components/AppText"; | 9 | import { AppText as Text } from "../../components/AppText"; |
| 10 | import { ProjectFavicon } from "../../components/ProjectFavicon"; | 10 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 11 | import { NewTaskSheetHeader } from "../../features/threads/NewTaskSheetHeader"; | ||
| 12 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | 11 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 13 | import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog"; | 12 | import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 14 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | 13 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; |
| ... | @@ -102,9 +101,14 @@ export default function NewTaskRoute() { | ... | @@ -102,9 +101,14 @@ export default function NewTaskRoute() { |
| 102 | 101 | ||
| 103 | return ( | 102 | return ( |
| 104 | <View collapsable={false} className="flex-1 bg-sheet"> | 103 | <View collapsable={false} className="flex-1 bg-sheet"> |
| 105 | <Stack.Screen options={{ headerShown: false }} /> | 104 | <Stack.Screen options={{ title: "Choose project" }} /> |
| 106 | 105 | <Stack.Toolbar placement="right"> | |
| 107 | <NewTaskSheetHeader title="Choose project" /> | 106 | <Stack.Toolbar.Button |
| 107 | icon="plus" | ||
| 108 | onPress={() => router.push("/new/add-project")} | ||
| 109 | separateBackground | ||
| 110 | /> | ||
| 111 | </Stack.Toolbar> | ||
| 108 | 112 | ||
| 109 | <ScrollView | 113 | <ScrollView |
| 110 | showsVerticalScrollIndicator={false} | 114 | showsVerticalScrollIndicator={false} |
| ... | @@ -148,6 +152,7 @@ export default function NewTaskRoute() { | ... | @@ -148,6 +152,7 @@ export default function NewTaskRoute() { |
| 148 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | 152 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> |
| 149 | {items.map((item, index) => { | 153 | {items.map((item, index) => { |
| 150 | const isFirst = index === 0; | 154 | const isFirst = index === 0; |
| 155 | const isLast = index === items.length - 1; | ||
| 151 | 156 | ||
| 152 | return ( | 157 | return ( |
| 153 | <Link | 158 | <Link |
| ... | @@ -166,25 +171,33 @@ export default function NewTaskRoute() { | ... | @@ -166,25 +171,33 @@ export default function NewTaskRoute() { |
| 166 | className="bg-card" | 171 | className="bg-card" |
| 167 | style={{ | 172 | style={{ |
| 168 | paddingHorizontal: 16, | 173 | paddingHorizontal: 16, |
| 169 | paddingVertical: 18, | 174 | paddingVertical: 14, |
| 170 | borderTopWidth: isFirst ? 0 : 1, | 175 | borderTopWidth: isFirst ? 0 : 1, |
| 171 | borderTopColor: borderSubtleColor, | 176 | borderTopColor: borderSubtleColor, |
| 172 | borderTopLeftRadius: isFirst ? 24 : 0, | 177 | borderTopLeftRadius: isFirst ? 24 : 0, |
| 173 | borderTopRightRadius: isFirst ? 24 : 0, | 178 | borderTopRightRadius: isFirst ? 24 : 0, |
| 174 | borderBottomLeftRadius: 0, | 179 | borderBottomLeftRadius: isLast ? 24 : 0, |
| 175 | borderBottomRightRadius: 0, | 180 | borderBottomRightRadius: isLast ? 24 : 0, |
| 176 | }} | 181 | }} |
| 177 | > | 182 | > |
| 178 | <View className="flex-row items-center justify-between gap-3"> | 183 | <View className="flex-row items-center justify-between gap-3"> |
| 179 | <ProjectFavicon | 184 | <View className="h-7 w-7 items-center justify-center"> |
| 180 | size={22} | 185 | <ProjectFavicon |
| 181 | projectTitle={item.title} | 186 | size={20} |
| 182 | httpBaseUrl={savedConnectionsById[item.environmentId]?.httpBaseUrl ?? null} | 187 | projectTitle={item.title} |
| 183 | workspaceRoot={item.workspaceRoot} | 188 | httpBaseUrl={ |
| 184 | bearerToken={savedConnectionsById[item.environmentId]?.bearerToken ?? null} | 189 | savedConnectionsById[item.environmentId]?.httpBaseUrl ?? null |
| 185 | /> | 190 | } |
| 191 | workspaceRoot={item.workspaceRoot} | ||
| 192 | bearerToken={ | ||
| 193 | savedConnectionsById[item.environmentId]?.bearerToken ?? null | ||
| 194 | } | ||
| 195 | /> | ||
| 196 | </View> | ||
| 186 | <View className="flex-1"> | 197 | <View className="flex-1"> |
| 187 | <Text className="text-[18px] font-t3-bold">{item.title}</Text> | 198 | <Text className="text-[16px] leading-[21px] font-t3-bold"> |
| 199 | {item.title} | ||
| 200 | </Text> | ||
| 188 | </View> | 201 | </View> |
| 189 | <SymbolView | 202 | <SymbolView |
| 190 | name="chevron.right" | 203 | name="chevron.right" |
| ... | @@ -197,33 +210,6 @@ export default function NewTaskRoute() { | ... | @@ -197,33 +210,6 @@ export default function NewTaskRoute() { |
| 197 | </Link> | 210 | </Link> |
| 198 | ); | 211 | ); |
| 199 | })} | 212 | })} |
| 200 | <Pressable | ||
| 201 | className="bg-card" | ||
| 202 | style={{ | ||
| 203 | paddingHorizontal: 16, | ||
| 204 | paddingVertical: 18, | ||
| 205 | borderTopWidth: 1, | ||
| 206 | borderTopColor: borderSubtleColor, | ||
| 207 | borderBottomLeftRadius: 24, | ||
| 208 | borderBottomRightRadius: 24, | ||
| 209 | }} | ||
| 210 | onPress={() => router.push("/new/add-project")} | ||
| 211 | > | ||
| 212 | <View className="flex-row items-center justify-between gap-3"> | ||
| 213 | <View className="h-[22px] w-[22px] items-center justify-center rounded-full bg-subtle"> | ||
| 214 | <SymbolView name="plus" size={13} tintColor={accentColor} type="monochrome" /> | ||
| 215 | </View> | ||
| 216 | <View className="flex-1"> | ||
| 217 | <Text className="text-[18px] font-t3-bold">Add new project</Text> | ||
| 218 | </View> | ||
| 219 | <SymbolView | ||
| 220 | name="chevron.right" | ||
| 221 | size={14} | ||
| 222 | tintColor={chevronColor} | ||
| 223 | type="monochrome" | ||
| 224 | /> | ||
| 225 | </View> | ||
| 226 | </Pressable> | ||
| 227 | </View> | 213 | </View> |
| 228 | )} | 214 | )} |
| 229 | </ScrollView> | 215 | </ScrollView> |
apps/mobile/src/app/settings/_layout.tsx created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import Stack from "expo-router/stack"; | ||
| 2 | import { useColorScheme } from "react-native"; | ||
| 3 | import { useResolveClassNames } from "uniwind"; | ||
| 4 | |||
| 5 | export const unstable_settings = { | ||
| 6 | anchor: "index", | ||
| 7 | }; | ||
| 8 | |||
| 9 | export default function SettingsLayout() { | ||
| 10 | const contentStyle = useResolveClassNames("bg-sheet"); | ||
| 11 | const isDark = useColorScheme() === "dark"; | ||
| 12 | const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; | ||
| 13 | const headerTint = isDark ? "#f5f5f5" : "#262626"; | ||
| 14 | |||
| 15 | return ( | ||
| 16 | <Stack | ||
| 17 | screenOptions={{ | ||
| 18 | contentStyle, | ||
| 19 | headerBackButtonDisplayMode: "minimal", | ||
| 20 | headerShadowVisible: false, | ||
| 21 | headerStyle: { backgroundColor: sheetBg }, | ||
| 22 | headerTintColor: headerTint, | ||
| 23 | headerTitleStyle: { fontFamily: "DMSans_700Bold" }, | ||
| 24 | }} | ||
| 25 | > | ||
| 26 | <Stack.Screen name="index" options={{ animation: "none", title: "Settings" }} /> | ||
| 27 | <Stack.Screen | ||
| 28 | name="environments" | ||
| 29 | options={{ animation: "slide_from_right", title: "Environments" }} | ||
| 30 | /> | ||
| 31 | <Stack.Screen | ||
| 32 | name="environment-new" | ||
| 33 | options={{ animation: "slide_from_right", title: "Add Environment" }} | ||
| 34 | /> | ||
| 35 | <Stack.Screen | ||
| 36 | name="waitlist" | ||
| 37 | options={{ animation: "slide_from_right", title: "Join the waitlist" }} | ||
| 38 | /> | ||
| 39 | </Stack> | ||
| 40 | ); | ||
| 41 | } | ||
apps/mobile/src/app/settings/environment-new.tsx created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | export { default } from "../connections/new"; | ||
apps/mobile/src/app/settings/environments.tsx created+275| ... | @@ -0,0 +1,275 @@ | ||
| 1 | import { useAuth } from "@clerk/expo"; | ||
| 2 | import { Stack, useRouter } from "expo-router"; | ||
| 3 | import { SymbolView } from "expo-symbols"; | ||
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 5 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import { useCallback, useMemo, useState } from "react"; | ||
| 8 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; | ||
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 10 | |||
| 11 | import { AppText as Text } from "../../components/AppText"; | ||
| 12 | import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment"; | ||
| 13 | import { | ||
| 14 | hasCloudPublicConfig, | ||
| 15 | resolveRelayClerkTokenOptions, | ||
| 16 | } from "../../features/cloud/publicConfig"; | ||
| 17 | import { | ||
| 18 | useManagedRelayEnvironments, | ||
| 19 | useManagedRelayEnvironmentStatus, | ||
| 20 | } from "../../features/cloud/managedRelayState"; | ||
| 21 | import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; | ||
| 22 | import { cn } from "../../lib/cn"; | ||
| 23 | import { mobileRuntime } from "../../lib/runtime"; | ||
| 24 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 25 | import { | ||
| 26 | connectSavedEnvironment, | ||
| 27 | useRemoteConnections, | ||
| 28 | useRemoteEnvironmentState, | ||
| 29 | } from "../../state/use-remote-environment-registry"; | ||
| 30 | |||
| 31 | export default function SettingsEnvironmentsRouteScreen() { | ||
| 32 | const { | ||
| 33 | connectedEnvironments, | ||
| 34 | onReconnectEnvironment, | ||
| 35 | onRemoveEnvironmentPress, | ||
| 36 | onUpdateEnvironment, | ||
| 37 | } = useRemoteConnections(); | ||
| 38 | const router = useRouter(); | ||
| 39 | const insets = useSafeAreaInsets(); | ||
| 40 | const hasEnvironments = connectedEnvironments.length > 0; | ||
| 41 | const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null); | ||
| 42 | const accentColor = useThemeColor("--color-icon-muted"); | ||
| 43 | |||
| 44 | const handleToggle = useCallback((environmentId: EnvironmentId) => { | ||
| 45 | setExpandedId((prev) => (prev === environmentId ? null : environmentId)); | ||
| 46 | }, []); | ||
| 47 | |||
| 48 | return ( | ||
| 49 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 50 | <Stack.Screen | ||
| 51 | options={{ | ||
| 52 | title: "Environments", | ||
| 53 | }} | ||
| 54 | /> | ||
| 55 | <Stack.Toolbar placement="right"> | ||
| 56 | <Stack.Toolbar.Button | ||
| 57 | icon="plus" | ||
| 58 | onPress={() => router.push("/settings/environment-new")} | ||
| 59 | separateBackground | ||
| 60 | /> | ||
| 61 | </Stack.Toolbar> | ||
| 62 | <ScrollView | ||
| 63 | contentInsetAdjustmentBehavior="automatic" | ||
| 64 | showsVerticalScrollIndicator={false} | ||
| 65 | style={{ flex: 1 }} | ||
| 66 | contentContainerStyle={{ | ||
| 67 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 68 | paddingHorizontal: 20, | ||
| 69 | paddingTop: 16, | ||
| 70 | }} | ||
| 71 | > | ||
| 72 | {hasEnvironments ? ( | ||
| 73 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 74 | {connectedEnvironments.map((environment, index) => ( | ||
| 75 | <View | ||
| 76 | key={environment.environmentId} | ||
| 77 | collapsable={false} | ||
| 78 | style={{ | ||
| 79 | borderTopWidth: index === 0 ? 0 : 1, | ||
| 80 | }} | ||
| 81 | className={cn(index !== 0 && "border-border")} | ||
| 82 | > | ||
| 83 | <ConnectionEnvironmentRow | ||
| 84 | environment={environment} | ||
| 85 | expanded={expandedId === environment.environmentId} | ||
| 86 | onToggle={() => handleToggle(environment.environmentId)} | ||
| 87 | onReconnect={onReconnectEnvironment} | ||
| 88 | onRemove={onRemoveEnvironmentPress} | ||
| 89 | onUpdate={onUpdateEnvironment} | ||
| 90 | /> | ||
| 91 | </View> | ||
| 92 | ))} | ||
| 93 | </View> | ||
| 94 | ) : ( | ||
| 95 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> | ||
| 96 | <View className="h-12 w-12 items-center justify-center rounded-[16px] bg-subtle"> | ||
| 97 | <SymbolView | ||
| 98 | name="point.3.connected.trianglepath.dotted" | ||
| 99 | size={20} | ||
| 100 | tintColor={accentColor} | ||
| 101 | type="monochrome" | ||
| 102 | /> | ||
| 103 | </View> | ||
| 104 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> | ||
| 105 | No environments connected yet.{"\n"}Tap{" "} | ||
| 106 | <Text className="font-t3-bold text-foreground">+</Text> to add one. | ||
| 107 | </Text> | ||
| 108 | </View> | ||
| 109 | )} | ||
| 110 | |||
| 111 | {hasCloudPublicConfig() ? <ConfiguredCloudEnvironmentRows /> : null} | ||
| 112 | </ScrollView> | ||
| 113 | </View> | ||
| 114 | ); | ||
| 115 | } | ||
| 116 | |||
| 117 | function ConfiguredCloudEnvironmentRows() { | ||
| 118 | const { getToken, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 119 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 120 | const cloudEnvironmentsState = useManagedRelayEnvironments(); | ||
| 121 | const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState<string | null>( | ||
| 122 | null, | ||
| 123 | ); | ||
| 124 | const iconColor = useThemeColor("--color-icon"); | ||
| 125 | const availableCloudEnvironments = useMemo( | ||
| 126 | () => | ||
| 127 | (cloudEnvironmentsState.data ?? []).filter( | ||
| 128 | (environment) => savedConnectionsById[environment.environmentId] === undefined, | ||
| 129 | ), | ||
| 130 | [cloudEnvironmentsState.data, savedConnectionsById], | ||
| 131 | ); | ||
| 132 | |||
| 133 | const handleConnectCloudEnvironment = useCallback( | ||
| 134 | async (environment: RelayClientEnvironmentRecord) => { | ||
| 135 | setConnectingCloudEnvironmentId(environment.environmentId); | ||
| 136 | try { | ||
| 137 | const token = await getToken(resolveRelayClerkTokenOptions()); | ||
| 138 | if (!token) { | ||
| 139 | throw new Error("Sign in to T3 Cloud before connecting."); | ||
| 140 | } | ||
| 141 | await mobileRuntime.runPromise( | ||
| 142 | connectCloudEnvironment({ | ||
| 143 | clerkToken: token, | ||
| 144 | environment, | ||
| 145 | }).pipe(Effect.flatMap(connectSavedEnvironment)), | ||
| 146 | ); | ||
| 147 | } catch (error) { | ||
| 148 | Alert.alert( | ||
| 149 | "Connect failed", | ||
| 150 | error instanceof Error ? error.message : "Could not connect to this environment.", | ||
| 151 | ); | ||
| 152 | } finally { | ||
| 153 | setConnectingCloudEnvironmentId(null); | ||
| 154 | } | ||
| 155 | }, | ||
| 156 | [getToken], | ||
| 157 | ); | ||
| 158 | |||
| 159 | if (!isSignedIn) return null; | ||
| 160 | |||
| 161 | return ( | ||
| 162 | <View collapsable={false} className="mt-5 gap-3"> | ||
| 163 | <View className="flex-row items-center justify-between px-1"> | ||
| 164 | <Text className="text-[13px] font-t3-bold uppercase text-foreground-muted">T3 Cloud</Text> | ||
| 165 | <Pressable | ||
| 166 | accessibilityRole="button" | ||
| 167 | disabled={cloudEnvironmentsState.isPending} | ||
| 168 | onPress={cloudEnvironmentsState.refresh} | ||
| 169 | className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" | ||
| 170 | > | ||
| 171 | {cloudEnvironmentsState.isPending ? ( | ||
| 172 | <ActivityIndicator color={iconColor} size="small" /> | ||
| 173 | ) : ( | ||
| 174 | <SymbolView name="arrow.clockwise" size={14} tintColor={iconColor} type="monochrome" /> | ||
| 175 | )} | ||
| 176 | </Pressable> | ||
| 177 | </View> | ||
| 178 | |||
| 179 | {availableCloudEnvironments.length > 0 ? ( | ||
| 180 | <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card"> | ||
| 181 | {availableCloudEnvironments.map((environment, index) => ( | ||
| 182 | <CloudEnvironmentRow | ||
| 183 | key={environment.environmentId} | ||
| 184 | environment={environment} | ||
| 185 | borderTop={index !== 0} | ||
| 186 | isConnecting={connectingCloudEnvironmentId === environment.environmentId} | ||
| 187 | onConnect={() => handleConnectCloudEnvironment(environment)} | ||
| 188 | /> | ||
| 189 | ))} | ||
| 190 | </View> | ||
| 191 | ) : cloudEnvironmentsState.data === null ? ( | ||
| 192 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card p-6"> | ||
| 193 | <ActivityIndicator color={iconColor} /> | ||
| 194 | <Text className="text-center text-[14px] leading-[20px] text-foreground-muted"> | ||
| 195 | Loading linked cloud environments. | ||
| 196 | </Text> | ||
| 197 | </View> | ||
| 198 | ) : cloudEnvironmentsState.error ? ( | ||
| 199 | <View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5"> | ||
| 200 | <Text className="text-[15px] font-t3-bold text-foreground"> | ||
| 201 | Could not load T3 Cloud environments | ||
| 202 | </Text> | ||
| 203 | <Text className="text-[13px] leading-[18px] text-foreground-muted"> | ||
| 204 | {cloudEnvironmentsState.error} | ||
| 205 | </Text> | ||
| 206 | </View> | ||
| 207 | ) : ( | ||
| 208 | <View collapsable={false} className="rounded-[24px] bg-card p-5"> | ||
| 209 | <Text className="text-[14px] leading-[20px] text-foreground-muted"> | ||
| 210 | No additional linked cloud environments. | ||
| 211 | </Text> | ||
| 212 | </View> | ||
| 213 | )} | ||
| 214 | </View> | ||
| 215 | ); | ||
| 216 | } | ||
| 217 | |||
| 218 | function CloudEnvironmentRow(props: { | ||
| 219 | readonly environment: RelayClientEnvironmentRecord; | ||
| 220 | readonly borderTop: boolean; | ||
| 221 | readonly isConnecting: boolean; | ||
| 222 | readonly onConnect: () => void; | ||
| 223 | }) { | ||
| 224 | const mutedColor = useThemeColor("--color-icon-muted"); | ||
| 225 | const statusState = useManagedRelayEnvironmentStatus(props.environment); | ||
| 226 | const status = statusState.data; | ||
| 227 | const disabled = props.isConnecting; | ||
| 228 | const statusText = | ||
| 229 | status === null | ||
| 230 | ? (statusState.error ?? (statusState.isPending ? "Checking status..." : "Status unavailable")) | ||
| 231 | : status.status === "online" | ||
| 232 | ? "Online" | ||
| 233 | : (status.error ?? "Offline"); | ||
| 234 | |||
| 235 | return ( | ||
| 236 | <View | ||
| 237 | collapsable={false} | ||
| 238 | className={cn( | ||
| 239 | "flex-row items-center gap-3 bg-card px-4 py-3.5", | ||
| 240 | props.borderTop && "border-t border-border", | ||
| 241 | )} | ||
| 242 | > | ||
| 243 | <View className="h-9 w-9 items-center justify-center rounded-[14px] bg-subtle"> | ||
| 244 | <SymbolView | ||
| 245 | name="cloud" | ||
| 246 | size={17} | ||
| 247 | tintColor={mutedColor} | ||
| 248 | type="monochrome" | ||
| 249 | weight="semibold" | ||
| 250 | /> | ||
| 251 | </View> | ||
| 252 | <View className="min-w-0 flex-1 gap-0.5"> | ||
| 253 | <Text className="text-[16px] font-t3-bold leading-[21px] text-foreground" numberOfLines={1}> | ||
| 254 | {props.environment.label} | ||
| 255 | </Text> | ||
| 256 | <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}> | ||
| 257 | {props.environment.endpoint.httpBaseUrl} | ||
| 258 | </Text> | ||
| 259 | <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={1}> | ||
| 260 | {statusText} | ||
| 261 | </Text> | ||
| 262 | </View> | ||
| 263 | <Pressable | ||
| 264 | accessibilityRole="button" | ||
| 265 | disabled={disabled} | ||
| 266 | onPress={props.onConnect} | ||
| 267 | className="min-h-[40px] min-w-[88px] items-center justify-center rounded-[14px] bg-primary px-4 active:opacity-70 disabled:opacity-50" | ||
| 268 | > | ||
| 269 | <Text className="text-[13px] font-t3-bold text-primary-foreground"> | ||
| 270 | {props.isConnecting ? "Connecting" : "Connect"} | ||
| 271 | </Text> | ||
| 272 | </Pressable> | ||
| 273 | </View> | ||
| 274 | ); | ||
| 275 | } | ||
apps/mobile/src/app/settings/index.tsx created+451| ... | @@ -0,0 +1,451 @@ | ||
| 1 | import { useAuth, useUser, useUserProfileModal } from "@clerk/expo"; | ||
| 2 | import * as Notifications from "expo-notifications"; | ||
| 3 | import { Link, Stack, useRouter } from "expo-router"; | ||
| 4 | import { SymbolView } from "expo-symbols"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 7 | import type { ComponentProps, ReactNode } from "react"; | ||
| 8 | import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native"; | ||
| 9 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 10 | |||
| 11 | import { AppText as Text } from "../../components/AppText"; | ||
| 12 | import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences"; | ||
| 13 | import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; | ||
| 14 | import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; | ||
| 15 | import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; | ||
| 16 | import { | ||
| 17 | hasCloudPublicConfig, | ||
| 18 | resolveRelayClerkTokenOptions, | ||
| 19 | } from "../../features/cloud/publicConfig"; | ||
| 20 | import { mobileRuntime } from "../../lib/runtime"; | ||
| 21 | import { loadPreferences } from "../../lib/storage"; | ||
| 22 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 23 | import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; | ||
| 24 | |||
| 25 | type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; | ||
| 26 | type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; | ||
| 27 | |||
| 28 | export default function SettingsRouteScreen() { | ||
| 29 | return hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />; | ||
| 30 | } | ||
| 31 | |||
| 32 | function LocalSettingsRouteScreen() { | ||
| 33 | const insets = useSafeAreaInsets(); | ||
| 34 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 35 | const environmentCount = Object.keys(savedConnectionsById).length; | ||
| 36 | |||
| 37 | return ( | ||
| 38 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 39 | <Stack.Screen options={{ title: "Settings" }} /> | ||
| 40 | <ScrollView | ||
| 41 | contentInsetAdjustmentBehavior="automatic" | ||
| 42 | showsVerticalScrollIndicator={false} | ||
| 43 | style={{ flex: 1 }} | ||
| 44 | contentContainerStyle={{ | ||
| 45 | gap: 24, | ||
| 46 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 47 | paddingHorizontal: 20, | ||
| 48 | paddingTop: 16, | ||
| 49 | }} | ||
| 50 | > | ||
| 51 | <SettingsSection title="Configuration"> | ||
| 52 | <SettingsRow | ||
| 53 | icon="desktopcomputer" | ||
| 54 | label="Environments" | ||
| 55 | value={`${environmentCount}`} | ||
| 56 | href="/settings/environments" | ||
| 57 | /> | ||
| 58 | </SettingsSection> | ||
| 59 | |||
| 60 | <AppSettingsSection /> | ||
| 61 | </ScrollView> | ||
| 62 | </View> | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | |||
| 66 | function ConfiguredSettingsRouteScreen() { | ||
| 67 | const insets = useSafeAreaInsets(); | ||
| 68 | const { push } = useRouter(); | ||
| 69 | const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 70 | const { user } = useUser(); | ||
| 71 | const { isAvailable: isUserProfileModalAvailable, presentUserProfile } = useUserProfileModal(); | ||
| 72 | const { savedConnectionsById } = useRemoteEnvironmentState(); | ||
| 73 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); | ||
| 74 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); | ||
| 75 | |||
| 76 | const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); | ||
| 77 | const environmentCount = connections.length; | ||
| 78 | const accountLabel = useMemo(() => { | ||
| 79 | if (!isLoaded) return "Checking"; | ||
| 80 | if (!isSignedIn) return "Request access"; | ||
| 81 | return user?.primaryEmailAddress?.emailAddress ?? "Signed in"; | ||
| 82 | }, [isLoaded, isSignedIn, user?.primaryEmailAddress?.emailAddress]); | ||
| 83 | |||
| 84 | const refreshNotifications = useCallback(async () => { | ||
| 85 | if (process.env.EXPO_OS !== "ios") { | ||
| 86 | setNotificationStatus("unsupported"); | ||
| 87 | return; | ||
| 88 | } | ||
| 89 | const permission = await Notifications.getPermissionsAsync(); | ||
| 90 | setNotificationStatus(permission.granted ? "enabled" : "disabled"); | ||
| 91 | }, []); | ||
| 92 | |||
| 93 | useEffect(() => { | ||
| 94 | void refreshNotifications(); | ||
| 95 | }, [refreshNotifications]); | ||
| 96 | |||
| 97 | useEffect(() => { | ||
| 98 | if (!isLoaded) { | ||
| 99 | setLiveActivityStatus("checking"); | ||
| 100 | return; | ||
| 101 | } | ||
| 102 | if (!isSignedIn) { | ||
| 103 | setLiveActivityStatus("signed-out"); | ||
| 104 | return; | ||
| 105 | } | ||
| 106 | void loadPreferences().then( | ||
| 107 | (preferences) => { | ||
| 108 | setLiveActivityStatus(preferences.liveActivitiesEnabled === false ? "disabled" : "enabled"); | ||
| 109 | }, | ||
| 110 | () => { | ||
| 111 | setLiveActivityStatus("enabled"); | ||
| 112 | }, | ||
| 113 | ); | ||
| 114 | }, [isLoaded, isSignedIn]); | ||
| 115 | |||
| 116 | const requestNotifications = useCallback(async () => { | ||
| 117 | try { | ||
| 118 | const result = await mobileRuntime.runPromise( | ||
| 119 | requestAgentNotificationPermission.pipe( | ||
| 120 | Effect.tap((permission) => | ||
| 121 | permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, | ||
| 122 | ), | ||
| 123 | ), | ||
| 124 | ); | ||
| 125 | if (result.type === "granted") { | ||
| 126 | setNotificationStatus("enabled"); | ||
| 127 | Alert.alert( | ||
| 128 | "Notifications enabled", | ||
| 129 | "Live Activity notifications are enabled for this device.", | ||
| 130 | ); | ||
| 131 | return; | ||
| 132 | } | ||
| 133 | if (result.type === "unsupported") { | ||
| 134 | setNotificationStatus("unsupported"); | ||
| 135 | Alert.alert( | ||
| 136 | "Notifications unavailable", | ||
| 137 | "Live Activity notifications are only available on iOS.", | ||
| 138 | ); | ||
| 139 | return; | ||
| 140 | } | ||
| 141 | setNotificationStatus("disabled"); | ||
| 142 | if (result.canAskAgain) { | ||
| 143 | Alert.alert("Notifications disabled", "Notifications were not enabled."); | ||
| 144 | return; | ||
| 145 | } | ||
| 146 | Alert.alert( | ||
| 147 | "Notifications disabled", | ||
| 148 | "Notifications were denied for this app. Open Settings to enable them.", | ||
| 149 | [ | ||
| 150 | { text: "Cancel", style: "cancel" }, | ||
| 151 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | ||
| 152 | ], | ||
| 153 | ); | ||
| 154 | } catch (error) { | ||
| 155 | Alert.alert( | ||
| 156 | "Notifications unavailable", | ||
| 157 | error instanceof Error ? error.message : "Could not request notification permission.", | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | }, []); | ||
| 161 | |||
| 162 | const promptSignIn = useCallback(() => { | ||
| 163 | Alert.alert( | ||
| 164 | "Request T3 Cloud access", | ||
| 165 | "Live Activity updates require approved T3 Cloud access so relay can deliver updates to this device.", | ||
| 166 | [ | ||
| 167 | { text: "Cancel", style: "cancel" }, | ||
| 168 | { text: "Continue", onPress: () => push("/settings/waitlist") }, | ||
| 169 | ], | ||
| 170 | ); | ||
| 171 | }, [push]); | ||
| 172 | |||
| 173 | const linkEnvironments = useCallback(async () => { | ||
| 174 | if (!isSignedIn) { | ||
| 175 | promptSignIn(); | ||
| 176 | return; | ||
| 177 | } | ||
| 178 | |||
| 179 | setLiveActivityStatus("linking"); | ||
| 180 | try { | ||
| 181 | const token = await getToken(resolveRelayClerkTokenOptions()); | ||
| 182 | if (!token) { | ||
| 183 | promptSignIn(); | ||
| 184 | setLiveActivityStatus("signed-out"); | ||
| 185 | return; | ||
| 186 | } | ||
| 187 | |||
| 188 | await mobileRuntime.runPromise( | ||
| 189 | setLiveActivityUpdatesEnabled({ | ||
| 190 | enabled: true, | ||
| 191 | clerkToken: token, | ||
| 192 | connections, | ||
| 193 | }), | ||
| 194 | ); | ||
| 195 | refreshManagedRelayEnvironments(); | ||
| 196 | setLiveActivityStatus("enabled"); | ||
| 197 | Alert.alert( | ||
| 198 | "Live Activities enabled", | ||
| 199 | environmentCount > 0 | ||
| 200 | ? `${environmentCount} environment${environmentCount === 1 ? "" : "s"} linked for Live Activity updates.` | ||
| 201 | : "Live Activity updates are enabled. Add an environment to start receiving updates.", | ||
| 202 | ); | ||
| 203 | } catch (error) { | ||
| 204 | setLiveActivityStatus("disabled"); | ||
| 205 | Alert.alert( | ||
| 206 | "Live Activities unavailable", | ||
| 207 | error instanceof Error ? error.message : "Could not enable Live Activity updates.", | ||
| 208 | ); | ||
| 209 | } | ||
| 210 | }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); | ||
| 211 | |||
| 212 | const handleDeviceNotificationsChange = useCallback( | ||
| 213 | (enabled: boolean) => { | ||
| 214 | if (enabled) { | ||
| 215 | void requestNotifications(); | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | |||
| 219 | Alert.alert( | ||
| 220 | "Disable notifications", | ||
| 221 | "Notification permission is controlled by iOS. Open Settings to disable notifications for T3 Code.", | ||
| 222 | [ | ||
| 223 | { text: "Cancel", style: "cancel" }, | ||
| 224 | { text: "Open Settings", onPress: () => void Linking.openSettings() }, | ||
| 225 | ], | ||
| 226 | ); | ||
| 227 | }, | ||
| 228 | [requestNotifications], | ||
| 229 | ); | ||
| 230 | |||
| 231 | const handleLiveActivitiesChange = useCallback( | ||
| 232 | (enabled: boolean) => { | ||
| 233 | if (!enabled) { | ||
| 234 | setLiveActivityStatus("disabled"); | ||
| 235 | void (async () => { | ||
| 236 | try { | ||
| 237 | const token = isSignedIn ? await getToken(resolveRelayClerkTokenOptions()) : null; | ||
| 238 | await mobileRuntime.runPromise( | ||
| 239 | setLiveActivityUpdatesEnabled({ | ||
| 240 | enabled: false, | ||
| 241 | clerkToken: token, | ||
| 242 | connections, | ||
| 243 | }), | ||
| 244 | ); | ||
| 245 | refreshManagedRelayEnvironments(); | ||
| 246 | } catch { | ||
| 247 | // The switch is optimistic; a future refresh reconciles relay state. | ||
| 248 | } | ||
| 249 | })(); | ||
| 250 | return; | ||
| 251 | } | ||
| 252 | |||
| 253 | if (!isSignedIn) { | ||
| 254 | promptSignIn(); | ||
| 255 | return; | ||
| 256 | } | ||
| 257 | |||
| 258 | void linkEnvironments(); | ||
| 259 | }, | ||
| 260 | [connections, getToken, isSignedIn, linkEnvironments, promptSignIn], | ||
| 261 | ); | ||
| 262 | |||
| 263 | const openAccount = useCallback(() => { | ||
| 264 | if (!isLoaded) return; | ||
| 265 | if (!isSignedIn) { | ||
| 266 | push("/settings/waitlist"); | ||
| 267 | return; | ||
| 268 | } | ||
| 269 | if (isUserProfileModalAvailable) { | ||
| 270 | void presentUserProfile(); | ||
| 271 | return; | ||
| 272 | } | ||
| 273 | Alert.alert( | ||
| 274 | "T3 Cloud unavailable", | ||
| 275 | "Native T3 Cloud account management is not available in this build.", | ||
| 276 | ); | ||
| 277 | }, [isLoaded, isSignedIn, isUserProfileModalAvailable, presentUserProfile, push]); | ||
| 278 | |||
| 279 | return ( | ||
| 280 | <View collapsable={false} className="flex-1 bg-sheet"> | ||
| 281 | <Stack.Screen options={{ title: "Settings" }} /> | ||
| 282 | <ScrollView | ||
| 283 | contentInsetAdjustmentBehavior="automatic" | ||
| 284 | showsVerticalScrollIndicator={false} | ||
| 285 | style={{ flex: 1 }} | ||
| 286 | contentContainerStyle={{ | ||
| 287 | gap: 24, | ||
| 288 | paddingBottom: Math.max(insets.bottom, 18) + 18, | ||
| 289 | paddingHorizontal: 20, | ||
| 290 | paddingTop: 16, | ||
| 291 | }} | ||
| 292 | > | ||
| 293 | <View className="gap-3"> | ||
| 294 | <SettingsSection title="Account"> | ||
| 295 | <SettingsRow | ||
| 296 | icon="person.crop.circle" | ||
| 297 | label="T3 Account" | ||
| 298 | value={accountLabel} | ||
| 299 | onPress={openAccount} | ||
| 300 | /> | ||
| 301 | </SettingsSection> | ||
| 302 | <Text className="px-2 text-[13px] leading-[18px] text-foreground-muted"> | ||
| 303 | T3 Code works locally without signing in. Cloud features are optional. | ||
| 304 | </Text> | ||
| 305 | </View> | ||
| 306 | |||
| 307 | <SettingsSection title="Configuration"> | ||
| 308 | <SettingsRow | ||
| 309 | icon="desktopcomputer" | ||
| 310 | label="Environments" | ||
| 311 | value={`${environmentCount}`} | ||
| 312 | href="/settings/environments" | ||
| 313 | /> | ||
| 314 | <SettingsSwitchRow | ||
| 315 | icon="bell.badge" | ||
| 316 | label="Device Notifications" | ||
| 317 | disabled={notificationStatus === "checking" || notificationStatus === "unsupported"} | ||
| 318 | value={notificationStatus === "enabled"} | ||
| 319 | onValueChange={handleDeviceNotificationsChange} | ||
| 320 | /> | ||
| 321 | <SettingsSwitchRow | ||
| 322 | disabled={ | ||
| 323 | !isLoaded || liveActivityStatus === "checking" || liveActivityStatus === "linking" | ||
| 324 | } | ||
| 325 | icon="bolt.circle" | ||
| 326 | label="Live Activity Updates" | ||
| 327 | value={liveActivityStatus === "enabled" || liveActivityStatus === "linking"} | ||
| 328 | onValueChange={handleLiveActivitiesChange} | ||
| 329 | /> | ||
| 330 | </SettingsSection> | ||
| 331 | |||
| 332 | <AppSettingsSection /> | ||
| 333 | </ScrollView> | ||
| 334 | </View> | ||
| 335 | ); | ||
| 336 | } | ||
| 337 | |||
| 338 | type SymbolName = ComponentProps<typeof SymbolView>["name"]; | ||
| 339 | |||
| 340 | function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { | ||
| 341 | return ( | ||
| 342 | <View className="gap-2"> | ||
| 343 | <Text className="px-2 font-t3-bold text-[17px] text-foreground-tertiary">{props.title}</Text> | ||
| 344 | <View | ||
| 345 | className="overflow-hidden rounded-[28px] bg-card" | ||
| 346 | style={{ borderCurve: "continuous" }} | ||
| 347 | > | ||
| 348 | {props.children} | ||
| 349 | </View> | ||
| 350 | </View> | ||
| 351 | ); | ||
| 352 | } | ||
| 353 | |||
| 354 | function AppSettingsSection() { | ||
| 355 | const icon = useThemeColor("--color-icon"); | ||
| 356 | |||
| 357 | return ( | ||
| 358 | <SettingsSection title="App"> | ||
| 359 | <View className="flex-row items-center gap-4 p-4"> | ||
| 360 | <SymbolView | ||
| 361 | name="info.circle" | ||
| 362 | size={22} | ||
| 363 | tintColor={icon} | ||
| 364 | type="monochrome" | ||
| 365 | weight="regular" | ||
| 366 | /> | ||
| 367 | <Text className="flex-1 text-[17px] text-foreground">Version</Text> | ||
| 368 | <Text className="text-[17px] text-foreground-muted">Alpha</Text> | ||
| 369 | </View> | ||
| 370 | </SettingsSection> | ||
| 371 | ); | ||
| 372 | } | ||
| 373 | |||
| 374 | function SettingsRow(props: { | ||
| 375 | readonly disabled?: boolean; | ||
| 376 | readonly icon: SymbolName; | ||
| 377 | readonly label: string; | ||
| 378 | readonly value?: string; | ||
| 379 | readonly href?: "/settings/environments"; | ||
| 380 | readonly onPress?: () => void; | ||
| 381 | }) { | ||
| 382 | const icon = useThemeColor("--color-icon"); | ||
| 383 | const chevron = useThemeColor("--color-chevron"); | ||
| 384 | const content = ( | ||
| 385 | <View | ||
| 386 | className="flex-row items-center gap-4 p-4" | ||
| 387 | style={{ opacity: props.disabled ? 0.45 : 1 }} | ||
| 388 | > | ||
| 389 | <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" /> | ||
| 390 | <Text className="flex-1 text-[17px] text-foreground">{props.label}</Text> | ||
| 391 | {props.value ? ( | ||
| 392 | <Text | ||
| 393 | className="max-w-[180px] text-right text-[16px] text-foreground-muted" | ||
| 394 | numberOfLines={1} | ||
| 395 | > | ||
| 396 | {props.value} | ||
| 397 | </Text> | ||
| 398 | ) : null} | ||
| 399 | <SymbolView | ||
| 400 | name="chevron.right" | ||
| 401 | size={16} | ||
| 402 | tintColor={chevron} | ||
| 403 | type="monochrome" | ||
| 404 | weight="semibold" | ||
| 405 | /> | ||
| 406 | </View> | ||
| 407 | ); | ||
| 408 | |||
| 409 | if (props.href) { | ||
| 410 | return ( | ||
| 411 | <Link href={props.href} asChild> | ||
| 412 | <Pressable>{content}</Pressable> | ||
| 413 | </Link> | ||
| 414 | ); | ||
| 415 | } | ||
| 416 | |||
| 417 | return ( | ||
| 418 | <Pressable accessibilityRole="button" disabled={props.disabled} onPress={props.onPress}> | ||
| 419 | {content} | ||
| 420 | </Pressable> | ||
| 421 | ); | ||
| 422 | } | ||
| 423 | |||
| 424 | function SettingsSwitchRow(props: { | ||
| 425 | readonly disabled?: boolean; | ||
| 426 | readonly icon: SymbolName; | ||
| 427 | readonly label: string; | ||
| 428 | readonly value: boolean; | ||
| 429 | readonly onValueChange: (value: boolean) => void; | ||
| 430 | }) { | ||
| 431 | const icon = useThemeColor("--color-icon"); | ||
| 432 | const activeTrack = String(useThemeColor("--color-switch-active")); | ||
| 433 | const track = String(useThemeColor("--color-secondary-border")); | ||
| 434 | |||
| 435 | return ( | ||
| 436 | <View | ||
| 437 | className="flex-row items-center gap-4 p-4" | ||
| 438 | style={{ opacity: props.disabled ? 0.45 : 1 }} | ||
| 439 | > | ||
| 440 | <SymbolView name={props.icon} size={22} tintColor={icon} type="monochrome" weight="regular" /> | ||
| 441 | <Text className="flex-1 text-[17px] text-foreground">{props.label}</Text> | ||
| 442 | <Switch | ||
| 443 | disabled={props.disabled} | ||
| 444 | ios_backgroundColor={track} | ||
| 445 | onValueChange={props.onValueChange} | ||
| 446 | trackColor={{ false: track, true: activeTrack }} | ||
| 447 | value={props.value} | ||
| 448 | /> | ||
| 449 | </View> | ||
| 450 | ); | ||
| 451 | } | ||
apps/mobile/src/app/settings/waitlist.tsx created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import { Redirect, Stack } from "expo-router"; | ||
| 2 | import { ScrollView } from "react-native"; | ||
| 3 | |||
| 4 | import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment"; | ||
| 5 | import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; | ||
| 6 | import { useNativeClerkAuthModal } from "../../features/cloud/useNativeClerkAuthModal"; | ||
| 7 | |||
| 8 | export default function SettingsWaitlistRouteScreen() { | ||
| 9 | return hasCloudPublicConfig() ? ( | ||
| 10 | <ConfiguredSettingsWaitlistRouteScreen /> | ||
| 11 | ) : ( | ||
| 12 | <Redirect href="/settings" /> | ||
| 13 | ); | ||
| 14 | } | ||
| 15 | |||
| 16 | function ConfiguredSettingsWaitlistRouteScreen() { | ||
| 17 | const { presentAuth } = useNativeClerkAuthModal(); | ||
| 18 | |||
| 19 | return ( | ||
| 20 | <> | ||
| 21 | <Stack.Screen options={{ title: "Join the waitlist" }} /> | ||
| 22 | <ScrollView | ||
| 23 | automaticallyAdjustKeyboardInsets | ||
| 24 | contentContainerStyle={{ | ||
| 25 | paddingBottom: 32, | ||
| 26 | paddingHorizontal: 20, | ||
| 27 | paddingTop: 12, | ||
| 28 | }} | ||
| 29 | contentInsetAdjustmentBehavior="automatic" | ||
| 30 | keyboardDismissMode="interactive" | ||
| 31 | keyboardShouldPersistTaps="handled" | ||
| 32 | showsVerticalScrollIndicator={false} | ||
| 33 | > | ||
| 34 | <CloudWaitlistEnrollment onSignIn={() => void presentAuth()} /> | ||
| 35 | </ScrollView> | ||
| 36 | </> | ||
| 37 | ); | ||
| 38 | } | ||
apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx+1| ... | @@ -17,6 +17,7 @@ export default function ThreadLayout() { | ... | @@ -17,6 +17,7 @@ export default function ThreadLayout() { |
| 17 | headerShown: true, | 17 | headerShown: true, |
| 18 | headerTransparent: true, | 18 | headerTransparent: true, |
| 19 | headerShadowVisible: false, | 19 | headerShadowVisible: false, |
| 20 | headerTitle: "", | ||
| 20 | }} | 21 | }} |
| 21 | /> | 22 | /> |
| 22 | <Stack.Screen | 23 | <Stack.Screen |
apps/mobile/src/components/ComposerToolbarTrigger.tsx created+247| ... | @@ -0,0 +1,247 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import type { ComponentProps, ReactNode } from "react"; | ||
| 3 | import { useCallback, useMemo, useState } from "react"; | ||
| 4 | import { | ||
| 5 | Pressable, | ||
| 6 | ScrollView, | ||
| 7 | View, | ||
| 8 | useColorScheme, | ||
| 9 | type LayoutChangeEvent, | ||
| 10 | type NativeScrollEvent, | ||
| 11 | type NativeSyntheticEvent, | ||
| 12 | type StyleProp, | ||
| 13 | type ViewStyle, | ||
| 14 | } from "react-native"; | ||
| 15 | |||
| 16 | import { useThemeColor } from "../lib/useThemeColor"; | ||
| 17 | import { cn } from "../lib/cn"; | ||
| 18 | import { AppText as Text } from "./AppText"; | ||
| 19 | |||
| 20 | export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44; | ||
| 21 | export const COMPOSER_TOOLBAR_GAP = 8; | ||
| 22 | export const COMPOSER_TOOLBAR_FADE_WIDTH = 18; | ||
| 23 | const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; | ||
| 24 | |||
| 25 | export function ComposerToolbarRow(props: { | ||
| 26 | readonly children: ReactNode; | ||
| 27 | readonly paddingBottom?: number; | ||
| 28 | readonly paddingHorizontal?: number; | ||
| 29 | readonly paddingTop?: number; | ||
| 30 | readonly style?: StyleProp<ViewStyle>; | ||
| 31 | }) { | ||
| 32 | return ( | ||
| 33 | <View | ||
| 34 | style={[ | ||
| 35 | { | ||
| 36 | alignItems: "center", | ||
| 37 | flexDirection: "row", | ||
| 38 | gap: 6, | ||
| 39 | paddingBottom: props.paddingBottom ?? 8, | ||
| 40 | paddingHorizontal: props.paddingHorizontal ?? 6, | ||
| 41 | paddingTop: props.paddingTop ?? 8, | ||
| 42 | }, | ||
| 43 | props.style, | ||
| 44 | ]} | ||
| 45 | > | ||
| 46 | {props.children} | ||
| 47 | </View> | ||
| 48 | ); | ||
| 49 | } | ||
| 50 | |||
| 51 | export function ComposerToolbarScroller(props: { | ||
| 52 | readonly children: ReactNode; | ||
| 53 | readonly fadeOpaque: string; | ||
| 54 | readonly fadeTransparent: string; | ||
| 55 | readonly contentPaddingRight?: number; | ||
| 56 | }) { | ||
| 57 | const [metrics, setMetrics] = useState({ | ||
| 58 | contentWidth: 0, | ||
| 59 | offsetX: 0, | ||
| 60 | viewportWidth: 0, | ||
| 61 | }); | ||
| 62 | |||
| 63 | const scrollEdges = useMemo(() => { | ||
| 64 | const maxOffset = Math.max(0, metrics.contentWidth - metrics.viewportWidth); | ||
| 65 | return { | ||
| 66 | showLeftFade: metrics.offsetX > COMPOSER_TOOLBAR_SCROLL_EPSILON, | ||
| 67 | showRightFade: metrics.offsetX < maxOffset - COMPOSER_TOOLBAR_SCROLL_EPSILON, | ||
| 68 | }; | ||
| 69 | }, [metrics]); | ||
| 70 | |||
| 71 | const handleLayout = useCallback((event: LayoutChangeEvent) => { | ||
| 72 | const viewportWidth = event.nativeEvent.layout.width; | ||
| 73 | setMetrics((current) => | ||
| 74 | current.viewportWidth === viewportWidth ? current : { ...current, viewportWidth }, | ||
| 75 | ); | ||
| 76 | }, []); | ||
| 77 | |||
| 78 | const handleContentSizeChange = useCallback((contentWidth: number) => { | ||
| 79 | setMetrics((current) => | ||
| 80 | current.contentWidth === contentWidth ? current : { ...current, contentWidth }, | ||
| 81 | ); | ||
| 82 | }, []); | ||
| 83 | |||
| 84 | const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 85 | const offsetX = event.nativeEvent.contentOffset.x; | ||
| 86 | setMetrics((current) => | ||
| 87 | Math.abs(current.offsetX - offsetX) < 1 ? current : { ...current, offsetX }, | ||
| 88 | ); | ||
| 89 | }, []); | ||
| 90 | |||
| 91 | return ( | ||
| 92 | <View className="min-w-0 flex-1" style={{ position: "relative" }}> | ||
| 93 | <ScrollView | ||
| 94 | horizontal | ||
| 95 | keyboardShouldPersistTaps="always" | ||
| 96 | onContentSizeChange={handleContentSizeChange} | ||
| 97 | onLayout={handleLayout} | ||
| 98 | onScroll={handleScroll} | ||
| 99 | scrollEventThrottle={16} | ||
| 100 | showsHorizontalScrollIndicator={false} | ||
| 101 | contentContainerStyle={{ | ||
| 102 | alignItems: "center", | ||
| 103 | gap: COMPOSER_TOOLBAR_GAP, | ||
| 104 | paddingLeft: 0, | ||
| 105 | paddingRight: props.contentPaddingRight ?? 1, | ||
| 106 | }} | ||
| 107 | > | ||
| 108 | {props.children} | ||
| 109 | </ScrollView> | ||
| 110 | {scrollEdges.showLeftFade ? ( | ||
| 111 | <View | ||
| 112 | pointerEvents="none" | ||
| 113 | style={{ | ||
| 114 | bottom: 0, | ||
| 115 | experimental_backgroundImage: `linear-gradient(to right, ${props.fadeOpaque} 0%, ${props.fadeTransparent} 100%)`, | ||
| 116 | left: 0, | ||
| 117 | position: "absolute", | ||
| 118 | top: 0, | ||
| 119 | width: COMPOSER_TOOLBAR_FADE_WIDTH, | ||
| 120 | }} | ||
| 121 | /> | ||
| 122 | ) : null} | ||
| 123 | {scrollEdges.showRightFade ? ( | ||
| 124 | <View | ||
| 125 | pointerEvents="none" | ||
| 126 | style={{ | ||
| 127 | bottom: 0, | ||
| 128 | experimental_backgroundImage: `linear-gradient(to right, ${props.fadeTransparent} 0%, ${props.fadeOpaque} 100%)`, | ||
| 129 | position: "absolute", | ||
| 130 | right: 0, | ||
| 131 | top: 0, | ||
| 132 | width: COMPOSER_TOOLBAR_FADE_WIDTH, | ||
| 133 | }} | ||
| 134 | /> | ||
| 135 | ) : null} | ||
| 136 | </View> | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | |||
| 140 | export function ComposerToolbarButton(props: { | ||
| 141 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; | ||
| 142 | readonly iconNode?: ReactNode; | ||
| 143 | readonly label?: string; | ||
| 144 | readonly accessibilityLabel?: string; | ||
| 145 | readonly active?: boolean; | ||
| 146 | readonly disabled?: boolean; | ||
| 147 | readonly maxWidth?: number; | ||
| 148 | readonly minWidth?: number; | ||
| 149 | readonly onPress?: () => void; | ||
| 150 | readonly showChevron?: boolean; | ||
| 151 | readonly textTransform?: "none" | "uppercase"; | ||
| 152 | readonly variant?: "default" | "primary" | "danger"; | ||
| 153 | readonly style?: StyleProp<ViewStyle>; | ||
| 154 | }) { | ||
| 155 | const isDarkMode = useColorScheme() === "dark"; | ||
| 156 | const iconColor = useThemeColor("--color-icon"); | ||
| 157 | const iconSubtle = useThemeColor("--color-icon-subtle"); | ||
| 158 | const primaryFg = useThemeColor("--color-primary-foreground"); | ||
| 159 | const dangerFg = useThemeColor("--color-danger-foreground"); | ||
| 160 | const variant = props.variant ?? "default"; | ||
| 161 | const isCircle = !props.label && props.showChevron === false; | ||
| 162 | const defaultBorderColor = isDarkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)"; | ||
| 163 | const activeBorderColor = isDarkMode ? "rgba(255,255,255,0.13)" : "rgba(0,0,0,0.1)"; | ||
| 164 | const filledBorderColor = | ||
| 165 | variant === "danger" | ||
| 166 | ? "rgba(255,255,255,0.14)" | ||
| 167 | : props.disabled | ||
| 168 | ? defaultBorderColor | ||
| 169 | : "rgba(255,255,255,0.18)"; | ||
| 170 | const iconTintColor = | ||
| 171 | variant === "primary" | ||
| 172 | ? props.disabled | ||
| 173 | ? iconSubtle | ||
| 174 | : primaryFg | ||
| 175 | : variant === "danger" | ||
| 176 | ? dangerFg | ||
| 177 | : iconColor; | ||
| 178 | |||
| 179 | return ( | ||
| 180 | <Pressable | ||
| 181 | accessibilityLabel={props.accessibilityLabel ?? props.label} | ||
| 182 | accessibilityRole="button" | ||
| 183 | disabled={props.disabled} | ||
| 184 | onPress={props.onPress} | ||
| 185 | className={cn( | ||
| 186 | "h-11 flex-row items-center justify-center rounded-full active:opacity-70", | ||
| 187 | isCircle ? "w-11" : "gap-2 px-3.5", | ||
| 188 | variant === "primary" | ||
| 189 | ? props.disabled | ||
| 190 | ? "bg-subtle-strong" | ||
| 191 | : "bg-primary" | ||
| 192 | : variant === "danger" | ||
| 193 | ? "bg-danger" | ||
| 194 | : props.active | ||
| 195 | ? "bg-subtle-strong" | ||
| 196 | : "bg-subtle", | ||
| 197 | )} | ||
| 198 | style={({ pressed }) => [ | ||
| 199 | { | ||
| 200 | borderColor: | ||
| 201 | variant === "default" | ||
| 202 | ? props.active | ||
| 203 | ? activeBorderColor | ||
| 204 | : defaultBorderColor | ||
| 205 | : filledBorderColor, | ||
| 206 | borderWidth: 1, | ||
| 207 | maxWidth: props.maxWidth ?? 172, | ||
| 208 | minWidth: props.minWidth, | ||
| 209 | opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1, | ||
| 210 | shadowColor: "#000", | ||
| 211 | shadowOffset: { width: 0, height: isDarkMode ? 3 : 2 }, | ||
| 212 | shadowOpacity: props.disabled ? 0 : isDarkMode ? 0.24 : 0.08, | ||
| 213 | shadowRadius: isDarkMode ? 10 : 8, | ||
| 214 | }, | ||
| 215 | props.style, | ||
| 216 | ]} | ||
| 217 | > | ||
| 218 | {props.iconNode ? ( | ||
| 219 | <View className="h-4 w-4 items-center justify-center">{props.iconNode}</View> | ||
| 220 | ) : props.icon ? ( | ||
| 221 | <SymbolView name={props.icon} size={16} tintColor={iconTintColor} type="monochrome" /> | ||
| 222 | ) : null} | ||
| 223 | {props.label ? ( | ||
| 224 | <Text | ||
| 225 | className={cn( | ||
| 226 | "shrink text-center text-[13px] font-t3-bold", | ||
| 227 | variant === "primary" | ||
| 228 | ? props.disabled | ||
| 229 | ? "text-foreground-muted" | ||
| 230 | : "text-primary-foreground" | ||
| 231 | : "text-foreground", | ||
| 232 | )} | ||
| 233 | ellipsizeMode="tail" | ||
| 234 | numberOfLines={1} | ||
| 235 | style={{ textTransform: props.textTransform ?? "none" }} | ||
| 236 | > | ||
| 237 | {props.label} | ||
| 238 | </Text> | ||
| 239 | ) : null} | ||
| 240 | {props.showChevron === false ? null : ( | ||
| 241 | <SymbolView name="chevron.down" size={11} tintColor={iconTintColor} type="monochrome" /> | ||
| 242 | )} | ||
| 243 | </Pressable> | ||
| 244 | ); | ||
| 245 | } | ||
| 246 | |||
| 247 | export const ComposerToolbarTrigger = ComposerToolbarButton; | ||
apps/mobile/src/components/LoadingScreen.tsx+11-2| ... | @@ -5,10 +5,14 @@ import { useThemeColor } from "../lib/useThemeColor"; | ... | @@ -5,10 +5,14 @@ import { useThemeColor } from "../lib/useThemeColor"; |
| 5 | import { AppText as Text } from "./AppText"; | 5 | import { AppText as Text } from "./AppText"; |
| 6 | import { BrandMark } from "./BrandMark"; | 6 | import { BrandMark } from "./BrandMark"; |
| 7 | 7 | ||
| 8 | export function LoadingScreen(props: { readonly message: string }) { | 8 | export function LoadingScreen(props: { |
| 9 | readonly message: string; | ||
| 10 | readonly messagePlacement?: "above-spinner" | "below-spinner"; | ||
| 11 | }) { | ||
| 9 | const colorScheme = useColorScheme(); | 12 | const colorScheme = useColorScheme(); |
| 10 | const screenBg = useThemeColor("--color-screen"); | 13 | const screenBg = useThemeColor("--color-screen"); |
| 11 | const insets = useSafeAreaInsets(); | 14 | const insets = useSafeAreaInsets(); |
| 15 | const messagePlacement = props.messagePlacement ?? "below-spinner"; | ||
| 12 | 16 | ||
| 13 | return ( | 17 | return ( |
| 14 | <View className="flex-1 bg-screen" style={{ paddingTop: insets.top }}> | 18 | <View className="flex-1 bg-screen" style={{ paddingTop: insets.top }}> |
| ... | @@ -19,8 +23,13 @@ export function LoadingScreen(props: { readonly message: string }) { | ... | @@ -19,8 +23,13 @@ export function LoadingScreen(props: { readonly message: string }) { |
| 19 | /> | 23 | /> |
| 20 | <View className="flex-1 items-center justify-center gap-5 px-6"> | 24 | <View className="flex-1 items-center justify-center gap-5 px-6"> |
| 21 | <BrandMark compact /> | 25 | <BrandMark compact /> |
| 26 | {messagePlacement === "above-spinner" ? ( | ||
| 27 | <Text className="font-t3-bold text-lg text-foreground">{props.message}</Text> | ||
| 28 | ) : null} | ||
| 22 | <ActivityIndicator size="large" /> | 29 | <ActivityIndicator size="large" /> |
| 23 | <Text className="font-t3-bold text-lg text-foreground">{props.message}</Text> | 30 | {messagePlacement === "below-spinner" ? ( |
| 31 | <Text className="font-t3-bold text-lg text-foreground">{props.message}</Text> | ||
| 32 | ) : null} | ||
| 24 | </View> | 33 | </View> |
| 25 | </View> | 34 | </View> |
| 26 | ); | 35 | ); |
apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts created+122| ... | @@ -0,0 +1,122 @@ | ||
| 1 | import { beforeEach, vi } from "vitest"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 5 | import { ManagedRelayClient } from "@t3tools/client-runtime"; | ||
| 6 | import { HttpClient } from "effect/unstable/http"; | ||
| 7 | |||
| 8 | import type { SavedRemoteConnection } from "../../lib/connection"; | ||
| 9 | import { savePreferencesPatch } from "../../lib/storage"; | ||
| 10 | import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; | ||
| 11 | import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; | ||
| 12 | import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; | ||
| 13 | |||
| 14 | vi.mock("../../lib/storage", () => ({ | ||
| 15 | savePreferencesPatch: vi.fn(() => Promise.resolve()), | ||
| 16 | })); | ||
| 17 | |||
| 18 | vi.mock("../cloud/linkEnvironment", () => ({ | ||
| 19 | linkEnvironmentToCloud: vi.fn(() => Effect.void), | ||
| 20 | })); | ||
| 21 | |||
| 22 | vi.mock("./remoteRegistration", () => ({ | ||
| 23 | refreshAgentAwarenessRegistration: vi.fn(() => Effect.void), | ||
| 24 | })); | ||
| 25 | |||
| 26 | const connection: SavedRemoteConnection = { | ||
| 27 | environmentId: "env-1" as EnvironmentId, | ||
| 28 | environmentLabel: "Desktop", | ||
| 29 | pairingUrl: "https://desktop.example.test/", | ||
| 30 | displayUrl: "https://desktop.example.test/", | ||
| 31 | httpBaseUrl: "https://desktop.example.test/", | ||
| 32 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 33 | bearerToken: "local-bearer", | ||
| 34 | }; | ||
| 35 | |||
| 36 | const runWithHttpClient = <A, E>( | ||
| 37 | effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient>, | ||
| 38 | ): Promise<A> => | ||
| 39 | Effect.runPromise( | ||
| 40 | effect.pipe( | ||
| 41 | Effect.provideService(ManagedRelayClient, null as never), | ||
| 42 | Effect.provideService( | ||
| 43 | HttpClient.HttpClient, | ||
| 44 | HttpClient.make(() => Effect.die("unexpected HTTP request")), | ||
| 45 | ), | ||
| 46 | ), | ||
| 47 | ); | ||
| 48 | |||
| 49 | describe("liveActivityPreferences", () => { | ||
| 50 | beforeEach(() => { | ||
| 51 | vi.clearAllMocks(); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("pushes disabled Live Activity preferences to relay registrations", async () => { | ||
| 55 | await runWithHttpClient( | ||
| 56 | setLiveActivityUpdatesEnabled({ | ||
| 57 | enabled: false, | ||
| 58 | clerkToken: "clerk-token", | ||
| 59 | connections: [connection], | ||
| 60 | }), | ||
| 61 | ); | ||
| 62 | |||
| 63 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); | ||
| 64 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | ||
| 65 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | ||
| 66 | clerkToken: "clerk-token", | ||
| 67 | connection, | ||
| 68 | }); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("pushes enabled Live Activity preferences to relay registrations", async () => { | ||
| 72 | await runWithHttpClient( | ||
| 73 | setLiveActivityUpdatesEnabled({ | ||
| 74 | enabled: true, | ||
| 75 | clerkToken: "clerk-token", | ||
| 76 | connections: [connection], | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | |||
| 80 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); | ||
| 81 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | ||
| 82 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | ||
| 83 | clerkToken: "clerk-token", | ||
| 84 | connection, | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | it("keeps local preferences refreshable when signed out", async () => { | ||
| 89 | await runWithHttpClient( | ||
| 90 | setLiveActivityUpdatesEnabled({ | ||
| 91 | enabled: false, | ||
| 92 | clerkToken: null, | ||
| 93 | connections: [connection], | ||
| 94 | }), | ||
| 95 | ); | ||
| 96 | |||
| 97 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); | ||
| 98 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | ||
| 99 | expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("does not try to re-link managed relay connections without bearer credentials", async () => { | ||
| 103 | const managedConnection: SavedRemoteConnection = { | ||
| 104 | ...connection, | ||
| 105 | bearerToken: null, | ||
| 106 | }; | ||
| 107 | |||
| 108 | await runWithHttpClient( | ||
| 109 | setLiveActivityUpdatesEnabled({ | ||
| 110 | enabled: true, | ||
| 111 | clerkToken: "clerk-token", | ||
| 112 | connections: [connection, managedConnection], | ||
| 113 | }), | ||
| 114 | ); | ||
| 115 | |||
| 116 | expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); | ||
| 117 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | ||
| 118 | clerkToken: "clerk-token", | ||
| 119 | connection, | ||
| 120 | }); | ||
| 121 | }); | ||
| 122 | }); | ||
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import { HttpClient } from "effect/unstable/http"; | ||
| 3 | import { ManagedRelayClient } from "@t3tools/client-runtime"; | ||
| 4 | |||
| 5 | import type { SavedRemoteConnection } from "../../lib/connection"; | ||
| 6 | import { savePreferencesPatch } from "../../lib/storage"; | ||
| 7 | import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; | ||
| 8 | import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; | ||
| 9 | |||
| 10 | export function setLiveActivityUpdatesEnabled(input: { | ||
| 11 | readonly enabled: boolean; | ||
| 12 | readonly clerkToken: string | null; | ||
| 13 | readonly connections: ReadonlyArray<SavedRemoteConnection>; | ||
| 14 | }): Effect.Effect<void, unknown, HttpClient.HttpClient | ManagedRelayClient> { | ||
| 15 | return Effect.gen(function* () { | ||
| 16 | yield* Effect.tryPromise({ | ||
| 17 | try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), | ||
| 18 | catch: (error) => error, | ||
| 19 | }); | ||
| 20 | |||
| 21 | yield* refreshAgentAwarenessRegistration(); | ||
| 22 | |||
| 23 | const clerkToken = input.clerkToken; | ||
| 24 | if (!clerkToken) { | ||
| 25 | return; | ||
| 26 | } | ||
| 27 | |||
| 28 | yield* Effect.forEach( | ||
| 29 | input.connections.filter((connection) => connection.bearerToken !== null), | ||
| 30 | (connection) => linkEnvironmentToCloud({ clerkToken, connection }), | ||
| 31 | { concurrency: "unbounded" }, | ||
| 32 | ); | ||
| 33 | }); | ||
| 34 | } | ||
apps/mobile/src/features/agent-awareness/notificationNavigation.test.ts created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | extractAgentNotificationDeepLink, | ||
| 5 | routeAgentNotificationResponseOnce, | ||
| 6 | } from "./notificationPayload"; | ||
| 7 | |||
| 8 | function responseWithData(data: Record<string, unknown>, identifier = "notification-1") { | ||
| 9 | return { | ||
| 10 | notification: { | ||
| 11 | request: { | ||
| 12 | identifier, | ||
| 13 | content: { | ||
| 14 | data, | ||
| 15 | }, | ||
| 16 | }, | ||
| 17 | }, | ||
| 18 | }; | ||
| 19 | } | ||
| 20 | |||
| 21 | describe("extractAgentNotificationDeepLink", () => { | ||
| 22 | it("uses explicit deep links from APNs payload data", () => { | ||
| 23 | expect( | ||
| 24 | extractAgentNotificationDeepLink( | ||
| 25 | responseWithData({ | ||
| 26 | deepLink: "/threads/env/thread", | ||
| 27 | environmentId: "ignored", | ||
| 28 | threadId: "ignored", | ||
| 29 | }), | ||
| 30 | ), | ||
| 31 | ).toBe("/threads/env/thread"); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("normalizes explicit thread deep links from APNs payload data", () => { | ||
| 35 | expect( | ||
| 36 | extractAgentNotificationDeepLink( | ||
| 37 | responseWithData({ | ||
| 38 | deepLink: "/threads/env%201/thread%2F2", | ||
| 39 | }), | ||
| 40 | ), | ||
| 41 | ).toBe("/threads/env%201/thread%2F2"); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("falls back to the thread route from environment and thread ids", () => { | ||
| 45 | expect( | ||
| 46 | extractAgentNotificationDeepLink( | ||
| 47 | responseWithData({ | ||
| 48 | environmentId: "env 1", | ||
| 49 | threadId: "thread/2", | ||
| 50 | }), | ||
| 51 | ), | ||
| 52 | ).toBe("/threads/env%201/thread%2F2"); | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("falls back to ids when explicit deep link is not an agent thread route", () => { | ||
| 56 | expect( | ||
| 57 | extractAgentNotificationDeepLink( | ||
| 58 | responseWithData({ | ||
| 59 | deepLink: "/", | ||
| 60 | environmentId: "env", | ||
| 61 | threadId: "thread", | ||
| 62 | }), | ||
| 63 | ), | ||
| 64 | ).toBe("/threads/env/thread"); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("ignores malformed or external links", () => { | ||
| 68 | expect( | ||
| 69 | extractAgentNotificationDeepLink(responseWithData({ deepLink: "https://example.com" })), | ||
| 70 | ).toBeNull(); | ||
| 71 | expect( | ||
| 72 | extractAgentNotificationDeepLink(responseWithData({ deepLink: "/settings" })), | ||
| 73 | ).toBeNull(); | ||
| 74 | expect( | ||
| 75 | extractAgentNotificationDeepLink(responseWithData({ deepLink: "//example.com" })), | ||
| 76 | ).toBeNull(); | ||
| 77 | expect( | ||
| 78 | extractAgentNotificationDeepLink(responseWithData({ deepLink: "/threads/env/thread?x=1" })), | ||
| 79 | ).toBeNull(); | ||
| 80 | expect(extractAgentNotificationDeepLink({})).toBeNull(); | ||
| 81 | }); | ||
| 82 | }); | ||
| 83 | |||
| 84 | describe("routeAgentNotificationResponseOnce", () => { | ||
| 85 | it("does not navigate twice when the initial and listener responses refer to one notification", () => { | ||
| 86 | const handledResponseIds = new Set<string>(); | ||
| 87 | const navigations: Array<string> = []; | ||
| 88 | const response = responseWithData({ | ||
| 89 | environmentId: "env", | ||
| 90 | threadId: "thread", | ||
| 91 | }); | ||
| 92 | |||
| 93 | routeAgentNotificationResponseOnce({ | ||
| 94 | handledResponseIds, | ||
| 95 | response, | ||
| 96 | navigate: (deepLink) => navigations.push(deepLink), | ||
| 97 | }); | ||
| 98 | routeAgentNotificationResponseOnce({ | ||
| 99 | handledResponseIds, | ||
| 100 | response, | ||
| 101 | navigate: (deepLink) => navigations.push(deepLink), | ||
| 102 | }); | ||
| 103 | |||
| 104 | expect(navigations).toEqual(["/threads/env/thread"]); | ||
| 105 | }); | ||
| 106 | }); | ||
apps/mobile/src/features/agent-awareness/notificationNavigation.ts created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | import { useEffect, useRef } from "react"; | ||
| 2 | import * as Notifications from "expo-notifications"; | ||
| 3 | import { useRouter } from "expo-router"; | ||
| 4 | |||
| 5 | import { routeAgentNotificationResponseOnce } from "./notificationPayload"; | ||
| 6 | |||
| 7 | export function useAgentNotificationNavigation(): void { | ||
| 8 | const router = useRouter(); | ||
| 9 | const handledResponseIds = useRef(new Set<string>()); | ||
| 10 | |||
| 11 | useEffect(() => { | ||
| 12 | const handleResponse = (response: Notifications.NotificationResponse): void => { | ||
| 13 | routeAgentNotificationResponseOnce({ | ||
| 14 | handledResponseIds: handledResponseIds.current, | ||
| 15 | response, | ||
| 16 | navigate: (deepLink) => router.push(deepLink as never), | ||
| 17 | }); | ||
| 18 | }; | ||
| 19 | |||
| 20 | const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); | ||
| 21 | void Notifications.getLastNotificationResponseAsync() | ||
| 22 | .then((response) => { | ||
| 23 | if (response) { | ||
| 24 | handleResponse(response); | ||
| 25 | return Notifications.clearLastNotificationResponseAsync(); | ||
| 26 | } | ||
| 27 | return undefined; | ||
| 28 | }) | ||
| 29 | .catch(() => undefined); | ||
| 30 | |||
| 31 | return () => { | ||
| 32 | subscription.remove(); | ||
| 33 | }; | ||
| 34 | }, [router]); | ||
| 35 | } | ||
apps/mobile/src/features/agent-awareness/notificationPayload.ts created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | function dataFromNotificationResponse(response: unknown): Record<string, unknown> | null { | ||
| 2 | if (typeof response !== "object" || response === null) { | ||
| 3 | return null; | ||
| 4 | } | ||
| 5 | const notification = (response as { readonly notification?: unknown }).notification; | ||
| 6 | if (typeof notification !== "object" || notification === null) { | ||
| 7 | return null; | ||
| 8 | } | ||
| 9 | const request = (notification as { readonly request?: unknown }).request; | ||
| 10 | if (typeof request !== "object" || request === null) { | ||
| 11 | return null; | ||
| 12 | } | ||
| 13 | const content = (request as { readonly content?: unknown }).content; | ||
| 14 | if (typeof content !== "object" || content === null) { | ||
| 15 | return null; | ||
| 16 | } | ||
| 17 | const data = (content as { readonly data?: unknown }).data; | ||
| 18 | return typeof data === "object" && data !== null ? (data as Record<string, unknown>) : null; | ||
| 19 | } | ||
| 20 | |||
| 21 | function identifierFromNotificationResponse(response: unknown): string | null { | ||
| 22 | if (typeof response !== "object" || response === null) { | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | const notification = (response as { readonly notification?: unknown }).notification; | ||
| 26 | if (typeof notification !== "object" || notification === null) { | ||
| 27 | return null; | ||
| 28 | } | ||
| 29 | const request = (notification as { readonly request?: unknown }).request; | ||
| 30 | if (typeof request !== "object" || request === null) { | ||
| 31 | return null; | ||
| 32 | } | ||
| 33 | const identifier = (request as { readonly identifier?: unknown }).identifier; | ||
| 34 | return typeof identifier === "string" ? identifier : null; | ||
| 35 | } | ||
| 36 | |||
| 37 | function encodeThreadDeepLink(input: { | ||
| 38 | readonly environmentId: string; | ||
| 39 | readonly threadId: string; | ||
| 40 | }): string | null { | ||
| 41 | if (input.environmentId.length === 0 || input.threadId.length === 0) { | ||
| 42 | return null; | ||
| 43 | } | ||
| 44 | return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; | ||
| 45 | } | ||
| 46 | |||
| 47 | function normalizeThreadDeepLink(value: string): string | null { | ||
| 48 | if ( | ||
| 49 | value.trim() !== value || | ||
| 50 | value.startsWith("//") || | ||
| 51 | value.includes("?") || | ||
| 52 | value.includes("#") | ||
| 53 | ) { | ||
| 54 | return null; | ||
| 55 | } | ||
| 56 | |||
| 57 | const parts = value.split("/"); | ||
| 58 | if (parts.length !== 4 || parts[0] !== "" || parts[1] !== "threads") { | ||
| 59 | return null; | ||
| 60 | } | ||
| 61 | |||
| 62 | try { | ||
| 63 | return encodeThreadDeepLink({ | ||
| 64 | environmentId: decodeURIComponent(parts[2] ?? ""), | ||
| 65 | threadId: decodeURIComponent(parts[3] ?? ""), | ||
| 66 | }); | ||
| 67 | } catch { | ||
| 68 | return null; | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | export function extractAgentNotificationDeepLink(response: unknown): string | null { | ||
| 73 | const data = dataFromNotificationResponse(response); | ||
| 74 | const deepLink = data?.deepLink; | ||
| 75 | if (typeof deepLink === "string") { | ||
| 76 | const normalizedDeepLink = normalizeThreadDeepLink(deepLink); | ||
| 77 | if (normalizedDeepLink) { | ||
| 78 | return normalizedDeepLink; | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | const environmentId = data?.environmentId; | ||
| 83 | const threadId = data?.threadId; | ||
| 84 | if (typeof environmentId === "string" && typeof threadId === "string") { | ||
| 85 | return encodeThreadDeepLink({ environmentId, threadId }); | ||
| 86 | } | ||
| 87 | return null; | ||
| 88 | } | ||
| 89 | |||
| 90 | export function routeAgentNotificationResponseOnce(input: { | ||
| 91 | readonly handledResponseIds: Set<string>; | ||
| 92 | readonly response: unknown; | ||
| 93 | readonly navigate: (deepLink: string) => void; | ||
| 94 | }): void { | ||
| 95 | const responseId = identifierFromNotificationResponse(input.response); | ||
| 96 | if (responseId && input.handledResponseIds.has(responseId)) { | ||
| 97 | return; | ||
| 98 | } | ||
| 99 | if (responseId) { | ||
| 100 | input.handledResponseIds.add(responseId); | ||
| 101 | } | ||
| 102 | const deepLink = extractAgentNotificationDeepLink(input.response); | ||
| 103 | if (deepLink) { | ||
| 104 | input.navigate(deepLink); | ||
| 105 | } | ||
| 106 | } | ||
apps/mobile/src/features/agent-awareness/notificationPermissions.ts created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | import * as Notifications from "expo-notifications"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { Platform } from "react-native"; | ||
| 4 | |||
| 5 | export type NotificationPermissionResult = | ||
| 6 | | { readonly type: "unsupported" } | ||
| 7 | | { readonly type: "granted" } | ||
| 8 | | { readonly type: "denied"; readonly canAskAgain: boolean }; | ||
| 9 | |||
| 10 | export const requestAgentNotificationPermission: Effect.Effect< | ||
| 11 | NotificationPermissionResult, | ||
| 12 | unknown | ||
| 13 | > = Effect.gen(function* () { | ||
| 14 | if (Platform.OS !== "ios") { | ||
| 15 | return { type: "unsupported" }; | ||
| 16 | } | ||
| 17 | |||
| 18 | const existing = yield* Effect.tryPromise({ | ||
| 19 | try: () => Notifications.getPermissionsAsync(), | ||
| 20 | catch: (error) => error, | ||
| 21 | }); | ||
| 22 | if (existing.granted) { | ||
| 23 | return { type: "granted" }; | ||
| 24 | } | ||
| 25 | |||
| 26 | if (!existing.canAskAgain) { | ||
| 27 | return { type: "denied", canAskAgain: false }; | ||
| 28 | } | ||
| 29 | |||
| 30 | const requested = yield* Effect.tryPromise({ | ||
| 31 | try: () => | ||
| 32 | Notifications.requestPermissionsAsync({ | ||
| 33 | ios: { | ||
| 34 | allowAlert: true, | ||
| 35 | allowBadge: true, | ||
| 36 | allowSound: true, | ||
| 37 | }, | ||
| 38 | }), | ||
| 39 | catch: (error) => error, | ||
| 40 | }); | ||
| 41 | return requested.granted | ||
| 42 | ? { type: "granted" } | ||
| 43 | : { type: "denied", canAskAgain: requested.canAskAgain }; | ||
| 44 | }); | ||
apps/mobile/src/features/agent-awareness/registrationPayload.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; | ||
| 2 | |||
| 3 | import type { MobilePreferences } from "../../lib/storage"; | ||
| 4 | |||
| 5 | export function makeRelayDeviceRegistrationRequest(input: { | ||
| 6 | readonly deviceId: string; | ||
| 7 | readonly label: string; | ||
| 8 | readonly iosMajorVersion: number; | ||
| 9 | readonly appVersion?: string; | ||
| 10 | readonly pushToken?: string; | ||
| 11 | readonly pushToStartToken?: string; | ||
| 12 | readonly notificationsEnabled: boolean; | ||
| 13 | readonly preferences: MobilePreferences; | ||
| 14 | }): RelayDeviceRegistrationRequest { | ||
| 15 | const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; | ||
| 16 | return { | ||
| 17 | deviceId: input.deviceId, | ||
| 18 | label: input.label, | ||
| 19 | platform: "ios", | ||
| 20 | iosMajorVersion: input.iosMajorVersion, | ||
| 21 | appVersion: input.appVersion, | ||
| 22 | ...(input.pushToken ? { pushToken: input.pushToken } : {}), | ||
| 23 | ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), | ||
| 24 | preferences: { | ||
| 25 | liveActivitiesEnabled, | ||
| 26 | notificationsEnabled: input.notificationsEnabled, | ||
| 27 | notifyOnApproval: true, | ||
| 28 | notifyOnInput: true, | ||
| 29 | notifyOnCompletion: true, | ||
| 30 | notifyOnFailure: true, | ||
| 31 | }, | ||
| 32 | }; | ||
| 33 | } | ||
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts created+444| ... | @@ -0,0 +1,444 @@ | ||
| 1 | /// <reference types="node" /> | ||
| 2 | |||
| 3 | import * as NodeCrypto from "node:crypto"; | ||
| 4 | |||
| 5 | import { beforeEach, vi } from "vitest"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import Constants from "expo-constants"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 11 | import type { ManagedRelayClient } from "@t3tools/client-runtime"; | ||
| 12 | |||
| 13 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 14 | import { verifyDpopProof } from "@t3tools/shared/dpop"; | ||
| 15 | import type { SavedRemoteConnection } from "../../lib/connection"; | ||
| 16 | import { mobileCryptoLayer } from "../cloud/dpop"; | ||
| 17 | import { mobileManagedRelayClientLayer } from "../cloud/managedRelayLayer"; | ||
| 18 | import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; | ||
| 19 | import { | ||
| 20 | __resetAgentAwarenessRemoteRegistrationForTest, | ||
| 21 | refreshActiveLiveActivityRemoteRegistration, | ||
| 22 | refreshAgentAwarenessRegistration, | ||
| 23 | normalizeAgentAwarenessRelayBaseUrl, | ||
| 24 | registerAgentAwarenessConnection, | ||
| 25 | registerLiveActivityPushToken, | ||
| 26 | setAgentAwarenessRelayTokenProvider, | ||
| 27 | shouldRegisterAgentAwarenessDeviceForProvider, | ||
| 28 | unregisterAgentAwarenessConnection, | ||
| 29 | } from "./remoteRegistration"; | ||
| 30 | import * as Notifications from "expo-notifications"; | ||
| 31 | |||
| 32 | const secureStore = vi.hoisted(() => new Map<string, string>()); | ||
| 33 | const widgetMocks = vi.hoisted(() => ({ | ||
| 34 | getInstances: vi.fn(() => []), | ||
| 35 | })); | ||
| 36 | |||
| 37 | vi.mock("expo-constants", () => ({ | ||
| 38 | default: { | ||
| 39 | expoConfig: { | ||
| 40 | version: "1.0.0", | ||
| 41 | extra: {}, | ||
| 42 | }, | ||
| 43 | }, | ||
| 44 | })); | ||
| 45 | |||
| 46 | vi.mock("expo-widgets", () => ({ | ||
| 47 | addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })), | ||
| 48 | })); | ||
| 49 | |||
| 50 | vi.mock("../../widgets/AgentActivity", () => ({ | ||
| 51 | default: { | ||
| 52 | getInstances: widgetMocks.getInstances, | ||
| 53 | }, | ||
| 54 | })); | ||
| 55 | |||
| 56 | vi.mock("expo-notifications", () => ({ | ||
| 57 | addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })), | ||
| 58 | getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })), | ||
| 59 | getPermissionsAsync: vi.fn(() => Promise.resolve({ granted: true })), | ||
| 60 | })); | ||
| 61 | |||
| 62 | vi.mock("expo-crypto", () => ({ | ||
| 63 | CryptoDigestAlgorithm: { | ||
| 64 | SHA1: "SHA-1", | ||
| 65 | SHA256: "SHA-256", | ||
| 66 | SHA384: "SHA-384", | ||
| 67 | SHA512: "SHA-512", | ||
| 68 | }, | ||
| 69 | getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)), | ||
| 70 | getRandomBytesAsync: (byteCount: number) => | ||
| 71 | Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))), | ||
| 72 | digest: (algorithm: string, data: unknown) => { | ||
| 73 | if (!(data instanceof Uint8Array)) { | ||
| 74 | return Promise.reject(new TypeError("expo-crypto digest data must be a typed array.")); | ||
| 75 | } | ||
| 76 | return Promise.resolve( | ||
| 77 | new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer, | ||
| 78 | ); | ||
| 79 | }, | ||
| 80 | })); | ||
| 81 | |||
| 82 | vi.mock("expo-secure-store", () => ({ | ||
| 83 | getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null), | ||
| 84 | setItemAsync: (key: string, value: string) => { | ||
| 85 | secureStore.set(key, value); | ||
| 86 | return Promise.resolve(); | ||
| 87 | }, | ||
| 88 | })); | ||
| 89 | |||
| 90 | vi.mock("react-native", () => ({ | ||
| 91 | Platform: { | ||
| 92 | OS: "ios", | ||
| 93 | Version: "18.0", | ||
| 94 | }, | ||
| 95 | })); | ||
| 96 | |||
| 97 | vi.mock("../../lib/runtime", () => ({ | ||
| 98 | mobileRuntime: { | ||
| 99 | runPromise: <A, E>(operation: Effect.Effect<A, E>) => | ||
| 100 | Effect.runPromise( | ||
| 101 | operation.pipe( | ||
| 102 | Effect.provide( | ||
| 103 | mobileManagedRelayClientLayer("https://relay.example.test").pipe( | ||
| 104 | Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), | ||
| 105 | ), | ||
| 106 | ), | ||
| 107 | ), | ||
| 108 | ), | ||
| 109 | }, | ||
| 110 | })); | ||
| 111 | |||
| 112 | vi.mock("../../lib/storage", () => ({ | ||
| 113 | loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), | ||
| 114 | loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), | ||
| 115 | loadPreferences: vi.fn(() => Promise.resolve({})), | ||
| 116 | })); | ||
| 117 | |||
| 118 | function proofIat(proof: string): number { | ||
| 119 | const payload = proof.split(".")[1]; | ||
| 120 | if (!payload) { | ||
| 121 | throw new Error("Missing DPoP payload."); | ||
| 122 | } | ||
| 123 | const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { | ||
| 124 | readonly iat: number; | ||
| 125 | }; | ||
| 126 | return decoded.iat; | ||
| 127 | } | ||
| 128 | |||
| 129 | function savedConnection(): SavedRemoteConnection { | ||
| 130 | return { | ||
| 131 | environmentId: "env-1" as EnvironmentId, | ||
| 132 | environmentLabel: "Desktop", | ||
| 133 | pairingUrl: "https://desktop.example/pair", | ||
| 134 | displayUrl: "https://desktop.example", | ||
| 135 | httpBaseUrl: "https://desktop.example", | ||
| 136 | wsBaseUrl: "wss://desktop.example/ws", | ||
| 137 | bearerToken: "bearer-token", | ||
| 138 | }; | ||
| 139 | } | ||
| 140 | |||
| 141 | const runRegistrationEffect = <A, E>(effect: Effect.Effect<A, E, ManagedRelayClient>): Promise<A> => | ||
| 142 | Effect.runPromise( | ||
| 143 | effect.pipe( | ||
| 144 | Effect.provide( | ||
| 145 | mobileManagedRelayClientLayer("https://relay.example.test").pipe( | ||
| 146 | Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), | ||
| 147 | ), | ||
| 148 | ), | ||
| 149 | ), | ||
| 150 | ); | ||
| 151 | |||
| 152 | async function waitForFetchCalls( | ||
| 153 | fetchMock: ReturnType<typeof vi.fn>, | ||
| 154 | count: number, | ||
| 155 | ): Promise<void> { | ||
| 156 | for (let attempt = 0; attempt < 20; attempt += 1) { | ||
| 157 | if (fetchMock.mock.calls.length >= count) { | ||
| 158 | return; | ||
| 159 | } | ||
| 160 | await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | describe("makeRelayDeviceRegistrationRequest", () => { | ||
| 165 | beforeEach(() => { | ||
| 166 | vi.unstubAllGlobals(); | ||
| 167 | vi.stubGlobal("__DEV__", false); | ||
| 168 | secureStore.clear(); | ||
| 169 | Constants.expoConfig!.extra = {}; | ||
| 170 | __resetAgentAwarenessRemoteRegistrationForTest(); | ||
| 171 | widgetMocks.getInstances.mockReset(); | ||
| 172 | widgetMocks.getInstances.mockReturnValue([]); | ||
| 173 | }); | ||
| 174 | |||
| 175 | it("preserves disabled Live Activity preferences in relay registrations", () => { | ||
| 176 | expect( | ||
| 177 | makeRelayDeviceRegistrationRequest({ | ||
| 178 | deviceId: "device-1", | ||
| 179 | label: "Julius's iPhone", | ||
| 180 | iosMajorVersion: 18, | ||
| 181 | appVersion: "1.0.0", | ||
| 182 | pushToken: "apns-token", | ||
| 183 | pushToStartToken: "push-to-start-token", | ||
| 184 | notificationsEnabled: true, | ||
| 185 | preferences: { | ||
| 186 | liveActivitiesEnabled: false, | ||
| 187 | }, | ||
| 188 | }), | ||
| 189 | ).toEqual({ | ||
| 190 | deviceId: "device-1", | ||
| 191 | label: "Julius's iPhone", | ||
| 192 | platform: "ios", | ||
| 193 | iosMajorVersion: 18, | ||
| 194 | appVersion: "1.0.0", | ||
| 195 | pushToken: "apns-token", | ||
| 196 | pushToStartToken: "push-to-start-token", | ||
| 197 | preferences: { | ||
| 198 | liveActivitiesEnabled: false, | ||
| 199 | notificationsEnabled: true, | ||
| 200 | notifyOnApproval: true, | ||
| 201 | notifyOnInput: true, | ||
| 202 | notifyOnCompletion: true, | ||
| 203 | notifyOnFailure: true, | ||
| 204 | }, | ||
| 205 | }); | ||
| 206 | }); | ||
| 207 | |||
| 208 | it("marks notification delivery disabled when APNs permission is unavailable", () => { | ||
| 209 | expect( | ||
| 210 | makeRelayDeviceRegistrationRequest({ | ||
| 211 | deviceId: "device-1", | ||
| 212 | label: "Julius's iPhone", | ||
| 213 | iosMajorVersion: 18, | ||
| 214 | appVersion: "1.0.0", | ||
| 215 | pushToStartToken: "push-to-start-token", | ||
| 216 | notificationsEnabled: false, | ||
| 217 | preferences: { | ||
| 218 | liveActivitiesEnabled: true, | ||
| 219 | }, | ||
| 220 | }), | ||
| 221 | ).toEqual({ | ||
| 222 | deviceId: "device-1", | ||
| 223 | label: "Julius's iPhone", | ||
| 224 | platform: "ios", | ||
| 225 | iosMajorVersion: 18, | ||
| 226 | appVersion: "1.0.0", | ||
| 227 | pushToStartToken: "push-to-start-token", | ||
| 228 | preferences: { | ||
| 229 | liveActivitiesEnabled: true, | ||
| 230 | notificationsEnabled: false, | ||
| 231 | notifyOnApproval: true, | ||
| 232 | notifyOnInput: true, | ||
| 233 | notifyOnCompletion: true, | ||
| 234 | notifyOnFailure: true, | ||
| 235 | }, | ||
| 236 | }); | ||
| 237 | }); | ||
| 238 | |||
| 239 | it("normalizes relay base URLs for APNs registration requests", () => { | ||
| 240 | expect(normalizeAgentAwarenessRelayBaseUrl(" https://relay.example.test/// ")).toBe( | ||
| 241 | "https://relay.example.test", | ||
| 242 | ); | ||
| 243 | expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); | ||
| 244 | }); | ||
| 245 | |||
| 246 | it("registers at most one listener while a Live Activity push token is pending", async () => { | ||
| 247 | registerAgentAwarenessConnection(savedConnection()); | ||
| 248 | const addPushTokenListener = vi.fn(); | ||
| 249 | const activity = { | ||
| 250 | getPushToken: vi.fn(() => Promise.resolve(null)), | ||
| 251 | addPushTokenListener, | ||
| 252 | }; | ||
| 253 | |||
| 254 | await expect( | ||
| 255 | runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), | ||
| 256 | ).resolves.toBe(false); | ||
| 257 | await expect( | ||
| 258 | runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), | ||
| 259 | ).resolves.toBe(false); | ||
| 260 | |||
| 261 | expect(activity.getPushToken).toHaveBeenCalledTimes(2); | ||
| 262 | expect(addPushTokenListener).toHaveBeenCalledTimes(1); | ||
| 263 | }); | ||
| 264 | |||
| 265 | it("reports Live Activity token registration as skipped when relay auth is unavailable", async () => { | ||
| 266 | registerAgentAwarenessConnection(savedConnection()); | ||
| 267 | const activity = { | ||
| 268 | getPushToken: vi.fn(() => Promise.resolve("activity-token")), | ||
| 269 | addPushTokenListener: vi.fn(), | ||
| 270 | }; | ||
| 271 | |||
| 272 | await expect( | ||
| 273 | runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), | ||
| 274 | ).resolves.toBe(false); | ||
| 275 | }); | ||
| 276 | |||
| 277 | it("registers APNS-started Live Activities for relay updates without mutating them locally", async () => { | ||
| 278 | const activity = { | ||
| 279 | getPushToken: vi.fn(() => Promise.resolve("activity-token")), | ||
| 280 | addPushTokenListener: vi.fn(), | ||
| 281 | start: vi.fn(), | ||
| 282 | update: vi.fn(), | ||
| 283 | end: vi.fn(), | ||
| 284 | }; | ||
| 285 | widgetMocks.getInstances.mockReturnValue([activity] as never); | ||
| 286 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 287 | |||
| 288 | await runRegistrationEffect(refreshActiveLiveActivityRemoteRegistration()); | ||
| 289 | |||
| 290 | expect(activity.getPushToken).toHaveBeenCalled(); | ||
| 291 | expect(activity.start).not.toHaveBeenCalled(); | ||
| 292 | expect(activity.update).not.toHaveBeenCalled(); | ||
| 293 | expect(activity.end).not.toHaveBeenCalled(); | ||
| 294 | }); | ||
| 295 | |||
| 296 | it("refreshes APNs registration for connected environments after settings changes", async () => { | ||
| 297 | registerAgentAwarenessConnection(savedConnection()); | ||
| 298 | await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| 299 | vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); | ||
| 300 | |||
| 301 | await runRegistrationEffect(refreshAgentAwarenessRegistration()); | ||
| 302 | |||
| 303 | expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); | ||
| 304 | }); | ||
| 305 | |||
| 306 | it.effect("registers the APNs device when cloud auth becomes available", () => { | ||
| 307 | const fetchMock = vi.fn((request: RequestInfo | URL) => { | ||
| 308 | const url = request instanceof Request ? request.url : String(request); | ||
| 309 | return Promise.resolve( | ||
| 310 | Response.json( | ||
| 311 | url.endsWith("/v1/client/dpop-token") | ||
| 312 | ? { | ||
| 313 | access_token: "relay-dpop-token", | ||
| 314 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 315 | token_type: "DPoP", | ||
| 316 | expires_in: 300, | ||
| 317 | scope: "mobile:registration", | ||
| 318 | } | ||
| 319 | : { ok: true }, | ||
| 320 | ), | ||
| 321 | ); | ||
| 322 | }); | ||
| 323 | vi.stubGlobal("fetch", fetchMock); | ||
| 324 | Constants.expoConfig!.extra = { | ||
| 325 | relay: { | ||
| 326 | url: "https://relay.example.test/", | ||
| 327 | }, | ||
| 328 | }; | ||
| 329 | |||
| 330 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 331 | |||
| 332 | return Effect.gen(function* () { | ||
| 333 | yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); | ||
| 334 | |||
| 335 | expect(fetchMock).toHaveBeenCalledTimes(2); | ||
| 336 | const [request, init] = fetchMock.mock.calls[1] as unknown as [ | ||
| 337 | unknown, | ||
| 338 | RequestInit | undefined, | ||
| 339 | ]; | ||
| 340 | const url = request instanceof Request ? request.url : String(request); | ||
| 341 | const method = request instanceof Request ? request.method : init?.method; | ||
| 342 | const headers = request instanceof Request ? request.headers : new Headers(init?.headers); | ||
| 343 | const dpop = headers.get("dpop"); | ||
| 344 | expect(url).toBe("https://relay.example.test/v1/mobile/devices"); | ||
| 345 | expect(method).toBe("POST"); | ||
| 346 | expect(headers.get("authorization")).toBe("DPoP relay-dpop-token"); | ||
| 347 | expect(dpop).toEqual(expect.any(String)); | ||
| 348 | if (!dpop) { | ||
| 349 | throw new Error("Missing DPoP header."); | ||
| 350 | } | ||
| 351 | expect( | ||
| 352 | verifyDpopProof({ | ||
| 353 | proof: dpop, | ||
| 354 | method: "POST", | ||
| 355 | url: "https://relay.example.test/v1/mobile/devices", | ||
| 356 | expectedAccessToken: "relay-dpop-token", | ||
| 357 | nowEpochSeconds: proofIat(dpop), | ||
| 358 | }), | ||
| 359 | ).toMatchObject({ ok: true }); | ||
| 360 | }); | ||
| 361 | }); | ||
| 362 | |||
| 363 | it("only registers again when the authenticated identity changes", () => { | ||
| 364 | expect(shouldRegisterAgentAwarenessDeviceForProvider(null, "user-a")).toBe(true); | ||
| 365 | expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", "user-a")).toBe(false); | ||
| 366 | expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", "user-b")).toBe(true); | ||
| 367 | expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", undefined)).toBe(true); | ||
| 368 | }); | ||
| 369 | |||
| 370 | it("registers rotated APNs tokens without rereading the native token", async () => { | ||
| 371 | const fetchMock = vi.fn((request: RequestInfo | URL) => { | ||
| 372 | const url = request instanceof Request ? request.url : String(request); | ||
| 373 | return Promise.resolve( | ||
| 374 | Response.json( | ||
| 375 | url.endsWith("/v1/client/dpop-token") | ||
| 376 | ? { | ||
| 377 | access_token: "relay-dpop-token", | ||
| 378 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 379 | token_type: "DPoP", | ||
| 380 | expires_in: 300, | ||
| 381 | scope: "mobile:registration", | ||
| 382 | } | ||
| 383 | : { ok: true }, | ||
| 384 | ), | ||
| 385 | ); | ||
| 386 | }); | ||
| 387 | vi.stubGlobal("fetch", fetchMock); | ||
| 388 | Constants.expoConfig!.extra = { | ||
| 389 | relay: { | ||
| 390 | url: "https://relay.example.test/", | ||
| 391 | }, | ||
| 392 | }; | ||
| 393 | |||
| 394 | vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); | ||
| 395 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 396 | |||
| 397 | const tokenListener = vi.mocked(Notifications.addPushTokenListener).mock.calls.at(-1)?.[0]; | ||
| 398 | expect(tokenListener).toBeDefined(); | ||
| 399 | tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never); | ||
| 400 | |||
| 401 | await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| 402 | |||
| 403 | expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); | ||
| 404 | }); | ||
| 405 | |||
| 406 | it.effect( | ||
| 407 | "keeps the user-scoped relay APNs device when an environment connection is removed", | ||
| 408 | () => { | ||
| 409 | const fetchMock = vi.fn((request: RequestInfo | URL) => { | ||
| 410 | const url = request instanceof Request ? request.url : String(request); | ||
| 411 | return Promise.resolve( | ||
| 412 | Response.json( | ||
| 413 | url.endsWith("/v1/client/dpop-token") | ||
| 414 | ? { | ||
| 415 | access_token: "relay-dpop-token", | ||
| 416 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 417 | token_type: "DPoP", | ||
| 418 | expires_in: 300, | ||
| 419 | scope: "mobile:registration", | ||
| 420 | } | ||
| 421 | : { ok: true }, | ||
| 422 | ), | ||
| 423 | ); | ||
| 424 | }); | ||
| 425 | vi.stubGlobal("fetch", fetchMock); | ||
| 426 | Constants.expoConfig!.extra = { | ||
| 427 | relay: { | ||
| 428 | url: "https://relay.example.test/", | ||
| 429 | }, | ||
| 430 | }; | ||
| 431 | |||
| 432 | registerAgentAwarenessConnection(savedConnection()); | ||
| 433 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | ||
| 434 | return Effect.gen(function* () { | ||
| 435 | yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); | ||
| 436 | fetchMock.mockClear(); | ||
| 437 | |||
| 438 | unregisterAgentAwarenessConnection(savedConnection().environmentId); | ||
| 439 | |||
| 440 | expect(fetchMock).not.toHaveBeenCalled(); | ||
| 441 | }); | ||
| 442 | }, | ||
| 443 | ); | ||
| 444 | }); | ||
apps/mobile/src/features/agent-awareness/remoteRegistration.ts created+523| ... | @@ -0,0 +1,523 @@ | ||
| 1 | import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets"; | ||
| 2 | import Constants from "expo-constants"; | ||
| 3 | import * as Notifications from "expo-notifications"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import { Platform } from "react-native"; | ||
| 6 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 7 | import { | ||
| 8 | type RelayDeviceRegistrationRequest, | ||
| 9 | type RelayLiveActivityRegistrationRequest, | ||
| 10 | } from "@t3tools/contracts/relay"; | ||
| 11 | import { ManagedRelayClient } from "@t3tools/client-runtime"; | ||
| 12 | |||
| 13 | import type { SavedRemoteConnection } from "../../lib/connection"; | ||
| 14 | import { mobileRuntime } from "../../lib/runtime"; | ||
| 15 | import { | ||
| 16 | loadAgentAwarenessDeviceId, | ||
| 17 | loadOrCreateAgentAwarenessDeviceId, | ||
| 18 | loadPreferences, | ||
| 19 | } from "../../lib/storage"; | ||
| 20 | import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; | ||
| 21 | import { resolveCloudPublicConfig } from "../cloud/publicConfig"; | ||
| 22 | import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; | ||
| 23 | |||
| 24 | const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; | ||
| 25 | const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>(); | ||
| 26 | const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>(); | ||
| 27 | let pushToStartSubscription: { remove: () => void } | null = null; | ||
| 28 | let pushTokenSubscription: { remove: () => void } | null = null; | ||
| 29 | let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null; | ||
| 30 | let relayTokenProvider: (() => Promise<string | null>) | null = null; | ||
| 31 | let relayTokenProviderIdentity: string | null = null; | ||
| 32 | |||
| 33 | export function normalizeAgentAwarenessRelayBaseUrl( | ||
| 34 | value: string | null | undefined, | ||
| 35 | ): string | null { | ||
| 36 | const trimmed = value?.trim(); | ||
| 37 | if (!trimmed) { | ||
| 38 | return null; | ||
| 39 | } | ||
| 40 | return trimmed.replace(/\/+$/g, ""); | ||
| 41 | } | ||
| 42 | |||
| 43 | function readRelayConfig(): { readonly url: string } | null { | ||
| 44 | const relayUrl = resolveCloudPublicConfig().relayUrl; | ||
| 45 | if (!relayUrl) { | ||
| 46 | logRegistrationDebug("relay registration skipped; relay config missing"); | ||
| 47 | return null; | ||
| 48 | } | ||
| 49 | |||
| 50 | return { url: relayUrl }; | ||
| 51 | } | ||
| 52 | |||
| 53 | function canRegisterRemoteLiveActivities(): boolean { | ||
| 54 | return Platform.OS === "ios"; | ||
| 55 | } | ||
| 56 | |||
| 57 | export function shouldRegisterAgentAwarenessDeviceForProvider( | ||
| 58 | previousIdentity: string | null, | ||
| 59 | identity: string | undefined, | ||
| 60 | ): boolean { | ||
| 61 | return identity === undefined || identity !== previousIdentity; | ||
| 62 | } | ||
| 63 | |||
| 64 | export function setAgentAwarenessRelayTokenProvider( | ||
| 65 | provider: (() => Promise<string | null>) | null, | ||
| 66 | identity?: string, | ||
| 67 | ): void { | ||
| 68 | const isExistingIdentity = | ||
| 69 | provider !== null && | ||
| 70 | !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity); | ||
| 71 | relayTokenProvider = provider; | ||
| 72 | relayTokenProviderIdentity = provider ? (identity ?? null) : null; | ||
| 73 | if (!provider) { | ||
| 74 | pushToStartSubscription?.remove(); | ||
| 75 | pushToStartSubscription = null; | ||
| 76 | pushTokenSubscription?.remove(); | ||
| 77 | pushTokenSubscription = null; | ||
| 78 | if (activeLiveActivityRegistrationRetry) { | ||
| 79 | clearTimeout(activeLiveActivityRegistrationRetry); | ||
| 80 | activeLiveActivityRegistrationRetry = null; | ||
| 81 | } | ||
| 82 | return; | ||
| 83 | } | ||
| 84 | ensurePushToStartListener(); | ||
| 85 | ensurePushTokenListener(); | ||
| 86 | runRegistrationInBackground( | ||
| 87 | refreshActiveLiveActivityRemoteRegistration(), | ||
| 88 | "active live activity registration after cloud sign-in failed", | ||
| 89 | ); | ||
| 90 | if (isExistingIdentity) { | ||
| 91 | return; | ||
| 92 | } | ||
| 93 | runRegistrationInBackground(registerDevice(), "device registration after cloud sign-in failed"); | ||
| 94 | } | ||
| 95 | |||
| 96 | function iosMajorVersion(): number { | ||
| 97 | const version = Platform.Version; | ||
| 98 | if (typeof version === "number") { | ||
| 99 | return Math.floor(version); | ||
| 100 | } | ||
| 101 | const major = Number.parseInt(version.split(".")[0] ?? "", 10); | ||
| 102 | return Number.isFinite(major) ? major : 18; | ||
| 103 | } | ||
| 104 | |||
| 105 | function nativePushTokenRegistration(observedPushToken?: string) { | ||
| 106 | return Effect.gen(function* () { | ||
| 107 | if (!canRegisterRemoteLiveActivities()) { | ||
| 108 | return { notificationsEnabled: false, pushToken: null }; | ||
| 109 | } | ||
| 110 | if (observedPushToken) { | ||
| 111 | return { notificationsEnabled: true, pushToken: observedPushToken }; | ||
| 112 | } | ||
| 113 | const permissions = yield* Effect.tryPromise({ | ||
| 114 | try: () => Notifications.getPermissionsAsync(), | ||
| 115 | catch: (error) => error, | ||
| 116 | }); | ||
| 117 | if (!permissions.granted) { | ||
| 118 | return { notificationsEnabled: false, pushToken: null }; | ||
| 119 | } | ||
| 120 | const token = yield* Effect.tryPromise({ | ||
| 121 | try: () => Notifications.getDevicePushTokenAsync(), | ||
| 122 | catch: (error) => error, | ||
| 123 | }).pipe( | ||
| 124 | Effect.tapError((error) => | ||
| 125 | Effect.sync(() => { | ||
| 126 | logRegistrationError("native APNs token lookup failed", error); | ||
| 127 | }), | ||
| 128 | ), | ||
| 129 | Effect.orElseSucceed(() => null), | ||
| 130 | ); | ||
| 131 | const pushToken = | ||
| 132 | token?.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0 | ||
| 133 | ? token.data.trim() | ||
| 134 | : null; | ||
| 135 | return { notificationsEnabled: pushToken !== null, pushToken }; | ||
| 136 | }); | ||
| 137 | } | ||
| 138 | |||
| 139 | const relayToken = Effect.gen(function* () { | ||
| 140 | const provider = relayTokenProvider; | ||
| 141 | if (!provider) { | ||
| 142 | return null; | ||
| 143 | } | ||
| 144 | return yield* Effect.tryPromise({ | ||
| 145 | try: provider, | ||
| 146 | catch: (error) => error, | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | function registerDeviceWithRelay( | ||
| 151 | body: RelayDeviceRegistrationRequest, | ||
| 152 | ): Effect.Effect<void, unknown, ManagedRelayClient> { | ||
| 153 | return Effect.gen(function* () { | ||
| 154 | if (!readRelayConfig()) return; | ||
| 155 | const token = yield* relayToken; | ||
| 156 | if (!token) { | ||
| 157 | logRegistrationDebug("relay device registration skipped; user is not signed in"); | ||
| 158 | return; | ||
| 159 | } | ||
| 160 | |||
| 161 | const client = yield* ManagedRelayClient; | ||
| 162 | yield* client.registerDevice({ | ||
| 163 | clerkToken: token, | ||
| 164 | payload: body, | ||
| 165 | }); | ||
| 166 | }); | ||
| 167 | } | ||
| 168 | |||
| 169 | function unregisterDeviceWithRelay(input: { | ||
| 170 | readonly deviceId: string; | ||
| 171 | readonly tokenProvider: () => Promise<string | null>; | ||
| 172 | }): Effect.Effect<void, unknown, ManagedRelayClient> { | ||
| 173 | return Effect.gen(function* () { | ||
| 174 | if (!readRelayConfig()) return; | ||
| 175 | const token = yield* Effect.tryPromise({ | ||
| 176 | try: input.tokenProvider, | ||
| 177 | catch: (error) => error, | ||
| 178 | }); | ||
| 179 | if (!token) { | ||
| 180 | logRegistrationDebug("relay device unregistration skipped; user is not signed in"); | ||
| 181 | return; | ||
| 182 | } | ||
| 183 | |||
| 184 | const client = yield* ManagedRelayClient; | ||
| 185 | yield* client.unregisterDevice({ | ||
| 186 | clerkToken: token, | ||
| 187 | deviceId: input.deviceId, | ||
| 188 | }); | ||
| 189 | }); | ||
| 190 | } | ||
| 191 | |||
| 192 | function registerLiveActivityWithRelay( | ||
| 193 | body: RelayLiveActivityRegistrationRequest, | ||
| 194 | ): Effect.Effect<boolean, unknown, ManagedRelayClient> { | ||
| 195 | return Effect.gen(function* () { | ||
| 196 | if (!readRelayConfig()) return false; | ||
| 197 | const token = yield* relayToken; | ||
| 198 | if (!token) { | ||
| 199 | logRegistrationDebug("relay live activity registration skipped; user is not signed in"); | ||
| 200 | return false; | ||
| 201 | } | ||
| 202 | |||
| 203 | const client = yield* ManagedRelayClient; | ||
| 204 | yield* client.registerLiveActivity({ | ||
| 205 | clerkToken: token, | ||
| 206 | payload: body, | ||
| 207 | }); | ||
| 208 | return true; | ||
| 209 | }); | ||
| 210 | } | ||
| 211 | |||
| 212 | function logRegistrationError(context: string, error: unknown): void { | ||
| 213 | if (!__DEV__) { | ||
| 214 | return; | ||
| 215 | } | ||
| 216 | console.warn( | ||
| 217 | `[agent-awareness] ${context}`, | ||
| 218 | error instanceof Error ? error.message : String(error), | ||
| 219 | ); | ||
| 220 | } | ||
| 221 | |||
| 222 | function logRegistrationDebug(context: string, details?: unknown): void { | ||
| 223 | if (!__DEV__) { | ||
| 224 | return; | ||
| 225 | } | ||
| 226 | console.log(`[agent-awareness] ${context}`, details ?? ""); | ||
| 227 | } | ||
| 228 | |||
| 229 | function runRegistrationInBackground( | ||
| 230 | operation: Effect.Effect<unknown, unknown, ManagedRelayClient>, | ||
| 231 | context: string, | ||
| 232 | ): void { | ||
| 233 | void mobileRuntime.runPromise(operation).catch((error: unknown) => { | ||
| 234 | logRegistrationError(context, error); | ||
| 235 | }); | ||
| 236 | } | ||
| 237 | |||
| 238 | function registerDevice(input?: { | ||
| 239 | readonly pushToStartToken?: string; | ||
| 240 | readonly observedPushToken?: string; | ||
| 241 | }): Effect.Effect<void, unknown, ManagedRelayClient> { | ||
| 242 | return Effect.gen(function* () { | ||
| 243 | if (!canRegisterRemoteLiveActivities()) { | ||
| 244 | return; | ||
| 245 | } | ||
| 246 | |||
| 247 | const [deviceId, preferences] = yield* Effect.all([ | ||
| 248 | Effect.tryPromise({ | ||
| 249 | try: () => loadOrCreateAgentAwarenessDeviceId(), | ||
| 250 | catch: (error) => error, | ||
| 251 | }), | ||
| 252 | Effect.tryPromise({ | ||
| 253 | try: () => loadPreferences(), | ||
| 254 | catch: (error) => error, | ||
| 255 | }), | ||
| 256 | ]); | ||
| 257 | const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); | ||
| 258 | yield* registerDeviceWithRelay( | ||
| 259 | makeRelayDeviceRegistrationRequest({ | ||
| 260 | deviceId, | ||
| 261 | label: Constants.deviceName?.trim() || "iOS device", | ||
| 262 | iosMajorVersion: iosMajorVersion(), | ||
| 263 | appVersion: Constants.expoConfig?.version, | ||
| 264 | ...(pushTokenRegistration.pushToken ? { pushToken: pushTokenRegistration.pushToken } : {}), | ||
| 265 | ...(input?.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), | ||
| 266 | notificationsEnabled: pushTokenRegistration.notificationsEnabled, | ||
| 267 | preferences, | ||
| 268 | }), | ||
| 269 | ); | ||
| 270 | }); | ||
| 271 | } | ||
| 272 | |||
| 273 | function registerDeviceForCurrentUser( | ||
| 274 | pushToStartToken?: string, | ||
| 275 | ): Effect.Effect<void, unknown, ManagedRelayClient> { | ||
| 276 | return registerDevice(pushToStartToken ? { pushToStartToken } : undefined); | ||
| 277 | } | ||
| 278 | |||
| 279 | function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void { | ||
| 280 | runRegistrationInBackground( | ||
| 281 | registerDeviceForCurrentUser(pushToStartToken), | ||
| 282 | "push-to-start token registration failed", | ||
| 283 | ); | ||
| 284 | } | ||
| 285 | |||
| 286 | function ensurePushToStartListener(): void { | ||
| 287 | if (pushToStartSubscription || !canRegisterRemoteLiveActivities()) { | ||
| 288 | return; | ||
| 289 | } | ||
| 290 | |||
| 291 | pushToStartSubscription = addPushToStartTokenListener((event) => { | ||
| 292 | const token = event.activityPushToStartToken; | ||
| 293 | if (token) { | ||
| 294 | registerPushToStartTokenForCurrentUser(token); | ||
| 295 | } | ||
| 296 | }); | ||
| 297 | } | ||
| 298 | |||
| 299 | function ensurePushTokenListener(): void { | ||
| 300 | if (pushTokenSubscription || !canRegisterRemoteLiveActivities()) { | ||
| 301 | return; | ||
| 302 | } | ||
| 303 | |||
| 304 | pushTokenSubscription = Notifications.addPushTokenListener((token) => { | ||
| 305 | if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) { | ||
| 306 | runRegistrationInBackground( | ||
| 307 | registerDevice({ observedPushToken: token.data.trim() }), | ||
| 308 | "native APNs token rotation registration failed", | ||
| 309 | ); | ||
| 310 | } | ||
| 311 | }); | ||
| 312 | } | ||
| 313 | |||
| 314 | export function registerAgentAwarenessConnection(connection: SavedRemoteConnection): void { | ||
| 315 | if (!canRegisterRemoteLiveActivities()) { | ||
| 316 | return; | ||
| 317 | } | ||
| 318 | |||
| 319 | environmentConnections.set(connection.environmentId, connection); | ||
| 320 | ensurePushToStartListener(); | ||
| 321 | ensurePushTokenListener(); | ||
| 322 | runRegistrationInBackground(registerDevice(), "device registration failed"); | ||
| 323 | runRegistrationInBackground( | ||
| 324 | refreshActiveLiveActivityRemoteRegistration(), | ||
| 325 | "active live activity registration after environment connection failed", | ||
| 326 | ); | ||
| 327 | } | ||
| 328 | |||
| 329 | function removeAgentAwarenessConnection(environmentId: EnvironmentId): void { | ||
| 330 | environmentConnections.delete(environmentId); | ||
| 331 | } | ||
| 332 | |||
| 333 | export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId): void { | ||
| 334 | removeAgentAwarenessConnection(environmentId); | ||
| 335 | } | ||
| 336 | |||
| 337 | export function unregisterAllAgentAwarenessConnections(): void { | ||
| 338 | environmentConnections.clear(); | ||
| 339 | pushToStartSubscription?.remove(); | ||
| 340 | pushToStartSubscription = null; | ||
| 341 | pushTokenSubscription?.remove(); | ||
| 342 | pushTokenSubscription = null; | ||
| 343 | if (activeLiveActivityRegistrationRetry) { | ||
| 344 | clearTimeout(activeLiveActivityRegistrationRetry); | ||
| 345 | activeLiveActivityRegistrationRetry = null; | ||
| 346 | } | ||
| 347 | } | ||
| 348 | |||
| 349 | export function refreshAgentAwarenessRegistration(): Effect.Effect< | ||
| 350 | void, | ||
| 351 | never, | ||
| 352 | ManagedRelayClient | ||
| 353 | > { | ||
| 354 | return registerDeviceForCurrentUser().pipe( | ||
| 355 | Effect.catch((error) => | ||
| 356 | Effect.sync(() => { | ||
| 357 | logRegistrationError("device registration refresh failed", error); | ||
| 358 | }), | ||
| 359 | ), | ||
| 360 | ); | ||
| 361 | } | ||
| 362 | |||
| 363 | export function __resetAgentAwarenessRemoteRegistrationForTest(): void { | ||
| 364 | environmentConnections.clear(); | ||
| 365 | pushToStartSubscription?.remove(); | ||
| 366 | pushToStartSubscription = null; | ||
| 367 | pushTokenSubscription?.remove(); | ||
| 368 | pushTokenSubscription = null; | ||
| 369 | if (activeLiveActivityRegistrationRetry) { | ||
| 370 | clearTimeout(activeLiveActivityRegistrationRetry); | ||
| 371 | activeLiveActivityRegistrationRetry = null; | ||
| 372 | } | ||
| 373 | relayTokenProvider = null; | ||
| 374 | relayTokenProviderIdentity = null; | ||
| 375 | } | ||
| 376 | |||
| 377 | export function unregisterAgentAwarenessDeviceForCurrentUser( | ||
| 378 | tokenProvider: () => Promise<string | null>, | ||
| 379 | ): Effect.Effect<void, never, ManagedRelayClient> { | ||
| 380 | return Effect.gen(function* () { | ||
| 381 | const deviceId = yield* Effect.tryPromise({ | ||
| 382 | try: () => loadAgentAwarenessDeviceId(), | ||
| 383 | catch: (error) => error, | ||
| 384 | }); | ||
| 385 | if (!deviceId) { | ||
| 386 | return; | ||
| 387 | } | ||
| 388 | yield* unregisterDeviceWithRelay({ deviceId, tokenProvider }); | ||
| 389 | }).pipe( | ||
| 390 | Effect.catch((error) => | ||
| 391 | Effect.sync(() => { | ||
| 392 | logRegistrationError("device unregistration failed", error); | ||
| 393 | }), | ||
| 394 | ), | ||
| 395 | ); | ||
| 396 | } | ||
| 397 | |||
| 398 | export function registerLiveActivityPushToken(input: { | ||
| 399 | readonly activity: LiveActivity<AgentActivityProps>; | ||
| 400 | }): Effect.Effect<boolean, unknown, ManagedRelayClient> { | ||
| 401 | return Effect.gen(function* () { | ||
| 402 | if (!canRegisterRemoteLiveActivities()) { | ||
| 403 | return false; | ||
| 404 | } | ||
| 405 | |||
| 406 | const activityPushToken = yield* Effect.tryPromise({ | ||
| 407 | try: () => input.activity.getPushToken(), | ||
| 408 | catch: (error) => error, | ||
| 409 | }); | ||
| 410 | if (!activityPushToken) { | ||
| 411 | if (activityPushTokenListeners.has(input.activity)) { | ||
| 412 | logRegistrationDebug( | ||
| 413 | "live activity push token not available yet; token listener already registered", | ||
| 414 | { | ||
| 415 | connectionCount: environmentConnections.size, | ||
| 416 | }, | ||
| 417 | ); | ||
| 418 | return false; | ||
| 419 | } | ||
| 420 | |||
| 421 | logRegistrationDebug( | ||
| 422 | "live activity push token not available yet; listening for token event", | ||
| 423 | { | ||
| 424 | connectionCount: environmentConnections.size, | ||
| 425 | }, | ||
| 426 | ); | ||
| 427 | activityPushTokenListeners.add(input.activity); | ||
| 428 | input.activity.addPushTokenListener((event) => { | ||
| 429 | if (event.pushToken) { | ||
| 430 | logRegistrationDebug("live activity push token event received", { | ||
| 431 | tokenSuffix: event.pushToken.slice(-8), | ||
| 432 | }); | ||
| 433 | runRegistrationInBackground( | ||
| 434 | registerLiveActivityPushTokenValue({ | ||
| 435 | activityPushToken: event.pushToken, | ||
| 436 | }), | ||
| 437 | "live activity token listener registration failed", | ||
| 438 | ); | ||
| 439 | } | ||
| 440 | }); | ||
| 441 | return false; | ||
| 442 | } | ||
| 443 | |||
| 444 | return yield* registerLiveActivityPushTokenValue({ | ||
| 445 | activityPushToken, | ||
| 446 | }); | ||
| 447 | }); | ||
| 448 | } | ||
| 449 | |||
| 450 | function registerLiveActivityPushTokenValue(input: { | ||
| 451 | readonly activityPushToken: string; | ||
| 452 | }): Effect.Effect<boolean, unknown, ManagedRelayClient> { | ||
| 453 | return Effect.gen(function* () { | ||
| 454 | const deviceId = yield* Effect.tryPromise({ | ||
| 455 | try: () => loadOrCreateAgentAwarenessDeviceId(), | ||
| 456 | catch: (error) => error, | ||
| 457 | }); | ||
| 458 | const registered = yield* registerLiveActivityWithRelay({ | ||
| 459 | deviceId, | ||
| 460 | activityPushToken: input.activityPushToken, | ||
| 461 | }); | ||
| 462 | if (registered) { | ||
| 463 | logRegistrationDebug("live activity push token registered", { | ||
| 464 | tokenSuffix: input.activityPushToken.slice(-8), | ||
| 465 | }); | ||
| 466 | } | ||
| 467 | return registered; | ||
| 468 | }); | ||
| 469 | } | ||
| 470 | |||
| 471 | function scheduleActiveLiveActivityRegistrationRetry(): void { | ||
| 472 | if (activeLiveActivityRegistrationRetry || !relayTokenProvider) { | ||
| 473 | return; | ||
| 474 | } | ||
| 475 | |||
| 476 | activeLiveActivityRegistrationRetry = setTimeout(() => { | ||
| 477 | activeLiveActivityRegistrationRetry = null; | ||
| 478 | runRegistrationInBackground( | ||
| 479 | refreshActiveLiveActivityRemoteRegistration(), | ||
| 480 | "active live activity token retry failed", | ||
| 481 | ); | ||
| 482 | }, REMOTE_ACTIVITY_REGISTRATION_RETRY_MS); | ||
| 483 | } | ||
| 484 | |||
| 485 | export function refreshActiveLiveActivityRemoteRegistration(): Effect.Effect< | ||
| 486 | void, | ||
| 487 | never, | ||
| 488 | ManagedRelayClient | ||
| 489 | > { | ||
| 490 | return Effect.gen(function* () { | ||
| 491 | if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { | ||
| 492 | return; | ||
| 493 | } | ||
| 494 | |||
| 495 | const activities = yield* Effect.try({ | ||
| 496 | try: () => AgentActivity.getInstances(), | ||
| 497 | catch: (error) => error, | ||
| 498 | }).pipe( | ||
| 499 | Effect.catch((error) => | ||
| 500 | Effect.sync(() => { | ||
| 501 | logRegistrationError("active live activity lookup failed", error); | ||
| 502 | return [] as ReadonlyArray<LiveActivity<AgentActivityProps>>; | ||
| 503 | }), | ||
| 504 | ), | ||
| 505 | ); | ||
| 506 | |||
| 507 | const registrationResults = yield* Effect.forEach(activities, (activity) => | ||
| 508 | registerLiveActivityPushToken({ activity }).pipe( | ||
| 509 | Effect.map((registered) => !registered), | ||
| 510 | Effect.catch((error) => | ||
| 511 | Effect.sync(() => { | ||
| 512 | logRegistrationError("active live activity token registration failed", error); | ||
| 513 | return true; | ||
| 514 | }), | ||
| 515 | ), | ||
| 516 | ), | ||
| 517 | ); | ||
| 518 | |||
| 519 | if (registrationResults.some(Boolean)) { | ||
| 520 | scheduleActiveLiveActivityRegistrationRetry(); | ||
| 521 | } | ||
| 522 | }); | ||
| 523 | } | ||
apps/mobile/src/features/cloud/CloudAuthProvider.tsx created+87| ... | @@ -0,0 +1,87 @@ | ||
| 1 | import { ClerkProvider, useAuth } from "@clerk/expo"; | ||
| 2 | import { tokenCache } from "@clerk/expo/token-cache"; | ||
| 3 | import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; | ||
| 4 | import { type ReactNode, useEffect, useRef } from "react"; | ||
| 5 | |||
| 6 | import { mobileRuntime } from "../../lib/runtime"; | ||
| 7 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 8 | import { | ||
| 9 | setAgentAwarenessRelayTokenProvider, | ||
| 10 | unregisterAgentAwarenessDeviceForCurrentUser, | ||
| 11 | } from "../agent-awareness/remoteRegistration"; | ||
| 12 | import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; | ||
| 13 | |||
| 14 | function CloudAuthBridge(props: { readonly children: ReactNode }) { | ||
| 15 | const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); | ||
| 16 | const previousTokenProviderRef = useRef<{ | ||
| 17 | readonly userId: string; | ||
| 18 | readonly provider: () => Promise<string | null>; | ||
| 19 | } | null>(null); | ||
| 20 | |||
| 21 | useEffect(() => { | ||
| 22 | if (!isLoaded) { | ||
| 23 | return; | ||
| 24 | } | ||
| 25 | if (!isSignedIn || !userId) { | ||
| 26 | const previous = previousTokenProviderRef.current; | ||
| 27 | previousTokenProviderRef.current = null; | ||
| 28 | if (previous) { | ||
| 29 | void mobileRuntime | ||
| 30 | .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) | ||
| 31 | .catch(() => undefined); | ||
| 32 | } | ||
| 33 | setAgentAwarenessRelayTokenProvider(null); | ||
| 34 | setManagedRelaySession(appAtomRegistry, null); | ||
| 35 | return; | ||
| 36 | } | ||
| 37 | |||
| 38 | const previous = previousTokenProviderRef.current; | ||
| 39 | if (previous && previous.userId !== userId) { | ||
| 40 | void mobileRuntime | ||
| 41 | .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) | ||
| 42 | .catch(() => undefined); | ||
| 43 | } | ||
| 44 | const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); | ||
| 45 | previousTokenProviderRef.current = { userId, provider: tokenProvider }; | ||
| 46 | setAgentAwarenessRelayTokenProvider(tokenProvider, userId); | ||
| 47 | setManagedRelaySession( | ||
| 48 | appAtomRegistry, | ||
| 49 | createManagedRelaySession({ | ||
| 50 | accountId: userId, | ||
| 51 | readClerkToken: tokenProvider, | ||
| 52 | }), | ||
| 53 | ); | ||
| 54 | }, [getToken, isLoaded, isSignedIn, userId]); | ||
| 55 | |||
| 56 | useEffect( | ||
| 57 | () => () => { | ||
| 58 | previousTokenProviderRef.current = null; | ||
| 59 | setAgentAwarenessRelayTokenProvider(null); | ||
| 60 | setManagedRelaySession(appAtomRegistry, null); | ||
| 61 | }, | ||
| 62 | [], | ||
| 63 | ); | ||
| 64 | |||
| 65 | return props.children; | ||
| 66 | } | ||
| 67 | |||
| 68 | export function CloudAuthProvider(props: { readonly children: ReactNode }) { | ||
| 69 | const { clerkPublishableKey: publishableKey, relayUrl } = resolveCloudPublicConfig(); | ||
| 70 | |||
| 71 | useEffect(() => { | ||
| 72 | if (!publishableKey || !relayUrl) { | ||
| 73 | setAgentAwarenessRelayTokenProvider(null); | ||
| 74 | setManagedRelaySession(appAtomRegistry, null); | ||
| 75 | } | ||
| 76 | }, [publishableKey, relayUrl]); | ||
| 77 | |||
| 78 | if (!publishableKey || !relayUrl) { | ||
| 79 | return props.children; | ||
| 80 | } | ||
| 81 | |||
| 82 | return ( | ||
| 83 | <ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}> | ||
| 84 | <CloudAuthBridge>{props.children}</CloudAuthBridge> | ||
| 85 | </ClerkProvider> | ||
| 86 | ); | ||
| 87 | } | ||
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx created+208| ... | @@ -0,0 +1,208 @@ | ||
| 1 | import { useWaitlist } from "@clerk/expo"; | ||
| 2 | import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; | ||
| 3 | import { useState } from "react"; | ||
| 4 | |||
| 5 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 6 | |||
| 7 | export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) { | ||
| 8 | const { errors, fetchStatus, waitlist } = useWaitlist(); | ||
| 9 | const colors = useCloudWaitlistColors(); | ||
| 10 | const [emailAddress, setEmailAddress] = useState(""); | ||
| 11 | const [requestError, setRequestError] = useState<string | null>(null); | ||
| 12 | const isSubmitting = fetchStatus === "fetching"; | ||
| 13 | const fieldError = errors.fields.emailAddress?.longMessage; | ||
| 14 | |||
| 15 | const joinWaitlist = async () => { | ||
| 16 | const normalizedEmailAddress = emailAddress.trim(); | ||
| 17 | if (!normalizedEmailAddress || isSubmitting) { | ||
| 18 | return; | ||
| 19 | } | ||
| 20 | |||
| 21 | setRequestError(null); | ||
| 22 | try { | ||
| 23 | const { error } = await waitlist.join({ emailAddress: normalizedEmailAddress }); | ||
| 24 | if (error) { | ||
| 25 | setRequestError("Could not join the waitlist. Check your email address and try again."); | ||
| 26 | } | ||
| 27 | } catch { | ||
| 28 | setRequestError("Could not join the waitlist. Check your connection and try again."); | ||
| 29 | } | ||
| 30 | }; | ||
| 31 | |||
| 32 | if (waitlist.id) { | ||
| 33 | return ( | ||
| 34 | <View style={styles.content}> | ||
| 35 | <Text style={[styles.title, { color: colors.foreground }]}>You are on the waitlist</Text> | ||
| 36 | <Text style={[styles.body, styles.confirmationBody, { color: colors.secondaryForeground }]}> | ||
| 37 | We will email you when your T3 Cloud access is ready. | ||
| 38 | </Text> | ||
| 39 | <SignInAction onPress={props.onSignIn} /> | ||
| 40 | </View> | ||
| 41 | ); | ||
| 42 | } | ||
| 43 | |||
| 44 | return ( | ||
| 45 | <View style={styles.content}> | ||
| 46 | <Text style={[styles.body, { color: colors.secondaryForeground }]}> | ||
| 47 | Enter your email and we will let you know when access is ready. | ||
| 48 | </Text> | ||
| 49 | |||
| 50 | <View style={styles.field}> | ||
| 51 | <Text style={[styles.label, { color: colors.secondaryForeground }]}>Email address</Text> | ||
| 52 | <TextInput | ||
| 53 | accessibilityLabel="Email address" | ||
| 54 | autoCapitalize="none" | ||
| 55 | autoComplete="email" | ||
| 56 | autoCorrect={false} | ||
| 57 | keyboardType="email-address" | ||
| 58 | onChangeText={(value) => { | ||
| 59 | setEmailAddress(value); | ||
| 60 | setRequestError(null); | ||
| 61 | }} | ||
| 62 | onSubmitEditing={() => void joinWaitlist()} | ||
| 63 | placeholder="Enter your email address" | ||
| 64 | placeholderTextColor={colors.placeholder} | ||
| 65 | returnKeyType="join" | ||
| 66 | style={[ | ||
| 67 | styles.input, | ||
| 68 | { | ||
| 69 | backgroundColor: colors.input, | ||
| 70 | borderColor: | ||
| 71 | fieldError || requestError ? colors.dangerForeground : colors.inputBorder, | ||
| 72 | color: colors.foreground, | ||
| 73 | }, | ||
| 74 | ]} | ||
| 75 | textContentType="emailAddress" | ||
| 76 | value={emailAddress} | ||
| 77 | /> | ||
| 78 | {fieldError || requestError ? ( | ||
| 79 | <Text | ||
| 80 | accessibilityLiveRegion="polite" | ||
| 81 | style={[styles.error, { color: colors.dangerForeground }]} | ||
| 82 | selectable | ||
| 83 | > | ||
| 84 | {fieldError ?? requestError} | ||
| 85 | </Text> | ||
| 86 | ) : null} | ||
| 87 | </View> | ||
| 88 | |||
| 89 | <Pressable | ||
| 90 | accessibilityRole="button" | ||
| 91 | accessibilityState={{ | ||
| 92 | busy: isSubmitting, | ||
| 93 | disabled: isSubmitting || emailAddress.trim().length === 0, | ||
| 94 | }} | ||
| 95 | disabled={isSubmitting || emailAddress.trim().length === 0} | ||
| 96 | onPress={() => void joinWaitlist()} | ||
| 97 | style={[ | ||
| 98 | styles.primaryButton, | ||
| 99 | { | ||
| 100 | backgroundColor: colors.primary, | ||
| 101 | opacity: isSubmitting || emailAddress.trim().length === 0 ? 0.45 : 1, | ||
| 102 | }, | ||
| 103 | ]} | ||
| 104 | > | ||
| 105 | {isSubmitting ? <ActivityIndicator color={colors.primaryForeground} size="small" /> : null} | ||
| 106 | <Text style={[styles.buttonText, { color: colors.primaryForeground }]}> | ||
| 107 | {isSubmitting ? "Joining" : "Join the waitlist"} | ||
| 108 | </Text> | ||
| 109 | </Pressable> | ||
| 110 | |||
| 111 | <SignInAction onPress={props.onSignIn} /> | ||
| 112 | </View> | ||
| 113 | ); | ||
| 114 | } | ||
| 115 | |||
| 116 | function SignInAction(props: { readonly onPress: () => void }) { | ||
| 117 | const colors = useCloudWaitlistColors(); | ||
| 118 | return ( | ||
| 119 | <View style={styles.signInRow}> | ||
| 120 | <Text style={[styles.body, { color: colors.secondaryForeground }]}>Already have access?</Text> | ||
| 121 | <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}> | ||
| 122 | <Text style={[styles.signInText, { color: colors.foreground }]}>Sign in</Text> | ||
| 123 | </Pressable> | ||
| 124 | </View> | ||
| 125 | ); | ||
| 126 | } | ||
| 127 | |||
| 128 | function useCloudWaitlistColors() { | ||
| 129 | return { | ||
| 130 | dangerForeground: String(useThemeColor("--color-danger-foreground")), | ||
| 131 | foreground: String(useThemeColor("--color-foreground")), | ||
| 132 | input: String(useThemeColor("--color-input")), | ||
| 133 | inputBorder: String(useThemeColor("--color-input-border")), | ||
| 134 | placeholder: String(useThemeColor("--color-placeholder")), | ||
| 135 | primary: String(useThemeColor("--color-primary")), | ||
| 136 | primaryForeground: String(useThemeColor("--color-primary-foreground")), | ||
| 137 | secondaryForeground: String(useThemeColor("--color-foreground-secondary")), | ||
| 138 | }; | ||
| 139 | } | ||
| 140 | |||
| 141 | const styles = StyleSheet.create({ | ||
| 142 | body: { | ||
| 143 | fontFamily: "DMSans_400Regular", | ||
| 144 | fontSize: 15, | ||
| 145 | lineHeight: 21, | ||
| 146 | }, | ||
| 147 | buttonText: { | ||
| 148 | fontFamily: "DMSans_700Bold", | ||
| 149 | fontSize: 16, | ||
| 150 | }, | ||
| 151 | content: { | ||
| 152 | gap: 18, | ||
| 153 | }, | ||
| 154 | confirmationBody: { | ||
| 155 | textAlign: "center", | ||
| 156 | }, | ||
| 157 | error: { | ||
| 158 | fontFamily: "DMSans_400Regular", | ||
| 159 | fontSize: 13, | ||
| 160 | lineHeight: 18, | ||
| 161 | }, | ||
| 162 | field: { | ||
| 163 | gap: 8, | ||
| 164 | }, | ||
| 165 | input: { | ||
| 166 | borderCurve: "continuous", | ||
| 167 | borderRadius: 16, | ||
| 168 | borderWidth: 1, | ||
| 169 | fontFamily: "DMSans_400Regular", | ||
| 170 | fontSize: 17, | ||
| 171 | minHeight: 54, | ||
| 172 | paddingHorizontal: 16, | ||
| 173 | paddingVertical: 14, | ||
| 174 | }, | ||
| 175 | label: { | ||
| 176 | fontFamily: "DMSans_700Bold", | ||
| 177 | fontSize: 13, | ||
| 178 | lineHeight: 18, | ||
| 179 | }, | ||
| 180 | primaryButton: { | ||
| 181 | alignItems: "center", | ||
| 182 | borderRadius: 999, | ||
| 183 | flexDirection: "row", | ||
| 184 | gap: 8, | ||
| 185 | justifyContent: "center", | ||
| 186 | minHeight: 54, | ||
| 187 | paddingHorizontal: 20, | ||
| 188 | paddingVertical: 12, | ||
| 189 | }, | ||
| 190 | signInRow: { | ||
| 191 | alignItems: "center", | ||
| 192 | flexDirection: "row", | ||
| 193 | gap: 4, | ||
| 194 | justifyContent: "center", | ||
| 195 | paddingTop: 4, | ||
| 196 | }, | ||
| 197 | signInText: { | ||
| 198 | fontFamily: "DMSans_700Bold", | ||
| 199 | fontSize: 15, | ||
| 200 | lineHeight: 21, | ||
| 201 | }, | ||
| 202 | title: { | ||
| 203 | fontFamily: "DMSans_700Bold", | ||
| 204 | fontSize: 20, | ||
| 205 | lineHeight: 26, | ||
| 206 | textAlign: "center", | ||
| 207 | }, | ||
| 208 | }); | ||
apps/mobile/src/features/cloud/dpop.test.ts created+166| ... | @@ -0,0 +1,166 @@ | ||
| 1 | /// <reference types="node" /> | ||
| 2 | |||
| 3 | import * as NodeCrypto from "node:crypto"; | ||
| 4 | |||
| 5 | import { vi } from "vitest"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as Crypto from "effect/Crypto"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import { verifyDpopProof } from "@t3tools/shared/dpop"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | createDpopProof, | ||
| 13 | generateDpopProofKeyPair, | ||
| 14 | loadOrCreateDpopProofKeyPair, | ||
| 15 | mobileCryptoLayer, | ||
| 16 | } from "./dpop"; | ||
| 17 | |||
| 18 | vi.mock("expo-crypto", () => ({ | ||
| 19 | CryptoDigestAlgorithm: { | ||
| 20 | SHA1: "SHA-1", | ||
| 21 | SHA256: "SHA-256", | ||
| 22 | SHA384: "SHA-384", | ||
| 23 | SHA512: "SHA-512", | ||
| 24 | }, | ||
| 25 | getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)), | ||
| 26 | getRandomBytesAsync: (byteCount: number) => | ||
| 27 | Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))), | ||
| 28 | digest: (algorithm: string, data: unknown) => { | ||
| 29 | if (!(data instanceof Uint8Array)) { | ||
| 30 | return Promise.reject(new TypeError("expo-crypto digest data must be a typed array.")); | ||
| 31 | } | ||
| 32 | return Promise.resolve( | ||
| 33 | new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer, | ||
| 34 | ); | ||
| 35 | }, | ||
| 36 | })); | ||
| 37 | |||
| 38 | const secureStore = new Map<string, string>(); | ||
| 39 | vi.mock("expo-secure-store", () => ({ | ||
| 40 | getItemAsync: (key: string) => Promise.resolve(secureStore.get(key) ?? null), | ||
| 41 | setItemAsync: (key: string, value: string) => { | ||
| 42 | secureStore.set(key, value); | ||
| 43 | return Promise.resolve(); | ||
| 44 | }, | ||
| 45 | })); | ||
| 46 | |||
| 47 | function proofIat(proof: string): number { | ||
| 48 | const payload = proof.split(".")[1]; | ||
| 49 | if (!payload) { | ||
| 50 | throw new Error("Missing DPoP payload."); | ||
| 51 | } | ||
| 52 | const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { | ||
| 53 | readonly iat: number; | ||
| 54 | }; | ||
| 55 | return decoded.iat; | ||
| 56 | } | ||
| 57 | |||
| 58 | function proofHtu(proof: string): string { | ||
| 59 | const payload = proof.split(".")[1]; | ||
| 60 | if (!payload) { | ||
| 61 | throw new Error("Missing DPoP payload."); | ||
| 62 | } | ||
| 63 | const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { | ||
| 64 | readonly htu: string; | ||
| 65 | }; | ||
| 66 | return decoded.htu; | ||
| 67 | } | ||
| 68 | |||
| 69 | describe("mobile DPoP", () => { | ||
| 70 | it.effect("passes typed-array digest input through the Expo Crypto adapter", () => | ||
| 71 | Effect.gen(function* () { | ||
| 72 | const crypto = yield* Crypto.Crypto; | ||
| 73 | const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode("typed-array")); | ||
| 74 | |||
| 75 | expect(Buffer.from(digest).toString("hex")).toBe( | ||
| 76 | NodeCrypto.createHash("sha256").update("typed-array").digest("hex"), | ||
| 77 | ); | ||
| 78 | }).pipe(Effect.provide(mobileCryptoLayer)), | ||
| 79 | ); | ||
| 80 | |||
| 81 | it.effect("persists and reuses the installation proof key", () => | ||
| 82 | Effect.gen(function* () { | ||
| 83 | secureStore.clear(); | ||
| 84 | const first = yield* loadOrCreateDpopProofKeyPair(); | ||
| 85 | const second = yield* loadOrCreateDpopProofKeyPair(); | ||
| 86 | |||
| 87 | expect(second.thumbprint).toBe(first.thumbprint); | ||
| 88 | expect(second.privateJwk).toEqual(first.privateJwk); | ||
| 89 | }).pipe(Effect.provide(mobileCryptoLayer)), | ||
| 90 | ); | ||
| 91 | |||
| 92 | it.effect("rejects malformed persisted proof keys", () => | ||
| 93 | Effect.gen(function* () { | ||
| 94 | secureStore.set("t3code.cloud.dpop-proof-key", `{"kty":"EC","crv":"P-256","d":42}`); | ||
| 95 | |||
| 96 | const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); | ||
| 97 | |||
| 98 | expect(error.message).toBe("Stored DPoP proof key is invalid."); | ||
| 99 | }).pipe(Effect.provide(mobileCryptoLayer)), | ||
| 100 | ); | ||
| 101 | |||
| 102 | it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () => | ||
| 103 | Effect.gen(function* () { | ||
| 104 | const proofKey = yield* generateDpopProofKeyPair(); | ||
| 105 | const connect = yield* createDpopProof({ | ||
| 106 | method: "POST", | ||
| 107 | url: "https://relay.example.test/v1/environments/env-1/connect", | ||
| 108 | accessToken: "clerk-token", | ||
| 109 | proofKey, | ||
| 110 | }); | ||
| 111 | const bootstrap = yield* createDpopProof({ | ||
| 112 | method: "POST", | ||
| 113 | url: "https://desktop.example.test/oauth/token", | ||
| 114 | proofKey, | ||
| 115 | }); | ||
| 116 | |||
| 117 | expect(connect.thumbprint).toBe(proofKey.thumbprint); | ||
| 118 | expect(bootstrap.thumbprint).toBe(proofKey.thumbprint); | ||
| 119 | expect( | ||
| 120 | verifyDpopProof({ | ||
| 121 | proof: connect.proof, | ||
| 122 | method: "POST", | ||
| 123 | url: "https://relay.example.test/v1/environments/env-1/connect", | ||
| 124 | expectedThumbprint: proofKey.thumbprint, | ||
| 125 | expectedAccessToken: "clerk-token", | ||
| 126 | nowEpochSeconds: proofIat(connect.proof), | ||
| 127 | }), | ||
| 128 | ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint }); | ||
| 129 | expect( | ||
| 130 | verifyDpopProof({ | ||
| 131 | proof: bootstrap.proof, | ||
| 132 | method: "POST", | ||
| 133 | url: "https://desktop.example.test/oauth/token", | ||
| 134 | expectedThumbprint: proofKey.thumbprint, | ||
| 135 | nowEpochSeconds: proofIat(bootstrap.proof), | ||
| 136 | }), | ||
| 137 | ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint }); | ||
| 138 | }).pipe(Effect.provide(mobileCryptoLayer)), | ||
| 139 | ); | ||
| 140 | |||
| 141 | it.effect("signs DPoP proofs with RFC 9449 htu normalization", () => | ||
| 142 | Effect.gen(function* () { | ||
| 143 | const proofKey = yield* generateDpopProofKeyPair(); | ||
| 144 | const proof = yield* createDpopProof({ | ||
| 145 | method: "POST", | ||
| 146 | url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored", | ||
| 147 | accessToken: "clerk-token", | ||
| 148 | proofKey, | ||
| 149 | }); | ||
| 150 | |||
| 151 | expect(proofHtu(proof.proof)).toBe( | ||
| 152 | "https://relay.example.test/v1/environments/env-1/connect", | ||
| 153 | ); | ||
| 154 | expect( | ||
| 155 | verifyDpopProof({ | ||
| 156 | proof: proof.proof, | ||
| 157 | method: "POST", | ||
| 158 | url: "https://relay.example.test/v1/environments/env-1/connect?debug=1#ignored", | ||
| 159 | expectedThumbprint: proofKey.thumbprint, | ||
| 160 | expectedAccessToken: "clerk-token", | ||
| 161 | nowEpochSeconds: proofIat(proof.proof), | ||
| 162 | }), | ||
| 163 | ).toMatchObject({ ok: true }); | ||
| 164 | }).pipe(Effect.provide(mobileCryptoLayer)), | ||
| 165 | ); | ||
| 166 | }); | ||
apps/mobile/src/features/cloud/dpop.ts created+270| ... | @@ -0,0 +1,270 @@ | ||
| 1 | import * as Clock from "effect/Clock"; | ||
| 2 | import * as Crypto from "effect/Crypto"; | ||
| 3 | import * as Data from "effect/Data"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Encoding from "effect/Encoding"; | ||
| 6 | import * as Result from "effect/Result"; | ||
| 7 | import * as Schema from "effect/Schema"; | ||
| 8 | import * as ExpoCrypto from "expo-crypto"; | ||
| 9 | import * as SecureStore from "expo-secure-store"; | ||
| 10 | import { p256 } from "@noble/curves/nist"; | ||
| 11 | import { | ||
| 12 | computeDpopAccessTokenHash, | ||
| 13 | computeDpopJwkThumbprint, | ||
| 14 | DpopPublicJwk, | ||
| 15 | normalizeDpopHtu, | ||
| 16 | } from "@t3tools/shared/dpop"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | |||
| 19 | export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{ | ||
| 20 | readonly message: string; | ||
| 21 | readonly cause?: unknown; | ||
| 22 | }> {} | ||
| 23 | |||
| 24 | function cloudDpopError(message: string) { | ||
| 25 | return (cause: unknown) => new CloudDpopError({ message, cause }); | ||
| 26 | } | ||
| 27 | |||
| 28 | const DpopPrivateJwkSchema = Schema.Struct({ | ||
| 29 | ...DpopPublicJwk.fields, | ||
| 30 | d: Schema.String, | ||
| 31 | }); | ||
| 32 | |||
| 33 | const DpopPrivateJwkJson = Schema.fromJsonString(DpopPrivateJwkSchema); | ||
| 34 | const decodeDpopPrivateJwkJson = Schema.decodeUnknownEffect(DpopPrivateJwkJson); | ||
| 35 | const encodeDpopPrivateJwkJson = Schema.encodeEffect(DpopPrivateJwkJson); | ||
| 36 | |||
| 37 | const DpopJwtHeaderJson = Schema.fromJsonString( | ||
| 38 | Schema.Struct({ | ||
| 39 | typ: Schema.Literal("dpop+jwt"), | ||
| 40 | alg: Schema.Literal("ES256"), | ||
| 41 | jwk: DpopPublicJwk, | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | |||
| 45 | const DpopJwtPayloadJson = Schema.fromJsonString( | ||
| 46 | Schema.Struct({ | ||
| 47 | htm: Schema.String, | ||
| 48 | htu: Schema.String, | ||
| 49 | jti: Schema.String, | ||
| 50 | iat: Schema.Int, | ||
| 51 | ath: Schema.optionalKey(Schema.String), | ||
| 52 | }), | ||
| 53 | ); | ||
| 54 | |||
| 55 | const encodeDpopJwtHeaderJson = Schema.encodeEffect(DpopJwtHeaderJson); | ||
| 56 | const encodeDpopJwtPayloadJson = Schema.encodeEffect(DpopJwtPayloadJson); | ||
| 57 | |||
| 58 | function toExpoDigestAlgorithm( | ||
| 59 | algorithm: Crypto.DigestAlgorithm, | ||
| 60 | ): ExpoCrypto.CryptoDigestAlgorithm { | ||
| 61 | switch (algorithm) { | ||
| 62 | case "SHA-1": | ||
| 63 | return ExpoCrypto.CryptoDigestAlgorithm.SHA1; | ||
| 64 | case "SHA-256": | ||
| 65 | return ExpoCrypto.CryptoDigestAlgorithm.SHA256; | ||
| 66 | case "SHA-384": | ||
| 67 | return ExpoCrypto.CryptoDigestAlgorithm.SHA384; | ||
| 68 | case "SHA-512": | ||
| 69 | return ExpoCrypto.CryptoDigestAlgorithm.SHA512; | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | export const mobileCryptoLayer = Layer.succeed( | ||
| 74 | Crypto.Crypto, | ||
| 75 | Crypto.make({ | ||
| 76 | randomBytes: ExpoCrypto.getRandomBytes, | ||
| 77 | digest: (algorithm, data) => | ||
| 78 | Effect.promise(async () => { | ||
| 79 | const input = new Uint8Array(data.length); | ||
| 80 | input.set(data); | ||
| 81 | return new Uint8Array(await ExpoCrypto.digest(toExpoDigestAlgorithm(algorithm), input)); | ||
| 82 | }), | ||
| 83 | }), | ||
| 84 | ); | ||
| 85 | |||
| 86 | type DpopPrivateJwk = typeof DpopPrivateJwkSchema.Type; | ||
| 87 | |||
| 88 | export interface DpopProofKeyPair { | ||
| 89 | readonly privateJwk: DpopPrivateJwk; | ||
| 90 | readonly publicJwk: DpopPublicJwk; | ||
| 91 | readonly thumbprint: string; | ||
| 92 | } | ||
| 93 | |||
| 94 | const DPOP_PROOF_KEY_STORAGE_KEY = "t3code.cloud.dpop-proof-key"; | ||
| 95 | |||
| 96 | function base64UrlToBytes(value: string): Uint8Array { | ||
| 97 | return Result.getOrThrow(Encoding.decodeBase64Url(value)); | ||
| 98 | } | ||
| 99 | |||
| 100 | function sha256Digest( | ||
| 101 | data: Uint8Array, | ||
| 102 | message: string, | ||
| 103 | ): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> { | ||
| 104 | return Crypto.Crypto.pipe( | ||
| 105 | Effect.flatMap((crypto) => crypto.digest("SHA-256", data)), | ||
| 106 | Effect.mapError(cloudDpopError(message)), | ||
| 107 | ); | ||
| 108 | } | ||
| 109 | |||
| 110 | function secureRandomBytes( | ||
| 111 | byteCount: number, | ||
| 112 | message: string, | ||
| 113 | ): Effect.Effect<Uint8Array, CloudDpopError, Crypto.Crypto> { | ||
| 114 | return Crypto.Crypto.pipe( | ||
| 115 | Effect.flatMap((crypto) => crypto.randomBytes(byteCount)), | ||
| 116 | Effect.mapError(cloudDpopError(message)), | ||
| 117 | ); | ||
| 118 | } | ||
| 119 | |||
| 120 | function publicJwkFromUncompressedPublicKey(publicKey: Uint8Array): DpopPublicJwk { | ||
| 121 | if (publicKey.length !== 65 || publicKey[0] !== 0x04) { | ||
| 122 | throw new Error("Generated DPoP public key is not an uncompressed P-256 point."); | ||
| 123 | } | ||
| 124 | return { | ||
| 125 | kty: "EC", | ||
| 126 | crv: "P-256", | ||
| 127 | x: Encoding.encodeBase64Url(publicKey.slice(1, 33)), | ||
| 128 | y: Encoding.encodeBase64Url(publicKey.slice(33, 65)), | ||
| 129 | }; | ||
| 130 | } | ||
| 131 | |||
| 132 | function privateJwkFromPrivateKey( | ||
| 133 | privateKey: Uint8Array, | ||
| 134 | publicJwk: DpopPublicJwk, | ||
| 135 | ): DpopPrivateJwk { | ||
| 136 | return { ...publicJwk, d: Encoding.encodeBase64Url(privateKey) }; | ||
| 137 | } | ||
| 138 | |||
| 139 | export function generateDpopProofKeyPair(): Effect.Effect< | ||
| 140 | DpopProofKeyPair, | ||
| 141 | CloudDpopError, | ||
| 142 | Crypto.Crypto | ||
| 143 | > { | ||
| 144 | return Effect.gen(function* () { | ||
| 145 | let privateKey: Uint8Array; | ||
| 146 | do { | ||
| 147 | privateKey = yield* secureRandomBytes( | ||
| 148 | p256.CURVE.nByteLength, | ||
| 149 | "Could not generate DPoP key pair randomness.", | ||
| 150 | ); | ||
| 151 | } while (!p256.utils.isValidPrivateKey(privateKey)); | ||
| 152 | const publicJwk = yield* Effect.try({ | ||
| 153 | try: () => publicJwkFromUncompressedPublicKey(p256.getPublicKey(privateKey, false)), | ||
| 154 | catch: cloudDpopError("Generated DPoP public key is invalid."), | ||
| 155 | }); | ||
| 156 | const thumbprint = computeDpopJwkThumbprint(publicJwk); | ||
| 157 | return { | ||
| 158 | privateJwk: privateJwkFromPrivateKey(privateKey, publicJwk), | ||
| 159 | publicJwk, | ||
| 160 | thumbprint, | ||
| 161 | }; | ||
| 162 | }); | ||
| 163 | } | ||
| 164 | |||
| 165 | export function loadOrCreateDpopProofKeyPair(): Effect.Effect< | ||
| 166 | DpopProofKeyPair, | ||
| 167 | CloudDpopError, | ||
| 168 | Crypto.Crypto | ||
| 169 | > { | ||
| 170 | return Effect.gen(function* () { | ||
| 171 | const stored = yield* Effect.tryPromise({ | ||
| 172 | try: () => SecureStore.getItemAsync(DPOP_PROOF_KEY_STORAGE_KEY), | ||
| 173 | catch: cloudDpopError("Could not read the DPoP proof key."), | ||
| 174 | }); | ||
| 175 | if (stored) { | ||
| 176 | const storedPrivateJwk = yield* decodeDpopPrivateJwkJson(stored).pipe( | ||
| 177 | Effect.mapError(cloudDpopError("Stored DPoP proof key is invalid.")), | ||
| 178 | ); | ||
| 179 | const restored = yield* Effect.try({ | ||
| 180 | try: () => { | ||
| 181 | const privateKey = base64UrlToBytes(storedPrivateJwk.d); | ||
| 182 | const publicJwk = publicJwkFromUncompressedPublicKey( | ||
| 183 | p256.getPublicKey(privateKey, false), | ||
| 184 | ); | ||
| 185 | if (publicJwk.x !== storedPrivateJwk.x || publicJwk.y !== storedPrivateJwk.y) { | ||
| 186 | throw new Error("Stored DPoP key does not match its public key."); | ||
| 187 | } | ||
| 188 | return { privateJwk: storedPrivateJwk, publicJwk }; | ||
| 189 | }, | ||
| 190 | catch: cloudDpopError("Stored DPoP proof key is invalid."), | ||
| 191 | }); | ||
| 192 | return { | ||
| 193 | ...restored, | ||
| 194 | thumbprint: computeDpopJwkThumbprint(restored.publicJwk), | ||
| 195 | }; | ||
| 196 | } | ||
| 197 | const generated = yield* generateDpopProofKeyPair(); | ||
| 198 | const encodedPrivateJwk = yield* encodeDpopPrivateJwkJson(generated.privateJwk).pipe( | ||
| 199 | Effect.mapError(cloudDpopError("Could not encode the DPoP proof key.")), | ||
| 200 | ); | ||
| 201 | yield* Effect.tryPromise({ | ||
| 202 | try: () => SecureStore.setItemAsync(DPOP_PROOF_KEY_STORAGE_KEY, encodedPrivateJwk), | ||
| 203 | catch: cloudDpopError("Could not store the DPoP proof key."), | ||
| 204 | }); | ||
| 205 | return generated; | ||
| 206 | }); | ||
| 207 | } | ||
| 208 | |||
| 209 | function normalizeHtu(url: string): Effect.Effect<string, CloudDpopError> { | ||
| 210 | const normalized = normalizeDpopHtu(url); | ||
| 211 | return normalized | ||
| 212 | ? Effect.succeed(normalized) | ||
| 213 | : Effect.fail(new CloudDpopError({ message: "DPoP URL is invalid." })); | ||
| 214 | } | ||
| 215 | |||
| 216 | export function createDpopProof(input: { | ||
| 217 | readonly method: string; | ||
| 218 | readonly url: string; | ||
| 219 | readonly accessToken?: string; | ||
| 220 | readonly proofKey?: DpopProofKeyPair; | ||
| 221 | }): Effect.Effect< | ||
| 222 | { readonly proof: string; readonly thumbprint: string }, | ||
| 223 | CloudDpopError, | ||
| 224 | Crypto.Crypto | ||
| 225 | > { | ||
| 226 | return Effect.gen(function* () { | ||
| 227 | const keyPair = input.proofKey ?? (yield* generateDpopProofKeyPair()); | ||
| 228 | const privateKey = yield* Effect.try({ | ||
| 229 | try: () => base64UrlToBytes(keyPair.privateJwk.d), | ||
| 230 | catch: cloudDpopError("Could not import DPoP private key."), | ||
| 231 | }); | ||
| 232 | const nowMs = yield* Clock.currentTimeMillis; | ||
| 233 | const jti = yield* Crypto.Crypto.pipe( | ||
| 234 | Effect.flatMap((crypto) => crypto.randomUUIDv4), | ||
| 235 | Effect.mapError(cloudDpopError("Could not generate DPoP proof identifier.")), | ||
| 236 | ); | ||
| 237 | const htu = yield* normalizeHtu(input.url); | ||
| 238 | const header = yield* encodeDpopJwtHeaderJson({ | ||
| 239 | typ: "dpop+jwt", | ||
| 240 | alg: "ES256", | ||
| 241 | jwk: keyPair.publicJwk, | ||
| 242 | }).pipe( | ||
| 243 | Effect.map(Encoding.encodeBase64Url), | ||
| 244 | Effect.mapError(cloudDpopError("Could not encode DPoP proof header.")), | ||
| 245 | ); | ||
| 246 | const ath = input.accessToken ? computeDpopAccessTokenHash(input.accessToken) : null; | ||
| 247 | const payload = yield* encodeDpopJwtPayloadJson({ | ||
| 248 | htm: input.method.toUpperCase(), | ||
| 249 | htu, | ||
| 250 | jti, | ||
| 251 | iat: Math.floor(nowMs / 1_000), | ||
| 252 | ...(ath ? { ath } : {}), | ||
| 253 | }).pipe( | ||
| 254 | Effect.map(Encoding.encodeBase64Url), | ||
| 255 | Effect.mapError(cloudDpopError("Could not encode DPoP proof payload.")), | ||
| 256 | ); | ||
| 257 | const signatureInputHash = yield* sha256Digest( | ||
| 258 | new TextEncoder().encode(`${header}.${payload}`), | ||
| 259 | "Could not hash DPoP signing input.", | ||
| 260 | ); | ||
| 261 | const signature = yield* Effect.try({ | ||
| 262 | try: () => p256.sign(signatureInputHash, privateKey, { prehash: false }).toCompactRawBytes(), | ||
| 263 | catch: cloudDpopError("Could not sign DPoP proof."), | ||
| 264 | }); | ||
| 265 | return { | ||
| 266 | proof: `${header}.${payload}.${Encoding.encodeBase64Url(signature)}`, | ||
| 267 | thumbprint: keyPair.thumbprint, | ||
| 268 | }; | ||
| 269 | }); | ||
| 270 | } | ||
apps/mobile/src/features/cloud/linkEnvironment.test.ts created+1107| ... | @@ -0,0 +1,1107 @@ | ||
| 1 | import { beforeEach, vi } from "vitest"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 6 | import { RelayMobileClientId } from "@t3tools/contracts/relay"; | ||
| 7 | import { | ||
| 8 | managedRelayClientLayer, | ||
| 9 | ManagedRelayClient, | ||
| 10 | ManagedRelayDpopSigner, | ||
| 11 | remoteHttpClientLayer, | ||
| 12 | } from "@t3tools/client-runtime"; | ||
| 13 | import { HttpClient } from "effect/unstable/http"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | cloudEnvironmentsPendingStatus, | ||
| 17 | linkEnvironmentToCloud, | ||
| 18 | connectCloudEnvironment, | ||
| 19 | listCloudEnvironments, | ||
| 20 | listCloudEnvironmentsWithStatus, | ||
| 21 | normalizeRelayBaseUrl, | ||
| 22 | refreshCloudEnvironmentConnection, | ||
| 23 | } from "./linkEnvironment"; | ||
| 24 | |||
| 25 | vi.mock("expo-constants", () => ({ | ||
| 26 | default: { | ||
| 27 | expoConfig: { | ||
| 28 | extra: { | ||
| 29 | relay: { | ||
| 30 | url: "https://relay.example.test", | ||
| 31 | }, | ||
| 32 | }, | ||
| 33 | }, | ||
| 34 | }, | ||
| 35 | })); | ||
| 36 | |||
| 37 | vi.mock("react-native", () => ({ | ||
| 38 | Platform: { | ||
| 39 | OS: "ios", | ||
| 40 | }, | ||
| 41 | })); | ||
| 42 | |||
| 43 | vi.mock("../../lib/storage", () => ({ | ||
| 44 | loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), | ||
| 45 | loadPreferences: vi.fn(() => Promise.resolve({})), | ||
| 46 | })); | ||
| 47 | |||
| 48 | const savedConnection = { | ||
| 49 | environmentId: EnvironmentId.make("env-1"), | ||
| 50 | environmentLabel: "Desktop", | ||
| 51 | pairingUrl: "https://desktop.example.test/", | ||
| 52 | displayUrl: "https://desktop.example.test/", | ||
| 53 | httpBaseUrl: "https://desktop.example.test/", | ||
| 54 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 55 | bearerToken: "local-bearer", | ||
| 56 | }; | ||
| 57 | |||
| 58 | const createProofMock = vi.fn( | ||
| 59 | (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => | ||
| 60 | Effect.succeed(`dpop:${input.method}:${input.url}`), | ||
| 61 | ); | ||
| 62 | const testDpopSignerLayer = Layer.succeed( | ||
| 63 | ManagedRelayDpopSigner, | ||
| 64 | ManagedRelayDpopSigner.of({ | ||
| 65 | thumbprint: Effect.succeed("client-proof-key-thumbprint"), | ||
| 66 | createProof: (input) => createProofMock(input), | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | |||
| 70 | function cloudClientLayer() { | ||
| 71 | const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); | ||
| 72 | return Layer.mergeAll( | ||
| 73 | httpClientLayer, | ||
| 74 | managedRelayClientLayer({ | ||
| 75 | relayUrl: "https://relay.example.test", | ||
| 76 | clientId: RelayMobileClientId, | ||
| 77 | }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)), | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | |||
| 81 | const withCloudServices = <A, E>( | ||
| 82 | effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>, | ||
| 83 | ) => effect.pipe(Effect.provide(cloudClientLayer())); | ||
| 84 | |||
| 85 | function validLinkProof() { | ||
| 86 | return "signed-environment-link-jwt"; | ||
| 87 | } | ||
| 88 | |||
| 89 | function validLinkResponse(environmentId = "env-1") { | ||
| 90 | return { | ||
| 91 | ok: true, | ||
| 92 | environmentId, | ||
| 93 | endpoint: { | ||
| 94 | httpBaseUrl: "https://managed.example.test/", | ||
| 95 | wsBaseUrl: "wss://managed.example.test/ws", | ||
| 96 | providerKind: "cloudflare_tunnel", | ||
| 97 | }, | ||
| 98 | endpointRuntime: { | ||
| 99 | providerKind: "cloudflare_tunnel", | ||
| 100 | connectorToken: "connector-token", | ||
| 101 | }, | ||
| 102 | relayIssuer: "https://relay.example.test", | ||
| 103 | cloudUserId: "user_123", | ||
| 104 | environmentCredential: "environment-credential", | ||
| 105 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 106 | }; | ||
| 107 | } | ||
| 108 | |||
| 109 | function validLinkChallengeResponse() { | ||
| 110 | return { | ||
| 111 | challenge: "link-challenge", | ||
| 112 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | |||
| 116 | function requestBodyText(body: BodyInit | null | undefined): string { | ||
| 117 | return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); | ||
| 118 | } | ||
| 119 | |||
| 120 | function validDpopAccessTokenResponse(scope = "environment:status environment:connect") { | ||
| 121 | return { | ||
| 122 | access_token: "relay-dpop-token", | ||
| 123 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 124 | token_type: "DPoP", | ||
| 125 | expires_in: 300, | ||
| 126 | scope, | ||
| 127 | }; | ||
| 128 | } | ||
| 129 | |||
| 130 | function listedEnvironment(environmentId: string) { | ||
| 131 | return { | ||
| 132 | environmentId: EnvironmentId.make(environmentId), | ||
| 133 | label: "Desktop", | ||
| 134 | endpoint: { | ||
| 135 | httpBaseUrl: `https://${environmentId}.example.test/`, | ||
| 136 | wsBaseUrl: `wss://${environmentId}.example.test/ws`, | ||
| 137 | providerKind: "cloudflare_tunnel" as const, | ||
| 138 | }, | ||
| 139 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 140 | }; | ||
| 141 | } | ||
| 142 | |||
| 143 | describe("mobile cloud link environment client", () => { | ||
| 144 | beforeEach(() => { | ||
| 145 | vi.restoreAllMocks(); | ||
| 146 | createProofMock.mockClear(); | ||
| 147 | }); | ||
| 148 | |||
| 149 | it("normalizes configured relay base URLs before building DPoP-bound requests", () => { | ||
| 150 | expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( | ||
| 151 | "https://relay.example.test", | ||
| 152 | ); | ||
| 153 | expect(normalizeRelayBaseUrl(" ")).toBeNull(); | ||
| 154 | }); | ||
| 155 | |||
| 156 | it("makes linked environments visible while their status is still loading", () => { | ||
| 157 | expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([ | ||
| 158 | { | ||
| 159 | environment: { environmentId: "env-1", label: "Desktop" }, | ||
| 160 | status: null, | ||
| 161 | statusError: "Checking status...", | ||
| 162 | }, | ||
| 163 | ]); | ||
| 164 | }); | ||
| 165 | |||
| 166 | it.effect("decodes relay environment list responses before returning records", () => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | vi.stubGlobal( | ||
| 169 | "fetch", | ||
| 170 | vi.fn(() => | ||
| 171 | Promise.resolve( | ||
| 172 | Response.json({ | ||
| 173 | environments: [ | ||
| 174 | { | ||
| 175 | environmentId: "env-1", | ||
| 176 | label: "Desktop", | ||
| 177 | endpoint: { | ||
| 178 | httpBaseUrl: "https://desktop.example.test/", | ||
| 179 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 180 | providerKind: "cloudflare_tunnel", | ||
| 181 | }, | ||
| 182 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 183 | }, | ||
| 184 | ], | ||
| 185 | }), | ||
| 186 | ), | ||
| 187 | ), | ||
| 188 | ); | ||
| 189 | |||
| 190 | const records = yield* withCloudServices( | ||
| 191 | listCloudEnvironments({ clerkToken: "clerk-token" }), | ||
| 192 | ); | ||
| 193 | expect(records).toEqual([ | ||
| 194 | { | ||
| 195 | environmentId: "env-1", | ||
| 196 | label: "Desktop", | ||
| 197 | endpoint: { | ||
| 198 | httpBaseUrl: "https://desktop.example.test/", | ||
| 199 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 200 | providerKind: "cloudflare_tunnel", | ||
| 201 | }, | ||
| 202 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 203 | }, | ||
| 204 | ]); | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | |||
| 208 | it.effect("rejects malformed relay environment list responses", () => | ||
| 209 | Effect.gen(function* () { | ||
| 210 | vi.stubGlobal( | ||
| 211 | "fetch", | ||
| 212 | vi.fn(() => | ||
| 213 | Promise.resolve( | ||
| 214 | Response.json({ | ||
| 215 | environments: [ | ||
| 216 | { | ||
| 217 | environmentId: "env-1", | ||
| 218 | label: "Desktop", | ||
| 219 | endpoint: { | ||
| 220 | httpBaseUrl: "", | ||
| 221 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 222 | providerKind: "cloudflare_tunnel", | ||
| 223 | }, | ||
| 224 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 225 | }, | ||
| 226 | ], | ||
| 227 | }), | ||
| 228 | ), | ||
| 229 | ), | ||
| 230 | ); | ||
| 231 | |||
| 232 | const error = yield* withCloudServices( | ||
| 233 | listCloudEnvironments({ clerkToken: "clerk-token" }), | ||
| 234 | ).pipe(Effect.flip); | ||
| 235 | expect(error).toMatchObject({ | ||
| 236 | _tag: "CloudEnvironmentLinkError", | ||
| 237 | message: "https://relay.example.test/v1/environments failed", | ||
| 238 | }); | ||
| 239 | }), | ||
| 240 | ); | ||
| 241 | |||
| 242 | it.effect("loads signed status for each advertised cloud environment", () => | ||
| 243 | Effect.gen(function* () { | ||
| 244 | const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => { | ||
| 245 | if (String(url) === "https://relay.example.test/v1/environments") { | ||
| 246 | return Promise.resolve( | ||
| 247 | Response.json({ | ||
| 248 | environments: [ | ||
| 249 | { | ||
| 250 | environmentId: "env-1", | ||
| 251 | label: "Desktop", | ||
| 252 | endpoint: { | ||
| 253 | httpBaseUrl: "https://desktop.example.test/", | ||
| 254 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 255 | providerKind: "cloudflare_tunnel", | ||
| 256 | }, | ||
| 257 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 258 | }, | ||
| 259 | ], | ||
| 260 | }), | ||
| 261 | ); | ||
| 262 | } | ||
| 263 | if (String(url) === "https://relay.example.test/v1/client/dpop-token") { | ||
| 264 | return Promise.resolve(Response.json(validDpopAccessTokenResponse())); | ||
| 265 | } | ||
| 266 | expect(String(url)).toBe("https://relay.example.test/v1/environments/env-1/status"); | ||
| 267 | return Promise.resolve( | ||
| 268 | Response.json({ | ||
| 269 | environmentId: "env-1", | ||
| 270 | endpoint: { | ||
| 271 | httpBaseUrl: "https://desktop.example.test/", | ||
| 272 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 273 | providerKind: "cloudflare_tunnel", | ||
| 274 | }, | ||
| 275 | status: "online", | ||
| 276 | checkedAt: "2026-05-25T00:01:00.000Z", | ||
| 277 | descriptor: { | ||
| 278 | environmentId: "env-1", | ||
| 279 | label: "Desktop", | ||
| 280 | platform: { os: "darwin", arch: "arm64" }, | ||
| 281 | serverVersion: "0.0.0-test", | ||
| 282 | capabilities: { repositoryIdentity: true }, | ||
| 283 | }, | ||
| 284 | }), | ||
| 285 | ); | ||
| 286 | }); | ||
| 287 | vi.stubGlobal("fetch", fetchMock); | ||
| 288 | |||
| 289 | const records = yield* withCloudServices( | ||
| 290 | listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }), | ||
| 291 | ); | ||
| 292 | expect(records).toMatchObject([ | ||
| 293 | { | ||
| 294 | environment: { | ||
| 295 | environmentId: "env-1", | ||
| 296 | label: "Desktop", | ||
| 297 | }, | ||
| 298 | status: { | ||
| 299 | environmentId: "env-1", | ||
| 300 | status: "online", | ||
| 301 | checkedAt: "2026-05-25T00:01:00.000Z", | ||
| 302 | }, | ||
| 303 | statusError: null, | ||
| 304 | }, | ||
| 305 | ]); | ||
| 306 | expect(String(fetchMock.mock.calls[2]?.[0])).toBe( | ||
| 307 | "https://relay.example.test/v1/environments/env-1/status", | ||
| 308 | ); | ||
| 309 | expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST"); | ||
| 310 | const statusHeaders = new Headers(fetchMock.mock.calls[2]?.[1]?.headers); | ||
| 311 | expect(statusHeaders.get("authorization")).toBe("DPoP relay-dpop-token"); | ||
| 312 | expect(statusHeaders.get("dpop")).toBe( | ||
| 313 | "dpop:POST:https://relay.example.test/v1/environments/env-1/status", | ||
| 314 | ); | ||
| 315 | expect(createProofMock).toHaveBeenCalledWith({ | ||
| 316 | method: "POST", | ||
| 317 | url: "https://relay.example.test/v1/environments/env-1/status", | ||
| 318 | accessToken: "relay-dpop-token", | ||
| 319 | }); | ||
| 320 | }), | ||
| 321 | ); | ||
| 322 | |||
| 323 | it.effect("reuses one valid DPoP access token while probing multiple environment statuses", () => | ||
| 324 | Effect.gen(function* () { | ||
| 325 | const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => { | ||
| 326 | if (String(url).endsWith("/v1/environments")) { | ||
| 327 | return Promise.resolve( | ||
| 328 | Response.json({ | ||
| 329 | environments: [listedEnvironment("env-1"), listedEnvironment("env-2")], | ||
| 330 | }), | ||
| 331 | ); | ||
| 332 | } | ||
| 333 | if (String(url).endsWith("/v1/client/dpop-token")) { | ||
| 334 | return Promise.resolve(Response.json(validDpopAccessTokenResponse())); | ||
| 335 | } | ||
| 336 | const environmentId = String(url).includes("/env-1/") ? "env-1" : "env-2"; | ||
| 337 | return Promise.resolve( | ||
| 338 | Response.json({ | ||
| 339 | environmentId, | ||
| 340 | endpoint: listedEnvironment(environmentId).endpoint, | ||
| 341 | status: "online", | ||
| 342 | checkedAt: "2026-05-25T00:01:00.000Z", | ||
| 343 | descriptor: { | ||
| 344 | environmentId, | ||
| 345 | label: "Desktop", | ||
| 346 | platform: { os: "darwin", arch: "arm64" }, | ||
| 347 | serverVersion: "0.0.0-test", | ||
| 348 | capabilities: { repositoryIdentity: true }, | ||
| 349 | }, | ||
| 350 | }), | ||
| 351 | ); | ||
| 352 | }); | ||
| 353 | vi.stubGlobal("fetch", fetchMock); | ||
| 354 | |||
| 355 | yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" })); | ||
| 356 | |||
| 357 | expect( | ||
| 358 | fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), | ||
| 359 | ).toHaveLength(1); | ||
| 360 | }), | ||
| 361 | ); | ||
| 362 | |||
| 363 | it.effect("reuses the status-and-connect token when connecting from the cloud list", () => | ||
| 364 | Effect.gen(function* () { | ||
| 365 | const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => { | ||
| 366 | if (String(url).endsWith("/v1/environments")) { | ||
| 367 | return Promise.resolve( | ||
| 368 | Response.json({ | ||
| 369 | environments: [listedEnvironment("env-1")], | ||
| 370 | }), | ||
| 371 | ); | ||
| 372 | } | ||
| 373 | if (String(url).endsWith("/v1/client/dpop-token")) { | ||
| 374 | return Promise.resolve(Response.json(validDpopAccessTokenResponse())); | ||
| 375 | } | ||
| 376 | if (String(url).endsWith("/status")) { | ||
| 377 | return Promise.resolve( | ||
| 378 | Response.json({ | ||
| 379 | environmentId: "env-1", | ||
| 380 | endpoint: listedEnvironment("env-1").endpoint, | ||
| 381 | status: "online", | ||
| 382 | checkedAt: "2026-05-25T00:01:00.000Z", | ||
| 383 | descriptor: { | ||
| 384 | environmentId: "env-1", | ||
| 385 | label: "Desktop", | ||
| 386 | platform: { os: "darwin", arch: "arm64" }, | ||
| 387 | serverVersion: "0.0.0-test", | ||
| 388 | capabilities: { repositoryIdentity: true }, | ||
| 389 | }, | ||
| 390 | }), | ||
| 391 | ); | ||
| 392 | } | ||
| 393 | if (String(url).endsWith("/connect")) { | ||
| 394 | return Promise.resolve( | ||
| 395 | Response.json({ | ||
| 396 | environmentId: "env-1", | ||
| 397 | endpoint: listedEnvironment("env-1").endpoint, | ||
| 398 | credential: "one-time-cloud-credential", | ||
| 399 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 400 | }), | ||
| 401 | ); | ||
| 402 | } | ||
| 403 | if (String(url).endsWith("/.well-known/t3/environment")) { | ||
| 404 | return Promise.resolve( | ||
| 405 | Response.json({ | ||
| 406 | environmentId: "env-1", | ||
| 407 | label: "Desktop", | ||
| 408 | platform: { os: "darwin", arch: "arm64" }, | ||
| 409 | serverVersion: "0.0.0-test", | ||
| 410 | capabilities: { repositoryIdentity: true }, | ||
| 411 | }), | ||
| 412 | ); | ||
| 413 | } | ||
| 414 | return Promise.resolve( | ||
| 415 | Response.json({ | ||
| 416 | access_token: "environment-dpop-token", | ||
| 417 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 418 | token_type: "DPoP", | ||
| 419 | expires_in: 3600, | ||
| 420 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 421 | }), | ||
| 422 | ); | ||
| 423 | }); | ||
| 424 | vi.stubGlobal("fetch", fetchMock); | ||
| 425 | |||
| 426 | yield* withCloudServices( | ||
| 427 | Effect.gen(function* () { | ||
| 428 | const records = yield* listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }); | ||
| 429 | yield* connectCloudEnvironment({ | ||
| 430 | clerkToken: "clerk-token", | ||
| 431 | environment: records[0]!.environment, | ||
| 432 | }); | ||
| 433 | }), | ||
| 434 | ); | ||
| 435 | |||
| 436 | expect( | ||
| 437 | fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), | ||
| 438 | ).toHaveLength(1); | ||
| 439 | const exchangeRequest = fetchMock.mock.calls.find(([url]) => | ||
| 440 | String(url).endsWith("/v1/client/dpop-token"), | ||
| 441 | )?.[1]; | ||
| 442 | expect(new URLSearchParams(requestBodyText(exchangeRequest?.body)).get("scope")).toBe( | ||
| 443 | "environment:status environment:connect", | ||
| 444 | ); | ||
| 445 | const environmentTokenRequest = fetchMock.mock.calls.find(([url]) => | ||
| 446 | String(url).endsWith("/oauth/token"), | ||
| 447 | )?.[1]; | ||
| 448 | const environmentTokenBody = new URLSearchParams( | ||
| 449 | requestBodyText(environmentTokenRequest?.body), | ||
| 450 | ); | ||
| 451 | expect(environmentTokenBody.get("client_label")).toBe("T3 Code Mobile"); | ||
| 452 | expect(environmentTokenBody.get("client_device_type")).toBe("mobile"); | ||
| 453 | expect(environmentTokenBody.get("client_os")).toBe("iOS"); | ||
| 454 | }), | ||
| 455 | ); | ||
| 456 | |||
| 457 | it.effect("keeps advertised environments visible when status probing fails", () => | ||
| 458 | Effect.gen(function* () { | ||
| 459 | vi.stubGlobal( | ||
| 460 | "fetch", | ||
| 461 | vi.fn((url: string | URL) => { | ||
| 462 | if (String(url) === "https://relay.example.test/v1/environments") { | ||
| 463 | return Promise.resolve( | ||
| 464 | Response.json({ | ||
| 465 | environments: [ | ||
| 466 | { | ||
| 467 | environmentId: "env-1", | ||
| 468 | label: "Desktop", | ||
| 469 | endpoint: { | ||
| 470 | httpBaseUrl: "https://desktop.example.test/", | ||
| 471 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 472 | providerKind: "cloudflare_tunnel", | ||
| 473 | }, | ||
| 474 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 475 | }, | ||
| 476 | ], | ||
| 477 | }), | ||
| 478 | ); | ||
| 479 | } | ||
| 480 | if (String(url) === "https://relay.example.test/v1/client/dpop-token") { | ||
| 481 | return Promise.resolve(Response.json(validDpopAccessTokenResponse())); | ||
| 482 | } | ||
| 483 | return Promise.resolve(Response.json({ error: "offline" }, { status: 503 })); | ||
| 484 | }), | ||
| 485 | ); | ||
| 486 | |||
| 487 | const records = yield* withCloudServices( | ||
| 488 | listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }), | ||
| 489 | ); | ||
| 490 | expect(records).toMatchObject([ | ||
| 491 | { | ||
| 492 | environment: { | ||
| 493 | environmentId: "env-1", | ||
| 494 | label: "Desktop", | ||
| 495 | }, | ||
| 496 | status: null, | ||
| 497 | statusError: "https://relay.example.test/v1/environments/env-1/status failed", | ||
| 498 | }, | ||
| 499 | ]); | ||
| 500 | }), | ||
| 501 | ); | ||
| 502 | |||
| 503 | it.effect("rejects status responses for a different advertised environment", () => | ||
| 504 | Effect.gen(function* () { | ||
| 505 | vi.stubGlobal( | ||
| 506 | "fetch", | ||
| 507 | vi.fn((url: string | URL) => { | ||
| 508 | if (String(url) === "https://relay.example.test/v1/environments") { | ||
| 509 | return Promise.resolve( | ||
| 510 | Response.json({ | ||
| 511 | environments: [ | ||
| 512 | { | ||
| 513 | environmentId: "env-1", | ||
| 514 | label: "Desktop", | ||
| 515 | endpoint: { | ||
| 516 | httpBaseUrl: "https://desktop.example.test/", | ||
| 517 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 518 | providerKind: "cloudflare_tunnel", | ||
| 519 | }, | ||
| 520 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 521 | }, | ||
| 522 | ], | ||
| 523 | }), | ||
| 524 | ); | ||
| 525 | } | ||
| 526 | if (String(url) === "https://relay.example.test/v1/client/dpop-token") { | ||
| 527 | return Promise.resolve(Response.json(validDpopAccessTokenResponse())); | ||
| 528 | } | ||
| 529 | return Promise.resolve( | ||
| 530 | Response.json({ | ||
| 531 | environmentId: "env-other", | ||
| 532 | endpoint: { | ||
| 533 | httpBaseUrl: "https://desktop.example.test/", | ||
| 534 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 535 | providerKind: "cloudflare_tunnel", | ||
| 536 | }, | ||
| 537 | status: "online", | ||
| 538 | checkedAt: "2026-05-25T00:01:00.000Z", | ||
| 539 | descriptor: { | ||
| 540 | environmentId: "env-other", | ||
| 541 | label: "Other Desktop", | ||
| 542 | platform: { os: "darwin", arch: "arm64" }, | ||
| 543 | serverVersion: "0.0.0-test", | ||
| 544 | capabilities: { repositoryIdentity: true }, | ||
| 545 | }, | ||
| 546 | }), | ||
| 547 | ); | ||
| 548 | }), | ||
| 549 | ); | ||
| 550 | |||
| 551 | const records = yield* withCloudServices( | ||
| 552 | listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }), | ||
| 553 | ); | ||
| 554 | expect(records).toMatchObject([ | ||
| 555 | { | ||
| 556 | environment: { | ||
| 557 | environmentId: "env-1", | ||
| 558 | label: "Desktop", | ||
| 559 | }, | ||
| 560 | status: null, | ||
| 561 | statusError: "Relay returned status for a different environment.", | ||
| 562 | }, | ||
| 563 | ]); | ||
| 564 | }), | ||
| 565 | ); | ||
| 566 | |||
| 567 | it.effect( | ||
| 568 | "rejects relay link credentials for a different environment before persisting relay config", | ||
| 569 | () => | ||
| 570 | Effect.gen(function* () { | ||
| 571 | const fetchMock = vi.fn((url: string | URL) => { | ||
| 572 | if (String(url).endsWith("/v1/client/environment-link-challenges")) { | ||
| 573 | return Promise.resolve(Response.json(validLinkChallengeResponse())); | ||
| 574 | } | ||
| 575 | if (String(url).endsWith("/api/cloud/link-proof")) { | ||
| 576 | return Promise.resolve(Response.json(validLinkProof())); | ||
| 577 | } | ||
| 578 | return Promise.resolve(Response.json(validLinkResponse("env-other"))); | ||
| 579 | }); | ||
| 580 | vi.stubGlobal("fetch", fetchMock); | ||
| 581 | |||
| 582 | const error = yield* withCloudServices( | ||
| 583 | linkEnvironmentToCloud({ | ||
| 584 | clerkToken: "clerk-token", | ||
| 585 | connection: savedConnection, | ||
| 586 | }), | ||
| 587 | ).pipe(Effect.flip); | ||
| 588 | expect(error).toMatchObject({ | ||
| 589 | _tag: "CloudEnvironmentLinkError", | ||
| 590 | message: "Relay returned credentials for a different environment.", | ||
| 591 | }); | ||
| 592 | expect(fetchMock).toHaveBeenCalledTimes(3); | ||
| 593 | }), | ||
| 594 | ); | ||
| 595 | |||
| 596 | it.effect("preserves typed local environment failures while obtaining a link proof", () => | ||
| 597 | Effect.gen(function* () { | ||
| 598 | const fetchMock = vi.fn((url: string | URL) => { | ||
| 599 | if (String(url).endsWith("/v1/client/environment-link-challenges")) { | ||
| 600 | return Promise.resolve(Response.json(validLinkChallengeResponse())); | ||
| 601 | } | ||
| 602 | return Promise.resolve( | ||
| 603 | Response.json( | ||
| 604 | { | ||
| 605 | _tag: "EnvironmentHttpUnauthorizedError", | ||
| 606 | message: "Invalid environment bearer session.", | ||
| 607 | }, | ||
| 608 | { status: 401 }, | ||
| 609 | ), | ||
| 610 | ); | ||
| 611 | }); | ||
| 612 | vi.stubGlobal("fetch", fetchMock); | ||
| 613 | |||
| 614 | const error = yield* withCloudServices( | ||
| 615 | linkEnvironmentToCloud({ | ||
| 616 | clerkToken: "clerk-token", | ||
| 617 | connection: savedConnection, | ||
| 618 | }), | ||
| 619 | ).pipe(Effect.flip); | ||
| 620 | expect(error._tag).toBe("CloudEnvironmentLinkError"); | ||
| 621 | expect(error.message).toBe( | ||
| 622 | "Could not obtain environment link proof: Invalid environment bearer session.", | ||
| 623 | ); | ||
| 624 | expect(fetchMock).toHaveBeenCalledTimes(2); | ||
| 625 | }), | ||
| 626 | ); | ||
| 627 | |||
| 628 | it.effect("preserves typed relay error bodies while linking environments", () => | ||
| 629 | Effect.gen(function* () { | ||
| 630 | const fetchMock = vi.fn((url: string | URL) => { | ||
| 631 | if (String(url).endsWith("/v1/client/environment-link-challenges")) { | ||
| 632 | return Promise.resolve(Response.json(validLinkChallengeResponse())); | ||
| 633 | } | ||
| 634 | if (String(url).endsWith("/api/cloud/link-proof")) { | ||
| 635 | return Promise.resolve(Response.json(validLinkProof())); | ||
| 636 | } | ||
| 637 | return Promise.resolve( | ||
| 638 | Response.json( | ||
| 639 | { | ||
| 640 | _tag: "RelayEnvironmentLinkProofInvalidError", | ||
| 641 | code: "environment_link_proof_invalid", | ||
| 642 | reason: "origin_not_allowed", | ||
| 643 | traceId: "trace-test", | ||
| 644 | }, | ||
| 645 | { status: 400 }, | ||
| 646 | ), | ||
| 647 | ); | ||
| 648 | }); | ||
| 649 | vi.stubGlobal("fetch", fetchMock); | ||
| 650 | |||
| 651 | const error = yield* withCloudServices( | ||
| 652 | linkEnvironmentToCloud({ | ||
| 653 | clerkToken: "clerk-token", | ||
| 654 | connection: savedConnection, | ||
| 655 | }), | ||
| 656 | ).pipe(Effect.flip); | ||
| 657 | expect(error).toMatchObject({ | ||
| 658 | _tag: "CloudEnvironmentLinkError", | ||
| 659 | message: | ||
| 660 | "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", | ||
| 661 | }); | ||
| 662 | expect(fetchMock).toHaveBeenCalledTimes(3); | ||
| 663 | }), | ||
| 664 | ); | ||
| 665 | |||
| 666 | it.effect("rejects relay link credentials for a different managed endpoint provider", () => | ||
| 667 | Effect.gen(function* () { | ||
| 668 | const fetchMock = vi.fn((url: string | URL) => { | ||
| 669 | if (String(url).endsWith("/v1/client/environment-link-challenges")) { | ||
| 670 | return Promise.resolve(Response.json(validLinkChallengeResponse())); | ||
| 671 | } | ||
| 672 | if (String(url).endsWith("/api/cloud/link-proof")) { | ||
| 673 | return Promise.resolve(Response.json(validLinkProof())); | ||
| 674 | } | ||
| 675 | return Promise.resolve( | ||
| 676 | Response.json({ | ||
| 677 | ...validLinkResponse(), | ||
| 678 | endpoint: { | ||
| 679 | ...validLinkResponse().endpoint, | ||
| 680 | providerKind: "manual", | ||
| 681 | }, | ||
| 682 | }), | ||
| 683 | ); | ||
| 684 | }); | ||
| 685 | vi.stubGlobal("fetch", fetchMock); | ||
| 686 | |||
| 687 | const error = yield* withCloudServices( | ||
| 688 | linkEnvironmentToCloud({ | ||
| 689 | clerkToken: "clerk-token", | ||
| 690 | connection: savedConnection, | ||
| 691 | }), | ||
| 692 | ).pipe(Effect.flip); | ||
| 693 | expect(error).toMatchObject({ | ||
| 694 | _tag: "CloudEnvironmentLinkError", | ||
| 695 | message: "Relay returned credentials for a different endpoint provider.", | ||
| 696 | }); | ||
| 697 | expect(fetchMock).toHaveBeenCalledTimes(3); | ||
| 698 | }), | ||
| 699 | ); | ||
| 700 | |||
| 701 | it.effect("preserves disabled Live Activity preferences when linking an environment", () => | ||
| 702 | Effect.gen(function* () { | ||
| 703 | const storage = yield* Effect.promise(() => import("../../lib/storage")); | ||
| 704 | vi.mocked(storage.loadPreferences).mockResolvedValueOnce({ | ||
| 705 | liveActivitiesEnabled: false, | ||
| 706 | }); | ||
| 707 | const bodies: Array<unknown> = []; | ||
| 708 | const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { | ||
| 709 | if (init?.body) { | ||
| 710 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 711 | bodies.push(JSON.parse(requestBodyText(init.body))); | ||
| 712 | } | ||
| 713 | if (String(url).endsWith("/v1/client/environment-link-challenges")) { | ||
| 714 | return Promise.resolve(Response.json(validLinkChallengeResponse())); | ||
| 715 | } | ||
| 716 | if (String(url).endsWith("/api/cloud/link-proof")) { | ||
| 717 | return Promise.resolve(Response.json(validLinkProof())); | ||
| 718 | } | ||
| 719 | if (String(url).endsWith("/v1/client/environment-links")) { | ||
| 720 | return Promise.resolve(Response.json(validLinkResponse())); | ||
| 721 | } | ||
| 722 | return Promise.resolve( | ||
| 723 | Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), | ||
| 724 | ); | ||
| 725 | }); | ||
| 726 | vi.stubGlobal("fetch", fetchMock); | ||
| 727 | |||
| 728 | yield* withCloudServices( | ||
| 729 | linkEnvironmentToCloud({ | ||
| 730 | clerkToken: "clerk-token", | ||
| 731 | connection: savedConnection, | ||
| 732 | }), | ||
| 733 | ); | ||
| 734 | |||
| 735 | expect(bodies[1]).toMatchObject({ | ||
| 736 | endpoint: { | ||
| 737 | httpBaseUrl: "https://desktop.example.test/", | ||
| 738 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 739 | providerKind: "cloudflare_tunnel", | ||
| 740 | }, | ||
| 741 | origin: { | ||
| 742 | localHttpHost: "127.0.0.1", | ||
| 743 | localHttpPort: 443, | ||
| 744 | }, | ||
| 745 | }); | ||
| 746 | expect(bodies[2]).toMatchObject({ | ||
| 747 | deviceId: "device-1", | ||
| 748 | notificationsEnabled: true, | ||
| 749 | liveActivitiesEnabled: false, | ||
| 750 | managedTunnelsEnabled: true, | ||
| 751 | }); | ||
| 752 | expect(bodies[3]).toMatchObject({ | ||
| 753 | cloudUserId: "user_123", | ||
| 754 | environmentCredential: "environment-credential", | ||
| 755 | }); | ||
| 756 | }), | ||
| 757 | ); | ||
| 758 | |||
| 759 | it.effect( | ||
| 760 | "does not persist cloud connect bootstrap credentials in saved connection records", | ||
| 761 | () => | ||
| 762 | Effect.gen(function* () { | ||
| 763 | let connectRequestBody = ""; | ||
| 764 | const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { | ||
| 765 | if (String(url).endsWith("/v1/client/dpop-token")) { | ||
| 766 | return Promise.resolve( | ||
| 767 | Response.json(validDpopAccessTokenResponse("environment:connect")), | ||
| 768 | ); | ||
| 769 | } | ||
| 770 | if (String(url).endsWith("/.well-known/t3/environment")) { | ||
| 771 | return Promise.resolve( | ||
| 772 | Response.json({ | ||
| 773 | environmentId: "env-1", | ||
| 774 | label: "Desktop", | ||
| 775 | platform: { os: "darwin", arch: "arm64" }, | ||
| 776 | serverVersion: "0.0.0-test", | ||
| 777 | capabilities: { repositoryIdentity: true }, | ||
| 778 | }), | ||
| 779 | ); | ||
| 780 | } | ||
| 781 | if (String(url).endsWith("/oauth/token")) { | ||
| 782 | return Promise.resolve( | ||
| 783 | Response.json({ | ||
| 784 | access_token: "environment-dpop-token", | ||
| 785 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 786 | token_type: "DPoP", | ||
| 787 | expires_in: 3600, | ||
| 788 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 789 | }), | ||
| 790 | ); | ||
| 791 | } | ||
| 792 | connectRequestBody = requestBodyText(init?.body); | ||
| 793 | return Promise.resolve( | ||
| 794 | Response.json({ | ||
| 795 | environmentId: "env-1", | ||
| 796 | endpoint: { | ||
| 797 | httpBaseUrl: "https://desktop.example.test/", | ||
| 798 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 799 | providerKind: "cloudflare_tunnel", | ||
| 800 | }, | ||
| 801 | credential: "one-time-cloud-credential", | ||
| 802 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 803 | }), | ||
| 804 | ); | ||
| 805 | }); | ||
| 806 | vi.stubGlobal("fetch", fetchMock); | ||
| 807 | |||
| 808 | const connection = yield* withCloudServices( | ||
| 809 | connectCloudEnvironment({ | ||
| 810 | clerkToken: "clerk-token", | ||
| 811 | environment: { | ||
| 812 | environmentId: EnvironmentId.make("env-1"), | ||
| 813 | label: "Desktop", | ||
| 814 | endpoint: { | ||
| 815 | httpBaseUrl: "https://desktop.example.test/", | ||
| 816 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 817 | providerKind: "cloudflare_tunnel", | ||
| 818 | }, | ||
| 819 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 820 | }, | ||
| 821 | }), | ||
| 822 | ); | ||
| 823 | |||
| 824 | expect(connection.pairingUrl).toBe("https://desktop.example.test/"); | ||
| 825 | expect(connection.pairingUrl).not.toContain("one-time-cloud-credential"); | ||
| 826 | expect(connection.bearerToken).toBeNull(); | ||
| 827 | expect(connection.authenticationMethod).toBe("dpop"); | ||
| 828 | expect(connection.dpopAccessToken).toBe("environment-dpop-token"); | ||
| 829 | expect(connection.relayManaged).toBe(true); | ||
| 830 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 831 | expect(JSON.parse(connectRequestBody)).toMatchObject({ | ||
| 832 | deviceId: "device-1", | ||
| 833 | clientKeyThumbprint: "client-proof-key-thumbprint", | ||
| 834 | }); | ||
| 835 | expect(createProofMock).toHaveBeenCalledWith({ | ||
| 836 | method: "POST", | ||
| 837 | url: "https://relay.example.test/v1/environments/env-1/connect", | ||
| 838 | accessToken: "relay-dpop-token", | ||
| 839 | }); | ||
| 840 | expect(createProofMock).toHaveBeenCalledWith({ | ||
| 841 | method: "POST", | ||
| 842 | url: "https://desktop.example.test/oauth/token", | ||
| 843 | }); | ||
| 844 | }), | ||
| 845 | ); | ||
| 846 | |||
| 847 | it.effect("refreshes a saved environment against a rotated managed endpoint", () => | ||
| 848 | Effect.gen(function* () { | ||
| 849 | vi.stubGlobal( | ||
| 850 | "fetch", | ||
| 851 | vi.fn((url: string | URL) => { | ||
| 852 | if (String(url).endsWith("/v1/client/dpop-token")) { | ||
| 853 | return Promise.resolve( | ||
| 854 | Response.json(validDpopAccessTokenResponse("environment:connect")), | ||
| 855 | ); | ||
| 856 | } | ||
| 857 | if (String(url).endsWith("/.well-known/t3/environment")) { | ||
| 858 | return Promise.resolve( | ||
| 859 | Response.json({ | ||
| 860 | environmentId: "env-1", | ||
| 861 | label: "Rotated Desktop", | ||
| 862 | platform: { os: "darwin", arch: "arm64" }, | ||
| 863 | serverVersion: "0.0.0-test", | ||
| 864 | capabilities: { repositoryIdentity: true }, | ||
| 865 | }), | ||
| 866 | ); | ||
| 867 | } | ||
| 868 | if (String(url).endsWith("/oauth/token")) { | ||
| 869 | return Promise.resolve( | ||
| 870 | Response.json({ | ||
| 871 | access_token: "fresh-environment-dpop-token", | ||
| 872 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 873 | token_type: "DPoP", | ||
| 874 | expires_in: 3600, | ||
| 875 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 876 | }), | ||
| 877 | ); | ||
| 878 | } | ||
| 879 | return Promise.resolve( | ||
| 880 | Response.json({ | ||
| 881 | environmentId: "env-1", | ||
| 882 | endpoint: { | ||
| 883 | httpBaseUrl: "https://rotated-desktop.example.test/", | ||
| 884 | wsBaseUrl: "wss://rotated-desktop.example.test/ws", | ||
| 885 | providerKind: "cloudflare_tunnel", | ||
| 886 | }, | ||
| 887 | credential: "rotated-one-time-cloud-credential", | ||
| 888 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 889 | }), | ||
| 890 | ); | ||
| 891 | }), | ||
| 892 | ); | ||
| 893 | |||
| 894 | const connection = yield* withCloudServices( | ||
| 895 | refreshCloudEnvironmentConnection({ | ||
| 896 | clerkToken: "clerk-token", | ||
| 897 | connection: { | ||
| 898 | environmentId: EnvironmentId.make("env-1"), | ||
| 899 | environmentLabel: "Desktop", | ||
| 900 | pairingUrl: "https://desktop.example.test/", | ||
| 901 | displayUrl: "https://desktop.example.test/", | ||
| 902 | httpBaseUrl: "https://desktop.example.test/", | ||
| 903 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 904 | bearerToken: null, | ||
| 905 | authenticationMethod: "dpop", | ||
| 906 | relayManaged: true, | ||
| 907 | }, | ||
| 908 | }), | ||
| 909 | ); | ||
| 910 | |||
| 911 | expect(connection).toMatchObject({ | ||
| 912 | environmentId: "env-1", | ||
| 913 | environmentLabel: "Rotated Desktop", | ||
| 914 | displayUrl: "https://rotated-desktop.example.test/", | ||
| 915 | httpBaseUrl: "https://rotated-desktop.example.test/", | ||
| 916 | wsBaseUrl: "wss://rotated-desktop.example.test/ws", | ||
| 917 | dpopAccessToken: "fresh-environment-dpop-token", | ||
| 918 | }); | ||
| 919 | }), | ||
| 920 | ); | ||
| 921 | |||
| 922 | it.effect("rejects relay connect responses for a different environment", () => | ||
| 923 | Effect.gen(function* () { | ||
| 924 | vi.stubGlobal( | ||
| 925 | "fetch", | ||
| 926 | vi.fn((url: string | URL) => | ||
| 927 | Promise.resolve( | ||
| 928 | String(url).endsWith("/v1/client/dpop-token") | ||
| 929 | ? Response.json(validDpopAccessTokenResponse("environment:connect")) | ||
| 930 | : Response.json({ | ||
| 931 | environmentId: "env-other", | ||
| 932 | endpoint: { | ||
| 933 | httpBaseUrl: "https://desktop.example.test/", | ||
| 934 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 935 | providerKind: "cloudflare_tunnel", | ||
| 936 | }, | ||
| 937 | credential: "one-time-cloud-credential", | ||
| 938 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 939 | }), | ||
| 940 | ), | ||
| 941 | ), | ||
| 942 | ); | ||
| 943 | |||
| 944 | const error = yield* withCloudServices( | ||
| 945 | connectCloudEnvironment({ | ||
| 946 | clerkToken: "clerk-token", | ||
| 947 | environment: { | ||
| 948 | environmentId: EnvironmentId.make("env-1"), | ||
| 949 | label: "Desktop", | ||
| 950 | endpoint: { | ||
| 951 | httpBaseUrl: "https://desktop.example.test/", | ||
| 952 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 953 | providerKind: "cloudflare_tunnel", | ||
| 954 | }, | ||
| 955 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 956 | }, | ||
| 957 | }), | ||
| 958 | ).pipe(Effect.flip); | ||
| 959 | expect(error).toMatchObject({ | ||
| 960 | _tag: "CloudEnvironmentLinkError", | ||
| 961 | message: "Relay returned credentials for a different environment.", | ||
| 962 | }); | ||
| 963 | }), | ||
| 964 | ); | ||
| 965 | |||
| 966 | it.effect("preserves relay DPoP auth failures while connecting environments", () => | ||
| 967 | Effect.gen(function* () { | ||
| 968 | vi.stubGlobal( | ||
| 969 | "fetch", | ||
| 970 | vi.fn((url: string | URL) => | ||
| 971 | Promise.resolve( | ||
| 972 | String(url).endsWith("/v1/client/dpop-token") | ||
| 973 | ? Response.json(validDpopAccessTokenResponse("environment:connect")) | ||
| 974 | : Response.json( | ||
| 975 | { | ||
| 976 | _tag: "RelayAuthInvalidError", | ||
| 977 | code: "auth_invalid", | ||
| 978 | reason: "invalid_dpop", | ||
| 979 | traceId: "trace-connect", | ||
| 980 | }, | ||
| 981 | { status: 401 }, | ||
| 982 | ), | ||
| 983 | ), | ||
| 984 | ), | ||
| 985 | ); | ||
| 986 | |||
| 987 | const error = yield* withCloudServices( | ||
| 988 | connectCloudEnvironment({ | ||
| 989 | clerkToken: "clerk-token", | ||
| 990 | environment: { | ||
| 991 | environmentId: EnvironmentId.make("env-1"), | ||
| 992 | label: "Desktop", | ||
| 993 | endpoint: { | ||
| 994 | httpBaseUrl: "https://desktop.example.test/", | ||
| 995 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 996 | providerKind: "cloudflare_tunnel", | ||
| 997 | }, | ||
| 998 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 999 | }, | ||
| 1000 | }), | ||
| 1001 | ).pipe(Effect.flip); | ||
| 1002 | expect(error).toMatchObject({ | ||
| 1003 | _tag: "CloudEnvironmentLinkError", | ||
| 1004 | message: | ||
| 1005 | "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", | ||
| 1006 | }); | ||
| 1007 | }), | ||
| 1008 | ); | ||
| 1009 | |||
| 1010 | it.effect("rejects relay connect responses for a different endpoint", () => | ||
| 1011 | Effect.gen(function* () { | ||
| 1012 | vi.stubGlobal( | ||
| 1013 | "fetch", | ||
| 1014 | vi.fn((url: string | URL) => | ||
| 1015 | Promise.resolve( | ||
| 1016 | String(url).endsWith("/v1/client/dpop-token") | ||
| 1017 | ? Response.json(validDpopAccessTokenResponse("environment:connect")) | ||
| 1018 | : Response.json({ | ||
| 1019 | environmentId: "env-1", | ||
| 1020 | endpoint: { | ||
| 1021 | httpBaseUrl: "https://other-desktop.example.test/", | ||
| 1022 | wsBaseUrl: "wss://other-desktop.example.test/ws", | ||
| 1023 | providerKind: "cloudflare_tunnel", | ||
| 1024 | }, | ||
| 1025 | credential: "one-time-cloud-credential", | ||
| 1026 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 1027 | }), | ||
| 1028 | ), | ||
| 1029 | ), | ||
| 1030 | ); | ||
| 1031 | |||
| 1032 | const error = yield* withCloudServices( | ||
| 1033 | connectCloudEnvironment({ | ||
| 1034 | clerkToken: "clerk-token", | ||
| 1035 | environment: { | ||
| 1036 | environmentId: EnvironmentId.make("env-1"), | ||
| 1037 | label: "Desktop", | ||
| 1038 | endpoint: { | ||
| 1039 | httpBaseUrl: "https://desktop.example.test/", | ||
| 1040 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 1041 | providerKind: "cloudflare_tunnel", | ||
| 1042 | }, | ||
| 1043 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 1044 | }, | ||
| 1045 | }), | ||
| 1046 | ).pipe(Effect.flip); | ||
| 1047 | expect(error).toMatchObject({ | ||
| 1048 | _tag: "CloudEnvironmentLinkError", | ||
| 1049 | message: "Relay returned credentials for a different endpoint.", | ||
| 1050 | }); | ||
| 1051 | }), | ||
| 1052 | ); | ||
| 1053 | |||
| 1054 | it.effect( | ||
| 1055 | "rejects managed endpoints whose descriptor does not match the selected environment", | ||
| 1056 | () => | ||
| 1057 | Effect.gen(function* () { | ||
| 1058 | vi.stubGlobal( | ||
| 1059 | "fetch", | ||
| 1060 | vi.fn((url: string | URL) => | ||
| 1061 | Promise.resolve( | ||
| 1062 | String(url).endsWith("/v1/client/dpop-token") | ||
| 1063 | ? Response.json(validDpopAccessTokenResponse("environment:connect")) | ||
| 1064 | : String(url).endsWith("/.well-known/t3/environment") | ||
| 1065 | ? Response.json({ | ||
| 1066 | environmentId: "env-other", | ||
| 1067 | label: "Other Desktop", | ||
| 1068 | platform: { os: "darwin", arch: "arm64" }, | ||
| 1069 | serverVersion: "0.0.0-test", | ||
| 1070 | capabilities: { repositoryIdentity: true }, | ||
| 1071 | }) | ||
| 1072 | : Response.json({ | ||
| 1073 | environmentId: "env-1", | ||
| 1074 | endpoint: { | ||
| 1075 | httpBaseUrl: "https://desktop.example.test/", | ||
| 1076 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 1077 | providerKind: "cloudflare_tunnel", | ||
| 1078 | }, | ||
| 1079 | credential: "one-time-cloud-credential", | ||
| 1080 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 1081 | }), | ||
| 1082 | ), | ||
| 1083 | ), | ||
| 1084 | ); | ||
| 1085 | |||
| 1086 | const error = yield* withCloudServices( | ||
| 1087 | connectCloudEnvironment({ | ||
| 1088 | clerkToken: "clerk-token", | ||
| 1089 | environment: { | ||
| 1090 | environmentId: EnvironmentId.make("env-1"), | ||
| 1091 | label: "Desktop", | ||
| 1092 | endpoint: { | ||
| 1093 | httpBaseUrl: "https://desktop.example.test/", | ||
| 1094 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 1095 | providerKind: "cloudflare_tunnel", | ||
| 1096 | }, | ||
| 1097 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 1098 | }, | ||
| 1099 | }), | ||
| 1100 | ).pipe(Effect.flip); | ||
| 1101 | expect(error).toMatchObject({ | ||
| 1102 | _tag: "CloudEnvironmentLinkError", | ||
| 1103 | message: "Connected endpoint descriptor does not match the selected environment.", | ||
| 1104 | }); | ||
| 1105 | }), | ||
| 1106 | ); | ||
| 1107 | }); | ||
apps/mobile/src/features/cloud/linkEnvironment.ts created+582| ... | @@ -0,0 +1,582 @@ | ||
| 1 | import * as Data from "effect/Data"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import { HttpClient } from "effect/unstable/http"; | ||
| 5 | import { | ||
| 6 | EnvironmentCloudEndpointUnavailableError, | ||
| 7 | EnvironmentHttpBadRequestError, | ||
| 8 | EnvironmentHttpConflictError, | ||
| 9 | EnvironmentHttpForbiddenError, | ||
| 10 | EnvironmentHttpInternalServerError, | ||
| 11 | EnvironmentHttpUnauthorizedError, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; | ||
| 14 | import { | ||
| 15 | type RelayEnvironmentConnectResponse as RelayEnvironmentConnectResponseType, | ||
| 16 | type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, | ||
| 17 | RelayEnvironmentConnectScope, | ||
| 18 | RelayEnvironmentStatusScope, | ||
| 19 | RelayProtectedError, | ||
| 20 | type RelayDpopAccessTokenScope, | ||
| 21 | type RelayProtectedError as RelayProtectedErrorType, | ||
| 22 | type RelayClientEnvironmentRecord, | ||
| 23 | type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, | ||
| 24 | type RelayManagedEndpointProviderKind, | ||
| 25 | } from "@t3tools/contracts/relay"; | ||
| 26 | import { | ||
| 27 | exchangeRemoteDpopAccessToken, | ||
| 28 | fetchRemoteEnvironmentDescriptor, | ||
| 29 | makeEnvironmentHttpApiClient, | ||
| 30 | ManagedRelayClient, | ||
| 31 | ManagedRelayDpopSigner, | ||
| 32 | } from "@t3tools/client-runtime"; | ||
| 33 | |||
| 34 | import { mobileAuthClientMetadata } from "../../lib/authClientMetadata"; | ||
| 35 | import type { SavedRemoteConnection } from "../../lib/connection"; | ||
| 36 | import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; | ||
| 37 | import { resolveCloudPublicConfig } from "./publicConfig"; | ||
| 38 | |||
| 39 | const RELAY_STATUS_AND_CONNECT_SCOPES = [ | ||
| 40 | RelayEnvironmentStatusScope, | ||
| 41 | RelayEnvironmentConnectScope, | ||
| 42 | ] satisfies ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 43 | |||
| 44 | export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { | ||
| 45 | const trimmed = value?.trim(); | ||
| 46 | if (!trimmed) { | ||
| 47 | return null; | ||
| 48 | } | ||
| 49 | return trimmed.replace(/\/+$/g, ""); | ||
| 50 | } | ||
| 51 | |||
| 52 | function readRelayUrl(): string | null { | ||
| 53 | return resolveCloudPublicConfig().relayUrl; | ||
| 54 | } | ||
| 55 | |||
| 56 | export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ | ||
| 57 | readonly message: string; | ||
| 58 | readonly cause?: unknown; | ||
| 59 | }> {} | ||
| 60 | |||
| 61 | export interface CloudEnvironmentRecordWithStatus { | ||
| 62 | readonly environment: RelayClientEnvironmentRecord; | ||
| 63 | readonly status: RelayEnvironmentStatusResponseType | null; | ||
| 64 | readonly statusError: string | null; | ||
| 65 | } | ||
| 66 | |||
| 67 | const isRelayProtectedError = Schema.is(RelayProtectedError); | ||
| 68 | const isEnvironmentCloudApiError = Schema.is( | ||
| 69 | Schema.Union([ | ||
| 70 | EnvironmentHttpBadRequestError, | ||
| 71 | EnvironmentHttpUnauthorizedError, | ||
| 72 | EnvironmentHttpForbiddenError, | ||
| 73 | EnvironmentHttpConflictError, | ||
| 74 | EnvironmentHttpInternalServerError, | ||
| 75 | EnvironmentCloudEndpointUnavailableError, | ||
| 76 | ]), | ||
| 77 | ); | ||
| 78 | |||
| 79 | const MANAGED_ENDPOINT_PROVIDER_KIND = | ||
| 80 | "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; | ||
| 81 | |||
| 82 | function cloudEnvironmentLinkError(message: string) { | ||
| 83 | return (cause: unknown) => { | ||
| 84 | const environmentError = findEnvironmentCloudApiError(cause); | ||
| 85 | return new CloudEnvironmentLinkError({ | ||
| 86 | message: environmentError | ||
| 87 | ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` | ||
| 88 | : withDevCause(message, cause), | ||
| 89 | cause, | ||
| 90 | }); | ||
| 91 | }; | ||
| 92 | } | ||
| 93 | |||
| 94 | function isDevRuntime(): boolean { | ||
| 95 | return typeof __DEV__ !== "undefined" && __DEV__; | ||
| 96 | } | ||
| 97 | |||
| 98 | function causeMessage(cause: unknown): string | null { | ||
| 99 | if (cause instanceof Error && cause.message) { | ||
| 100 | return cause.message; | ||
| 101 | } | ||
| 102 | if (typeof cause === "object" && cause !== null) { | ||
| 103 | const record = cause as { readonly message?: unknown; readonly cause?: unknown }; | ||
| 104 | if (typeof record.message === "string" && record.message.length > 0) { | ||
| 105 | const nested = causeMessage(record.cause); | ||
| 106 | return nested ? `${record.message}: ${nested}` : record.message; | ||
| 107 | } | ||
| 108 | } | ||
| 109 | return null; | ||
| 110 | } | ||
| 111 | |||
| 112 | function withDevCause(message: string, cause: unknown): string { | ||
| 113 | if (!isDevRuntime()) { | ||
| 114 | return message; | ||
| 115 | } | ||
| 116 | const detail = causeMessage(cause); | ||
| 117 | return detail ? `${message} (${detail})` : message; | ||
| 118 | } | ||
| 119 | |||
| 120 | function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { | ||
| 121 | switch (error._tag) { | ||
| 122 | case "RelayAuthInvalidError": | ||
| 123 | switch (error.reason) { | ||
| 124 | case "missing_bearer": | ||
| 125 | case "invalid_bearer": | ||
| 126 | return "Relay rejected the cloud session token."; | ||
| 127 | case "invalid_dpop": | ||
| 128 | return "Relay rejected the DPoP proof."; | ||
| 129 | case "not_authorized": | ||
| 130 | return "Relay rejected the authenticated request."; | ||
| 131 | } | ||
| 132 | case "RelayEnvironmentLinkProofExpiredError": | ||
| 133 | return "Relay rejected an expired environment link proof."; | ||
| 134 | case "RelayEnvironmentLinkProofInvalidError": | ||
| 135 | return `Relay rejected the environment link proof (${error.reason}).`; | ||
| 136 | case "RelayEnvironmentConnectNotAuthorizedError": | ||
| 137 | return "Relay rejected the environment connection request."; | ||
| 138 | case "RelayEnvironmentEndpointUnavailableError": | ||
| 139 | return `Relay could not reach the environment endpoint (${error.reason}).`; | ||
| 140 | case "RelayEnvironmentEndpointTimedOutError": | ||
| 141 | return "Relay timed out while contacting the environment endpoint."; | ||
| 142 | case "RelayEnvironmentLinkFailedError": | ||
| 143 | return `Relay could not link the environment (${error.reason}).`; | ||
| 144 | case "RelayEnvironmentLinkUnavailableError": | ||
| 145 | return `Relay cannot provision the managed endpoint (${error.reason}).`; | ||
| 146 | case "RelayAgentActivityPublishProofExpiredError": | ||
| 147 | return "Relay rejected an expired agent activity publish proof."; | ||
| 148 | case "RelayAgentActivityPublishProofInvalidError": | ||
| 149 | return `Relay rejected the agent activity publish proof (${error.reason}).`; | ||
| 150 | case "RelayInternalError": | ||
| 151 | return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; | ||
| 152 | } | ||
| 153 | } | ||
| 154 | |||
| 155 | function decodedRelayClientError(message: string) { | ||
| 156 | return (cause: unknown) => { | ||
| 157 | const relayError = findRelayProtectedError(cause); | ||
| 158 | const detail = relayError ? relayProtectedErrorMessage(relayError) : null; | ||
| 159 | return new CloudEnvironmentLinkError({ | ||
| 160 | message: detail ? `${message}: ${detail}` : message, | ||
| 161 | cause, | ||
| 162 | }); | ||
| 163 | }; | ||
| 164 | } | ||
| 165 | |||
| 166 | function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { | ||
| 167 | if (isRelayProtectedError(cause)) { | ||
| 168 | return cause; | ||
| 169 | } | ||
| 170 | if (typeof cause !== "object" || cause === null) { | ||
| 171 | return null; | ||
| 172 | } | ||
| 173 | return "cause" in cause ? findRelayProtectedError(cause.cause) : null; | ||
| 174 | } | ||
| 175 | |||
| 176 | function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { | ||
| 177 | if (isEnvironmentCloudApiError(cause)) { | ||
| 178 | return cause; | ||
| 179 | } | ||
| 180 | if (typeof cause !== "object" || cause === null) { | ||
| 181 | return null; | ||
| 182 | } | ||
| 183 | return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; | ||
| 184 | } | ||
| 185 | |||
| 186 | function requireRelayUrl(): Effect.Effect<string, CloudEnvironmentLinkError> { | ||
| 187 | const relayUrl = readRelayUrl(); | ||
| 188 | return relayUrl | ||
| 189 | ? Effect.succeed(relayUrl) | ||
| 190 | : Effect.fail(new CloudEnvironmentLinkError({ message: "Relay URL is not configured." })); | ||
| 191 | } | ||
| 192 | |||
| 193 | function endpointOrigin(httpBaseUrl: string) { | ||
| 194 | const url = new URL(httpBaseUrl); | ||
| 195 | return { | ||
| 196 | localHttpHost: "127.0.0.1", | ||
| 197 | localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), | ||
| 198 | }; | ||
| 199 | } | ||
| 200 | |||
| 201 | function ensureLinkedEnvironmentMatches(input: { | ||
| 202 | readonly expectedEnvironmentId: string; | ||
| 203 | readonly expectedProviderKind: RelayManagedEndpointProviderKind; | ||
| 204 | readonly link: RelayEnvironmentLinkResponseType; | ||
| 205 | }): Effect.Effect<void, CloudEnvironmentLinkError> { | ||
| 206 | if (input.link.environmentId !== input.expectedEnvironmentId) { | ||
| 207 | return new CloudEnvironmentLinkError({ | ||
| 208 | message: "Relay returned credentials for a different environment.", | ||
| 209 | }); | ||
| 210 | } | ||
| 211 | if (input.link.endpoint.providerKind !== input.expectedProviderKind) { | ||
| 212 | return new CloudEnvironmentLinkError({ | ||
| 213 | message: "Relay returned credentials for a different endpoint provider.", | ||
| 214 | }); | ||
| 215 | } | ||
| 216 | return Effect.void; | ||
| 217 | } | ||
| 218 | |||
| 219 | function endpointMatches( | ||
| 220 | left: RelayClientEnvironmentRecord["endpoint"], | ||
| 221 | right: RelayClientEnvironmentRecord["endpoint"], | ||
| 222 | ): boolean { | ||
| 223 | return ( | ||
| 224 | left.httpBaseUrl === right.httpBaseUrl && | ||
| 225 | left.wsBaseUrl === right.wsBaseUrl && | ||
| 226 | left.providerKind === right.providerKind | ||
| 227 | ); | ||
| 228 | } | ||
| 229 | |||
| 230 | function ensureStatusMatchesEnvironment(input: { | ||
| 231 | readonly environment: RelayClientEnvironmentRecord; | ||
| 232 | readonly status: RelayEnvironmentStatusResponseType; | ||
| 233 | }): Effect.Effect<void, CloudEnvironmentLinkError> { | ||
| 234 | if (input.status.environmentId !== input.environment.environmentId) { | ||
| 235 | return new CloudEnvironmentLinkError({ | ||
| 236 | message: "Relay returned status for a different environment.", | ||
| 237 | }); | ||
| 238 | } | ||
| 239 | if (!endpointMatches(input.status.endpoint, input.environment.endpoint)) { | ||
| 240 | return new CloudEnvironmentLinkError({ | ||
| 241 | message: "Relay returned status for a different endpoint.", | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | if ( | ||
| 245 | input.status.descriptor && | ||
| 246 | input.status.descriptor.environmentId !== input.environment.environmentId | ||
| 247 | ) { | ||
| 248 | return new CloudEnvironmentLinkError({ | ||
| 249 | message: "Relay returned status descriptor for a different environment.", | ||
| 250 | }); | ||
| 251 | } | ||
| 252 | return Effect.void; | ||
| 253 | } | ||
| 254 | |||
| 255 | function ensureConnectEndpointMatchesEnvironment(input: { | ||
| 256 | readonly environment: RelayClientEnvironmentRecord; | ||
| 257 | readonly connect: RelayEnvironmentConnectResponseType; | ||
| 258 | }): Effect.Effect<void, CloudEnvironmentLinkError> { | ||
| 259 | if (!endpointMatches(input.connect.endpoint, input.environment.endpoint)) { | ||
| 260 | return new CloudEnvironmentLinkError({ | ||
| 261 | message: "Relay returned credentials for a different endpoint.", | ||
| 262 | }); | ||
| 263 | } | ||
| 264 | return Effect.void; | ||
| 265 | } | ||
| 266 | |||
| 267 | export function linkEnvironmentToCloud(input: { | ||
| 268 | readonly connection: SavedRemoteConnection; | ||
| 269 | readonly clerkToken: string; | ||
| 270 | }): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> { | ||
| 271 | return Effect.gen(function* () { | ||
| 272 | if (!input.connection.bearerToken) { | ||
| 273 | return yield* new CloudEnvironmentLinkError({ | ||
| 274 | message: "Only a locally paired bearer connection can be linked to the cloud.", | ||
| 275 | }); | ||
| 276 | } | ||
| 277 | const localBearerToken = input.connection.bearerToken; | ||
| 278 | const relayUrl = yield* requireRelayUrl(); | ||
| 279 | const relayClient = yield* ManagedRelayClient; | ||
| 280 | const deviceId = yield* Effect.tryPromise({ | ||
| 281 | try: () => loadOrCreateAgentAwarenessDeviceId(), | ||
| 282 | catch: cloudEnvironmentLinkError("Could not load the mobile device id."), | ||
| 283 | }); | ||
| 284 | const preferences = yield* Effect.tryPromise({ | ||
| 285 | try: () => loadPreferences(), | ||
| 286 | catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."), | ||
| 287 | }); | ||
| 288 | const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; | ||
| 289 | const challenge = yield* relayClient | ||
| 290 | .createEnvironmentLinkChallenge({ | ||
| 291 | clerkToken: input.clerkToken, | ||
| 292 | payload: { | ||
| 293 | notificationsEnabled: true, | ||
| 294 | liveActivitiesEnabled, | ||
| 295 | managedTunnelsEnabled: true, | ||
| 296 | }, | ||
| 297 | }) | ||
| 298 | .pipe( | ||
| 299 | Effect.mapError( | ||
| 300 | decodedRelayClientError(`${relayUrl}/v1/client/environment-link-challenges failed`), | ||
| 301 | ), | ||
| 302 | ); | ||
| 303 | const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); | ||
| 304 | const proof = yield* environmentClient.cloud | ||
| 305 | .linkProof({ | ||
| 306 | headers: { authorization: `Bearer ${localBearerToken}` }, | ||
| 307 | payload: { | ||
| 308 | challenge: challenge.challenge, | ||
| 309 | relayIssuer: relayUrl, | ||
| 310 | endpoint: { | ||
| 311 | httpBaseUrl: input.connection.httpBaseUrl, | ||
| 312 | wsBaseUrl: input.connection.wsBaseUrl, | ||
| 313 | providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, | ||
| 314 | }, | ||
| 315 | origin: endpointOrigin(input.connection.httpBaseUrl), | ||
| 316 | }, | ||
| 317 | }) | ||
| 318 | .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof."))); | ||
| 319 | const link = yield* relayClient | ||
| 320 | .linkEnvironment({ | ||
| 321 | clerkToken: input.clerkToken, | ||
| 322 | payload: { | ||
| 323 | deviceId, | ||
| 324 | proof, | ||
| 325 | notificationsEnabled: true, | ||
| 326 | liveActivitiesEnabled, | ||
| 327 | managedTunnelsEnabled: true, | ||
| 328 | }, | ||
| 329 | }) | ||
| 330 | .pipe( | ||
| 331 | Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/client/environment-links failed`)), | ||
| 332 | ); | ||
| 333 | yield* ensureLinkedEnvironmentMatches({ | ||
| 334 | expectedEnvironmentId: input.connection.environmentId, | ||
| 335 | expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, | ||
| 336 | link, | ||
| 337 | }); | ||
| 338 | |||
| 339 | yield* environmentClient.cloud | ||
| 340 | .relayConfig({ | ||
| 341 | headers: { authorization: `Bearer ${localBearerToken}` }, | ||
| 342 | payload: { | ||
| 343 | relayUrl, | ||
| 344 | relayIssuer: link.relayIssuer, | ||
| 345 | cloudUserId: link.cloudUserId, | ||
| 346 | environmentCredential: link.environmentCredential, | ||
| 347 | cloudMintPublicKey: link.cloudMintPublicKey, | ||
| 348 | endpointRuntime: link.endpointRuntime, | ||
| 349 | }, | ||
| 350 | }) | ||
| 351 | .pipe( | ||
| 352 | Effect.mapError(cloudEnvironmentLinkError("Could not configure environment relay access.")), | ||
| 353 | ); | ||
| 354 | }); | ||
| 355 | } | ||
| 356 | |||
| 357 | export function listCloudEnvironments(input: { | ||
| 358 | readonly clerkToken: string; | ||
| 359 | }): Effect.Effect< | ||
| 360 | ReadonlyArray<RelayClientEnvironmentRecord>, | ||
| 361 | CloudEnvironmentLinkError, | ||
| 362 | ManagedRelayClient | ||
| 363 | > { | ||
| 364 | return Effect.gen(function* () { | ||
| 365 | const relayUrl = yield* requireRelayUrl(); | ||
| 366 | const relayClient = yield* ManagedRelayClient; | ||
| 367 | |||
| 368 | return yield* relayClient | ||
| 369 | .listEnvironments({ | ||
| 370 | clerkToken: input.clerkToken, | ||
| 371 | }) | ||
| 372 | .pipe(Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/environments failed`))); | ||
| 373 | }); | ||
| 374 | } | ||
| 375 | |||
| 376 | export function getCloudEnvironmentStatus(input: { | ||
| 377 | readonly clerkToken: string; | ||
| 378 | readonly environment: RelayClientEnvironmentRecord; | ||
| 379 | readonly relayScopes?: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 380 | }): Effect.Effect< | ||
| 381 | RelayEnvironmentStatusResponseType, | ||
| 382 | CloudEnvironmentLinkError, | ||
| 383 | ManagedRelayClient | ||
| 384 | > { | ||
| 385 | return Effect.gen(function* () { | ||
| 386 | const relayUrl = yield* requireRelayUrl(); | ||
| 387 | const relayClient = yield* ManagedRelayClient; | ||
| 388 | const status = yield* relayClient | ||
| 389 | .getEnvironmentStatus({ | ||
| 390 | clerkToken: input.clerkToken, | ||
| 391 | scopes: input.relayScopes ?? [RelayEnvironmentStatusScope], | ||
| 392 | environmentId: input.environment.environmentId, | ||
| 393 | }) | ||
| 394 | .pipe( | ||
| 395 | Effect.mapError( | ||
| 396 | decodedRelayClientError( | ||
| 397 | `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/status failed`, | ||
| 398 | ), | ||
| 399 | ), | ||
| 400 | ); | ||
| 401 | yield* ensureStatusMatchesEnvironment({ environment: input.environment, status }); | ||
| 402 | return status; | ||
| 403 | }); | ||
| 404 | } | ||
| 405 | |||
| 406 | export function cloudEnvironmentsPendingStatus( | ||
| 407 | environments: ReadonlyArray<RelayClientEnvironmentRecord>, | ||
| 408 | ): ReadonlyArray<CloudEnvironmentRecordWithStatus> { | ||
| 409 | return environments.map((environment) => ({ | ||
| 410 | environment, | ||
| 411 | status: null, | ||
| 412 | statusError: "Checking status...", | ||
| 413 | })); | ||
| 414 | } | ||
| 415 | |||
| 416 | export function loadCloudEnvironmentStatuses(input: { | ||
| 417 | readonly clerkToken: string; | ||
| 418 | readonly environments: ReadonlyArray<RelayClientEnvironmentRecord>; | ||
| 419 | }): Effect.Effect< | ||
| 420 | ReadonlyArray<CloudEnvironmentRecordWithStatus>, | ||
| 421 | CloudEnvironmentLinkError, | ||
| 422 | ManagedRelayClient | ||
| 423 | > { | ||
| 424 | return Effect.forEach( | ||
| 425 | input.environments, | ||
| 426 | (environment) => | ||
| 427 | getCloudEnvironmentStatus({ | ||
| 428 | clerkToken: input.clerkToken, | ||
| 429 | environment, | ||
| 430 | relayScopes: RELAY_STATUS_AND_CONNECT_SCOPES, | ||
| 431 | }).pipe( | ||
| 432 | Effect.match({ | ||
| 433 | onFailure: (error) => ({ | ||
| 434 | environment, | ||
| 435 | status: null, | ||
| 436 | statusError: error.message, | ||
| 437 | }), | ||
| 438 | onSuccess: (status) => ({ | ||
| 439 | environment, | ||
| 440 | status, | ||
| 441 | statusError: null, | ||
| 442 | }), | ||
| 443 | }), | ||
| 444 | ), | ||
| 445 | { concurrency: "unbounded" }, | ||
| 446 | ); | ||
| 447 | } | ||
| 448 | |||
| 449 | export function listCloudEnvironmentsWithStatus(input: { | ||
| 450 | readonly clerkToken: string; | ||
| 451 | }): Effect.Effect< | ||
| 452 | ReadonlyArray<CloudEnvironmentRecordWithStatus>, | ||
| 453 | CloudEnvironmentLinkError, | ||
| 454 | ManagedRelayClient | ||
| 455 | > { | ||
| 456 | return Effect.gen(function* () { | ||
| 457 | const environments = yield* listCloudEnvironments(input); | ||
| 458 | return yield* loadCloudEnvironmentStatuses({ | ||
| 459 | clerkToken: input.clerkToken, | ||
| 460 | environments, | ||
| 461 | }); | ||
| 462 | }); | ||
| 463 | } | ||
| 464 | |||
| 465 | function connectRelayManagedEnvironment(input: { | ||
| 466 | readonly clerkToken: string; | ||
| 467 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 468 | readonly expectedEnvironment?: RelayClientEnvironmentRecord; | ||
| 469 | }): Effect.Effect< | ||
| 470 | SavedRemoteConnection, | ||
| 471 | CloudEnvironmentLinkError, | ||
| 472 | HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner | ||
| 473 | > { | ||
| 474 | return Effect.gen(function* () { | ||
| 475 | const relayUrl = yield* requireRelayUrl(); | ||
| 476 | const relayClient = yield* ManagedRelayClient; | ||
| 477 | |||
| 478 | const deviceId = yield* Effect.tryPromise({ | ||
| 479 | try: () => loadOrCreateAgentAwarenessDeviceId(), | ||
| 480 | catch: cloudEnvironmentLinkError("Could not load the mobile device id."), | ||
| 481 | }); | ||
| 482 | const connect = yield* relayClient | ||
| 483 | .connectEnvironment({ | ||
| 484 | clerkToken: input.clerkToken, | ||
| 485 | scopes: [RelayEnvironmentConnectScope], | ||
| 486 | environmentId: input.environmentId, | ||
| 487 | deviceId, | ||
| 488 | }) | ||
| 489 | .pipe( | ||
| 490 | Effect.mapError( | ||
| 491 | decodedRelayClientError( | ||
| 492 | `${relayUrl}/v1/environments/${encodeURIComponent(input.environmentId)}/connect failed`, | ||
| 493 | ), | ||
| 494 | ), | ||
| 495 | ); | ||
| 496 | if (connect.environmentId !== input.environmentId) { | ||
| 497 | return yield* new CloudEnvironmentLinkError({ | ||
| 498 | message: "Relay returned credentials for a different environment.", | ||
| 499 | }); | ||
| 500 | } | ||
| 501 | if (input.expectedEnvironment) { | ||
| 502 | yield* ensureConnectEndpointMatchesEnvironment({ | ||
| 503 | environment: input.expectedEnvironment, | ||
| 504 | connect, | ||
| 505 | }); | ||
| 506 | } | ||
| 507 | |||
| 508 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 509 | httpBaseUrl: connect.endpoint.httpBaseUrl, | ||
| 510 | }).pipe( | ||
| 511 | Effect.mapError( | ||
| 512 | cloudEnvironmentLinkError("Could not fetch the connected environment descriptor."), | ||
| 513 | ), | ||
| 514 | ); | ||
| 515 | if (descriptor.environmentId !== connect.environmentId) { | ||
| 516 | return yield* new CloudEnvironmentLinkError({ | ||
| 517 | message: "Connected endpoint descriptor does not match the selected environment.", | ||
| 518 | }); | ||
| 519 | } | ||
| 520 | const signer = yield* ManagedRelayDpopSigner; | ||
| 521 | const bootstrapDpop = yield* signer | ||
| 522 | .createProof({ | ||
| 523 | method: "POST", | ||
| 524 | url: new URL("/oauth/token", connect.endpoint.httpBaseUrl).toString(), | ||
| 525 | }) | ||
| 526 | .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not create bootstrap DPoP proof."))); | ||
| 527 | const bootstrap = yield* exchangeRemoteDpopAccessToken({ | ||
| 528 | httpBaseUrl: connect.endpoint.httpBaseUrl, | ||
| 529 | credential: connect.credential, | ||
| 530 | dpopProof: bootstrapDpop, | ||
| 531 | clientMetadata: mobileAuthClientMetadata(), | ||
| 532 | }).pipe( | ||
| 533 | Effect.mapError( | ||
| 534 | cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), | ||
| 535 | ), | ||
| 536 | ); | ||
| 537 | const pairingUrl = new URL(connect.endpoint.httpBaseUrl); | ||
| 538 | pairingUrl.hash = new URLSearchParams([["token", connect.credential]]).toString(); | ||
| 539 | |||
| 540 | return { | ||
| 541 | environmentId: descriptor.environmentId, | ||
| 542 | environmentLabel: descriptor.label, | ||
| 543 | pairingUrl: stripPairingTokenFromUrl(pairingUrl).toString(), | ||
| 544 | displayUrl: connect.endpoint.httpBaseUrl, | ||
| 545 | httpBaseUrl: connect.endpoint.httpBaseUrl, | ||
| 546 | wsBaseUrl: connect.endpoint.wsBaseUrl, | ||
| 547 | bearerToken: null, | ||
| 548 | authenticationMethod: "dpop", | ||
| 549 | dpopAccessToken: bootstrap.access_token, | ||
| 550 | relayManaged: true, | ||
| 551 | }; | ||
| 552 | }); | ||
| 553 | } | ||
| 554 | |||
| 555 | export function connectCloudEnvironment(input: { | ||
| 556 | readonly clerkToken: string; | ||
| 557 | readonly environment: RelayClientEnvironmentRecord; | ||
| 558 | }): Effect.Effect< | ||
| 559 | SavedRemoteConnection, | ||
| 560 | CloudEnvironmentLinkError, | ||
| 561 | HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner | ||
| 562 | > { | ||
| 563 | return connectRelayManagedEnvironment({ | ||
| 564 | clerkToken: input.clerkToken, | ||
| 565 | environmentId: input.environment.environmentId, | ||
| 566 | expectedEnvironment: input.environment, | ||
| 567 | }); | ||
| 568 | } | ||
| 569 | |||
| 570 | export function refreshCloudEnvironmentConnection(input: { | ||
| 571 | readonly clerkToken: string; | ||
| 572 | readonly connection: SavedRemoteConnection; | ||
| 573 | }): Effect.Effect< | ||
| 574 | SavedRemoteConnection, | ||
| 575 | CloudEnvironmentLinkError, | ||
| 576 | HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner | ||
| 577 | > { | ||
| 578 | return connectRelayManagedEnvironment({ | ||
| 579 | clerkToken: input.clerkToken, | ||
| 580 | environmentId: input.connection.environmentId, | ||
| 581 | }); | ||
| 582 | } | ||
apps/mobile/src/features/cloud/managedRelayLayer.ts created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | import { | ||
| 2 | managedRelayClientLayer, | ||
| 3 | ManagedRelayDpopSigner, | ||
| 4 | ManagedRelayDpopSignerError, | ||
| 5 | } from "@t3tools/client-runtime"; | ||
| 6 | import { RelayMobileClientId } from "@t3tools/contracts/relay"; | ||
| 7 | import * as Crypto from "effect/Crypto"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | |||
| 11 | import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; | ||
| 12 | |||
| 13 | const mobileRelayDpopSignerLayer = Layer.effect( | ||
| 14 | ManagedRelayDpopSigner, | ||
| 15 | Effect.gen(function* () { | ||
| 16 | const crypto = yield* Crypto.Crypto; | ||
| 17 | return ManagedRelayDpopSigner.of({ | ||
| 18 | thumbprint: Effect.suspend(() => | ||
| 19 | loadOrCreateDpopProofKeyPair().pipe( | ||
| 20 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 21 | Effect.map((proofKey) => proofKey.thumbprint), | ||
| 22 | Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), | ||
| 23 | ), | ||
| 24 | ), | ||
| 25 | createProof: (input) => | ||
| 26 | Effect.gen(function* () { | ||
| 27 | const proofKey = yield* loadOrCreateDpopProofKeyPair().pipe( | ||
| 28 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 29 | ); | ||
| 30 | return yield* createDpopProof({ ...input, proofKey }).pipe( | ||
| 31 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 32 | Effect.map((proof) => proof.proof), | ||
| 33 | ); | ||
| 34 | }).pipe(Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause }))), | ||
| 35 | }); | ||
| 36 | }), | ||
| 37 | ); | ||
| 38 | |||
| 39 | export const mobileManagedRelayClientLayer = (relayUrl: string) => | ||
| 40 | managedRelayClientLayer({ relayUrl, clientId: RelayMobileClientId }).pipe( | ||
| 41 | Layer.provideMerge(mobileRelayDpopSignerLayer), | ||
| 42 | ); | ||
apps/mobile/src/features/cloud/managedRelayState.ts created+88| ... | @@ -0,0 +1,88 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | createManagedRelayQueryManager, | ||
| 4 | ManagedRelayClient, | ||
| 5 | managedRelaySessionAtom, | ||
| 6 | readManagedRelaySnapshotState, | ||
| 7 | } from "@t3tools/client-runtime"; | ||
| 8 | import type { | ||
| 9 | RelayClientEnvironmentRecord, | ||
| 10 | RelayEnvironmentStatusResponse, | ||
| 11 | } from "@t3tools/contracts/relay"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 16 | import { useCallback } from "react"; | ||
| 17 | |||
| 18 | import { mobileRuntime } from "../../lib/runtime"; | ||
| 19 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 20 | |||
| 21 | const managedRelayAtomRuntime = Atom.runtime( | ||
| 22 | Layer.effect( | ||
| 23 | ManagedRelayClient, | ||
| 24 | mobileRuntime.contextEffect.pipe( | ||
| 25 | Effect.map((context) => Context.get(context, ManagedRelayClient)), | ||
| 26 | ), | ||
| 27 | ), | ||
| 28 | ); | ||
| 29 | |||
| 30 | export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); | ||
| 31 | |||
| 32 | const EMPTY_ENVIRONMENTS_ATOM = Atom.make( | ||
| 33 | AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]), | ||
| 34 | ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null")); | ||
| 35 | |||
| 36 | const EMPTY_ENVIRONMENT_STATUS_ATOM = Atom.make( | ||
| 37 | AsyncResult.initial<RelayEnvironmentStatusResponse, never>(false), | ||
| 38 | ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environment-status:null")); | ||
| 39 | |||
| 40 | export function useManagedRelayEnvironments() { | ||
| 41 | const session = useAtomValue(managedRelaySessionAtom); | ||
| 42 | const accountId = session?.accountId ?? null; | ||
| 43 | const atom = accountId | ||
| 44 | ? managedRelayQueryManager.environmentsAtom(accountId) | ||
| 45 | : EMPTY_ENVIRONMENTS_ATOM; | ||
| 46 | const result = useAtomValue(atom); | ||
| 47 | const refresh = useCallback(() => { | ||
| 48 | if (accountId) { | ||
| 49 | managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); | ||
| 50 | } | ||
| 51 | }, [accountId]); | ||
| 52 | |||
| 53 | return { | ||
| 54 | ...readManagedRelaySnapshotState(result), | ||
| 55 | accountId, | ||
| 56 | refresh, | ||
| 57 | }; | ||
| 58 | } | ||
| 59 | |||
| 60 | export function useManagedRelayEnvironmentStatus(environment: RelayClientEnvironmentRecord) { | ||
| 61 | const session = useAtomValue(managedRelaySessionAtom); | ||
| 62 | const accountId = session?.accountId ?? null; | ||
| 63 | const atom = accountId | ||
| 64 | ? managedRelayQueryManager.environmentStatusAtom({ accountId, environment }) | ||
| 65 | : EMPTY_ENVIRONMENT_STATUS_ATOM; | ||
| 66 | const result = useAtomValue(atom); | ||
| 67 | const refresh = useCallback(() => { | ||
| 68 | if (accountId) { | ||
| 69 | managedRelayQueryManager.refreshEnvironmentStatus(appAtomRegistry, { | ||
| 70 | accountId, | ||
| 71 | environment, | ||
| 72 | }); | ||
| 73 | } | ||
| 74 | }, [accountId, environment]); | ||
| 75 | |||
| 76 | return { | ||
| 77 | ...readManagedRelaySnapshotState(result), | ||
| 78 | accountId, | ||
| 79 | refresh, | ||
| 80 | }; | ||
| 81 | } | ||
| 82 | |||
| 83 | export function refreshManagedRelayEnvironments(): void { | ||
| 84 | const session = appAtomRegistry.get(managedRelaySessionAtom); | ||
| 85 | if (session) { | ||
| 86 | managedRelayQueryManager.refreshEnvironments(appAtomRegistry, session.accountId); | ||
| 87 | } | ||
| 88 | } | ||
apps/mobile/src/features/cloud/publicConfig.test.ts created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | import { describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | import { resolveCloudPublicConfig } from "./publicConfig"; | ||
| 4 | |||
| 5 | vi.mock("expo-constants", () => ({ | ||
| 6 | default: { | ||
| 7 | expoConfig: { | ||
| 8 | extra: {}, | ||
| 9 | }, | ||
| 10 | }, | ||
| 11 | })); | ||
| 12 | |||
| 13 | describe("resolveCloudPublicConfig", () => { | ||
| 14 | it("returns no cloud configuration for an unconfigured build", () => { | ||
| 15 | expect(resolveCloudPublicConfig({})).toEqual({ | ||
| 16 | clerkPublishableKey: null, | ||
| 17 | clerkJwtTemplate: null, | ||
| 18 | relayUrl: null, | ||
| 19 | }); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("normalizes statically injected cloud configuration", () => { | ||
| 23 | expect( | ||
| 24 | resolveCloudPublicConfig({ | ||
| 25 | clerk: { publishableKey: " pk_test_example ", jwtTemplate: " t3-relay " }, | ||
| 26 | relay: { url: " https://relay.example.test/// " }, | ||
| 27 | }), | ||
| 28 | ).toEqual({ | ||
| 29 | clerkPublishableKey: "pk_test_example", | ||
| 30 | clerkJwtTemplate: "t3-relay", | ||
| 31 | relayUrl: "https://relay.example.test", | ||
| 32 | }); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("rejects an insecure relay URL", () => { | ||
| 36 | expect( | ||
| 37 | resolveCloudPublicConfig({ | ||
| 38 | clerk: { publishableKey: "pk_test_example", jwtTemplate: "t3-relay" }, | ||
| 39 | relay: { url: "http://relay.example.test" }, | ||
| 40 | }), | ||
| 41 | ).toEqual({ | ||
| 42 | clerkPublishableKey: "pk_test_example", | ||
| 43 | clerkJwtTemplate: "t3-relay", | ||
| 44 | relayUrl: null, | ||
| 45 | }); | ||
| 46 | }); | ||
| 47 | }); | ||
apps/mobile/src/features/cloud/publicConfig.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import Constants from "expo-constants"; | ||
| 2 | import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; | ||
| 3 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 4 | |||
| 5 | type ExpoExtra = Readonly<Record<string, unknown>> | undefined; | ||
| 6 | |||
| 7 | export interface CloudPublicConfig { | ||
| 8 | readonly clerkPublishableKey: string | null; | ||
| 9 | readonly clerkJwtTemplate: string | null; | ||
| 10 | readonly relayUrl: string | null; | ||
| 11 | } | ||
| 12 | |||
| 13 | function trimNonEmpty(value: unknown): string | null { | ||
| 14 | return typeof value === "string" && value.trim() ? value.trim() : null; | ||
| 15 | } | ||
| 16 | |||
| 17 | export function resolveCloudPublicConfig(extra: ExpoExtra = Constants.expoConfig?.extra) { | ||
| 18 | const clerk = extra?.clerk as | ||
| 19 | | { readonly publishableKey?: unknown; readonly jwtTemplate?: unknown } | ||
| 20 | | undefined; | ||
| 21 | const relay = extra?.relay as { readonly url?: unknown } | undefined; | ||
| 22 | |||
| 23 | return { | ||
| 24 | clerkPublishableKey: trimNonEmpty(clerk?.publishableKey), | ||
| 25 | clerkJwtTemplate: trimNonEmpty(clerk?.jwtTemplate), | ||
| 26 | relayUrl: normalizeSecureRelayUrl(trimNonEmpty(relay?.url) ?? ""), | ||
| 27 | } satisfies CloudPublicConfig; | ||
| 28 | } | ||
| 29 | |||
| 30 | export function hasCloudPublicConfig(): boolean { | ||
| 31 | const config = resolveCloudPublicConfig(); | ||
| 32 | return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); | ||
| 33 | } | ||
| 34 | |||
| 35 | export function resolveRelayClerkTokenOptions() { | ||
| 36 | const { clerkJwtTemplate } = resolveCloudPublicConfig(); | ||
| 37 | if (!clerkJwtTemplate) { | ||
| 38 | throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); | ||
| 39 | } | ||
| 40 | return relayClerkTokenOptions(clerkJwtTemplate); | ||
| 41 | } | ||
apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts created+134| ... | @@ -0,0 +1,134 @@ | ||
| 1 | import { getClerkInstance } from "@clerk/expo"; | ||
| 2 | import { tokenCache } from "@clerk/expo/token-cache"; | ||
| 3 | import * as Data from "effect/Data"; | ||
| 4 | import { useCallback, useRef } from "react"; | ||
| 5 | import type { TurboModule } from "react-native"; | ||
| 6 | import { TurboModuleRegistry } from "react-native"; | ||
| 7 | |||
| 8 | const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt"; | ||
| 9 | |||
| 10 | interface NativeClerkModule extends TurboModule { | ||
| 11 | readonly getClientToken?: () => Promise<string | null>; | ||
| 12 | readonly presentAuth?: (options: { | ||
| 13 | readonly dismissable: boolean; | ||
| 14 | readonly mode: "signInOrUp"; | ||
| 15 | }) => Promise<NativeAuthResult | null>; | ||
| 16 | } | ||
| 17 | |||
| 18 | interface NativeAuthResult { | ||
| 19 | readonly cancelled?: boolean; | ||
| 20 | readonly session?: { | ||
| 21 | readonly id?: string; | ||
| 22 | }; | ||
| 23 | readonly sessionId?: string; | ||
| 24 | } | ||
| 25 | |||
| 26 | interface ClerkWithNativeSync { | ||
| 27 | readonly __internal_reloadInitialResources?: () => Promise<void>; | ||
| 28 | readonly setActive?: (params: { readonly session: string }) => Promise<void>; | ||
| 29 | } | ||
| 30 | |||
| 31 | const NativeClerk = TurboModuleRegistry.get<NativeClerkModule>("ClerkExpo"); | ||
| 32 | |||
| 33 | class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{ | ||
| 34 | readonly message: string; | ||
| 35 | readonly cause?: unknown; | ||
| 36 | }> {} | ||
| 37 | |||
| 38 | async function syncNativeSession(sessionId: string): Promise<void> { | ||
| 39 | const getClientToken = NativeClerk?.getClientToken; | ||
| 40 | let nativeClientToken: string | null = null; | ||
| 41 | if (getClientToken) { | ||
| 42 | try { | ||
| 43 | nativeClientToken = await getClientToken(); | ||
| 44 | } catch (cause) { | ||
| 45 | throw new NativeClerkAuthError({ | ||
| 46 | message: "Could not read native Clerk client token.", | ||
| 47 | cause, | ||
| 48 | }); | ||
| 49 | } | ||
| 50 | } | ||
| 51 | if (nativeClientToken) { | ||
| 52 | const saveToken = tokenCache?.saveToken; | ||
| 53 | if (saveToken) { | ||
| 54 | try { | ||
| 55 | await saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); | ||
| 56 | } catch (cause) { | ||
| 57 | throw new NativeClerkAuthError({ | ||
| 58 | message: "Could not save native Clerk client token.", | ||
| 59 | cause, | ||
| 60 | }); | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | const clerk = getClerkInstance(); | ||
| 66 | const clerkWithNativeSync = clerk as ClerkWithNativeSync; | ||
| 67 | const reloadInitialResources = clerkWithNativeSync.__internal_reloadInitialResources; | ||
| 68 | if (reloadInitialResources) { | ||
| 69 | try { | ||
| 70 | await reloadInitialResources(); | ||
| 71 | } catch (cause) { | ||
| 72 | throw new NativeClerkAuthError({ | ||
| 73 | message: "Could not reload Clerk resources after native auth.", | ||
| 74 | cause, | ||
| 75 | }); | ||
| 76 | } | ||
| 77 | } | ||
| 78 | const setActive = clerkWithNativeSync.setActive; | ||
| 79 | if (setActive) { | ||
| 80 | try { | ||
| 81 | await setActive({ session: sessionId }); | ||
| 82 | } catch (cause) { | ||
| 83 | throw new NativeClerkAuthError({ | ||
| 84 | message: "Could not activate native Clerk session.", | ||
| 85 | cause, | ||
| 86 | }); | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | export function useNativeClerkAuthModal() { | ||
| 92 | const presentingRef = useRef(false); | ||
| 93 | |||
| 94 | const presentAuth = useCallback(async (): Promise<void> => { | ||
| 95 | if (presentingRef.current || !NativeClerk?.presentAuth) { | ||
| 96 | return; | ||
| 97 | } | ||
| 98 | |||
| 99 | presentingRef.current = true; | ||
| 100 | const presentNativeAuth = NativeClerk.presentAuth; | ||
| 101 | try { | ||
| 102 | // Clerk's iOS AuthView is not inline. It presents this same native modal | ||
| 103 | // internally; call the presenter directly so Expo Router does not render | ||
| 104 | // an empty formSheet behind it. | ||
| 105 | let result: NativeAuthResult | null; | ||
| 106 | try { | ||
| 107 | result = await presentNativeAuth({ | ||
| 108 | dismissable: true, | ||
| 109 | mode: "signInOrUp", | ||
| 110 | }); | ||
| 111 | } catch (cause) { | ||
| 112 | throw new NativeClerkAuthError({ | ||
| 113 | message: "Native Clerk auth presentation failed.", | ||
| 114 | cause, | ||
| 115 | }); | ||
| 116 | } | ||
| 117 | const sessionId = result?.sessionId ?? result?.session?.id ?? null; | ||
| 118 | if (sessionId && !result?.cancelled) { | ||
| 119 | await syncNativeSession(sessionId); | ||
| 120 | } | ||
| 121 | } catch (error) { | ||
| 122 | if (__DEV__) { | ||
| 123 | console.error("[useNativeClerkAuthModal] presentAuth failed:", error); | ||
| 124 | } | ||
| 125 | } finally { | ||
| 126 | presentingRef.current = false; | ||
| 127 | } | ||
| 128 | }, []); | ||
| 129 | |||
| 130 | return { | ||
| 131 | isAvailable: !!NativeClerk?.presentAuth, | ||
| 132 | presentAuth, | ||
| 133 | }; | ||
| 134 | } | ||
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+56-46| ... | @@ -113,56 +113,66 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -113,56 +113,66 @@ export function ConnectionEnvironmentRow(props: { |
| 113 | exiting={FadeOut.duration(150)} | 113 | exiting={FadeOut.duration(150)} |
| 114 | className="gap-3 px-4 pb-4" | 114 | className="gap-3 px-4 pb-4" |
| 115 | > | 115 | > |
| 116 | <View className="gap-1.5"> | 116 | {props.environment.isRelayManaged ? ( |
| 117 | <Text | 117 | <Text className="text-[13px] leading-[18px] text-foreground-muted"> |
| 118 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" | 118 | Managed by T3 Cloud. Tunnel details update automatically. |
| 119 | style={{ letterSpacing: 0.8 }} | ||
| 120 | > | ||
| 121 | Label | ||
| 122 | </Text> | 119 | </Text> |
| 123 | <TextInput | 120 | ) : ( |
| 124 | autoCapitalize="words" | 121 | <> |
| 125 | autoCorrect={false} | 122 | <View className="gap-1.5"> |
| 126 | placeholder="My MacBook" | 123 | <Text |
| 127 | placeholderTextColor={placeholderColor} | 124 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" |
| 128 | value={label} | 125 | style={{ letterSpacing: 0.8 }} |
| 129 | onChangeText={setLabel} | 126 | > |
| 130 | className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" | 127 | Label |
| 131 | /> | 128 | </Text> |
| 132 | </View> | 129 | <TextInput |
| 130 | autoCapitalize="words" | ||
| 131 | autoCorrect={false} | ||
| 132 | placeholder="My MacBook" | ||
| 133 | placeholderTextColor={placeholderColor} | ||
| 134 | value={label} | ||
| 135 | onChangeText={setLabel} | ||
| 136 | className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" | ||
| 137 | /> | ||
| 138 | </View> | ||
| 133 | 139 | ||
| 134 | <View className="gap-1.5"> | 140 | <View className="gap-1.5"> |
| 135 | <Text | 141 | <Text |
| 136 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" | 142 | className="text-[11px] font-t3-bold uppercase text-foreground-muted" |
| 137 | style={{ letterSpacing: 0.8 }} | 143 | style={{ letterSpacing: 0.8 }} |
| 138 | > | 144 | > |
| 139 | URL | 145 | URL |
| 140 | </Text> | 146 | </Text> |
| 141 | <TextInput | 147 | <TextInput |
| 142 | autoCapitalize="none" | 148 | autoCapitalize="none" |
| 143 | autoCorrect={false} | 149 | autoCorrect={false} |
| 144 | keyboardType="url" | 150 | keyboardType="url" |
| 145 | placeholder="192.168.1.100:8080" | 151 | placeholder="192.168.1.100:8080" |
| 146 | placeholderTextColor={placeholderColor} | 152 | placeholderTextColor={placeholderColor} |
| 147 | value={url} | 153 | value={url} |
| 148 | onChangeText={setUrl} | 154 | onChangeText={setUrl} |
| 149 | className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" | 155 | className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground" |
| 150 | /> | 156 | /> |
| 151 | </View> | 157 | </View> |
| 158 | </> | ||
| 159 | )} | ||
| 152 | 160 | ||
| 153 | <View className="flex-row gap-2"> | 161 | <View className="flex-row justify-end gap-2"> |
| 154 | <Pressable | 162 | {props.environment.isRelayManaged ? null : ( |
| 155 | className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] bg-primary px-3.5 py-2.5 active:opacity-70" | 163 | <Pressable |
| 156 | onPress={handleSave} | 164 | className="min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-[14px] bg-primary px-3.5 py-2.5 active:opacity-70" |
| 157 | > | 165 | onPress={handleSave} |
| 158 | <SymbolView name="checkmark" size={13} tintColor={primaryFg} type="monochrome" /> | ||
| 159 | <Text | ||
| 160 | className="text-[12px] font-t3-bold uppercase text-primary-foreground" | ||
| 161 | style={{ letterSpacing: 0.8 }} | ||
| 162 | > | 166 | > |
| 163 | Save | 167 | <SymbolView name="checkmark" size={13} tintColor={primaryFg} type="monochrome" /> |
| 164 | </Text> | 168 | <Text |
| 165 | </Pressable> | 169 | className="text-[12px] font-t3-bold uppercase text-primary-foreground" |
| 170 | style={{ letterSpacing: 0.8 }} | ||
| 171 | > | ||
| 172 | Save | ||
| 173 | </Text> | ||
| 174 | </Pressable> | ||
| 175 | )} | ||
| 166 | 176 | ||
| 167 | <Pressable | 177 | <Pressable |
| 168 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70" | 178 | className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-input-border bg-input active:opacity-70" |
apps/mobile/src/features/projects/AddProjectScreen.tsx+7-22| ... | @@ -41,7 +41,6 @@ import { | ... | @@ -41,7 +41,6 @@ import { |
| 41 | refreshSourceControlDiscoveryForEnvironment, | 41 | refreshSourceControlDiscoveryForEnvironment, |
| 42 | useSourceControlDiscovery, | 42 | useSourceControlDiscovery, |
| 43 | } from "../../state/use-source-control-discovery"; | 43 | } from "../../state/use-source-control-discovery"; |
| 44 | import { NewTaskSheetHeader } from "../threads/NewTaskSheetHeader"; | ||
| 45 | 44 | ||
| 46 | interface EnvironmentOption { | 45 | interface EnvironmentOption { |
| 47 | readonly environmentId: EnvironmentId; | 46 | readonly environmentId: EnvironmentId; |
| ... | @@ -105,25 +104,11 @@ function SectionTitle(props: { readonly children: string }) { | ... | @@ -105,25 +104,11 @@ function SectionTitle(props: { readonly children: string }) { |
| 105 | ); | 104 | ); |
| 106 | } | 105 | } |
| 107 | 106 | ||
| 108 | function AddProjectShell(props: { | 107 | function AddProjectShell(props: { readonly children: ReactNode }) { |
| 109 | readonly title: string; | ||
| 110 | readonly closeToNew?: boolean; | ||
| 111 | readonly children: ReactNode; | ||
| 112 | }) { | ||
| 113 | const router = useRouter(); | ||
| 114 | const insets = useSafeAreaInsets(); | 108 | const insets = useSafeAreaInsets(); |
| 115 | 109 | ||
| 116 | return ( | 110 | return ( |
| 117 | <View className="flex-1 bg-sheet"> | 111 | <View className="flex-1 bg-sheet"> |
| 118 | <NewTaskSheetHeader | ||
| 119 | eyebrow="New project" | ||
| 120 | title={props.title} | ||
| 121 | control={{ | ||
| 122 | icon: props.closeToNew ? "xmark" : "chevron.left", | ||
| 123 | onPress: () => router.back(), | ||
| 124 | }} | ||
| 125 | /> | ||
| 126 | |||
| 127 | <ScrollView | 112 | <ScrollView |
| 128 | keyboardShouldPersistTaps="handled" | 113 | keyboardShouldPersistTaps="handled" |
| 129 | contentInsetAdjustmentBehavior="automatic" | 114 | contentInsetAdjustmentBehavior="automatic" |
| ... | @@ -179,9 +164,9 @@ function ListRow(props: { | ... | @@ -179,9 +164,9 @@ function ListRow(props: { |
| 179 | {props.icon} | 164 | {props.icon} |
| 180 | </View> | 165 | </View> |
| 181 | <View className="flex-1 gap-0.5"> | 166 | <View className="flex-1 gap-0.5"> |
| 182 | <Text className="text-[15px] font-t3-bold">{props.title}</Text> | 167 | <Text className="text-[16px] leading-[21px] font-t3-bold">{props.title}</Text> |
| 183 | {props.subtitle ? ( | 168 | {props.subtitle ? ( |
| 184 | <Text className="text-[12px] leading-[16px] text-foreground-muted" numberOfLines={2}> | 169 | <Text className="text-[13px] leading-[17px] text-foreground-muted" numberOfLines={2}> |
| 185 | {props.subtitle} | 170 | {props.subtitle} |
| 186 | </Text> | 171 | </Text> |
| 187 | ) : null} | 172 | ) : null} |
| ... | @@ -363,7 +348,7 @@ export function AddProjectSourceScreen() { | ... | @@ -363,7 +348,7 @@ export function AddProjectSourceScreen() { |
| 363 | }, [selectedEnvironment]); | 348 | }, [selectedEnvironment]); |
| 364 | 349 | ||
| 365 | return ( | 350 | return ( |
| 366 | <AddProjectShell title="Select source" closeToNew> | 351 | <AddProjectShell> |
| 367 | {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null} | 352 | {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null} |
| 368 | 353 | ||
| 369 | {environmentOptions.length > 1 ? ( | 354 | {environmentOptions.length > 1 ? ( |
| ... | @@ -561,7 +546,7 @@ export function AddProjectRepositoryScreen() { | ... | @@ -561,7 +546,7 @@ export function AddProjectRepositoryScreen() { |
| 561 | }, [environment, isSubmitting, repositoryInput, router, source]); | 546 | }, [environment, isSubmitting, repositoryInput, router, source]); |
| 562 | 547 | ||
| 563 | return ( | 548 | return ( |
| 564 | <AddProjectShell title={source === "url" ? "Git URL" : addProjectRemoteSourceLabel(source)}> | 549 | <AddProjectShell> |
| 565 | {error ? <ErrorBanner message={error} /> : null} | 550 | {error ? <ErrorBanner message={error} /> : null} |
| 566 | <TextInput | 551 | <TextInput |
| 567 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-[15px] leading-[20px]" | 552 | className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-[15px] leading-[20px]" |
| ... | @@ -711,7 +696,7 @@ export function AddProjectLocalFolderScreen() { | ... | @@ -711,7 +696,7 @@ export function AddProjectLocalFolderScreen() { |
| 711 | }, [createProject, environment, isSubmitting, pathInput]); | 696 | }, [createProject, environment, isSubmitting, pathInput]); |
| 712 | 697 | ||
| 713 | return ( | 698 | return ( |
| 714 | <AddProjectShell title="Local folder"> | 699 | <AddProjectShell> |
| 715 | {error ? <ErrorBanner message={error} /> : null} | 700 | {error ? <ErrorBanner message={error} /> : null} |
| 716 | {environment ? ( | 701 | {environment ? ( |
| 717 | <> | 702 | <> |
| ... | @@ -790,7 +775,7 @@ export function AddProjectDestinationScreen() { | ... | @@ -790,7 +775,7 @@ export function AddProjectDestinationScreen() { |
| 790 | }, [createProject, environment, isSubmitting, pathInput, remoteUrl]); | 775 | }, [createProject, environment, isSubmitting, pathInput, remoteUrl]); |
| 791 | 776 | ||
| 792 | return ( | 777 | return ( |
| 793 | <AddProjectShell title="Clone destination"> | 778 | <AddProjectShell> |
| 794 | {error ? <ErrorBanner message={error} /> : null} | 779 | {error ? <ErrorBanner message={error} /> : null} |
| 795 | {repositoryTitle ? ( | 780 | {repositoryTitle ? ( |
| 796 | <View className="rounded-[24px] bg-card px-4 py-3"> | 781 | <View className="rounded-[24px] bg-card px-4 py-3"> |
apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx+4-3| ... | @@ -238,17 +238,18 @@ export function ReviewCommentComposerSheet() { | ... | @@ -238,17 +238,18 @@ export function ReviewCommentComposerSheet() { |
| 238 | <View className="min-h-0 flex-1 gap-2"> | 238 | <View className="min-h-0 flex-1 gap-2"> |
| 239 | <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text> | 239 | <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text> |
| 240 | <View className="min-h-[132px] flex-1 overflow-hidden rounded-[20px] border border-border bg-card"> | 240 | <View className="min-h-[132px] flex-1 overflow-hidden rounded-[20px] border border-border bg-card"> |
| 241 | <View className="flex-1 px-4 pt-3.5"> | 241 | <View className="min-h-0 flex-1 px-4 pt-3.5"> |
| 242 | <TextInputWrapper onPaste={handleNativePaste} style={{ flex: 1 }}> | 242 | <TextInputWrapper onPaste={handleNativePaste} style={{ flex: 1, minHeight: 0 }}> |
| 243 | <TextInput | 243 | <TextInput |
| 244 | autoFocus | 244 | autoFocus |
| 245 | multiline | 245 | multiline |
| 246 | scrollEnabled | ||
| 246 | placeholder="Leave a comment..." | 247 | placeholder="Leave a comment..." |
| 247 | textAlignVertical="top" | 248 | textAlignVertical="top" |
| 248 | value={commentText} | 249 | value={commentText} |
| 249 | onChangeText={setCommentText} | 250 | onChangeText={setCommentText} |
| 250 | className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-[15px]" | 251 | className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-[15px]" |
| 251 | style={{ flex: 1 }} | 252 | style={{ flex: 1, minHeight: 0 }} |
| 252 | /> | 253 | /> |
| 253 | </TextInputWrapper> | 254 | </TextInputWrapper> |
| 254 | </View> | 255 | </View> |
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+40-242| ... | @@ -8,16 +8,7 @@ import type { KnownTerminalSession } from "@t3tools/client-runtime"; | ... | @@ -8,16 +8,7 @@ import type { KnownTerminalSession } from "@t3tools/client-runtime"; |
| 8 | import { SymbolView } from "expo-symbols"; | 8 | import { SymbolView } from "expo-symbols"; |
| 9 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; | 9 | import { Stack, useLocalSearchParams, useRouter } from "expo-router"; |
| 10 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 10 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 11 | import { | 11 | import { Pressable, Text as RNText, View, useColorScheme } from "react-native"; |
| 12 | Pressable, | ||
| 13 | ScrollView, | ||
| 14 | Text as RNText, | ||
| 15 | View, | ||
| 16 | useColorScheme, | ||
| 17 | type LayoutChangeEvent, | ||
| 18 | type NativeScrollEvent, | ||
| 19 | type NativeSyntheticEvent, | ||
| 20 | } from "react-native"; | ||
| 21 | import { | 12 | import { |
| 22 | KeyboardController, | 13 | KeyboardController, |
| 23 | KeyboardEvents, | 14 | KeyboardEvents, |
| ... | @@ -25,6 +16,11 @@ import { | ... | @@ -25,6 +16,11 @@ import { |
| 25 | useKeyboardState, | 16 | useKeyboardState, |
| 26 | } from "react-native-keyboard-controller"; | 17 | } from "react-native-keyboard-controller"; |
| 27 | 18 | ||
| 19 | import { | ||
| 20 | ComposerToolbarButton, | ||
| 21 | ComposerToolbarRow, | ||
| 22 | ComposerToolbarScroller, | ||
| 23 | } from "../../components/ComposerToolbarTrigger"; | ||
| 28 | import { EmptyState } from "../../components/EmptyState"; | 24 | import { EmptyState } from "../../components/EmptyState"; |
| 29 | import { GlassSurface } from "../../components/GlassSurface"; | 25 | import { GlassSurface } from "../../components/GlassSurface"; |
| 30 | import { LoadingScreen } from "../../components/LoadingScreen"; | 26 | import { LoadingScreen } from "../../components/LoadingScreen"; |
| ... | @@ -79,7 +75,6 @@ import { | ... | @@ -79,7 +75,6 @@ import { |
| 79 | const DEFAULT_TERMINAL_COLS = 80; | 75 | const DEFAULT_TERMINAL_COLS = 80; |
| 80 | const DEFAULT_TERMINAL_ROWS = 24; | 76 | const DEFAULT_TERMINAL_ROWS = 24; |
| 81 | const TERMINAL_ACCESSORY_HEIGHT = 52; | 77 | const TERMINAL_ACCESSORY_HEIGHT = 52; |
| 82 | const TERMINAL_ACCESSORY_FADE_WIDTH = 18; | ||
| 83 | 78 | ||
| 84 | type PendingModifier = "ctrl" | "meta"; | 79 | type PendingModifier = "ctrl" | "meta"; |
| 85 | type HostPlatform = "mac" | "linux" | "windows" | "unknown"; | 80 | type HostPlatform = "mac" | "linux" | "windows" | "unknown"; |
| ... | @@ -94,47 +89,6 @@ type TerminalToolbarAction = | ... | @@ -94,47 +89,6 @@ type TerminalToolbarAction = |
| 94 | readonly modifier: PendingModifier; | 89 | readonly modifier: PendingModifier; |
| 95 | }; | 90 | }; |
| 96 | 91 | ||
| 97 | function getTerminalStatusTone(input: { | ||
| 98 | readonly status: TerminalMenuSession["status"]; | ||
| 99 | readonly hasRunningSubprocess?: boolean; | ||
| 100 | }): { | ||
| 101 | readonly tintColor: string; | ||
| 102 | readonly textColor: string; | ||
| 103 | } { | ||
| 104 | if (input.status === "running") { | ||
| 105 | if (input.hasRunningSubprocess) { | ||
| 106 | return { | ||
| 107 | tintColor: "#fbbf24", | ||
| 108 | textColor: "#a3a3a3", | ||
| 109 | }; | ||
| 110 | } | ||
| 111 | |||
| 112 | return { | ||
| 113 | tintColor: "#34d399", | ||
| 114 | textColor: "#a3a3a3", | ||
| 115 | }; | ||
| 116 | } | ||
| 117 | |||
| 118 | if (input.status === "starting") { | ||
| 119 | return { | ||
| 120 | tintColor: "#f59e0b", | ||
| 121 | textColor: "#a3a3a3", | ||
| 122 | }; | ||
| 123 | } | ||
| 124 | |||
| 125 | if (input.status === "error") { | ||
| 126 | return { | ||
| 127 | tintColor: "#ef4444", | ||
| 128 | textColor: "#fca5a5", | ||
| 129 | }; | ||
| 130 | } | ||
| 131 | |||
| 132 | return { | ||
| 133 | tintColor: "#ef4444", | ||
| 134 | textColor: "#a3a3a3", | ||
| 135 | }; | ||
| 136 | } | ||
| 137 | |||
| 138 | function firstRouteParam(value: string | string[] | undefined): string | null { | 92 | function firstRouteParam(value: string | string[] | undefined): string | null { |
| 139 | if (Array.isArray(value)) { | 93 | if (Array.isArray(value)) { |
| 140 | return value[0] ?? null; | 94 | return value[0] ?? null; |
| ... | @@ -186,10 +140,6 @@ function applyCtrlModifier(input: string): string { | ... | @@ -186,10 +140,6 @@ function applyCtrlModifier(input: string): string { |
| 186 | return input; | 140 | return input; |
| 187 | } | 141 | } |
| 188 | 142 | ||
| 189 | function withAlpha(hexColor: string, alpha: string): string { | ||
| 190 | return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor; | ||
| 191 | } | ||
| 192 | |||
| 193 | function pickRunningTerminalSessionForBootstrap( | 143 | function pickRunningTerminalSessionForBootstrap( |
| 194 | sessions: ReadonlyArray<KnownTerminalSession>, | 144 | sessions: ReadonlyArray<KnownTerminalSession>, |
| 195 | ): KnownTerminalSession | null { | 145 | ): KnownTerminalSession | null { |
| ... | @@ -248,15 +198,6 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -248,15 +198,6 @@ export function ThreadTerminalRouteScreen() { |
| 248 | const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); | 198 | const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); |
| 249 | const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); | 199 | const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); |
| 250 | const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); | 200 | const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); |
| 251 | const toolbarScrollGeometryRef = useRef({ | ||
| 252 | contentWidth: 0, | ||
| 253 | offsetX: 0, | ||
| 254 | viewportWidth: 0, | ||
| 255 | }); | ||
| 256 | const [toolbarScrollEdges, setToolbarScrollEdges] = useState({ | ||
| 257 | showLeftFade: false, | ||
| 258 | showRightFade: false, | ||
| 259 | }); | ||
| 260 | const hasOpenedRef = useRef(false); | 201 | const hasOpenedRef = useRef(false); |
| 261 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 202 | const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 262 | const attachStreamLogCountRef = useRef(0); | 203 | const attachStreamLogCountRef = useRef(0); |
| ... | @@ -370,14 +311,6 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -370,14 +311,6 @@ export function ThreadTerminalRouteScreen() { |
| 370 | activeKnownSummary: activeKnownSession?.state.summary ?? null, | 311 | activeKnownSummary: activeKnownSession?.state.summary ?? null, |
| 371 | }; | 312 | }; |
| 372 | 313 | ||
| 373 | const terminalStatusTone = useMemo( | ||
| 374 | () => | ||
| 375 | getTerminalStatusTone({ | ||
| 376 | status: terminal.status, | ||
| 377 | hasRunningSubprocess: terminal.hasRunningSubprocess, | ||
| 378 | }), | ||
| 379 | [terminal.hasRunningSubprocess, terminal.status], | ||
| 380 | ); | ||
| 381 | const terminalTheme = getPierreTerminalTheme(appearanceScheme); | 314 | const terminalTheme = getPierreTerminalTheme(appearanceScheme); |
| 382 | const pendingModifier = | 315 | const pendingModifier = |
| 383 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; | 316 | pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; |
| ... | @@ -964,47 +897,6 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -964,47 +897,6 @@ export function ThreadTerminalRouteScreen() { |
| 964 | [handleClearTerminal, pendingModifier, terminalId, writeInput], | 897 | [handleClearTerminal, pendingModifier, terminalId, writeInput], |
| 965 | ); | 898 | ); |
| 966 | 899 | ||
| 967 | const updateToolbarScrollEdges = useCallback( | ||
| 968 | (patch: Partial<(typeof toolbarScrollGeometryRef)["current"]>) => { | ||
| 969 | const nextGeometry = { ...toolbarScrollGeometryRef.current, ...patch }; | ||
| 970 | toolbarScrollGeometryRef.current = nextGeometry; | ||
| 971 | const nextEdges = { | ||
| 972 | showLeftFade: nextGeometry.offsetX > 1, | ||
| 973 | showRightFade: | ||
| 974 | nextGeometry.offsetX + nextGeometry.viewportWidth < nextGeometry.contentWidth - 1, | ||
| 975 | }; | ||
| 976 | |||
| 977 | setToolbarScrollEdges((current) => | ||
| 978 | current.showLeftFade === nextEdges.showLeftFade && | ||
| 979 | current.showRightFade === nextEdges.showRightFade | ||
| 980 | ? current | ||
| 981 | : nextEdges, | ||
| 982 | ); | ||
| 983 | }, | ||
| 984 | [], | ||
| 985 | ); | ||
| 986 | |||
| 987 | const handleToolbarLayout = useCallback( | ||
| 988 | (event: LayoutChangeEvent) => { | ||
| 989 | updateToolbarScrollEdges({ viewportWidth: event.nativeEvent.layout.width }); | ||
| 990 | }, | ||
| 991 | [updateToolbarScrollEdges], | ||
| 992 | ); | ||
| 993 | |||
| 994 | const handleToolbarContentSizeChange = useCallback( | ||
| 995 | (contentWidth: number) => { | ||
| 996 | updateToolbarScrollEdges({ contentWidth }); | ||
| 997 | }, | ||
| 998 | [updateToolbarScrollEdges], | ||
| 999 | ); | ||
| 1000 | |||
| 1001 | const handleToolbarScroll = useCallback( | ||
| 1002 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { | ||
| 1003 | updateToolbarScrollEdges({ offsetX: event.nativeEvent.contentOffset.x }); | ||
| 1004 | }, | ||
| 1005 | [updateToolbarScrollEdges], | ||
| 1006 | ); | ||
| 1007 | |||
| 1008 | const handleDismissKeyboard = useCallback(() => { | 900 | const handleDismissKeyboard = useCallback(() => { |
| 1009 | setIsAccessoryDismissed(true); | 901 | setIsAccessoryDismissed(true); |
| 1010 | void KeyboardController.dismiss(); | 902 | void KeyboardController.dismiss(); |
| ... | @@ -1089,17 +981,7 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -1089,17 +981,7 @@ export function ThreadTerminalRouteScreen() { |
| 1089 | /> | 981 | /> |
| 1090 | 982 | ||
| 1091 | <Stack.Toolbar placement="right"> | 983 | <Stack.Toolbar placement="right"> |
| 1092 | <Stack.Toolbar.Menu | 984 | <Stack.Toolbar.Menu icon="terminal" title="Terminal options" separateBackground> |
| 1093 | icon="circle.fill" | ||
| 1094 | style={{ | ||
| 1095 | color: terminalStatusTone.textColor, | ||
| 1096 | fontFamily: "DMSans_700Bold", | ||
| 1097 | fontSize: 12, | ||
| 1098 | fontWeight: "700", | ||
| 1099 | }} | ||
| 1100 | tintColor={terminalStatusTone.tintColor} | ||
| 1101 | title="Terminal options" | ||
| 1102 | > | ||
| 1103 | <Stack.Toolbar.Label> | 985 | <Stack.Toolbar.Label> |
| 1104 | {getTerminalStatusLabel({ | 986 | {getTerminalStatusLabel({ |
| 1105 | status: terminal.status, | 987 | status: terminal.status, |
| ... | @@ -1170,127 +1052,43 @@ export function ThreadTerminalRouteScreen() { | ... | @@ -1170,127 +1052,43 @@ export function ThreadTerminalRouteScreen() { |
| 1170 | borderTopColor: terminalTheme.border, | 1052 | borderTopColor: terminalTheme.border, |
| 1171 | borderTopWidth: 1, | 1053 | borderTopWidth: 1, |
| 1172 | minHeight: TERMINAL_ACCESSORY_HEIGHT, | 1054 | minHeight: TERMINAL_ACCESSORY_HEIGHT, |
| 1173 | paddingBottom: 4, | ||
| 1174 | paddingHorizontal: 8, | ||
| 1175 | paddingTop: 4, | ||
| 1176 | }} | 1055 | }} |
| 1177 | > | 1056 | > |
| 1178 | <View style={{ alignItems: "center", flexDirection: "row", gap: 6 }}> | 1057 | <ComposerToolbarRow paddingBottom={4} paddingHorizontal={8} paddingTop={4}> |
| 1179 | <View style={{ flex: 1, position: "relative" }}> | 1058 | <ComposerToolbarScroller |
| 1180 | <ScrollView | 1059 | contentPaddingRight={2} |
| 1181 | horizontal | 1060 | fadeOpaque={terminalTheme.background} |
| 1182 | contentContainerStyle={{ alignItems: "center", gap: 6, paddingRight: 2 }} | 1061 | fadeTransparent={`${terminalTheme.background}00`} |
| 1183 | onContentSizeChange={handleToolbarContentSizeChange} | 1062 | > |
| 1184 | onLayout={handleToolbarLayout} | 1063 | {terminalToolbarActions.map((action) => { |
| 1185 | onScroll={handleToolbarScroll} | 1064 | const active = |
| 1186 | scrollEventThrottle={16} | 1065 | action.kind === "modifier" && pendingModifier === action.modifier; |
| 1187 | showsHorizontalScrollIndicator={false} | 1066 | |
| 1188 | > | 1067 | return ( |
| 1189 | {terminalToolbarActions.map((action) => { | 1068 | <ComposerToolbarButton |
| 1190 | const active = | 1069 | key={action.key} |
| 1191 | action.kind === "modifier" && pendingModifier === action.modifier; | 1070 | active={active} |
| 1192 | 1071 | label={action.label} | |
| 1193 | return ( | 1072 | maxWidth={120} |
| 1194 | <Pressable | 1073 | minWidth={action.label.length > 1 ? 56 : 44} |
| 1195 | key={action.key} | 1074 | onPress={() => handleToolbarActionPress(action)} |
| 1196 | onPress={() => handleToolbarActionPress(action)} | 1075 | showChevron={false} |
| 1197 | style={({ pressed }) => ({ | 1076 | textTransform={ |
| 1198 | alignItems: "center", | 1077 | action.kind === "modifier" || action.kind === "clear" |
| 1199 | backgroundColor: active | 1078 | ? "uppercase" |
| 1200 | ? withAlpha( | 1079 | : "none" |
| 1201 | terminalTheme.palette[10] ?? terminalTheme.foreground, | 1080 | } |
| 1202 | "2e", | 1081 | /> |
| 1203 | ) | 1082 | ); |
| 1204 | : pressed | 1083 | })} |
| 1205 | ? withAlpha(terminalTheme.foreground, "1f") | 1084 | </ComposerToolbarScroller> |
| 1206 | : withAlpha(terminalTheme.foreground, "12"), | 1085 | <ComposerToolbarButton |
| 1207 | borderColor: active | ||
| 1208 | ? withAlpha( | ||
| 1209 | terminalTheme.palette[10] ?? terminalTheme.foreground, | ||
| 1210 | "52", | ||
| 1211 | ) | ||
| 1212 | : terminalTheme.border, | ||
| 1213 | borderRadius: 12, | ||
| 1214 | borderWidth: 1, | ||
| 1215 | justifyContent: "center", | ||
| 1216 | minWidth: action.label.length > 1 ? 46 : 38, | ||
| 1217 | paddingHorizontal: 11, | ||
| 1218 | paddingVertical: 8, | ||
| 1219 | })} | ||
| 1220 | > | ||
| 1221 | <RNText | ||
| 1222 | style={{ | ||
| 1223 | color: active | ||
| 1224 | ? (terminalTheme.palette[10] ?? terminalTheme.foreground) | ||
| 1225 | : terminalTheme.foreground, | ||
| 1226 | fontFamily: "DMSans_700Bold", | ||
| 1227 | fontSize: 12, | ||
| 1228 | fontWeight: "700", | ||
| 1229 | textTransform: | ||
| 1230 | action.kind === "modifier" || action.kind === "clear" | ||
| 1231 | ? "uppercase" | ||
| 1232 | : "none", | ||
| 1233 | }} | ||
| 1234 | > | ||
| 1235 | {action.label} | ||
| 1236 | </RNText> | ||
| 1237 | </Pressable> | ||
| 1238 | ); | ||
| 1239 | })} | ||
| 1240 | </ScrollView> | ||
| 1241 | {toolbarScrollEdges.showLeftFade ? ( | ||
| 1242 | <View | ||
| 1243 | pointerEvents="none" | ||
| 1244 | style={{ | ||
| 1245 | bottom: 0, | ||
| 1246 | experimental_backgroundImage: `linear-gradient(to right, ${terminalTheme.background} 0%, ${withAlpha(terminalTheme.background, "00")} 100%)`, | ||
| 1247 | left: 0, | ||
| 1248 | position: "absolute", | ||
| 1249 | top: 0, | ||
| 1250 | width: TERMINAL_ACCESSORY_FADE_WIDTH, | ||
| 1251 | }} | ||
| 1252 | /> | ||
| 1253 | ) : null} | ||
| 1254 | {toolbarScrollEdges.showRightFade ? ( | ||
| 1255 | <View | ||
| 1256 | pointerEvents="none" | ||
| 1257 | style={{ | ||
| 1258 | bottom: 0, | ||
| 1259 | experimental_backgroundImage: `linear-gradient(to right, ${withAlpha(terminalTheme.background, "00")} 0%, ${terminalTheme.background} 100%)`, | ||
| 1260 | position: "absolute", | ||
| 1261 | right: 0, | ||
| 1262 | top: 0, | ||
| 1263 | width: TERMINAL_ACCESSORY_FADE_WIDTH, | ||
| 1264 | }} | ||
| 1265 | /> | ||
| 1266 | ) : null} | ||
| 1267 | </View> | ||
| 1268 | <Pressable | ||
| 1269 | accessibilityLabel="Dismiss keyboard" | 1086 | accessibilityLabel="Dismiss keyboard" |
| 1270 | accessibilityRole="button" | 1087 | icon={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }} |
| 1271 | onPress={handleDismissKeyboard} | 1088 | onPress={handleDismissKeyboard} |
| 1272 | style={({ pressed }) => ({ | 1089 | showChevron={false} |
| 1273 | alignItems: "center", | 1090 | /> |
| 1274 | backgroundColor: pressed | 1091 | </ComposerToolbarRow> |
| 1275 | ? withAlpha(terminalTheme.foreground, "1f") | ||
| 1276 | : withAlpha(terminalTheme.foreground, "12"), | ||
| 1277 | borderColor: terminalTheme.border, | ||
| 1278 | borderRadius: 12, | ||
| 1279 | borderWidth: 1, | ||
| 1280 | justifyContent: "center", | ||
| 1281 | minWidth: 38, | ||
| 1282 | paddingHorizontal: 11, | ||
| 1283 | paddingVertical: 8, | ||
| 1284 | })} | ||
| 1285 | > | ||
| 1286 | <SymbolView | ||
| 1287 | name={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }} | ||
| 1288 | size={14} | ||
| 1289 | tintColor={terminalTheme.foreground} | ||
| 1290 | type="monochrome" | ||
| 1291 | /> | ||
| 1292 | </Pressable> | ||
| 1293 | </View> | ||
| 1294 | </View> | 1092 | </View> |
| 1295 | </KeyboardStickyView> | 1093 | </KeyboardStickyView> |
| 1296 | ) : !keyboardState.isVisible ? ( | 1094 | ) : !keyboardState.isVisible ? ( |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+162-67| ... | @@ -1,16 +1,27 @@ | ... | @@ -1,16 +1,27 @@ |
| 1 | import { useRouter } from "expo-router"; | 1 | import { Stack, useRouter } from "expo-router"; |
| 2 | import { TextInputWrapper } from "expo-paste-input"; | 2 | import { TextInputWrapper } from "expo-paste-input"; |
| 3 | import { useCallback, useEffect, useMemo } from "react"; | 3 | import { useCallback, useEffect, useMemo, useRef } from "react"; |
| 4 | import { View } from "react-native"; | 4 | import { |
| 5 | import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller"; | 5 | InteractionManager, |
| 6 | View, | ||
| 7 | useColorScheme, | ||
| 8 | type TextInput as RNTextInput, | ||
| 9 | } from "react-native"; | ||
| 10 | import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; | ||
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 11 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 7 | import { useThemeColor } from "../../lib/useThemeColor"; | 12 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 8 | 13 | ||
| 9 | import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; | 14 | import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; |
| 10 | 15 | ||
| 11 | import { AppTextInput as TextInput } from "../../components/AppText"; | 16 | import { AppTextInput as TextInput } from "../../components/AppText"; |
| 17 | import { | ||
| 18 | ComposerToolbarButton, | ||
| 19 | ComposerToolbarRow, | ||
| 20 | ComposerToolbarScroller, | ||
| 21 | ComposerToolbarTrigger, | ||
| 22 | } from "../../components/ComposerToolbarTrigger"; | ||
| 12 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 23 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 13 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | 24 | import { ControlPillMenu } from "../../components/ControlPill"; |
| 14 | import { ProviderIcon } from "../../components/ProviderIcon"; | 25 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 15 | 26 | ||
| 16 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; | 27 | import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; |
| ... | @@ -18,7 +29,6 @@ import { buildThreadRoutePath } from "../../lib/routes"; | ... | @@ -18,7 +29,6 @@ import { buildThreadRoutePath } from "../../lib/routes"; |
| 18 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | 29 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 19 | import { useNativePaste } from "../../lib/useNativePaste"; | 30 | import { useNativePaste } from "../../lib/useNativePaste"; |
| 20 | import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; | 31 | import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; |
| 21 | import { NewTaskSheetHeader } from "./NewTaskSheetHeader"; | ||
| 22 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; | 32 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 23 | import { useProjectActions } from "./use-project-actions"; | 33 | import { useProjectActions } from "./use-project-actions"; |
| 24 | 34 | ||
| ... | @@ -34,6 +44,22 @@ function withModelSelectionOption( | ... | @@ -34,6 +44,22 @@ function withModelSelectionOption( |
| 34 | }; | 44 | }; |
| 35 | } | 45 | } |
| 36 | 46 | ||
| 47 | function formatTitleCase(value: string): string { | ||
| 48 | return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; | ||
| 49 | } | ||
| 50 | |||
| 51 | function formatWorkspaceLabel(input: { | ||
| 52 | readonly workspaceMode: string; | ||
| 53 | readonly currentBranchName: string | null; | ||
| 54 | readonly selectedBranchName: string | null; | ||
| 55 | }): string { | ||
| 56 | const branchName = input.selectedBranchName ?? input.currentBranchName; | ||
| 57 | if (input.workspaceMode === "worktree") { | ||
| 58 | return branchName ? `New worktree · ${branchName}` : "New worktree"; | ||
| 59 | } | ||
| 60 | return branchName ? `Current · ${branchName}` : "Current checkout"; | ||
| 61 | } | ||
| 62 | |||
| 37 | export function NewTaskDraftScreen(props: { | 63 | export function NewTaskDraftScreen(props: { |
| 38 | readonly initialProjectRef?: { | 64 | readonly initialProjectRef?: { |
| 39 | readonly environmentId?: string; | 65 | readonly environmentId?: string; |
| ... | @@ -45,11 +71,15 @@ export function NewTaskDraftScreen(props: { | ... | @@ -45,11 +71,15 @@ export function NewTaskDraftScreen(props: { |
| 45 | const flow = useNewTaskFlow(); | 71 | const flow = useNewTaskFlow(); |
| 46 | const router = useRouter(); | 72 | const router = useRouter(); |
| 47 | const insets = useSafeAreaInsets(); | 73 | const insets = useSafeAreaInsets(); |
| 74 | const colorScheme = useColorScheme(); | ||
| 48 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); | 75 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| 49 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); | 76 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); |
| 50 | const { logicalProjects, selectedProject, setProject } = flow; | 77 | const { logicalProjects, selectedProject, setProject } = flow; |
| 78 | const promptInputRef = useRef<RNTextInput>(null); | ||
| 51 | 79 | ||
| 52 | const borderColor = useThemeColor("--color-border"); | 80 | const borderColor = useThemeColor("--color-border"); |
| 81 | const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; | ||
| 82 | const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; | ||
| 53 | 83 | ||
| 54 | useEffect(() => { | 84 | useEffect(() => { |
| 55 | if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { | 85 | if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { |
| ... | @@ -93,6 +123,24 @@ export function NewTaskDraftScreen(props: { | ... | @@ -93,6 +123,24 @@ export function NewTaskDraftScreen(props: { |
| 93 | void flow.loadBranches(); | 123 | void flow.loadBranches(); |
| 94 | }, [flow, selectedProject]); | 124 | }, [flow, selectedProject]); |
| 95 | 125 | ||
| 126 | useEffect(() => { | ||
| 127 | if (!selectedProject) { | ||
| 128 | return; | ||
| 129 | } | ||
| 130 | |||
| 131 | let focusFrame: ReturnType<typeof requestAnimationFrame> | null = null; | ||
| 132 | const interaction = InteractionManager.runAfterInteractions(() => { | ||
| 133 | focusFrame = requestAnimationFrame(() => promptInputRef.current?.focus()); | ||
| 134 | }); | ||
| 135 | |||
| 136 | return () => { | ||
| 137 | interaction.cancel(); | ||
| 138 | if (focusFrame !== null) { | ||
| 139 | cancelAnimationFrame(focusFrame); | ||
| 140 | } | ||
| 141 | }; | ||
| 142 | }, [selectedProject]); | ||
| 143 | |||
| 96 | const environmentMenuActions = useMemo( | 144 | const environmentMenuActions = useMemo( |
| 97 | () => | 145 | () => |
| 98 | flow.environments.map((environment) => ({ | 146 | flow.environments.map((environment) => ({ |
| ... | @@ -231,10 +279,10 @@ export function NewTaskDraftScreen(props: { | ... | @@ -231,10 +279,10 @@ export function NewTaskDraftScreen(props: { |
| 231 | { | 279 | { |
| 232 | id: "workspace:mode", | 280 | id: "workspace:mode", |
| 233 | title: "Mode", | 281 | title: "Mode", |
| 234 | subtitle: flow.workspaceMode === "local" ? "Local" : "Worktree", | 282 | subtitle: flow.workspaceMode === "local" ? "Current checkout" : "New worktree", |
| 235 | subactions: (["local", "worktree"] as const).map((value) => ({ | 283 | subactions: (["local", "worktree"] as const).map((value) => ({ |
| 236 | id: `workspace:mode:${value}`, | 284 | id: `workspace:mode:${value}`, |
| 237 | title: value === "local" ? "Local" : "Worktree", | 285 | title: value === "local" ? "Current checkout" : "New worktree", |
| 238 | state: flow.workspaceMode === value ? ("on" as const) : undefined, | 286 | state: flow.workspaceMode === value ? ("on" as const) : undefined, |
| 239 | })), | 287 | })), |
| 240 | }, | 288 | }, |
| ... | @@ -253,6 +301,31 @@ export function NewTaskDraftScreen(props: { | ... | @@ -253,6 +301,31 @@ export function NewTaskDraftScreen(props: { |
| 253 | flow.workspaceMode, | 301 | flow.workspaceMode, |
| 254 | ]); | 302 | ]); |
| 255 | 303 | ||
| 304 | const selectedEnvironmentLabel = | ||
| 305 | flow.environments.find( | ||
| 306 | (environment) => environment.environmentId === flow.selectedEnvironmentId, | ||
| 307 | )?.environmentLabel ?? "Environment"; | ||
| 308 | const currentBranchName = | ||
| 309 | flow.availableBranches.find((branch) => branch.current)?.name ?? | ||
| 310 | flow.availableBranches.find((branch) => branch.isDefault)?.name ?? | ||
| 311 | null; | ||
| 312 | const configurationLabel = useMemo(() => { | ||
| 313 | const parts = [ | ||
| 314 | formatTitleCase(flow.effort), | ||
| 315 | flow.fastMode ? "Fast" : null, | ||
| 316 | flow.contextWindow !== "1M" ? flow.contextWindow : null, | ||
| 317 | ].filter((part): part is string => Boolean(part)); | ||
| 318 | return parts.length > 0 ? parts.join(" · ") : "Configuration"; | ||
| 319 | }, [flow.contextWindow, flow.effort, flow.fastMode]); | ||
| 320 | const workspaceLabel = useMemo( | ||
| 321 | () => | ||
| 322 | formatWorkspaceLabel({ | ||
| 323 | currentBranchName, | ||
| 324 | selectedBranchName: flow.selectedBranchName, | ||
| 325 | workspaceMode: flow.workspaceMode, | ||
| 326 | }), | ||
| 327 | [currentBranchName, flow.selectedBranchName, flow.workspaceMode], | ||
| 328 | ); | ||
| 256 | function handleModelMenuAction(event: string) { | 329 | function handleModelMenuAction(event: string) { |
| 257 | if (!event.startsWith("model:")) { | 330 | if (!event.startsWith("model:")) { |
| 258 | return; | 331 | return; |
| ... | @@ -382,6 +455,8 @@ export function NewTaskDraftScreen(props: { | ... | @@ -382,6 +455,8 @@ export function NewTaskDraftScreen(props: { |
| 382 | }); | 455 | }); |
| 383 | 456 | ||
| 384 | if (createdThread) { | 457 | if (createdThread) { |
| 458 | flow.setPrompt(""); | ||
| 459 | flow.clearAttachments(); | ||
| 385 | router.replace(buildThreadRoutePath(createdThread)); | 460 | router.replace(buildThreadRoutePath(createdThread)); |
| 386 | } | 461 | } |
| 387 | } finally { | 462 | } finally { |
| ... | @@ -392,40 +467,36 @@ export function NewTaskDraftScreen(props: { | ... | @@ -392,40 +467,36 @@ export function NewTaskDraftScreen(props: { |
| 392 | if (!selectedProject) { | 467 | if (!selectedProject) { |
| 393 | return ( | 468 | return ( |
| 394 | <View className="flex-1 bg-sheet"> | 469 | <View className="flex-1 bg-sheet"> |
| 395 | <NewTaskSheetHeader title="Loading task" /> | 470 | <Stack.Screen options={{ title: "Loading task" }} /> |
| 396 | </View> | 471 | </View> |
| 397 | ); | 472 | ); |
| 398 | } | 473 | } |
| 399 | 474 | ||
| 400 | return ( | 475 | return ( |
| 401 | <View className="flex-1 bg-sheet"> | 476 | <View className="flex-1 bg-sheet"> |
| 402 | <NewTaskSheetHeader | 477 | <Stack.Screen options={{ title: selectedProject.title }} /> |
| 403 | title={selectedProject.title} | 478 | |
| 404 | control={ | 479 | <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}> |
| 405 | flow.logicalProjects.length > 1 | 480 | <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}> |
| 406 | ? { icon: "chevron.left", onPress: () => router.back() } | 481 | <TextInputWrapper |
| 407 | : undefined | 482 | onPaste={(payload) => void handleNativePaste(payload)} |
| 408 | } | 483 | style={{ flex: 1, minHeight: 0 }} |
| 409 | /> | 484 | > |
| 410 | 485 | <TextInput | |
| 411 | <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}> | 486 | ref={promptInputRef} |
| 412 | <TextInputWrapper | 487 | autoFocus |
| 413 | onPaste={(payload) => void handleNativePaste(payload)} | 488 | multiline |
| 414 | style={{ flex: 1 }} | 489 | scrollEnabled |
| 415 | > | 490 | value={flow.prompt} |
| 416 | <TextInput | 491 | onChangeText={flow.setPrompt} |
| 417 | multiline | 492 | placeholder={`Describe a coding task in ${selectedProject.title}`} |
| 418 | value={flow.prompt} | 493 | textAlignVertical="top" |
| 419 | onChangeText={flow.setPrompt} | 494 | className="h-full flex-1 border-0 bg-transparent text-[18px] leading-[28px]" |
| 420 | placeholder={`Describe a coding task in ${selectedProject.title}`} | 495 | style={{ flex: 1, minHeight: 0 }} |
| 421 | textAlignVertical="top" | 496 | /> |
| 422 | className="h-full flex-1 border-0 bg-transparent text-[18px] leading-[28px]" | 497 | </TextInputWrapper> |
| 423 | style={{ flex: 1 }} | 498 | </View> |
| 424 | /> | ||
| 425 | </TextInputWrapper> | ||
| 426 | </View> | ||
| 427 | 499 | ||
| 428 | <KeyboardStickyView> | ||
| 429 | <View | 500 | <View |
| 430 | style={{ | 501 | style={{ |
| 431 | borderTopWidth: 1, | 502 | borderTopWidth: 1, |
| ... | @@ -443,41 +514,65 @@ export function NewTaskDraftScreen(props: { | ... | @@ -443,41 +514,65 @@ export function NewTaskDraftScreen(props: { |
| 443 | /> | 514 | /> |
| 444 | </View> | 515 | </View> |
| 445 | ) : null} | 516 | ) : null} |
| 446 | <View className="flex-row items-center justify-between gap-2 px-4 pt-2"> | 517 | <ComposerToolbarRow paddingBottom={controlsBottomPadding} paddingHorizontal={6}> |
| 447 | <ControlPill icon="plus" onPress={() => void handlePickImages()} /> | 518 | <ComposerToolbarScroller |
| 448 | <ControlPillMenu | 519 | fadeOpaque={sheetFadeOpaque} |
| 449 | actions={modelMenuActions} | 520 | fadeTransparent={sheetFadeTransparent} |
| 450 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} | ||
| 451 | > | 521 | > |
| 452 | <ControlPill | 522 | <ComposerToolbarButton |
| 453 | iconNode={ | 523 | icon="plus" |
| 454 | <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} /> | 524 | onPress={() => void handlePickImages()} |
| 455 | } | 525 | showChevron={false} |
| 456 | /> | 526 | /> |
| 457 | </ControlPillMenu> | 527 | <ControlPillMenu |
| 458 | <ControlPillMenu | 528 | actions={modelMenuActions} |
| 459 | actions={optionsMenuActions} | 529 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} |
| 460 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} | 530 | > |
| 461 | > | 531 | <ComposerToolbarTrigger |
| 462 | <ControlPill icon="slider.horizontal.3" /> | 532 | accessibilityLabel="Model" |
| 463 | </ControlPillMenu> | 533 | iconNode={ |
| 464 | <ControlPillMenu | 534 | <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} /> |
| 465 | actions={environmentMenuActions} | 535 | } |
| 466 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} | 536 | label={flow.selectedModelOption?.label ?? "Model"} |
| 467 | > | 537 | /> |
| 468 | <ControlPill icon="desktopcomputer" /> | 538 | </ControlPillMenu> |
| 469 | </ControlPillMenu> | 539 | <ControlPillMenu |
| 470 | <ControlPillMenu | 540 | actions={optionsMenuActions} |
| 471 | actions={workspaceMenuActions} | 541 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} |
| 472 | onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)} | 542 | > |
| 473 | > | 543 | <ComposerToolbarTrigger |
| 474 | <ControlPill icon="point.topleft.down.curvedto.point.bottomright.up" /> | 544 | accessibilityLabel="Configuration" |
| 475 | </ControlPillMenu> | 545 | icon="slider.horizontal.3" |
| 476 | <ControlPill | 546 | label={configurationLabel} |
| 547 | /> | ||
| 548 | </ControlPillMenu> | ||
| 549 | <ControlPillMenu | ||
| 550 | actions={environmentMenuActions} | ||
| 551 | onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)} | ||
| 552 | > | ||
| 553 | <ComposerToolbarTrigger | ||
| 554 | accessibilityLabel="Environment" | ||
| 555 | icon="desktopcomputer" | ||
| 556 | label={selectedEnvironmentLabel} | ||
| 557 | /> | ||
| 558 | </ControlPillMenu> | ||
| 559 | <ControlPillMenu | ||
| 560 | actions={workspaceMenuActions} | ||
| 561 | onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)} | ||
| 562 | > | ||
| 563 | <ComposerToolbarTrigger | ||
| 564 | accessibilityLabel="Workspace" | ||
| 565 | icon="point.topleft.down.curvedto.point.bottomright.up" | ||
| 566 | label={workspaceLabel} | ||
| 567 | /> | ||
| 568 | </ControlPillMenu> | ||
| 569 | </ComposerToolbarScroller> | ||
| 570 | <ComposerToolbarButton | ||
| 571 | accessibilityLabel={flow.submitting ? "Starting task" : "Start task"} | ||
| 477 | icon="arrow.up" | 572 | icon="arrow.up" |
| 478 | label={flow.submitting ? "Starting" : "Start"} | ||
| 479 | onPress={() => void handleStart()} | 573 | onPress={() => void handleStart()} |
| 480 | variant="primary" | 574 | variant="primary" |
| 575 | showChevron={false} | ||
| 481 | disabled={ | 576 | disabled={ |
| 482 | !flow.selectedProject || | 577 | !flow.selectedProject || |
| 483 | !flow.selectedModel || | 578 | !flow.selectedModel || |
| ... | @@ -486,9 +581,9 @@ export function NewTaskDraftScreen(props: { | ... | @@ -486,9 +581,9 @@ export function NewTaskDraftScreen(props: { |
| 486 | (flow.workspaceMode === "worktree" && !flow.selectedBranchName) | 581 | (flow.workspaceMode === "worktree" && !flow.selectedBranchName) |
| 487 | } | 582 | } |
| 488 | /> | 583 | /> |
| 489 | </View> | 584 | </ComposerToolbarRow> |
| 490 | </View> | 585 | </View> |
| 491 | </KeyboardStickyView> | 586 | </KeyboardAvoidingView> |
| 492 | </View> | 587 | </View> |
| 493 | ); | 588 | ); |
| 494 | } | 589 | } |
apps/mobile/src/features/threads/NewTaskSheetHeader.tsx deleted-46| ... | @@ -1,46 +0,0 @@ | ||
| 1 | import { SymbolView } from "expo-symbols"; | ||
| 2 | import { Pressable, View } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "../../components/AppText"; | ||
| 5 | import { useThemeColor } from "../../lib/useThemeColor"; | ||
| 6 | |||
| 7 | export function NewTaskSheetHeader(props: { | ||
| 8 | readonly eyebrow?: string; | ||
| 9 | readonly title: string; | ||
| 10 | readonly control?: { | ||
| 11 | readonly icon: "chevron.left" | "xmark"; | ||
| 12 | readonly onPress: () => void; | ||
| 13 | }; | ||
| 14 | }) { | ||
| 15 | const iconColor = useThemeColor("--color-icon"); | ||
| 16 | |||
| 17 | return ( | ||
| 18 | <> | ||
| 19 | <View style={{ minHeight: 16, paddingTop: 8 }} /> | ||
| 20 | <View className="items-center gap-1 px-5 pb-3 pt-4"> | ||
| 21 | {props.control ? ( | ||
| 22 | <Pressable | ||
| 23 | className="absolute left-3 top-4 h-9 w-9 items-center justify-center rounded-full bg-subtle" | ||
| 24 | style={{ zIndex: 1 }} | ||
| 25 | onPress={props.control.onPress} | ||
| 26 | > | ||
| 27 | <SymbolView | ||
| 28 | name={props.control.icon} | ||
| 29 | size={16} | ||
| 30 | tintColor={iconColor} | ||
| 31 | type="monochrome" | ||
| 32 | weight="medium" | ||
| 33 | /> | ||
| 34 | </Pressable> | ||
| 35 | ) : null} | ||
| 36 | <Text | ||
| 37 | className="text-[12px] font-t3-bold uppercase text-foreground-muted" | ||
| 38 | style={{ letterSpacing: 1 }} | ||
| 39 | > | ||
| 40 | {props.eyebrow ?? "New task"} | ||
| 41 | </Text> | ||
| 42 | <Text className="text-[28px] font-t3-bold">{props.title}</Text> | ||
| 43 | </View> | ||
| 44 | </> | ||
| 45 | ); | ||
| 46 | } | ||
apps/mobile/src/features/threads/ThreadComposer.tsx+76-41| ... | @@ -31,6 +31,12 @@ import { useThemeColor } from "../../lib/useThemeColor"; | ... | @@ -31,6 +31,12 @@ import { useThemeColor } from "../../lib/useThemeColor"; |
| 31 | 31 | ||
| 32 | import { AppText as Text } from "../../components/AppText"; | 32 | import { AppText as Text } from "../../components/AppText"; |
| 33 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; | 33 | import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; |
| 34 | import { | ||
| 35 | ComposerToolbarButton, | ||
| 36 | ComposerToolbarRow, | ||
| 37 | ComposerToolbarScroller, | ||
| 38 | ComposerToolbarTrigger, | ||
| 39 | } from "../../components/ComposerToolbarTrigger"; | ||
| 34 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; | 40 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; |
| 35 | import { ProviderIcon } from "../../components/ProviderIcon"; | 41 | import { ProviderIcon } from "../../components/ProviderIcon"; |
| 36 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; | 42 | import type { DraftComposerImageAttachment } from "../../lib/composerImages"; |
| ... | @@ -67,7 +73,7 @@ export const COMPOSER_EXPANDED_CHROME = 174; | ... | @@ -67,7 +73,7 @@ export const COMPOSER_EXPANDED_CHROME = 174; |
| 67 | * Used by the feed inset because KeyboardAvoidingLegendList only accounts for | 73 | * Used by the feed inset because KeyboardAvoidingLegendList only accounts for |
| 68 | * keyboard height; the floating toolbar remains an additional overlay. | 74 | * keyboard height; the floating toolbar remains an additional overlay. |
| 69 | */ | 75 | */ |
| 70 | export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 54; | 76 | export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 60; |
| 71 | 77 | ||
| 72 | export interface ThreadComposerProps { | 78 | export interface ThreadComposerProps { |
| 73 | readonly draftMessage: string; | 79 | readonly draftMessage: string; |
| ... | @@ -85,7 +91,6 @@ export interface ThreadComposerProps { | ... | @@ -85,7 +91,6 @@ export interface ThreadComposerProps { |
| 85 | readonly onPickDraftImages: () => Promise<void>; | 91 | readonly onPickDraftImages: () => Promise<void>; |
| 86 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 92 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 87 | readonly onRemoveDraftImage: (imageId: string) => void; | 93 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 88 | readonly onRefresh: () => Promise<void>; | ||
| 89 | readonly onStopThread: () => Promise<void>; | 94 | readonly onStopThread: () => Promise<void>; |
| 90 | readonly onSendMessage: () => void; | 95 | readonly onSendMessage: () => void; |
| 91 | readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise<void>; | 96 | readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise<void>; |
| ... | @@ -145,6 +150,10 @@ function withModelSelectionOption( | ... | @@ -145,6 +150,10 @@ function withModelSelectionOption( |
| 145 | }; | 150 | }; |
| 146 | } | 151 | } |
| 147 | 152 | ||
| 153 | function formatTitleCase(value: string): string { | ||
| 154 | return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; | ||
| 155 | } | ||
| 156 | |||
| 148 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { | 157 | export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { |
| 149 | const isDarkMode = useColorScheme() === "dark"; | 158 | const isDarkMode = useColorScheme() === "dark"; |
| 150 | const themePlaceholderColor = useThemeColor("--color-placeholder"); | 159 | const themePlaceholderColor = useThemeColor("--color-placeholder"); |
| ... | @@ -184,10 +193,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -184,10 +193,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 184 | props.queueCount > 0; | 193 | props.queueCount > 0; |
| 185 | 194 | ||
| 186 | const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send"; | 195 | const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send"; |
| 187 | const modelProvider = props.selectedThread.modelSelection?.instanceId ?? null; | ||
| 188 | const currentModelSelection = props.selectedThread.modelSelection; | 196 | const currentModelSelection = props.selectedThread.modelSelection; |
| 189 | const currentRuntimeMode = props.selectedThread.runtimeMode; | 197 | const currentRuntimeMode = props.selectedThread.runtimeMode; |
| 190 | const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; | 198 | const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; |
| 199 | const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; | ||
| 200 | const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; | ||
| 191 | const selectedProviderStatus = useMemo(() => { | 201 | const selectedProviderStatus = useMemo(() => { |
| 192 | if (!props.serverConfig) return null; | 202 | if (!props.serverConfig) return null; |
| 193 | return ( | 203 | return ( |
| ... | @@ -431,11 +441,25 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -431,11 +441,25 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 431 | ); | 441 | ); |
| 432 | 442 | ||
| 433 | // ── Model menu ─────────────────────────────────────────── | 443 | // ── Model menu ─────────────────────────────────────────── |
| 434 | const providerGroups = useMemo(() => { | 444 | const modelOptions = useMemo( |
| 435 | const options = buildModelOptions(props.serverConfig, currentModelSelection); | 445 | () => buildModelOptions(props.serverConfig, currentModelSelection), |
| 436 | return groupByProvider(options); | 446 | [props.serverConfig, currentModelSelection], |
| 437 | }, [props.serverConfig, currentModelSelection]); | 447 | ); |
| 438 | 448 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); | |
| 449 | const currentModelOption = | ||
| 450 | modelOptions.find( | ||
| 451 | (option) => | ||
| 452 | option.selection.instanceId === currentModelSelection.instanceId && | ||
| 453 | option.selection.model === currentModelSelection.model, | ||
| 454 | ) ?? null; | ||
| 455 | const configurationLabel = useMemo(() => { | ||
| 456 | const parts = [ | ||
| 457 | formatTitleCase(currentEffort), | ||
| 458 | currentFastMode ? "Fast" : null, | ||
| 459 | currentContextWindow !== "1M" ? currentContextWindow : null, | ||
| 460 | ].filter((part): part is string => Boolean(part)); | ||
| 461 | return parts.length > 0 ? parts.join(" · ") : "Configuration"; | ||
| 462 | }, [currentContextWindow, currentEffort, currentFastMode]); | ||
| 439 | const modelMenuActions = useMemo( | 463 | const modelMenuActions = useMemo( |
| 440 | () => | 464 | () => |
| 441 | providerGroups.map((group) => ({ | 465 | providerGroups.map((group) => ({ |
| ... | @@ -546,8 +570,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -546,8 +570,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 546 | return; | 570 | return; |
| 547 | } | 571 | } |
| 548 | const modelKey = event.slice("model:".length); | 572 | const modelKey = event.slice("model:".length); |
| 549 | const options = buildModelOptions(props.serverConfig, currentModelSelection); | 573 | const option = modelOptions.find((o) => o.key === modelKey); |
| 550 | const option = options.find((o) => o.key === modelKey); | ||
| 551 | if (option) { | 574 | if (option) { |
| 552 | void props.onUpdateModelSelection(option.selection); | 575 | void props.onUpdateModelSelection(option.selection); |
| 553 | } | 576 | } |
| ... | @@ -753,44 +776,56 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -753,44 +776,56 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 753 | 776 | ||
| 754 | {/* Toolbar row — matches draft page layout (expanded only) */} | 777 | {/* Toolbar row — matches draft page layout (expanded only) */} |
| 755 | {isExpanded ? ( | 778 | {isExpanded ? ( |
| 756 | <View | 779 | <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}> |
| 757 | style={{ | 780 | <ComposerToolbarScroller |
| 758 | flexDirection: "row", | 781 | fadeOpaque={toolbarFadeOpaque} |
| 759 | alignItems: "center", | 782 | fadeTransparent={toolbarFadeTransparent} |
| 760 | justifyContent: "space-between", | ||
| 761 | paddingTop: 10, | ||
| 762 | gap: 8, | ||
| 763 | }} | ||
| 764 | > | ||
| 765 | <ControlPill icon="plus" onPress={() => void props.onPickDraftImages()} /> | ||
| 766 | <ControlPillMenu | ||
| 767 | actions={modelMenuActions} | ||
| 768 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} | ||
| 769 | > | ||
| 770 | <ControlPill iconNode={<ProviderIcon provider={modelProvider} size={16} />} /> | ||
| 771 | </ControlPillMenu> | ||
| 772 | <ControlPillMenu | ||
| 773 | actions={optionsMenuActions} | ||
| 774 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} | ||
| 775 | > | 783 | > |
| 776 | <ControlPill icon="slider.horizontal.3" /> | 784 | <ComposerToolbarButton |
| 777 | </ControlPillMenu> | 785 | icon="plus" |
| 778 | <ControlPill icon="arrow.clockwise" onPress={() => void props.onRefresh()} /> | 786 | onPress={() => void props.onPickDraftImages()} |
| 779 | {showStopAction ? ( | 787 | showChevron={false} |
| 780 | <ControlPill | ||
| 781 | icon="stop.fill" | ||
| 782 | variant="danger" | ||
| 783 | onPress={() => void props.onStopThread()} | ||
| 784 | /> | 788 | /> |
| 785 | ) : null} | 789 | <ControlPillMenu |
| 786 | <ControlPill | 790 | actions={modelMenuActions} |
| 791 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} | ||
| 792 | > | ||
| 793 | <ComposerToolbarTrigger | ||
| 794 | accessibilityLabel="Model" | ||
| 795 | iconNode={ | ||
| 796 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | ||
| 797 | } | ||
| 798 | label={currentModelOption?.label ?? currentModelSelection.model} | ||
| 799 | /> | ||
| 800 | </ControlPillMenu> | ||
| 801 | <ControlPillMenu | ||
| 802 | actions={optionsMenuActions} | ||
| 803 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} | ||
| 804 | > | ||
| 805 | <ComposerToolbarTrigger | ||
| 806 | accessibilityLabel="Configuration" | ||
| 807 | icon="slider.horizontal.3" | ||
| 808 | label={configurationLabel} | ||
| 809 | /> | ||
| 810 | </ControlPillMenu> | ||
| 811 | {showStopAction ? ( | ||
| 812 | <ComposerToolbarButton | ||
| 813 | icon="stop.fill" | ||
| 814 | variant="danger" | ||
| 815 | onPress={() => void props.onStopThread()} | ||
| 816 | showChevron={false} | ||
| 817 | /> | ||
| 818 | ) : null} | ||
| 819 | </ComposerToolbarScroller> | ||
| 820 | <ComposerToolbarButton | ||
| 821 | accessibilityLabel={sendLabel} | ||
| 787 | icon="arrow.up" | 822 | icon="arrow.up" |
| 788 | label={sendLabel} | ||
| 789 | variant="primary" | 823 | variant="primary" |
| 790 | disabled={!canSend} | 824 | disabled={!canSend} |
| 791 | onPress={handleSend} | 825 | onPress={handleSend} |
| 826 | showChevron={false} | ||
| 792 | /> | 827 | /> |
| 793 | </View> | 828 | </ComposerToolbarRow> |
| 794 | ) : null} | 829 | ) : null} |
| 795 | 830 | ||
| 796 | {/* Queue count */} | 831 | {/* Queue count */} |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+2-20| ... | @@ -67,7 +67,6 @@ export interface ThreadDetailScreenProps { | ... | @@ -67,7 +67,6 @@ export interface ThreadDetailScreenProps { |
| 67 | readonly onPickDraftImages: () => Promise<void>; | 67 | readonly onPickDraftImages: () => Promise<void>; |
| 68 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; | 68 | readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>; |
| 69 | readonly onRemoveDraftImage: (imageId: string) => void; | 69 | readonly onRemoveDraftImage: (imageId: string) => void; |
| 70 | readonly onRefresh: () => Promise<void>; | ||
| 71 | readonly onStopThread: () => Promise<void>; | 70 | readonly onStopThread: () => Promise<void>; |
| 72 | readonly onSendMessage: () => void; | 71 | readonly onSendMessage: () => void; |
| 73 | readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>; | 72 | readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>; |
| ... | @@ -198,18 +197,17 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { | ... | @@ -198,18 +197,17 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { |
| 198 | }); | 197 | }); |
| 199 | 198 | ||
| 200 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { | 199 | export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { |
| 201 | const { onOpenDrawer, onRefresh } = props; | 200 | const { onOpenDrawer } = props; |
| 202 | 201 | ||
| 203 | const insets = useSafeAreaInsets(); | 202 | const insets = useSafeAreaInsets(); |
| 204 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; | 203 | const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; |
| 205 | const composerBottomInset = Math.max(insets.bottom, 12); | ||
| 206 | const [composerExpanded, setComposerExpanded] = useState(false); | 204 | const [composerExpanded, setComposerExpanded] = useState(false); |
| 205 | const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); | ||
| 207 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; | 206 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 208 | const composerOverlapHeight = composerChrome + composerBottomInset; | 207 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| 209 | const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; | 208 | const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; |
| 210 | const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; | 209 | const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; |
| 211 | const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0); | 210 | const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0); |
| 212 | const [refreshing, setRefreshing] = useState(false); | ||
| 213 | const showContent = props.showContent ?? true; | 211 | const showContent = props.showContent ?? true; |
| 214 | const layoutVariant = props.layoutVariant ?? "compact"; | 212 | const layoutVariant = props.layoutVariant ?? "compact"; |
| 215 | const isSplitLayout = layoutVariant === "split"; | 213 | const isSplitLayout = layoutVariant === "split"; |
| ... | @@ -223,19 +221,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -223,19 +221,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 223 | onOpenDrawer(); | 221 | onOpenDrawer(); |
| 224 | }, [onOpenDrawer]); | 222 | }, [onOpenDrawer]); |
| 225 | 223 | ||
| 226 | const handleRefresh = useCallback(async (): Promise<void> => { | ||
| 227 | if (refreshing) { | ||
| 228 | return; | ||
| 229 | } | ||
| 230 | |||
| 231 | setRefreshing(true); | ||
| 232 | try { | ||
| 233 | await onRefresh(); | ||
| 234 | } finally { | ||
| 235 | setRefreshing(false); | ||
| 236 | } | ||
| 237 | }, [onRefresh, refreshing]); | ||
| 238 | |||
| 239 | const drawerGestureThreshold = 80; | 224 | const drawerGestureThreshold = 80; |
| 240 | const headerDrawerGesture = useMemo( | 225 | const headerDrawerGesture = useMemo( |
| 241 | () => | 226 | () => |
| ... | @@ -273,8 +258,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -273,8 +258,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 273 | contentBottomInset={feedBottomInset} | 258 | contentBottomInset={feedBottomInset} |
| 274 | layoutVariant={layoutVariant} | 259 | layoutVariant={layoutVariant} |
| 275 | composerExpanded={composerExpanded} | 260 | composerExpanded={composerExpanded} |
| 276 | refreshing={refreshing} | ||
| 277 | onRefresh={handleRefresh} | ||
| 278 | /> | 261 | /> |
| 279 | ) : ( | 262 | ) : ( |
| 280 | <View style={{ flex: 1 }} /> | 263 | <View style={{ flex: 1 }} /> |
| ... | @@ -330,7 +313,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -330,7 +313,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 330 | onPickDraftImages={props.onPickDraftImages} | 313 | onPickDraftImages={props.onPickDraftImages} |
| 331 | onNativePasteImages={props.onNativePasteImages} | 314 | onNativePasteImages={props.onNativePasteImages} |
| 332 | onRemoveDraftImage={props.onRemoveDraftImage} | 315 | onRemoveDraftImage={props.onRemoveDraftImage} |
| 333 | onRefresh={props.onRefresh} | ||
| 334 | onStopThread={props.onStopThread} | 316 | onStopThread={props.onStopThread} |
| 335 | onSendMessage={props.onSendMessage} | 317 | onSendMessage={props.onSendMessage} |
| 336 | onUpdateModelSelection={props.onUpdateThreadModelSelection} | 318 | onUpdateModelSelection={props.onUpdateThreadModelSelection} |
apps/mobile/src/features/threads/ThreadFeed.tsx-4| ... | @@ -55,8 +55,6 @@ export interface ThreadFeedProps { | ... | @@ -55,8 +55,6 @@ export interface ThreadFeedProps { |
| 55 | readonly bearerToken: string | null; | 55 | readonly bearerToken: string | null; |
| 56 | readonly agentLabel: string; | 56 | readonly agentLabel: string; |
| 57 | readonly contentBottomInset?: number; | 57 | readonly contentBottomInset?: number; |
| 58 | readonly refreshing?: boolean; | ||
| 59 | readonly onRefresh?: () => void; | ||
| 60 | readonly layoutVariant?: MobileLayoutVariant; | 58 | readonly layoutVariant?: MobileLayoutVariant; |
| 61 | readonly composerExpanded?: boolean; | 59 | readonly composerExpanded?: boolean; |
| 62 | } | 60 | } |
| ... | @@ -958,8 +956,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -958,8 +956,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 958 | on: { layout: true, itemLayout: true, dataChange: true }, | 956 | on: { layout: true, itemLayout: true, dataChange: true }, |
| 959 | }} | 957 | }} |
| 960 | maintainScrollAtEndThreshold={0.1} | 958 | maintainScrollAtEndThreshold={0.1} |
| 961 | refreshing={props.refreshing ?? false} | ||
| 962 | onRefresh={props.onRefresh} | ||
| 963 | safeAreaInsetBottom={insets.bottom} | 959 | safeAreaInsetBottom={insets.bottom} |
| 964 | contentContainerStyle={{ | 960 | contentContainerStyle={{ |
| 965 | paddingTop: 12, | 961 | paddingTop: 12, |
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+8-20| ... | @@ -7,7 +7,7 @@ import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; | ... | @@ -7,7 +7,7 @@ import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; |
| 7 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 7 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 8 | import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; | 8 | import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; |
| 9 | import { useThemeColor } from "../../lib/useThemeColor"; | 9 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 10 | import { useVcsStatus, vcsStatusManager } from "../../state/use-vcs-status"; | 10 | import { useVcsStatus } from "../../state/use-vcs-status"; |
| 11 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; | 11 | import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; |
| 12 | 12 | ||
| 13 | import { EmptyState } from "../../components/EmptyState"; | 13 | import { EmptyState } from "../../components/EmptyState"; |
| ... | @@ -53,6 +53,10 @@ function firstRouteParam(value: string | string[] | undefined): string | null { | ... | @@ -53,6 +53,10 @@ function firstRouteParam(value: string | string[] | undefined): string | null { |
| 53 | return value ?? null; | 53 | return value ?? null; |
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | function OpeningThreadLoadingScreen() { | ||
| 57 | return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />; | ||
| 58 | } | ||
| 59 | |||
| 56 | export function ThreadRouteScreen() { | 60 | export function ThreadRouteScreen() { |
| 57 | const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } = | 61 | const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } = |
| 58 | useRemoteEnvironmentState(); | 62 | useRemoteEnvironmentState(); |
| ... | @@ -70,7 +74,6 @@ export function ThreadRouteScreen() { | ... | @@ -70,7 +74,6 @@ export function ThreadRouteScreen() { |
| 70 | const commands = useSelectedThreadCommands({ | 74 | const commands = useSelectedThreadCommands({ |
| 71 | refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus, | 75 | refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus, |
| 72 | }); | 76 | }); |
| 73 | const refreshSelectedThread = commands.onRefresh; | ||
| 74 | const router = useRouter(); | 77 | const router = useRouter(); |
| 75 | const params = useLocalSearchParams<{ | 78 | const params = useLocalSearchParams<{ |
| 76 | environmentId?: string | string[]; | 79 | environmentId?: string | string[]; |
| ... | @@ -122,20 +125,6 @@ export function ThreadRouteScreen() { | ... | @@ -122,20 +125,6 @@ export function ThreadRouteScreen() { |
| 122 | ); | 125 | ); |
| 123 | const gitActionProgress = useGitActionProgress(gitActionProgressTarget); | 126 | const gitActionProgress = useGitActionProgress(gitActionProgressTarget); |
| 124 | 127 | ||
| 125 | const handleRefreshGitStatus = useCallback(async () => { | ||
| 126 | if (!selectedThread) return; | ||
| 127 | await vcsStatusManager.refresh({ | ||
| 128 | environmentId: selectedThread.environmentId, | ||
| 129 | cwd: selectedThreadCwd, | ||
| 130 | }); | ||
| 131 | }, [selectedThread, selectedThreadCwd]); | ||
| 132 | |||
| 133 | /** Wraps thread refresh + git status refresh for pull-to-refresh */ | ||
| 134 | const handleRefreshAll = useCallback(async () => { | ||
| 135 | await refreshSelectedThread(); | ||
| 136 | await handleRefreshGitStatus(); | ||
| 137 | }, [handleRefreshGitStatus, refreshSelectedThread]); | ||
| 138 | |||
| 139 | const handleOpenDrawer = useCallback(() => { | 128 | const handleOpenDrawer = useCallback(() => { |
| 140 | setDrawerVisible(true); | 129 | setDrawerVisible(true); |
| 141 | }, []); | 130 | }, []); |
| ... | @@ -245,7 +234,7 @@ export function ThreadRouteScreen() { | ... | @@ -245,7 +234,7 @@ export function ThreadRouteScreen() { |
| 245 | ); | 234 | ); |
| 246 | 235 | ||
| 247 | if (!environmentId || !threadId) { | 236 | if (!environmentId || !threadId) { |
| 248 | return <LoadingScreen message="Opening thread…" />; | 237 | return <OpeningThreadLoadingScreen />; |
| 249 | } | 238 | } |
| 250 | 239 | ||
| 251 | if (!selectedThread) { | 240 | if (!selectedThread) { |
| ... | @@ -255,7 +244,7 @@ export function ThreadRouteScreen() { | ... | @@ -255,7 +244,7 @@ export function ThreadRouteScreen() { |
| 255 | routeConnectionState === "reconnecting"; | 244 | routeConnectionState === "reconnecting"; |
| 256 | 245 | ||
| 257 | if (stillHydrating) { | 246 | if (stillHydrating) { |
| 258 | return <LoadingScreen message="Opening thread…" />; | 247 | return <OpeningThreadLoadingScreen />; |
| 259 | } | 248 | } |
| 260 | 249 | ||
| 261 | return ( | 250 | return ( |
| ... | @@ -278,7 +267,7 @@ export function ThreadRouteScreen() { | ... | @@ -278,7 +267,7 @@ export function ThreadRouteScreen() { |
| 278 | } | 267 | } |
| 279 | 268 | ||
| 280 | if (!selectedThreadDetail) { | 269 | if (!selectedThreadDetail) { |
| 281 | return <LoadingScreen message="Opening thread…" />; | 270 | return <OpeningThreadLoadingScreen />; |
| 282 | } | 271 | } |
| 283 | 272 | ||
| 284 | const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); | 273 | const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); |
| ... | @@ -388,7 +377,6 @@ export function ThreadRouteScreen() { | ... | @@ -388,7 +377,6 @@ export function ThreadRouteScreen() { |
| 388 | onPickDraftImages={composer.onPickDraftImages} | 377 | onPickDraftImages={composer.onPickDraftImages} |
| 389 | onNativePasteImages={composer.onNativePasteImages} | 378 | onNativePasteImages={composer.onNativePasteImages} |
| 390 | onRemoveDraftImage={composer.onRemoveDraftImage} | 379 | onRemoveDraftImage={composer.onRemoveDraftImage} |
| 391 | onRefresh={handleRefreshAll} | ||
| 392 | serverConfig={serverConfig} | 380 | serverConfig={serverConfig} |
| 393 | onStopThread={commands.onStopThread} | 381 | onStopThread={commands.onStopThread} |
| 394 | onSendMessage={composer.onSendMessage} | 382 | onSendMessage={composer.onSendMessage} |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+56-27| ... | @@ -15,6 +15,13 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | ... | @@ -15,6 +15,13 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; |
| 15 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 15 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; |
| 16 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | 16 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; |
| 17 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 17 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 18 | import { | ||
| 19 | appendComposerDraftAttachments, | ||
| 20 | removeComposerDraftAttachment, | ||
| 21 | replaceComposerDraftAttachments, | ||
| 22 | setComposerDraftText, | ||
| 23 | useComposerDraft, | ||
| 24 | } from "../../state/use-composer-drafts"; | ||
| 18 | import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs"; | 25 | import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs"; |
| 19 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; | 26 | import { useRemoteCatalog } from "../../state/use-remote-catalog"; |
| 20 | import { | 27 | import { |
| ... | @@ -153,8 +160,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -153,8 +160,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 153 | const [selectedBranchName, setSelectedBranchName] = useState<string | null>(null); | 160 | const [selectedBranchName, setSelectedBranchName] = useState<string | null>(null); |
| 154 | const [selectedWorktreePath, setSelectedWorktreePath] = useState<string | null>(null); | 161 | const [selectedWorktreePath, setSelectedWorktreePath] = useState<string | null>(null); |
| 155 | const branchLoadVersionRef = useRef(0); | 162 | const branchLoadVersionRef = useRef(0); |
| 156 | const [prompt, setPrompt] = useState(""); | ||
| 157 | const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]); | ||
| 158 | const [submitting, setSubmitting] = useState(false); | 163 | const [submitting, setSubmitting] = useState(false); |
| 159 | const [branchQuery, setBranchQuery] = useState(""); | 164 | const [branchQuery, setBranchQuery] = useState(""); |
| 160 | const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE); | 165 | const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE); |
| ... | @@ -166,28 +171,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -166,28 +171,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 166 | const [contextWindow, setContextWindow] = useState("1M"); | 171 | const [contextWindow, setContextWindow] = useState("1M"); |
| 167 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); | 172 | const [expandedProvider, setExpandedProvider] = useState<string | null>(null); |
| 168 | 173 | ||
| 169 | const replaceAttachments = useCallback( | ||
| 170 | (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => { | ||
| 171 | setAttachments(nextAttachments); | ||
| 172 | }, | ||
| 173 | [], | ||
| 174 | ); | ||
| 175 | |||
| 176 | const appendAttachments = useCallback( | ||
| 177 | (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => { | ||
| 178 | setAttachments((current) => [...current, ...nextAttachments]); | ||
| 179 | }, | ||
| 180 | [], | ||
| 181 | ); | ||
| 182 | |||
| 183 | const removeAttachment = useCallback((imageId: string) => { | ||
| 184 | setAttachments((current) => current.filter((candidate) => candidate.id !== imageId)); | ||
| 185 | }, []); | ||
| 186 | |||
| 187 | const clearAttachments = useCallback(() => { | ||
| 188 | setAttachments([]); | ||
| 189 | }, []); | ||
| 190 | |||
| 191 | const reset = useCallback(() => { | 174 | const reset = useCallback(() => { |
| 192 | console.log("[new task flow] reset", { | 175 | console.log("[new task flow] reset", { |
| 193 | defaultEnvironmentId: projects[0]?.environmentId ?? null, | 176 | defaultEnvironmentId: projects[0]?.environmentId ?? null, |
| ... | @@ -199,8 +182,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -199,8 +182,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 199 | setWorkspaceMode("local"); | 182 | setWorkspaceMode("local"); |
| 200 | setSelectedBranchName(null); | 183 | setSelectedBranchName(null); |
| 201 | setSelectedWorktreePath(null); | 184 | setSelectedWorktreePath(null); |
| 202 | setPrompt(""); | ||
| 203 | clearAttachments(); | ||
| 204 | setSubmitting(false); | 185 | setSubmitting(false); |
| 205 | setBranchQuery(""); | 186 | setBranchQuery(""); |
| 206 | setRuntimeMode(DEFAULT_RUNTIME_MODE); | 187 | setRuntimeMode(DEFAULT_RUNTIME_MODE); |
| ... | @@ -209,7 +190,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -209,7 +190,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 209 | setFastMode(false); | 190 | setFastMode(false); |
| 210 | setContextWindow("1M"); | 191 | setContextWindow("1M"); |
| 211 | setExpandedProvider(null); | 192 | setExpandedProvider(null); |
| 212 | }, [clearAttachments, projects]); | 193 | }, [projects]); |
| 213 | 194 | ||
| 214 | useEffect(() => { | 195 | useEffect(() => { |
| 215 | if (selectedEnvironmentId !== null || projects.length === 0) { | 196 | if (selectedEnvironmentId !== null || projects.length === 0) { |
| ... | @@ -271,6 +252,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -271,6 +252,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 271 | ) ?? | 252 | ) ?? |
| 272 | projectsForEnvironment[0] ?? | 253 | projectsForEnvironment[0] ?? |
| 273 | null; | 254 | null; |
| 255 | const selectedProjectDraftKey = selectedProject | ||
| 256 | ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` | ||
| 257 | : null; | ||
| 258 | const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); | ||
| 259 | const prompt = selectedProjectDraft.text; | ||
| 260 | const attachments = selectedProjectDraft.attachments; | ||
| 274 | 261 | ||
| 275 | const modelOptions = useMemo( | 262 | const modelOptions = useMemo( |
| 276 | () => | 263 | () => |
| ... | @@ -298,6 +285,48 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { | ... | @@ -298,6 +285,48 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 298 | ) ?? null; | 285 | ) ?? null; |
| 299 | 286 | ||
| 300 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); | 287 | const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); |
| 288 | const setPrompt = useCallback( | ||
| 289 | (value: string) => { | ||
| 290 | if (!selectedProjectDraftKey) { | ||
| 291 | return; | ||
| 292 | } | ||
| 293 | setComposerDraftText(selectedProjectDraftKey, value); | ||
| 294 | }, | ||
| 295 | [selectedProjectDraftKey], | ||
| 296 | ); | ||
| 297 | const replaceAttachments = useCallback( | ||
| 298 | (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => { | ||
| 299 | if (!selectedProjectDraftKey) { | ||
| 300 | return; | ||
| 301 | } | ||
| 302 | replaceComposerDraftAttachments(selectedProjectDraftKey, nextAttachments); | ||
| 303 | }, | ||
| 304 | [selectedProjectDraftKey], | ||
| 305 | ); | ||
| 306 | const appendAttachments = useCallback( | ||
| 307 | (nextAttachments: ReadonlyArray<DraftComposerImageAttachment>) => { | ||
| 308 | if (!selectedProjectDraftKey) { | ||
| 309 | return; | ||
| 310 | } | ||
| 311 | appendComposerDraftAttachments(selectedProjectDraftKey, nextAttachments); | ||
| 312 | }, | ||
| 313 | [selectedProjectDraftKey], | ||
| 314 | ); | ||
| 315 | const removeAttachment = useCallback( | ||
| 316 | (imageId: string) => { | ||
| 317 | if (!selectedProjectDraftKey) { | ||
| 318 | return; | ||
| 319 | } | ||
| 320 | removeComposerDraftAttachment(selectedProjectDraftKey, imageId); | ||
| 321 | }, | ||
| 322 | [selectedProjectDraftKey], | ||
| 323 | ); | ||
| 324 | const clearAttachments = useCallback(() => { | ||
| 325 | if (!selectedProjectDraftKey) { | ||
| 326 | return; | ||
| 327 | } | ||
| 328 | replaceComposerDraftAttachments(selectedProjectDraftKey, []); | ||
| 329 | }, [selectedProjectDraftKey]); | ||
| 301 | const branchTarget = useMemo( | 330 | const branchTarget = useMemo( |
| 302 | () => ({ | 331 | () => ({ |
| 303 | environmentId: selectedProject?.environmentId ?? null, | 332 | environmentId: selectedProject?.environmentId ?? null, |
apps/mobile/src/lib/authClientMetadata.ts created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; | ||
| 2 | import { Platform } from "react-native"; | ||
| 3 | |||
| 4 | export function mobileAuthClientMetadata(): AuthClientPresentationMetadata { | ||
| 5 | return { | ||
| 6 | label: "T3 Code Mobile", | ||
| 7 | deviceType: "mobile", | ||
| 8 | ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), | ||
| 9 | }; | ||
| 10 | } | ||
apps/mobile/src/lib/connection.test.ts+34-2| ... | @@ -1,9 +1,15 @@ | ... | @@ -1,9 +1,15 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | import { mobileAuthClientMetadata, redactPairingCredential } from "./connection"; | 4 | import { |
| 5 | isRelayManagedConnection, | ||
| 6 | mobileAuthClientMetadata, | ||
| 7 | redactPairingCredential, | ||
| 8 | toStableSavedRemoteConnection, | ||
| 9 | } from "./connection"; | ||
| 4 | 10 | ||
| 5 | vi.mock("./runtime", () => ({ | 11 | vi.mock("./runtime", () => ({ |
| 6 | mobileRemoteHttpRuntime: { | 12 | mobileRuntime: { |
| 7 | runPromise: vi.fn(), | 13 | runPromise: vi.fn(), |
| 8 | }, | 14 | }, |
| 9 | })); | 15 | })); |
| ... | @@ -39,4 +45,30 @@ describe("mobile remote connection records", () => { | ... | @@ -39,4 +45,30 @@ describe("mobile remote connection records", () => { |
| 39 | ), | 45 | ), |
| 40 | ).toBe("https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&label=Desktop"); | 46 | ).toBe("https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&label=Desktop"); |
| 41 | }); | 47 | }); |
| 48 | |||
| 49 | it("recognizes explicitly managed relay connections", () => { | ||
| 50 | expect(isRelayManagedConnection({ relayManaged: true })).toBe(true); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("keeps existing DPoP tunnel records read-only after upgrading", () => { | ||
| 54 | expect(isRelayManagedConnection({ authenticationMethod: "dpop" })).toBe(true); | ||
| 55 | expect(isRelayManagedConnection({ authenticationMethod: "bearer" })).toBe(false); | ||
| 56 | }); | ||
| 57 | |||
| 58 | it("drops short-lived managed environment credentials from stable records", () => { | ||
| 59 | const connection = { | ||
| 60 | environmentId: EnvironmentId.make("environment-1"), | ||
| 61 | environmentLabel: "Desktop", | ||
| 62 | pairingUrl: "https://desktop.example/", | ||
| 63 | displayUrl: "https://desktop.example/", | ||
| 64 | httpBaseUrl: "https://desktop.example/", | ||
| 65 | wsBaseUrl: "wss://desktop.example/", | ||
| 66 | bearerToken: null, | ||
| 67 | authenticationMethod: "dpop", | ||
| 68 | dpopAccessToken: "short-lived-token", | ||
| 69 | relayManaged: true, | ||
| 70 | } as const; | ||
| 71 | |||
| 72 | expect(toStableSavedRemoteConnection(connection)).not.toHaveProperty("dpopAccessToken"); | ||
| 73 | }); | ||
| 42 | }); | 74 | }); |
apps/mobile/src/lib/connection.ts+40-22| ... | @@ -1,11 +1,14 @@ | ... | @@ -1,11 +1,14 @@ |
| 1 | import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { | 2 | import { |
| 3 | bootstrapRemoteBearerSession, | 3 | bootstrapRemoteBearerSession, |
| 4 | fetchRemoteEnvironmentDescriptor, | 4 | fetchRemoteEnvironmentDescriptor, |
| 5 | } from "@t3tools/client-runtime"; | 5 | } from "@t3tools/client-runtime"; |
| 6 | import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; | 6 | import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; |
| 7 | import { Platform } from "react-native"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import { mobileRemoteHttpRuntime } from "./runtime"; | 8 | import { mobileAuthClientMetadata } from "./authClientMetadata"; |
| 9 | import { mobileRuntime } from "./runtime"; | ||
| 10 | |||
| 11 | export { mobileAuthClientMetadata } from "./authClientMetadata"; | ||
| 9 | 12 | ||
| 10 | export interface RemoteConnectionInput { | 13 | export interface RemoteConnectionInput { |
| 11 | readonly pairingUrl: string; | 14 | readonly pairingUrl: string; |
| ... | @@ -18,7 +21,10 @@ export interface SavedRemoteConnection { | ... | @@ -18,7 +21,10 @@ export interface SavedRemoteConnection { |
| 18 | readonly displayUrl: string; | 21 | readonly displayUrl: string; |
| 19 | readonly httpBaseUrl: string; | 22 | readonly httpBaseUrl: string; |
| 20 | readonly wsBaseUrl: string; | 23 | readonly wsBaseUrl: string; |
| 21 | readonly bearerToken: string; | 24 | readonly bearerToken: string | null; |
| 25 | readonly authenticationMethod?: "bearer" | "dpop"; | ||
| 26 | readonly dpopAccessToken?: string; | ||
| 27 | readonly relayManaged?: true; | ||
| 22 | } | 28 | } |
| 23 | 29 | ||
| 24 | export type RemoteClientConnectionState = | 30 | export type RemoteClientConnectionState = |
| ... | @@ -37,12 +43,21 @@ export function redactPairingCredential(pairingUrl: string): string { | ... | @@ -37,12 +43,21 @@ export function redactPairingCredential(pairingUrl: string): string { |
| 37 | } | 43 | } |
| 38 | } | 44 | } |
| 39 | 45 | ||
| 40 | export function mobileAuthClientMetadata(): AuthClientPresentationMetadata { | 46 | export function isRelayManagedConnection( |
| 41 | return { | 47 | connection: Pick<SavedRemoteConnection, "authenticationMethod" | "relayManaged">, |
| 42 | label: "T3 Code Mobile", | 48 | ): boolean { |
| 43 | deviceType: "mobile", | 49 | return connection.relayManaged === true || connection.authenticationMethod === "dpop"; |
| 44 | ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), | 50 | } |
| 45 | }; | 51 | |
| 52 | export function toStableSavedRemoteConnection( | ||
| 53 | connection: SavedRemoteConnection, | ||
| 54 | ): SavedRemoteConnection { | ||
| 55 | if (!isRelayManagedConnection(connection) || !connection.dpopAccessToken) { | ||
| 56 | return connection; | ||
| 57 | } | ||
| 58 | |||
| 59 | const { dpopAccessToken: _, ...stableConnection } = connection; | ||
| 60 | return stableConnection; | ||
| 46 | } | 61 | } |
| 47 | 62 | ||
| 48 | export async function bootstrapRemoteConnection( | 63 | export async function bootstrapRemoteConnection( |
| ... | @@ -52,18 +67,20 @@ export async function bootstrapRemoteConnection( | ... | @@ -52,18 +67,20 @@ export async function bootstrapRemoteConnection( |
| 52 | pairingUrl: input.pairingUrl, | 67 | pairingUrl: input.pairingUrl, |
| 53 | }); | 68 | }); |
| 54 | 69 | ||
| 55 | const descriptor = await mobileRemoteHttpRuntime.runPromise( | 70 | const { descriptor, bootstrap } = await mobileRuntime.runPromise( |
| 56 | fetchRemoteEnvironmentDescriptor({ | 71 | Effect.all( |
| 57 | httpBaseUrl: target.httpBaseUrl, | 72 | { |
| 58 | }), | 73 | descriptor: fetchRemoteEnvironmentDescriptor({ |
| 59 | ); | 74 | httpBaseUrl: target.httpBaseUrl, |
| 60 | 75 | }), | |
| 61 | const bootstrap = await mobileRemoteHttpRuntime.runPromise( | 76 | bootstrap: bootstrapRemoteBearerSession({ |
| 62 | bootstrapRemoteBearerSession({ | 77 | httpBaseUrl: target.httpBaseUrl, |
| 63 | httpBaseUrl: target.httpBaseUrl, | 78 | credential: target.credential, |
| 64 | credential: target.credential, | 79 | clientMetadata: mobileAuthClientMetadata(), |
| 65 | clientMetadata: mobileAuthClientMetadata(), | 80 | }), |
| 66 | }), | 81 | }, |
| 82 | { concurrency: "unbounded" }, | ||
| 83 | ), | ||
| 67 | ); | 84 | ); |
| 68 | 85 | ||
| 69 | return { | 86 | return { |
| ... | @@ -74,5 +91,6 @@ export async function bootstrapRemoteConnection( | ... | @@ -74,5 +91,6 @@ export async function bootstrapRemoteConnection( |
| 74 | httpBaseUrl: target.httpBaseUrl, | 91 | httpBaseUrl: target.httpBaseUrl, |
| 75 | wsBaseUrl: target.wsBaseUrl, | 92 | wsBaseUrl: target.wsBaseUrl, |
| 76 | bearerToken: bootstrap.access_token, | 93 | bearerToken: bootstrap.access_token, |
| 94 | authenticationMethod: "bearer", | ||
| 77 | }; | 95 | }; |
| 78 | } | 96 | } |
apps/mobile/src/lib/runtime.ts+20-1| ... | @@ -1,5 +1,24 @@ | ... | @@ -1,5 +1,24 @@ |
| 1 | import * as Layer from "effect/Layer"; | ||
| 1 | import * as ManagedRuntime from "effect/ManagedRuntime"; | 2 | import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 2 | 3 | ||
| 3 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; | 4 | import { remoteHttpClientLayer } from "@t3tools/client-runtime"; |
| 4 | 5 | ||
| 5 | export const mobileRemoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(fetch)); | 6 | import { mobileCryptoLayer } from "../features/cloud/dpop"; |
| 7 | import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer"; | ||
| 8 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; | ||
| 9 | |||
| 10 | function configuredRelayUrl(): string { | ||
| 11 | return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; | ||
| 12 | } | ||
| 13 | |||
| 14 | const mobileHttpClientLayer = remoteHttpClientLayer(fetch); | ||
| 15 | |||
| 16 | export const mobileRuntime = ManagedRuntime.make( | ||
| 17 | Layer.mergeAll( | ||
| 18 | mobileHttpClientLayer, | ||
| 19 | mobileCryptoLayer, | ||
| 20 | mobileManagedRelayClientLayer(configuredRelayUrl()).pipe( | ||
| 21 | Layer.provide(Layer.mergeAll(mobileHttpClientLayer, mobileCryptoLayer)), | ||
| 22 | ), | ||
| 23 | ), | ||
| 24 | ); |
apps/mobile/src/lib/storage.test.ts created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
| 3 | |||
| 4 | const mocks = vi.hoisted(() => { | ||
| 5 | const values = new Map<string, string>(); | ||
| 6 | return { | ||
| 7 | clear: () => values.clear(), | ||
| 8 | getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)), | ||
| 9 | setItemAsync: vi.fn((key: string, value: string) => { | ||
| 10 | values.set(key, value); | ||
| 11 | return Promise.resolve(); | ||
| 12 | }), | ||
| 13 | }; | ||
| 14 | }); | ||
| 15 | |||
| 16 | vi.mock("expo-secure-store", () => ({ | ||
| 17 | getItemAsync: mocks.getItemAsync, | ||
| 18 | setItemAsync: mocks.setItemAsync, | ||
| 19 | })); | ||
| 20 | |||
| 21 | vi.mock("react-native", () => ({ | ||
| 22 | Platform: { | ||
| 23 | OS: "ios", | ||
| 24 | }, | ||
| 25 | })); | ||
| 26 | |||
| 27 | vi.mock("./runtime", () => ({ | ||
| 28 | mobileRuntime: { | ||
| 29 | runPromise: vi.fn(), | ||
| 30 | }, | ||
| 31 | })); | ||
| 32 | |||
| 33 | import { loadSavedConnections, saveConnection } from "./storage"; | ||
| 34 | import { toStableSavedRemoteConnection } from "./connection"; | ||
| 35 | |||
| 36 | const managedConnection = { | ||
| 37 | environmentId: EnvironmentId.make("environment-1"), | ||
| 38 | environmentLabel: "Desktop", | ||
| 39 | pairingUrl: "https://desktop.example/", | ||
| 40 | displayUrl: "https://desktop.example/", | ||
| 41 | httpBaseUrl: "https://desktop.example/", | ||
| 42 | wsBaseUrl: "wss://desktop.example/", | ||
| 43 | bearerToken: null, | ||
| 44 | authenticationMethod: "dpop", | ||
| 45 | dpopAccessToken: "short-lived-token", | ||
| 46 | relayManaged: true, | ||
| 47 | } as const; | ||
| 48 | |||
| 49 | describe("mobile connection storage", () => { | ||
| 50 | beforeEach(() => { | ||
| 51 | mocks.clear(); | ||
| 52 | vi.clearAllMocks(); | ||
| 53 | }); | ||
| 54 | |||
| 55 | it("persists relay-managed connections without their ephemeral access token", async () => { | ||
| 56 | await saveConnection(managedConnection); | ||
| 57 | |||
| 58 | const savedValue = mocks.setItemAsync.mock.calls[0]?.[1]; | ||
| 59 | expect(savedValue).toBeDefined(); | ||
| 60 | expect(JSON.parse(savedValue ?? "")).toEqual({ | ||
| 61 | connections: [toStableSavedRemoteConnection(managedConnection)], | ||
| 62 | }); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("loads relay-managed connection metadata without a cached access token", async () => { | ||
| 66 | await saveConnection(managedConnection); | ||
| 67 | |||
| 68 | await expect(loadSavedConnections()).resolves.toEqual([ | ||
| 69 | toStableSavedRemoteConnection(managedConnection), | ||
| 70 | ]); | ||
| 71 | }); | ||
| 72 | }); | ||
apps/mobile/src/lib/storage.ts+42-6| ... | @@ -5,10 +5,15 @@ import * as Schema from "effect/Schema"; | ... | @@ -5,10 +5,15 @@ import * as Schema from "effect/Schema"; |
| 5 | import * as SecureStore from "expo-secure-store"; | 5 | import * as SecureStore from "expo-secure-store"; |
| 6 | import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; | 6 | import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; |
| 7 | 7 | ||
| 8 | import type { SavedRemoteConnection } from "./connection"; | 8 | import { |
| 9 | isRelayManagedConnection, | ||
| 10 | type SavedRemoteConnection, | ||
| 11 | toStableSavedRemoteConnection, | ||
| 12 | } from "./connection"; | ||
| 9 | 13 | ||
| 10 | const CONNECTIONS_KEY = "t3code.connections"; | 14 | const CONNECTIONS_KEY = "t3code.connections"; |
| 11 | const PREFERENCES_KEY = "t3code.preferences"; | 15 | const PREFERENCES_KEY = "t3code.preferences"; |
| 16 | const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; | ||
| 12 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | 17 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; |
| 13 | const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; | 18 | const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; |
| 14 | 19 | ||
| ... | @@ -20,6 +25,7 @@ export interface CachedShellSnapshot { | ... | @@ -20,6 +25,7 @@ export interface CachedShellSnapshot { |
| 20 | } | 25 | } |
| 21 | 26 | ||
| 22 | export interface MobilePreferences { | 27 | export interface MobilePreferences { |
| 28 | readonly liveActivitiesEnabled?: boolean; | ||
| 23 | readonly terminalFontSize?: number; | 29 | readonly terminalFontSize?: number; |
| 24 | } | 30 | } |
| 25 | 31 | ||
| ... | @@ -133,18 +139,23 @@ export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteC | ... | @@ -133,18 +139,23 @@ export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteC |
| 133 | 139 | ||
| 134 | return pipe( | 140 | return pipe( |
| 135 | parsed.connections ?? [], | 141 | parsed.connections ?? [], |
| 136 | Arr.filter((c) => !!c.environmentId && !!c.bearerToken?.trim()), | 142 | Arr.filter( |
| 143 | (c) => !!c.environmentId && (!!c.bearerToken?.trim() || isRelayManagedConnection(c)), | ||
| 144 | ), | ||
| 137 | ); | 145 | ); |
| 138 | } | 146 | } |
| 139 | 147 | ||
| 140 | export async function saveConnection(connection: SavedRemoteConnection): Promise<void> { | 148 | export async function saveConnection(connection: SavedRemoteConnection): Promise<void> { |
| 141 | const current = await loadSavedConnections(); | 149 | const current = await loadSavedConnections(); |
| 150 | const stableConnection = toStableSavedRemoteConnection(connection); | ||
| 142 | const next = current.some((entry) => entry.environmentId === connection.environmentId) | 151 | const next = current.some((entry) => entry.environmentId === connection.environmentId) |
| 143 | ? pipe( | 152 | ? pipe( |
| 144 | current, | 153 | current, |
| 145 | Arr.map((entry) => (entry.environmentId === connection.environmentId ? connection : entry)), | 154 | Arr.map((entry) => |
| 155 | entry.environmentId === connection.environmentId ? stableConnection : entry, | ||
| 156 | ), | ||
| 146 | ) | 157 | ) |
| 147 | : pipe(current, Arr.append(connection)); | 158 | : pipe(current, Arr.append(stableConnection)); |
| 148 | 159 | ||
| 149 | await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); | 160 | await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); |
| 150 | } | 161 | } |
| ... | @@ -164,11 +175,19 @@ export async function loadPreferences(): Promise<MobilePreferences> { | ... | @@ -164,11 +175,19 @@ export async function loadPreferences(): Promise<MobilePreferences> { |
| 164 | return {}; | 175 | return {}; |
| 165 | } | 176 | } |
| 166 | 177 | ||
| 178 | const preferences: { | ||
| 179 | liveActivitiesEnabled?: boolean; | ||
| 180 | terminalFontSize?: number; | ||
| 181 | } = {}; | ||
| 182 | |||
| 183 | if (typeof parsed.liveActivitiesEnabled === "boolean") { | ||
| 184 | preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; | ||
| 185 | } | ||
| 167 | if (typeof parsed.terminalFontSize === "number") { | 186 | if (typeof parsed.terminalFontSize === "number") { |
| 168 | return { terminalFontSize: parsed.terminalFontSize }; | 187 | preferences.terminalFontSize = parsed.terminalFontSize; |
| 169 | } | 188 | } |
| 170 | 189 | ||
| 171 | return {}; | 190 | return preferences; |
| 172 | } | 191 | } |
| 173 | 192 | ||
| 174 | export async function savePreferencesPatch( | 193 | export async function savePreferencesPatch( |
| ... | @@ -182,3 +201,20 @@ export async function savePreferencesPatch( | ... | @@ -182,3 +201,20 @@ export async function savePreferencesPatch( |
| 182 | await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); | 201 | await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); |
| 183 | return next; | 202 | return next; |
| 184 | } | 203 | } |
| 204 | |||
| 205 | export async function loadOrCreateAgentAwarenessDeviceId(): Promise<string> { | ||
| 206 | const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); | ||
| 207 | if (existing?.trim()) { | ||
| 208 | return existing; | ||
| 209 | } | ||
| 210 | |||
| 211 | const { uuidv4 } = await import("./uuid"); | ||
| 212 | const deviceId = uuidv4(); | ||
| 213 | await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); | ||
| 214 | return deviceId; | ||
| 215 | } | ||
| 216 | |||
| 217 | export async function loadAgentAwarenessDeviceId(): Promise<string | null> { | ||
| 218 | const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); | ||
| 219 | return existing?.trim() ? existing : null; | ||
| 220 | } |
apps/mobile/src/state/remote-runtime-types.ts+1| ... | @@ -11,6 +11,7 @@ export interface ConnectedEnvironmentSummary { | ... | @@ -11,6 +11,7 @@ export interface ConnectedEnvironmentSummary { |
| 11 | readonly environmentId: EnvironmentId; | 11 | readonly environmentId: EnvironmentId; |
| 12 | readonly environmentLabel: string; | 12 | readonly environmentLabel: string; |
| 13 | readonly displayUrl: string; | 13 | readonly displayUrl: string; |
| 14 | readonly isRelayManaged: boolean; | ||
| 14 | readonly connectionState: EnvironmentConnectionState; | 15 | readonly connectionState: EnvironmentConnectionState; |
| 15 | readonly connectionError: string | null; | 16 | readonly connectionError: string | null; |
| 16 | } | 17 | } |
apps/mobile/src/state/use-composer-drafts.ts created+243| ... | @@ -0,0 +1,243 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useEffect } from "react"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; | ||
| 6 | import { appAtomRegistry } from "./atom-registry"; | ||
| 7 | |||
| 8 | const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; | ||
| 9 | const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; | ||
| 10 | const COMPOSER_DRAFTS_FILE = "drafts.json"; | ||
| 11 | const PERSIST_DEBOUNCE_MS = 200; | ||
| 12 | |||
| 13 | export interface ComposerDraft { | ||
| 14 | readonly text: string; | ||
| 15 | readonly attachments: ReadonlyArray<DraftComposerImageAttachment>; | ||
| 16 | } | ||
| 17 | |||
| 18 | interface PersistedComposerDrafts { | ||
| 19 | readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION; | ||
| 20 | readonly drafts: Record<string, ComposerDraft>; | ||
| 21 | } | ||
| 22 | |||
| 23 | const EMPTY_DRAFT: ComposerDraft = { | ||
| 24 | text: "", | ||
| 25 | attachments: [], | ||
| 26 | }; | ||
| 27 | |||
| 28 | export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).pipe( | ||
| 29 | Atom.keepAlive, | ||
| 30 | Atom.withLabel("mobile:composer-drafts"), | ||
| 31 | ); | ||
| 32 | |||
| 33 | let loadStarted = false; | ||
| 34 | let persistTimer: ReturnType<typeof setTimeout> | null = null; | ||
| 35 | |||
| 36 | function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { | ||
| 37 | if (!draft) { | ||
| 38 | return EMPTY_DRAFT; | ||
| 39 | } | ||
| 40 | return { | ||
| 41 | text: draft.text, | ||
| 42 | attachments: draft.attachments, | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 46 | function isEmptyDraft(draft: ComposerDraft): boolean { | ||
| 47 | return draft.text.length === 0 && draft.attachments.length === 0; | ||
| 48 | } | ||
| 49 | |||
| 50 | async function getComposerDraftsFile() { | ||
| 51 | const { Directory, File, Paths } = await import("expo-file-system"); | ||
| 52 | const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); | ||
| 53 | directory.create({ idempotent: true, intermediates: true }); | ||
| 54 | return new File(directory, COMPOSER_DRAFTS_FILE); | ||
| 55 | } | ||
| 56 | |||
| 57 | async function loadPersistedComposerDrafts(): Promise<Record<string, ComposerDraft>> { | ||
| 58 | try { | ||
| 59 | const file = await getComposerDraftsFile(); | ||
| 60 | if (!file.exists) { | ||
| 61 | return {}; | ||
| 62 | } | ||
| 63 | const parsed = JSON.parse(await file.text()) as Partial<PersistedComposerDrafts>; | ||
| 64 | if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) { | ||
| 65 | return {}; | ||
| 66 | } | ||
| 67 | return Object.fromEntries( | ||
| 68 | Object.entries(parsed.drafts).filter((entry): entry is [string, ComposerDraft] => { | ||
| 69 | const draft = entry[1]; | ||
| 70 | return ( | ||
| 71 | typeof draft?.text === "string" && | ||
| 72 | Array.isArray(draft.attachments) && | ||
| 73 | !isEmptyDraft(draft) | ||
| 74 | ); | ||
| 75 | }), | ||
| 76 | ); | ||
| 77 | } catch { | ||
| 78 | return {}; | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>): Promise<void> { | ||
| 83 | try { | ||
| 84 | const file = await getComposerDraftsFile(); | ||
| 85 | const nonEmptyDrafts = Object.fromEntries( | ||
| 86 | Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), | ||
| 87 | ); | ||
| 88 | const document: PersistedComposerDrafts = { | ||
| 89 | schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, | ||
| 90 | drafts: nonEmptyDrafts, | ||
| 91 | }; | ||
| 92 | if (!file.exists) { | ||
| 93 | file.create({ intermediates: true, overwrite: true }); | ||
| 94 | } | ||
| 95 | file.write(JSON.stringify(document)); | ||
| 96 | } catch { | ||
| 97 | // Draft persistence is best-effort; in-memory drafts still keep working. | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void { | ||
| 102 | if (persistTimer !== null) { | ||
| 103 | clearTimeout(persistTimer); | ||
| 104 | } | ||
| 105 | persistTimer = setTimeout(() => { | ||
| 106 | persistTimer = null; | ||
| 107 | void savePersistedComposerDrafts(drafts); | ||
| 108 | }, PERSIST_DEBOUNCE_MS); | ||
| 109 | } | ||
| 110 | |||
| 111 | export function ensureComposerDraftsLoaded(): void { | ||
| 112 | if (loadStarted) { | ||
| 113 | return; | ||
| 114 | } | ||
| 115 | loadStarted = true; | ||
| 116 | void loadPersistedComposerDrafts().then((persistedDrafts) => { | ||
| 117 | if (Object.keys(persistedDrafts).length === 0) { | ||
| 118 | return; | ||
| 119 | } | ||
| 120 | const current = appAtomRegistry.get(composerDraftsAtom); | ||
| 121 | appAtomRegistry.set(composerDraftsAtom, { | ||
| 122 | ...persistedDrafts, | ||
| 123 | ...current, | ||
| 124 | }); | ||
| 125 | }); | ||
| 126 | } | ||
| 127 | |||
| 128 | function updateComposerDrafts( | ||
| 129 | update: (current: Record<string, ComposerDraft>) => Record<string, ComposerDraft>, | ||
| 130 | ): void { | ||
| 131 | const next = update(appAtomRegistry.get(composerDraftsAtom)); | ||
| 132 | appAtomRegistry.set(composerDraftsAtom, next); | ||
| 133 | schedulePersistComposerDrafts(next); | ||
| 134 | } | ||
| 135 | |||
| 136 | export function setComposerDraftText(draftKey: string, value: string): void { | ||
| 137 | updateComposerDrafts((current) => { | ||
| 138 | const draft = { | ||
| 139 | ...normalizeDraft(current[draftKey]), | ||
| 140 | text: value, | ||
| 141 | }; | ||
| 142 | if (isEmptyDraft(draft)) { | ||
| 143 | const next = { ...current }; | ||
| 144 | delete next[draftKey]; | ||
| 145 | return next; | ||
| 146 | } | ||
| 147 | return { | ||
| 148 | ...current, | ||
| 149 | [draftKey]: draft, | ||
| 150 | }; | ||
| 151 | }); | ||
| 152 | } | ||
| 153 | |||
| 154 | export function appendComposerDraftText(draftKey: string, value: string): void { | ||
| 155 | updateComposerDrafts((current) => { | ||
| 156 | const existing = normalizeDraft(current[draftKey]); | ||
| 157 | return { | ||
| 158 | ...current, | ||
| 159 | [draftKey]: { | ||
| 160 | ...existing, | ||
| 161 | text: `${existing.text}${value}`, | ||
| 162 | }, | ||
| 163 | }; | ||
| 164 | }); | ||
| 165 | } | ||
| 166 | |||
| 167 | export function appendComposerDraftAttachments( | ||
| 168 | draftKey: string, | ||
| 169 | attachments: ReadonlyArray<DraftComposerImageAttachment>, | ||
| 170 | ): void { | ||
| 171 | if (attachments.length === 0) { | ||
| 172 | return; | ||
| 173 | } | ||
| 174 | updateComposerDrafts((current) => { | ||
| 175 | const existing = normalizeDraft(current[draftKey]); | ||
| 176 | return { | ||
| 177 | ...current, | ||
| 178 | [draftKey]: { | ||
| 179 | ...existing, | ||
| 180 | attachments: [...existing.attachments, ...attachments], | ||
| 181 | }, | ||
| 182 | }; | ||
| 183 | }); | ||
| 184 | } | ||
| 185 | |||
| 186 | export function replaceComposerDraftAttachments( | ||
| 187 | draftKey: string, | ||
| 188 | attachments: ReadonlyArray<DraftComposerImageAttachment>, | ||
| 189 | ): void { | ||
| 190 | updateComposerDrafts((current) => { | ||
| 191 | const draft = { | ||
| 192 | ...normalizeDraft(current[draftKey]), | ||
| 193 | attachments, | ||
| 194 | }; | ||
| 195 | if (isEmptyDraft(draft)) { | ||
| 196 | const next = { ...current }; | ||
| 197 | delete next[draftKey]; | ||
| 198 | return next; | ||
| 199 | } | ||
| 200 | return { | ||
| 201 | ...current, | ||
| 202 | [draftKey]: draft, | ||
| 203 | }; | ||
| 204 | }); | ||
| 205 | } | ||
| 206 | |||
| 207 | export function removeComposerDraftAttachment(draftKey: string, imageId: string): void { | ||
| 208 | updateComposerDrafts((current) => { | ||
| 209 | const existing = normalizeDraft(current[draftKey]); | ||
| 210 | const draft = { | ||
| 211 | ...existing, | ||
| 212 | attachments: existing.attachments.filter((image) => image.id !== imageId), | ||
| 213 | }; | ||
| 214 | if (isEmptyDraft(draft)) { | ||
| 215 | const next = { ...current }; | ||
| 216 | delete next[draftKey]; | ||
| 217 | return next; | ||
| 218 | } | ||
| 219 | return { | ||
| 220 | ...current, | ||
| 221 | [draftKey]: draft, | ||
| 222 | }; | ||
| 223 | }); | ||
| 224 | } | ||
| 225 | |||
| 226 | export function clearComposerDraft(draftKey: string): void { | ||
| 227 | updateComposerDrafts((current) => { | ||
| 228 | if (!current[draftKey]) { | ||
| 229 | return current; | ||
| 230 | } | ||
| 231 | const next = { ...current }; | ||
| 232 | delete next[draftKey]; | ||
| 233 | return next; | ||
| 234 | }); | ||
| 235 | } | ||
| 236 | |||
| 237 | export function useComposerDraft(draftKey: string | null): ComposerDraft { | ||
| 238 | const drafts = useAtomValue(composerDraftsAtom); | ||
| 239 | useEffect(() => { | ||
| 240 | ensureComposerDraftsLoaded(); | ||
| 241 | }, []); | ||
| 242 | return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT; | ||
| 243 | } | ||
apps/mobile/src/state/use-remote-environment-registry.test.ts created+430| ... | @@ -0,0 +1,430 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import { | ||
| 4 | createManagedRelaySession, | ||
| 5 | ManagedRelayDpopSigner, | ||
| 6 | setManagedRelaySession, | ||
| 7 | } from "@t3tools/client-runtime"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import { beforeEach, vi } from "vitest"; | ||
| 10 | |||
| 11 | const mocks = vi.hoisted(() => { | ||
| 12 | const environmentConnection = { | ||
| 13 | ensureBootstrapped: vi.fn(() => Promise.resolve()), | ||
| 14 | dispose: vi.fn(() => Promise.resolve()), | ||
| 15 | }; | ||
| 16 | const sessionConnection = { | ||
| 17 | dispose: vi.fn(() => Promise.resolve()), | ||
| 18 | reconnect: vi.fn(() => Promise.resolve()), | ||
| 19 | }; | ||
| 20 | const sessionClient = { | ||
| 21 | isHeartbeatFresh: vi.fn(() => false), | ||
| 22 | }; | ||
| 23 | return { | ||
| 24 | environmentConnection, | ||
| 25 | sessionConnection, | ||
| 26 | sessionClient, | ||
| 27 | createEnvironmentConnection: vi.fn(() => environmentConnection), | ||
| 28 | createKnownEnvironment: vi.fn((input: unknown) => input), | ||
| 29 | createWsRpcClient: vi.fn(() => ({ rpc: true })), | ||
| 30 | wsTransportConstructor: vi.fn(), | ||
| 31 | resolveRemoteWebSocketConnectionUrl: vi.fn(() => ({ _tag: "remote-ws-url-effect" })), | ||
| 32 | resolveRemoteDpopWebSocketConnectionUrl: vi.fn(), | ||
| 33 | remoteEndpointUrl: vi.fn((baseUrl: string, path: string) => new URL(path, baseUrl).toString()), | ||
| 34 | createDpopProof: vi.fn(), | ||
| 35 | refreshCloudEnvironmentConnection: vi.fn(), | ||
| 36 | bootstrapRemoteConnection: vi.fn(), | ||
| 37 | clearCachedShellSnapshot: vi.fn(() => Promise.resolve()), | ||
| 38 | clearSavedConnection: vi.fn(() => Promise.resolve()), | ||
| 39 | saveConnection: vi.fn((_connection?: unknown) => Promise.resolve()), | ||
| 40 | saveCachedShellSnapshot: vi.fn(() => Promise.resolve()), | ||
| 41 | mobileRunPromise: vi.fn((_effect?: unknown) => | ||
| 42 | Promise.resolve("wss://desktop.example/ws?wsTicket=token"), | ||
| 43 | ), | ||
| 44 | removeEnvironmentSession: vi.fn(() => null), | ||
| 45 | getEnvironmentSession: vi.fn(() => null), | ||
| 46 | setEnvironmentSession: vi.fn(), | ||
| 47 | notifyEnvironmentConnectionListeners: vi.fn(), | ||
| 48 | unregisterAgentAwarenessConnection: vi.fn(), | ||
| 49 | registerAgentAwarenessConnection: vi.fn(), | ||
| 50 | shellSnapshotInvalidate: vi.fn(), | ||
| 51 | shellSnapshotMarkPending: vi.fn(), | ||
| 52 | environmentRuntimeInvalidate: vi.fn(), | ||
| 53 | environmentRuntimePatch: vi.fn(), | ||
| 54 | clearCachedShellSnapshotMetadata: vi.fn(), | ||
| 55 | invalidateSourceControlDiscoveryForEnvironment: vi.fn(), | ||
| 56 | terminalSessionInvalidateEnvironment: vi.fn(), | ||
| 57 | subscribeTerminalMetadata: vi.fn(() => vi.fn()), | ||
| 58 | terminalDebugLog: vi.fn(), | ||
| 59 | WsTransport: function WsTransport(...args: ReadonlyArray<unknown>) { | ||
| 60 | mocks.wsTransportConstructor(...args); | ||
| 61 | }, | ||
| 62 | }; | ||
| 63 | }); | ||
| 64 | |||
| 65 | vi.mock("react-native", () => ({ | ||
| 66 | Alert: { | ||
| 67 | alert: vi.fn(), | ||
| 68 | }, | ||
| 69 | AppState: { | ||
| 70 | currentState: "active", | ||
| 71 | addEventListener: vi.fn(() => ({ remove: vi.fn() })), | ||
| 72 | }, | ||
| 73 | })); | ||
| 74 | |||
| 75 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { | ||
| 76 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); | ||
| 77 | return { | ||
| 78 | ...actual, | ||
| 79 | WsTransport: mocks.WsTransport, | ||
| 80 | createEnvironmentConnection: mocks.createEnvironmentConnection, | ||
| 81 | createKnownEnvironment: mocks.createKnownEnvironment, | ||
| 82 | createWsRpcClient: mocks.createWsRpcClient, | ||
| 83 | remoteEndpointUrl: mocks.remoteEndpointUrl, | ||
| 84 | resolveRemoteDpopWebSocketConnectionUrl: mocks.resolveRemoteDpopWebSocketConnectionUrl, | ||
| 85 | resolveRemoteWebSocketConnectionUrl: mocks.resolveRemoteWebSocketConnectionUrl, | ||
| 86 | }; | ||
| 87 | }); | ||
| 88 | |||
| 89 | vi.mock("../lib/connection", async (importOriginal) => ({ | ||
| 90 | ...(await importOriginal<typeof import("../lib/connection")>()), | ||
| 91 | bootstrapRemoteConnection: mocks.bootstrapRemoteConnection, | ||
| 92 | })); | ||
| 93 | |||
| 94 | vi.mock("../features/cloud/linkEnvironment", () => ({ | ||
| 95 | refreshCloudEnvironmentConnection: mocks.refreshCloudEnvironmentConnection, | ||
| 96 | })); | ||
| 97 | |||
| 98 | vi.mock("../lib/storage", () => ({ | ||
| 99 | clearCachedShellSnapshot: mocks.clearCachedShellSnapshot, | ||
| 100 | clearSavedConnection: mocks.clearSavedConnection, | ||
| 101 | loadCachedShellSnapshot: vi.fn(() => Promise.resolve(null)), | ||
| 102 | loadSavedConnections: vi.fn(() => Promise.resolve([])), | ||
| 103 | saveCachedShellSnapshot: mocks.saveCachedShellSnapshot, | ||
| 104 | saveConnection: mocks.saveConnection, | ||
| 105 | })); | ||
| 106 | |||
| 107 | vi.mock("../lib/runtime", () => ({ | ||
| 108 | mobileRuntime: { | ||
| 109 | runPromise: mocks.mobileRunPromise, | ||
| 110 | }, | ||
| 111 | })); | ||
| 112 | |||
| 113 | vi.mock("./environment-session-registry", () => ({ | ||
| 114 | drainEnvironmentSessions: vi.fn(() => []), | ||
| 115 | getEnvironmentSession: mocks.getEnvironmentSession, | ||
| 116 | notifyEnvironmentConnectionListeners: mocks.notifyEnvironmentConnectionListeners, | ||
| 117 | removeEnvironmentSession: mocks.removeEnvironmentSession, | ||
| 118 | setEnvironmentSession: mocks.setEnvironmentSession, | ||
| 119 | })); | ||
| 120 | |||
| 121 | vi.mock("../features/agent-awareness/remoteRegistration", () => ({ | ||
| 122 | registerAgentAwarenessConnection: mocks.registerAgentAwarenessConnection, | ||
| 123 | unregisterAgentAwarenessConnection: mocks.unregisterAgentAwarenessConnection, | ||
| 124 | unregisterAllAgentAwarenessConnections: vi.fn(), | ||
| 125 | })); | ||
| 126 | |||
| 127 | vi.mock("../features/terminal/terminalDebugLog", () => ({ | ||
| 128 | terminalDebugLog: mocks.terminalDebugLog, | ||
| 129 | })); | ||
| 130 | |||
| 131 | vi.mock("./use-environment-runtime", () => ({ | ||
| 132 | environmentRuntimeManager: { | ||
| 133 | invalidate: mocks.environmentRuntimeInvalidate, | ||
| 134 | patch: mocks.environmentRuntimePatch, | ||
| 135 | }, | ||
| 136 | useEnvironmentRuntimeStates: vi.fn(() => ({})), | ||
| 137 | })); | ||
| 138 | |||
| 139 | vi.mock("./use-shell-snapshot", () => ({ | ||
| 140 | clearCachedShellSnapshotMetadata: mocks.clearCachedShellSnapshotMetadata, | ||
| 141 | hydrateCachedShellSnapshot: vi.fn(), | ||
| 142 | markShellSnapshotLive: vi.fn(), | ||
| 143 | shellSnapshotManager: { | ||
| 144 | applyEvent: vi.fn(), | ||
| 145 | invalidate: mocks.shellSnapshotInvalidate, | ||
| 146 | markPending: mocks.shellSnapshotMarkPending, | ||
| 147 | syncSnapshot: vi.fn(), | ||
| 148 | }, | ||
| 149 | })); | ||
| 150 | |||
| 151 | vi.mock("./use-source-control-discovery", () => ({ | ||
| 152 | invalidateSourceControlDiscoveryForEnvironment: | ||
| 153 | mocks.invalidateSourceControlDiscoveryForEnvironment, | ||
| 154 | resetSourceControlDiscoveryState: vi.fn(), | ||
| 155 | })); | ||
| 156 | |||
| 157 | vi.mock("./use-terminal-session", () => ({ | ||
| 158 | subscribeTerminalMetadata: mocks.subscribeTerminalMetadata, | ||
| 159 | terminalSessionManager: { | ||
| 160 | invalidate: vi.fn(), | ||
| 161 | invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment, | ||
| 162 | }, | ||
| 163 | })); | ||
| 164 | |||
| 165 | import { | ||
| 166 | connectSavedEnvironment, | ||
| 167 | disconnectEnvironment, | ||
| 168 | reconnectEnvironmentConnectionsAfterAppResume, | ||
| 169 | } from "./use-remote-environment-registry"; | ||
| 170 | import { appAtomRegistry } from "./atom-registry"; | ||
| 171 | |||
| 172 | const environmentId = EnvironmentId.make("env-mobile-test"); | ||
| 173 | |||
| 174 | const connection = { | ||
| 175 | environmentId, | ||
| 176 | environmentLabel: "Mobile Test Desktop", | ||
| 177 | pairingUrl: "https://desktop.example/", | ||
| 178 | displayUrl: "https://desktop.example/", | ||
| 179 | httpBaseUrl: "https://desktop.example/", | ||
| 180 | wsBaseUrl: "wss://desktop.example/", | ||
| 181 | bearerToken: "remote-access-token", | ||
| 182 | } as const; | ||
| 183 | |||
| 184 | describe("mobile remote environment registry effects", () => { | ||
| 185 | beforeEach(() => { | ||
| 186 | vi.clearAllMocks(); | ||
| 187 | mocks.createEnvironmentConnection.mockReturnValue(mocks.environmentConnection); | ||
| 188 | mocks.environmentConnection.ensureBootstrapped.mockResolvedValue(undefined); | ||
| 189 | mocks.environmentConnection.dispose.mockResolvedValue(undefined); | ||
| 190 | mocks.sessionConnection.dispose.mockResolvedValue(undefined); | ||
| 191 | mocks.sessionConnection.reconnect.mockResolvedValue(undefined); | ||
| 192 | mocks.sessionClient.isHeartbeatFresh.mockReturnValue(false); | ||
| 193 | mocks.removeEnvironmentSession.mockReturnValue(null); | ||
| 194 | mocks.getEnvironmentSession.mockReturnValue(null); | ||
| 195 | mocks.mobileRunPromise.mockResolvedValue("wss://desktop.example/ws?wsTicket=token"); | ||
| 196 | mocks.createDpopProof.mockReturnValue(Effect.succeed("dpop-proof")); | ||
| 197 | mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.die("unexpected refresh")); | ||
| 198 | mocks.resolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( | ||
| 199 | Effect.succeed("wss://desktop.example/ws?wsTicket=dpop-token"), | ||
| 200 | ); | ||
| 201 | setManagedRelaySession(appAtomRegistry, null); | ||
| 202 | }); | ||
| 203 | |||
| 204 | it.effect("connects a saved managed endpoint environment through Effect-wrapped APIs", () => | ||
| 205 | Effect.gen(function* () { | ||
| 206 | yield* connectSavedEnvironment(connection); | ||
| 207 | |||
| 208 | expect(mocks.saveConnection).toHaveBeenCalledWith(connection); | ||
| 209 | expect(mocks.wsTransportConstructor).toHaveBeenCalledTimes(1); | ||
| 210 | expect(mocks.createEnvironmentConnection).toHaveBeenCalledTimes(1); | ||
| 211 | expect(mocks.setEnvironmentSession).toHaveBeenCalledWith( | ||
| 212 | connection.environmentId, | ||
| 213 | expect.objectContaining({ | ||
| 214 | connection: mocks.environmentConnection, | ||
| 215 | }), | ||
| 216 | ); | ||
| 217 | expect(mocks.subscribeTerminalMetadata).toHaveBeenCalledWith( | ||
| 218 | expect.objectContaining({ environmentId: connection.environmentId }), | ||
| 219 | ); | ||
| 220 | expect(mocks.registerAgentAwarenessConnection).toHaveBeenCalledWith(connection); | ||
| 221 | expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); | ||
| 222 | }), | ||
| 223 | ); | ||
| 224 | |||
| 225 | it.effect("uses DPoP-bound admission for a managed DPoP connection", () => | ||
| 226 | Effect.gen(function* () { | ||
| 227 | const dpopConnection = { | ||
| 228 | ...connection, | ||
| 229 | bearerToken: null, | ||
| 230 | authenticationMethod: "dpop", | ||
| 231 | dpopAccessToken: "environment-dpop-token", | ||
| 232 | } as const; | ||
| 233 | mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => | ||
| 234 | Effect.runPromise( | ||
| 235 | (effect as Effect.Effect<string, unknown, ManagedRelayDpopSigner>).pipe( | ||
| 236 | Effect.provideService( | ||
| 237 | ManagedRelayDpopSigner, | ||
| 238 | ManagedRelayDpopSigner.of({ | ||
| 239 | thumbprint: Effect.succeed("mobile-key-thumbprint"), | ||
| 240 | createProof: mocks.createDpopProof, | ||
| 241 | }), | ||
| 242 | ), | ||
| 243 | ), | ||
| 244 | ), | ||
| 245 | ); | ||
| 246 | |||
| 247 | yield* connectSavedEnvironment(dpopConnection); | ||
| 248 | const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as | ||
| 249 | | (() => Promise<string>) | ||
| 250 | | undefined; | ||
| 251 | expect(openSocket).toBeDefined(); | ||
| 252 | yield* Effect.promise(() => openSocket!()); | ||
| 253 | |||
| 254 | expect(mocks.createDpopProof).toHaveBeenCalledWith({ | ||
| 255 | method: "POST", | ||
| 256 | url: "https://desktop.example/api/auth/websocket-ticket", | ||
| 257 | accessToken: "environment-dpop-token", | ||
| 258 | }); | ||
| 259 | expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ | ||
| 260 | wsBaseUrl: dpopConnection.wsBaseUrl, | ||
| 261 | httpBaseUrl: dpopConnection.httpBaseUrl, | ||
| 262 | accessToken: "environment-dpop-token", | ||
| 263 | dpopProof: "dpop-proof", | ||
| 264 | }); | ||
| 265 | expect(mocks.resolveRemoteWebSocketConnectionUrl).not.toHaveBeenCalled(); | ||
| 266 | }), | ||
| 267 | ); | ||
| 268 | |||
| 269 | it.effect("refreshes a persisted managed connection before reconnecting", () => | ||
| 270 | Effect.gen(function* () { | ||
| 271 | const savedDpopConnection = { | ||
| 272 | ...connection, | ||
| 273 | bearerToken: null, | ||
| 274 | authenticationMethod: "dpop", | ||
| 275 | relayManaged: true, | ||
| 276 | } as const; | ||
| 277 | const refreshedConnection = { | ||
| 278 | ...savedDpopConnection, | ||
| 279 | displayUrl: "https://rotated-desktop.example/", | ||
| 280 | httpBaseUrl: "https://rotated-desktop.example/", | ||
| 281 | wsBaseUrl: "wss://rotated-desktop.example/", | ||
| 282 | dpopAccessToken: "fresh-environment-dpop-token", | ||
| 283 | } as const; | ||
| 284 | setManagedRelaySession( | ||
| 285 | appAtomRegistry, | ||
| 286 | createManagedRelaySession({ | ||
| 287 | accountId: "account-1", | ||
| 288 | readClerkToken: () => Promise.resolve("fresh-clerk-token"), | ||
| 289 | }), | ||
| 290 | ); | ||
| 291 | mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.succeed(refreshedConnection)); | ||
| 292 | mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => | ||
| 293 | Effect.runPromise( | ||
| 294 | (effect as Effect.Effect<string, unknown, ManagedRelayDpopSigner>).pipe( | ||
| 295 | Effect.provideService( | ||
| 296 | ManagedRelayDpopSigner, | ||
| 297 | ManagedRelayDpopSigner.of({ | ||
| 298 | thumbprint: Effect.succeed("mobile-key-thumbprint"), | ||
| 299 | createProof: mocks.createDpopProof, | ||
| 300 | }), | ||
| 301 | ), | ||
| 302 | ), | ||
| 303 | ), | ||
| 304 | ); | ||
| 305 | |||
| 306 | yield* connectSavedEnvironment(savedDpopConnection, { persist: false }); | ||
| 307 | const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as | ||
| 308 | | (() => Promise<string>) | ||
| 309 | | undefined; | ||
| 310 | expect(openSocket).toBeDefined(); | ||
| 311 | yield* Effect.promise(() => openSocket!()); | ||
| 312 | |||
| 313 | expect(mocks.refreshCloudEnvironmentConnection).toHaveBeenCalledWith({ | ||
| 314 | clerkToken: "fresh-clerk-token", | ||
| 315 | connection: savedDpopConnection, | ||
| 316 | }); | ||
| 317 | const persistedConnection = mocks.saveConnection.mock.calls[0]?.[0]; | ||
| 318 | expect(persistedConnection).toMatchObject({ | ||
| 319 | ...savedDpopConnection, | ||
| 320 | displayUrl: refreshedConnection.displayUrl, | ||
| 321 | httpBaseUrl: refreshedConnection.httpBaseUrl, | ||
| 322 | wsBaseUrl: refreshedConnection.wsBaseUrl, | ||
| 323 | }); | ||
| 324 | expect(persistedConnection).not.toHaveProperty("dpopAccessToken"); | ||
| 325 | expect(mocks.createDpopProof).toHaveBeenCalledWith({ | ||
| 326 | method: "POST", | ||
| 327 | url: "https://rotated-desktop.example/api/auth/websocket-ticket", | ||
| 328 | accessToken: "fresh-environment-dpop-token", | ||
| 329 | }); | ||
| 330 | expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ | ||
| 331 | wsBaseUrl: refreshedConnection.wsBaseUrl, | ||
| 332 | httpBaseUrl: refreshedConnection.httpBaseUrl, | ||
| 333 | accessToken: "fresh-environment-dpop-token", | ||
| 334 | dpopProof: "dpop-proof", | ||
| 335 | }); | ||
| 336 | }), | ||
| 337 | ); | ||
| 338 | |||
| 339 | it.effect("fails interactive connects when the managed endpoint bootstrap fails", () => | ||
| 340 | Effect.gen(function* () { | ||
| 341 | mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( | ||
| 342 | new Error("bootstrap failed"), | ||
| 343 | ); | ||
| 344 | mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ | ||
| 345 | connection: mocks.sessionConnection, | ||
| 346 | } as never); | ||
| 347 | |||
| 348 | const result = yield* Effect.exit(connectSavedEnvironment(connection)); | ||
| 349 | |||
| 350 | expect(result._tag).toBe("Failure"); | ||
| 351 | expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( | ||
| 352 | { environmentId: connection.environmentId }, | ||
| 353 | expect.any(Function), | ||
| 354 | ); | ||
| 355 | expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); | ||
| 356 | expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); | ||
| 357 | expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled(); | ||
| 358 | }), | ||
| 359 | ); | ||
| 360 | |||
| 361 | it.effect("can suppress bootstrap failures during best-effort startup reconnect", () => | ||
| 362 | Effect.gen(function* () { | ||
| 363 | mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( | ||
| 364 | new Error("bootstrap failed"), | ||
| 365 | ); | ||
| 366 | mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ | ||
| 367 | connection: mocks.sessionConnection, | ||
| 368 | } as never); | ||
| 369 | |||
| 370 | yield* connectSavedEnvironment(connection, { | ||
| 371 | persist: false, | ||
| 372 | suppressBootstrapError: true, | ||
| 373 | }); | ||
| 374 | |||
| 375 | expect(mocks.saveConnection).not.toHaveBeenCalled(); | ||
| 376 | expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); | ||
| 377 | expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); | ||
| 378 | expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); | ||
| 379 | expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled(); | ||
| 380 | expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( | ||
| 381 | { environmentId: connection.environmentId }, | ||
| 382 | expect.any(Function), | ||
| 383 | ); | ||
| 384 | }), | ||
| 385 | ); | ||
| 386 | |||
| 387 | it.effect("reconnects a stale saved environment session after app resume", () => | ||
| 388 | Effect.gen(function* () { | ||
| 389 | yield* connectSavedEnvironment(connection); | ||
| 390 | vi.clearAllMocks(); | ||
| 391 | mocks.getEnvironmentSession.mockReturnValue({ | ||
| 392 | client: mocks.sessionClient, | ||
| 393 | connection: mocks.sessionConnection, | ||
| 394 | } as never); | ||
| 395 | |||
| 396 | reconnectEnvironmentConnectionsAfterAppResume("test"); | ||
| 397 | |||
| 398 | yield* Effect.promise(() => | ||
| 399 | vi.waitFor(() => { | ||
| 400 | expect(mocks.sessionConnection.reconnect).toHaveBeenCalledTimes(1); | ||
| 401 | }), | ||
| 402 | ); | ||
| 403 | expect(mocks.shellSnapshotMarkPending).toHaveBeenCalledWith({ | ||
| 404 | environmentId: connection.environmentId, | ||
| 405 | }); | ||
| 406 | expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( | ||
| 407 | { environmentId: connection.environmentId }, | ||
| 408 | expect.any(Function), | ||
| 409 | ); | ||
| 410 | }), | ||
| 411 | ); | ||
| 412 | |||
| 413 | it.effect("disconnects and removes persisted managed endpoint state when requested", () => | ||
| 414 | Effect.gen(function* () { | ||
| 415 | mocks.removeEnvironmentSession.mockReturnValue({ | ||
| 416 | connection: mocks.sessionConnection, | ||
| 417 | } as never); | ||
| 418 | |||
| 419 | yield* disconnectEnvironment(connection.environmentId, { removeSaved: true }); | ||
| 420 | |||
| 421 | expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); | ||
| 422 | expect(mocks.unregisterAgentAwarenessConnection).toHaveBeenCalledWith( | ||
| 423 | connection.environmentId, | ||
| 424 | ); | ||
| 425 | expect(mocks.clearSavedConnection).toHaveBeenCalledWith(connection.environmentId); | ||
| 426 | expect(mocks.clearCachedShellSnapshot).toHaveBeenCalledWith(connection.environmentId); | ||
| 427 | expect(mocks.clearCachedShellSnapshotMetadata).toHaveBeenCalledWith(connection.environmentId); | ||
| 428 | }), | ||
| 429 | ); | ||
| 430 | }); | ||
apps/mobile/src/state/use-remote-environment-registry.ts+372-204| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useCallback, useEffect, useMemo } from "react"; | 2 | import { useCallback, useEffect, useMemo } from "react"; |
| 3 | import { Alert } from "react-native"; | 3 | import { Alert, AppState } from "react-native"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | type EnvironmentRuntimeState, | 6 | type EnvironmentRuntimeState, |
| ... | @@ -9,16 +9,28 @@ import { | ... | @@ -9,16 +9,28 @@ import { |
| 9 | createKnownEnvironment, | 9 | createKnownEnvironment, |
| 10 | createWsRpcClient, | 10 | createWsRpcClient, |
| 11 | EnvironmentConnectionState, | 11 | EnvironmentConnectionState, |
| 12 | ManagedRelayDpopSigner, | ||
| 12 | WsTransport, | 13 | WsTransport, |
| 14 | remoteEndpointUrl, | ||
| 15 | resolveRemoteDpopWebSocketConnectionUrl, | ||
| 13 | resolveRemoteWebSocketConnectionUrl, | 16 | resolveRemoteWebSocketConnectionUrl, |
| 17 | waitForManagedRelayClerkToken, | ||
| 14 | } from "@t3tools/client-runtime"; | 18 | } from "@t3tools/client-runtime"; |
| 15 | import type { EnvironmentId } from "@t3tools/contracts"; | 19 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 16 | import * as Arr from "effect/Array"; | 20 | import * as Arr from "effect/Array"; |
| 21 | import * as Duration from "effect/Duration"; | ||
| 22 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Order from "effect/Order"; | 23 | import * as Order from "effect/Order"; |
| 18 | import * as Option from "effect/Option"; | 24 | import * as Option from "effect/Option"; |
| 19 | import { pipe } from "effect/Function"; | 25 | import { pipe } from "effect/Function"; |
| 20 | import { Atom } from "effect/unstable/reactivity"; | 26 | import { Atom } from "effect/unstable/reactivity"; |
| 21 | import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection"; | 27 | import { |
| 28 | type SavedRemoteConnection, | ||
| 29 | bootstrapRemoteConnection, | ||
| 30 | isRelayManagedConnection, | ||
| 31 | toStableSavedRemoteConnection, | ||
| 32 | } from "../lib/connection"; | ||
| 33 | import { refreshCloudEnvironmentConnection } from "../features/cloud/linkEnvironment"; | ||
| 22 | import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; | 34 | import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; |
| 23 | import { | 35 | import { |
| 24 | clearCachedShellSnapshot, | 36 | clearCachedShellSnapshot, |
| ... | @@ -29,9 +41,10 @@ import { | ... | @@ -29,9 +41,10 @@ import { |
| 29 | saveConnection, | 41 | saveConnection, |
| 30 | } from "../lib/storage"; | 42 | } from "../lib/storage"; |
| 31 | import { appAtomRegistry } from "./atom-registry"; | 43 | import { appAtomRegistry } from "./atom-registry"; |
| 32 | import { mobileRemoteHttpRuntime } from "../lib/runtime"; | 44 | import { mobileRuntime } from "../lib/runtime"; |
| 33 | import { | 45 | import { |
| 34 | drainEnvironmentSessions, | 46 | drainEnvironmentSessions, |
| 47 | getEnvironmentSession, | ||
| 35 | notifyEnvironmentConnectionListeners, | 48 | notifyEnvironmentConnectionListeners, |
| 36 | removeEnvironmentSession, | 49 | removeEnvironmentSession, |
| 37 | setEnvironmentSession, | 50 | setEnvironmentSession, |
| ... | @@ -41,6 +54,11 @@ import { | ... | @@ -41,6 +54,11 @@ import { |
| 41 | invalidateSourceControlDiscoveryForEnvironment, | 54 | invalidateSourceControlDiscoveryForEnvironment, |
| 42 | resetSourceControlDiscoveryState, | 55 | resetSourceControlDiscoveryState, |
| 43 | } from "./use-source-control-discovery"; | 56 | } from "./use-source-control-discovery"; |
| 57 | import { | ||
| 58 | registerAgentAwarenessConnection, | ||
| 59 | unregisterAgentAwarenessConnection, | ||
| 60 | unregisterAllAgentAwarenessConnections, | ||
| 61 | } from "../features/agent-awareness/remoteRegistration"; | ||
| 44 | import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; | 62 | import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; |
| 45 | import { | 63 | import { |
| 46 | clearCachedShellSnapshotMetadata, | 64 | clearCachedShellSnapshotMetadata, |
| ... | @@ -53,6 +71,8 @@ import { subscribeTerminalMetadata, terminalSessionManager } from "./use-termina | ... | @@ -53,6 +71,8 @@ import { subscribeTerminalMetadata, terminalSessionManager } from "./use-termina |
| 53 | const terminalMetadataUnsubscribers = new Map<EnvironmentId, () => void>(); | 71 | const terminalMetadataUnsubscribers = new Map<EnvironmentId, () => void>(); |
| 54 | const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); | 72 | const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); |
| 55 | const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000; | 73 | const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000; |
| 74 | const APP_RESUME_RECONNECT_COOLDOWN_MS = 2_000; | ||
| 75 | let lastAppResumeReconnectAt = Number.NEGATIVE_INFINITY; | ||
| 56 | 76 | ||
| 57 | interface RemoteEnvironmentLocalState { | 77 | interface RemoteEnvironmentLocalState { |
| 58 | readonly isLoadingSavedConnection: boolean; | 78 | readonly isLoadingSavedConnection: boolean; |
| ... | @@ -153,225 +173,357 @@ function setEnvironmentConnectionStatus( | ... | @@ -153,225 +173,357 @@ function setEnvironmentConnectionStatus( |
| 153 | })); | 173 | })); |
| 154 | } | 174 | } |
| 155 | 175 | ||
| 156 | function withTimeout<T>( | 176 | function fromPromise<T>(tryPromise: () => Promise<T>): Effect.Effect<T, unknown> { |
| 157 | promise: Promise<T>, | 177 | return Effect.tryPromise({ |
| 158 | timeoutMs: number, | 178 | try: tryPromise, |
| 159 | timeoutMessage: string, | 179 | catch: (cause) => cause, |
| 160 | ): Promise<T> { | ||
| 161 | return new Promise<T>((resolve, reject) => { | ||
| 162 | const timeoutId = setTimeout(() => { | ||
| 163 | reject(new Error(timeoutMessage)); | ||
| 164 | }, timeoutMs); | ||
| 165 | |||
| 166 | promise.then( | ||
| 167 | (value) => { | ||
| 168 | clearTimeout(timeoutId); | ||
| 169 | resolve(value); | ||
| 170 | }, | ||
| 171 | (error: unknown) => { | ||
| 172 | clearTimeout(timeoutId); | ||
| 173 | reject(error); | ||
| 174 | }, | ||
| 175 | ); | ||
| 176 | }); | 180 | }); |
| 177 | } | 181 | } |
| 178 | 182 | ||
| 179 | export async function disconnectEnvironment( | 183 | export function disconnectEnvironment( |
| 180 | environmentId: EnvironmentId, | 184 | environmentId: EnvironmentId, |
| 181 | options?: { | 185 | options?: { |
| 182 | readonly preserveShellSnapshot?: boolean; | 186 | readonly preserveShellSnapshot?: boolean; |
| 183 | readonly removeSaved?: boolean; | 187 | readonly removeSaved?: boolean; |
| 184 | readonly preserveConnectionAttempt?: boolean; | 188 | readonly preserveConnectionAttempt?: boolean; |
| 185 | }, | 189 | }, |
| 186 | ) { | 190 | ): Effect.Effect<void, unknown> { |
| 187 | if (!options?.preserveConnectionAttempt) { | 191 | return Effect.gen(function* () { |
| 188 | environmentConnectionAttempts.cancel(environmentId); | 192 | if (!options?.preserveConnectionAttempt) { |
| 189 | } | 193 | environmentConnectionAttempts.cancel(environmentId); |
| 194 | } | ||
| 190 | 195 | ||
| 191 | const session = removeEnvironmentSession(environmentId); | 196 | const session = removeEnvironmentSession(environmentId); |
| 192 | notifyEnvironmentConnectionListeners(); | 197 | notifyEnvironmentConnectionListeners(); |
| 193 | await session?.connection.dispose(); | 198 | if (session) { |
| 194 | terminalMetadataUnsubscribers.get(environmentId)?.(); | 199 | yield* fromPromise(() => session.connection.dispose()); |
| 195 | terminalMetadataUnsubscribers.delete(environmentId); | 200 | } |
| 196 | if (!options?.preserveShellSnapshot) { | 201 | terminalMetadataUnsubscribers.get(environmentId)?.(); |
| 197 | shellSnapshotManager.invalidate({ environmentId }); | 202 | terminalMetadataUnsubscribers.delete(environmentId); |
| 198 | } | 203 | unregisterAgentAwarenessConnection(environmentId); |
| 199 | invalidateSourceControlDiscoveryForEnvironment(environmentId); | 204 | if (!options?.preserveShellSnapshot) { |
| 200 | terminalSessionManager.invalidateEnvironment(environmentId); | 205 | shellSnapshotManager.invalidate({ environmentId }); |
| 201 | environmentRuntimeManager.invalidate({ environmentId }); | 206 | } |
| 202 | 207 | invalidateSourceControlDiscoveryForEnvironment(environmentId); | |
| 203 | if (options?.removeSaved) { | 208 | terminalSessionManager.invalidateEnvironment(environmentId); |
| 204 | await clearSavedConnection(environmentId); | 209 | environmentRuntimeManager.invalidate({ environmentId }); |
| 205 | await clearCachedShellSnapshot(environmentId); | 210 | |
| 206 | clearCachedShellSnapshotMetadata(environmentId); | 211 | if (options?.removeSaved) { |
| 207 | removeSavedConnection(environmentId); | 212 | yield* Effect.all( |
| 208 | } | 213 | [ |
| 214 | fromPromise(() => clearSavedConnection(environmentId)), | ||
| 215 | fromPromise(() => clearCachedShellSnapshot(environmentId)), | ||
| 216 | ], | ||
| 217 | { concurrency: 2 }, | ||
| 218 | ); | ||
| 219 | clearCachedShellSnapshotMetadata(environmentId); | ||
| 220 | removeSavedConnection(environmentId); | ||
| 221 | } | ||
| 222 | }); | ||
| 209 | } | 223 | } |
| 210 | 224 | ||
| 211 | export async function connectSavedEnvironment( | 225 | export function connectSavedEnvironment( |
| 212 | connection: SavedRemoteConnection, | 226 | connection: SavedRemoteConnection, |
| 213 | options?: { readonly persist?: boolean }, | 227 | options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean }, |
| 214 | ) { | 228 | ): Effect.Effect<void, unknown> { |
| 215 | const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); | 229 | return Effect.gen(function* () { |
| 216 | const isCurrentAttempt = connectionAttempt.isCurrent; | 230 | const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); |
| 217 | 231 | const isCurrentAttempt = connectionAttempt.isCurrent; | |
| 218 | await disconnectEnvironment(connection.environmentId, { | 232 | let activeConnection = connection; |
| 219 | preserveShellSnapshot: true, | 233 | let initialDpopAccessToken = |
| 220 | preserveConnectionAttempt: true, | 234 | options?.persist === false ? undefined : connection.dpopAccessToken; |
| 221 | }); | 235 | |
| 222 | if (!isCurrentAttempt()) { | 236 | yield* disconnectEnvironment(connection.environmentId, { |
| 223 | return; | 237 | preserveShellSnapshot: true, |
| 224 | } | 238 | preserveConnectionAttempt: true, |
| 225 | 239 | }); | |
| 226 | if (options?.persist !== false) { | ||
| 227 | await saveConnection(connection); | ||
| 228 | if (!isCurrentAttempt()) { | 240 | if (!isCurrentAttempt()) { |
| 229 | return; | 241 | return; |
| 230 | } | 242 | } |
| 231 | } | ||
| 232 | 243 | ||
| 233 | upsertSavedConnection(connection); | 244 | if (options?.persist !== false) { |
| 234 | setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); | 245 | yield* fromPromise(() => saveConnection(toStableSavedRemoteConnection(connection))); |
| 235 | shellSnapshotManager.markPending({ environmentId: connection.environmentId }); | 246 | if (!isCurrentAttempt()) { |
| 247 | return; | ||
| 248 | } | ||
| 249 | } | ||
| 250 | |||
| 251 | upsertSavedConnection(toStableSavedRemoteConnection(connection)); | ||
| 252 | setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); | ||
| 253 | shellSnapshotManager.markPending({ environmentId: connection.environmentId }); | ||
| 254 | |||
| 255 | const transport = new WsTransport( | ||
| 256 | () => | ||
| 257 | mobileRuntime.runPromise( | ||
| 258 | isRelayManagedConnection(connection) | ||
| 259 | ? Effect.gen(function* () { | ||
| 260 | let dpopAccessToken = initialDpopAccessToken; | ||
| 261 | initialDpopAccessToken = undefined; | ||
| 262 | if (!dpopAccessToken) { | ||
| 263 | const clerkToken = yield* waitForManagedRelayClerkToken(appAtomRegistry); | ||
| 264 | const refreshedConnection = yield* refreshCloudEnvironmentConnection({ | ||
| 265 | clerkToken, | ||
| 266 | connection: activeConnection, | ||
| 267 | }); | ||
| 268 | const stableConnection = toStableSavedRemoteConnection(refreshedConnection); | ||
| 269 | activeConnection = refreshedConnection; | ||
| 270 | if (isCurrentAttempt()) { | ||
| 271 | yield* fromPromise(() => saveConnection(stableConnection)); | ||
| 272 | upsertSavedConnection(stableConnection); | ||
| 273 | } | ||
| 274 | dpopAccessToken = refreshedConnection.dpopAccessToken; | ||
| 275 | } | ||
| 276 | if (!dpopAccessToken) { | ||
| 277 | return yield* Effect.fail( | ||
| 278 | new Error("Managed environment connection did not return a DPoP access token."), | ||
| 279 | ); | ||
| 280 | } | ||
| 281 | const signer = yield* ManagedRelayDpopSigner; | ||
| 282 | const dpop = yield* signer.createProof({ | ||
| 283 | method: "POST", | ||
| 284 | url: remoteEndpointUrl( | ||
| 285 | activeConnection.httpBaseUrl, | ||
| 286 | "/api/auth/websocket-ticket", | ||
| 287 | ), | ||
| 288 | accessToken: dpopAccessToken, | ||
| 289 | }); | ||
| 290 | return yield* resolveRemoteDpopWebSocketConnectionUrl({ | ||
| 291 | wsBaseUrl: activeConnection.wsBaseUrl, | ||
| 292 | httpBaseUrl: activeConnection.httpBaseUrl, | ||
| 293 | accessToken: dpopAccessToken, | ||
| 294 | dpopProof: dpop, | ||
| 295 | }); | ||
| 296 | }) | ||
| 297 | : resolveRemoteWebSocketConnectionUrl({ | ||
| 298 | wsBaseUrl: connection.wsBaseUrl, | ||
| 299 | httpBaseUrl: connection.httpBaseUrl, | ||
| 300 | bearerToken: connection.bearerToken ?? "", | ||
| 301 | }), | ||
| 302 | ), | ||
| 303 | { | ||
| 304 | onAttempt: () => { | ||
| 305 | if (!isCurrentAttempt()) { | ||
| 306 | return; | ||
| 307 | } | ||
| 236 | 308 | ||
| 237 | const transport = new WsTransport( | 309 | environmentRuntimeManager.patch( |
| 238 | () => | 310 | { environmentId: connection.environmentId }, |
| 239 | mobileRemoteHttpRuntime.runPromise( | 311 | (previous) => { |
| 240 | resolveRemoteWebSocketConnectionUrl({ | 312 | const nextState = |
| 241 | wsBaseUrl: connection.wsBaseUrl, | 313 | previous.connectionState === "ready" || previous.connectionState === "reconnecting" |
| 242 | httpBaseUrl: connection.httpBaseUrl, | 314 | ? "reconnecting" |
| 243 | bearerToken: connection.bearerToken, | 315 | : "connecting"; |
| 316 | const keepSettledFailure = | ||
| 317 | previous.connectionState === "disconnected" && previous.connectionError !== null; | ||
| 318 | return { | ||
| 319 | ...previous, | ||
| 320 | connectionState: keepSettledFailure ? "disconnected" : nextState, | ||
| 321 | connectionError: keepSettledFailure ? previous.connectionError : null, | ||
| 322 | }; | ||
| 323 | }, | ||
| 324 | ); | ||
| 325 | }, | ||
| 326 | onError: (message) => { | ||
| 327 | if (isCurrentAttempt()) { | ||
| 328 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); | ||
| 329 | } | ||
| 330 | }, | ||
| 331 | onClose: (details) => { | ||
| 332 | if (!isCurrentAttempt()) { | ||
| 333 | return; | ||
| 334 | } | ||
| 335 | |||
| 336 | const reason = | ||
| 337 | details.reason.trim().length > 0 | ||
| 338 | ? details.reason | ||
| 339 | : details.code === 1000 | ||
| 340 | ? null | ||
| 341 | : `Remote connection closed (${details.code}).`; | ||
| 342 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); | ||
| 343 | }, | ||
| 344 | }, | ||
| 345 | ); | ||
| 346 | |||
| 347 | const client = createWsRpcClient(transport); | ||
| 348 | const environmentConnection = createEnvironmentConnection({ | ||
| 349 | kind: "saved", | ||
| 350 | knownEnvironment: { | ||
| 351 | ...createKnownEnvironment({ | ||
| 352 | id: connection.environmentId, | ||
| 353 | label: connection.environmentLabel, | ||
| 354 | source: "manual", | ||
| 355 | target: { | ||
| 356 | httpBaseUrl: connection.httpBaseUrl, | ||
| 357 | wsBaseUrl: connection.wsBaseUrl, | ||
| 358 | }, | ||
| 244 | }), | 359 | }), |
| 245 | ), | 360 | environmentId: connection.environmentId, |
| 246 | { | 361 | }, |
| 247 | onAttempt: () => { | 362 | client, |
| 363 | applyShellEvent: (event, environmentId) => { | ||
| 364 | if (isCurrentAttempt()) { | ||
| 365 | shellSnapshotManager.applyEvent({ environmentId }, event); | ||
| 366 | } | ||
| 367 | }, | ||
| 368 | syncShellSnapshot: (snapshot, environmentId) => { | ||
| 248 | if (!isCurrentAttempt()) { | 369 | if (!isCurrentAttempt()) { |
| 249 | return; | 370 | return; |
| 250 | } | 371 | } |
| 251 | 372 | ||
| 252 | environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => { | 373 | shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); |
| 253 | const nextState = | 374 | markShellSnapshotLive(environmentId); |
| 254 | previous.connectionState === "ready" || previous.connectionState === "reconnecting" | 375 | void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined); |
| 255 | ? "reconnecting" | 376 | environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ |
| 256 | : "connecting"; | 377 | ...runtime, |
| 257 | const keepSettledFailure = | 378 | connectionState: "ready", |
| 258 | previous.connectionState === "disconnected" && previous.connectionError !== null; | 379 | connectionError: null, |
| 259 | return { | 380 | })); |
| 260 | ...previous, | ||
| 261 | connectionState: keepSettledFailure ? "disconnected" : nextState, | ||
| 262 | connectionError: keepSettledFailure ? previous.connectionError : null, | ||
| 263 | }; | ||
| 264 | }); | ||
| 265 | }, | 381 | }, |
| 266 | onError: (message) => { | 382 | onShellResubscribe: (environmentId) => { |
| 267 | if (isCurrentAttempt()) { | 383 | if (isCurrentAttempt()) { |
| 268 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); | 384 | shellSnapshotManager.markPending({ environmentId }); |
| 269 | } | 385 | } |
| 270 | }, | 386 | }, |
| 271 | onClose: (details) => { | 387 | onConfigSnapshot: (serverConfig) => { |
| 272 | if (!isCurrentAttempt()) { | 388 | if (isCurrentAttempt()) { |
| 273 | return; | 389 | environmentRuntimeManager.patch( |
| 390 | { environmentId: connection.environmentId }, | ||
| 391 | (runtime) => ({ | ||
| 392 | ...runtime, | ||
| 393 | serverConfig, | ||
| 394 | }), | ||
| 395 | ); | ||
| 274 | } | 396 | } |
| 275 | |||
| 276 | const reason = | ||
| 277 | details.reason.trim().length > 0 | ||
| 278 | ? details.reason | ||
| 279 | : details.code === 1000 | ||
| 280 | ? null | ||
| 281 | : `Remote connection closed (${details.code}).`; | ||
| 282 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); | ||
| 283 | }, | 397 | }, |
| 284 | }, | 398 | }); |
| 285 | ); | ||
| 286 | 399 | ||
| 287 | const client = createWsRpcClient(transport); | 400 | if (!isCurrentAttempt()) { |
| 288 | const environmentConnection = createEnvironmentConnection({ | 401 | yield* fromPromise(() => environmentConnection.dispose()); |
| 289 | kind: "saved", | 402 | return; |
| 290 | knownEnvironment: { | 403 | } |
| 291 | ...createKnownEnvironment({ | 404 | |
| 292 | id: connection.environmentId, | 405 | setEnvironmentSession(connection.environmentId, { |
| 293 | label: connection.environmentLabel, | 406 | client, |
| 294 | source: "manual", | 407 | connection: environmentConnection, |
| 295 | target: { | 408 | }); |
| 296 | httpBaseUrl: connection.httpBaseUrl, | 409 | |
| 297 | wsBaseUrl: connection.wsBaseUrl, | 410 | const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe( |
| 298 | }, | 411 | Effect.timeoutOption(Duration.millis(SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS)), |
| 412 | Effect.flatMap((result) => | ||
| 413 | Option.match(result, { | ||
| 414 | onNone: () => | ||
| 415 | Effect.fail(new Error("Environment did not respond before the connection timeout.")), | ||
| 416 | onSome: Effect.succeed, | ||
| 417 | }), | ||
| 418 | ), | ||
| 419 | Effect.tapError((error: unknown) => | ||
| 420 | isCurrentAttempt() | ||
| 421 | ? Effect.gen(function* () { | ||
| 422 | setEnvironmentConnectionStatus( | ||
| 423 | connection.environmentId, | ||
| 424 | "disconnected", | ||
| 425 | error instanceof Error ? error.message : "Failed to bootstrap remote connection.", | ||
| 426 | ); | ||
| 427 | const pendingSession = removeEnvironmentSession(connection.environmentId); | ||
| 428 | notifyEnvironmentConnectionListeners(); | ||
| 429 | if (pendingSession) { | ||
| 430 | yield* fromPromise(() => pendingSession.connection.dispose()); | ||
| 431 | } | ||
| 432 | }) | ||
| 433 | : Effect.void, | ||
| 434 | ), | ||
| 435 | ); | ||
| 436 | const bootstrapped = yield* options?.suppressBootstrapError | ||
| 437 | ? bootstrap.pipe( | ||
| 438 | Effect.as(true), | ||
| 439 | Effect.catch(() => Effect.succeed(false)), | ||
| 440 | ) | ||
| 441 | : bootstrap.pipe(Effect.as(true)); | ||
| 442 | |||
| 443 | if (!bootstrapped || !isCurrentAttempt()) { | ||
| 444 | return; | ||
| 445 | } | ||
| 446 | |||
| 447 | terminalMetadataUnsubscribers.set( | ||
| 448 | connection.environmentId, | ||
| 449 | subscribeTerminalMetadata({ | ||
| 450 | environmentId: connection.environmentId, | ||
| 451 | client, | ||
| 299 | }), | 452 | }), |
| 453 | ); | ||
| 454 | terminalDebugLog("registry:terminal-metadata-subscribed", { | ||
| 300 | environmentId: connection.environmentId, | 455 | environmentId: connection.environmentId, |
| 301 | }, | 456 | }); |
| 302 | client, | 457 | registerAgentAwarenessConnection(toStableSavedRemoteConnection(activeConnection)); |
| 303 | applyShellEvent: (event, environmentId) => { | 458 | notifyEnvironmentConnectionListeners(); |
| 304 | if (isCurrentAttempt()) { | ||
| 305 | shellSnapshotManager.applyEvent({ environmentId }, event); | ||
| 306 | } | ||
| 307 | }, | ||
| 308 | syncShellSnapshot: (snapshot, environmentId) => { | ||
| 309 | if (!isCurrentAttempt()) { | ||
| 310 | return; | ||
| 311 | } | ||
| 312 | |||
| 313 | shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); | ||
| 314 | markShellSnapshotLive(environmentId); | ||
| 315 | void saveCachedShellSnapshot(environmentId, snapshot); | ||
| 316 | environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ | ||
| 317 | ...runtime, | ||
| 318 | connectionState: "ready", | ||
| 319 | connectionError: null, | ||
| 320 | })); | ||
| 321 | }, | ||
| 322 | onShellResubscribe: (environmentId) => { | ||
| 323 | if (isCurrentAttempt()) { | ||
| 324 | shellSnapshotManager.markPending({ environmentId }); | ||
| 325 | } | ||
| 326 | }, | ||
| 327 | onConfigSnapshot: (serverConfig) => { | ||
| 328 | if (isCurrentAttempt()) { | ||
| 329 | environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (runtime) => ({ | ||
| 330 | ...runtime, | ||
| 331 | serverConfig, | ||
| 332 | })); | ||
| 333 | } | ||
| 334 | }, | ||
| 335 | }); | 459 | }); |
| 460 | } | ||
| 336 | 461 | ||
| 337 | if (!isCurrentAttempt()) { | 462 | export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void { |
| 338 | await environmentConnection.dispose(); | 463 | const now = Date.now(); |
| 464 | if (now - lastAppResumeReconnectAt < APP_RESUME_RECONNECT_COOLDOWN_MS) { | ||
| 339 | return; | 465 | return; |
| 340 | } | 466 | } |
| 341 | 467 | ||
| 342 | setEnvironmentSession(connection.environmentId, { | 468 | for (const connection of Object.values(getSavedConnectionsById())) { |
| 343 | client, | 469 | const session = getEnvironmentSession(connection.environmentId); |
| 344 | connection: environmentConnection, | 470 | if (session?.client.isHeartbeatFresh()) { |
| 345 | }); | 471 | continue; |
| 346 | terminalMetadataUnsubscribers.set( | 472 | } |
| 347 | connection.environmentId, | ||
| 348 | subscribeTerminalMetadata({ | ||
| 349 | environmentId: connection.environmentId, | ||
| 350 | client, | ||
| 351 | }), | ||
| 352 | ); | ||
| 353 | terminalDebugLog("registry:terminal-metadata-subscribed", { | ||
| 354 | environmentId: connection.environmentId, | ||
| 355 | }); | ||
| 356 | notifyEnvironmentConnectionListeners(); | ||
| 357 | 473 | ||
| 358 | try { | 474 | lastAppResumeReconnectAt = now; |
| 359 | await withTimeout( | 475 | terminalDebugLog("registry:app-resume-reconnect", { |
| 360 | environmentConnection.ensureBootstrapped(), | 476 | environmentId: connection.environmentId, |
| 361 | SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS, | 477 | reason, |
| 362 | "Environment did not respond before the connection timeout.", | 478 | hasSession: session !== null, |
| 363 | ); | 479 | }); |
| 364 | } catch (error) { | 480 | |
| 365 | if (isCurrentAttempt()) { | 481 | if (!session) { |
| 366 | setEnvironmentConnectionStatus( | 482 | void mobileRuntime |
| 367 | connection.environmentId, | 483 | .runPromise( |
| 368 | "disconnected", | 484 | connectSavedEnvironment(connection, { |
| 369 | error instanceof Error ? error.message : "Failed to bootstrap remote connection.", | 485 | persist: false, |
| 370 | ); | 486 | suppressBootstrapError: true, |
| 487 | }), | ||
| 488 | ) | ||
| 489 | .catch((error: unknown) => { | ||
| 490 | terminalDebugLog("registry:app-resume-reconnect-failed", { | ||
| 491 | environmentId: connection.environmentId, | ||
| 492 | reason, | ||
| 493 | error: error instanceof Error ? error.message : String(error), | ||
| 494 | }); | ||
| 495 | }); | ||
| 496 | continue; | ||
| 371 | } | 497 | } |
| 498 | |||
| 499 | setEnvironmentConnectionStatus(connection.environmentId, "reconnecting", null); | ||
| 500 | shellSnapshotManager.markPending({ environmentId: connection.environmentId }); | ||
| 501 | void session.connection.reconnect().catch((error: unknown) => { | ||
| 502 | const message = | ||
| 503 | error instanceof Error ? error.message : "Failed to reconnect remote environment."; | ||
| 504 | setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); | ||
| 505 | terminalDebugLog("registry:app-resume-reconnect-failed", { | ||
| 506 | environmentId: connection.environmentId, | ||
| 507 | reason, | ||
| 508 | error: message, | ||
| 509 | }); | ||
| 510 | }); | ||
| 372 | } | 511 | } |
| 373 | } | 512 | } |
| 374 | 513 | ||
| 514 | function subscribeAppResumeReconnects(): () => void { | ||
| 515 | let previousAppState = AppState.currentState; | ||
| 516 | const subscription = AppState.addEventListener("change", (nextAppState) => { | ||
| 517 | const wasInactive = previousAppState !== "active"; | ||
| 518 | previousAppState = nextAppState; | ||
| 519 | if (nextAppState === "active" && wasInactive) { | ||
| 520 | reconnectEnvironmentConnectionsAfterAppResume("appstate"); | ||
| 521 | } | ||
| 522 | }); | ||
| 523 | |||
| 524 | return () => subscription.remove(); | ||
| 525 | } | ||
| 526 | |||
| 375 | const environmentsSortOrder = Order.mapInput( | 527 | const environmentsSortOrder = Order.mapInput( |
| 376 | Order.Struct({ | 528 | Order.Struct({ |
| 377 | environmentLabel: Order.String, | 529 | environmentLabel: Order.String, |
| ... | @@ -392,6 +544,7 @@ function deriveConnectedEnvironments( | ... | @@ -392,6 +544,7 @@ function deriveConnectedEnvironments( |
| 392 | environmentId: connection.environmentId, | 544 | environmentId: connection.environmentId, |
| 393 | environmentLabel: connection.environmentLabel, | 545 | environmentLabel: connection.environmentLabel, |
| 394 | displayUrl: connection.displayUrl, | 546 | displayUrl: connection.displayUrl, |
| 547 | isRelayManaged: isRelayManagedConnection(connection), | ||
| 395 | connectionState: runtime?.connectionState ?? "idle", | 548 | connectionState: runtime?.connectionState ?? "idle", |
| 396 | connectionError: runtime?.connectionError ?? null, | 549 | connectionError: runtime?.connectionError ?? null, |
| 397 | }; | 550 | }; |
| ... | @@ -403,9 +556,11 @@ function deriveConnectedEnvironments( | ... | @@ -403,9 +556,11 @@ function deriveConnectedEnvironments( |
| 403 | export function useRemoteEnvironmentBootstrap() { | 556 | export function useRemoteEnvironmentBootstrap() { |
| 404 | useEffect(() => { | 557 | useEffect(() => { |
| 405 | let cancelled = false; | 558 | let cancelled = false; |
| 559 | const unsubscribeAppResumeReconnects = subscribeAppResumeReconnects(); | ||
| 406 | 560 | ||
| 407 | void loadSavedConnections() | 561 | void (async () => { |
| 408 | .then((connections) => { | 562 | try { |
| 563 | const connections = await loadSavedConnections(); | ||
| 409 | if (cancelled) { | 564 | if (cancelled) { |
| 410 | return; | 565 | return; |
| 411 | } | 566 | } |
| ... | @@ -418,37 +573,40 @@ export function useRemoteEnvironmentBootstrap() { | ... | @@ -418,37 +573,40 @@ export function useRemoteEnvironmentBootstrap() { |
| 418 | 573 | ||
| 419 | setIsLoadingSavedConnection(false); | 574 | setIsLoadingSavedConnection(false); |
| 420 | 575 | ||
| 421 | void (async () => { | 576 | await Promise.all( |
| 422 | await Promise.all( | 577 | connections.map(async (connection) => { |
| 423 | connections.map(async (connection) => { | 578 | const cached = await loadCachedShellSnapshot(connection.environmentId); |
| 424 | const cached = await loadCachedShellSnapshot(connection.environmentId); | 579 | if (!cancelled && cached) { |
| 425 | if (!cancelled && cached) { | 580 | hydrateCachedShellSnapshot(cached); |
| 426 | hydrateCachedShellSnapshot(cached); | 581 | } |
| 427 | } | 582 | }), |
| 428 | }), | 583 | ); |
| 429 | ); | ||
| 430 | 584 | ||
| 431 | if (cancelled) { | 585 | if (cancelled) { |
| 432 | return; | 586 | return; |
| 433 | } | 587 | } |
| 434 | 588 | ||
| 435 | await Promise.all( | 589 | await mobileRuntime.runPromise( |
| 590 | Effect.all( | ||
| 436 | connections.map((connection) => | 591 | connections.map((connection) => |
| 437 | connectSavedEnvironment(connection, { | 592 | connectSavedEnvironment(connection, { |
| 438 | persist: false, | 593 | persist: false, |
| 594 | suppressBootstrapError: true, | ||
| 439 | }), | 595 | }), |
| 440 | ), | 596 | ), |
| 441 | ); | 597 | { concurrency: "unbounded" }, |
| 442 | })(); | 598 | ), |
| 443 | }) | 599 | ); |
| 444 | .catch(() => { | 600 | } catch { |
| 445 | if (!cancelled) { | 601 | if (!cancelled) { |
| 446 | setIsLoadingSavedConnection(false); | 602 | setIsLoadingSavedConnection(false); |
| 447 | } | 603 | } |
| 448 | }); | 604 | } |
| 605 | })(); | ||
| 449 | 606 | ||
| 450 | return () => { | 607 | return () => { |
| 451 | cancelled = true; | 608 | cancelled = true; |
| 609 | unsubscribeAppResumeReconnects(); | ||
| 452 | for (const session of drainEnvironmentSessions()) { | 610 | for (const session of drainEnvironmentSessions()) { |
| 453 | void session.connection.dispose(); | 611 | void session.connection.dispose(); |
| 454 | } | 612 | } |
| ... | @@ -457,6 +615,7 @@ export function useRemoteEnvironmentBootstrap() { | ... | @@ -457,6 +615,7 @@ export function useRemoteEnvironmentBootstrap() { |
| 457 | } | 615 | } |
| 458 | terminalMetadataUnsubscribers.clear(); | 616 | terminalMetadataUnsubscribers.clear(); |
| 459 | environmentConnectionAttempts.clear(); | 617 | environmentConnectionAttempts.clear(); |
| 618 | unregisterAllAgentAwarenessConnections(); | ||
| 460 | environmentRuntimeManager.invalidate(); | 619 | environmentRuntimeManager.invalidate(); |
| 461 | shellSnapshotManager.invalidate(); | 620 | shellSnapshotManager.invalidate(); |
| 462 | resetSourceControlDiscoveryState(); | 621 | resetSourceControlDiscoveryState(); |
| ... | @@ -538,7 +697,7 @@ export function useRemoteConnections() { | ... | @@ -538,7 +697,7 @@ export function useRemoteConnections() { |
| 538 | const nextPairingUrl = pairingUrl ?? connectionPairingUrl; | 697 | const nextPairingUrl = pairingUrl ?? connectionPairingUrl; |
| 539 | const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); | 698 | const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); |
| 540 | clearPendingConnectionError(); | 699 | clearPendingConnectionError(); |
| 541 | await connectSavedEnvironment(connection); | 700 | await mobileRuntime.runPromise(connectSavedEnvironment(connection)); |
| 542 | clearConnectionPairingUrl(); | 701 | clearConnectionPairingUrl(); |
| 543 | } catch (error) { | 702 | } catch (error) { |
| 544 | setPendingConnectionError( | 703 | setPendingConnectionError( |
| ... | @@ -556,7 +715,7 @@ export function useRemoteConnections() { | ... | @@ -556,7 +715,7 @@ export function useRemoteConnections() { |
| 556 | updates: { readonly label: string; readonly displayUrl: string }, | 715 | updates: { readonly label: string; readonly displayUrl: string }, |
| 557 | ) => { | 716 | ) => { |
| 558 | const connection = getSavedConnectionsById()[environmentId]; | 717 | const connection = getSavedConnectionsById()[environmentId]; |
| 559 | if (!connection) { | 718 | if (!connection || isRelayManagedConnection(connection)) { |
| 560 | return; | 719 | return; |
| 561 | } | 720 | } |
| 562 | 721 | ||
| ... | @@ -577,7 +736,14 @@ export function useRemoteConnections() { | ... | @@ -577,7 +736,14 @@ export function useRemoteConnections() { |
| 577 | if (!connection) { | 736 | if (!connection) { |
| 578 | return; | 737 | return; |
| 579 | } | 738 | } |
| 580 | void connectSavedEnvironment(connection, { persist: false }); | 739 | void mobileRuntime |
| 740 | .runPromise( | ||
| 741 | connectSavedEnvironment(connection, { | ||
| 742 | persist: false, | ||
| 743 | suppressBootstrapError: true, | ||
| 744 | }), | ||
| 745 | ) | ||
| 746 | .catch(() => undefined); | ||
| 581 | }, []); | 747 | }, []); |
| 582 | 748 | ||
| 583 | const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { | 749 | const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { |
| ... | @@ -595,7 +761,9 @@ export function useRemoteConnections() { | ... | @@ -595,7 +761,9 @@ export function useRemoteConnections() { |
| 595 | text: "Remove", | 761 | text: "Remove", |
| 596 | style: "destructive", | 762 | style: "destructive", |
| 597 | onPress: () => { | 763 | onPress: () => { |
| 598 | void disconnectEnvironment(environmentId, { removeSaved: true }); | 764 | void mobileRuntime |
| 765 | .runPromise(disconnectEnvironment(environmentId, { removeSaved: true })) | ||
| 766 | .catch(() => undefined); | ||
| 599 | }, | 767 | }, |
| 600 | }, | 768 | }, |
| 601 | ], | 769 | ], |
apps/mobile/src/state/use-thread-composer-state.ts+41-90| ... | @@ -16,6 +16,16 @@ import type { DraftComposerImageAttachment } from "../lib/composerImages"; | ... | @@ -16,6 +16,16 @@ import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 16 | import { scopedThreadKey } from "../lib/scopedEntities"; | 16 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 17 | import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity"; | 17 | import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity"; |
| 18 | import { appAtomRegistry } from "../state/atom-registry"; | 18 | import { appAtomRegistry } from "../state/atom-registry"; |
| 19 | import { | ||
| 20 | appendComposerDraftAttachments, | ||
| 21 | appendComposerDraftText, | ||
| 22 | clearComposerDraft, | ||
| 23 | composerDraftsAtom, | ||
| 24 | ensureComposerDraftsLoaded, | ||
| 25 | removeComposerDraftAttachment, | ||
| 26 | setComposerDraftText, | ||
| 27 | useComposerDraft, | ||
| 28 | } from "./use-composer-drafts"; | ||
| 19 | import { getEnvironmentClient } from "./environment-session-registry"; | 29 | import { getEnvironmentClient } from "./environment-session-registry"; |
| 20 | import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types"; | 30 | import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types"; |
| 21 | import { | 31 | import { |
| ... | @@ -26,15 +36,6 @@ import { useRemoteCatalog } from "../state/use-remote-catalog"; | ... | @@ -26,15 +36,6 @@ import { useRemoteCatalog } from "../state/use-remote-catalog"; |
| 26 | import { useSelectedThreadDetail } from "../state/use-thread-detail"; | 36 | import { useSelectedThreadDetail } from "../state/use-thread-detail"; |
| 27 | import { useThreadSelection } from "../state/use-thread-selection"; | 37 | import { useThreadSelection } from "../state/use-thread-selection"; |
| 28 | 38 | ||
| 29 | const draftMessageByThreadKeyAtom = Atom.make<Record<string, string>>({}).pipe( | ||
| 30 | Atom.keepAlive, | ||
| 31 | Atom.withLabel("mobile:thread-composer:draft-message"), | ||
| 32 | ); | ||
| 33 | |||
| 34 | const draftAttachmentsByThreadKeyAtom = Atom.make< | ||
| 35 | Record<string, ReadonlyArray<DraftComposerImageAttachment>> | ||
| 36 | >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:draft-attachments")); | ||
| 37 | |||
| 38 | const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe( | 39 | const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe( |
| 39 | Atom.keepAlive, | 40 | Atom.keepAlive, |
| 40 | Atom.withLabel("mobile:thread-composer:dispatching-message-id"), | 41 | Atom.withLabel("mobile:thread-composer:dispatching-message-id"), |
| ... | @@ -44,33 +45,6 @@ const queuedMessagesByThreadKeyAtom = Atom.make<Record<string, ReadonlyArray<Que | ... | @@ -44,33 +45,6 @@ const queuedMessagesByThreadKeyAtom = Atom.make<Record<string, ReadonlyArray<Que |
| 44 | {}, | 45 | {}, |
| 45 | ).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages")); | 46 | ).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages")); |
| 46 | 47 | ||
| 47 | function setDraftMessage(threadKey: string, value: string): void { | ||
| 48 | const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); | ||
| 49 | appAtomRegistry.set(draftMessageByThreadKeyAtom, { | ||
| 50 | ...current, | ||
| 51 | [threadKey]: value, | ||
| 52 | }); | ||
| 53 | } | ||
| 54 | |||
| 55 | function appendDraftAttachments( | ||
| 56 | threadKey: string, | ||
| 57 | attachments: ReadonlyArray<DraftComposerImageAttachment>, | ||
| 58 | ): void { | ||
| 59 | const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); | ||
| 60 | appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { | ||
| 61 | ...current, | ||
| 62 | [threadKey]: [...(current[threadKey] ?? []), ...attachments], | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | |||
| 66 | function appendDraftMessage(threadKey: string, value: string): void { | ||
| 67 | const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); | ||
| 68 | appAtomRegistry.set(draftMessageByThreadKeyAtom, { | ||
| 69 | ...current, | ||
| 70 | [threadKey]: `${current[threadKey] ?? ""}${value}`, | ||
| 71 | }); | ||
| 72 | } | ||
| 73 | |||
| 74 | export function appendReviewCommentToDraft(input: { | 48 | export function appendReviewCommentToDraft(input: { |
| 75 | readonly environmentId: EnvironmentId; | 49 | readonly environmentId: EnvironmentId; |
| 76 | readonly threadId: ThreadId; | 50 | readonly threadId: ThreadId; |
| ... | @@ -78,15 +52,11 @@ export function appendReviewCommentToDraft(input: { | ... | @@ -78,15 +52,11 @@ export function appendReviewCommentToDraft(input: { |
| 78 | readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>; | 52 | readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>; |
| 79 | }): void { | 53 | }): void { |
| 80 | const threadKey = scopedThreadKey(input.environmentId, input.threadId); | 54 | const threadKey = scopedThreadKey(input.environmentId, input.threadId); |
| 81 | const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); | 55 | const existing = appAtomRegistry.get(composerDraftsAtom)[threadKey]?.text ?? ""; |
| 82 | const existing = current[threadKey] ?? ""; | ||
| 83 | const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : ""; | 56 | const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : ""; |
| 84 | appAtomRegistry.set(draftMessageByThreadKeyAtom, { | 57 | setComposerDraftText(threadKey, `${existing}${separator}${input.text}`); |
| 85 | ...current, | ||
| 86 | [threadKey]: `${existing}${separator}${input.text}`, | ||
| 87 | }); | ||
| 88 | if (input.attachments && input.attachments.length > 0) { | 58 | if (input.attachments && input.attachments.length > 0) { |
| 89 | appendDraftAttachments(threadKey, input.attachments); | 59 | appendComposerDraftAttachments(threadKey, input.attachments); |
| 90 | } | 60 | } |
| 91 | } | 61 | } |
| 92 | 62 | ||
| ... | @@ -94,40 +64,18 @@ export function useThreadDraftForThread(input: { | ... | @@ -94,40 +64,18 @@ export function useThreadDraftForThread(input: { |
| 94 | readonly environmentId?: EnvironmentId; | 64 | readonly environmentId?: EnvironmentId; |
| 95 | readonly threadId?: ThreadId; | 65 | readonly threadId?: ThreadId; |
| 96 | }) { | 66 | }) { |
| 97 | const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom); | ||
| 98 | const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom); | ||
| 99 | const threadKey = | 67 | const threadKey = |
| 100 | input.environmentId && input.threadId | 68 | input.environmentId && input.threadId |
| 101 | ? scopedThreadKey(input.environmentId, input.threadId) | 69 | ? scopedThreadKey(input.environmentId, input.threadId) |
| 102 | : null; | 70 | : null; |
| 71 | const draft = useComposerDraft(threadKey); | ||
| 103 | 72 | ||
| 104 | return { | 73 | return { |
| 105 | draftMessage: threadKey ? (draftMessageByThreadKey[threadKey] ?? "") : "", | 74 | draftMessage: draft.text, |
| 106 | draftAttachments: threadKey ? (draftAttachmentsByThreadKey[threadKey] ?? []) : [], | 75 | draftAttachments: draft.attachments, |
| 107 | }; | 76 | }; |
| 108 | } | 77 | } |
| 109 | 78 | ||
| 110 | function clearDraft(threadKey: string): void { | ||
| 111 | const draftMessages = appAtomRegistry.get(draftMessageByThreadKeyAtom); | ||
| 112 | const draftAttachments = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); | ||
| 113 | appAtomRegistry.set(draftMessageByThreadKeyAtom, { | ||
| 114 | ...draftMessages, | ||
| 115 | [threadKey]: "", | ||
| 116 | }); | ||
| 117 | appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { | ||
| 118 | ...draftAttachments, | ||
| 119 | [threadKey]: [], | ||
| 120 | }); | ||
| 121 | } | ||
| 122 | |||
| 123 | function removeDraftImage(threadKey: string, imageId: string): void { | ||
| 124 | const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); | ||
| 125 | appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { | ||
| 126 | ...current, | ||
| 127 | [threadKey]: (current[threadKey] ?? []).filter((image) => image.id !== imageId), | ||
| 128 | }); | ||
| 129 | } | ||
| 130 | |||
| 131 | function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { | 79 | function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { |
| 132 | appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); | 80 | appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); |
| 133 | } | 81 | } |
| ... | @@ -231,11 +179,14 @@ export function useThreadComposerState() { | ... | @@ -231,11 +179,14 @@ export function useThreadComposerState() { |
| 231 | const { threads } = useRemoteCatalog(); | 179 | const { threads } = useRemoteCatalog(); |
| 232 | const { selectedThread: selectedThreadShell } = useThreadSelection(); | 180 | const { selectedThread: selectedThreadShell } = useThreadSelection(); |
| 233 | const selectedThread = useSelectedThreadDetail(); | 181 | const selectedThread = useSelectedThreadDetail(); |
| 234 | const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom); | 182 | const composerDrafts = useAtomValue(composerDraftsAtom); |
| 235 | const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom); | ||
| 236 | const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); | 183 | const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); |
| 237 | const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom); | 184 | const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom); |
| 238 | 185 | ||
| 186 | useEffect(() => { | ||
| 187 | ensureComposerDraftsLoaded(); | ||
| 188 | }, []); | ||
| 189 | |||
| 239 | const selectedThreadKey = selectedThreadShell | 190 | const selectedThreadKey = selectedThreadShell |
| 240 | ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) | 191 | ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) |
| 241 | : null; | 192 | : null; |
| ... | @@ -252,10 +203,9 @@ export function useThreadComposerState() { | ... | @@ -252,10 +203,9 @@ export function useThreadComposerState() { |
| 252 | [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages], | 203 | [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages], |
| 253 | ); | 204 | ); |
| 254 | 205 | ||
| 255 | const draftMessage = selectedThreadKey ? (draftMessageByThreadKey[selectedThreadKey] ?? "") : ""; | 206 | const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; |
| 256 | const draftAttachments = selectedThreadKey | 207 | const draftMessage = selectedDraft?.text ?? ""; |
| 257 | ? (draftAttachmentsByThreadKey[selectedThreadKey] ?? []) | 208 | const draftAttachments = selectedDraft?.attachments ?? []; |
| 258 | : []; | ||
| 259 | const selectedThreadQueueCount = selectedThreadQueuedMessages.length; | 209 | const selectedThreadQueueCount = selectedThreadQueuedMessages.length; |
| 260 | 210 | ||
| 261 | const selectedThreadSessionActivity = useMemo(() => { | 211 | const selectedThreadSessionActivity = useMemo(() => { |
| ... | @@ -350,8 +300,9 @@ export function useThreadComposerState() { | ... | @@ -350,8 +300,9 @@ export function useThreadComposerState() { |
| 350 | } | 300 | } |
| 351 | 301 | ||
| 352 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | 302 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 353 | const text = (draftMessageByThreadKey[threadKey] ?? "").trim(); | 303 | const draft = composerDrafts[threadKey]; |
| 354 | const attachments = draftAttachmentsByThreadKey[threadKey] ?? []; | 304 | const text = (draft?.text ?? "").trim(); |
| 305 | const attachments = draft?.attachments ?? []; | ||
| 355 | if (text.length === 0 && attachments.length === 0) { | 306 | if (text.length === 0 && attachments.length === 0) { |
| 356 | return; | 307 | return; |
| 357 | } | 308 | } |
| ... | @@ -366,8 +317,8 @@ export function useThreadComposerState() { | ... | @@ -366,8 +317,8 @@ export function useThreadComposerState() { |
| 366 | attachments, | 317 | attachments, |
| 367 | createdAt: metadata.createdAt, | 318 | createdAt: metadata.createdAt, |
| 368 | }); | 319 | }); |
| 369 | clearDraft(threadKey); | 320 | clearComposerDraft(threadKey); |
| 370 | }, [draftAttachmentsByThreadKey, draftMessageByThreadKey, selectedThreadShell]); | 321 | }, [composerDrafts, selectedThreadShell]); |
| 371 | 322 | ||
| 372 | const onChangeDraftMessage = useCallback( | 323 | const onChangeDraftMessage = useCallback( |
| 373 | (value: string) => { | 324 | (value: string) => { |
| ... | @@ -376,7 +327,7 @@ export function useThreadComposerState() { | ... | @@ -376,7 +327,7 @@ export function useThreadComposerState() { |
| 376 | } | 327 | } |
| 377 | 328 | ||
| 378 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | 329 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 379 | setDraftMessage(threadKey, value); | 330 | setComposerDraftText(threadKey, value); |
| 380 | }, | 331 | }, |
| 381 | [selectedThreadShell], | 332 | [selectedThreadShell], |
| 382 | ); | 333 | ); |
| ... | @@ -388,15 +339,15 @@ export function useThreadComposerState() { | ... | @@ -388,15 +339,15 @@ export function useThreadComposerState() { |
| 388 | 339 | ||
| 389 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | 340 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 390 | const result = await pickComposerImages({ | 341 | const result = await pickComposerImages({ |
| 391 | existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, | 342 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, |
| 392 | }); | 343 | }); |
| 393 | if (result.images.length > 0) { | 344 | if (result.images.length > 0) { |
| 394 | appendDraftAttachments(threadKey, result.images); | 345 | appendComposerDraftAttachments(threadKey, result.images); |
| 395 | } | 346 | } |
| 396 | if (result.error) { | 347 | if (result.error) { |
| 397 | setPendingConnectionError(result.error); | 348 | setPendingConnectionError(result.error); |
| 398 | } | 349 | } |
| 399 | }, [draftAttachmentsByThreadKey, selectedThreadShell]); | 350 | }, [composerDrafts, selectedThreadShell]); |
| 400 | 351 | ||
| 401 | const onPasteIntoDraft = useCallback(async () => { | 352 | const onPasteIntoDraft = useCallback(async () => { |
| 402 | if (!selectedThreadShell) { | 353 | if (!selectedThreadShell) { |
| ... | @@ -405,18 +356,18 @@ export function useThreadComposerState() { | ... | @@ -405,18 +356,18 @@ export function useThreadComposerState() { |
| 405 | 356 | ||
| 406 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | 357 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 407 | const result = await pasteComposerClipboard({ | 358 | const result = await pasteComposerClipboard({ |
| 408 | existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, | 359 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, |
| 409 | }); | 360 | }); |
| 410 | if (result.images.length > 0) { | 361 | if (result.images.length > 0) { |
| 411 | appendDraftAttachments(threadKey, result.images); | 362 | appendComposerDraftAttachments(threadKey, result.images); |
| 412 | } | 363 | } |
| 413 | if (result.text) { | 364 | if (result.text) { |
| 414 | appendDraftMessage(threadKey, result.text); | 365 | appendComposerDraftText(threadKey, result.text); |
| 415 | } | 366 | } |
| 416 | if (result.error) { | 367 | if (result.error) { |
| 417 | setPendingConnectionError(result.error); | 368 | setPendingConnectionError(result.error); |
| 418 | } | 369 | } |
| 419 | }, [draftAttachmentsByThreadKey, selectedThreadShell]); | 370 | }, [composerDrafts, selectedThreadShell]); |
| 420 | 371 | ||
| 421 | const onNativePasteImages = useCallback( | 372 | const onNativePasteImages = useCallback( |
| 422 | async (uris: ReadonlyArray<string>) => { | 373 | async (uris: ReadonlyArray<string>) => { |
| ... | @@ -428,16 +379,16 @@ export function useThreadComposerState() { | ... | @@ -428,16 +379,16 @@ export function useThreadComposerState() { |
| 428 | try { | 379 | try { |
| 429 | const images = await convertPastedImagesToAttachments({ | 380 | const images = await convertPastedImagesToAttachments({ |
| 430 | uris, | 381 | uris, |
| 431 | existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, | 382 | existingCount: composerDrafts[threadKey]?.attachments.length ?? 0, |
| 432 | }); | 383 | }); |
| 433 | if (images.length > 0) { | 384 | if (images.length > 0) { |
| 434 | appendDraftAttachments(threadKey, images); | 385 | appendComposerDraftAttachments(threadKey, images); |
| 435 | } | 386 | } |
| 436 | } catch (error) { | 387 | } catch (error) { |
| 437 | console.error("[native paste] error converting images", error); | 388 | console.error("[native paste] error converting images", error); |
| 438 | } | 389 | } |
| 439 | }, | 390 | }, |
| 440 | [draftAttachmentsByThreadKey, selectedThreadShell], | 391 | [composerDrafts, selectedThreadShell], |
| 441 | ); | 392 | ); |
| 442 | 393 | ||
| 443 | const onRemoveDraftImage = useCallback( | 394 | const onRemoveDraftImage = useCallback( |
| ... | @@ -447,7 +398,7 @@ export function useThreadComposerState() { | ... | @@ -447,7 +398,7 @@ export function useThreadComposerState() { |
| 447 | } | 398 | } |
| 448 | 399 | ||
| 449 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); | 400 | const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); |
| 450 | removeDraftImage(threadKey, imageId); | 401 | removeComposerDraftAttachment(threadKey, imageId); |
| 451 | }, | 402 | }, |
| 452 | [selectedThreadShell], | 403 | [selectedThreadShell], |
| 453 | ); | 404 | ); |
apps/mobile/src/widgets/AgentActivity.test.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import { describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | vi.mock("@expo/ui/swift-ui", () => ({ | ||
| 4 | HStack: "HStack", | ||
| 5 | Spacer: "Spacer", | ||
| 6 | Text: "Text", | ||
| 7 | VStack: "VStack", | ||
| 8 | })); | ||
| 9 | |||
| 10 | vi.mock("@expo/ui/swift-ui/modifiers", () => ({ | ||
| 11 | font: (value: unknown) => value, | ||
| 12 | foregroundStyle: (value: unknown) => value, | ||
| 13 | lineLimit: (value: unknown) => value, | ||
| 14 | padding: (value: unknown) => value, | ||
| 15 | })); | ||
| 16 | |||
| 17 | vi.mock("expo-widgets", () => ({ | ||
| 18 | createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })), | ||
| 19 | })); | ||
| 20 | |||
| 21 | import { AgentActivity, type AgentActivityProps } from "./AgentActivity"; | ||
| 22 | |||
| 23 | const props = { | ||
| 24 | title: "T3 Code", | ||
| 25 | subtitle: "Agent work in progress", | ||
| 26 | activeCount: 1, | ||
| 27 | updatedAt: "2026-05-25T13:07:00.000Z", | ||
| 28 | activities: [], | ||
| 29 | } satisfies AgentActivityProps; | ||
| 30 | |||
| 31 | const environment = { | ||
| 32 | colorScheme: "dark", | ||
| 33 | isLuminanceReduced: false, | ||
| 34 | } as const; | ||
| 35 | |||
| 36 | describe("AgentActivity widget layout", () => { | ||
| 37 | it("formats its updated-at label without app-runtime helper references", () => { | ||
| 38 | expect(JSON.stringify(AgentActivity(props, environment as never))).toContain( | ||
| 39 | '"children":["Updated ","1:07"]', | ||
| 40 | ); | ||
| 41 | expect(AgentActivity.toString()).not.toContain("formatAgentActivityUpdatedAtLabel"); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("uses now when the updated-at timestamp is malformed", () => { | ||
| 45 | expect( | ||
| 46 | JSON.stringify(AgentActivity({ ...props, updatedAt: "not-a-date" }, environment as never)), | ||
| 47 | ).toContain('"children":["Updated ","now"]'); | ||
| 48 | }); | ||
| 49 | }); | ||
apps/mobile/src/widgets/AgentActivity.tsx created+321| ... | @@ -0,0 +1,321 @@ | ||
| 1 | import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui"; | ||
| 2 | import { font, foregroundStyle, lineLimit, padding } from "@expo/ui/swift-ui/modifiers"; | ||
| 3 | import { | ||
| 4 | createLiveActivity, | ||
| 5 | type LiveActivityComponent, | ||
| 6 | type LiveActivityLayout, | ||
| 7 | } from "expo-widgets"; | ||
| 8 | |||
| 9 | type LiveActivityEnvironment = Parameters<LiveActivityComponent<AgentActivityProps>>[1]; | ||
| 10 | |||
| 11 | export type AgentActivityPhase = | ||
| 12 | | "starting" | ||
| 13 | | "running" | ||
| 14 | | "waiting_for_approval" | ||
| 15 | | "waiting_for_input" | ||
| 16 | | "completed" | ||
| 17 | | "failed" | ||
| 18 | | "stale"; | ||
| 19 | |||
| 20 | export interface AgentActivityRowProps { | ||
| 21 | readonly environmentId: string; | ||
| 22 | readonly threadId: string; | ||
| 23 | readonly projectTitle: string; | ||
| 24 | readonly threadTitle: string; | ||
| 25 | readonly modelTitle: string; | ||
| 26 | readonly phase: AgentActivityPhase; | ||
| 27 | readonly status: string; | ||
| 28 | readonly updatedAt: string; | ||
| 29 | readonly deepLink: string; | ||
| 30 | } | ||
| 31 | |||
| 32 | export interface AgentActivityProps { | ||
| 33 | readonly title: string; | ||
| 34 | readonly subtitle: string; | ||
| 35 | readonly activeCount: number; | ||
| 36 | readonly updatedAt: string; | ||
| 37 | readonly activities: ReadonlyArray<AgentActivityRowProps>; | ||
| 38 | } | ||
| 39 | |||
| 40 | export function AgentActivity( | ||
| 41 | props: AgentActivityProps, | ||
| 42 | environment: LiveActivityEnvironment, | ||
| 43 | ): LiveActivityLayout { | ||
| 44 | "widget"; | ||
| 45 | |||
| 46 | const row0 = props.activities[0]; | ||
| 47 | const row1 = props.activities[1]; | ||
| 48 | const row2 = props.activities[2]; | ||
| 49 | const updatedAtMatch = /^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2}):/.exec(props.updatedAt); | ||
| 50 | const updatedAtHours24 = Number(updatedAtMatch?.[1]); | ||
| 51 | const updatedAtMinutes = updatedAtMatch?.[2]; | ||
| 52 | const updatedAt = | ||
| 53 | Number.isInteger(updatedAtHours24) && | ||
| 54 | updatedAtHours24 >= 0 && | ||
| 55 | updatedAtHours24 <= 23 && | ||
| 56 | updatedAtMinutes | ||
| 57 | ? `${updatedAtHours24 % 12 || 12}:${updatedAtMinutes}` | ||
| 58 | : "now"; | ||
| 59 | const activeLabel = `${props.activeCount} active`; | ||
| 60 | const isLight = environment.colorScheme === "light"; | ||
| 61 | const primaryForeground = isLight ? "#0f172a" : "#ffffff"; | ||
| 62 | const secondaryForeground = isLight ? "#475569" : "#cbd5e1"; | ||
| 63 | const mutedForeground = isLight ? "#64748b" : "#94a3b8"; | ||
| 64 | const tint = environment.isLuminanceReduced | ||
| 65 | ? secondaryForeground | ||
| 66 | : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" | ||
| 67 | ? "#f97316" | ||
| 68 | : row0?.phase === "failed" | ||
| 69 | ? "#ef4444" | ||
| 70 | : "#14b8a6"; | ||
| 71 | |||
| 72 | return { | ||
| 73 | banner: ( | ||
| 74 | <VStack modifiers={[padding({ all: 14 })]}> | ||
| 75 | <HStack> | ||
| 76 | <VStack> | ||
| 77 | <Text | ||
| 78 | modifiers={[font({ weight: "bold", size: 15 }), foregroundStyle(primaryForeground)]} | ||
| 79 | > | ||
| 80 | {props.title} | ||
| 81 | </Text> | ||
| 82 | <Text | ||
| 83 | modifiers={[font({ size: 12 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 84 | > | ||
| 85 | {props.subtitle} | ||
| 86 | </Text> | ||
| 87 | </VStack> | ||
| 88 | <Spacer minLength={8} /> | ||
| 89 | <Text modifiers={[font({ weight: "semibold", size: 12 }), foregroundStyle(tint)]}> | ||
| 90 | {activeLabel} | ||
| 91 | </Text> | ||
| 92 | </HStack> | ||
| 93 | {row0 ? ( | ||
| 94 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 95 | <VStack> | ||
| 96 | <Text | ||
| 97 | modifiers={[ | ||
| 98 | font({ weight: "bold", size: 13 }), | ||
| 99 | foregroundStyle(primaryForeground), | ||
| 100 | lineLimit(1), | ||
| 101 | ]} | ||
| 102 | > | ||
| 103 | {row0.threadTitle} | ||
| 104 | </Text> | ||
| 105 | <Text | ||
| 106 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 107 | > | ||
| 108 | {row0.projectTitle} - {row0.modelTitle} | ||
| 109 | </Text> | ||
| 110 | </VStack> | ||
| 111 | <Spacer minLength={8} /> | ||
| 112 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 113 | {row0.status} | ||
| 114 | </Text> | ||
| 115 | </HStack> | ||
| 116 | ) : null} | ||
| 117 | {row1 ? ( | ||
| 118 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 119 | <VStack> | ||
| 120 | <Text | ||
| 121 | modifiers={[ | ||
| 122 | font({ weight: "bold", size: 13 }), | ||
| 123 | foregroundStyle(primaryForeground), | ||
| 124 | lineLimit(1), | ||
| 125 | ]} | ||
| 126 | > | ||
| 127 | {row1.threadTitle} | ||
| 128 | </Text> | ||
| 129 | <Text | ||
| 130 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 131 | > | ||
| 132 | {row1.projectTitle} - {row1.modelTitle} | ||
| 133 | </Text> | ||
| 134 | </VStack> | ||
| 135 | <Spacer minLength={8} /> | ||
| 136 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 137 | {row1.status} | ||
| 138 | </Text> | ||
| 139 | </HStack> | ||
| 140 | ) : null} | ||
| 141 | {row2 ? ( | ||
| 142 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 143 | <VStack> | ||
| 144 | <Text | ||
| 145 | modifiers={[ | ||
| 146 | font({ weight: "bold", size: 13 }), | ||
| 147 | foregroundStyle(primaryForeground), | ||
| 148 | lineLimit(1), | ||
| 149 | ]} | ||
| 150 | > | ||
| 151 | {row2.threadTitle} | ||
| 152 | </Text> | ||
| 153 | <Text | ||
| 154 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 155 | > | ||
| 156 | {row2.projectTitle} - {row2.modelTitle} | ||
| 157 | </Text> | ||
| 158 | </VStack> | ||
| 159 | <Spacer minLength={8} /> | ||
| 160 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 161 | {row2.status} | ||
| 162 | </Text> | ||
| 163 | </HStack> | ||
| 164 | ) : null} | ||
| 165 | <Text modifiers={[font({ size: 11 }), foregroundStyle(mutedForeground)]}> | ||
| 166 | Updated {updatedAt} | ||
| 167 | </Text> | ||
| 168 | </VStack> | ||
| 169 | ), | ||
| 170 | bannerSmall: ( | ||
| 171 | <VStack modifiers={[padding({ all: 12 })]}> | ||
| 172 | <HStack> | ||
| 173 | <Text | ||
| 174 | modifiers={[font({ weight: "bold", size: 13 }), foregroundStyle(primaryForeground)]} | ||
| 175 | > | ||
| 176 | {props.title} | ||
| 177 | </Text> | ||
| 178 | <Spacer minLength={6} /> | ||
| 179 | <Text modifiers={[font({ weight: "semibold", size: 12 }), foregroundStyle(tint)]}> | ||
| 180 | {activeLabel} | ||
| 181 | </Text> | ||
| 182 | </HStack> | ||
| 183 | {row0 ? ( | ||
| 184 | <VStack> | ||
| 185 | <Text | ||
| 186 | modifiers={[ | ||
| 187 | font({ weight: "bold", size: 12 }), | ||
| 188 | foregroundStyle(primaryForeground), | ||
| 189 | lineLimit(1), | ||
| 190 | ]} | ||
| 191 | > | ||
| 192 | {row0.threadTitle} | ||
| 193 | </Text> | ||
| 194 | <Text | ||
| 195 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 196 | > | ||
| 197 | {row0.projectTitle} - {row0.status} | ||
| 198 | </Text> | ||
| 199 | </VStack> | ||
| 200 | ) : null} | ||
| 201 | </VStack> | ||
| 202 | ), | ||
| 203 | compactLeading: ( | ||
| 204 | <Text modifiers={[font({ weight: "bold", size: 11 }), foregroundStyle(tint)]}>T3</Text> | ||
| 205 | ), | ||
| 206 | compactTrailing: ( | ||
| 207 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 208 | {activeLabel} | ||
| 209 | </Text> | ||
| 210 | ), | ||
| 211 | minimal: ( | ||
| 212 | <Text modifiers={[font({ weight: "bold", size: 11 }), foregroundStyle(tint)]}>T3</Text> | ||
| 213 | ), | ||
| 214 | expandedLeading: ( | ||
| 215 | <VStack modifiers={[padding({ all: 8 })]}> | ||
| 216 | <Text modifiers={[font({ weight: "bold", size: 12 }), foregroundStyle(tint)]}> | ||
| 217 | {activeLabel} | ||
| 218 | </Text> | ||
| 219 | </VStack> | ||
| 220 | ), | ||
| 221 | expandedCenter: row0 ? ( | ||
| 222 | <VStack> | ||
| 223 | <Text | ||
| 224 | modifiers={[ | ||
| 225 | font({ weight: "bold", size: 12 }), | ||
| 226 | foregroundStyle(primaryForeground), | ||
| 227 | lineLimit(1), | ||
| 228 | ]} | ||
| 229 | > | ||
| 230 | {row0.threadTitle} | ||
| 231 | </Text> | ||
| 232 | <Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]}> | ||
| 233 | {row0.projectTitle} - {row0.status} | ||
| 234 | </Text> | ||
| 235 | </VStack> | ||
| 236 | ) : null, | ||
| 237 | expandedTrailing: ( | ||
| 238 | <Text modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground)]}> | ||
| 239 | Updated {updatedAt} | ||
| 240 | </Text> | ||
| 241 | ), | ||
| 242 | expandedBottom: ( | ||
| 243 | <VStack modifiers={[padding({ all: 8 })]}> | ||
| 244 | {row0 ? ( | ||
| 245 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 246 | <VStack> | ||
| 247 | <Text | ||
| 248 | modifiers={[ | ||
| 249 | font({ weight: "bold", size: 13 }), | ||
| 250 | foregroundStyle(primaryForeground), | ||
| 251 | lineLimit(1), | ||
| 252 | ]} | ||
| 253 | > | ||
| 254 | {row0.threadTitle} | ||
| 255 | </Text> | ||
| 256 | <Text | ||
| 257 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 258 | > | ||
| 259 | {row0.projectTitle} - {row0.modelTitle} | ||
| 260 | </Text> | ||
| 261 | </VStack> | ||
| 262 | <Spacer minLength={8} /> | ||
| 263 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 264 | {row0.status} | ||
| 265 | </Text> | ||
| 266 | </HStack> | ||
| 267 | ) : null} | ||
| 268 | {row1 ? ( | ||
| 269 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 270 | <VStack> | ||
| 271 | <Text | ||
| 272 | modifiers={[ | ||
| 273 | font({ weight: "bold", size: 13 }), | ||
| 274 | foregroundStyle(primaryForeground), | ||
| 275 | lineLimit(1), | ||
| 276 | ]} | ||
| 277 | > | ||
| 278 | {row1.threadTitle} | ||
| 279 | </Text> | ||
| 280 | <Text | ||
| 281 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 282 | > | ||
| 283 | {row1.projectTitle} - {row1.modelTitle} | ||
| 284 | </Text> | ||
| 285 | </VStack> | ||
| 286 | <Spacer minLength={8} /> | ||
| 287 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 288 | {row1.status} | ||
| 289 | </Text> | ||
| 290 | </HStack> | ||
| 291 | ) : null} | ||
| 292 | {row2 ? ( | ||
| 293 | <HStack modifiers={[padding({ vertical: 4 })]}> | ||
| 294 | <VStack> | ||
| 295 | <Text | ||
| 296 | modifiers={[ | ||
| 297 | font({ weight: "bold", size: 13 }), | ||
| 298 | foregroundStyle(primaryForeground), | ||
| 299 | lineLimit(1), | ||
| 300 | ]} | ||
| 301 | > | ||
| 302 | {row2.threadTitle} | ||
| 303 | </Text> | ||
| 304 | <Text | ||
| 305 | modifiers={[font({ size: 11 }), foregroundStyle(secondaryForeground), lineLimit(1)]} | ||
| 306 | > | ||
| 307 | {row2.projectTitle} - {row2.modelTitle} | ||
| 308 | </Text> | ||
| 309 | </VStack> | ||
| 310 | <Spacer minLength={8} /> | ||
| 311 | <Text modifiers={[font({ weight: "semibold", size: 11 }), foregroundStyle(tint)]}> | ||
| 312 | {row2.status} | ||
| 313 | </Text> | ||
| 314 | </HStack> | ||
| 315 | ) : null} | ||
| 316 | </VStack> | ||
| 317 | ), | ||
| 318 | }; | ||
| 319 | } | ||
| 320 | |||
| 321 | export default createLiveActivity<AgentActivityProps>("AgentActivity", AgentActivity); | ||
apps/server/integration/OrchestrationEngineHarness.integration.ts+8-1| ... | @@ -77,6 +77,7 @@ import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; | ... | @@ -77,6 +77,7 @@ import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; |
| 77 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; | 77 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; |
| 78 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; | 78 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; |
| 79 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; | 79 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; |
| 80 | import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; | ||
| 80 | 81 | ||
| 81 | const decodeCodexSettings = Schema.decodeEffect(CodexSettings); | 82 | const decodeCodexSettings = Schema.decodeEffect(CodexSettings); |
| 82 | 83 | ||
| ... | @@ -160,7 +161,7 @@ class OrchestrationHarnessRuntimeError extends Schema.TaggedErrorClass<Orchestra | ... | @@ -160,7 +161,7 @@ class OrchestrationHarnessRuntimeError extends Schema.TaggedErrorClass<Orchestra |
| 160 | "OrchestrationHarnessRuntimeError", | 161 | "OrchestrationHarnessRuntimeError", |
| 161 | { | 162 | { |
| 162 | operation: Schema.String, | 163 | operation: Schema.String, |
| 163 | cause: Schema.optional(Schema.Defect), | 164 | cause: Schema.optional(Schema.Defect()), |
| 164 | }, | 165 | }, |
| 165 | ) {} | 166 | ) {} |
| 166 | 167 | ||
| ... | @@ -364,6 +365,12 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -364,6 +365,12 @@ export const makeOrchestrationIntegrationHarness = ( |
| 364 | drain: Effect.void, | 365 | drain: Effect.void, |
| 365 | }), | 366 | }), |
| 366 | ), | 367 | ), |
| 368 | Layer.provideMerge( | ||
| 369 | Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { | ||
| 370 | publishThread: () => Effect.void, | ||
| 371 | start: () => Effect.void, | ||
| 372 | }), | ||
| 373 | ), | ||
| 367 | ); | 374 | ); |
| 368 | const layer = Layer.empty.pipe( | 375 | const layer = Layer.empty.pipe( |
| 369 | Layer.provideMerge(runtimeServicesLayer), | 376 | Layer.provideMerge(runtimeServicesLayer), |
apps/server/src/auth/EnvironmentAuth.ts+65-6| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 20 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | 20 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 21 | import * as Context from "effect/Context"; | 21 | import * as Context from "effect/Context"; |
| 22 | import * as Crypto from "effect/Crypto"; | ||
| 22 | import * as Data from "effect/Data"; | 23 | import * as Data from "effect/Data"; |
| 23 | import * as DateTime from "effect/DateTime"; | 24 | import * as DateTime from "effect/DateTime"; |
| 24 | import * as Duration from "effect/Duration"; | 25 | import * as Duration from "effect/Duration"; |
| ... | @@ -31,6 +32,7 @@ import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; | ... | @@ -31,6 +32,7 @@ import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; |
| 31 | import * as PairingGrantStore from "./PairingGrantStore.ts"; | 32 | import * as PairingGrantStore from "./PairingGrantStore.ts"; |
| 32 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 33 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 33 | import * as SessionStore from "./SessionStore.ts"; | 34 | import * as SessionStore from "./SessionStore.ts"; |
| 35 | import { verifyRequestDpopProof } from "./dpop.ts"; | ||
| 34 | import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; | 36 | import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; |
| 35 | 37 | ||
| 36 | export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; | 38 | export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; |
| ... | @@ -61,6 +63,7 @@ export interface AuthenticatedSession { | ... | @@ -61,6 +63,7 @@ export interface AuthenticatedSession { |
| 61 | readonly subject: string; | 63 | readonly subject: string; |
| 62 | readonly method: ServerAuthSessionMethod; | 64 | readonly method: ServerAuthSessionMethod; |
| 63 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 65 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 66 | readonly proofKeyThumbprint?: string; | ||
| 64 | readonly expiresAt?: DateTime.DateTime; | 67 | readonly expiresAt?: DateTime.DateTime; |
| 65 | } | 68 | } |
| 66 | 69 | ||
| ... | @@ -107,6 +110,9 @@ export interface EnvironmentAuthShape { | ... | @@ -107,6 +110,9 @@ export interface EnvironmentAuthShape { |
| 107 | credential: string, | 110 | credential: string, |
| 108 | requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined, | 111 | requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined, |
| 109 | requestMetadata: AuthClientMetadata, | 112 | requestMetadata: AuthClientMetadata, |
| 113 | input?: { | ||
| 114 | readonly proofKeyThumbprint?: string; | ||
| 115 | }, | ||
| 110 | ) => Effect.Effect< | 116 | ) => Effect.Effect< |
| 111 | AuthAccessTokenResult, | 117 | AuthAccessTokenResult, |
| 112 | ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError | 118 | ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError |
| ... | @@ -116,6 +122,7 @@ export interface EnvironmentAuthShape { | ... | @@ -116,6 +122,7 @@ export interface EnvironmentAuthShape { |
| 116 | readonly label?: string; | 122 | readonly label?: string; |
| 117 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | 123 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 118 | readonly subject?: string; | 124 | readonly subject?: string; |
| 125 | readonly proofKeyThumbprint?: string; | ||
| 119 | }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>; | 126 | }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>; |
| 120 | readonly issuePairingCredential: ( | 127 | readonly issuePairingCredential: ( |
| 121 | input?: AuthCreatePairingCredentialInput, | 128 | input?: AuthCreatePairingCredentialInput, |
| ... | @@ -184,6 +191,7 @@ type BootstrapExchangeResult = { | ... | @@ -184,6 +191,7 @@ type BootstrapExchangeResult = { |
| 184 | }; | 191 | }; |
| 185 | 192 | ||
| 186 | const AUTHORIZATION_PREFIX = "Bearer "; | 193 | const AUTHORIZATION_PREFIX = "Bearer "; |
| 194 | const DPOP_AUTHORIZATION_PREFIX = "DPoP "; | ||
| 187 | const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; | 195 | const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; |
| 188 | 196 | ||
| 189 | const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { | 197 | const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { |
| ... | @@ -245,10 +253,21 @@ function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | ... | @@ -245,10 +253,21 @@ function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string |
| 245 | return token.length > 0 ? token : null; | 253 | return token.length > 0 ? token : null; |
| 246 | } | 254 | } |
| 247 | 255 | ||
| 256 | function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | null { | ||
| 257 | const header = request.headers["authorization"]; | ||
| 258 | if (typeof header !== "string" || !header.startsWith(DPOP_AUTHORIZATION_PREFIX)) { | ||
| 259 | return null; | ||
| 260 | } | ||
| 261 | const token = header.slice(DPOP_AUTHORIZATION_PREFIX.length).trim(); | ||
| 262 | return token.length > 0 ? token : null; | ||
| 263 | } | ||
| 264 | |||
| 248 | export const make = Effect.fn("makeEnvironmentAuth")(function* () { | 265 | export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 249 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | 266 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; |
| 250 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | 267 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 251 | const sessions = yield* SessionStore.SessionStore; | 268 | const sessions = yield* SessionStore.SessionStore; |
| 269 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | ||
| 270 | const crypto = yield* Crypto.Crypto; | ||
| 252 | const descriptor = yield* policy.getDescriptor(); | 271 | const descriptor = yield* policy.getDescriptor(); |
| 253 | 272 | ||
| 254 | const authenticateToken = ( | 273 | const authenticateToken = ( |
| ... | @@ -270,6 +289,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -270,6 +289,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 270 | subject: session.subject, | 289 | subject: session.subject, |
| 271 | method: session.method, | 290 | method: session.method, |
| 272 | scopes: session.scopes, | 291 | scopes: session.scopes, |
| 292 | ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), | ||
| 273 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | 293 | ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), |
| 274 | })), | 294 | })), |
| 275 | mapSessionVerificationErrors, | 295 | mapSessionVerificationErrors, |
| ... | @@ -278,11 +298,43 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -278,11 +298,43 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 278 | const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { | 298 | const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { |
| 279 | const cookieToken = request.cookies[sessions.cookieName]; | 299 | const cookieToken = request.cookies[sessions.cookieName]; |
| 280 | const bearerToken = parseBearerToken(request); | 300 | const bearerToken = parseBearerToken(request); |
| 281 | const credential = cookieToken ?? bearerToken; | 301 | const dpopToken = parseDpopToken(request); |
| 302 | const credential = cookieToken ?? bearerToken ?? dpopToken; | ||
| 282 | if (!credential) { | 303 | if (!credential) { |
| 283 | return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); | 304 | return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); |
| 284 | } | 305 | } |
| 285 | return authenticateToken(credential); | 306 | return authenticateToken(credential).pipe( |
| 307 | Effect.flatMap((session) => { | ||
| 308 | if (session.proofKeyThumbprint) { | ||
| 309 | if (!dpopToken || dpopToken !== credential) { | ||
| 310 | return Effect.fail( | ||
| 311 | new ServerAuthInvalidCredentialError({ | ||
| 312 | reason: "invalid_credential", | ||
| 313 | cause: "DPoP-bound access token requires DPoP authorization.", | ||
| 314 | }), | ||
| 315 | ); | ||
| 316 | } | ||
| 317 | return verifyRequestDpopProof({ | ||
| 318 | request, | ||
| 319 | expectedThumbprint: session.proofKeyThumbprint, | ||
| 320 | expectedAccessToken: dpopToken, | ||
| 321 | }).pipe( | ||
| 322 | Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), | ||
| 323 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 324 | Effect.as(session), | ||
| 325 | ); | ||
| 326 | } | ||
| 327 | if (dpopToken) { | ||
| 328 | return Effect.fail( | ||
| 329 | new ServerAuthInvalidCredentialError({ | ||
| 330 | reason: "invalid_credential", | ||
| 331 | cause: "DPoP authorization requires a proof-bound access token.", | ||
| 332 | }), | ||
| 333 | ); | ||
| 334 | } | ||
| 335 | return Effect.succeed(session); | ||
| 336 | }), | ||
| 337 | ); | ||
| 286 | }; | 338 | }; |
| 287 | 339 | ||
| 288 | const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => | 340 | const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => |
| ... | @@ -349,8 +401,8 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -349,8 +401,8 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 349 | ); | 401 | ); |
| 350 | 402 | ||
| 351 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = | 403 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = |
| 352 | (credential, requestedScopes, requestMetadata) => | 404 | (credential, requestedScopes, requestMetadata, input) => |
| 353 | bootstrapCredentials.consume(credential).pipe( | 405 | bootstrapCredentials.consume(credential, input).pipe( |
| 354 | Effect.mapError(toBootstrapExchangeError), | 406 | Effect.mapError(toBootstrapExchangeError), |
| 355 | Effect.flatMap((grant) => | 407 | Effect.flatMap((grant) => |
| 356 | Effect.gen(function* () { | 408 | Effect.gen(function* () { |
| ... | @@ -362,9 +414,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -362,9 +414,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 362 | } | 414 | } |
| 363 | return yield* sessions | 415 | return yield* sessions |
| 364 | .issue({ | 416 | .issue({ |
| 365 | method: "bearer-access-token", | 417 | method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", |
| 366 | subject: grant.subject, | 418 | subject: grant.subject, |
| 367 | scopes: grantedScopes, | 419 | scopes: grantedScopes, |
| 420 | ...(input?.proofKeyThumbprint | ||
| 421 | ? { | ||
| 422 | proofKeyThumbprint: input.proofKeyThumbprint, | ||
| 423 | ttl: Duration.hours(1), | ||
| 424 | } | ||
| 425 | : {}), | ||
| 368 | client: { | 426 | client: { |
| 369 | ...requestMetadata, | 427 | ...requestMetadata, |
| 370 | ...(grant.label ? { label: grant.label } : {}), | 428 | ...(grant.label ? { label: grant.label } : {}), |
| ... | @@ -388,7 +446,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -388,7 +446,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 388 | ({ | 446 | ({ |
| 389 | access_token: session.token, | 447 | access_token: session.token, |
| 390 | issued_token_type: AuthAccessTokenType, | 448 | issued_token_type: AuthAccessTokenType, |
| 391 | token_type: "Bearer", | 449 | token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", |
| 392 | expires_in: Math.max( | 450 | expires_in: Math.max( |
| 393 | 0, | 451 | 0, |
| 394 | Math.floor( | 452 | Math.floor( |
| ... | @@ -434,6 +492,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -434,6 +492,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 434 | subject: input?.subject ?? "one-time-token", | 492 | subject: input?.subject ?? "one-time-token", |
| 435 | ...(input?.ttl ? { ttl: input.ttl } : {}), | 493 | ...(input?.ttl ? { ttl: input.ttl } : {}), |
| 436 | ...(input?.label ? { label: input.label } : {}), | 494 | ...(input?.label ? { label: input.label } : {}), |
| 495 | ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), | ||
| 437 | }); | 496 | }); |
| 438 | return { | 497 | return { |
| 439 | id: issued.id, | 498 | id: issued.id, |
apps/server/src/auth/EnvironmentAuthPolicy.ts+1-1| ... | @@ -39,7 +39,7 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { | ... | @@ -39,7 +39,7 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { |
| 39 | const descriptor: ServerAuthDescriptor = { | 39 | const descriptor: ServerAuthDescriptor = { |
| 40 | policy, | 40 | policy, |
| 41 | bootstrapMethods, | 41 | bootstrapMethods, |
| 42 | sessionMethods: ["browser-session-cookie", "bearer-access-token"], | 42 | sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"], |
| 43 | sessionCookieName: resolveSessionCookieName({ | 43 | sessionCookieName: resolveSessionCookieName({ |
| 44 | mode: config.mode, | 44 | mode: config.mode, |
| 45 | port: config.port, | 45 | port: config.port, |
apps/server/src/auth/PairingGrantStore.test.ts+23| ... | @@ -92,6 +92,29 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { | ... | @@ -92,6 +92,29 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 92 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | 92 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), |
| 93 | ); | 93 | ); |
| 94 | 94 | ||
| 95 | it.effect("requires the bound proof key thumbprint when present", () => | ||
| 96 | Effect.gen(function* () { | ||
| 97 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | ||
| 98 | const token = yield* bootstrapCredentials.issueOneTimeToken({ | ||
| 99 | proofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 100 | }); | ||
| 101 | |||
| 102 | const missing = yield* Effect.flip(bootstrapCredentials.consume(token.credential)); | ||
| 103 | const wrong = yield* Effect.flip( | ||
| 104 | bootstrapCredentials.consume(token.credential, { | ||
| 105 | proofKeyThumbprint: "other-proof-key-thumbprint", | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | const consumed = yield* bootstrapCredentials.consume(token.credential, { | ||
| 109 | proofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 110 | }); | ||
| 111 | |||
| 112 | expect(missing.message).toContain("proof key mismatch"); | ||
| 113 | expect(wrong.message).toContain("proof key mismatch"); | ||
| 114 | expect(consumed.proofKeyThumbprint).toBe("client-proof-key-thumbprint"); | ||
| 115 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | ||
| 116 | ); | ||
| 117 | |||
| 95 | it.effect("seeds the desktop bootstrap credential as a one-time grant", () => | 118 | it.effect("seeds the desktop bootstrap credential as a one-time grant", () => |
| 96 | Effect.gen(function* () { | 119 | Effect.gen(function* () { |
| 97 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | 120 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
apps/server/src/auth/PairingGrantStore.ts+37-2| ... | @@ -26,6 +26,7 @@ export interface BootstrapGrant { | ... | @@ -26,6 +26,7 @@ export interface BootstrapGrant { |
| 26 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 26 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 27 | readonly subject: string; | 27 | readonly subject: string; |
| 28 | readonly label?: string; | 28 | readonly label?: string; |
| 29 | readonly proofKeyThumbprint?: string; | ||
| 29 | readonly expiresAt: DateTime.DateTime; | 30 | readonly expiresAt: DateTime.DateTime; |
| 30 | } | 31 | } |
| 31 | 32 | ||
| ... | @@ -50,6 +51,7 @@ export interface IssuedBootstrapCredential { | ... | @@ -50,6 +51,7 @@ export interface IssuedBootstrapCredential { |
| 50 | readonly id: string; | 51 | readonly id: string; |
| 51 | readonly credential: string; | 52 | readonly credential: string; |
| 52 | readonly label?: string; | 53 | readonly label?: string; |
| 54 | readonly proofKeyThumbprint?: string; | ||
| 53 | readonly expiresAt: DateTime.Utc; | 55 | readonly expiresAt: DateTime.Utc; |
| 54 | } | 56 | } |
| 55 | 57 | ||
| ... | @@ -69,6 +71,7 @@ export interface PairingGrantStoreShape { | ... | @@ -69,6 +71,7 @@ export interface PairingGrantStoreShape { |
| 69 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | 71 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 70 | readonly subject?: string; | 72 | readonly subject?: string; |
| 71 | readonly label?: string; | 73 | readonly label?: string; |
| 74 | readonly proofKeyThumbprint?: string; | ||
| 72 | }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>; | 75 | }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>; |
| 73 | readonly listActive: () => Effect.Effect< | 76 | readonly listActive: () => Effect.Effect< |
| 74 | ReadonlyArray<AuthPairingLink>, | 77 | ReadonlyArray<AuthPairingLink>, |
| ... | @@ -76,7 +79,12 @@ export interface PairingGrantStoreShape { | ... | @@ -76,7 +79,12 @@ export interface PairingGrantStoreShape { |
| 76 | >; | 79 | >; |
| 77 | readonly streamChanges: Stream.Stream<BootstrapCredentialChange>; | 80 | readonly streamChanges: Stream.Stream<BootstrapCredentialChange>; |
| 78 | readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>; | 81 | readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>; |
| 79 | readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | 82 | readonly consume: ( |
| 83 | credential: string, | ||
| 84 | input?: { | ||
| 85 | readonly proofKeyThumbprint?: string; | ||
| 86 | }, | ||
| 87 | ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | ||
| 80 | } | 88 | } |
| 81 | 89 | ||
| 82 | export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()( | 90 | export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()( |
| ... | @@ -232,6 +240,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -232,6 +240,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 232 | id, | 240 | id, |
| 233 | credential, | 241 | credential, |
| 234 | ...(input?.label ? { label: input.label } : {}), | 242 | ...(input?.label ? { label: input.label } : {}), |
| 243 | ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), | ||
| 235 | expiresAt, | 244 | expiresAt, |
| 236 | }; | 245 | }; |
| 237 | yield* pairingLinks.create({ | 246 | yield* pairingLinks.create({ |
| ... | @@ -241,6 +250,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -241,6 +250,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 241 | scopes: input?.scopes ?? AuthStandardClientScopes, | 250 | scopes: input?.scopes ?? AuthStandardClientScopes, |
| 242 | subject: input?.subject ?? "one-time-token", | 251 | subject: input?.subject ?? "one-time-token", |
| 243 | label: input?.label ?? null, | 252 | label: input?.label ?? null, |
| 253 | proofKeyThumbprint: input?.proofKeyThumbprint ?? null, | ||
| 244 | createdAt: now, | 254 | createdAt: now, |
| 245 | expiresAt: expiresAt, | 255 | expiresAt: expiresAt, |
| 246 | }); | 256 | }); |
| ... | @@ -259,7 +269,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -259,7 +269,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 259 | ); | 269 | ); |
| 260 | 270 | ||
| 261 | const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")( | 271 | const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")( |
| 262 | function* (credential) { | 272 | function* (credential, input) { |
| 263 | const now = yield* DateTime.now; | 273 | const now = yield* DateTime.now; |
| 264 | const seededResult: ConsumeResult = yield* Ref.modify( | 274 | const seededResult: ConsumeResult = yield* Ref.modify( |
| 265 | seededGrantsRef, | 275 | seededGrantsRef, |
| ... | @@ -289,6 +299,17 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -289,6 +299,17 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 289 | ]; | 299 | ]; |
| 290 | } | 300 | } |
| 291 | 301 | ||
| 302 | if (grant.proofKeyThumbprint && grant.proofKeyThumbprint !== input?.proofKeyThumbprint) { | ||
| 303 | return [ | ||
| 304 | { | ||
| 305 | _tag: "error", | ||
| 306 | reason: "not-found", | ||
| 307 | error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."), | ||
| 308 | }, | ||
| 309 | next, | ||
| 310 | ]; | ||
| 311 | } | ||
| 312 | |||
| 292 | const remainingUses = grant.remainingUses; | 313 | const remainingUses = grant.remainingUses; |
| 293 | if (typeof remainingUses === "number") { | 314 | if (typeof remainingUses === "number") { |
| 294 | if (remainingUses <= 1) { | 315 | if (remainingUses <= 1) { |
| ... | @@ -309,6 +330,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -309,6 +330,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 309 | scopes: grant.scopes, | 330 | scopes: grant.scopes, |
| 310 | subject: grant.subject, | 331 | subject: grant.subject, |
| 311 | ...(grant.label ? { label: grant.label } : {}), | 332 | ...(grant.label ? { label: grant.label } : {}), |
| 333 | ...(grant.proofKeyThumbprint | ||
| 334 | ? { proofKeyThumbprint: grant.proofKeyThumbprint } | ||
| 335 | : {}), | ||
| 312 | expiresAt: grant.expiresAt, | 336 | expiresAt: grant.expiresAt, |
| 313 | } satisfies BootstrapGrant, | 337 | } satisfies BootstrapGrant, |
| 314 | }, | 338 | }, |
| ... | @@ -326,6 +350,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -326,6 +350,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 326 | 350 | ||
| 327 | const consumed = yield* pairingLinks.consumeAvailable({ | 351 | const consumed = yield* pairingLinks.consumeAvailable({ |
| 328 | credential, | 352 | credential, |
| 353 | proofKeyThumbprint: input?.proofKeyThumbprint ?? null, | ||
| 329 | consumedAt: now, | 354 | consumedAt: now, |
| 330 | now, | 355 | now, |
| 331 | }); | 356 | }); |
| ... | @@ -337,6 +362,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -337,6 +362,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 337 | scopes: consumed.value.scopes, | 362 | scopes: consumed.value.scopes, |
| 338 | subject: consumed.value.subject, | 363 | subject: consumed.value.subject, |
| 339 | ...(consumed.value.label ? { label: consumed.value.label } : {}), | 364 | ...(consumed.value.label ? { label: consumed.value.label } : {}), |
| 365 | ...(consumed.value.proofKeyThumbprint | ||
| 366 | ? { proofKeyThumbprint: consumed.value.proofKeyThumbprint } | ||
| 367 | : {}), | ||
| 340 | expiresAt: consumed.value.expiresAt, | 368 | expiresAt: consumed.value.expiresAt, |
| 341 | } satisfies BootstrapGrant; | 369 | } satisfies BootstrapGrant; |
| 342 | } | 370 | } |
| ... | @@ -360,6 +388,13 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -360,6 +388,13 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 360 | return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); | 388 | return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); |
| 361 | } | 389 | } |
| 362 | 390 | ||
| 391 | if ( | ||
| 392 | matching.value.proofKeyThumbprint !== null && | ||
| 393 | matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint | ||
| 394 | ) { | ||
| 395 | return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."); | ||
| 396 | } | ||
| 397 | |||
| 363 | return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); | 398 | return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); |
| 364 | }, | 399 | }, |
| 365 | Effect.mapError((cause) => | 400 | Effect.mapError((cause) => |
apps/server/src/auth/ServerSecretStore.ts+10-5| ... | @@ -15,9 +15,16 @@ export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ | ... | @@ -15,9 +15,16 @@ export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ |
| 15 | readonly cause?: unknown; | 15 | readonly cause?: unknown; |
| 16 | }> {} | 16 | }> {} |
| 17 | 17 | ||
| 18 | const isPlatformError = (value: unknown): value is PlatformError.PlatformError => | ||
| 19 | Predicate.isTagged(value, "PlatformError"); | ||
| 20 | |||
| 21 | export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => | ||
| 22 | isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; | ||
| 23 | |||
| 18 | export interface ServerSecretStoreShape { | 24 | export interface ServerSecretStoreShape { |
| 19 | readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>; | 25 | readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>; |
| 20 | readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | 26 | readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; |
| 27 | readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | ||
| 21 | readonly getOrCreateRandom: ( | 28 | readonly getOrCreateRandom: ( |
| 22 | name: string, | 29 | name: string, |
| 23 | bytes: number, | 30 | bytes: number, |
| ... | @@ -48,9 +55,6 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -48,9 +55,6 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 48 | 55 | ||
| 49 | const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); | 56 | const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); |
| 50 | 57 | ||
| 51 | const isPlatformError = (u: unknown): u is PlatformError.PlatformError => | ||
| 52 | Predicate.isTagged(u, "PlatformError"); | ||
| 53 | |||
| 54 | const get: ServerSecretStoreShape["get"] = (name) => | 58 | const get: ServerSecretStoreShape["get"] = (name) => |
| 55 | fileSystem.readFile(resolveSecretPath(name)).pipe( | 59 | fileSystem.readFile(resolveSecretPath(name)).pipe( |
| 56 | Effect.map((bytes) => Uint8Array.from(bytes)), | 60 | Effect.map((bytes) => Uint8Array.from(bytes)), |
| ... | @@ -104,7 +108,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -104,7 +108,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 104 | ); | 108 | ); |
| 105 | }; | 109 | }; |
| 106 | 110 | ||
| 107 | const create: ServerSecretStoreShape["set"] = (name, value) => { | 111 | const create: ServerSecretStoreShape["create"] = (name, value) => { |
| 108 | const secretPath = resolveSecretPath(name); | 112 | const secretPath = resolveSecretPath(name); |
| 109 | return Effect.scoped( | 113 | return Effect.scoped( |
| 110 | Effect.gen(function* () { | 114 | Effect.gen(function* () { |
| ... | @@ -146,7 +150,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -146,7 +150,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 146 | create(name, generated).pipe( | 150 | create(name, generated).pipe( |
| 147 | Effect.as(Uint8Array.from(generated)), | 151 | Effect.as(Uint8Array.from(generated)), |
| 148 | Effect.catchTag("SecretStoreError", (error) => | 152 | Effect.catchTag("SecretStoreError", (error) => |
| 149 | isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists" | 153 | isSecretAlreadyExistsError(error) |
| 150 | ? get(name).pipe( | 154 | ? get(name).pipe( |
| 151 | Effect.flatMap((created) => | 155 | Effect.flatMap((created) => |
| 152 | created !== null | 156 | created !== null |
| ... | @@ -185,6 +189,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -185,6 +189,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 185 | return { | 189 | return { |
| 186 | get, | 190 | get, |
| 187 | set, | 191 | set, |
| 192 | create, | ||
| 188 | getOrCreateRandom, | 193 | getOrCreateRandom, |
| 189 | remove, | 194 | remove, |
| 190 | } satisfies ServerSecretStoreShape; | 195 | } satisfies ServerSecretStoreShape; |
apps/server/src/auth/SessionStore.ts+8-1| ... | @@ -40,6 +40,7 @@ export interface IssuedSession { | ... | @@ -40,6 +40,7 @@ export interface IssuedSession { |
| 40 | readonly client: AuthClientMetadata; | 40 | readonly client: AuthClientMetadata; |
| 41 | readonly expiresAt: DateTime.DateTime; | 41 | readonly expiresAt: DateTime.DateTime; |
| 42 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 42 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 43 | readonly proofKeyThumbprint?: string; | ||
| 43 | } | 44 | } |
| 44 | 45 | ||
| 45 | export interface VerifiedSession { | 46 | export interface VerifiedSession { |
| ... | @@ -50,6 +51,7 @@ export interface VerifiedSession { | ... | @@ -50,6 +51,7 @@ export interface VerifiedSession { |
| 50 | readonly expiresAt?: DateTime.DateTime; | 51 | readonly expiresAt?: DateTime.DateTime; |
| 51 | readonly subject: string; | 52 | readonly subject: string; |
| 52 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 53 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 54 | readonly proofKeyThumbprint?: string; | ||
| 53 | } | 55 | } |
| 54 | 56 | ||
| 55 | export type SessionCredentialChange = | 57 | export type SessionCredentialChange = |
| ... | @@ -86,6 +88,7 @@ export interface SessionStoreShape { | ... | @@ -86,6 +88,7 @@ export interface SessionStoreShape { |
| 86 | readonly method?: ServerAuthSessionMethod; | 88 | readonly method?: ServerAuthSessionMethod; |
| 87 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | 89 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 88 | readonly client?: AuthClientMetadata; | 90 | readonly client?: AuthClientMetadata; |
| 91 | readonly proofKeyThumbprint?: string; | ||
| 89 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; | 92 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; |
| 90 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | 93 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; |
| 91 | readonly issueWebSocketToken: ( | 94 | readonly issueWebSocketToken: ( |
| ... | @@ -132,7 +135,8 @@ const SessionClaims = Schema.Struct({ | ... | @@ -132,7 +135,8 @@ const SessionClaims = Schema.Struct({ |
| 132 | sid: AuthSessionId, | 135 | sid: AuthSessionId, |
| 133 | sub: Schema.String, | 136 | sub: Schema.String, |
| 134 | scopes: AuthEnvironmentScopes, | 137 | scopes: AuthEnvironmentScopes, |
| 135 | method: Schema.Literals(["browser-session-cookie", "bearer-access-token"]), | 138 | method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]), |
| 139 | jkt: Schema.optionalKey(Schema.String), | ||
| 136 | iat: Schema.Number, | 140 | iat: Schema.Number, |
| 137 | exp: Schema.Number, | 141 | exp: Schema.Number, |
| 138 | }); | 142 | }); |
| ... | @@ -310,6 +314,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -310,6 +314,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 310 | sub: input?.subject ?? "browser", | 314 | sub: input?.subject ?? "browser", |
| 311 | scopes: input?.scopes ?? AuthStandardClientScopes, | 315 | scopes: input?.scopes ?? AuthStandardClientScopes, |
| 312 | method: input?.method ?? "browser-session-cookie", | 316 | method: input?.method ?? "browser-session-cookie", |
| 317 | ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}), | ||
| 313 | iat: issuedAt.epochMilliseconds, | 318 | iat: issuedAt.epochMilliseconds, |
| 314 | exp: expiresAt.epochMilliseconds, | 319 | exp: expiresAt.epochMilliseconds, |
| 315 | }; | 320 | }; |
| ... | @@ -360,6 +365,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -360,6 +365,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 360 | client, | 365 | client, |
| 361 | expiresAt: expiresAt, | 366 | expiresAt: expiresAt, |
| 362 | scopes: claims.scopes, | 367 | scopes: claims.scopes, |
| 368 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), | ||
| 363 | } satisfies IssuedSession; | 369 | } satisfies IssuedSession; |
| 364 | }, | 370 | }, |
| 365 | Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), | 371 | Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), |
| ... | @@ -425,6 +431,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -425,6 +431,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 425 | expiresAt: expiresAt.value, | 431 | expiresAt: expiresAt.value, |
| 426 | subject: claims.sub, | 432 | subject: claims.sub, |
| 427 | scopes: claims.scopes, | 433 | scopes: claims.scopes, |
| 434 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), | ||
| 428 | } satisfies VerifiedSession; | 435 | } satisfies VerifiedSession; |
| 429 | }, | 436 | }, |
| 430 | Effect.mapError((cause) => | 437 | Effect.mapError((cause) => |
apps/server/src/auth/dpop.test.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import * as PlatformError from "effect/PlatformError"; | ||
| 3 | |||
| 4 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | ||
| 5 | import { mapDpopReplayStoreError } from "./dpop.ts"; | ||
| 6 | |||
| 7 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => | ||
| 8 | new ServerSecretStore.SecretStoreError({ | ||
| 9 | message: "Failed to persist DPoP proof.", | ||
| 10 | cause: PlatformError.systemError({ | ||
| 11 | _tag: tag, | ||
| 12 | module: "FileSystem", | ||
| 13 | method: "open", | ||
| 14 | pathOrDescriptor: "dpop-proof.bin", | ||
| 15 | }), | ||
| 16 | }); | ||
| 17 | |||
| 18 | describe("mapDpopReplayStoreError", () => { | ||
| 19 | it("reports replay conflicts as invalid credentials", () => { | ||
| 20 | const error = mapDpopReplayStoreError(storeFailure("AlreadyExists")); | ||
| 21 | |||
| 22 | expect(error._tag).toBe("ServerAuthInvalidCredentialError"); | ||
| 23 | }); | ||
| 24 | |||
| 25 | it("reports replay-store availability failures as internal errors", () => { | ||
| 26 | const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); | ||
| 27 | |||
| 28 | expect(error._tag).toBe("ServerAuthInternalError"); | ||
| 29 | if (error._tag === "ServerAuthInternalError") { | ||
| 30 | expect(error.message).toBe("Failed to record DPoP proof replay state."); | ||
| 31 | } | ||
| 32 | }); | ||
| 33 | }); | ||
apps/server/src/auth/dpop.ts created+92| ... | @@ -0,0 +1,92 @@ | ||
| 1 | import { verifyDpopProof } from "@t3tools/shared/dpop"; | ||
| 2 | import * as Crypto from "effect/Crypto"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Encoding from "effect/Encoding"; | ||
| 6 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | ||
| 7 | |||
| 8 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | ||
| 9 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | ||
| 10 | |||
| 11 | function firstHeaderValue(value: string | undefined): string | undefined { | ||
| 12 | const first = value?.split(",")[0]?.trim(); | ||
| 13 | return first && first.length > 0 ? first : undefined; | ||
| 14 | } | ||
| 15 | |||
| 16 | export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string { | ||
| 17 | try { | ||
| 18 | return new URL(request.originalUrl).href; | ||
| 19 | } catch { | ||
| 20 | const host = firstHeaderValue(request.headers.host) ?? "127.0.0.1"; | ||
| 21 | const forwardedProto = firstHeaderValue(request.headers["x-forwarded-proto"]); | ||
| 22 | const proto = forwardedProto === "https" || forwardedProto === "http" ? forwardedProto : "http"; | ||
| 23 | return new URL(request.originalUrl, `${proto}://${host}`).href; | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | export const mapDpopReplayStoreError = ( | ||
| 28 | error: ServerSecretStore.SecretStoreError, | ||
| 29 | ): EnvironmentAuth.ServerAuthInvalidCredentialError | EnvironmentAuth.ServerAuthInternalError => | ||
| 30 | ServerSecretStore.isSecretAlreadyExistsError(error) | ||
| 31 | ? new EnvironmentAuth.ServerAuthInvalidCredentialError({ | ||
| 32 | reason: "invalid_credential", | ||
| 33 | cause: "DPoP proof replayed.", | ||
| 34 | }) | ||
| 35 | : new EnvironmentAuth.ServerAuthInternalError({ | ||
| 36 | message: "Failed to record DPoP proof replay state.", | ||
| 37 | cause: error, | ||
| 38 | }); | ||
| 39 | |||
| 40 | export const verifyRequestDpopProof = (input: { | ||
| 41 | readonly request: HttpServerRequest.HttpServerRequest; | ||
| 42 | readonly expectedThumbprint?: string; | ||
| 43 | readonly expectedAccessToken?: string; | ||
| 44 | }) => | ||
| 45 | Effect.gen(function* () { | ||
| 46 | const proof = input.request.headers.dpop; | ||
| 47 | const now = yield* DateTime.now; | ||
| 48 | const result = verifyDpopProof({ | ||
| 49 | proof, | ||
| 50 | method: input.request.method, | ||
| 51 | url: requestAbsoluteUrl(input.request), | ||
| 52 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 53 | ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), | ||
| 54 | ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), | ||
| 55 | }); | ||
| 56 | if (!result.ok) { | ||
| 57 | return yield* new EnvironmentAuth.ServerAuthInvalidCredentialError({ | ||
| 58 | reason: "invalid_credential", | ||
| 59 | cause: result.reason, | ||
| 60 | }); | ||
| 61 | } | ||
| 62 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | ||
| 63 | const replayKey = yield* Crypto.Crypto.pipe( | ||
| 64 | Effect.flatMap((crypto) => | ||
| 65 | crypto.digest("SHA-256", new TextEncoder().encode(`${result.thumbprint}:${result.jti}`)), | ||
| 66 | ), | ||
| 67 | Effect.map(Encoding.encodeBase64Url), | ||
| 68 | Effect.mapError( | ||
| 69 | (cause) => | ||
| 70 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 71 | message: "Failed to calculate DPoP replay key.", | ||
| 72 | cause, | ||
| 73 | }), | ||
| 74 | ), | ||
| 75 | ); | ||
| 76 | yield* secretStore | ||
| 77 | .create( | ||
| 78 | `dpop-proof-${replayKey}`, | ||
| 79 | new TextEncoder().encode( | ||
| 80 | [ | ||
| 81 | `thumbprint=${result.thumbprint}`, | ||
| 82 | `jti=${result.jti}`, | ||
| 83 | `iat=${result.iat}`, | ||
| 84 | `consumedAt=${DateTime.formatIso(now)}`, | ||
| 85 | ].join("\n"), | ||
| 86 | ), | ||
| 87 | ) | ||
| 88 | .pipe( | ||
| 89 | Effect.catchTag("SecretStoreError", (error) => Effect.fail(mapDpopReplayStoreError(error))), | ||
| 90 | ); | ||
| 91 | return result.thumbprint; | ||
| 92 | }); | ||
apps/server/src/auth/http.ts+45-2| ... | @@ -34,6 +34,32 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ... | @@ -34,6 +34,32 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 34 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 34 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; |
| 35 | import * as SessionStore from "./SessionStore.ts"; | 35 | import * as SessionStore from "./SessionStore.ts"; |
| 36 | import { deriveAuthClientMetadata } from "./utils.ts"; | 36 | import { deriveAuthClientMetadata } from "./utils.ts"; |
| 37 | import { verifyRequestDpopProof } from "./dpop.ts"; | ||
| 38 | |||
| 39 | const CREDENTIAL_RESPONSE_HEADERS = { | ||
| 40 | "cache-control": "no-store", | ||
| 41 | pragma: "no-cache", | ||
| 42 | } as const; | ||
| 43 | |||
| 44 | const appendCredentialResponseHeaders = HttpEffect.appendPreResponseHandler((_request, response) => | ||
| 45 | Effect.succeed(HttpServerResponse.setHeaders(response, CREDENTIAL_RESPONSE_HEADERS)), | ||
| 46 | ); | ||
| 47 | |||
| 48 | const appendDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) => | ||
| 49 | Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", "DPoP")), | ||
| 50 | ); | ||
| 51 | |||
| 52 | const appendDpopChallengeOnUnauthorized = (error: EnvironmentAuthInvalidError) => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 55 | const usesDpop = | ||
| 56 | (request.originalUrl.startsWith("/oauth/token") && request.headers.dpop !== undefined) || | ||
| 57 | request.headers.authorization?.startsWith("DPoP ") === true; | ||
| 58 | if (usesDpop) { | ||
| 59 | yield* appendDpopChallengeHeader; | ||
| 60 | } | ||
| 61 | return yield* error; | ||
| 62 | }); | ||
| 37 | 63 | ||
| 38 | export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( | 64 | export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( |
| 39 | Effect.map((span) => span.traceId), | 65 | Effect.map((span) => span.traceId), |
| ... | @@ -152,7 +178,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( | ... | @@ -152,7 +178,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( |
| 152 | scopes: new Set(session.scopes), | 178 | scopes: new Set(session.scopes), |
| 153 | }), | 179 | }), |
| 154 | ); | 180 | ); |
| 155 | }); | 181 | }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); |
| 156 | }), | 182 | }), |
| 157 | ); | 183 | ); |
| 158 | 184 | ||
| ... | @@ -199,6 +225,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -199,6 +225,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 199 | yield* HttpEffect.appendPreResponseHandler((_request, response) => | 225 | yield* HttpEffect.appendPreResponseHandler((_request, response) => |
| 200 | Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), | 226 | Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)), |
| 201 | ); | 227 | ); |
| 228 | yield* appendCredentialResponseHeaders; | ||
| 202 | return result.response; | 229 | return result.response; |
| 203 | }, | 230 | }, |
| 204 | Effect.catchTags({ | 231 | Effect.catchTags({ |
| ... | @@ -230,8 +257,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -230,8 +257,22 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 230 | AuthRelayWriteScope, | 257 | AuthRelayWriteScope, |
| 231 | ]), | 258 | ]), |
| 232 | }); | 259 | }); |
| 233 | if (requestedScopes === null) | 260 | if (requestedScopes === null) { |
| 234 | return yield* failEnvironmentInvalidRequest("invalid_scope"); | 261 | return yield* failEnvironmentInvalidRequest("invalid_scope"); |
| 262 | } | ||
| 263 | const proofKeyThumbprint = args.headers.dpop | ||
| 264 | ? yield* verifyRequestDpopProof({ request }).pipe( | ||
| 265 | Effect.catchTags({ | ||
| 266 | ServerAuthInvalidCredentialError: () => | ||
| 267 | appendDpopChallengeHeader.pipe( | ||
| 268 | Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), | ||
| 269 | ), | ||
| 270 | ServerAuthInternalError: (error) => | ||
| 271 | failEnvironmentInternal("access_token_issuance_failed", error), | ||
| 272 | }), | ||
| 273 | ) | ||
| 274 | : undefined; | ||
| 275 | yield* appendCredentialResponseHeaders; | ||
| 235 | return yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | 276 | return yield* serverAuth.exchangeBootstrapCredentialForAccessToken( |
| 236 | args.payload.subject_token, | 277 | args.payload.subject_token, |
| 237 | requestedScopes, | 278 | requestedScopes, |
| ... | @@ -245,6 +286,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -245,6 +286,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 245 | ...(args.payload.client_os ? { os: args.payload.client_os } : {}), | 286 | ...(args.payload.client_os ? { os: args.payload.client_os } : {}), |
| 246 | }, | 287 | }, |
| 247 | }), | 288 | }), |
| 289 | proofKeyThumbprint ? { proofKeyThumbprint } : undefined, | ||
| 248 | ); | 290 | ); |
| 249 | }, | 291 | }, |
| 250 | Effect.catchTags({ | 292 | Effect.catchTags({ |
| ... | @@ -261,6 +303,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -261,6 +303,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 261 | function* (args) { | 303 | function* (args) { |
| 262 | yield* annotateEnvironmentRequest(args.endpoint.name); | 304 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 263 | const session = yield* EnvironmentAuthenticatedPrincipal; | 305 | const session = yield* EnvironmentAuthenticatedPrincipal; |
| 306 | yield* appendCredentialResponseHeaders; | ||
| 264 | return yield* serverAuth.issueWebSocketTicket(session); | 307 | return yield* serverAuth.issueWebSocketTicket(session); |
| 265 | }, | 308 | }, |
| 266 | Effect.catchTag("ServerAuthInternalError", (error) => | 309 | Effect.catchTag("ServerAuthInternalError", (error) => |
apps/server/src/auth/utils.ts+6-3| ... | @@ -5,6 +5,8 @@ import type { | ... | @@ -5,6 +5,8 @@ import type { |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | 6 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 7 | import * as Crypto from "node:crypto"; | 7 | import * as Crypto from "node:crypto"; |
| 8 | import * as Encoding from "effect/Encoding"; | ||
| 9 | import * as Result from "effect/Result"; | ||
| 8 | 10 | ||
| 9 | const SESSION_COOKIE_NAME = "t3_session"; | 11 | const SESSION_COOKIE_NAME = "t3_session"; |
| 10 | 12 | ||
| ... | @@ -20,12 +22,13 @@ export function resolveSessionCookieName(input: { | ... | @@ -20,12 +22,13 @@ export function resolveSessionCookieName(input: { |
| 20 | } | 22 | } |
| 21 | 23 | ||
| 22 | export function base64UrlEncode(input: string | Uint8Array): string { | 24 | export function base64UrlEncode(input: string | Uint8Array): string { |
| 23 | const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); | 25 | return typeof input === "string" |
| 24 | return buffer.toString("base64url"); | 26 | ? Encoding.encodeBase64Url(new TextEncoder().encode(input)) |
| 27 | : Encoding.encodeBase64Url(input); | ||
| 25 | } | 28 | } |
| 26 | 29 | ||
| 27 | export function base64UrlDecodeUtf8(input: string): string { | 30 | export function base64UrlDecodeUtf8(input: string): string { |
| 28 | return Buffer.from(input, "base64url").toString("utf8"); | 31 | return Result.getOrThrow(Encoding.decodeBase64UrlString(input)); |
| 29 | } | 32 | } |
| 30 | 33 | ||
| 31 | export function signPayload(payload: string, secret: Uint8Array): string { | 34 | export function signPayload(payload: string, secret: Uint8Array): string { |
apps/server/src/bin.test.ts+122-3| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. | 1 | // @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. |
| 2 | import * as NodeHttp from "node:http"; | 2 | import * as NodeHttp from "node:http"; |
| 3 | import { mkdtempSync } from "node:fs"; | 3 | import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; |
| 4 | import { tmpdir } from "node:os"; | 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; | 5 | import { join } from "node:path"; |
| 6 | 6 | ||
| ... | @@ -19,7 +19,7 @@ import * as CliError from "effect/unstable/cli/CliError"; | ... | @@ -19,7 +19,7 @@ import * as CliError from "effect/unstable/cli/CliError"; |
| 19 | import * as TestConsole from "effect/testing/TestConsole"; | 19 | import * as TestConsole from "effect/testing/TestConsole"; |
| 20 | import { Command } from "effect/unstable/cli"; | 20 | import { Command } from "effect/unstable/cli"; |
| 21 | 21 | ||
| 22 | import { cli } from "./bin.ts"; | 22 | import { cli, makeCli } from "./bin.ts"; |
| 23 | import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; | 23 | import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; |
| 24 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 24 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 25 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | 25 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; |
| ... | @@ -38,7 +38,11 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | ... | @@ -38,7 +38,11 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 38 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | 38 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 39 | class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} | 39 | class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} |
| 40 | 40 | ||
| 41 | const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args); | 41 | const cloudCli = makeCli({ cloudEnabled: true }); |
| 42 | const noCloudCli = makeCli({ cloudEnabled: false }); | ||
| 43 | const runCli = (args: ReadonlyArray<string>, command = cli) => | ||
| 44 | Command.runWith(command, { version: "0.0.0" })(args); | ||
| 45 | const runCloudCli = (args: ReadonlyArray<string>) => runCli(args, cloudCli); | ||
| 42 | const runCliWithRuntime = (args: ReadonlyArray<string>) => | 46 | const runCliWithRuntime = (args: ReadonlyArray<string>) => |
| 43 | runCli(args).pipe(Effect.provide(CliRuntimeLayer)); | 47 | runCli(args).pipe(Effect.provide(CliRuntimeLayer)); |
| 44 | 48 | ||
| ... | @@ -175,6 +179,121 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { | ... | @@ -175,6 +179,121 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { |
| 175 | }), | 179 | }), |
| 176 | ); | 180 | ); |
| 177 | 181 | ||
| 182 | it.effect("rejects cloud commands when public cloud configuration is missing", () => | ||
| 183 | Effect.gen(function* () { | ||
| 184 | const error = yield* runCli(["cloud", "status"], noCloudCli).pipe(Effect.flip); | ||
| 185 | |||
| 186 | if (!CliError.isCliError(error)) { | ||
| 187 | assert.fail(`Expected CliError, got ${String(error)}`); | ||
| 188 | } | ||
| 189 | if (error._tag !== "ShowHelp") { | ||
| 190 | assert.fail(`Expected ShowHelp, got ${error._tag}`); | ||
| 191 | } | ||
| 192 | assert.deepEqual(error.commandPath, ["t3", "cloud"]); | ||
| 193 | assert.include(error.errors[0]?.message ?? "", "missing T3 Cloud public configuration"); | ||
| 194 | |||
| 195 | const output = (yield* TestConsole.errorLines).join("\n"); | ||
| 196 | assert.include(output, "ERROR"); | ||
| 197 | assert.include(output, "missing T3 Cloud public configuration"); | ||
| 198 | }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))), | ||
| 199 | ); | ||
| 200 | |||
| 201 | it.effect("reports fresh headless cloud state without requiring local configuration", () => | ||
| 202 | Effect.gen(function* () { | ||
| 203 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-test-")); | ||
| 204 | const { output } = yield* captureStdout( | ||
| 205 | runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]), | ||
| 206 | ); | ||
| 207 | // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO. | ||
| 208 | const status = JSON.parse(output) as { | ||
| 209 | readonly desired: boolean; | ||
| 210 | readonly authenticated: boolean; | ||
| 211 | readonly linked: boolean; | ||
| 212 | readonly cloudUserId: string | null; | ||
| 213 | readonly relayUrl: string | null; | ||
| 214 | }; | ||
| 215 | |||
| 216 | assert.equal(status.desired, false); | ||
| 217 | assert.equal(status.authenticated, false); | ||
| 218 | assert.equal(status.linked, false); | ||
| 219 | assert.equal(status.cloudUserId, null); | ||
| 220 | assert.equal(status.relayUrl, null); | ||
| 221 | }), | ||
| 222 | ); | ||
| 223 | |||
| 224 | it.effect("reports actionable human-readable headless cloud state", () => | ||
| 225 | Effect.gen(function* () { | ||
| 226 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-status-human-test-")); | ||
| 227 | const { output } = yield* captureStdout( | ||
| 228 | runCloudCli(["cloud", "status", "--base-dir", baseDir]), | ||
| 229 | ); | ||
| 230 | |||
| 231 | assert.include(output, "T3 Cloud\n Exposure: disabled"); | ||
| 232 | assert.include(output, " Authorization: missing"); | ||
| 233 | assert.include(output, " Environment link: not provisioned"); | ||
| 234 | assert.include(output, "Next: Run `t3 cloud link` to authorize and enable cloud exposure."); | ||
| 235 | }), | ||
| 236 | ); | ||
| 237 | |||
| 238 | it.effect("logs in to headless cloud without enabling exposure", () => | ||
| 239 | Effect.gen(function* () { | ||
| 240 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-login-test-")); | ||
| 241 | const { secretsDir } = yield* deriveServerPaths(baseDir, undefined); | ||
| 242 | mkdirSync(secretsDir, { recursive: true }); | ||
| 243 | writeFileSync( | ||
| 244 | join(secretsDir, "cloud-cli-oauth-token.bin"), | ||
| 245 | // @effect-diagnostics-next-line preferSchemaOverJson:off - Test fixture matches the persisted CLI token representation. | ||
| 246 | JSON.stringify({ | ||
| 247 | accessToken: "access-token", | ||
| 248 | refreshToken: "refresh-token", | ||
| 249 | expiresAtEpochMs: Number.MAX_SAFE_INTEGER, | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | |||
| 253 | const login = yield* captureStdout(runCloudCli(["cloud", "login", "--base-dir", baseDir])); | ||
| 254 | const status = yield* captureStdout( | ||
| 255 | runCloudCli(["cloud", "status", "--base-dir", baseDir, "--json"]), | ||
| 256 | ); | ||
| 257 | // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO. | ||
| 258 | const decoded = JSON.parse(status.output) as { | ||
| 259 | readonly desired: boolean; | ||
| 260 | readonly authenticated: boolean; | ||
| 261 | }; | ||
| 262 | |||
| 263 | assert.equal(login.output, "Signed in to T3 Cloud."); | ||
| 264 | assert.isFalse(decoded.desired); | ||
| 265 | assert.isTrue(decoded.authenticated); | ||
| 266 | }), | ||
| 267 | ); | ||
| 268 | |||
| 269 | it.effect("disables headless cloud exposure without a running server", () => | ||
| 270 | Effect.gen(function* () { | ||
| 271 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-unlink-test-")); | ||
| 272 | const { output } = yield* captureStdout( | ||
| 273 | runCloudCli(["cloud", "unlink", "--base-dir", baseDir]), | ||
| 274 | ); | ||
| 275 | |||
| 276 | assert.equal(output, "T3 Cloud exposure is disabled locally."); | ||
| 277 | }), | ||
| 278 | ); | ||
| 279 | |||
| 280 | it.effect("logs out of headless cloud and removes the stored CLI authorization", () => | ||
| 281 | Effect.gen(function* () { | ||
| 282 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-cloud-logout-test-")); | ||
| 283 | const { secretsDir } = yield* deriveServerPaths(baseDir, undefined); | ||
| 284 | const tokenPath = join(secretsDir, "cloud-cli-oauth-token.bin"); | ||
| 285 | mkdirSync(secretsDir, { recursive: true }); | ||
| 286 | writeFileSync(tokenPath, "invalid persisted token"); | ||
| 287 | |||
| 288 | const { output } = yield* captureStdout( | ||
| 289 | runCloudCli(["cloud", "logout", "--base-dir", baseDir]), | ||
| 290 | ); | ||
| 291 | |||
| 292 | assert.equal(output, "Signed out of T3 Cloud locally."); | ||
| 293 | assert.isFalse(existsSync(tokenPath)); | ||
| 294 | }), | ||
| 295 | ); | ||
| 296 | |||
| 178 | it.effect("executes auth pairing subcommands and redacts secrets from list output", () => | 297 | it.effect("executes auth pairing subcommands and redacts secrets from list output", () => |
| 179 | Effect.gen(function* () { | 298 | Effect.gen(function* () { |
| 180 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-")); | 299 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-")); |
apps/server/src/bin.ts+38-4| ... | @@ -3,22 +3,56 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -3,22 +3,56 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import { Command } from "effect/unstable/cli"; | 5 | import { Command } from "effect/unstable/cli"; |
| 6 | import * as CliError from "effect/unstable/cli/CliError"; | ||
| 6 | 7 | ||
| 7 | import * as NetService from "@t3tools/shared/Net"; | 8 | import * as NetService from "@t3tools/shared/Net"; |
| 8 | import packageJson from "../package.json" with { type: "json" }; | 9 | import packageJson from "../package.json" with { type: "json" }; |
| 9 | import { authCommand } from "./cli/auth.ts"; | 10 | import { authCommand } from "./cli/auth.ts"; |
| 11 | import { cloudCommand } from "./cli/cloud.ts"; | ||
| 12 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; | ||
| 10 | import { sharedServerCommandFlags } from "./cli/config.ts"; | 13 | import { sharedServerCommandFlags } from "./cli/config.ts"; |
| 11 | import { projectCommand } from "./cli/project.ts"; | 14 | import { projectCommand } from "./cli/project.ts"; |
| 12 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; | 15 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; |
| 13 | 16 | ||
| 14 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | 17 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 15 | 18 | ||
| 16 | export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( | 19 | const cloudPublicConfigMissingMessage = |
| 17 | Command.withDescription("Run the T3 Code server."), | 20 | "T3 Cloud commands are unavailable: this build is missing T3 Cloud public configuration."; |
| 18 | Command.withHandler((flags) => runServerCommand(flags)), | 21 | |
| 19 | Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), | 22 | class CloudPublicConfigMissingError extends CliError.UserError { |
| 23 | override get message() { | ||
| 24 | return cloudPublicConfigMissingMessage; | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | const cloudUnavailableCommand = Command.make("cloud").pipe( | ||
| 29 | Command.withDescription("T3 Cloud is unavailable in builds without public cloud configuration."), | ||
| 30 | Command.withHidden, | ||
| 31 | Command.withHandler(() => | ||
| 32 | Effect.fail( | ||
| 33 | new CliError.ShowHelp({ | ||
| 34 | commandPath: ["t3", "cloud"], | ||
| 35 | errors: [new CloudPublicConfigMissingError({ cause: cloudPublicConfigMissingMessage })], | ||
| 36 | }), | ||
| 37 | ), | ||
| 38 | ), | ||
| 20 | ); | 39 | ); |
| 21 | 40 | ||
| 41 | export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => | ||
| 42 | Command.make("t3", { ...sharedServerCommandFlags }).pipe( | ||
| 43 | Command.withDescription("Run the T3 Code server."), | ||
| 44 | Command.withHandler((flags) => runServerCommand(flags)), | ||
| 45 | Command.withSubcommands([ | ||
| 46 | startCommand, | ||
| 47 | serveCommand, | ||
| 48 | authCommand, | ||
| 49 | projectCommand, | ||
| 50 | cloudEnabled ? cloudCommand : cloudUnavailableCommand, | ||
| 51 | ]), | ||
| 52 | ); | ||
| 53 | |||
| 54 | export const cli = makeCli(); | ||
| 55 | |||
| 22 | if (import.meta.main) { | 56 | if (import.meta.main) { |
| 23 | Command.run(cli, { version: packageJson.version }).pipe( | 57 | Command.run(cli, { version: packageJson.version }).pipe( |
| 24 | Effect.scoped, | 58 | Effect.scoped, |
apps/server/src/checkpointing/Errors.ts+2-2| ... | @@ -11,7 +11,7 @@ export class CheckpointUnavailableError extends Schema.TaggedErrorClass<Checkpoi | ... | @@ -11,7 +11,7 @@ export class CheckpointUnavailableError extends Schema.TaggedErrorClass<Checkpoi |
| 11 | threadId: Schema.String, | 11 | threadId: Schema.String, |
| 12 | turnCount: Schema.Number, | 12 | turnCount: Schema.Number, |
| 13 | detail: Schema.String, | 13 | detail: Schema.String, |
| 14 | cause: Schema.optional(Schema.Defect), | 14 | cause: Schema.optional(Schema.Defect()), |
| 15 | }, | 15 | }, |
| 16 | ) { | 16 | ) { |
| 17 | override get message(): string { | 17 | override get message(): string { |
| ... | @@ -27,7 +27,7 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint | ... | @@ -27,7 +27,7 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint |
| 27 | { | 27 | { |
| 28 | operation: Schema.String, | 28 | operation: Schema.String, |
| 29 | detail: Schema.String, | 29 | detail: Schema.String, |
| 30 | cause: Schema.optional(Schema.Defect), | 30 | cause: Schema.optional(Schema.Defect()), |
| 31 | }, | 31 | }, |
| 32 | ) { | 32 | ) { |
| 33 | override get message(): string { | 33 | override get message(): string { |
apps/server/src/cli/cloud.test.ts created+102| ... | @@ -0,0 +1,102 @@ | ||
| 1 | import * as RelayClient from "@t3tools/shared/relayClient"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | |||
| 6 | import { acquireRelayClientForLink } from "./cloud.ts"; | ||
| 7 | |||
| 8 | const managedExecutable = { | ||
| 9 | status: "available", | ||
| 10 | executablePath: "/tmp/cloudflared", | ||
| 11 | source: "managed", | ||
| 12 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 13 | } as const; | ||
| 14 | |||
| 15 | it.effect("does not install the relay client when the user declines the managed download", () => | ||
| 16 | Effect.gen(function* () { | ||
| 17 | let installCalls = 0; | ||
| 18 | const result = yield* acquireRelayClientForLink( | ||
| 19 | { | ||
| 20 | resolve: Effect.succeed({ | ||
| 21 | status: "missing", | ||
| 22 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 23 | }), | ||
| 24 | install: Effect.sync(() => { | ||
| 25 | installCalls += 1; | ||
| 26 | return managedExecutable; | ||
| 27 | }), | ||
| 28 | installWithProgress: () => | ||
| 29 | Effect.sync(() => { | ||
| 30 | installCalls += 1; | ||
| 31 | return managedExecutable; | ||
| 32 | }), | ||
| 33 | }, | ||
| 34 | () => Effect.succeed(false), | ||
| 35 | () => Effect.void, | ||
| 36 | ); | ||
| 37 | |||
| 38 | assert.isTrue(Option.isNone(result)); | ||
| 39 | assert.equal(installCalls, 0); | ||
| 40 | }), | ||
| 41 | ); | ||
| 42 | |||
| 43 | it.effect("installs the relay client after the user accepts the managed download", () => | ||
| 44 | Effect.gen(function* () { | ||
| 45 | let installCalls = 0; | ||
| 46 | const progress: Array<string> = []; | ||
| 47 | const result = yield* acquireRelayClientForLink( | ||
| 48 | { | ||
| 49 | resolve: Effect.succeed({ | ||
| 50 | status: "missing", | ||
| 51 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 52 | }), | ||
| 53 | install: Effect.sync(() => { | ||
| 54 | installCalls += 1; | ||
| 55 | return managedExecutable; | ||
| 56 | }), | ||
| 57 | installWithProgress: (report) => | ||
| 58 | report({ type: "progress", stage: "downloading" }).pipe( | ||
| 59 | Effect.andThen( | ||
| 60 | Effect.sync(() => { | ||
| 61 | installCalls += 1; | ||
| 62 | return managedExecutable; | ||
| 63 | }), | ||
| 64 | ), | ||
| 65 | ), | ||
| 66 | }, | ||
| 67 | () => Effect.succeed(true), | ||
| 68 | (event) => | ||
| 69 | Effect.sync(() => { | ||
| 70 | if (event.type === "progress") { | ||
| 71 | progress.push(event.stage); | ||
| 72 | } | ||
| 73 | }), | ||
| 74 | ); | ||
| 75 | |||
| 76 | assert.deepEqual(Option.getOrThrow(result), managedExecutable); | ||
| 77 | assert.equal(installCalls, 1); | ||
| 78 | assert.deepEqual(progress, ["downloading"]); | ||
| 79 | }), | ||
| 80 | ); | ||
| 81 | |||
| 82 | it.effect("reuses an available relay client executable without prompting", () => | ||
| 83 | Effect.gen(function* () { | ||
| 84 | let promptCalls = 0; | ||
| 85 | const result = yield* acquireRelayClientForLink( | ||
| 86 | { | ||
| 87 | resolve: Effect.succeed(managedExecutable), | ||
| 88 | install: Effect.die("unexpected install"), | ||
| 89 | installWithProgress: () => Effect.die("unexpected install"), | ||
| 90 | }, | ||
| 91 | () => | ||
| 92 | Effect.sync(() => { | ||
| 93 | promptCalls += 1; | ||
| 94 | return false; | ||
| 95 | }), | ||
| 96 | () => Effect.void, | ||
| 97 | ); | ||
| 98 | |||
| 99 | assert.deepEqual(Option.getOrThrow(result), managedExecutable); | ||
| 100 | assert.equal(promptCalls, 0); | ||
| 101 | }), | ||
| 102 | ); | ||
apps/server/src/cli/cloud.ts created+425| ... | @@ -0,0 +1,425 @@ | ||
| 1 | import { | ||
| 2 | AuthRelayWriteScope, | ||
| 3 | EnvironmentHttpApi, | ||
| 4 | type RelayClientInstallProgressEvent, | ||
| 5 | type RelayClientInstallProgressStage, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { RelayOkResponse } from "@t3tools/contracts/relay"; | ||
| 8 | import * as RelayClient from "@t3tools/shared/relayClient"; | ||
| 9 | import * as Console from "effect/Console"; | ||
| 10 | import * as Duration from "effect/Duration"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Exit from "effect/Exit"; | ||
| 13 | import * as FileSystem from "effect/FileSystem"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as References from "effect/References"; | ||
| 17 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; | ||
| 18 | import { | ||
| 19 | FetchHttpClient, | ||
| 20 | HttpClient, | ||
| 21 | HttpClientRequest, | ||
| 22 | HttpClientResponse, | ||
| 23 | } from "effect/unstable/http"; | ||
| 24 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | ||
| 25 | |||
| 26 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 27 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 28 | import * as CliState from "../cloud/CliState.ts"; | ||
| 29 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; | ||
| 30 | import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts"; | ||
| 31 | import { relayUrlConfig } from "../cloud/publicConfig.ts"; | ||
| 32 | import { ServerConfig } from "../config.ts"; | ||
| 33 | import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts"; | ||
| 34 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | ||
| 35 | import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; | ||
| 36 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; | ||
| 37 | |||
| 38 | const jsonFlag = Flag.boolean("json").pipe( | ||
| 39 | Flag.withDescription("Emit JSON instead of human-readable output."), | ||
| 40 | Flag.withDefault(false), | ||
| 41 | ); | ||
| 42 | |||
| 43 | function bytesToString(value: Uint8Array): string { | ||
| 44 | return new TextDecoder().decode(value); | ||
| 45 | } | ||
| 46 | |||
| 47 | interface CloudCliStatus { | ||
| 48 | readonly desired: boolean; | ||
| 49 | readonly authenticated: boolean; | ||
| 50 | readonly linked: boolean; | ||
| 51 | readonly cloudUserId: string | null; | ||
| 52 | readonly relayUrl: string | null; | ||
| 53 | readonly relayClient: RelayClient.RelayClientStatus; | ||
| 54 | } | ||
| 55 | |||
| 56 | function formatRelayClientStatus(executable: RelayClient.RelayClientStatus): ReadonlyArray<string> { | ||
| 57 | switch (executable.status) { | ||
| 58 | case "available": { | ||
| 59 | const source = | ||
| 60 | executable.source === "path" | ||
| 61 | ? "PATH" | ||
| 62 | : executable.source === "managed" | ||
| 63 | ? "managed install" | ||
| 64 | : "configured override"; | ||
| 65 | return [ | ||
| 66 | ` Relay client: available via ${source}`, | ||
| 67 | ` Path: ${executable.executablePath}`, | ||
| 68 | ` Version: ${executable.version}`, | ||
| 69 | ]; | ||
| 70 | } | ||
| 71 | case "missing": | ||
| 72 | return [" Relay client: not installed"]; | ||
| 73 | case "unsupported": | ||
| 74 | return [ | ||
| 75 | ` Relay client: unsupported on ${executable.platform}-${executable.arch}`, | ||
| 76 | ` Managed version: ${executable.version}`, | ||
| 77 | ]; | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | function formatCloudStatus(status: CloudCliStatus, options?: { readonly json?: boolean }): string { | ||
| 82 | if (options?.json) { | ||
| 83 | return JSON.stringify(status, null, 2); | ||
| 84 | } | ||
| 85 | |||
| 86 | const provisioned = status.linked | ||
| 87 | ? "provisioned" | ||
| 88 | : status.desired && status.authenticated | ||
| 89 | ? "pending server startup" | ||
| 90 | : "not provisioned"; | ||
| 91 | const nextStep = !status.authenticated | ||
| 92 | ? "Run `t3 cloud link` to authorize and enable cloud exposure." | ||
| 93 | : !status.desired | ||
| 94 | ? "Run `t3 cloud link` to enable cloud exposure." | ||
| 95 | : !status.linked | ||
| 96 | ? "Start T3 to provision the environment link and launch its managed tunnel." | ||
| 97 | : undefined; | ||
| 98 | |||
| 99 | return [ | ||
| 100 | "T3 Cloud", | ||
| 101 | ` Exposure: ${status.desired ? "enabled" : "disabled"}`, | ||
| 102 | ` Authorization: ${status.authenticated ? "stored credential" : "missing"}`, | ||
| 103 | ` Environment link: ${provisioned}`, | ||
| 104 | ` Relay: ${status.relayUrl ?? "not provisioned"}`, | ||
| 105 | ...formatRelayClientStatus(status.relayClient), | ||
| 106 | ...(nextStep ? ["", `Next: ${nextStep}`] : []), | ||
| 107 | ].join("\n"); | ||
| 108 | } | ||
| 109 | |||
| 110 | const CLOUD_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5); | ||
| 111 | |||
| 112 | const confirmRelayClientInstall = (version: string) => | ||
| 113 | Prompt.run( | ||
| 114 | Prompt.confirm({ | ||
| 115 | message: `The T3 relay client is required for T3 Cloud exposure. Download and install version ${version}?`, | ||
| 116 | initial: false, | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | |||
| 120 | function relayClientInstallProgressMessage(stage: RelayClientInstallProgressStage): string { | ||
| 121 | switch (stage) { | ||
| 122 | case "checking": | ||
| 123 | return "Checking existing installation"; | ||
| 124 | case "waiting_for_lock": | ||
| 125 | return "Waiting for installation lock"; | ||
| 126 | case "downloading": | ||
| 127 | return "Downloading"; | ||
| 128 | case "verifying": | ||
| 129 | return "Verifying download"; | ||
| 130 | case "installing": | ||
| 131 | return "Installing"; | ||
| 132 | case "validating": | ||
| 133 | return "Validating executable"; | ||
| 134 | case "activating": | ||
| 135 | return "Activating installation"; | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent) => | ||
| 140 | event.type === "progress" | ||
| 141 | ? Console.log(`Relay client: ${relayClientInstallProgressMessage(event.stage)}...`) | ||
| 142 | : Effect.void; | ||
| 143 | |||
| 144 | export const acquireRelayClientForLink = Effect.fn("cloud.cli.acquire_relay_client_for_link")( | ||
| 145 | function* <ConfirmError, ConfirmContext>( | ||
| 146 | relayClient: RelayClient.RelayClientShape, | ||
| 147 | confirmInstall: (version: string) => Effect.Effect<boolean, ConfirmError, ConfirmContext>, | ||
| 148 | reportProgress: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>, | ||
| 149 | ) { | ||
| 150 | const executable = yield* relayClient.resolve; | ||
| 151 | if (executable.status === "available") { | ||
| 152 | return Option.some(executable); | ||
| 153 | } | ||
| 154 | if (executable.status === "unsupported") { | ||
| 155 | return Option.some(yield* relayClient.installWithProgress(reportProgress)); | ||
| 156 | } | ||
| 157 | if (!(yield* confirmInstall(executable.version))) { | ||
| 158 | return Option.none(); | ||
| 159 | } | ||
| 160 | return Option.some(yield* relayClient.installWithProgress(reportProgress)); | ||
| 161 | }, | ||
| 162 | ); | ||
| 163 | |||
| 164 | const withCloudCliSessionToken = <A, E, R>( | ||
| 165 | environmentAuth: EnvironmentAuth.EnvironmentAuthShape, | ||
| 166 | run: (token: string) => Effect.Effect<A, E, R>, | ||
| 167 | ) => | ||
| 168 | Effect.acquireUseRelease( | ||
| 169 | environmentAuth.issueSession({ | ||
| 170 | scopes: [AuthRelayWriteScope], | ||
| 171 | subject: "cloud-cli", | ||
| 172 | label: "t3 cloud cli", | ||
| 173 | }), | ||
| 174 | (issued) => run(issued.token), | ||
| 175 | (issued) => environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), | ||
| 176 | ); | ||
| 177 | |||
| 178 | type LiveCloudActionResult = | ||
| 179 | | { readonly status: "not-running" } | ||
| 180 | | { readonly status: "succeeded" } | ||
| 181 | | { readonly status: "failed"; readonly cause: unknown }; | ||
| 182 | |||
| 183 | const runLiveCloudUnlink = Effect.fn("cloud.cli.run_live_unlink")(function* () { | ||
| 184 | const config = yield* ServerConfig; | ||
| 185 | const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); | ||
| 186 | if (Option.isNone(runtimeState)) { | ||
| 187 | return { status: "not-running" } satisfies LiveCloudActionResult; | ||
| 188 | } | ||
| 189 | |||
| 190 | const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; | ||
| 191 | const result = yield* Effect.exit( | ||
| 192 | withCloudCliSessionToken(environmentAuth, (token) => | ||
| 193 | HttpApiClient.make(EnvironmentHttpApi, { | ||
| 194 | baseUrl: runtimeState.value.origin, | ||
| 195 | }).pipe( | ||
| 196 | Effect.flatMap((client) => | ||
| 197 | client.cloud.unlink({ headers: { authorization: `Bearer ${token}` } }), | ||
| 198 | ), | ||
| 199 | Effect.timeout(CLOUD_CLI_LIVE_SERVER_TIMEOUT), | ||
| 200 | ), | ||
| 201 | ), | ||
| 202 | ); | ||
| 203 | return Exit.isSuccess(result) | ||
| 204 | ? ({ status: "succeeded" } satisfies LiveCloudActionResult) | ||
| 205 | : ({ status: "failed", cause: result.cause } satisfies LiveCloudActionResult); | ||
| 206 | }); | ||
| 207 | |||
| 208 | type RelayUnlinkResult = | ||
| 209 | | { readonly status: "not-authenticated" } | ||
| 210 | | { readonly status: "revoked" } | ||
| 211 | | { readonly status: "not-linked" }; | ||
| 212 | |||
| 213 | const unlinkRelayEnvironment = Effect.fn("cloud.cli.unlink_relay_environment")(function* () { | ||
| 214 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | ||
| 215 | const token = yield* tokens.getExisting; | ||
| 216 | if (Option.isNone(token)) { | ||
| 217 | return { status: "not-authenticated" } satisfies RelayUnlinkResult; | ||
| 218 | } | ||
| 219 | |||
| 220 | const environment = yield* ServerEnvironment; | ||
| 221 | const environmentId = yield* environment.getEnvironmentId; | ||
| 222 | const relayUrl = yield* relayUrlConfig; | ||
| 223 | const httpClient = yield* HttpClient.HttpClient; | ||
| 224 | const response = yield* HttpClientRequest.delete( | ||
| 225 | `${relayUrl}/v1/client/environment-links/${encodeURIComponent(environmentId)}`, | ||
| 226 | ).pipe( | ||
| 227 | HttpClientRequest.bearerToken(token.value.accessToken), | ||
| 228 | httpClient.execute, | ||
| 229 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 230 | Effect.flatMap(HttpClientResponse.schemaBodyJson(RelayOkResponse)), | ||
| 231 | ); | ||
| 232 | return response.ok | ||
| 233 | ? ({ status: "revoked" } satisfies RelayUnlinkResult) | ||
| 234 | : ({ status: "not-linked" } satisfies RelayUnlinkResult); | ||
| 235 | }); | ||
| 236 | |||
| 237 | const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { | ||
| 238 | readonly clearAuthorization: boolean; | ||
| 239 | }) { | ||
| 240 | yield* CliState.setCliDesiredCloudLink(false); | ||
| 241 | const liveResult = yield* runLiveCloudUnlink(); | ||
| 242 | const relayResult = yield* Effect.exit(unlinkRelayEnvironment()); | ||
| 243 | yield* CliState.clearPersistedCloudLink; | ||
| 244 | |||
| 245 | if (options.clearAuthorization) { | ||
| 246 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | ||
| 247 | yield* tokens.clear; | ||
| 248 | } | ||
| 249 | |||
| 250 | if (liveResult.status === "failed") { | ||
| 251 | yield* Console.warn( | ||
| 252 | `T3 Cloud exposure is disabled, but the running server could not stop its tunnel: ${String(liveResult.cause)}\nRestart that server to stop the connector.`, | ||
| 253 | ); | ||
| 254 | } else { | ||
| 255 | yield* Console.log("T3 Cloud exposure is disabled locally."); | ||
| 256 | } | ||
| 257 | |||
| 258 | if (Exit.isFailure(relayResult)) { | ||
| 259 | yield* Console.warn( | ||
| 260 | options.clearAuthorization | ||
| 261 | ? `Could not revoke the relay-side environment record before signing out: ${String(relayResult.cause)}\nThe stored CLI authorization was still removed locally.` | ||
| 262 | : `Could not revoke the relay-side environment record yet: ${String(relayResult.cause)}\nRun \`t3 cloud unlink\` again when the relay is reachable.`, | ||
| 263 | ); | ||
| 264 | } else if (relayResult.value.status === "revoked") { | ||
| 265 | yield* Console.log("Revoked the relay-side environment record."); | ||
| 266 | } | ||
| 267 | |||
| 268 | if (options.clearAuthorization) { | ||
| 269 | yield* Console.log("Signed out of T3 Cloud locally."); | ||
| 270 | } | ||
| 271 | }); | ||
| 272 | |||
| 273 | const runCloudCommand = <A, E>( | ||
| 274 | flags: { readonly baseDir: Option.Option<string> }, | ||
| 275 | run: Effect.Effect< | ||
| 276 | A, | ||
| 277 | E, | ||
| 278 | | ServerSecretStore.ServerSecretStore | ||
| 279 | | CliTokenManager.CloudCliTokenManager | ||
| 280 | | RelayClient.RelayClient | ||
| 281 | | EnvironmentAuth.EnvironmentAuth | ||
| 282 | | FileSystem.FileSystem | ||
| 283 | | HttpClient.HttpClient | ||
| 284 | | Prompt.Environment | ||
| 285 | | ServerConfig | ||
| 286 | | ServerEnvironment | ||
| 287 | >, | ||
| 288 | options?: { | ||
| 289 | readonly quietLogs?: boolean; | ||
| 290 | }, | ||
| 291 | ) => | ||
| 292 | Effect.gen(function* () { | ||
| 293 | const logLevel = yield* GlobalFlag.LogLevel; | ||
| 294 | const config = yield* resolveCliAuthConfig(flags, logLevel); | ||
| 295 | const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; | ||
| 296 | const runtimeLayer = Layer.mergeAll( | ||
| 297 | ServerSecretStore.layer, | ||
| 298 | CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), | ||
| 299 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), | ||
| 300 | EnvironmentAuth.runtimeLayer, | ||
| 301 | ServerEnvironmentLive, | ||
| 302 | ).pipe( | ||
| 303 | Layer.provideMerge(FetchHttpClient.layer), | ||
| 304 | Layer.provideMerge(Layer.succeed(ServerConfig, config)), | ||
| 305 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | ||
| 306 | ); | ||
| 307 | return yield* run.pipe(Effect.provide(runtimeLayer)); | ||
| 308 | }); | ||
| 309 | |||
| 310 | const cloudLoginCommand = Command.make("login", { | ||
| 311 | ...projectLocationFlags, | ||
| 312 | }).pipe( | ||
| 313 | Command.withDescription("Authorize the T3 Cloud CLI without enabling cloud exposure."), | ||
| 314 | Command.withHandler((flags) => | ||
| 315 | runCloudCommand( | ||
| 316 | flags, | ||
| 317 | Effect.gen(function* () { | ||
| 318 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | ||
| 319 | yield* tokens.get; | ||
| 320 | yield* Console.log("Signed in to T3 Cloud."); | ||
| 321 | }), | ||
| 322 | ), | ||
| 323 | ), | ||
| 324 | ); | ||
| 325 | |||
| 326 | const cloudLinkCommand = Command.make("link", { | ||
| 327 | ...projectLocationFlags, | ||
| 328 | }).pipe( | ||
| 329 | Command.withDescription("Authorize this environment for T3 Cloud and expose it on next start."), | ||
| 330 | Command.withHandler((flags) => | ||
| 331 | runCloudCommand( | ||
| 332 | flags, | ||
| 333 | Effect.gen(function* () { | ||
| 334 | const relayClient = yield* RelayClient.RelayClient; | ||
| 335 | const installed = yield* acquireRelayClientForLink( | ||
| 336 | relayClient, | ||
| 337 | confirmRelayClientInstall, | ||
| 338 | reportRelayClientInstallProgress, | ||
| 339 | ); | ||
| 340 | if (Option.isNone(installed)) { | ||
| 341 | yield* Console.log("T3 Cloud link cancelled. The relay client was not installed."); | ||
| 342 | return; | ||
| 343 | } | ||
| 344 | yield* Console.log( | ||
| 345 | `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`, | ||
| 346 | ); | ||
| 347 | |||
| 348 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | ||
| 349 | yield* tokens.get; | ||
| 350 | yield* CliState.setCliDesiredCloudLink(true); | ||
| 351 | yield* Console.log( | ||
| 352 | "This T3 environment will be available over T3 Cloud the next time T3 starts.", | ||
| 353 | ); | ||
| 354 | }), | ||
| 355 | ), | ||
| 356 | ), | ||
| 357 | ); | ||
| 358 | |||
| 359 | const cloudStatusCommand = Command.make("status", { | ||
| 360 | ...projectLocationFlags, | ||
| 361 | json: jsonFlag, | ||
| 362 | }).pipe( | ||
| 363 | Command.withDescription("Show persisted T3 Cloud and relay client state."), | ||
| 364 | Command.withHandler((flags) => | ||
| 365 | runCloudCommand( | ||
| 366 | flags, | ||
| 367 | Effect.gen(function* () { | ||
| 368 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 369 | const relayClient = yield* RelayClient.RelayClient; | ||
| 370 | const tokens = yield* CliTokenManager.CloudCliTokenManager; | ||
| 371 | const [desired, authenticated, cloudUserId, relayUrl, executable] = yield* Effect.all( | ||
| 372 | [ | ||
| 373 | CliState.readCliDesiredCloudLink, | ||
| 374 | tokens.hasCredential, | ||
| 375 | secrets.get(CLOUD_LINKED_USER_ID), | ||
| 376 | secrets.get(RELAY_URL_SECRET), | ||
| 377 | relayClient.resolve, | ||
| 378 | ], | ||
| 379 | { concurrency: "unbounded" }, | ||
| 380 | ); | ||
| 381 | const status: CloudCliStatus = { | ||
| 382 | desired, | ||
| 383 | authenticated, | ||
| 384 | linked: cloudUserId !== null, | ||
| 385 | cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, | ||
| 386 | relayUrl: relayUrl ? bytesToString(relayUrl) : null, | ||
| 387 | relayClient: executable, | ||
| 388 | }; | ||
| 389 | yield* Console.log(formatCloudStatus(status, { json: flags.json })); | ||
| 390 | }), | ||
| 391 | { | ||
| 392 | quietLogs: flags.json, | ||
| 393 | }, | ||
| 394 | ), | ||
| 395 | ), | ||
| 396 | ); | ||
| 397 | |||
| 398 | const cloudUnlinkCommand = Command.make("unlink", { | ||
| 399 | ...projectLocationFlags, | ||
| 400 | }).pipe( | ||
| 401 | Command.withDescription("Disable T3 Cloud exposure while retaining the stored authorization."), | ||
| 402 | Command.withHandler((flags) => | ||
| 403 | runCloudCommand(flags, disconnectCloud({ clearAuthorization: false })), | ||
| 404 | ), | ||
| 405 | ); | ||
| 406 | |||
| 407 | const cloudLogoutCommand = Command.make("logout", { | ||
| 408 | ...projectLocationFlags, | ||
| 409 | }).pipe( | ||
| 410 | Command.withDescription("Disable T3 Cloud exposure and clear the stored CLI authorization."), | ||
| 411 | Command.withHandler((flags) => | ||
| 412 | runCloudCommand(flags, disconnectCloud({ clearAuthorization: true })), | ||
| 413 | ), | ||
| 414 | ); | ||
| 415 | |||
| 416 | export const cloudCommand = Command.make("cloud").pipe( | ||
| 417 | Command.withDescription("Manage headless T3 Cloud exposure."), | ||
| 418 | Command.withSubcommands([ | ||
| 419 | cloudLoginCommand, | ||
| 420 | cloudLinkCommand, | ||
| 421 | cloudStatusCommand, | ||
| 422 | cloudUnlinkCommand, | ||
| 423 | cloudLogoutCommand, | ||
| 424 | ]), | ||
| 425 | ); | ||
apps/server/src/cli/project.ts+2-6| ... | @@ -116,12 +116,8 @@ const failLiveServerRequest = (cause: unknown) => { | ... | @@ -116,12 +116,8 @@ const failLiveServerRequest = (cause: unknown) => { |
| 116 | }; | 116 | }; |
| 117 | 117 | ||
| 118 | const makeLiveServerClient = (origin: string) => | 118 | const makeLiveServerClient = (origin: string) => |
| 119 | Effect.gen(function* () { | 119 | HttpApiClient.make(EnvironmentHttpApi, { |
| 120 | const httpClient = yield* HttpClient.HttpClient; | 120 | baseUrl: origin, |
| 121 | return yield* HttpApiClient.makeWith(EnvironmentHttpApi, { | ||
| 122 | baseUrl: origin, | ||
| 123 | httpClient, | ||
| 124 | }); | ||
| 125 | }); | 121 | }); |
| 126 | 122 | ||
| 127 | const normalizeWorkspaceRootForProjectCommand = Effect.fn( | 123 | const normalizeWorkspaceRootForProjectCommand = Effect.fn( |
apps/server/src/cloud/CliState.test.ts created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | |||
| 6 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 7 | import { ServerConfig } from "../config.ts"; | ||
| 8 | import * as CliState from "./CliState.ts"; | ||
| 9 | import { | ||
| 10 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | ||
| 11 | CLOUD_LINKED_USER_ID, | ||
| 12 | CLOUD_MINT_PUBLIC_KEY, | ||
| 13 | PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 14 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 15 | RELAY_ISSUER_SECRET, | ||
| 16 | RELAY_URL_SECRET, | ||
| 17 | } from "./config.ts"; | ||
| 18 | |||
| 19 | const persistedCloudLinkSecrets = [ | ||
| 20 | CLOUD_LINKED_USER_ID, | ||
| 21 | RELAY_URL_SECRET, | ||
| 22 | RELAY_ISSUER_SECRET, | ||
| 23 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 24 | CLOUD_MINT_PUBLIC_KEY, | ||
| 25 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | ||
| 26 | PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 27 | ] as const; | ||
| 28 | |||
| 29 | const makeTestLayer = () => | ||
| 30 | ServerSecretStore.layer.pipe( | ||
| 31 | Layer.provide( | ||
| 32 | ServerConfig.layerTest(process.cwd(), { | ||
| 33 | prefix: "t3-cloud-cli-state-test-", | ||
| 34 | }), | ||
| 35 | ), | ||
| 36 | ); | ||
| 37 | |||
| 38 | it.layer(NodeServices.layer)("CliState", (it) => { | ||
| 39 | it.effect("persists desired exposure and clears provisioned relay state", () => | ||
| 40 | Effect.gen(function* () { | ||
| 41 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 42 | |||
| 43 | expect(yield* CliState.readCliDesiredCloudLink).toBe(false); | ||
| 44 | yield* CliState.setCliDesiredCloudLink(true); | ||
| 45 | expect(yield* CliState.readCliDesiredCloudLink).toBe(true); | ||
| 46 | |||
| 47 | for (const name of persistedCloudLinkSecrets) { | ||
| 48 | yield* secrets.set(name, new TextEncoder().encode(name)); | ||
| 49 | } | ||
| 50 | yield* CliState.clearPersistedCloudLink; | ||
| 51 | |||
| 52 | expect(yield* CliState.readCliDesiredCloudLink).toBe(false); | ||
| 53 | for (const name of persistedCloudLinkSecrets) { | ||
| 54 | expect(yield* secrets.get(name)).toBe(null); | ||
| 55 | } | ||
| 56 | }).pipe(Effect.provide(makeTestLayer())), | ||
| 57 | ); | ||
| 58 | }); | ||
apps/server/src/cloud/CliState.ts created+49| ... | @@ -0,0 +1,49 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | |||
| 3 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 4 | import { | ||
| 5 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | ||
| 6 | CLOUD_LINKED_USER_ID, | ||
| 7 | CLOUD_MINT_PUBLIC_KEY, | ||
| 8 | PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 9 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 10 | RELAY_ISSUER_SECRET, | ||
| 11 | RELAY_URL_SECRET, | ||
| 12 | } from "./config.ts"; | ||
| 13 | |||
| 14 | export const CLOUD_CLI_DESIRED_LINK_SECRET = "cloud-cli-desired-link"; | ||
| 15 | |||
| 16 | const TRUE_BYTES = new TextEncoder().encode("true"); | ||
| 17 | |||
| 18 | export const readCliDesiredCloudLink = Effect.gen(function* () { | ||
| 19 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 20 | return (yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET)) !== null; | ||
| 21 | }); | ||
| 22 | |||
| 23 | export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(function* ( | ||
| 24 | desired: boolean, | ||
| 25 | ) { | ||
| 26 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 27 | if (desired) { | ||
| 28 | yield* secrets.set(CLOUD_CLI_DESIRED_LINK_SECRET, TRUE_BYTES); | ||
| 29 | } else { | ||
| 30 | yield* secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET); | ||
| 31 | } | ||
| 32 | }); | ||
| 33 | |||
| 34 | export const clearPersistedCloudLink = Effect.gen(function* () { | ||
| 35 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 36 | yield* Effect.all( | ||
| 37 | [ | ||
| 38 | secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET), | ||
| 39 | secrets.remove(CLOUD_LINKED_USER_ID), | ||
| 40 | secrets.remove(RELAY_URL_SECRET), | ||
| 41 | secrets.remove(RELAY_ISSUER_SECRET), | ||
| 42 | secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), | ||
| 43 | secrets.remove(CLOUD_MINT_PUBLIC_KEY), | ||
| 44 | secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), | ||
| 45 | secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), | ||
| 46 | ], | ||
| 47 | { concurrency: "unbounded" }, | ||
| 48 | ); | ||
| 49 | }); | ||
apps/server/src/cloud/CliTokenManager.ts created+236| ... | @@ -0,0 +1,236 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - The CLI loopback OAuth callback is a Node HTTP boundary. | ||
| 2 | import { createServer } from "node:http"; | ||
| 3 | |||
| 4 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | ||
| 5 | import * as Clock from "effect/Clock"; | ||
| 6 | import * as Console from "effect/Console"; | ||
| 7 | import * as Context from "effect/Context"; | ||
| 8 | import * as Crypto from "effect/Crypto"; | ||
| 9 | import * as Data from "effect/Data"; | ||
| 10 | import * as Deferred from "effect/Deferred"; | ||
| 11 | import * as Duration from "effect/Duration"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Encoding from "effect/Encoding"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Schema from "effect/Schema"; | ||
| 17 | import * as Semaphore from "effect/Semaphore"; | ||
| 18 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; | ||
| 19 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | ||
| 20 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; | ||
| 21 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 22 | |||
| 23 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 24 | import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts"; | ||
| 25 | |||
| 26 | const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; | ||
| 27 | const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); | ||
| 28 | const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); | ||
| 29 | |||
| 30 | const PersistedToken = Schema.Struct({ | ||
| 31 | accessToken: Schema.String, | ||
| 32 | refreshToken: Schema.String, | ||
| 33 | expiresAtEpochMs: Schema.Number, | ||
| 34 | }); | ||
| 35 | type PersistedToken = typeof PersistedToken.Type; | ||
| 36 | |||
| 37 | const PersistedTokenJson = Schema.fromJsonString(PersistedToken); | ||
| 38 | const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson); | ||
| 39 | const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson); | ||
| 40 | |||
| 41 | const OAuthTokenResponse = Schema.Struct({ | ||
| 42 | access_token: Schema.String, | ||
| 43 | refresh_token: Schema.optional(Schema.String), | ||
| 44 | expires_in: Schema.Number, | ||
| 45 | token_type: Schema.String, | ||
| 46 | }); | ||
| 47 | |||
| 48 | export class CloudCliTokenManagerError extends Data.TaggedError("CloudCliTokenManagerError")<{ | ||
| 49 | readonly message: string; | ||
| 50 | readonly cause?: unknown; | ||
| 51 | }> {} | ||
| 52 | |||
| 53 | export interface CloudCliTokenManagerShape { | ||
| 54 | readonly get: Effect.Effect<PersistedToken, CloudCliTokenManagerError>; | ||
| 55 | readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>; | ||
| 56 | readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>; | ||
| 57 | readonly clear: Effect.Effect<void, CloudCliTokenManagerError>; | ||
| 58 | } | ||
| 59 | |||
| 60 | export class CloudCliTokenManager extends Context.Service< | ||
| 61 | CloudCliTokenManager, | ||
| 62 | CloudCliTokenManagerShape | ||
| 63 | >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} | ||
| 64 | |||
| 65 | const wrapError = | ||
| 66 | (message: string) => | ||
| 67 | <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, CloudCliTokenManagerError, R> => | ||
| 68 | effect.pipe( | ||
| 69 | Effect.mapError( | ||
| 70 | (cause) => | ||
| 71 | new CloudCliTokenManagerError({ | ||
| 72 | message, | ||
| 73 | cause, | ||
| 74 | }), | ||
| 75 | ), | ||
| 76 | ); | ||
| 77 | |||
| 78 | function stringToBytes(value: string): Uint8Array { | ||
| 79 | return new TextEncoder().encode(value); | ||
| 80 | } | ||
| 81 | |||
| 82 | function bytesToString(value: Uint8Array): string { | ||
| 83 | return new TextDecoder().decode(value); | ||
| 84 | } | ||
| 85 | |||
| 86 | const make = Effect.gen(function* () { | ||
| 87 | const crypto = yield* Crypto.Crypto; | ||
| 88 | const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); | ||
| 89 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 90 | const semaphore = yield* Semaphore.make(1); | ||
| 91 | const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) { | ||
| 92 | const encoded = yield* encodePersistedToken(token); | ||
| 93 | yield* secrets.set(CLOUD_CLI_OAUTH_TOKEN_SECRET, stringToBytes(encoded)); | ||
| 94 | return token; | ||
| 95 | }); | ||
| 96 | |||
| 97 | const clear = secrets | ||
| 98 | .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) | ||
| 99 | .pipe(wrapError("Could not remove the stored T3 Cloud CLI credential.")); | ||
| 100 | |||
| 101 | const read = Effect.fn("cloud.cli_token.read")(function* () { | ||
| 102 | const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); | ||
| 103 | if (!encoded) return Option.none<PersistedToken>(); | ||
| 104 | return Option.some(yield* decodePersistedToken(bytesToString(encoded))); | ||
| 105 | }); | ||
| 106 | |||
| 107 | const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* ( | ||
| 108 | metadata: CloudCliOAuthConfig, | ||
| 109 | params: Record<string, string>, | ||
| 110 | ) { | ||
| 111 | const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( | ||
| 112 | HttpClientRequest.bodyUrlParams(params), | ||
| 113 | httpClient.execute, | ||
| 114 | Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)), | ||
| 115 | ); | ||
| 116 | const now = yield* Clock.currentTimeMillis; | ||
| 117 | return { | ||
| 118 | accessToken: response.access_token, | ||
| 119 | refreshToken: response.refresh_token ?? params.refresh_token ?? "", | ||
| 120 | expiresAtEpochMs: now + response.expires_in * 1_000, | ||
| 121 | } satisfies PersistedToken; | ||
| 122 | }); | ||
| 123 | |||
| 124 | const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { | ||
| 125 | const metadata = yield* cloudCliOAuthConfig; | ||
| 126 | return yield* exchangeToken(metadata, { | ||
| 127 | grant_type: "refresh_token", | ||
| 128 | refresh_token: token.refreshToken, | ||
| 129 | client_id: metadata.clientId, | ||
| 130 | }); | ||
| 131 | }); | ||
| 132 | |||
| 133 | const login = Effect.fn("cloud.cli_token.login")(function* () { | ||
| 134 | const metadata = yield* cloudCliOAuthConfig; | ||
| 135 | const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); | ||
| 136 | const challenge = Encoding.encodeBase64Url( | ||
| 137 | yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), | ||
| 138 | ); | ||
| 139 | const state = yield* crypto.randomUUIDv4; | ||
| 140 | const callback = yield* Deferred.make<string>(); | ||
| 141 | const callbackRoute = HttpRouter.add( | ||
| 142 | "GET", | ||
| 143 | "/callback", | ||
| 144 | Effect.gen(function* () { | ||
| 145 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 146 | const url = new URL(request.originalUrl, metadata.redirectUri); | ||
| 147 | const code = url.searchParams.get("code"); | ||
| 148 | if (url.searchParams.get("state") !== state || !code) { | ||
| 149 | return HttpServerResponse.text("Invalid T3 Cloud authorization callback.", { | ||
| 150 | status: 400, | ||
| 151 | }); | ||
| 152 | } | ||
| 153 | yield* Deferred.succeed(callback, code); | ||
| 154 | return yield* HttpServerResponse.html` | ||
| 155 | <html> | ||
| 156 | <body style="font-family: sans-serif; text-align: center; margin-top: 50px;"> | ||
| 157 | <h1>T3 Cloud authorization complete</h1> | ||
| 158 | <p>You can close this window and return to your terminal.</p> | ||
| 159 | </body> | ||
| 160 | </html> | ||
| 161 | `; | ||
| 162 | }), | ||
| 163 | ); | ||
| 164 | yield* HttpRouter.serve(callbackRoute, { | ||
| 165 | disableListenLog: true, | ||
| 166 | disableLogger: true, | ||
| 167 | }).pipe( | ||
| 168 | Layer.provide( | ||
| 169 | NodeHttpServer.layer(createServer, { | ||
| 170 | host: "127.0.0.1", | ||
| 171 | port: 34338, | ||
| 172 | disablePreemptiveShutdown: true, | ||
| 173 | }), | ||
| 174 | ), | ||
| 175 | Layer.build, | ||
| 176 | ); | ||
| 177 | const authorizationUrl = new URL(metadata.authorizationEndpoint); | ||
| 178 | authorizationUrl.searchParams.set("client_id", metadata.clientId); | ||
| 179 | authorizationUrl.searchParams.set("redirect_uri", metadata.redirectUri); | ||
| 180 | authorizationUrl.searchParams.set("response_type", "code"); | ||
| 181 | authorizationUrl.searchParams.set("scope", metadata.scopes.join(" ")); | ||
| 182 | authorizationUrl.searchParams.set("state", state); | ||
| 183 | authorizationUrl.searchParams.set("code_challenge", challenge); | ||
| 184 | authorizationUrl.searchParams.set("code_challenge_method", "S256"); | ||
| 185 | yield* Console.log(`Open this URL to authorize T3 Cloud:\n${authorizationUrl.toString()}\n`); | ||
| 186 | const code = yield* Deferred.await(callback).pipe( | ||
| 187 | Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), | ||
| 188 | Effect.catchTag("TimeoutError", () => | ||
| 189 | Effect.fail( | ||
| 190 | new CloudCliTokenManagerError({ | ||
| 191 | message: "Timed out waiting for T3 Cloud authorization.", | ||
| 192 | }), | ||
| 193 | ), | ||
| 194 | ), | ||
| 195 | ); | ||
| 196 | return yield* exchangeToken(metadata, { | ||
| 197 | grant_type: "authorization_code", | ||
| 198 | code, | ||
| 199 | redirect_uri: metadata.redirectUri, | ||
| 200 | client_id: metadata.clientId, | ||
| 201 | code_verifier: verifier, | ||
| 202 | }); | ||
| 203 | }); | ||
| 204 | |||
| 205 | const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () { | ||
| 206 | const token = yield* read(); | ||
| 207 | if (Option.isNone(token)) return token; | ||
| 208 | const now = yield* Clock.currentTimeMillis; | ||
| 209 | if (token.value.expiresAtEpochMs - CLOUD_CLI_OAUTH_REFRESH_EARLY_MS > now) { | ||
| 210 | return token; | ||
| 211 | } | ||
| 212 | return Option.some(yield* refresh(token.value).pipe(Effect.flatMap(persist))); | ||
| 213 | }); | ||
| 214 | |||
| 215 | const getExisting = semaphore.withPermits(1)( | ||
| 216 | getExistingNoLock().pipe(wrapError("Could not refresh the T3 Cloud CLI credential.")), | ||
| 217 | ); | ||
| 218 | const hasCredential = semaphore.withPermits(1)( | ||
| 219 | read().pipe( | ||
| 220 | Effect.map(Option.isSome), | ||
| 221 | wrapError("Could not read the stored T3 Cloud CLI credential."), | ||
| 222 | ), | ||
| 223 | ); | ||
| 224 | const get = semaphore.withPermits(1)( | ||
| 225 | Effect.gen(function* () { | ||
| 226 | const token = yield* getExistingNoLock(); | ||
| 227 | return Option.isSome(token) | ||
| 228 | ? token.value | ||
| 229 | : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); | ||
| 230 | }).pipe(wrapError("Could not authorize the T3 Cloud CLI.")), | ||
| 231 | ); | ||
| 232 | |||
| 233 | return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); | ||
| 234 | }); | ||
| 235 | |||
| 236 | export const layer = Layer.effect(CloudCliTokenManager, make); | ||
apps/server/src/cloud/ManagedEndpointRuntime.test.ts created+362| ... | @@ -0,0 +1,362 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { vi } from "vitest"; | ||
| 3 | import * as Deferred from "effect/Deferred"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as PlatformError from "effect/PlatformError"; | ||
| 8 | import * as Sink from "effect/Sink"; | ||
| 9 | import * as Stream from "effect/Stream"; | ||
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 11 | import * as RelayClient from "@t3tools/shared/relayClient"; | ||
| 12 | |||
| 13 | import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; | ||
| 14 | |||
| 15 | const relayClientAvailableLayer = Layer.succeed( | ||
| 16 | RelayClient.RelayClient, | ||
| 17 | RelayClient.RelayClient.of({ | ||
| 18 | resolve: Effect.succeed({ | ||
| 19 | status: "available", | ||
| 20 | executablePath: "cloudflared", | ||
| 21 | source: "path", | ||
| 22 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 23 | }), | ||
| 24 | install: Effect.die("unused"), | ||
| 25 | installWithProgress: () => Effect.die("unused"), | ||
| 26 | }), | ||
| 27 | ); | ||
| 28 | |||
| 29 | const runtimeDependencies = (spawner: ReturnType<typeof ChildProcessSpawner.make>) => | ||
| 30 | Layer.mergeAll( | ||
| 31 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 32 | relayClientAvailableLayer, | ||
| 33 | ); | ||
| 34 | |||
| 35 | function makeHandle(input: { | ||
| 36 | readonly pid: number; | ||
| 37 | readonly onKill: () => void; | ||
| 38 | readonly isRunning?: () => boolean; | ||
| 39 | readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>; | ||
| 40 | }) { | ||
| 41 | return ChildProcessSpawner.makeHandle({ | ||
| 42 | pid: ChildProcessSpawner.ProcessId(input.pid), | ||
| 43 | exitCode: input.exitCode ?? Effect.never, | ||
| 44 | isRunning: Effect.sync(() => input.isRunning?.() ?? true), | ||
| 45 | kill: () => | ||
| 46 | Effect.sync(() => { | ||
| 47 | input.onKill(); | ||
| 48 | }), | ||
| 49 | unref: Effect.succeed(Effect.void), | ||
| 50 | stdin: Sink.drain, | ||
| 51 | stdout: Stream.empty, | ||
| 52 | stderr: Stream.empty, | ||
| 53 | all: Stream.empty, | ||
| 54 | getInputFd: () => Sink.drain, | ||
| 55 | getOutputFd: () => Stream.empty, | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | |||
| 59 | describe("CloudManagedEndpointRuntime", () => { | ||
| 60 | it.effect("starts, deduplicates, rotates, and stops the Cloudflare connector", () => | ||
| 61 | Effect.gen(function* () { | ||
| 62 | const spawned: Array<ChildProcess.StandardCommand> = []; | ||
| 63 | const killed: Array<number> = []; | ||
| 64 | let nextPid = 100; | ||
| 65 | const spawner = ChildProcessSpawner.make((command) => | ||
| 66 | Effect.gen(function* () { | ||
| 67 | if (!ChildProcess.isStandardCommand(command)) { | ||
| 68 | throw new Error("Expected standard command."); | ||
| 69 | } | ||
| 70 | spawned.push(command); | ||
| 71 | const pid = nextPid; | ||
| 72 | nextPid += 1; | ||
| 73 | const handle = makeHandle({ | ||
| 74 | pid, | ||
| 75 | onKill: () => { | ||
| 76 | killed.push(pid); | ||
| 77 | }, | ||
| 78 | }); | ||
| 79 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 80 | return handle; | ||
| 81 | }), | ||
| 82 | ); | ||
| 83 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | ||
| 84 | Effect.provide(runtimeDependencies(spawner)), | ||
| 85 | ); | ||
| 86 | |||
| 87 | yield* runtime.applyConfig({ | ||
| 88 | providerKind: "cloudflare_tunnel", | ||
| 89 | connectorToken: "token-1", | ||
| 90 | tunnelId: "tunnel-1", | ||
| 91 | tunnelName: "t3-code-env-1", | ||
| 92 | }); | ||
| 93 | yield* runtime.applyConfig({ | ||
| 94 | providerKind: "cloudflare_tunnel", | ||
| 95 | connectorToken: "token-1", | ||
| 96 | tunnelId: "tunnel-1", | ||
| 97 | tunnelName: "t3-code-env-1", | ||
| 98 | }); | ||
| 99 | yield* runtime.applyConfig({ | ||
| 100 | providerKind: "cloudflare_tunnel", | ||
| 101 | connectorToken: "token-2", | ||
| 102 | tunnelId: "tunnel-1", | ||
| 103 | tunnelName: "t3-code-env-1", | ||
| 104 | }); | ||
| 105 | const stopped = yield* runtime.applyConfig(null); | ||
| 106 | |||
| 107 | expect(spawned.map((command) => command.command)).toEqual(["cloudflared", "cloudflared"]); | ||
| 108 | expect(spawned.map((command) => command.args)).toEqual([ | ||
| 109 | ["tunnel", "run"], | ||
| 110 | ["tunnel", "run"], | ||
| 111 | ]); | ||
| 112 | expect(spawned.map((command) => command.options.env?.TUNNEL_TOKEN)).toEqual([ | ||
| 113 | "token-1", | ||
| 114 | "token-2", | ||
| 115 | ]); | ||
| 116 | expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]); | ||
| 117 | expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]); | ||
| 118 | expect(spawned.map((command) => command.options.detached)).toEqual([false, false]); | ||
| 119 | expect(spawned.map((command) => command.options.shell)).toEqual([false, false]); | ||
| 120 | expect(killed).toEqual([100, 101]); | ||
| 121 | expect(stopped).toEqual({ status: "disabled" }); | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | |||
| 125 | it.effect("stops an active connector when a non-Cloudflare runtime config is applied", () => | ||
| 126 | Effect.gen(function* () { | ||
| 127 | const killed: Array<number> = []; | ||
| 128 | const spawner = ChildProcessSpawner.make(() => | ||
| 129 | Effect.gen(function* () { | ||
| 130 | const handle = makeHandle({ | ||
| 131 | pid: 200, | ||
| 132 | onKill: () => { | ||
| 133 | killed.push(200); | ||
| 134 | }, | ||
| 135 | }); | ||
| 136 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 137 | return handle; | ||
| 138 | }), | ||
| 139 | ); | ||
| 140 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | ||
| 141 | Effect.provide(runtimeDependencies(spawner)), | ||
| 142 | ); | ||
| 143 | |||
| 144 | const started = yield* runtime.applyConfig({ | ||
| 145 | providerKind: "cloudflare_tunnel", | ||
| 146 | connectorToken: "token", | ||
| 147 | }); | ||
| 148 | const unsupported = yield* runtime.applyConfig({ | ||
| 149 | providerKind: "manual", | ||
| 150 | connectorToken: "manual-token", | ||
| 151 | }); | ||
| 152 | |||
| 153 | expect(started.status).toBe("running"); | ||
| 154 | expect(unsupported).toEqual({ status: "unsupported", providerKind: "manual" }); | ||
| 155 | expect(killed).toEqual([200]); | ||
| 156 | }), | ||
| 157 | ); | ||
| 158 | |||
| 159 | it.effect("restarts the connector when the active process has exited", () => | ||
| 160 | Effect.gen(function* () { | ||
| 161 | const spawned: Array<number> = []; | ||
| 162 | const killed: Array<number> = []; | ||
| 163 | let firstRunning = true; | ||
| 164 | const spawner = ChildProcessSpawner.make(() => | ||
| 165 | Effect.gen(function* () { | ||
| 166 | const pid = spawned.length === 0 ? 300 : 301; | ||
| 167 | spawned.push(pid); | ||
| 168 | const handle = makeHandle({ | ||
| 169 | pid, | ||
| 170 | isRunning: () => (pid === 300 ? firstRunning : true), | ||
| 171 | onKill: () => { | ||
| 172 | killed.push(pid); | ||
| 173 | }, | ||
| 174 | }); | ||
| 175 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 176 | return handle; | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | ||
| 180 | Effect.provide(runtimeDependencies(spawner)), | ||
| 181 | ); | ||
| 182 | const config = { | ||
| 183 | providerKind: "cloudflare_tunnel" as const, | ||
| 184 | connectorToken: "token", | ||
| 185 | tunnelId: "tunnel-1", | ||
| 186 | }; | ||
| 187 | |||
| 188 | const first = yield* runtime.applyConfig(config); | ||
| 189 | firstRunning = false; | ||
| 190 | const second = yield* runtime.applyConfig(config); | ||
| 191 | |||
| 192 | expect(first).toMatchObject({ status: "running", pid: 300 }); | ||
| 193 | expect(second).toMatchObject({ status: "running", pid: 301 }); | ||
| 194 | expect(spawned).toEqual([300, 301]); | ||
| 195 | expect(killed).toEqual([300]); | ||
| 196 | }), | ||
| 197 | ); | ||
| 198 | |||
| 199 | it.effect("supervises the active connector and restarts it after process exit", () => | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const spawned: Array<number> = []; | ||
| 202 | const killed: Array<number> = []; | ||
| 203 | const firstExit = yield* Deferred.make<ChildProcessSpawner.ExitCode>(); | ||
| 204 | const secondSpawned = yield* Deferred.make<void>(); | ||
| 205 | const spawner = ChildProcessSpawner.make(() => | ||
| 206 | Effect.gen(function* () { | ||
| 207 | const pid = spawned.length === 0 ? 400 : 401; | ||
| 208 | spawned.push(pid); | ||
| 209 | if (pid === 401) { | ||
| 210 | yield* Deferred.succeed(secondSpawned, undefined); | ||
| 211 | } | ||
| 212 | const handle = makeHandle({ | ||
| 213 | pid, | ||
| 214 | exitCode: | ||
| 215 | pid === 400 | ||
| 216 | ? Deferred.await(firstExit) | ||
| 217 | : (Effect.never as Effect.Effect<ChildProcessSpawner.ExitCode>), | ||
| 218 | onKill: () => { | ||
| 219 | killed.push(pid); | ||
| 220 | }, | ||
| 221 | }); | ||
| 222 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 223 | return handle; | ||
| 224 | }), | ||
| 225 | ); | ||
| 226 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | ||
| 227 | Effect.provide(runtimeDependencies(spawner)), | ||
| 228 | ); | ||
| 229 | |||
| 230 | const started = yield* runtime.applyConfig({ | ||
| 231 | providerKind: "cloudflare_tunnel", | ||
| 232 | connectorToken: "token", | ||
| 233 | tunnelId: "tunnel-1", | ||
| 234 | }); | ||
| 235 | yield* Deferred.succeed(firstExit, ChildProcessSpawner.ExitCode(1)); | ||
| 236 | yield* Deferred.await(secondSpawned); | ||
| 237 | |||
| 238 | expect(started).toMatchObject({ status: "running", pid: 400 }); | ||
| 239 | expect(spawned).toEqual([400, 401]); | ||
| 240 | expect(killed).toEqual([400]); | ||
| 241 | }), | ||
| 242 | ); | ||
| 243 | |||
| 244 | it.effect("serializes concurrent connector config changes", () => | ||
| 245 | Effect.gen(function* () { | ||
| 246 | const spawned: Array<number> = []; | ||
| 247 | const killed: Array<number> = []; | ||
| 248 | const firstSpawnEntered = yield* Deferred.make<void>(); | ||
| 249 | const releaseFirstSpawn = yield* Deferred.make<void>(); | ||
| 250 | const spawner = ChildProcessSpawner.make(() => | ||
| 251 | Effect.gen(function* () { | ||
| 252 | const pid = 500 + spawned.length; | ||
| 253 | spawned.push(pid); | ||
| 254 | if (pid === 500) { | ||
| 255 | yield* Deferred.succeed(firstSpawnEntered, undefined); | ||
| 256 | yield* Deferred.await(releaseFirstSpawn); | ||
| 257 | } | ||
| 258 | const handle = makeHandle({ | ||
| 259 | pid, | ||
| 260 | onKill: () => { | ||
| 261 | killed.push(pid); | ||
| 262 | }, | ||
| 263 | }); | ||
| 264 | yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore)); | ||
| 265 | return handle; | ||
| 266 | }), | ||
| 267 | ); | ||
| 268 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | ||
| 269 | Effect.provide(runtimeDependencies(spawner)), | ||
| 270 | ); | ||
| 271 | |||
| 272 | const first = yield* runtime | ||
| 273 | .applyConfig({ | ||
| 274 | providerKind: "cloudflare_tunnel", | ||
| 275 | connectorToken: "token-1", | ||
| 276 | }) | ||
| 277 | .pipe(Effect.forkChild); | ||
| 278 | yield* Deferred.await(firstSpawnEntered); | ||
| 279 | const second = yield* runtime | ||
| 280 | .applyConfig({ | ||
| 281 | providerKind: "cloudflare_tunnel", | ||
| 282 | connectorToken: "token-2", | ||
| 283 | }) | ||
| 284 | .pipe(Effect.forkChild); | ||
| 285 | yield* Deferred.succeed(releaseFirstSpawn, undefined); | ||
| 286 | |||
| 287 | yield* Fiber.join(first); | ||
| 288 | const status = yield* Fiber.join(second); | ||
| 289 | |||
| 290 | expect(status).toMatchObject({ status: "running", pid: 501 }); | ||
| 291 | expect(spawned).toEqual([500, 501]); | ||
| 292 | expect(killed).toEqual([500]); | ||
| 293 | }), | ||
| 294 | ); | ||
| 295 | |||
| 296 | it.effect("reports connector spawn failures", () => | ||
| 297 | Effect.gen(function* () { | ||
| 298 | const spawner = ChildProcessSpawner.make(() => | ||
| 299 | Effect.fail( | ||
| 300 | PlatformError.systemError({ | ||
| 301 | _tag: "NotFound", | ||
| 302 | module: "ChildProcess", | ||
| 303 | method: "spawn", | ||
| 304 | description: "cloudflared missing", | ||
| 305 | }), | ||
| 306 | ), | ||
| 307 | ); | ||
| 308 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | ||
| 309 | Effect.provide(runtimeDependencies(spawner)), | ||
| 310 | ); | ||
| 311 | |||
| 312 | const status = yield* runtime.applyConfig({ | ||
| 313 | providerKind: "cloudflare_tunnel", | ||
| 314 | connectorToken: "token", | ||
| 315 | tunnelId: "tunnel-1", | ||
| 316 | }); | ||
| 317 | |||
| 318 | expect(status).toMatchObject({ | ||
| 319 | status: "failed", | ||
| 320 | providerKind: "cloudflare_tunnel", | ||
| 321 | tunnelId: "tunnel-1", | ||
| 322 | }); | ||
| 323 | }), | ||
| 324 | ); | ||
| 325 | |||
| 326 | it.effect("reports a missing relay client executable without spawning", () => | ||
| 327 | Effect.gen(function* () { | ||
| 328 | const spawn = vi.fn(); | ||
| 329 | const spawner = ChildProcessSpawner.make(spawn); | ||
| 330 | const runtime = yield* makeCloudManagedEndpointRuntime.pipe( | ||
| 331 | Effect.provide( | ||
| 332 | Layer.mergeAll( | ||
| 333 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 334 | Layer.succeed( | ||
| 335 | RelayClient.RelayClient, | ||
| 336 | RelayClient.RelayClient.of({ | ||
| 337 | resolve: Effect.succeed({ | ||
| 338 | status: "missing", | ||
| 339 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 340 | }), | ||
| 341 | install: Effect.die("unused"), | ||
| 342 | installWithProgress: () => Effect.die("unused"), | ||
| 343 | }), | ||
| 344 | ), | ||
| 345 | ), | ||
| 346 | ), | ||
| 347 | ); | ||
| 348 | |||
| 349 | const status = yield* runtime.applyConfig({ | ||
| 350 | providerKind: "cloudflare_tunnel", | ||
| 351 | connectorToken: "token", | ||
| 352 | }); | ||
| 353 | |||
| 354 | expect(status).toEqual({ | ||
| 355 | status: "failed", | ||
| 356 | providerKind: "cloudflare_tunnel", | ||
| 357 | reason: "The relay client is not installed.", | ||
| 358 | }); | ||
| 359 | expect(spawn).not.toHaveBeenCalled(); | ||
| 360 | }), | ||
| 361 | ); | ||
| 362 | }); | ||
apps/server/src/cloud/ManagedEndpointRuntime.ts created+283| ... | @@ -0,0 +1,283 @@ | ||
| 1 | import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; | ||
| 2 | import * as RelayClient from "@t3tools/shared/relayClient"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Exit from "effect/Exit"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Ref from "effect/Ref"; | ||
| 9 | import * as Result from "effect/Result"; | ||
| 10 | import * as Semaphore from "effect/Semaphore"; | ||
| 11 | import * as Scope from "effect/Scope"; | ||
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 13 | |||
| 14 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 15 | import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts"; | ||
| 16 | |||
| 17 | function bytesToString(bytes: Uint8Array): string { | ||
| 18 | return new TextDecoder().decode(bytes); | ||
| 19 | } | ||
| 20 | |||
| 21 | const readRuntimeConfig = Effect.gen(function* () { | ||
| 22 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 23 | const bytes = yield* secrets.get(CLOUD_ENDPOINT_RUNTIME_CONFIG); | ||
| 24 | if (!bytes) { | ||
| 25 | return null; | ||
| 26 | } | ||
| 27 | return Option.getOrNull(decodeRuntimeConfig(bytesToString(bytes))); | ||
| 28 | }); | ||
| 29 | |||
| 30 | export interface CloudManagedEndpointRuntimeShape { | ||
| 31 | readonly applyConfig: ( | ||
| 32 | config: RelayManagedEndpointRuntimeConfig | null, | ||
| 33 | ) => Effect.Effect<CloudManagedEndpointRuntimeStatus>; | ||
| 34 | } | ||
| 35 | |||
| 36 | export class CloudManagedEndpointRuntime extends Context.Service< | ||
| 37 | CloudManagedEndpointRuntime, | ||
| 38 | CloudManagedEndpointRuntimeShape | ||
| 39 | >()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {} | ||
| 40 | |||
| 41 | export type CloudManagedEndpointRuntimeStatus = | ||
| 42 | | { | ||
| 43 | readonly status: "disabled"; | ||
| 44 | } | ||
| 45 | | { | ||
| 46 | readonly status: "failed"; | ||
| 47 | readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"]; | ||
| 48 | readonly reason: string; | ||
| 49 | readonly tunnelId?: string; | ||
| 50 | readonly tunnelName?: string; | ||
| 51 | } | ||
| 52 | | { | ||
| 53 | readonly status: "running"; | ||
| 54 | readonly providerKind: "cloudflare_tunnel"; | ||
| 55 | readonly pid: number; | ||
| 56 | readonly tunnelId?: string; | ||
| 57 | readonly tunnelName?: string; | ||
| 58 | } | ||
| 59 | | { | ||
| 60 | readonly status: "unsupported"; | ||
| 61 | readonly providerKind: RelayManagedEndpointRuntimeConfig["providerKind"]; | ||
| 62 | }; | ||
| 63 | |||
| 64 | interface ActiveConnector { | ||
| 65 | readonly child: ChildProcessSpawner.ChildProcessHandle; | ||
| 66 | readonly scope: Scope.Closeable; | ||
| 67 | readonly configKey: string; | ||
| 68 | readonly config: RelayManagedEndpointRuntimeConfig; | ||
| 69 | } | ||
| 70 | |||
| 71 | function runtimeConfigKey(config: RelayManagedEndpointRuntimeConfig): string { | ||
| 72 | return JSON.stringify({ | ||
| 73 | providerKind: config.providerKind, | ||
| 74 | connectorToken: config.connectorToken, | ||
| 75 | tunnelId: config.tunnelId ?? null, | ||
| 76 | tunnelName: config.tunnelName ?? null, | ||
| 77 | }); | ||
| 78 | } | ||
| 79 | |||
| 80 | const stopConnector = (connector: ActiveConnector | null) => | ||
| 81 | connector | ||
| 82 | ? Scope.close(connector.scope, Exit.void).pipe( | ||
| 83 | Effect.tap(() => | ||
| 84 | Effect.logInfo("Relay client stopped", { | ||
| 85 | pid: Number(connector.child.pid), | ||
| 86 | }), | ||
| 87 | ), | ||
| 88 | Effect.ignore, | ||
| 89 | ) | ||
| 90 | : Effect.void; | ||
| 91 | |||
| 92 | export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { | ||
| 93 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 94 | const relayClient = yield* RelayClient.RelayClient; | ||
| 95 | const activeRef = yield* Ref.make<ActiveConnector | null>(null); | ||
| 96 | const desiredConfigRef = yield* Ref.make<RelayManagedEndpointRuntimeConfig | null>(null); | ||
| 97 | const reconcileSemaphore = yield* Semaphore.make(1); | ||
| 98 | let reconcileConfig: CloudManagedEndpointRuntimeShape["applyConfig"]; | ||
| 99 | |||
| 100 | const stopActive = Effect.gen(function* () { | ||
| 101 | const active = yield* Ref.getAndSet(activeRef, null); | ||
| 102 | yield* stopConnector(active); | ||
| 103 | }); | ||
| 104 | |||
| 105 | const superviseConnector = (connector: ActiveConnector) => | ||
| 106 | Effect.gen(function* () { | ||
| 107 | const result = yield* Effect.result(connector.child.exitCode); | ||
| 108 | yield* reconcileSemaphore.withPermits(1)( | ||
| 109 | Effect.gen(function* () { | ||
| 110 | const active = yield* Ref.get(activeRef); | ||
| 111 | if ( | ||
| 112 | active?.child.pid !== connector.child.pid || | ||
| 113 | active.configKey !== connector.configKey | ||
| 114 | ) { | ||
| 115 | return; | ||
| 116 | } | ||
| 117 | yield* Ref.set(activeRef, null); | ||
| 118 | yield* stopConnector(connector); | ||
| 119 | |||
| 120 | const desiredConfig = yield* Ref.get(desiredConfigRef); | ||
| 121 | if ( | ||
| 122 | !desiredConfig || | ||
| 123 | desiredConfig.providerKind !== "cloudflare_tunnel" || | ||
| 124 | runtimeConfigKey(desiredConfig) !== connector.configKey | ||
| 125 | ) { | ||
| 126 | return; | ||
| 127 | } | ||
| 128 | |||
| 129 | yield* Effect.logWarning("Relay client exited; restarting", { | ||
| 130 | pid: Number(connector.child.pid), | ||
| 131 | ...(Result.isSuccess(result) | ||
| 132 | ? { exitCode: Number(result.success) } | ||
| 133 | : { cause: result.failure }), | ||
| 134 | tunnelId: connector.config.tunnelId, | ||
| 135 | tunnelName: connector.config.tunnelName, | ||
| 136 | }); | ||
| 137 | yield* reconcileConfig(desiredConfig); | ||
| 138 | }), | ||
| 139 | ); | ||
| 140 | }).pipe( | ||
| 141 | Effect.catchCause((cause) => Effect.logWarning("Relay client supervisor failed", { cause })), | ||
| 142 | ); | ||
| 143 | |||
| 144 | reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) { | ||
| 145 | if (!config || config.providerKind !== "cloudflare_tunnel") { | ||
| 146 | yield* stopActive; | ||
| 147 | return config | ||
| 148 | ? { status: "unsupported", providerKind: config.providerKind } | ||
| 149 | : { status: "disabled" }; | ||
| 150 | } | ||
| 151 | |||
| 152 | const nextConfigKey = runtimeConfigKey(config); | ||
| 153 | const active = yield* Ref.get(activeRef); | ||
| 154 | if (active?.configKey === nextConfigKey) { | ||
| 155 | const isRunning = yield* active.child.isRunning.pipe( | ||
| 156 | Effect.catch(() => Effect.succeed(false)), | ||
| 157 | ); | ||
| 158 | if (isRunning) { | ||
| 159 | return { | ||
| 160 | status: "running", | ||
| 161 | providerKind: "cloudflare_tunnel", | ||
| 162 | pid: Number(active.child.pid), | ||
| 163 | ...(active.config.tunnelId ? { tunnelId: active.config.tunnelId } : {}), | ||
| 164 | ...(active.config.tunnelName ? { tunnelName: active.config.tunnelName } : {}), | ||
| 165 | } satisfies CloudManagedEndpointRuntimeStatus; | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 169 | yield* stopActive; | ||
| 170 | |||
| 171 | const executable = yield* relayClient.resolve; | ||
| 172 | if (executable.status !== "available") { | ||
| 173 | return { | ||
| 174 | status: "failed", | ||
| 175 | providerKind: "cloudflare_tunnel", | ||
| 176 | reason: | ||
| 177 | executable.status === "unsupported" | ||
| 178 | ? `Relay client is unsupported on ${executable.platform}-${executable.arch}.` | ||
| 179 | : "The relay client is not installed.", | ||
| 180 | ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), | ||
| 181 | ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), | ||
| 182 | } satisfies CloudManagedEndpointRuntimeStatus; | ||
| 183 | } | ||
| 184 | |||
| 185 | const connectorScope = yield* Scope.make("sequential"); | ||
| 186 | const child = yield* spawner | ||
| 187 | .spawn( | ||
| 188 | ChildProcess.make(executable.executablePath, ["tunnel", "run"], { | ||
| 189 | detached: false, | ||
| 190 | env: { | ||
| 191 | ...process.env, | ||
| 192 | TUNNEL_TOKEN: config.connectorToken, | ||
| 193 | }, | ||
| 194 | shell: false, | ||
| 195 | stderr: "ignore", | ||
| 196 | stdout: "ignore", | ||
| 197 | }), | ||
| 198 | ) | ||
| 199 | .pipe( | ||
| 200 | Effect.provideService(Scope.Scope, connectorScope), | ||
| 201 | Effect.tap(() => | ||
| 202 | Effect.logInfo("Relay client started", { | ||
| 203 | tunnelId: config.tunnelId, | ||
| 204 | tunnelName: config.tunnelName, | ||
| 205 | }), | ||
| 206 | ), | ||
| 207 | Effect.catch((cause) => | ||
| 208 | Effect.logWarning("Failed to start relay client", { | ||
| 209 | cause, | ||
| 210 | tunnelId: config.tunnelId, | ||
| 211 | tunnelName: config.tunnelName, | ||
| 212 | }).pipe( | ||
| 213 | Effect.andThen(Scope.close(connectorScope, Exit.void).pipe(Effect.ignore)), | ||
| 214 | Effect.as({ | ||
| 215 | status: "failed", | ||
| 216 | providerKind: "cloudflare_tunnel", | ||
| 217 | reason: String(cause), | ||
| 218 | ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), | ||
| 219 | ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), | ||
| 220 | } satisfies CloudManagedEndpointRuntimeStatus), | ||
| 221 | ), | ||
| 222 | ), | ||
| 223 | ); | ||
| 224 | |||
| 225 | if ("status" in child && child.status === "failed") { | ||
| 226 | return child; | ||
| 227 | } | ||
| 228 | |||
| 229 | if (!("status" in child)) { | ||
| 230 | const connector = { | ||
| 231 | child, | ||
| 232 | scope: connectorScope, | ||
| 233 | configKey: nextConfigKey, | ||
| 234 | config, | ||
| 235 | } satisfies ActiveConnector; | ||
| 236 | yield* Ref.set(activeRef, connector); | ||
| 237 | yield* Effect.forkIn(superviseConnector(connector), connectorScope); | ||
| 238 | return { | ||
| 239 | status: "running", | ||
| 240 | providerKind: "cloudflare_tunnel", | ||
| 241 | pid: Number(child.pid), | ||
| 242 | ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), | ||
| 243 | ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), | ||
| 244 | } satisfies CloudManagedEndpointRuntimeStatus; | ||
| 245 | } | ||
| 246 | |||
| 247 | return { | ||
| 248 | status: "failed", | ||
| 249 | providerKind: "cloudflare_tunnel", | ||
| 250 | reason: "Relay client did not start.", | ||
| 251 | ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), | ||
| 252 | ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), | ||
| 253 | } satisfies CloudManagedEndpointRuntimeStatus; | ||
| 254 | }); | ||
| 255 | |||
| 256 | const applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")( | ||
| 257 | (config: RelayManagedEndpointRuntimeConfig | null) => | ||
| 258 | reconcileSemaphore.withPermits(1)( | ||
| 259 | Ref.set(desiredConfigRef, config).pipe(Effect.andThen(reconcileConfig(config))), | ||
| 260 | ), | ||
| 261 | ); | ||
| 262 | |||
| 263 | return CloudManagedEndpointRuntime.of({ | ||
| 264 | applyConfig, | ||
| 265 | }); | ||
| 266 | }); | ||
| 267 | |||
| 268 | export const layer = Layer.effect( | ||
| 269 | CloudManagedEndpointRuntime, | ||
| 270 | Effect.gen(function* () { | ||
| 271 | const runtime = yield* makeCloudManagedEndpointRuntime; | ||
| 272 | const initialConfig = yield* readRuntimeConfig.pipe( | ||
| 273 | Effect.catch((cause) => | ||
| 274 | Effect.logWarning("Failed to read managed endpoint runtime config", { cause }).pipe( | ||
| 275 | Effect.as(null), | ||
| 276 | ), | ||
| 277 | ), | ||
| 278 | ); | ||
| 279 | yield* runtime.applyConfig(initialConfig); | ||
| 280 | yield* Effect.addFinalizer(() => runtime.applyConfig(null)); | ||
| 281 | return runtime; | ||
| 282 | }), | ||
| 283 | ); | ||
apps/server/src/cloud/config.ts created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key"; | ||
| 5 | export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config"; | ||
| 6 | export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id"; | ||
| 7 | export const RELAY_URL_SECRET = "cloud-relay-url"; | ||
| 8 | export const RELAY_ISSUER_SECRET = "cloud-relay-issuer"; | ||
| 9 | export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential"; | ||
| 10 | export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity"; | ||
| 11 | |||
| 12 | export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect( | ||
| 13 | Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), | ||
| 14 | ); | ||
| 15 | |||
| 16 | export const decodeRuntimeConfig = Schema.decodeUnknownOption( | ||
| 17 | Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), | ||
| 18 | ); | ||
apps/server/src/cloud/environmentKeys.test.ts created+87| ... | @@ -0,0 +1,87 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as PlatformError from "effect/PlatformError"; | ||
| 6 | |||
| 7 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 8 | import { ServerConfig } from "../config.ts"; | ||
| 9 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; | ||
| 10 | |||
| 11 | const makeServerSecretStoreLayer = () => | ||
| 12 | ServerSecretStore.layer.pipe( | ||
| 13 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-environment-keys-test-" })), | ||
| 14 | ); | ||
| 15 | |||
| 16 | const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); | ||
| 17 | |||
| 18 | it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it) => { | ||
| 19 | it.effect("persists one atomic keypair secret and reuses it", () => | ||
| 20 | Effect.gen(function* () { | ||
| 21 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | ||
| 22 | |||
| 23 | const first = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore); | ||
| 24 | const second = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore); | ||
| 25 | |||
| 26 | expect(second).toEqual(first); | ||
| 27 | expect(yield* secretStore.get("cloud-link-ed25519-key-pair")).not.toBeNull(); | ||
| 28 | expect(yield* secretStore.get("cloud-link-ed25519-private-key")).toBeNull(); | ||
| 29 | expect(yield* secretStore.get("cloud-link-ed25519-public-key")).toBeNull(); | ||
| 30 | }).pipe(Effect.provide(makeServerSecretStoreLayer())), | ||
| 31 | ); | ||
| 32 | |||
| 33 | it.effect("migrates a legacy keypair into the atomic secret", () => | ||
| 34 | Effect.gen(function* () { | ||
| 35 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | ||
| 36 | yield* secretStore.set("cloud-link-ed25519-private-key", new TextEncoder().encode("private")); | ||
| 37 | yield* secretStore.set("cloud-link-ed25519-public-key", new TextEncoder().encode("public")); | ||
| 38 | |||
| 39 | expect(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore)).toEqual({ | ||
| 40 | privateKey: "private", | ||
| 41 | publicKey: "public", | ||
| 42 | }); | ||
| 43 | expect(yield* secretStore.get("cloud-link-ed25519-key-pair")).not.toBeNull(); | ||
| 44 | }).pipe(Effect.provide(makeServerSecretStoreLayer())), | ||
| 45 | ); | ||
| 46 | |||
| 47 | it.effect("uses the persisted keypair when a concurrent creator wins", () => | ||
| 48 | Effect.gen(function* () { | ||
| 49 | const winner = new TextEncoder().encode( | ||
| 50 | '{"privateKey":"winner-private","publicKey":"winner-public"}', | ||
| 51 | ); | ||
| 52 | let createAttempted = false; | ||
| 53 | const secretStore = { | ||
| 54 | get: (name) => | ||
| 55 | Effect.sync(() => | ||
| 56 | name === "cloud-link-ed25519-key-pair" && createAttempted ? winner : null, | ||
| 57 | ), | ||
| 58 | set: unusedSecretStoreOperation, | ||
| 59 | create: () => | ||
| 60 | Effect.sync(() => { | ||
| 61 | createAttempted = true; | ||
| 62 | }).pipe( | ||
| 63 | Effect.flatMap(() => | ||
| 64 | Effect.fail( | ||
| 65 | new ServerSecretStore.SecretStoreError({ | ||
| 66 | message: "Concurrent keypair creation won.", | ||
| 67 | cause: PlatformError.systemError({ | ||
| 68 | _tag: "AlreadyExists", | ||
| 69 | module: "FileSystem", | ||
| 70 | method: "open", | ||
| 71 | pathOrDescriptor: "cloud-link-ed25519-key-pair.bin", | ||
| 72 | }), | ||
| 73 | }), | ||
| 74 | ), | ||
| 75 | ), | ||
| 76 | ), | ||
| 77 | getOrCreateRandom: unusedSecretStoreOperation, | ||
| 78 | remove: unusedSecretStoreOperation, | ||
| 79 | } satisfies ServerSecretStore.ServerSecretStoreShape; | ||
| 80 | |||
| 81 | expect(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore)).toEqual({ | ||
| 82 | privateKey: "winner-private", | ||
| 83 | publicKey: "winner-public", | ||
| 84 | }); | ||
| 85 | }), | ||
| 86 | ); | ||
| 87 | }); | ||
apps/server/src/cloud/environmentKeys.ts created+100| ... | @@ -0,0 +1,100 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 6 | |||
| 7 | const CLOUD_LINK_KEY_PAIR = "cloud-link-ed25519-key-pair"; | ||
| 8 | const CLOUD_LINK_PRIVATE_KEY = "cloud-link-ed25519-private-key"; | ||
| 9 | const CLOUD_LINK_PUBLIC_KEY = "cloud-link-ed25519-public-key"; | ||
| 10 | |||
| 11 | const EnvironmentKeyPair = Schema.Struct({ | ||
| 12 | privateKey: Schema.String, | ||
| 13 | publicKey: Schema.String, | ||
| 14 | }); | ||
| 15 | type EnvironmentKeyPair = typeof EnvironmentKeyPair.Type; | ||
| 16 | |||
| 17 | const EnvironmentKeyPairJson = Schema.fromJsonString(EnvironmentKeyPair); | ||
| 18 | const decodeEnvironmentKeyPair = Schema.decodeUnknownEffect(EnvironmentKeyPairJson); | ||
| 19 | const encodeEnvironmentKeyPair = Schema.encodeEffect(EnvironmentKeyPairJson); | ||
| 20 | |||
| 21 | function bytesToString(bytes: Uint8Array): string { | ||
| 22 | return new TextDecoder().decode(bytes); | ||
| 23 | } | ||
| 24 | |||
| 25 | function stringToBytes(value: string): Uint8Array { | ||
| 26 | return new TextEncoder().encode(value); | ||
| 27 | } | ||
| 28 | |||
| 29 | const keyPairPersistenceError = (message: string, cause?: unknown) => | ||
| 30 | new ServerSecretStore.SecretStoreError({ message, cause }); | ||
| 31 | |||
| 32 | const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( | ||
| 33 | secrets: ServerSecretStore.ServerSecretStoreShape, | ||
| 34 | ) { | ||
| 35 | const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR); | ||
| 36 | if (encoded === null) { | ||
| 37 | return null; | ||
| 38 | } | ||
| 39 | return yield* decodeEnvironmentKeyPair(bytesToString(encoded)).pipe( | ||
| 40 | Effect.mapError((cause) => | ||
| 41 | keyPairPersistenceError("Failed to decode environment signing key pair.", cause), | ||
| 42 | ), | ||
| 43 | ); | ||
| 44 | }); | ||
| 45 | |||
| 46 | const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* ( | ||
| 47 | secrets: ServerSecretStore.ServerSecretStoreShape, | ||
| 48 | keyPair: EnvironmentKeyPair, | ||
| 49 | ) { | ||
| 50 | const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( | ||
| 51 | Effect.mapError((cause) => | ||
| 52 | keyPairPersistenceError("Failed to encode environment signing key pair.", cause), | ||
| 53 | ), | ||
| 54 | ); | ||
| 55 | return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( | ||
| 56 | Effect.as(keyPair), | ||
| 57 | Effect.catchTag("SecretStoreError", (error) => | ||
| 58 | ServerSecretStore.isSecretAlreadyExistsError(error) | ||
| 59 | ? readEnvironmentKeyPair(secrets).pipe( | ||
| 60 | Effect.flatMap((existing) => | ||
| 61 | existing !== null | ||
| 62 | ? Effect.succeed(existing) | ||
| 63 | : Effect.fail( | ||
| 64 | keyPairPersistenceError( | ||
| 65 | "Failed to read environment signing key pair after concurrent creation.", | ||
| 66 | ), | ||
| 67 | ), | ||
| 68 | ), | ||
| 69 | ) | ||
| 70 | : Effect.fail(error), | ||
| 71 | ), | ||
| 72 | ); | ||
| 73 | }); | ||
| 74 | |||
| 75 | export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( | ||
| 76 | secrets: ServerSecretStore.ServerSecretStoreShape, | ||
| 77 | ) { | ||
| 78 | const existing = yield* readEnvironmentKeyPair(secrets); | ||
| 79 | if (existing !== null) { | ||
| 80 | return existing; | ||
| 81 | } | ||
| 82 | |||
| 83 | const existingPrivate = yield* secrets.get(CLOUD_LINK_PRIVATE_KEY); | ||
| 84 | const existingPublic = yield* secrets.get(CLOUD_LINK_PUBLIC_KEY); | ||
| 85 | if (existingPrivate && existingPublic) { | ||
| 86 | return yield* persistEnvironmentKeyPair(secrets, { | ||
| 87 | privateKey: bytesToString(existingPrivate), | ||
| 88 | publicKey: bytesToString(existingPublic), | ||
| 89 | }); | ||
| 90 | } | ||
| 91 | |||
| 92 | const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 93 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 94 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 95 | }); | ||
| 96 | return yield* persistEnvironmentKeyPair(secrets, { | ||
| 97 | privateKey: keyPair.privateKey, | ||
| 98 | publicKey: keyPair.publicKey, | ||
| 99 | }); | ||
| 100 | }); | ||
apps/server/src/cloud/http.test.ts created+119| ... | @@ -0,0 +1,119 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as PlatformError from "effect/PlatformError"; | ||
| 6 | import { HttpClient } from "effect/unstable/http"; | ||
| 7 | |||
| 8 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 9 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 10 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | ||
| 11 | import * as CliTokenManager from "./CliTokenManager.ts"; | ||
| 12 | import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; | ||
| 13 | import { | ||
| 14 | CloudManagedEndpointRuntime, | ||
| 15 | type CloudManagedEndpointRuntimeShape, | ||
| 16 | } from "./ManagedEndpointRuntime.ts"; | ||
| 17 | |||
| 18 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => | ||
| 19 | new ServerSecretStore.SecretStoreError({ | ||
| 20 | message: "Failed to persist cloud replay guard.", | ||
| 21 | cause: PlatformError.systemError({ | ||
| 22 | _tag: tag, | ||
| 23 | module: "FileSystem", | ||
| 24 | method: "open", | ||
| 25 | pathOrDescriptor: "cloud-replay-guard.bin", | ||
| 26 | }), | ||
| 27 | }); | ||
| 28 | |||
| 29 | const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); | ||
| 30 | |||
| 31 | function makeSecretStore( | ||
| 32 | create: ServerSecretStore.ServerSecretStoreShape["create"], | ||
| 33 | ): ServerSecretStore.ServerSecretStoreShape { | ||
| 34 | return { | ||
| 35 | get: unusedSecretStoreOperation, | ||
| 36 | set: unusedSecretStoreOperation, | ||
| 37 | create, | ||
| 38 | getOrCreateRandom: unusedSecretStoreOperation, | ||
| 39 | remove: unusedSecretStoreOperation, | ||
| 40 | }; | ||
| 41 | } | ||
| 42 | |||
| 43 | describe("consumeCloudReplayGuards", () => { | ||
| 44 | it.effect("reports already-created guards as replay conflicts", () => | ||
| 45 | Effect.gen(function* () { | ||
| 46 | const consumed = yield* consumeCloudReplayGuards({ | ||
| 47 | secrets: makeSecretStore(() => Effect.fail(storeFailure("AlreadyExists"))), | ||
| 48 | names: ["cloud-jti", "cloud-nonce"], | ||
| 49 | value: new Uint8Array(), | ||
| 50 | }); | ||
| 51 | |||
| 52 | expect(consumed).toBe(false); | ||
| 53 | }), | ||
| 54 | ); | ||
| 55 | |||
| 56 | it.effect("preserves replay-store availability failures", () => | ||
| 57 | Effect.gen(function* () { | ||
| 58 | const failure = storeFailure("PermissionDenied"); | ||
| 59 | const error = yield* Effect.flip( | ||
| 60 | consumeCloudReplayGuards({ | ||
| 61 | secrets: makeSecretStore(() => Effect.fail(failure)), | ||
| 62 | names: ["cloud-jti", "cloud-nonce"], | ||
| 63 | value: new Uint8Array(), | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | |||
| 67 | expect(error).toBe(failure); | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | }); | ||
| 71 | |||
| 72 | describe("reconcileDesiredCloudLink", () => { | ||
| 73 | it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => | ||
| 74 | Effect.gen(function* () { | ||
| 75 | const error = yield* Effect.flip(reconcileDesiredCloudLink("http://127.0.0.1:3774")); | ||
| 76 | |||
| 77 | expect(error).toMatchObject({ | ||
| 78 | _tag: "EnvironmentHttpUnauthorizedError", | ||
| 79 | message: "Run `t3 cloud link` to authorize this environment.", | ||
| 80 | }); | ||
| 81 | }).pipe( | ||
| 82 | Effect.provideService( | ||
| 83 | ServerSecretStore.ServerSecretStore, | ||
| 84 | makeSecretStore(unusedSecretStoreOperation), | ||
| 85 | ), | ||
| 86 | Effect.provideService( | ||
| 87 | ServerEnvironment, | ||
| 88 | ServerEnvironment.of({ | ||
| 89 | getEnvironmentId: unusedSecretStoreOperation(), | ||
| 90 | getDescriptor: unusedSecretStoreOperation(), | ||
| 91 | }), | ||
| 92 | ), | ||
| 93 | Effect.provideService( | ||
| 94 | CloudManagedEndpointRuntime, | ||
| 95 | CloudManagedEndpointRuntime.of({ | ||
| 96 | applyConfig: unusedSecretStoreOperation, | ||
| 97 | } satisfies CloudManagedEndpointRuntimeShape), | ||
| 98 | ), | ||
| 99 | Effect.provideService( | ||
| 100 | EnvironmentAuth.EnvironmentAuth, | ||
| 101 | EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuthShape), | ||
| 102 | ), | ||
| 103 | Effect.provideService( | ||
| 104 | CliTokenManager.CloudCliTokenManager, | ||
| 105 | CliTokenManager.CloudCliTokenManager.of({ | ||
| 106 | get: unusedSecretStoreOperation(), | ||
| 107 | getExisting: Effect.succeed(Option.none()), | ||
| 108 | hasCredential: unusedSecretStoreOperation(), | ||
| 109 | clear: unusedSecretStoreOperation(), | ||
| 110 | }), | ||
| 111 | ), | ||
| 112 | Effect.provideService( | ||
| 113 | HttpClient.HttpClient, | ||
| 114 | HttpClient.make(() => unusedSecretStoreOperation()), | ||
| 115 | ), | ||
| 116 | Effect.provide(NodeServices.layer), | ||
| 117 | ), | ||
| 118 | ); | ||
| 119 | }); | ||
apps/server/src/cloud/http.ts created+944| ... | @@ -0,0 +1,944 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | import { | ||
| 3 | AuthRelayReadScope, | ||
| 4 | AuthRelayWriteScope, | ||
| 5 | AuthStandardClientScopes, | ||
| 6 | EnvironmentCloudEndpointUnavailableError, | ||
| 7 | EnvironmentCloudLinkStateResult, | ||
| 8 | EnvironmentCloudRelayConfigResult, | ||
| 9 | EnvironmentHttpApi, | ||
| 10 | EnvironmentHttpBadRequestError, | ||
| 11 | EnvironmentHttpConflictError, | ||
| 12 | EnvironmentHttpInternalServerError, | ||
| 13 | EnvironmentHttpUnauthorizedError, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import { | ||
| 16 | RelayCloudEnvironmentHealthProofPayload, | ||
| 17 | RelayCloudEnvironmentHealthRequest, | ||
| 18 | RelayCloudMintCredentialProofPayload, | ||
| 19 | RelayCloudMintCredentialRequest, | ||
| 20 | RelayEnvironmentHealthResponseProofPayload, | ||
| 21 | type RelayEnvironmentHealthResponse as RelayEnvironmentHealthResponseShape, | ||
| 22 | RelayEnvironmentConfigRequest, | ||
| 23 | RelayEnvironmentLinkChallengeResponse, | ||
| 24 | RelayEnvironmentLinkResponse, | ||
| 25 | RelayEnvironmentMintResponseProofPayload, | ||
| 26 | type RelayEnvironmentMintResponse as RelayEnvironmentMintResponseShape, | ||
| 27 | RelayEnvironmentLinkProof, | ||
| 28 | RelayEnvironmentLinkProofPayload, | ||
| 29 | RelayLinkProofRequest, | ||
| 30 | RelayManagedEndpointOrigin, | ||
| 31 | } from "@t3tools/contracts/relay"; | ||
| 32 | import { | ||
| 33 | normalizeRelayIssuer, | ||
| 34 | RELAY_HEALTH_REQUEST_TYP, | ||
| 35 | RELAY_HEALTH_RESPONSE_TYP, | ||
| 36 | RELAY_LINK_PROOF_TYP, | ||
| 37 | RELAY_MINT_REQUEST_TYP, | ||
| 38 | RELAY_MINT_RESPONSE_TYP, | ||
| 39 | signRelayJwt, | ||
| 40 | verifyRelayJwt, | ||
| 41 | } from "@t3tools/shared/relayJwt"; | ||
| 42 | import { isSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 43 | import * as DateTime from "effect/DateTime"; | ||
| 44 | import * as Crypto from "effect/Crypto"; | ||
| 45 | import * as Duration from "effect/Duration"; | ||
| 46 | import * as Effect from "effect/Effect"; | ||
| 47 | import * as Option from "effect/Option"; | ||
| 48 | import * as Schema from "effect/Schema"; | ||
| 49 | import * as HttpEffect from "effect/unstable/http/HttpEffect"; | ||
| 50 | import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | ||
| 51 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 52 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ||
| 53 | |||
| 54 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; | ||
| 55 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 56 | import { requireEnvironmentScope } from "../auth/http.ts"; | ||
| 57 | import { | ||
| 58 | ServerEnvironment, | ||
| 59 | type ServerEnvironmentShape, | ||
| 60 | } from "../environment/Services/ServerEnvironment.ts"; | ||
| 61 | import { | ||
| 62 | CloudManagedEndpointRuntime, | ||
| 63 | type CloudManagedEndpointRuntimeShape, | ||
| 64 | } from "./ManagedEndpointRuntime.ts"; | ||
| 65 | import { | ||
| 66 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | ||
| 67 | CLOUD_LINKED_USER_ID, | ||
| 68 | CLOUD_MINT_PUBLIC_KEY, | ||
| 69 | encodeEndpointRuntimeConfigJson, | ||
| 70 | PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 71 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 72 | RELAY_ISSUER_SECRET, | ||
| 73 | RELAY_URL_SECRET, | ||
| 74 | } from "./config.ts"; | ||
| 75 | import { relayUrlConfig } from "./publicConfig.ts"; | ||
| 76 | import * as CliState from "./CliState.ts"; | ||
| 77 | import * as CliTokenManager from "./CliTokenManager.ts"; | ||
| 78 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; | ||
| 79 | |||
| 80 | const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; | ||
| 81 | const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; | ||
| 82 | const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-"; | ||
| 83 | const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-"; | ||
| 84 | const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60; | ||
| 85 | const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60; | ||
| 86 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | ||
| 87 | const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { | ||
| 88 | "cache-control": "no-store", | ||
| 89 | pragma: "no-cache", | ||
| 90 | } as const; | ||
| 91 | |||
| 92 | const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler( | ||
| 93 | (_request, response) => | ||
| 94 | Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)), | ||
| 95 | ); | ||
| 96 | |||
| 97 | const failEnvironmentCloudInternalError = | ||
| 98 | (message: string) => | ||
| 99 | (cause: unknown): Effect.Effect<never, EnvironmentHttpInternalServerError> => | ||
| 100 | Effect.logError(message, { cause }).pipe( | ||
| 101 | Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))), | ||
| 102 | ); | ||
| 103 | |||
| 104 | const requireRelayUrl = relayUrlConfig.pipe( | ||
| 105 | Effect.mapError( | ||
| 106 | () => | ||
| 107 | new EnvironmentHttpInternalServerError({ | ||
| 108 | message: "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.", | ||
| 109 | }), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | |||
| 113 | function bytesToString(bytes: Uint8Array): string { | ||
| 114 | return new TextDecoder().decode(bytes); | ||
| 115 | } | ||
| 116 | |||
| 117 | function stringToBytes(value: string): Uint8Array { | ||
| 118 | return new TextEncoder().encode(value); | ||
| 119 | } | ||
| 120 | |||
| 121 | export function consumeCloudReplayGuards(input: { | ||
| 122 | readonly secrets: ServerSecretStore.ServerSecretStoreShape; | ||
| 123 | readonly names: ReadonlyArray<string>; | ||
| 124 | readonly value: Uint8Array; | ||
| 125 | }) { | ||
| 126 | return Effect.all( | ||
| 127 | input.names.map((name) => | ||
| 128 | input.secrets.create(name, input.value).pipe( | ||
| 129 | Effect.as(true), | ||
| 130 | Effect.catchTag("SecretStoreError", (error) => | ||
| 131 | ServerSecretStore.isSecretAlreadyExistsError(error) | ||
| 132 | ? Effect.succeed(false) | ||
| 133 | : Effect.fail(error), | ||
| 134 | ), | ||
| 135 | ), | ||
| 136 | ), | ||
| 137 | { concurrency: input.names.length }, | ||
| 138 | ).pipe(Effect.map((created) => created.every(Boolean))); | ||
| 139 | } | ||
| 140 | |||
| 141 | function normalizePemForSignedPayload(value: string): string { | ||
| 142 | return value.trim(); | ||
| 143 | } | ||
| 144 | |||
| 145 | function normalizeHostname(hostname: string): string { | ||
| 146 | return hostname | ||
| 147 | .trim() | ||
| 148 | .toLowerCase() | ||
| 149 | .replace(/^\[(.*)\]$/, "$1"); | ||
| 150 | } | ||
| 151 | |||
| 152 | function validateCloudMintPublicKey( | ||
| 153 | publicKey: string, | ||
| 154 | ): Effect.Effect<void, EnvironmentHttpBadRequestError> { | ||
| 155 | return Effect.try({ | ||
| 156 | try: () => NodeCrypto.createPublicKey(publicKey.replace(/\\n/g, "\n")), | ||
| 157 | catch: () => | ||
| 158 | new EnvironmentHttpBadRequestError({ | ||
| 159 | message: "Cloud mint public key must be a valid Ed25519 public key.", | ||
| 160 | }), | ||
| 161 | }).pipe( | ||
| 162 | Effect.flatMap((key) => | ||
| 163 | key.asymmetricKeyType === "ed25519" | ||
| 164 | ? Effect.void | ||
| 165 | : Effect.fail( | ||
| 166 | new EnvironmentHttpBadRequestError({ | ||
| 167 | message: "Cloud mint public key must be a valid Ed25519 public key.", | ||
| 168 | }), | ||
| 169 | ), | ||
| 170 | ), | ||
| 171 | ); | ||
| 172 | } | ||
| 173 | |||
| 174 | function validateRelayConfigPayload( | ||
| 175 | payload: RelayEnvironmentConfigRequest, | ||
| 176 | ): Effect.Effect<void, EnvironmentHttpBadRequestError> { | ||
| 177 | if (!isSecureRelayUrl(payload.relayUrl)) { | ||
| 178 | return Effect.fail( | ||
| 179 | new EnvironmentHttpBadRequestError({ | ||
| 180 | message: "Relay URL must be a secure absolute HTTPS URL.", | ||
| 181 | }), | ||
| 182 | ); | ||
| 183 | } | ||
| 184 | if (payload.relayIssuer !== undefined && !isSecureRelayUrl(payload.relayIssuer)) { | ||
| 185 | return Effect.fail( | ||
| 186 | new EnvironmentHttpBadRequestError({ | ||
| 187 | message: "Relay issuer must be a secure absolute HTTPS URL.", | ||
| 188 | }), | ||
| 189 | ); | ||
| 190 | } | ||
| 191 | if (payload.environmentCredential.trim().length === 0) { | ||
| 192 | return Effect.fail( | ||
| 193 | new EnvironmentHttpBadRequestError({ | ||
| 194 | message: "Relay environment credential is required.", | ||
| 195 | }), | ||
| 196 | ); | ||
| 197 | } | ||
| 198 | if (payload.cloudUserId.trim().length === 0) { | ||
| 199 | return Effect.fail( | ||
| 200 | new EnvironmentHttpBadRequestError({ | ||
| 201 | message: "Cloud user id is required.", | ||
| 202 | }), | ||
| 203 | ); | ||
| 204 | } | ||
| 205 | return Effect.void; | ||
| 206 | } | ||
| 207 | |||
| 208 | function validateLinkedCloudUser(input: { | ||
| 209 | readonly secrets: ServerSecretStore.ServerSecretStoreShape; | ||
| 210 | readonly cloudUserId: string; | ||
| 211 | }): Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError | EnvironmentHttpConflictError> { | ||
| 212 | return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( | ||
| 213 | Effect.mapError( | ||
| 214 | (cause) => | ||
| 215 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 216 | message: "Could not verify the linked cloud account.", | ||
| 217 | cause, | ||
| 218 | }), | ||
| 219 | ), | ||
| 220 | Effect.flatMap((existing) => { | ||
| 221 | if (!existing) { | ||
| 222 | return Effect.void; | ||
| 223 | } | ||
| 224 | const existingCloudUserId = bytesToString(existing); | ||
| 225 | return existingCloudUserId === input.cloudUserId | ||
| 226 | ? Effect.void | ||
| 227 | : Effect.fail( | ||
| 228 | new EnvironmentHttpConflictError({ | ||
| 229 | message: | ||
| 230 | "This environment is already linked to a different cloud account. Unlink it before switching accounts.", | ||
| 231 | }), | ||
| 232 | ); | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | } | ||
| 236 | |||
| 237 | function readInstalledCloudUserId( | ||
| 238 | secrets: ServerSecretStore.ServerSecretStoreShape, | ||
| 239 | ): Effect.Effect<string, EnvironmentAuth.ServerAuthInternalError> { | ||
| 240 | return secrets.get(CLOUD_LINKED_USER_ID).pipe( | ||
| 241 | Effect.mapError( | ||
| 242 | (cause) => | ||
| 243 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 244 | message: "Could not read the linked cloud account.", | ||
| 245 | cause, | ||
| 246 | }), | ||
| 247 | ), | ||
| 248 | Effect.flatMap((bytes) => | ||
| 249 | bytes | ||
| 250 | ? Effect.succeed(bytesToString(bytes)) | ||
| 251 | : Effect.fail( | ||
| 252 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 253 | message: "Cloud linked user is not installed for this environment.", | ||
| 254 | }), | ||
| 255 | ), | ||
| 256 | ), | ||
| 257 | ); | ||
| 258 | } | ||
| 259 | |||
| 260 | function isLoopbackHostname(hostname: string): boolean { | ||
| 261 | return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname)); | ||
| 262 | } | ||
| 263 | |||
| 264 | function firstForwardedHeaderValue(value: string | undefined): string | undefined { | ||
| 265 | const first = value?.split(",")[0]?.trim(); | ||
| 266 | return first && first.length > 0 ? first : undefined; | ||
| 267 | } | ||
| 268 | |||
| 269 | function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string | null { | ||
| 270 | try { | ||
| 271 | return new URL(request.originalUrl).href; | ||
| 272 | } catch { | ||
| 273 | const host = firstForwardedHeaderValue(request.headers.host) ?? "127.0.0.1"; | ||
| 274 | try { | ||
| 275 | return new URL(request.originalUrl, `http://${host}`).href; | ||
| 276 | } catch { | ||
| 277 | return null; | ||
| 278 | } | ||
| 279 | } | ||
| 280 | } | ||
| 281 | |||
| 282 | function hasForwardedAuthorityHeaders(request: HttpServerRequest.HttpServerRequest): boolean { | ||
| 283 | return ( | ||
| 284 | firstForwardedHeaderValue(request.headers["x-forwarded-host"]) !== undefined || | ||
| 285 | firstForwardedHeaderValue(request.headers["x-forwarded-proto"]) !== undefined | ||
| 286 | ); | ||
| 287 | } | ||
| 288 | |||
| 289 | function endpointRequestPort(url: URL): number { | ||
| 290 | return Number(url.port || (url.protocol === "https:" ? 443 : 80)); | ||
| 291 | } | ||
| 292 | |||
| 293 | function isAllowedEndpointOrigin(input: { | ||
| 294 | readonly origin: RelayManagedEndpointOrigin; | ||
| 295 | readonly requestUrl: string; | ||
| 296 | }): boolean { | ||
| 297 | if (!isLoopbackHostname(input.origin.localHttpHost)) { | ||
| 298 | return false; | ||
| 299 | } | ||
| 300 | |||
| 301 | const url = new URL(input.requestUrl); | ||
| 302 | if (!isLoopbackHostname(url.hostname)) { | ||
| 303 | return false; | ||
| 304 | } | ||
| 305 | |||
| 306 | return input.origin.localHttpPort === endpointRequestPort(url); | ||
| 307 | } | ||
| 308 | |||
| 309 | function providerKindMatchesRequestedLinkScopes(request: RelayLinkProofRequest): boolean { | ||
| 310 | return request.endpoint.providerKind === "cloudflare_tunnel"; | ||
| 311 | } | ||
| 312 | |||
| 313 | function hasExactScope(input: { | ||
| 314 | readonly scopes: ReadonlyArray<string>; | ||
| 315 | readonly expected: string; | ||
| 316 | }): boolean { | ||
| 317 | return input.scopes.length === 1 && input.scopes[0] === input.expected; | ||
| 318 | } | ||
| 319 | |||
| 320 | function hasBoundedCloudProofLifetime(input: { | ||
| 321 | readonly iat: number; | ||
| 322 | readonly exp: number; | ||
| 323 | readonly nowSeconds: number; | ||
| 324 | }): boolean { | ||
| 325 | return ( | ||
| 326 | input.exp > input.iat && | ||
| 327 | input.exp - input.iat <= CLOUD_PROOF_MAX_LIFETIME_SECONDS && | ||
| 328 | input.iat <= input.nowSeconds + CLOUD_PROOF_CLOCK_SKEW_SECONDS | ||
| 329 | ); | ||
| 330 | } | ||
| 331 | |||
| 332 | const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentHealthProofPayload); | ||
| 333 | const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload); | ||
| 334 | |||
| 335 | interface CloudHttpDependencies { | ||
| 336 | readonly secrets: ServerSecretStore.ServerSecretStoreShape; | ||
| 337 | readonly environment: ServerEnvironmentShape; | ||
| 338 | readonly endpointRuntime: CloudManagedEndpointRuntimeShape; | ||
| 339 | readonly environmentAuth: EnvironmentAuth.EnvironmentAuthShape; | ||
| 340 | readonly cliTokenManager: CliTokenManager.CloudCliTokenManagerShape; | ||
| 341 | readonly httpClient: HttpClient.HttpClient; | ||
| 342 | } | ||
| 343 | |||
| 344 | const cloudHttpDependencies = Effect.gen(function* () { | ||
| 345 | return { | ||
| 346 | secrets: yield* ServerSecretStore.ServerSecretStore, | ||
| 347 | environment: yield* ServerEnvironment, | ||
| 348 | endpointRuntime: yield* CloudManagedEndpointRuntime, | ||
| 349 | environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, | ||
| 350 | cliTokenManager: yield* CliTokenManager.CloudCliTokenManager, | ||
| 351 | httpClient: yield* HttpClient.HttpClient, | ||
| 352 | } satisfies CloudHttpDependencies; | ||
| 353 | }); | ||
| 354 | |||
| 355 | const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function* ( | ||
| 356 | dependencies: CloudHttpDependencies, | ||
| 357 | request: RelayLinkProofRequest, | ||
| 358 | requestUrl: string, | ||
| 359 | ) { | ||
| 360 | const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); | ||
| 361 | if ( | ||
| 362 | !providerKindMatchesRequestedLinkScopes(request) || | ||
| 363 | !isAllowedEndpointOrigin({ | ||
| 364 | origin: request.origin, | ||
| 365 | requestUrl, | ||
| 366 | }) | ||
| 367 | ) { | ||
| 368 | return yield* new EnvironmentHttpBadRequestError({ | ||
| 369 | message: "Invalid managed endpoint origin.", | ||
| 370 | }); | ||
| 371 | } | ||
| 372 | const now = yield* DateTime.now; | ||
| 373 | const expiresAt = DateTime.add(now, { minutes: 5 }); | ||
| 374 | const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); | ||
| 375 | const descriptor = yield* dependencies.environment.getDescriptor; | ||
| 376 | const payload = { | ||
| 377 | iss: `t3-env:${descriptor.environmentId}`, | ||
| 378 | aud: normalizeRelayIssuer(request.relayIssuer), | ||
| 379 | sub: descriptor.environmentId, | ||
| 380 | jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)), | ||
| 381 | iat: nowSeconds, | ||
| 382 | exp: Math.floor(expiresAt.epochMilliseconds / 1_000), | ||
| 383 | challenge: request.challenge, | ||
| 384 | descriptor, | ||
| 385 | environmentId: descriptor.environmentId, | ||
| 386 | environmentPublicKey: normalizePemForSignedPayload(keyPair.publicKey), | ||
| 387 | endpoint: request.endpoint, | ||
| 388 | origin: request.origin, | ||
| 389 | scopes: ["agent_activity_notifications", "managed_tunnels"], | ||
| 390 | } satisfies RelayEnvironmentLinkProofPayload; | ||
| 391 | return yield* signRelayJwt({ | ||
| 392 | privateKey: keyPair.privateKey, | ||
| 393 | typ: RELAY_LINK_PROOF_TYP, | ||
| 394 | payload, | ||
| 395 | }).pipe( | ||
| 396 | Effect.mapError( | ||
| 397 | (cause) => | ||
| 398 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 399 | message: "Failed to sign cloud link JWT.", | ||
| 400 | cause, | ||
| 401 | }), | ||
| 402 | ), | ||
| 403 | ); | ||
| 404 | }); | ||
| 405 | |||
| 406 | const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( | ||
| 407 | function* (dependencies: CloudHttpDependencies, request: RelayLinkProofRequest) { | ||
| 408 | yield* requireEnvironmentScope(AuthRelayWriteScope); | ||
| 409 | const httpRequest = yield* HttpServerRequest.HttpServerRequest; | ||
| 410 | const requestUrl = requestAbsoluteUrl(httpRequest); | ||
| 411 | if (requestUrl === null || hasForwardedAuthorityHeaders(httpRequest)) { | ||
| 412 | return yield* new EnvironmentHttpBadRequestError({ | ||
| 413 | message: "Invalid managed endpoint origin.", | ||
| 414 | }); | ||
| 415 | } | ||
| 416 | const proof = yield* makeCloudLinkProof(dependencies, request, requestUrl); | ||
| 417 | yield* appendCloudCredentialResponseHeaders; | ||
| 418 | return proof satisfies RelayEnvironmentLinkProof; | ||
| 419 | }, | ||
| 420 | Effect.catchTag("ServerAuthInternalError", (error) => | ||
| 421 | failEnvironmentCloudInternalError(error.message)(error.cause), | ||
| 422 | ), | ||
| 423 | Effect.catchTags({ | ||
| 424 | PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."), | ||
| 425 | SecretStoreError: failEnvironmentCloudInternalError( | ||
| 426 | "Could not generate environment link proof.", | ||
| 427 | ), | ||
| 428 | }), | ||
| 429 | ); | ||
| 430 | |||
| 431 | const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( | ||
| 432 | dependencies: CloudHttpDependencies, | ||
| 433 | payload: RelayEnvironmentConfigRequest, | ||
| 434 | ) { | ||
| 435 | yield* validateRelayConfigPayload(payload); | ||
| 436 | yield* validateLinkedCloudUser({ | ||
| 437 | secrets: dependencies.secrets, | ||
| 438 | cloudUserId: payload.cloudUserId, | ||
| 439 | }); | ||
| 440 | yield* validateCloudMintPublicKey(payload.cloudMintPublicKey); | ||
| 441 | const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig( | ||
| 442 | payload.endpointRuntime, | ||
| 443 | ); | ||
| 444 | const ok = | ||
| 445 | endpointRuntimeStatus.status === "disabled" || endpointRuntimeStatus.status === "running"; | ||
| 446 | if (!ok) { | ||
| 447 | return yield* new EnvironmentCloudEndpointUnavailableError({ | ||
| 448 | message: "Managed endpoint runtime could not be started.", | ||
| 449 | endpointRuntimeStatus, | ||
| 450 | }); | ||
| 451 | } | ||
| 452 | |||
| 453 | yield* dependencies.secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl)); | ||
| 454 | yield* dependencies.secrets.set( | ||
| 455 | RELAY_ISSUER_SECRET, | ||
| 456 | stringToBytes(payload.relayIssuer ?? payload.relayUrl), | ||
| 457 | ); | ||
| 458 | yield* dependencies.secrets.set(CLOUD_LINKED_USER_ID, stringToBytes(payload.cloudUserId)); | ||
| 459 | yield* dependencies.secrets.set( | ||
| 460 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 461 | stringToBytes(payload.environmentCredential), | ||
| 462 | ); | ||
| 463 | yield* dependencies.secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); | ||
| 464 | if (payload.endpointRuntime) { | ||
| 465 | const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); | ||
| 466 | yield* dependencies.secrets.set( | ||
| 467 | CLOUD_ENDPOINT_RUNTIME_CONFIG, | ||
| 468 | stringToBytes(endpointRuntimeJson), | ||
| 469 | ); | ||
| 470 | } else { | ||
| 471 | yield* dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); | ||
| 472 | } | ||
| 473 | return { ok, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; | ||
| 474 | }); | ||
| 475 | |||
| 476 | const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( | ||
| 477 | function* (dependencies: CloudHttpDependencies, payload: RelayEnvironmentConfigRequest) { | ||
| 478 | yield* requireEnvironmentScope(AuthRelayWriteScope); | ||
| 479 | return yield* applyCloudRelayConfig(dependencies, payload); | ||
| 480 | }, | ||
| 481 | Effect.catchTag("ServerAuthInternalError", (error) => | ||
| 482 | failEnvironmentCloudInternalError(error.message)(error.cause), | ||
| 483 | ), | ||
| 484 | Effect.catchTags({ | ||
| 485 | SchemaError: failEnvironmentCloudInternalError( | ||
| 486 | "Could not persist environment relay configuration.", | ||
| 487 | ), | ||
| 488 | SecretStoreError: failEnvironmentCloudInternalError( | ||
| 489 | "Could not persist environment relay configuration.", | ||
| 490 | ), | ||
| 491 | }), | ||
| 492 | ); | ||
| 493 | |||
| 494 | const relayClientRequest = <A>( | ||
| 495 | dependencies: CloudHttpDependencies, | ||
| 496 | input: { | ||
| 497 | readonly url: string; | ||
| 498 | readonly token: string; | ||
| 499 | readonly payload: unknown; | ||
| 500 | readonly schema: Schema.Decoder<A>; | ||
| 501 | }, | ||
| 502 | ) => | ||
| 503 | HttpClientRequest.post(input.url).pipe( | ||
| 504 | HttpClientRequest.bearerToken(input.token), | ||
| 505 | HttpClientRequest.bodyJson(input.payload), | ||
| 506 | Effect.flatMap(dependencies.httpClient.execute), | ||
| 507 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 508 | Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), | ||
| 509 | Effect.mapError( | ||
| 510 | (cause) => | ||
| 511 | new EnvironmentHttpInternalServerError({ | ||
| 512 | message: `T3 Cloud relay request failed: ${String(cause)}`, | ||
| 513 | }), | ||
| 514 | ), | ||
| 515 | ); | ||
| 516 | |||
| 517 | const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesiredLinkWith")( | ||
| 518 | function* (dependencies: CloudHttpDependencies, localOrigin: string) { | ||
| 519 | const localUrl = yield* Effect.try({ | ||
| 520 | try: () => new URL(localOrigin), | ||
| 521 | catch: () => | ||
| 522 | new EnvironmentHttpBadRequestError({ | ||
| 523 | message: "Could not resolve local environment origin.", | ||
| 524 | }), | ||
| 525 | }); | ||
| 526 | if (localUrl.origin !== localOrigin) { | ||
| 527 | return yield* new EnvironmentHttpBadRequestError({ | ||
| 528 | message: "Could not resolve local environment origin.", | ||
| 529 | }); | ||
| 530 | } | ||
| 531 | const localWsOrigin = localOrigin.replace(/^http/u, "ws"); | ||
| 532 | const token = yield* dependencies.cliTokenManager.getExisting.pipe( | ||
| 533 | Effect.flatMap( | ||
| 534 | Option.match({ | ||
| 535 | onNone: () => | ||
| 536 | Effect.fail( | ||
| 537 | new EnvironmentHttpUnauthorizedError({ | ||
| 538 | message: "Run `t3 cloud link` to authorize this environment.", | ||
| 539 | }), | ||
| 540 | ), | ||
| 541 | onSome: Effect.succeed, | ||
| 542 | }), | ||
| 543 | ), | ||
| 544 | ); | ||
| 545 | const relayUrl = yield* requireRelayUrl; | ||
| 546 | const challenge = yield* relayClientRequest(dependencies, { | ||
| 547 | url: `${relayUrl}/v1/client/environment-link-challenges`, | ||
| 548 | token: token.accessToken, | ||
| 549 | payload: { | ||
| 550 | notificationsEnabled: true, | ||
| 551 | liveActivitiesEnabled: true, | ||
| 552 | managedTunnelsEnabled: true, | ||
| 553 | }, | ||
| 554 | schema: RelayEnvironmentLinkChallengeResponse, | ||
| 555 | }); | ||
| 556 | const proof = yield* makeCloudLinkProof( | ||
| 557 | dependencies, | ||
| 558 | { | ||
| 559 | challenge: challenge.challenge, | ||
| 560 | relayIssuer: relayUrl, | ||
| 561 | endpoint: { | ||
| 562 | httpBaseUrl: localOrigin, | ||
| 563 | wsBaseUrl: localWsOrigin, | ||
| 564 | providerKind: "cloudflare_tunnel", | ||
| 565 | }, | ||
| 566 | origin: { | ||
| 567 | localHttpHost: localUrl.hostname, | ||
| 568 | localHttpPort: endpointRequestPort(localUrl), | ||
| 569 | }, | ||
| 570 | }, | ||
| 571 | localOrigin, | ||
| 572 | ); | ||
| 573 | const link = yield* relayClientRequest(dependencies, { | ||
| 574 | url: `${relayUrl}/v1/client/environment-links`, | ||
| 575 | token: token.accessToken, | ||
| 576 | payload: { | ||
| 577 | proof, | ||
| 578 | notificationsEnabled: true, | ||
| 579 | liveActivitiesEnabled: true, | ||
| 580 | managedTunnelsEnabled: true, | ||
| 581 | }, | ||
| 582 | schema: RelayEnvironmentLinkResponse, | ||
| 583 | }); | ||
| 584 | yield* CliState.setCliDesiredCloudLink(true); | ||
| 585 | return yield* applyCloudRelayConfig(dependencies, { | ||
| 586 | relayUrl, | ||
| 587 | relayIssuer: link.relayIssuer, | ||
| 588 | cloudUserId: link.cloudUserId, | ||
| 589 | environmentCredential: link.environmentCredential, | ||
| 590 | cloudMintPublicKey: link.cloudMintPublicKey, | ||
| 591 | endpointRuntime: link.endpointRuntime, | ||
| 592 | }); | ||
| 593 | }, | ||
| 594 | Effect.catchTags({ | ||
| 595 | CloudCliTokenManagerError: (error) => | ||
| 596 | failEnvironmentCloudInternalError(error.message)(error.cause), | ||
| 597 | SecretStoreError: failEnvironmentCloudInternalError( | ||
| 598 | "Could not persist desired T3 Cloud link state.", | ||
| 599 | ), | ||
| 600 | }), | ||
| 601 | ); | ||
| 602 | |||
| 603 | export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileDesiredLink")( | ||
| 604 | function* (localOrigin: string) { | ||
| 605 | return yield* reconcileDesiredCloudLinkWith(yield* cloudHttpDependencies, localOrigin); | ||
| 606 | }, | ||
| 607 | ); | ||
| 608 | |||
| 609 | const readCloudLinkState = Effect.fn("environment.cloud.readLinkState")(function* ( | ||
| 610 | dependencies: CloudHttpDependencies, | ||
| 611 | ) { | ||
| 612 | const [cloudUserId, relayUrl, relayIssuer, publishAgentActivity] = yield* Effect.all( | ||
| 613 | [ | ||
| 614 | dependencies.secrets.get(CLOUD_LINKED_USER_ID), | ||
| 615 | dependencies.secrets.get(RELAY_URL_SECRET), | ||
| 616 | dependencies.secrets.get(RELAY_ISSUER_SECRET), | ||
| 617 | dependencies.secrets.get(PUBLISH_AGENT_ACTIVITY_SECRET), | ||
| 618 | ], | ||
| 619 | { concurrency: 4 }, | ||
| 620 | ); | ||
| 621 | return { | ||
| 622 | linked: cloudUserId !== null, | ||
| 623 | cloudUserId: cloudUserId ? bytesToString(cloudUserId) : null, | ||
| 624 | relayUrl: relayUrl ? bytesToString(relayUrl) : null, | ||
| 625 | relayIssuer: relayIssuer ? bytesToString(relayIssuer) : null, | ||
| 626 | publishAgentActivity: publishAgentActivity | ||
| 627 | ? bytesToString(publishAgentActivity) === "true" | ||
| 628 | : false, | ||
| 629 | } satisfies EnvironmentCloudLinkStateResult; | ||
| 630 | }); | ||
| 631 | |||
| 632 | const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( | ||
| 633 | function* (dependencies: CloudHttpDependencies) { | ||
| 634 | yield* requireEnvironmentScope(AuthRelayReadScope); | ||
| 635 | return yield* readCloudLinkState(dependencies); | ||
| 636 | }, | ||
| 637 | Effect.catchTag( | ||
| 638 | "SecretStoreError", | ||
| 639 | failEnvironmentCloudInternalError("Could not read environment relay configuration."), | ||
| 640 | ), | ||
| 641 | ); | ||
| 642 | |||
| 643 | const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( | ||
| 644 | function* (dependencies: CloudHttpDependencies) { | ||
| 645 | yield* requireEnvironmentScope(AuthRelayWriteScope); | ||
| 646 | const endpointRuntimeStatus = yield* dependencies.endpointRuntime.applyConfig(null); | ||
| 647 | yield* Effect.all( | ||
| 648 | [ | ||
| 649 | dependencies.secrets.remove(CLOUD_LINKED_USER_ID), | ||
| 650 | dependencies.secrets.remove(RELAY_URL_SECRET), | ||
| 651 | dependencies.secrets.remove(RELAY_ISSUER_SECRET), | ||
| 652 | dependencies.secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), | ||
| 653 | dependencies.secrets.remove(CLOUD_MINT_PUBLIC_KEY), | ||
| 654 | dependencies.secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), | ||
| 655 | dependencies.secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), | ||
| 656 | ], | ||
| 657 | { concurrency: 7 }, | ||
| 658 | ); | ||
| 659 | yield* CliState.setCliDesiredCloudLink(false); | ||
| 660 | return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; | ||
| 661 | }, | ||
| 662 | Effect.catchTag( | ||
| 663 | "SecretStoreError", | ||
| 664 | failEnvironmentCloudInternalError("Could not remove environment relay configuration."), | ||
| 665 | ), | ||
| 666 | ); | ||
| 667 | |||
| 668 | const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( | ||
| 669 | function* ( | ||
| 670 | dependencies: CloudHttpDependencies, | ||
| 671 | payload: { readonly publishAgentActivity: boolean }, | ||
| 672 | ) { | ||
| 673 | yield* requireEnvironmentScope(AuthRelayWriteScope); | ||
| 674 | yield* dependencies.secrets.set( | ||
| 675 | PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 676 | stringToBytes(String(payload.publishAgentActivity)), | ||
| 677 | ); | ||
| 678 | return yield* readCloudLinkState(dependencies); | ||
| 679 | }, | ||
| 680 | Effect.catchTag( | ||
| 681 | "SecretStoreError", | ||
| 682 | failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), | ||
| 683 | ), | ||
| 684 | ); | ||
| 685 | |||
| 686 | const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( | ||
| 687 | function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) { | ||
| 688 | const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( | ||
| 689 | Effect.flatMap((bytes) => | ||
| 690 | bytes | ||
| 691 | ? Effect.succeed(bytesToString(bytes)) | ||
| 692 | : Effect.fail( | ||
| 693 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 694 | message: "Cloud mint public key is not installed for this environment.", | ||
| 695 | }), | ||
| 696 | ), | ||
| 697 | ), | ||
| 698 | ); | ||
| 699 | const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( | ||
| 700 | Effect.flatMap((bytes) => | ||
| 701 | bytes | ||
| 702 | ? Effect.succeed(bytesToString(bytes)) | ||
| 703 | : dependencies.secrets.get(RELAY_URL_SECRET).pipe( | ||
| 704 | Effect.flatMap((fallbackBytes) => | ||
| 705 | fallbackBytes | ||
| 706 | ? Effect.succeed(bytesToString(fallbackBytes)) | ||
| 707 | : Effect.fail( | ||
| 708 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 709 | message: "Cloud relay issuer is not installed for this environment.", | ||
| 710 | }), | ||
| 711 | ), | ||
| 712 | ), | ||
| 713 | ), | ||
| 714 | ), | ||
| 715 | ); | ||
| 716 | const environmentId = yield* dependencies.environment.getEnvironmentId; | ||
| 717 | const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); | ||
| 718 | const now = yield* DateTime.now; | ||
| 719 | const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); | ||
| 720 | const proofOption = yield* verifyRelayJwt({ | ||
| 721 | publicKey: cloudMintPublicKey, | ||
| 722 | token: request.proof, | ||
| 723 | typ: RELAY_HEALTH_REQUEST_TYP, | ||
| 724 | issuer: normalizeRelayIssuer(relayIssuer), | ||
| 725 | audience: `t3-env:${environmentId}`, | ||
| 726 | nowEpochSeconds: nowSeconds, | ||
| 727 | }).pipe(Effect.flatMap(decodeCloudHealthProof), Effect.option); | ||
| 728 | if ( | ||
| 729 | Option.isNone(proofOption) || | ||
| 730 | proofOption.value.environmentId !== environmentId || | ||
| 731 | proofOption.value.sub !== linkedCloudUserId || | ||
| 732 | !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || | ||
| 733 | !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:status" }) | ||
| 734 | ) { | ||
| 735 | return yield* new EnvironmentHttpUnauthorizedError({ | ||
| 736 | message: "Invalid cloud health request.", | ||
| 737 | }); | ||
| 738 | } | ||
| 739 | const proof = proofOption.value; | ||
| 740 | |||
| 741 | const jtiSecretName = `${CLOUD_HEALTH_JTI_PREFIX}${proof.jti}`; | ||
| 742 | const nonceSecretName = `${CLOUD_HEALTH_NONCE_PREFIX}${proof.nonce}`; | ||
| 743 | const consumedReplayGuards = yield* consumeCloudReplayGuards({ | ||
| 744 | secrets: dependencies.secrets, | ||
| 745 | names: [jtiSecretName, nonceSecretName], | ||
| 746 | value: stringToBytes(DateTime.formatIso(now)), | ||
| 747 | }); | ||
| 748 | if (!consumedReplayGuards) { | ||
| 749 | return yield* new EnvironmentHttpConflictError({ | ||
| 750 | message: "Cloud health request was already consumed.", | ||
| 751 | }); | ||
| 752 | } | ||
| 753 | |||
| 754 | const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); | ||
| 755 | const descriptor = yield* dependencies.environment.getDescriptor; | ||
| 756 | const responseExpiresAt = DateTime.add(now, { minutes: 5 }); | ||
| 757 | const responsePayload = { | ||
| 758 | iss: `t3-env:${environmentId}`, | ||
| 759 | aud: normalizeRelayIssuer(relayIssuer), | ||
| 760 | sub: environmentId, | ||
| 761 | jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)), | ||
| 762 | iat: nowSeconds, | ||
| 763 | exp: Math.floor(responseExpiresAt.epochMilliseconds / 1_000), | ||
| 764 | environmentId, | ||
| 765 | requestNonce: proof.nonce, | ||
| 766 | status: "online", | ||
| 767 | descriptor, | ||
| 768 | checkedAt: DateTime.formatIso(now), | ||
| 769 | } satisfies RelayEnvironmentHealthResponseProofPayload; | ||
| 770 | const responseProof = yield* signRelayJwt({ | ||
| 771 | privateKey: keyPair.privateKey, | ||
| 772 | typ: RELAY_HEALTH_RESPONSE_TYP, | ||
| 773 | payload: responsePayload, | ||
| 774 | }).pipe( | ||
| 775 | Effect.mapError( | ||
| 776 | (cause) => | ||
| 777 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 778 | message: "Failed to sign cloud health JWT.", | ||
| 779 | cause, | ||
| 780 | }), | ||
| 781 | ), | ||
| 782 | ); | ||
| 783 | const response = { | ||
| 784 | environmentId, | ||
| 785 | status: "online", | ||
| 786 | descriptor, | ||
| 787 | checkedAt: responsePayload.checkedAt, | ||
| 788 | proof: responseProof, | ||
| 789 | } satisfies RelayEnvironmentHealthResponseShape; | ||
| 790 | |||
| 791 | yield* appendCloudCredentialResponseHeaders; | ||
| 792 | return response; | ||
| 793 | }, | ||
| 794 | Effect.catchTag("ServerAuthInternalError", (error) => | ||
| 795 | failEnvironmentCloudInternalError(error.message)(error.cause), | ||
| 796 | ), | ||
| 797 | Effect.catchTags({ | ||
| 798 | PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."), | ||
| 799 | SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."), | ||
| 800 | }), | ||
| 801 | ); | ||
| 802 | |||
| 803 | const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( | ||
| 804 | function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) { | ||
| 805 | const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( | ||
| 806 | Effect.flatMap((bytes) => | ||
| 807 | bytes | ||
| 808 | ? Effect.succeed(bytesToString(bytes)) | ||
| 809 | : Effect.fail( | ||
| 810 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 811 | message: "Cloud mint public key is not installed for this environment.", | ||
| 812 | }), | ||
| 813 | ), | ||
| 814 | ), | ||
| 815 | ); | ||
| 816 | const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( | ||
| 817 | Effect.flatMap((bytes) => | ||
| 818 | bytes | ||
| 819 | ? Effect.succeed(bytesToString(bytes)) | ||
| 820 | : dependencies.secrets.get(RELAY_URL_SECRET).pipe( | ||
| 821 | Effect.flatMap((fallbackBytes) => | ||
| 822 | fallbackBytes | ||
| 823 | ? Effect.succeed(bytesToString(fallbackBytes)) | ||
| 824 | : Effect.fail( | ||
| 825 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 826 | message: "Cloud relay issuer is not installed for this environment.", | ||
| 827 | }), | ||
| 828 | ), | ||
| 829 | ), | ||
| 830 | ), | ||
| 831 | ), | ||
| 832 | ); | ||
| 833 | const environmentId = yield* dependencies.environment.getEnvironmentId; | ||
| 834 | const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); | ||
| 835 | const now = yield* DateTime.now; | ||
| 836 | const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); | ||
| 837 | const proofOption = yield* verifyRelayJwt({ | ||
| 838 | publicKey: cloudMintPublicKey, | ||
| 839 | token: request.proof, | ||
| 840 | typ: RELAY_MINT_REQUEST_TYP, | ||
| 841 | issuer: normalizeRelayIssuer(relayIssuer), | ||
| 842 | audience: `t3-env:${environmentId}`, | ||
| 843 | nowEpochSeconds: nowSeconds, | ||
| 844 | }).pipe(Effect.flatMap(decodeCloudMintProof), Effect.option); | ||
| 845 | if ( | ||
| 846 | Option.isNone(proofOption) || | ||
| 847 | proofOption.value.environmentId !== environmentId || | ||
| 848 | proofOption.value.sub !== linkedCloudUserId || | ||
| 849 | proofOption.value.cnf.jkt !== proofOption.value.clientProofKeyThumbprint || | ||
| 850 | !hasBoundedCloudProofLifetime({ ...proofOption.value, nowSeconds }) || | ||
| 851 | !hasExactScope({ scopes: proofOption.value.scope, expected: "environment:connect" }) | ||
| 852 | ) { | ||
| 853 | return yield* new EnvironmentHttpUnauthorizedError({ | ||
| 854 | message: "Invalid cloud mint request.", | ||
| 855 | }); | ||
| 856 | } | ||
| 857 | const proof = proofOption.value; | ||
| 858 | |||
| 859 | const jtiSecretName = `${CLOUD_MINT_JTI_PREFIX}${proof.jti}`; | ||
| 860 | const nonceSecretName = `${CLOUD_MINT_NONCE_PREFIX}${proof.nonce}`; | ||
| 861 | const consumedReplayGuards = yield* consumeCloudReplayGuards({ | ||
| 862 | secrets: dependencies.secrets, | ||
| 863 | names: [jtiSecretName, nonceSecretName], | ||
| 864 | value: stringToBytes(DateTime.formatIso(now)), | ||
| 865 | }); | ||
| 866 | if (!consumedReplayGuards) { | ||
| 867 | return yield* new EnvironmentHttpConflictError({ | ||
| 868 | message: "Cloud mint request was already consumed.", | ||
| 869 | }); | ||
| 870 | } | ||
| 871 | |||
| 872 | const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(dependencies.secrets); | ||
| 873 | const issued = yield* dependencies.environmentAuth.createPairingLink({ | ||
| 874 | scopes: AuthStandardClientScopes, | ||
| 875 | subject: "cloud-connect", | ||
| 876 | ttl: Duration.minutes(2), | ||
| 877 | label: "T3 Cloud connect", | ||
| 878 | proofKeyThumbprint: proof.clientProofKeyThumbprint, | ||
| 879 | }); | ||
| 880 | const responsePayload = { | ||
| 881 | iss: `t3-env:${environmentId}`, | ||
| 882 | aud: normalizeRelayIssuer(relayIssuer), | ||
| 883 | sub: environmentId, | ||
| 884 | jti: yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)), | ||
| 885 | iat: nowSeconds, | ||
| 886 | exp: Math.floor(issued.expiresAt.epochMilliseconds / 1_000), | ||
| 887 | environmentId, | ||
| 888 | clientProofKeyThumbprint: proof.clientProofKeyThumbprint, | ||
| 889 | requestNonce: proof.nonce, | ||
| 890 | credential: issued.credential, | ||
| 891 | } satisfies RelayEnvironmentMintResponseProofPayload; | ||
| 892 | const responseProof = yield* signRelayJwt({ | ||
| 893 | privateKey: keyPair.privateKey, | ||
| 894 | typ: RELAY_MINT_RESPONSE_TYP, | ||
| 895 | payload: responsePayload, | ||
| 896 | }).pipe( | ||
| 897 | Effect.mapError( | ||
| 898 | (cause) => | ||
| 899 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 900 | message: "Failed to sign cloud mint JWT.", | ||
| 901 | cause, | ||
| 902 | }), | ||
| 903 | ), | ||
| 904 | ); | ||
| 905 | const response = { | ||
| 906 | credential: issued.credential, | ||
| 907 | expiresAt: DateTime.formatIso(issued.expiresAt), | ||
| 908 | proof: responseProof, | ||
| 909 | } satisfies RelayEnvironmentMintResponseShape; | ||
| 910 | |||
| 911 | yield* appendCloudCredentialResponseHeaders; | ||
| 912 | return response; | ||
| 913 | }, | ||
| 914 | Effect.catchTag("ServerAuthInternalError", (error) => | ||
| 915 | failEnvironmentCloudInternalError(error.message)(error.cause), | ||
| 916 | ), | ||
| 917 | Effect.catchTags({ | ||
| 918 | PlatformError: failEnvironmentCloudInternalError( | ||
| 919 | "Could not issue cloud connection credential.", | ||
| 920 | ), | ||
| 921 | SecretStoreError: failEnvironmentCloudInternalError( | ||
| 922 | "Could not issue cloud connection credential.", | ||
| 923 | ), | ||
| 924 | }), | ||
| 925 | ); | ||
| 926 | |||
| 927 | export const cloudHttpApiLayer = HttpApiBuilder.group( | ||
| 928 | EnvironmentHttpApi, | ||
| 929 | "cloud", | ||
| 930 | Effect.fnUntraced(function* (handlers) { | ||
| 931 | const dependencies = yield* cloudHttpDependencies; | ||
| 932 | return handlers | ||
| 933 | .handle("linkProof", ({ payload }) => cloudLinkProofHandler(dependencies, payload)) | ||
| 934 | .handle("relayConfig", ({ payload }) => cloudRelayConfigHandler(dependencies, payload)) | ||
| 935 | .handle("linkState", () => cloudLinkStateHandler(dependencies)) | ||
| 936 | .handle("unlink", () => cloudUnlinkHandler(dependencies)) | ||
| 937 | .handle("preferences", ({ payload }) => cloudPreferencesHandler(dependencies, payload)) | ||
| 938 | .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) | ||
| 939 | .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) | ||
| 940 | .handle("t3MintCredential", ({ payload }) => | ||
| 941 | cloudMintCredentialHandler(dependencies, payload), | ||
| 942 | ); | ||
| 943 | }), | ||
| 944 | ); | ||
apps/server/src/cloud/publicConfig.test.ts created+85| ... | @@ -0,0 +1,85 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | import { makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts"; | ||
| 6 | |||
| 7 | const provideEnv = (env: Readonly<Record<string, string>>) => | ||
| 8 | Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))); | ||
| 9 | |||
| 10 | it.effect("uses the statically injected relay URL when no runtime override exists", () => | ||
| 11 | Effect.gen(function* () { | ||
| 12 | const relayUrl = yield* makeRelayUrlConfig("https://embedded.example.test///").pipe( | ||
| 13 | provideEnv({}), | ||
| 14 | ); | ||
| 15 | |||
| 16 | assert.equal(relayUrl, "https://embedded.example.test"); | ||
| 17 | }), | ||
| 18 | ); | ||
| 19 | |||
| 20 | it.effect("prefers a runtime relay URL override over the statically injected value", () => | ||
| 21 | Effect.gen(function* () { | ||
| 22 | const relayUrl = yield* makeRelayUrlConfig("https://embedded.example.test").pipe( | ||
| 23 | provideEnv({ T3CODE_RELAY_URL: "https://runtime.example.test///" }), | ||
| 24 | ); | ||
| 25 | |||
| 26 | assert.equal(relayUrl, "https://runtime.example.test"); | ||
| 27 | }), | ||
| 28 | ); | ||
| 29 | |||
| 30 | it.effect("requires a relay URL when the server bundle has no injected value", () => | ||
| 31 | makeRelayUrlConfig("").pipe(provideEnv({}), Effect.flip), | ||
| 32 | ); | ||
| 33 | |||
| 34 | it.effect("rejects an insecure runtime relay URL override", () => | ||
| 35 | makeRelayUrlConfig("https://embedded.example.test").pipe( | ||
| 36 | provideEnv({ T3CODE_RELAY_URL: "http://runtime.example.test" }), | ||
| 37 | Effect.flip, | ||
| 38 | ), | ||
| 39 | ); | ||
| 40 | |||
| 41 | it.effect("rejects an injected relay URL with a non-origin path", () => | ||
| 42 | makeRelayUrlConfig("https://embedded.example.test/path").pipe(provideEnv({}), Effect.flip), | ||
| 43 | ); | ||
| 44 | |||
| 45 | it.effect("derives direct Clerk OAuth endpoints from statically injected public config", () => | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const config = yield* makeCloudCliOAuthConfig({ | ||
| 48 | clerkPublishableKeyFallback: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==", | ||
| 49 | clerkCliOAuthClientIdFallback: "oauth_client_embedded", | ||
| 50 | }).pipe(provideEnv({})); | ||
| 51 | |||
| 52 | assert.deepEqual(config, { | ||
| 53 | authorizationEndpoint: "https://clerk.example.test/oauth/authorize", | ||
| 54 | tokenEndpoint: "https://clerk.example.test/oauth/token", | ||
| 55 | clientId: "oauth_client_embedded", | ||
| 56 | redirectUri: "http://127.0.0.1:34338/callback", | ||
| 57 | scopes: ["openid", "profile", "email"], | ||
| 58 | }); | ||
| 59 | }), | ||
| 60 | ); | ||
| 61 | |||
| 62 | it.effect("prefers runtime Clerk OAuth config overrides over statically injected values", () => | ||
| 63 | Effect.gen(function* () { | ||
| 64 | const config = yield* makeCloudCliOAuthConfig({ | ||
| 65 | clerkPublishableKeyFallback: "pk_test_ZW1iZWRkZWQuZXhhbXBsZS50ZXN0JA==", | ||
| 66 | clerkCliOAuthClientIdFallback: "oauth_client_embedded", | ||
| 67 | }).pipe( | ||
| 68 | provideEnv({ | ||
| 69 | T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_cnVudGltZS5leGFtcGxlLnRlc3Qk", | ||
| 70 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_runtime", | ||
| 71 | }), | ||
| 72 | ); | ||
| 73 | |||
| 74 | assert.equal(config.authorizationEndpoint, "https://runtime.example.test/oauth/authorize"); | ||
| 75 | assert.equal(config.tokenEndpoint, "https://runtime.example.test/oauth/token"); | ||
| 76 | assert.equal(config.clientId, "oauth_client_runtime"); | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | |||
| 80 | it.effect("requires Clerk OAuth config when the server bundle has no injected values", () => | ||
| 81 | makeCloudCliOAuthConfig({ | ||
| 82 | clerkPublishableKeyFallback: "", | ||
| 83 | clerkCliOAuthClientIdFallback: "", | ||
| 84 | }).pipe(provideEnv({}), Effect.flip), | ||
| 85 | ); | ||
apps/server/src/cloud/publicConfig.ts created+110| ... | @@ -0,0 +1,110 @@ | ||
| 1 | import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth"; | ||
| 2 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 3 | import * as Config from "effect/Config"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as SchemaIssue from "effect/SchemaIssue"; | ||
| 8 | |||
| 9 | declare const __T3CODE_BUILD_RELAY_URL__: string | undefined; | ||
| 10 | declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; | ||
| 11 | declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined; | ||
| 12 | |||
| 13 | const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback"; | ||
| 14 | const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const; | ||
| 15 | |||
| 16 | function validateRelayUrl(value: string) { | ||
| 17 | const relayUrl = normalizeSecureRelayUrl(value); | ||
| 18 | return relayUrl === null | ||
| 19 | ? Effect.fail( | ||
| 20 | new Config.ConfigError( | ||
| 21 | new Schema.SchemaError( | ||
| 22 | new SchemaIssue.InvalidValue(Option.some(value), { | ||
| 23 | message: "Relay URL must be a secure absolute HTTPS origin.", | ||
| 24 | }), | ||
| 25 | ), | ||
| 26 | ), | ||
| 27 | ) | ||
| 28 | : Effect.succeed(relayUrl); | ||
| 29 | } | ||
| 30 | |||
| 31 | function readBuildTimeValue(value: string | undefined): string { | ||
| 32 | return typeof value === "undefined" ? "" : value.trim(); | ||
| 33 | } | ||
| 34 | |||
| 35 | export const buildTimeRelayUrl = | ||
| 36 | typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" | ||
| 37 | ? "" | ||
| 38 | : (normalizeSecureRelayUrl(__T3CODE_BUILD_RELAY_URL__) ?? ""); | ||
| 39 | export const buildTimeClerkPublishableKey = readBuildTimeValue( | ||
| 40 | typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" | ||
| 41 | ? undefined | ||
| 42 | : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, | ||
| 43 | ); | ||
| 44 | export const buildTimeClerkCliOAuthClientId = readBuildTimeValue( | ||
| 45 | typeof __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__ === "undefined" | ||
| 46 | ? undefined | ||
| 47 | : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, | ||
| 48 | ); | ||
| 49 | |||
| 50 | export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { | ||
| 51 | const runtimeConfig = Config.nonEmptyString("T3CODE_RELAY_URL"); | ||
| 52 | return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe( | ||
| 53 | Config.mapOrFail(validateRelayUrl), | ||
| 54 | ); | ||
| 55 | } | ||
| 56 | |||
| 57 | export const relayUrlConfig = makeRelayUrlConfig(); | ||
| 58 | |||
| 59 | function makePublicValueConfig(name: string, fallback: string) { | ||
| 60 | const runtimeConfig = Config.nonEmptyString(name); | ||
| 61 | return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe( | ||
| 62 | Config.map((value) => value.trim()), | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | |||
| 66 | export interface CloudCliOAuthConfig { | ||
| 67 | readonly authorizationEndpoint: string; | ||
| 68 | readonly tokenEndpoint: string; | ||
| 69 | readonly clientId: string; | ||
| 70 | readonly redirectUri: string; | ||
| 71 | readonly scopes: typeof CLOUD_CLI_OAUTH_SCOPES; | ||
| 72 | } | ||
| 73 | |||
| 74 | export function makeCloudCliOAuthConfig({ | ||
| 75 | clerkPublishableKeyFallback = buildTimeClerkPublishableKey, | ||
| 76 | clerkCliOAuthClientIdFallback = buildTimeClerkCliOAuthClientId, | ||
| 77 | }: { | ||
| 78 | readonly clerkPublishableKeyFallback?: string; | ||
| 79 | readonly clerkCliOAuthClientIdFallback?: string; | ||
| 80 | } = {}) { | ||
| 81 | return Config.all({ | ||
| 82 | clerkPublishableKey: makePublicValueConfig( | ||
| 83 | "T3CODE_CLERK_PUBLISHABLE_KEY", | ||
| 84 | clerkPublishableKeyFallback, | ||
| 85 | ), | ||
| 86 | clientId: makePublicValueConfig( | ||
| 87 | "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID", | ||
| 88 | clerkCliOAuthClientIdFallback, | ||
| 89 | ), | ||
| 90 | }).pipe( | ||
| 91 | Config.map(({ clerkPublishableKey, clientId }) => { | ||
| 92 | const clerkFrontendApiUrl = clerkFrontendApiUrlFromPublishableKey(clerkPublishableKey); | ||
| 93 | return { | ||
| 94 | authorizationEndpoint: `${clerkFrontendApiUrl}/oauth/authorize`, | ||
| 95 | tokenEndpoint: `${clerkFrontendApiUrl}/oauth/token`, | ||
| 96 | clientId, | ||
| 97 | redirectUri: CLOUD_CLI_OAUTH_REDIRECT_URI, | ||
| 98 | scopes: CLOUD_CLI_OAUTH_SCOPES, | ||
| 99 | } satisfies CloudCliOAuthConfig; | ||
| 100 | }), | ||
| 101 | ); | ||
| 102 | } | ||
| 103 | |||
| 104 | export const cloudCliOAuthConfig = makeCloudCliOAuthConfig(); | ||
| 105 | |||
| 106 | export const hasCloudPublicConfig = Boolean( | ||
| 107 | (normalizeSecureRelayUrl(process.env.T3CODE_RELAY_URL ?? "") ?? buildTimeRelayUrl) && | ||
| 108 | (process.env.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() || buildTimeClerkPublishableKey) && | ||
| 109 | (process.env.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || buildTimeClerkCliOAuthClientId), | ||
| 110 | ); | ||
apps/server/src/diagnostics/ProcessDiagnostics.ts+1-1| ... | @@ -47,7 +47,7 @@ class ProcessDiagnosticsError extends Schema.TaggedErrorClass<ProcessDiagnostics | ... | @@ -47,7 +47,7 @@ class ProcessDiagnosticsError extends Schema.TaggedErrorClass<ProcessDiagnostics |
| 47 | "ProcessDiagnosticsError", | 47 | "ProcessDiagnosticsError", |
| 48 | { | 48 | { |
| 49 | message: Schema.String, | 49 | message: Schema.String, |
| 50 | cause: Schema.optional(Schema.Defect), | 50 | cause: Schema.optional(Schema.Defect()), |
| 51 | }, | 51 | }, |
| 52 | ) {} | 52 | ) {} |
| 53 | const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); | 53 | const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); |
apps/server/src/http.ts+13-5| ... | @@ -8,6 +8,7 @@ import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; | ... | @@ -8,6 +8,7 @@ import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; |
| 8 | import * as Data from "effect/Data"; | 8 | import * as Data from "effect/Data"; |
| 9 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as FileSystem from "effect/FileSystem"; | 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Option from "effect/Option"; | 12 | import * as Option from "effect/Option"; |
| 12 | import * as Path from "effect/Path"; | 13 | import * as Path from "effect/Path"; |
| 13 | import { cast } from "effect/Function"; | 14 | import { cast } from "effect/Function"; |
| ... | @@ -47,11 +48,18 @@ const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" vi | ... | @@ -47,11 +48,18 @@ const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" vi |
| 47 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; | 48 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
| 48 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | 49 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 49 | 50 | ||
| 50 | export const browserApiCorsLayer = HttpRouter.cors({ | 51 | export const browserApiCorsLayer = Layer.unwrap( |
| 51 | allowedMethods: browserApiCorsAllowedMethods, | 52 | Effect.gen(function* () { |
| 52 | allowedHeaders: browserApiCorsAllowedHeaders, | 53 | const config = yield* ServerConfig; |
| 53 | maxAge: 600, | 54 | const devOrigin = config.devUrl?.origin; |
| 54 | }); | 55 | return HttpRouter.cors({ |
| 56 | ...(devOrigin ? { allowedOrigins: [devOrigin], credentials: true } : {}), | ||
| 57 | allowedMethods: browserApiCorsAllowedMethods, | ||
| 58 | allowedHeaders: browserApiCorsAllowedHeaders, | ||
| 59 | maxAge: 600, | ||
| 60 | }); | ||
| 61 | }), | ||
| 62 | ); | ||
| 55 | 63 | ||
| 56 | export function isLoopbackHostname(hostname: string): boolean { | 64 | export function isLoopbackHostname(hostname: string): boolean { |
| 57 | const normalizedHostname = hostname | 65 | const normalizedHostname = hostname |
apps/server/src/httpCors.ts+1| ... | @@ -4,6 +4,7 @@ export const browserApiCorsAllowedHeaders = [ | ... | @@ -4,6 +4,7 @@ export const browserApiCorsAllowedHeaders = [ |
| 4 | "b3", | 4 | "b3", |
| 5 | "traceparent", | 5 | "traceparent", |
| 6 | "content-type", | 6 | "content-type", |
| 7 | "dpop", | ||
| 7 | ] as const; | 8 | ] as const; |
| 8 | 9 | ||
| 9 | export const browserApiCorsHeaders = { | 10 | export const browserApiCorsHeaders = { |
apps/server/src/orchestration/Errors.ts+6-6| ... | @@ -7,7 +7,7 @@ export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass< | ... | @@ -7,7 +7,7 @@ export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass< |
| 7 | "OrchestrationCommandJsonParseError", | 7 | "OrchestrationCommandJsonParseError", |
| 8 | { | 8 | { |
| 9 | detail: Schema.String, | 9 | detail: Schema.String, |
| 10 | cause: Schema.optional(Schema.Defect), | 10 | cause: Schema.optional(Schema.Defect()), |
| 11 | }, | 11 | }, |
| 12 | ) { | 12 | ) { |
| 13 | override get message(): string { | 13 | override get message(): string { |
| ... | @@ -19,7 +19,7 @@ export class OrchestrationCommandDecodeError extends Schema.TaggedErrorClass<Orc | ... | @@ -19,7 +19,7 @@ export class OrchestrationCommandDecodeError extends Schema.TaggedErrorClass<Orc |
| 19 | "OrchestrationCommandDecodeError", | 19 | "OrchestrationCommandDecodeError", |
| 20 | { | 20 | { |
| 21 | issue: Schema.String, | 21 | issue: Schema.String, |
| 22 | cause: Schema.optional(Schema.Defect), | 22 | cause: Schema.optional(Schema.Defect()), |
| 23 | }, | 23 | }, |
| 24 | ) { | 24 | ) { |
| 25 | override get message(): string { | 25 | override get message(): string { |
| ... | @@ -32,7 +32,7 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< | ... | @@ -32,7 +32,7 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass< |
| 32 | { | 32 | { |
| 33 | commandType: Schema.String, | 33 | commandType: Schema.String, |
| 34 | detail: Schema.String, | 34 | detail: Schema.String, |
| 35 | cause: Schema.optional(Schema.Defect), | 35 | cause: Schema.optional(Schema.Defect()), |
| 36 | }, | 36 | }, |
| 37 | ) { | 37 | ) { |
| 38 | override get message(): string { | 38 | override get message(): string { |
| ... | @@ -45,7 +45,7 @@ export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedEr | ... | @@ -45,7 +45,7 @@ export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedEr |
| 45 | { | 45 | { |
| 46 | commandId: Schema.String, | 46 | commandId: Schema.String, |
| 47 | detail: Schema.String, | 47 | detail: Schema.String, |
| 48 | cause: Schema.optional(Schema.Defect), | 48 | cause: Schema.optional(Schema.Defect()), |
| 49 | }, | 49 | }, |
| 50 | ) { | 50 | ) { |
| 51 | override get message(): string { | 51 | override get message(): string { |
| ... | @@ -58,7 +58,7 @@ export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<O | ... | @@ -58,7 +58,7 @@ export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<O |
| 58 | { | 58 | { |
| 59 | eventType: Schema.String, | 59 | eventType: Schema.String, |
| 60 | issue: Schema.String, | 60 | issue: Schema.String, |
| 61 | cause: Schema.optional(Schema.Defect), | 61 | cause: Schema.optional(Schema.Defect()), |
| 62 | }, | 62 | }, |
| 63 | ) { | 63 | ) { |
| 64 | override get message(): string { | 64 | override get message(): string { |
| ... | @@ -71,7 +71,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< | ... | @@ -71,7 +71,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass< |
| 71 | { | 71 | { |
| 72 | listener: Schema.Literals(["read-model", "domain-event"]), | 72 | listener: Schema.Literals(["read-model", "domain-event"]), |
| 73 | detail: Schema.String, | 73 | detail: Schema.String, |
| 74 | cause: Schema.optional(Schema.Defect), | 74 | cause: Schema.optional(Schema.Defect()), |
| 75 | }, | 75 | }, |
| 76 | ) { | 76 | ) { |
| 77 | override get message(): string { | 77 | override get message(): string { |
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+11| ... | @@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge | ... | @@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge |
| 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; | 12 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; |
| 13 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; | 13 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; |
| 14 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; | ||
| 14 | 15 | ||
| 15 | describe("OrchestrationReactor", () => { | 16 | describe("OrchestrationReactor", () => { |
| 16 | let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null; | 17 | let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null; |
| ... | @@ -63,6 +64,15 @@ describe("OrchestrationReactor", () => { | ... | @@ -63,6 +64,15 @@ describe("OrchestrationReactor", () => { |
| 63 | drain: Effect.void, | 64 | drain: Effect.void, |
| 64 | }), | 65 | }), |
| 65 | ), | 66 | ), |
| 67 | Layer.provideMerge( | ||
| 68 | Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { | ||
| 69 | publishThread: () => Effect.void, | ||
| 70 | start: () => { | ||
| 71 | started.push("agent-awareness-relay"); | ||
| 72 | return Effect.void; | ||
| 73 | }, | ||
| 74 | }), | ||
| 75 | ), | ||
| 66 | ), | 76 | ), |
| 67 | ); | 77 | ); |
| 68 | 78 | ||
| ... | @@ -75,6 +85,7 @@ describe("OrchestrationReactor", () => { | ... | @@ -75,6 +85,7 @@ describe("OrchestrationReactor", () => { |
| 75 | "provider-command-reactor", | 85 | "provider-command-reactor", |
| 76 | "checkpoint-reactor", | 86 | "checkpoint-reactor", |
| 77 | "thread-deletion-reactor", | 87 | "thread-deletion-reactor", |
| 88 | "agent-awareness-relay", | ||
| 78 | ]); | 89 | ]); |
| 79 | 90 | ||
| 80 | await Effect.runPromise(Scope.close(scope, Exit.void)); | 91 | await Effect.runPromise(Scope.close(scope, Exit.void)); |
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3| ... | @@ -9,18 +9,21 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; | ... | @@ -9,18 +9,21 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; |
| 9 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | 9 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; | 10 | import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; |
| 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; | 11 | import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; |
| 12 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; | ||
| 12 | 13 | ||
| 13 | export const makeOrchestrationReactor = Effect.gen(function* () { | 14 | export const makeOrchestrationReactor = Effect.gen(function* () { |
| 14 | const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; | 15 | const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; |
| 15 | const providerCommandReactor = yield* ProviderCommandReactor; | 16 | const providerCommandReactor = yield* ProviderCommandReactor; |
| 16 | const checkpointReactor = yield* CheckpointReactor; | 17 | const checkpointReactor = yield* CheckpointReactor; |
| 17 | const threadDeletionReactor = yield* ThreadDeletionReactor; | 18 | const threadDeletionReactor = yield* ThreadDeletionReactor; |
| 19 | const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; | ||
| 18 | 20 | ||
| 19 | const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { | 21 | const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { |
| 20 | yield* providerRuntimeIngestion.start(); | 22 | yield* providerRuntimeIngestion.start(); |
| 21 | yield* providerCommandReactor.start(); | 23 | yield* providerCommandReactor.start(); |
| 22 | yield* checkpointReactor.start(); | 24 | yield* checkpointReactor.start(); |
| 23 | yield* threadDeletionReactor.start(); | 25 | yield* threadDeletionReactor.start(); |
| 26 | yield* agentAwarenessRelay.start(); | ||
| 24 | }); | 27 | }); |
| 25 | 28 | ||
| 26 | return { | 29 | return { |
apps/server/src/persistence/Errors.ts+4-4| ... | @@ -10,7 +10,7 @@ export class PersistenceSqlError extends Schema.TaggedErrorClass<PersistenceSqlE | ... | @@ -10,7 +10,7 @@ export class PersistenceSqlError extends Schema.TaggedErrorClass<PersistenceSqlE |
| 10 | { | 10 | { |
| 11 | operation: Schema.String, | 11 | operation: Schema.String, |
| 12 | detail: Schema.String, | 12 | detail: Schema.String, |
| 13 | cause: Schema.optional(Schema.Defect), | 13 | cause: Schema.optional(Schema.Defect()), |
| 14 | }, | 14 | }, |
| 15 | ) { | 15 | ) { |
| 16 | override get message(): string { | 16 | override get message(): string { |
| ... | @@ -23,7 +23,7 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceD | ... | @@ -23,7 +23,7 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceD |
| 23 | { | 23 | { |
| 24 | operation: Schema.String, | 24 | operation: Schema.String, |
| 25 | issue: Schema.String, | 25 | issue: Schema.String, |
| 26 | cause: Schema.optional(Schema.Defect), | 26 | cause: Schema.optional(Schema.Defect()), |
| 27 | }, | 27 | }, |
| 28 | ) { | 28 | ) { |
| 29 | override get message(): string { | 29 | override get message(): string { |
| ... | @@ -72,7 +72,7 @@ export class ProviderSessionRepositoryValidationError extends Schema.TaggedError | ... | @@ -72,7 +72,7 @@ export class ProviderSessionRepositoryValidationError extends Schema.TaggedError |
| 72 | { | 72 | { |
| 73 | operation: Schema.String, | 73 | operation: Schema.String, |
| 74 | issue: Schema.String, | 74 | issue: Schema.String, |
| 75 | cause: Schema.optional(Schema.Defect), | 75 | cause: Schema.optional(Schema.Defect()), |
| 76 | }, | 76 | }, |
| 77 | ) { | 77 | ) { |
| 78 | override get message(): string { | 78 | override get message(): string { |
| ... | @@ -85,7 +85,7 @@ export class ProviderSessionRepositoryPersistenceError extends Schema.TaggedErro | ... | @@ -85,7 +85,7 @@ export class ProviderSessionRepositoryPersistenceError extends Schema.TaggedErro |
| 85 | { | 85 | { |
| 86 | operation: Schema.String, | 86 | operation: Schema.String, |
| 87 | detail: Schema.String, | 87 | detail: Schema.String, |
| 88 | cause: Schema.optional(Schema.Defect), | 88 | cause: Schema.optional(Schema.Defect()), |
| 89 | }, | 89 | }, |
| 90 | ) { | 90 | ) { |
| 91 | override get message(): string { | 91 | override get message(): string { |
apps/server/src/persistence/Layers/AuthPairingLinks.ts+10-1| ... | @@ -41,6 +41,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -41,6 +41,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 41 | scopes, | 41 | scopes, |
| 42 | subject, | 42 | subject, |
| 43 | label, | 43 | label, |
| 44 | proof_key_thumbprint, | ||
| 44 | created_at, | 45 | created_at, |
| 45 | expires_at, | 46 | expires_at, |
| 46 | consumed_at, | 47 | consumed_at, |
| ... | @@ -53,6 +54,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -53,6 +54,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 53 | ${JSON.stringify(input.scopes)}, | 54 | ${JSON.stringify(input.scopes)}, |
| 54 | ${input.subject}, | 55 | ${input.subject}, |
| 55 | ${input.label}, | 56 | ${input.label}, |
| 57 | ${input.proofKeyThumbprint}, | ||
| 56 | ${input.createdAt}, | 58 | ${input.createdAt}, |
| 57 | ${input.expiresAt}, | 59 | ${input.expiresAt}, |
| 58 | NULL, | 60 | NULL, |
| ... | @@ -64,7 +66,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -64,7 +66,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 64 | const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ | 66 | const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ |
| 65 | Request: ConsumeAuthPairingLinkInput, | 67 | Request: ConsumeAuthPairingLinkInput, |
| 66 | Result: AuthPairingLinkRecord, | 68 | Result: AuthPairingLinkRecord, |
| 67 | execute: ({ credential, consumedAt, now }) => | 69 | execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => |
| 68 | sql` | 70 | sql` |
| 69 | UPDATE auth_pairing_links | 71 | UPDATE auth_pairing_links |
| 70 | SET consumed_at = ${consumedAt} | 72 | SET consumed_at = ${consumedAt} |
| ... | @@ -72,6 +74,10 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -72,6 +74,10 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 72 | AND revoked_at IS NULL | 74 | AND revoked_at IS NULL |
| 73 | AND consumed_at IS NULL | 75 | AND consumed_at IS NULL |
| 74 | AND expires_at > ${now} | 76 | AND expires_at > ${now} |
| 77 | AND ( | ||
| 78 | proof_key_thumbprint IS NULL | ||
| 79 | OR proof_key_thumbprint = ${proofKeyThumbprint} | ||
| 80 | ) | ||
| 75 | RETURNING | 81 | RETURNING |
| 76 | id AS "id", | 82 | id AS "id", |
| 77 | credential AS "credential", | 83 | credential AS "credential", |
| ... | @@ -79,6 +85,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -79,6 +85,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 79 | scopes AS "scopes", | 85 | scopes AS "scopes", |
| 80 | subject AS "subject", | 86 | subject AS "subject", |
| 81 | label AS "label", | 87 | label AS "label", |
| 88 | proof_key_thumbprint AS "proofKeyThumbprint", | ||
| 82 | created_at AS "createdAt", | 89 | created_at AS "createdAt", |
| 83 | expires_at AS "expiresAt", | 90 | expires_at AS "expiresAt", |
| 84 | consumed_at AS "consumedAt", | 91 | consumed_at AS "consumedAt", |
| ... | @@ -98,6 +105,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -98,6 +105,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 98 | scopes AS "scopes", | 105 | scopes AS "scopes", |
| 99 | subject AS "subject", | 106 | subject AS "subject", |
| 100 | label AS "label", | 107 | label AS "label", |
| 108 | proof_key_thumbprint AS "proofKeyThumbprint", | ||
| 101 | created_at AS "createdAt", | 109 | created_at AS "createdAt", |
| 102 | expires_at AS "expiresAt", | 110 | expires_at AS "expiresAt", |
| 103 | consumed_at AS "consumedAt", | 111 | consumed_at AS "consumedAt", |
| ... | @@ -136,6 +144,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { | ... | @@ -136,6 +144,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { |
| 136 | scopes AS "scopes", | 144 | scopes AS "scopes", |
| 137 | subject AS "subject", | 145 | subject AS "subject", |
| 138 | label AS "label", | 146 | label AS "label", |
| 147 | proof_key_thumbprint AS "proofKeyThumbprint", | ||
| 139 | created_at AS "createdAt", | 148 | created_at AS "createdAt", |
| 140 | expires_at AS "expiresAt", | 149 | expires_at AS "expiresAt", |
| 141 | consumed_at AS "consumedAt", | 150 | consumed_at AS "consumedAt", |
apps/server/src/persistence/Migrations.ts+2| ... | @@ -44,6 +44,7 @@ import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts | ... | @@ -44,6 +44,7 @@ import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts |
| 44 | import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; | 44 | import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; |
| 45 | import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; | 45 | import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; |
| 46 | import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; | 46 | import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; |
| 47 | import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; | ||
| 47 | 48 | ||
| 48 | /** | 49 | /** |
| 49 | * Migration loader with all migrations defined inline. | 50 | * Migration loader with all migrations defined inline. |
| ... | @@ -87,6 +88,7 @@ export const migrationEntries = [ | ... | @@ -87,6 +88,7 @@ export const migrationEntries = [ |
| 87 | [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], | 88 | [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], |
| 88 | [30, "ProjectionThreadShellArchiveIndexes", Migration0030], | 89 | [30, "ProjectionThreadShellArchiveIndexes", Migration0030], |
| 89 | [31, "AuthAuthorizationScopes", Migration0031], | 90 | [31, "AuthAuthorizationScopes", Migration0031], |
| 91 | [32, "AuthPairingProofKeyThumbprint", Migration0032], | ||
| 90 | ] as const; | 92 | ] as const; |
| 91 | 93 | ||
| 92 | export const makeMigrationLoader = (throughId?: number) => | 94 | export const makeMigrationLoader = (throughId?: number) => |
apps/server/src/persistence/Migrations/032_AuthPairingProofKeyThumbprint.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | |||
| 7 | const pairingLinkColumns = yield* sql<{ readonly name: string }>` | ||
| 8 | PRAGMA table_info(auth_pairing_links) | ||
| 9 | `; | ||
| 10 | if (!pairingLinkColumns.some((column) => column.name === "proof_key_thumbprint")) { | ||
| 11 | yield* sql` | ||
| 12 | ALTER TABLE auth_pairing_links | ||
| 13 | ADD COLUMN proof_key_thumbprint TEXT | ||
| 14 | `; | ||
| 15 | } | ||
| 16 | }); | ||
apps/server/src/persistence/Services/AuthPairingLinks.ts+3| ... | @@ -13,6 +13,7 @@ export const AuthPairingLinkRecord = Schema.Struct({ | ... | @@ -13,6 +13,7 @@ export const AuthPairingLinkRecord = Schema.Struct({ |
| 13 | scopes: Schema.fromJsonString(AuthEnvironmentScopes), | 13 | scopes: Schema.fromJsonString(AuthEnvironmentScopes), |
| 14 | subject: Schema.String, | 14 | subject: Schema.String, |
| 15 | label: Schema.NullOr(Schema.String), | 15 | label: Schema.NullOr(Schema.String), |
| 16 | proofKeyThumbprint: Schema.NullOr(Schema.String), | ||
| 16 | createdAt: Schema.DateTimeUtcFromString, | 17 | createdAt: Schema.DateTimeUtcFromString, |
| 17 | expiresAt: Schema.DateTimeUtcFromString, | 18 | expiresAt: Schema.DateTimeUtcFromString, |
| 18 | consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | 19 | consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), |
| ... | @@ -27,6 +28,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ | ... | @@ -27,6 +28,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({ |
| 27 | scopes: AuthEnvironmentScopes, | 28 | scopes: AuthEnvironmentScopes, |
| 28 | subject: Schema.String, | 29 | subject: Schema.String, |
| 29 | label: Schema.NullOr(Schema.String), | 30 | label: Schema.NullOr(Schema.String), |
| 31 | proofKeyThumbprint: Schema.NullOr(Schema.String), | ||
| 30 | createdAt: Schema.DateTimeUtcFromString, | 32 | createdAt: Schema.DateTimeUtcFromString, |
| 31 | expiresAt: Schema.DateTimeUtcFromString, | 33 | expiresAt: Schema.DateTimeUtcFromString, |
| 32 | }); | 34 | }); |
| ... | @@ -34,6 +36,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; | ... | @@ -34,6 +36,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; |
| 34 | 36 | ||
| 35 | export const ConsumeAuthPairingLinkInput = Schema.Struct({ | 37 | export const ConsumeAuthPairingLinkInput = Schema.Struct({ |
| 36 | credential: Schema.String, | 38 | credential: Schema.String, |
| 39 | proofKeyThumbprint: Schema.NullOr(Schema.String), | ||
| 37 | consumedAt: Schema.DateTimeUtcFromString, | 40 | consumedAt: Schema.DateTimeUtcFromString, |
| 38 | now: Schema.DateTimeUtcFromString, | 41 | now: Schema.DateTimeUtcFromString, |
| 39 | }); | 42 | }); |
apps/server/src/provider/Errors.ts+11-11| ... | @@ -11,7 +11,7 @@ export class ProviderAdapterValidationError extends Schema.TaggedErrorClass<Prov | ... | @@ -11,7 +11,7 @@ export class ProviderAdapterValidationError extends Schema.TaggedErrorClass<Prov |
| 11 | provider: Schema.String, | 11 | provider: Schema.String, |
| 12 | operation: Schema.String, | 12 | operation: Schema.String, |
| 13 | issue: Schema.String, | 13 | issue: Schema.String, |
| 14 | cause: Schema.optional(Schema.Defect), | 14 | cause: Schema.optional(Schema.Defect()), |
| 15 | }, | 15 | }, |
| 16 | ) { | 16 | ) { |
| 17 | override get message(): string { | 17 | override get message(): string { |
| ... | @@ -27,7 +27,7 @@ export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass | ... | @@ -27,7 +27,7 @@ export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass |
| 27 | { | 27 | { |
| 28 | provider: Schema.String, | 28 | provider: Schema.String, |
| 29 | threadId: Schema.String, | 29 | threadId: Schema.String, |
| 30 | cause: Schema.optional(Schema.Defect), | 30 | cause: Schema.optional(Schema.Defect()), |
| 31 | }, | 31 | }, |
| 32 | ) { | 32 | ) { |
| 33 | override get message(): string { | 33 | override get message(): string { |
| ... | @@ -43,7 +43,7 @@ export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass<P | ... | @@ -43,7 +43,7 @@ export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass<P |
| 43 | { | 43 | { |
| 44 | provider: Schema.String, | 44 | provider: Schema.String, |
| 45 | threadId: Schema.String, | 45 | threadId: Schema.String, |
| 46 | cause: Schema.optional(Schema.Defect), | 46 | cause: Schema.optional(Schema.Defect()), |
| 47 | }, | 47 | }, |
| 48 | ) { | 48 | ) { |
| 49 | override get message(): string { | 49 | override get message(): string { |
| ... | @@ -60,7 +60,7 @@ export class ProviderAdapterRequestError extends Schema.TaggedErrorClass<Provide | ... | @@ -60,7 +60,7 @@ export class ProviderAdapterRequestError extends Schema.TaggedErrorClass<Provide |
| 60 | provider: Schema.String, | 60 | provider: Schema.String, |
| 61 | method: Schema.String, | 61 | method: Schema.String, |
| 62 | detail: Schema.String, | 62 | detail: Schema.String, |
| 63 | cause: Schema.optional(Schema.Defect), | 63 | cause: Schema.optional(Schema.Defect()), |
| 64 | }, | 64 | }, |
| 65 | ) { | 65 | ) { |
| 66 | override get message(): string { | 66 | override get message(): string { |
| ... | @@ -77,7 +77,7 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<Provide | ... | @@ -77,7 +77,7 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<Provide |
| 77 | provider: Schema.String, | 77 | provider: Schema.String, |
| 78 | threadId: Schema.String, | 78 | threadId: Schema.String, |
| 79 | detail: Schema.String, | 79 | detail: Schema.String, |
| 80 | cause: Schema.optional(Schema.Defect), | 80 | cause: Schema.optional(Schema.Defect()), |
| 81 | }, | 81 | }, |
| 82 | ) { | 82 | ) { |
| 83 | override get message(): string { | 83 | override get message(): string { |
| ... | @@ -93,7 +93,7 @@ export class ProviderValidationError extends Schema.TaggedErrorClass<ProviderVal | ... | @@ -93,7 +93,7 @@ export class ProviderValidationError extends Schema.TaggedErrorClass<ProviderVal |
| 93 | { | 93 | { |
| 94 | operation: Schema.String, | 94 | operation: Schema.String, |
| 95 | issue: Schema.String, | 95 | issue: Schema.String, |
| 96 | cause: Schema.optional(Schema.Defect), | 96 | cause: Schema.optional(Schema.Defect()), |
| 97 | }, | 97 | }, |
| 98 | ) { | 98 | ) { |
| 99 | override get message(): string { | 99 | override get message(): string { |
| ... | @@ -108,7 +108,7 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass<ProviderUn | ... | @@ -108,7 +108,7 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass<ProviderUn |
| 108 | "ProviderUnsupportedError", | 108 | "ProviderUnsupportedError", |
| 109 | { | 109 | { |
| 110 | provider: Schema.String, | 110 | provider: Schema.String, |
| 111 | cause: Schema.optional(Schema.Defect), | 111 | cause: Schema.optional(Schema.Defect()), |
| 112 | }, | 112 | }, |
| 113 | ) { | 113 | ) { |
| 114 | override get message(): string { | 114 | override get message(): string { |
| ... | @@ -129,7 +129,7 @@ export class ProviderInstanceNotFoundError extends Schema.TaggedErrorClass<Provi | ... | @@ -129,7 +129,7 @@ export class ProviderInstanceNotFoundError extends Schema.TaggedErrorClass<Provi |
| 129 | "ProviderInstanceNotFoundError", | 129 | "ProviderInstanceNotFoundError", |
| 130 | { | 130 | { |
| 131 | instanceId: Schema.String, | 131 | instanceId: Schema.String, |
| 132 | cause: Schema.optional(Schema.Defect), | 132 | cause: Schema.optional(Schema.Defect()), |
| 133 | }, | 133 | }, |
| 134 | ) { | 134 | ) { |
| 135 | override get message(): string { | 135 | override get message(): string { |
| ... | @@ -148,7 +148,7 @@ export class ProviderDriverError extends Schema.TaggedErrorClass<ProviderDriverE | ... | @@ -148,7 +148,7 @@ export class ProviderDriverError extends Schema.TaggedErrorClass<ProviderDriverE |
| 148 | driver: Schema.String, | 148 | driver: Schema.String, |
| 149 | instanceId: Schema.String, | 149 | instanceId: Schema.String, |
| 150 | detail: Schema.String, | 150 | detail: Schema.String, |
| 151 | cause: Schema.optional(Schema.Defect), | 151 | cause: Schema.optional(Schema.Defect()), |
| 152 | }, | 152 | }, |
| 153 | ) { | 153 | ) { |
| 154 | override get message(): string { | 154 | override get message(): string { |
| ... | @@ -163,7 +163,7 @@ export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass<Provid | ... | @@ -163,7 +163,7 @@ export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass<Provid |
| 163 | "ProviderSessionNotFoundError", | 163 | "ProviderSessionNotFoundError", |
| 164 | { | 164 | { |
| 165 | threadId: Schema.String, | 165 | threadId: Schema.String, |
| 166 | cause: Schema.optional(Schema.Defect), | 166 | cause: Schema.optional(Schema.Defect()), |
| 167 | }, | 167 | }, |
| 168 | ) { | 168 | ) { |
| 169 | override get message(): string { | 169 | override get message(): string { |
| ... | @@ -179,7 +179,7 @@ export class ProviderSessionDirectoryPersistenceError extends Schema.TaggedError | ... | @@ -179,7 +179,7 @@ export class ProviderSessionDirectoryPersistenceError extends Schema.TaggedError |
| 179 | { | 179 | { |
| 180 | operation: Schema.String, | 180 | operation: Schema.String, |
| 181 | detail: Schema.String, | 181 | detail: Schema.String, |
| 182 | cause: Schema.optional(Schema.Defect), | 182 | cause: Schema.optional(Schema.Defect()), |
| 183 | }, | 183 | }, |
| 184 | ) { | 184 | ) { |
| 185 | override get message(): string { | 185 | override get message(): string { |
apps/server/src/relay/AgentAwarenessRelay.test.ts created+662| ... | @@ -0,0 +1,662 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | |||
| 4 | import type { | ||
| 5 | EnvironmentId, | ||
| 6 | ExecutionEnvironmentDescriptor, | ||
| 7 | OrchestrationEvent, | ||
| 8 | OrchestrationProjectShell, | ||
| 9 | OrchestrationShellSnapshot, | ||
| 10 | OrchestrationThreadShell, | ||
| 11 | ProjectId, | ||
| 12 | ThreadId, | ||
| 13 | TurnId, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import type { | ||
| 16 | RelayAgentActivityPublishProofPayload, | ||
| 17 | RelayAgentActivityState, | ||
| 18 | } from "@t3tools/contracts/relay"; | ||
| 19 | import { CommandId, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 20 | import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt"; | ||
| 21 | import { describe, expect, it } from "@effect/vitest"; | ||
| 22 | import * as Deferred from "effect/Deferred"; | ||
| 23 | import * as Effect from "effect/Effect"; | ||
| 24 | import * as Layer from "effect/Layer"; | ||
| 25 | import * as Option from "effect/Option"; | ||
| 26 | import * as Queue from "effect/Queue"; | ||
| 27 | import * as Stream from "effect/Stream"; | ||
| 28 | |||
| 29 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 30 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | ||
| 31 | import { | ||
| 32 | OrchestrationEngineService, | ||
| 33 | type OrchestrationEngineShape, | ||
| 34 | } from "../orchestration/Services/OrchestrationEngine.ts"; | ||
| 35 | import { | ||
| 36 | ProjectionSnapshotQuery, | ||
| 37 | type ProjectionSnapshotQueryShape, | ||
| 38 | } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 39 | import { | ||
| 40 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 41 | RELAY_ISSUER_SECRET, | ||
| 42 | RELAY_URL_SECRET, | ||
| 43 | PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 44 | } from "../cloud/config.ts"; | ||
| 45 | import * as AgentAwarenessRelay from "./AgentAwarenessRelay.ts"; | ||
| 46 | |||
| 47 | const state: RelayAgentActivityState = { | ||
| 48 | environmentId: "env" as RelayAgentActivityState["environmentId"], | ||
| 49 | threadId: "thread" as RelayAgentActivityState["threadId"], | ||
| 50 | projectTitle: "Project", | ||
| 51 | threadTitle: "Thread", | ||
| 52 | modelTitle: "gpt-5.4", | ||
| 53 | phase: "running", | ||
| 54 | headline: "Running", | ||
| 55 | updatedAt: "2026-05-25T00:00:00.000Z", | ||
| 56 | deepLink: "/threads/env/thread", | ||
| 57 | }; | ||
| 58 | |||
| 59 | const encodeSecret = (value: string): Uint8Array => new TextEncoder().encode(value); | ||
| 60 | |||
| 61 | function makeMemorySecretStore() { | ||
| 62 | const values = new Map<string, Uint8Array>(); | ||
| 63 | const store = { | ||
| 64 | get: ((name) => | ||
| 65 | Effect.sync( | ||
| 66 | () => values.get(name) ?? null, | ||
| 67 | )) satisfies ServerSecretStore.ServerSecretStoreShape["get"], | ||
| 68 | set: ((name, value) => | ||
| 69 | Effect.sync(() => { | ||
| 70 | values.set(name, Uint8Array.from(value)); | ||
| 71 | })) satisfies ServerSecretStore.ServerSecretStoreShape["set"], | ||
| 72 | create: ((name, value) => | ||
| 73 | Effect.sync(() => { | ||
| 74 | values.set(name, Uint8Array.from(value)); | ||
| 75 | })) satisfies ServerSecretStore.ServerSecretStoreShape["create"], | ||
| 76 | getOrCreateRandom: ((name, bytes) => | ||
| 77 | Effect.sync(() => { | ||
| 78 | const existing = values.get(name); | ||
| 79 | if (existing) { | ||
| 80 | return existing; | ||
| 81 | } | ||
| 82 | const generated = new Uint8Array(bytes); | ||
| 83 | values.set(name, generated); | ||
| 84 | return generated; | ||
| 85 | })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"], | ||
| 86 | remove: ((name) => | ||
| 87 | Effect.sync(() => { | ||
| 88 | values.delete(name); | ||
| 89 | })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"], | ||
| 90 | } satisfies ServerSecretStore.ServerSecretStoreShape; | ||
| 91 | return { | ||
| 92 | store, | ||
| 93 | setString: (name: string, value: string) => store.set(name, encodeSecret(value)), | ||
| 94 | }; | ||
| 95 | } | ||
| 96 | |||
| 97 | describe.sequential("signRelayAgentActivityPublishProof", () => { | ||
| 98 | it("derives the thread id from the aggregate id for thread events without payload thread ids", () => { | ||
| 99 | const threadId = "thread-aggregate-1" as ThreadId; | ||
| 100 | const now = "2026-05-25T00:00:00.000Z"; | ||
| 101 | const event = { | ||
| 102 | type: "thread.activity-appended", | ||
| 103 | sequence: 1, | ||
| 104 | eventId: "evt-aggregate-1", | ||
| 105 | commandId: CommandId.make("cmd-1"), | ||
| 106 | aggregateKind: "thread", | ||
| 107 | aggregateId: threadId, | ||
| 108 | actor: { kind: "server" }, | ||
| 109 | payload: {}, | ||
| 110 | occurredAt: now, | ||
| 111 | } as unknown as OrchestrationEvent; | ||
| 112 | |||
| 113 | expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it("does not publish streaming content or non-awareness activity events", () => { | ||
| 117 | const now = "2026-05-25T00:00:00.000Z"; | ||
| 118 | const base = { | ||
| 119 | sequence: 1, | ||
| 120 | eventId: "evt-1", | ||
| 121 | commandId: CommandId.make("cmd-1"), | ||
| 122 | aggregateKind: "thread", | ||
| 123 | aggregateId: "thread-1" as ThreadId, | ||
| 124 | occurredAt: now, | ||
| 125 | }; | ||
| 126 | |||
| 127 | expect( | ||
| 128 | AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ | ||
| 129 | ...base, | ||
| 130 | type: "thread.message-sent", | ||
| 131 | payload: { | ||
| 132 | threadId: "thread-1" as ThreadId, | ||
| 133 | streaming: true, | ||
| 134 | }, | ||
| 135 | } as unknown as OrchestrationEvent), | ||
| 136 | ).toBe(false); | ||
| 137 | expect( | ||
| 138 | AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ | ||
| 139 | ...base, | ||
| 140 | type: "thread.activity-appended", | ||
| 141 | payload: { | ||
| 142 | threadId: "thread-1" as ThreadId, | ||
| 143 | activity: { | ||
| 144 | kind: "task.progress", | ||
| 145 | }, | ||
| 146 | }, | ||
| 147 | } as unknown as OrchestrationEvent), | ||
| 148 | ).toBe(false); | ||
| 149 | expect( | ||
| 150 | AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ | ||
| 151 | ...base, | ||
| 152 | type: "thread.activity-appended", | ||
| 153 | payload: { | ||
| 154 | threadId: "thread-1" as ThreadId, | ||
| 155 | activity: { | ||
| 156 | kind: "approval.requested", | ||
| 157 | }, | ||
| 158 | }, | ||
| 159 | } as unknown as OrchestrationEvent), | ||
| 160 | ).toBe(true); | ||
| 161 | expect( | ||
| 162 | AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ | ||
| 163 | ...base, | ||
| 164 | type: "thread.message-sent", | ||
| 165 | payload: { | ||
| 166 | threadId: "thread-1" as ThreadId, | ||
| 167 | streaming: false, | ||
| 168 | }, | ||
| 169 | } as unknown as OrchestrationEvent), | ||
| 170 | ).toBe(true); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("deduplicates awareness state updates whose only change is their event timestamp", () => { | ||
| 174 | expect(AgentAwarenessRelay.agentAwarenessPublishIdentity(state)).toBe( | ||
| 175 | AgentAwarenessRelay.agentAwarenessPublishIdentity({ | ||
| 176 | ...state, | ||
| 177 | updatedAt: "2026-05-25T00:10:00.000Z", | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | expect(AgentAwarenessRelay.agentAwarenessPublishIdentity(state)).not.toBe( | ||
| 181 | AgentAwarenessRelay.agentAwarenessPublishIdentity({ | ||
| 182 | ...state, | ||
| 183 | phase: "completed", | ||
| 184 | headline: "Agent finished", | ||
| 185 | }), | ||
| 186 | ); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("requires an explicit opt-in before publishing agent activity", () => { | ||
| 190 | expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled(null)).toBe(false); | ||
| 191 | expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("false")).toBe(false); | ||
| 192 | expect(AgentAwarenessRelay.isAgentActivityPublishingEnabled("true")).toBe(true); | ||
| 193 | }); | ||
| 194 | |||
| 195 | it("redacts failed activity details and caps other relay detail", () => { | ||
| 196 | expect( | ||
| 197 | AgentAwarenessRelay.sanitizeRelayAgentActivityState({ | ||
| 198 | ...state, | ||
| 199 | phase: "failed", | ||
| 200 | detail: "Provider process exited with secret token.", | ||
| 201 | }), | ||
| 202 | ).toMatchObject({ | ||
| 203 | phase: "failed", | ||
| 204 | detail: "The agent run failed.", | ||
| 205 | }); | ||
| 206 | expect( | ||
| 207 | AgentAwarenessRelay.sanitizeRelayAgentActivityState({ | ||
| 208 | ...state, | ||
| 209 | detail: "x".repeat(200), | ||
| 210 | })?.detail, | ||
| 211 | ).toHaveLength(160); | ||
| 212 | }); | ||
| 213 | |||
| 214 | it("resolves a null publish state when a thread or project snapshot disappeared", () => { | ||
| 215 | const environmentId = "env-1" as EnvironmentId; | ||
| 216 | const threadId = "thread-1" as ThreadId; | ||
| 217 | const thread = { | ||
| 218 | id: threadId, | ||
| 219 | projectId: "project-1" as ProjectId, | ||
| 220 | title: "Deleted thread", | ||
| 221 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 222 | session: null, | ||
| 223 | latestTurn: null, | ||
| 224 | updatedAt: "2026-05-25T00:00:00.000Z", | ||
| 225 | hasPendingApprovals: false, | ||
| 226 | hasPendingUserInput: false, | ||
| 227 | } as OrchestrationThreadShell; | ||
| 228 | |||
| 229 | expect( | ||
| 230 | AgentAwarenessRelay.resolveAgentAwarenessRelayPublishSnapshot({ | ||
| 231 | environmentId, | ||
| 232 | threadId, | ||
| 233 | thread: Option.none(), | ||
| 234 | project: Option.none(), | ||
| 235 | }), | ||
| 236 | ).toEqual({ | ||
| 237 | projectId: null, | ||
| 238 | state: null, | ||
| 239 | reason: "thread-not-found", | ||
| 240 | }); | ||
| 241 | |||
| 242 | expect( | ||
| 243 | AgentAwarenessRelay.resolveAgentAwarenessRelayPublishSnapshot({ | ||
| 244 | environmentId, | ||
| 245 | threadId, | ||
| 246 | thread: Option.some(thread), | ||
| 247 | project: Option.none(), | ||
| 248 | }), | ||
| 249 | ).toEqual({ | ||
| 250 | projectId: "project-1", | ||
| 251 | state: null, | ||
| 252 | reason: "project-not-found", | ||
| 253 | }); | ||
| 254 | }); | ||
| 255 | |||
| 256 | it("selects only active shell snapshot threads for startup catch-up", () => { | ||
| 257 | const now = "2026-05-25T00:00:00.000Z"; | ||
| 258 | const environmentId = "env-1" as EnvironmentId; | ||
| 259 | const projectId = "project-1" as ProjectId; | ||
| 260 | const activeThreadId = "thread-active" as ThreadId; | ||
| 261 | const idleThreadId = "thread-idle" as ThreadId; | ||
| 262 | |||
| 263 | const baseThread = { | ||
| 264 | projectId, | ||
| 265 | title: "Run remote agent", | ||
| 266 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 267 | runtimeMode: "full-access", | ||
| 268 | interactionMode: "default", | ||
| 269 | branch: null, | ||
| 270 | worktreePath: null, | ||
| 271 | latestTurn: null, | ||
| 272 | createdAt: now, | ||
| 273 | updatedAt: now, | ||
| 274 | archivedAt: null, | ||
| 275 | session: null, | ||
| 276 | latestUserMessageAt: null, | ||
| 277 | hasPendingApprovals: false, | ||
| 278 | hasPendingUserInput: false, | ||
| 279 | hasActionableProposedPlan: false, | ||
| 280 | } satisfies Omit<OrchestrationThreadShell, "id">; | ||
| 281 | |||
| 282 | expect( | ||
| 283 | AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ | ||
| 284 | environmentId, | ||
| 285 | projects: [ | ||
| 286 | { | ||
| 287 | id: projectId, | ||
| 288 | title: "T3 Code", | ||
| 289 | }, | ||
| 290 | ], | ||
| 291 | threads: [ | ||
| 292 | { | ||
| 293 | ...baseThread, | ||
| 294 | id: activeThreadId, | ||
| 295 | latestTurn: { | ||
| 296 | turnId: "turn-1" as TurnId, | ||
| 297 | state: "running", | ||
| 298 | requestedAt: now, | ||
| 299 | startedAt: now, | ||
| 300 | completedAt: null, | ||
| 301 | assistantMessageId: null, | ||
| 302 | }, | ||
| 303 | }, | ||
| 304 | { | ||
| 305 | ...baseThread, | ||
| 306 | id: idleThreadId, | ||
| 307 | }, | ||
| 308 | { | ||
| 309 | ...baseThread, | ||
| 310 | id: "thread-missing-project" as ThreadId, | ||
| 311 | projectId: "missing-project" as ProjectId, | ||
| 312 | latestTurn: { | ||
| 313 | turnId: "turn-2" as TurnId, | ||
| 314 | state: "running", | ||
| 315 | requestedAt: now, | ||
| 316 | startedAt: now, | ||
| 317 | completedAt: null, | ||
| 318 | assistantMessageId: null, | ||
| 319 | }, | ||
| 320 | }, | ||
| 321 | ], | ||
| 322 | }), | ||
| 323 | ).toEqual([activeThreadId]); | ||
| 324 | }); | ||
| 325 | |||
| 326 | it("signs the activity publish JWT and rejects tampering", async () => { | ||
| 327 | const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 328 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 329 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 330 | }); | ||
| 331 | const payload = { | ||
| 332 | iss: "t3-env:env", | ||
| 333 | aud: "https://relay.example.test", | ||
| 334 | sub: "env", | ||
| 335 | jti: "nonce-1", | ||
| 336 | iat: 100, | ||
| 337 | exp: 200, | ||
| 338 | environmentId: state.environmentId, | ||
| 339 | threadId: state.threadId, | ||
| 340 | state, | ||
| 341 | } satisfies RelayAgentActivityPublishProofPayload; | ||
| 342 | const proof = await Effect.runPromise( | ||
| 343 | AgentAwarenessRelay.signRelayAgentActivityPublishProof({ | ||
| 344 | privateKey: keyPair.privateKey, | ||
| 345 | payload, | ||
| 346 | }), | ||
| 347 | ); | ||
| 348 | |||
| 349 | await expect( | ||
| 350 | Effect.runPromise( | ||
| 351 | verifyRelayJwt({ | ||
| 352 | publicKey: keyPair.publicKey, | ||
| 353 | token: proof, | ||
| 354 | typ: RELAY_ACTIVITY_PUBLISH_TYP, | ||
| 355 | issuer: "t3-env:env", | ||
| 356 | audience: "https://relay.example.test", | ||
| 357 | nowEpochSeconds: 150, | ||
| 358 | }), | ||
| 359 | ), | ||
| 360 | ).resolves.toMatchObject({ jti: "nonce-1", state }); | ||
| 361 | await expect( | ||
| 362 | Effect.runPromise( | ||
| 363 | verifyRelayJwt({ | ||
| 364 | publicKey: keyPair.publicKey, | ||
| 365 | token: (() => { | ||
| 366 | const [header, body, signature = ""] = proof.split("."); | ||
| 367 | const corruptedSignature = `${signature.startsWith("a") ? "b" : "a"}${signature.slice(1)}`; | ||
| 368 | return `${header}.${body}.${corruptedSignature}`; | ||
| 369 | })(), | ||
| 370 | typ: RELAY_ACTIVITY_PUBLISH_TYP, | ||
| 371 | issuer: "t3-env:env", | ||
| 372 | audience: "https://relay.example.test", | ||
| 373 | nowEpochSeconds: 150, | ||
| 374 | }), | ||
| 375 | ), | ||
| 376 | ).rejects.toBeDefined(); | ||
| 377 | }); | ||
| 378 | |||
| 379 | it.effect("keeps the orchestration listener armed until relay config is installed", () => | ||
| 380 | Effect.scoped( | ||
| 381 | Effect.gen(function* () { | ||
| 382 | const events = yield* Queue.unbounded<OrchestrationEvent>(); | ||
| 383 | const threadShellRequested = yield* Deferred.make<void>(); | ||
| 384 | const secrets = makeMemorySecretStore(); | ||
| 385 | const now = "2026-05-25T00:00:00.000Z"; | ||
| 386 | const projectId = "project-1" as ProjectId; | ||
| 387 | const threadId = "thread-1" as ThreadId; | ||
| 388 | const environmentId = "env-1" as EnvironmentId; | ||
| 389 | |||
| 390 | const project = { | ||
| 391 | id: projectId, | ||
| 392 | title: "T3 Code", | ||
| 393 | workspaceRoot: "/workspace", | ||
| 394 | repositoryIdentity: null, | ||
| 395 | defaultModelSelection: null, | ||
| 396 | scripts: [], | ||
| 397 | createdAt: now, | ||
| 398 | updatedAt: now, | ||
| 399 | } satisfies OrchestrationProjectShell; | ||
| 400 | |||
| 401 | const thread = { | ||
| 402 | id: threadId, | ||
| 403 | projectId, | ||
| 404 | title: "Run remote agent", | ||
| 405 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 406 | runtimeMode: "full-access", | ||
| 407 | interactionMode: "default", | ||
| 408 | branch: null, | ||
| 409 | worktreePath: null, | ||
| 410 | latestTurn: { | ||
| 411 | turnId: "turn-1" as TurnId, | ||
| 412 | state: "running", | ||
| 413 | requestedAt: now, | ||
| 414 | startedAt: now, | ||
| 415 | completedAt: null, | ||
| 416 | assistantMessageId: null, | ||
| 417 | }, | ||
| 418 | createdAt: now, | ||
| 419 | updatedAt: now, | ||
| 420 | archivedAt: null, | ||
| 421 | session: { | ||
| 422 | threadId, | ||
| 423 | status: "running", | ||
| 424 | providerName: "Codex", | ||
| 425 | runtimeMode: "full-access", | ||
| 426 | activeTurnId: "turn-1" as TurnId, | ||
| 427 | lastError: null, | ||
| 428 | updatedAt: now, | ||
| 429 | }, | ||
| 430 | latestUserMessageAt: now, | ||
| 431 | hasPendingApprovals: false, | ||
| 432 | hasPendingUserInput: false, | ||
| 433 | hasActionableProposedPlan: false, | ||
| 434 | } satisfies OrchestrationThreadShell; | ||
| 435 | |||
| 436 | const orchestrationEngine = { | ||
| 437 | readEvents: () => Stream.empty, | ||
| 438 | dispatch: () => Effect.succeed({ sequence: 1 }), | ||
| 439 | streamDomainEvents: Stream.fromQueue(events), | ||
| 440 | } satisfies OrchestrationEngineShape; | ||
| 441 | |||
| 442 | const snapshotQuery = { | ||
| 443 | getShellSnapshot: () => | ||
| 444 | Effect.succeed({ | ||
| 445 | snapshotSequence: 1, | ||
| 446 | projects: [project], | ||
| 447 | threads: [thread], | ||
| 448 | updatedAt: now, | ||
| 449 | } satisfies OrchestrationShellSnapshot), | ||
| 450 | getThreadShellById: () => | ||
| 451 | Deferred.succeed(threadShellRequested, undefined).pipe( | ||
| 452 | Effect.ignore, | ||
| 453 | Effect.as(Option.some(thread)), | ||
| 454 | ), | ||
| 455 | getProjectShellById: () => Effect.succeed(Option.some(project)), | ||
| 456 | } as unknown as ProjectionSnapshotQueryShape; | ||
| 457 | |||
| 458 | const descriptor = { | ||
| 459 | environmentId, | ||
| 460 | label: "Test Desktop", | ||
| 461 | platform: { | ||
| 462 | os: "darwin", | ||
| 463 | arch: "arm64", | ||
| 464 | }, | ||
| 465 | serverVersion: "0.0.0-test", | ||
| 466 | capabilities: { | ||
| 467 | repositoryIdentity: true, | ||
| 468 | }, | ||
| 469 | } satisfies ExecutionEnvironmentDescriptor; | ||
| 470 | |||
| 471 | const layer = Layer.mergeAll( | ||
| 472 | Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), | ||
| 473 | Layer.succeed(ServerEnvironment, { | ||
| 474 | getEnvironmentId: Effect.succeed(environmentId), | ||
| 475 | getDescriptor: Effect.succeed(descriptor), | ||
| 476 | }), | ||
| 477 | Layer.succeed(OrchestrationEngineService, orchestrationEngine), | ||
| 478 | Layer.succeed(ProjectionSnapshotQuery, snapshotQuery), | ||
| 479 | ); | ||
| 480 | |||
| 481 | yield* Effect.gen(function* () { | ||
| 482 | const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; | ||
| 483 | yield* relay.start(); | ||
| 484 | yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1"); | ||
| 485 | yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); | ||
| 486 | yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); | ||
| 487 | yield* Queue.offer(events, { | ||
| 488 | type: "thread.activity-appended", | ||
| 489 | sequence: 1, | ||
| 490 | eventId: "evt-1", | ||
| 491 | commandId: CommandId.make("cmd-1"), | ||
| 492 | aggregateKind: "thread", | ||
| 493 | aggregateId: threadId, | ||
| 494 | actor: { kind: "server" }, | ||
| 495 | payload: { | ||
| 496 | threadId, | ||
| 497 | activity: { | ||
| 498 | kind: "approval.requested", | ||
| 499 | }, | ||
| 500 | }, | ||
| 501 | occurredAt: now, | ||
| 502 | } as unknown as OrchestrationEvent); | ||
| 503 | |||
| 504 | yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds")); | ||
| 505 | }).pipe( | ||
| 506 | Effect.provide( | ||
| 507 | AgentAwarenessRelay.layer.pipe( | ||
| 508 | Layer.provide(layer), | ||
| 509 | Layer.provideMerge(NodeServices.layer), | ||
| 510 | ), | ||
| 511 | ), | ||
| 512 | ); | ||
| 513 | }), | ||
| 514 | ), | ||
| 515 | ); | ||
| 516 | |||
| 517 | it.effect("publishes agent activity to the relay transport URL, not the relay issuer", () => | ||
| 518 | Effect.scoped( | ||
| 519 | Effect.gen(function* () { | ||
| 520 | const originalFetch = globalThis.fetch; | ||
| 521 | const context = yield* Effect.context<never>(); | ||
| 522 | const runFork = Effect.runForkWith(context); | ||
| 523 | const events = yield* Queue.unbounded<OrchestrationEvent>(); | ||
| 524 | const fetchSeen = yield* Deferred.make<URL>(); | ||
| 525 | const secrets = makeMemorySecretStore(); | ||
| 526 | const now = "2026-05-25T00:00:00.000Z"; | ||
| 527 | const projectId = "project-1" as ProjectId; | ||
| 528 | const threadId = "thread-1" as ThreadId; | ||
| 529 | const environmentId = "env-1" as EnvironmentId; | ||
| 530 | |||
| 531 | const project = { | ||
| 532 | id: projectId, | ||
| 533 | title: "T3 Code", | ||
| 534 | workspaceRoot: "/workspace", | ||
| 535 | repositoryIdentity: null, | ||
| 536 | defaultModelSelection: null, | ||
| 537 | scripts: [], | ||
| 538 | createdAt: now, | ||
| 539 | updatedAt: now, | ||
| 540 | } satisfies OrchestrationProjectShell; | ||
| 541 | |||
| 542 | const thread = { | ||
| 543 | id: threadId, | ||
| 544 | projectId, | ||
| 545 | title: "Run remote agent", | ||
| 546 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 547 | runtimeMode: "full-access", | ||
| 548 | interactionMode: "default", | ||
| 549 | branch: null, | ||
| 550 | worktreePath: null, | ||
| 551 | latestTurn: { | ||
| 552 | turnId: "turn-1" as TurnId, | ||
| 553 | state: "running", | ||
| 554 | requestedAt: now, | ||
| 555 | startedAt: now, | ||
| 556 | completedAt: null, | ||
| 557 | assistantMessageId: null, | ||
| 558 | }, | ||
| 559 | createdAt: now, | ||
| 560 | updatedAt: now, | ||
| 561 | archivedAt: null, | ||
| 562 | session: { | ||
| 563 | threadId, | ||
| 564 | status: "running", | ||
| 565 | providerName: "Codex", | ||
| 566 | runtimeMode: "full-access", | ||
| 567 | activeTurnId: "turn-1" as TurnId, | ||
| 568 | lastError: null, | ||
| 569 | updatedAt: now, | ||
| 570 | }, | ||
| 571 | latestUserMessageAt: now, | ||
| 572 | hasPendingApprovals: false, | ||
| 573 | hasPendingUserInput: false, | ||
| 574 | hasActionableProposedPlan: false, | ||
| 575 | } satisfies OrchestrationThreadShell; | ||
| 576 | |||
| 577 | const descriptor = { | ||
| 578 | environmentId, | ||
| 579 | label: "Test Desktop", | ||
| 580 | platform: { | ||
| 581 | os: "darwin", | ||
| 582 | arch: "arm64", | ||
| 583 | }, | ||
| 584 | serverVersion: "0.0.0-test", | ||
| 585 | capabilities: { | ||
| 586 | repositoryIdentity: true, | ||
| 587 | }, | ||
| 588 | } satisfies ExecutionEnvironmentDescriptor; | ||
| 589 | |||
| 590 | globalThis.fetch = ((input: Parameters<typeof fetch>[0]) => { | ||
| 591 | const url = new URL(input instanceof Request ? input.url : input.toString()); | ||
| 592 | runFork(Deferred.succeed(fetchSeen, url)); | ||
| 593 | return Promise.resolve(Response.json({ ok: true, deliveries: [] })); | ||
| 594 | }) as unknown as typeof fetch; | ||
| 595 | yield* Effect.addFinalizer(() => | ||
| 596 | Effect.sync(() => { | ||
| 597 | globalThis.fetch = originalFetch; | ||
| 598 | }), | ||
| 599 | ); | ||
| 600 | |||
| 601 | const layer = Layer.mergeAll( | ||
| 602 | Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), | ||
| 603 | Layer.succeed(ServerEnvironment, { | ||
| 604 | getEnvironmentId: Effect.succeed(environmentId), | ||
| 605 | getDescriptor: Effect.succeed(descriptor), | ||
| 606 | }), | ||
| 607 | Layer.succeed(OrchestrationEngineService, { | ||
| 608 | readEvents: () => Stream.empty, | ||
| 609 | dispatch: () => Effect.succeed({ sequence: 1 }), | ||
| 610 | streamDomainEvents: Stream.fromQueue(events), | ||
| 611 | } satisfies OrchestrationEngineShape), | ||
| 612 | Layer.succeed(ProjectionSnapshotQuery, { | ||
| 613 | getShellSnapshot: () => | ||
| 614 | Effect.succeed({ | ||
| 615 | snapshotSequence: 1, | ||
| 616 | projects: [project], | ||
| 617 | threads: [thread], | ||
| 618 | updatedAt: now, | ||
| 619 | } satisfies OrchestrationShellSnapshot), | ||
| 620 | getThreadShellById: () => Effect.succeed(Option.some(thread)), | ||
| 621 | getProjectShellById: () => Effect.succeed(Option.some(project)), | ||
| 622 | } as unknown as ProjectionSnapshotQueryShape), | ||
| 623 | ); | ||
| 624 | |||
| 625 | yield* Effect.gen(function* () { | ||
| 626 | const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; | ||
| 627 | yield* secrets.setString(RELAY_URL_SECRET, "https://transport.example.test"); | ||
| 628 | yield* secrets.setString(RELAY_ISSUER_SECRET, "https://issuer.example.test"); | ||
| 629 | yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); | ||
| 630 | yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); | ||
| 631 | yield* relay.start(); | ||
| 632 | yield* Queue.offer(events, { | ||
| 633 | type: "thread.activity-appended", | ||
| 634 | sequence: 1, | ||
| 635 | eventId: "evt-1", | ||
| 636 | commandId: CommandId.make("cmd-1"), | ||
| 637 | aggregateKind: "thread", | ||
| 638 | aggregateId: threadId, | ||
| 639 | actor: { kind: "server" }, | ||
| 640 | payload: { | ||
| 641 | threadId, | ||
| 642 | activity: { | ||
| 643 | kind: "approval.requested", | ||
| 644 | }, | ||
| 645 | }, | ||
| 646 | occurredAt: now, | ||
| 647 | } as unknown as OrchestrationEvent); | ||
| 648 | |||
| 649 | const url = yield* Deferred.await(fetchSeen).pipe(Effect.timeout("2 seconds")); | ||
| 650 | expect(url.origin).toBe("https://transport.example.test"); | ||
| 651 | }).pipe( | ||
| 652 | Effect.provide( | ||
| 653 | AgentAwarenessRelay.layer.pipe( | ||
| 654 | Layer.provide(layer), | ||
| 655 | Layer.provideMerge(NodeServices.layer), | ||
| 656 | ), | ||
| 657 | ), | ||
| 658 | ); | ||
| 659 | }), | ||
| 660 | ), | ||
| 661 | ); | ||
| 662 | }); | ||
apps/server/src/relay/AgentAwarenessRelay.ts created+503| ... | @@ -0,0 +1,503 @@ | ||
| 1 | import { | ||
| 2 | RelayApi, | ||
| 3 | type RelayAgentActivityPublishProofPayload, | ||
| 4 | type RelayAgentActivityState, | ||
| 5 | } from "@t3tools/contracts/relay"; | ||
| 6 | import type { | ||
| 7 | EnvironmentId, | ||
| 8 | OrchestrationEvent, | ||
| 9 | OrchestrationProjectShell, | ||
| 10 | OrchestrationThreadShell, | ||
| 11 | ThreadId, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import { projectThreadAwareness } from "@t3tools/shared/agentAwareness"; | ||
| 14 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | ||
| 15 | import { | ||
| 16 | RELAY_ACTIVITY_PUBLISH_TYP, | ||
| 17 | signRelayJwt, | ||
| 18 | normalizeRelayIssuer, | ||
| 19 | } from "@t3tools/shared/relayJwt"; | ||
| 20 | import * as Cause from "effect/Cause"; | ||
| 21 | import * as Context from "effect/Context"; | ||
| 22 | import * as Crypto from "effect/Crypto"; | ||
| 23 | import * as DateTime from "effect/DateTime"; | ||
| 24 | import * as Effect from "effect/Effect"; | ||
| 25 | import * as Layer from "effect/Layer"; | ||
| 26 | import * as Option from "effect/Option"; | ||
| 27 | import * as Ref from "effect/Ref"; | ||
| 28 | import type * as Scope from "effect/Scope"; | ||
| 29 | import * as Stream from "effect/Stream"; | ||
| 30 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 31 | import * as HttpClient from "effect/unstable/http/HttpClient"; | ||
| 32 | import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; | ||
| 33 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | ||
| 34 | |||
| 35 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 36 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; | ||
| 37 | import { | ||
| 38 | PUBLISH_AGENT_ACTIVITY_SECRET, | ||
| 39 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | ||
| 40 | RELAY_ISSUER_SECRET, | ||
| 41 | RELAY_URL_SECRET, | ||
| 42 | } from "../cloud/config.ts"; | ||
| 43 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | ||
| 44 | import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; | ||
| 45 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 46 | |||
| 47 | export interface AgentAwarenessRelayShape { | ||
| 48 | readonly publishThread: (threadId: ThreadId) => Effect.Effect<void>; | ||
| 49 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; | ||
| 50 | } | ||
| 51 | |||
| 52 | export class AgentAwarenessRelay extends Context.Service< | ||
| 53 | AgentAwarenessRelay, | ||
| 54 | AgentAwarenessRelayShape | ||
| 55 | >()("t3/relay/AgentAwarenessRelay") {} | ||
| 56 | |||
| 57 | export function eventThreadId(event: OrchestrationEvent): ThreadId | null { | ||
| 58 | const payload = event.payload as { readonly threadId?: unknown }; | ||
| 59 | if (typeof payload.threadId === "string") { | ||
| 60 | return payload.threadId as ThreadId; | ||
| 61 | } | ||
| 62 | if (event.aggregateKind === "thread" && typeof event.aggregateId === "string") { | ||
| 63 | return event.aggregateId as ThreadId; | ||
| 64 | } | ||
| 65 | return null; | ||
| 66 | } | ||
| 67 | |||
| 68 | export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { | ||
| 69 | switch (event.type) { | ||
| 70 | case "thread.message-sent": | ||
| 71 | return !event.payload.streaming; | ||
| 72 | case "thread.proposed-plan-upserted": | ||
| 73 | case "thread.runtime-mode-set": | ||
| 74 | case "thread.interaction-mode-set": | ||
| 75 | return false; | ||
| 76 | case "thread.activity-appended": | ||
| 77 | return ( | ||
| 78 | event.payload.activity.kind === "approval.requested" || | ||
| 79 | event.payload.activity.kind === "approval.resolved" || | ||
| 80 | event.payload.activity.kind === "provider.approval.respond.failed" || | ||
| 81 | event.payload.activity.kind === "user-input.requested" || | ||
| 82 | event.payload.activity.kind === "user-input.resolved" || | ||
| 83 | event.payload.activity.kind === "runtime.error" | ||
| 84 | ); | ||
| 85 | default: | ||
| 86 | return true; | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): string { | ||
| 91 | if (state === null) { | ||
| 92 | return "null"; | ||
| 93 | } | ||
| 94 | const { updatedAt: _updatedAt, ...meaningfulState } = state; | ||
| 95 | return JSON.stringify(meaningfulState); | ||
| 96 | } | ||
| 97 | |||
| 98 | export function isAgentActivityPublishingEnabled(value: string | null): boolean { | ||
| 99 | return value === "true"; | ||
| 100 | } | ||
| 101 | |||
| 102 | const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160; | ||
| 103 | const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed."; | ||
| 104 | |||
| 105 | export function sanitizeRelayAgentActivityState( | ||
| 106 | state: RelayAgentActivityState | null, | ||
| 107 | ): RelayAgentActivityState | null { | ||
| 108 | if (state === null) { | ||
| 109 | return null; | ||
| 110 | } | ||
| 111 | const { detail: _detail, ...rest } = state; | ||
| 112 | const detail = (state.phase === "failed" ? REDACTED_RELAY_AGENT_FAILURE_DETAIL : state.detail) | ||
| 113 | ?.trim() | ||
| 114 | .slice(0, RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH) | ||
| 115 | .trim(); | ||
| 116 | return detail ? { ...rest, detail } : rest; | ||
| 117 | } | ||
| 118 | |||
| 119 | function relayEnvironmentClient(token: string) { | ||
| 120 | return HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); | ||
| 121 | } | ||
| 122 | |||
| 123 | function deliveryStats( | ||
| 124 | deliveries: ReadonlyArray<{ | ||
| 125 | readonly ok: boolean; | ||
| 126 | readonly queued?: boolean | undefined; | ||
| 127 | readonly kind: string; | ||
| 128 | readonly apnsStatus?: number | null; | ||
| 129 | readonly apnsReason?: string | null; | ||
| 130 | }>, | ||
| 131 | ) { | ||
| 132 | let queued = 0; | ||
| 133 | let successful = 0; | ||
| 134 | let failed = 0; | ||
| 135 | const failedReasons: string[] = []; | ||
| 136 | const kinds = new Set<string>(); | ||
| 137 | |||
| 138 | for (const delivery of deliveries) { | ||
| 139 | kinds.add(delivery.kind); | ||
| 140 | if (delivery.queued) { | ||
| 141 | queued += 1; | ||
| 142 | continue; | ||
| 143 | } | ||
| 144 | if (delivery.ok) { | ||
| 145 | successful += 1; | ||
| 146 | continue; | ||
| 147 | } | ||
| 148 | failed += 1; | ||
| 149 | failedReasons.push(`${delivery.apnsStatus ?? "transport"}:${delivery.apnsReason ?? "unknown"}`); | ||
| 150 | } | ||
| 151 | |||
| 152 | return { | ||
| 153 | total: deliveries.length, | ||
| 154 | queued, | ||
| 155 | successful, | ||
| 156 | failed, | ||
| 157 | kinds: [...kinds], | ||
| 158 | failedReasons, | ||
| 159 | }; | ||
| 160 | } | ||
| 161 | |||
| 162 | export function signRelayAgentActivityPublishProof(input: { | ||
| 163 | readonly privateKey: string; | ||
| 164 | readonly payload: RelayAgentActivityPublishProofPayload; | ||
| 165 | }) { | ||
| 166 | return signRelayJwt({ | ||
| 167 | privateKey: input.privateKey, | ||
| 168 | typ: RELAY_ACTIVITY_PUBLISH_TYP, | ||
| 169 | payload: input.payload, | ||
| 170 | }); | ||
| 171 | } | ||
| 172 | |||
| 173 | const makePublishProof = Effect.fn("makePublishProof")(function* (input: { | ||
| 174 | readonly privateKey: string; | ||
| 175 | readonly relayIssuer: string; | ||
| 176 | readonly environmentId: string; | ||
| 177 | readonly threadId: ThreadId; | ||
| 178 | readonly state: RelayAgentActivityState | null; | ||
| 179 | readonly jti: string; | ||
| 180 | }) { | ||
| 181 | const now = yield* DateTime.now; | ||
| 182 | const expiresAt = DateTime.add(now, { minutes: 5 }); | ||
| 183 | const payload = { | ||
| 184 | iss: `t3-env:${input.environmentId}`, | ||
| 185 | aud: normalizeRelayIssuer(input.relayIssuer), | ||
| 186 | sub: input.environmentId, | ||
| 187 | jti: input.jti, | ||
| 188 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 189 | exp: Math.floor(expiresAt.epochMilliseconds / 1_000), | ||
| 190 | environmentId: input.environmentId as RelayAgentActivityPublishProofPayload["environmentId"], | ||
| 191 | threadId: input.threadId, | ||
| 192 | state: input.state, | ||
| 193 | } satisfies RelayAgentActivityPublishProofPayload; | ||
| 194 | return yield* signRelayAgentActivityPublishProof({ privateKey: input.privateKey, payload }); | ||
| 195 | }); | ||
| 196 | |||
| 197 | export function resolveAgentAwarenessRelayPublishSnapshot(input: { | ||
| 198 | readonly environmentId: EnvironmentId; | ||
| 199 | readonly threadId: ThreadId; | ||
| 200 | readonly thread: Option.Option<OrchestrationThreadShell>; | ||
| 201 | readonly project: Option.Option<OrchestrationProjectShell>; | ||
| 202 | }): { | ||
| 203 | readonly projectId: string | null; | ||
| 204 | readonly state: RelayAgentActivityState | null; | ||
| 205 | readonly reason: "snapshot" | "thread-not-found" | "project-not-found"; | ||
| 206 | } { | ||
| 207 | if (Option.isNone(input.thread)) { | ||
| 208 | return { | ||
| 209 | projectId: null, | ||
| 210 | state: null, | ||
| 211 | reason: "thread-not-found", | ||
| 212 | }; | ||
| 213 | } | ||
| 214 | if (Option.isNone(input.project)) { | ||
| 215 | return { | ||
| 216 | projectId: input.thread.value.projectId, | ||
| 217 | state: null, | ||
| 218 | reason: "project-not-found", | ||
| 219 | }; | ||
| 220 | } | ||
| 221 | return { | ||
| 222 | projectId: input.thread.value.projectId, | ||
| 223 | state: sanitizeRelayAgentActivityState( | ||
| 224 | projectThreadAwareness({ | ||
| 225 | environmentId: input.environmentId, | ||
| 226 | project: input.project.value, | ||
| 227 | thread: input.thread.value, | ||
| 228 | }), | ||
| 229 | ), | ||
| 230 | reason: "snapshot", | ||
| 231 | }; | ||
| 232 | } | ||
| 233 | |||
| 234 | export function resolveAgentAwarenessRelayActiveThreadIds(input: { | ||
| 235 | readonly environmentId: EnvironmentId; | ||
| 236 | readonly projects: ReadonlyArray<Pick<OrchestrationProjectShell, "id" | "title">>; | ||
| 237 | readonly threads: ReadonlyArray<OrchestrationThreadShell>; | ||
| 238 | }): ReadonlyArray<ThreadId> { | ||
| 239 | const projectById = new Map(input.projects.map((project) => [project.id, project])); | ||
| 240 | return input.threads | ||
| 241 | .filter((thread) => { | ||
| 242 | const project = projectById.get(thread.projectId); | ||
| 243 | if (!project) { | ||
| 244 | return false; | ||
| 245 | } | ||
| 246 | return ( | ||
| 247 | projectThreadAwareness({ | ||
| 248 | environmentId: input.environmentId, | ||
| 249 | project, | ||
| 250 | thread, | ||
| 251 | }) !== null | ||
| 252 | ); | ||
| 253 | }) | ||
| 254 | .map((thread) => thread.id); | ||
| 255 | } | ||
| 256 | |||
| 257 | const make = Effect.gen(function* () { | ||
| 258 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 259 | const serverEnvironment = yield* ServerEnvironment; | ||
| 260 | const snapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 261 | const orchestrationEngine = yield* OrchestrationEngineService; | ||
| 262 | const crypto = yield* Crypto.Crypto; | ||
| 263 | const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); | ||
| 264 | const activeSnapshotPublishedRef = yield* Ref.make(false); | ||
| 265 | const publishedStateByThreadRef = yield* Ref.make(new Map<ThreadId, string>()); | ||
| 266 | |||
| 267 | const readSecretString = (name: string) => | ||
| 268 | secrets.get(name).pipe(Effect.map((bytes) => (bytes ? new TextDecoder().decode(bytes) : null))); | ||
| 269 | |||
| 270 | const readRelayConfig = Effect.gen(function* () { | ||
| 271 | const [url, issuer, environmentCredential] = yield* Effect.all([ | ||
| 272 | readSecretString(RELAY_URL_SECRET), | ||
| 273 | readSecretString(RELAY_ISSUER_SECRET), | ||
| 274 | readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), | ||
| 275 | ]); | ||
| 276 | return url && environmentCredential | ||
| 277 | ? { url, issuer: issuer ?? url, environmentCredential } | ||
| 278 | : null; | ||
| 279 | }); | ||
| 280 | |||
| 281 | const readPublishAgentActivityEnabled = readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET).pipe( | ||
| 282 | Effect.map(isAgentActivityPublishingEnabled), | ||
| 283 | ); | ||
| 284 | |||
| 285 | const makeRelayClient = (relayConfig: { | ||
| 286 | readonly url: string; | ||
| 287 | readonly environmentCredential: string; | ||
| 288 | }) => | ||
| 289 | HttpApiClient.make(RelayApi, { | ||
| 290 | baseUrl: relayConfig.url, | ||
| 291 | transformClient: relayEnvironmentClient(relayConfig.environmentCredential), | ||
| 292 | }).pipe(Effect.provide(FetchHttpClient.layer)); | ||
| 293 | |||
| 294 | const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { | ||
| 295 | const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( | ||
| 296 | Effect.orElseSucceed(() => false), | ||
| 297 | ); | ||
| 298 | if (!publishAgentActivity) { | ||
| 299 | yield* Effect.logDebug("agent activity publish skipped; publication disabled", { | ||
| 300 | threadId, | ||
| 301 | }); | ||
| 302 | return; | ||
| 303 | } | ||
| 304 | const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); | ||
| 305 | if (!relayConfig) { | ||
| 306 | yield* Effect.logDebug("agent activity publish skipped; T3 Cloud config missing", { | ||
| 307 | threadId, | ||
| 308 | }); | ||
| 309 | return; | ||
| 310 | } | ||
| 311 | const relayClient = yield* makeRelayClient(relayConfig); | ||
| 312 | const environmentId = yield* serverEnvironment.getEnvironmentId; | ||
| 313 | |||
| 314 | const publishState = (input: { | ||
| 315 | readonly projectId: string | null; | ||
| 316 | readonly state: RelayAgentActivityState | null; | ||
| 317 | readonly reason: string; | ||
| 318 | }) => | ||
| 319 | Effect.gen(function* () { | ||
| 320 | const proof = yield* makePublishProof({ | ||
| 321 | privateKey: cloudLinkKeyPair.privateKey, | ||
| 322 | relayIssuer: relayConfig.issuer, | ||
| 323 | environmentId, | ||
| 324 | threadId, | ||
| 325 | state: input.state, | ||
| 326 | jti: yield* crypto.randomUUIDv4, | ||
| 327 | }); | ||
| 328 | |||
| 329 | yield* Effect.logInfo("publishing agent activity for thread", { | ||
| 330 | environmentId, | ||
| 331 | threadId, | ||
| 332 | projectId: input.projectId, | ||
| 333 | statePhase: input.state?.phase ?? null, | ||
| 334 | hasState: input.state !== null, | ||
| 335 | reason: input.reason, | ||
| 336 | }); | ||
| 337 | |||
| 338 | const response = yield* relayClient.server.publishAgentActivity({ | ||
| 339 | params: { | ||
| 340 | environmentId, | ||
| 341 | threadId, | ||
| 342 | }, | ||
| 343 | payload: { | ||
| 344 | state: input.state, | ||
| 345 | proof, | ||
| 346 | }, | ||
| 347 | }); | ||
| 348 | |||
| 349 | yield* Effect.logInfo("agent activity publish completed", { | ||
| 350 | environmentId, | ||
| 351 | threadId, | ||
| 352 | ok: response.ok, | ||
| 353 | deliveries: deliveryStats(response.deliveries), | ||
| 354 | }); | ||
| 355 | }); | ||
| 356 | |||
| 357 | const thread = yield* snapshotQuery.getThreadShellById(threadId); | ||
| 358 | const project = Option.isSome(thread) | ||
| 359 | ? yield* snapshotQuery.getProjectShellById(thread.value.projectId) | ||
| 360 | : Option.none<OrchestrationProjectShell>(); | ||
| 361 | const snapshot = resolveAgentAwarenessRelayPublishSnapshot({ | ||
| 362 | environmentId, | ||
| 363 | threadId, | ||
| 364 | thread, | ||
| 365 | project, | ||
| 366 | }); | ||
| 367 | const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); | ||
| 368 | const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); | ||
| 369 | if (publishedStateByThread.get(threadId) === publishIdentity) { | ||
| 370 | yield* Effect.logDebug("agent activity publish skipped; projected state unchanged", { | ||
| 371 | environmentId, | ||
| 372 | threadId, | ||
| 373 | reason: snapshot.reason, | ||
| 374 | }); | ||
| 375 | return; | ||
| 376 | } | ||
| 377 | |||
| 378 | if (snapshot.reason === "thread-not-found") { | ||
| 379 | yield* Effect.logDebug("publishing agent activity tombstone; thread not found", { | ||
| 380 | environmentId, | ||
| 381 | threadId, | ||
| 382 | }); | ||
| 383 | } else if (snapshot.reason === "project-not-found") { | ||
| 384 | yield* Effect.logDebug("publishing agent activity tombstone; project not found", { | ||
| 385 | environmentId, | ||
| 386 | threadId, | ||
| 387 | projectId: snapshot.projectId, | ||
| 388 | }); | ||
| 389 | } | ||
| 390 | |||
| 391 | yield* publishState({ | ||
| 392 | projectId: snapshot.projectId, | ||
| 393 | state: snapshot.state, | ||
| 394 | reason: snapshot.reason, | ||
| 395 | }); | ||
| 396 | yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { | ||
| 397 | const nextPublishedStates = new Map(publishedStates); | ||
| 398 | nextPublishedStates.set(threadId, publishIdentity); | ||
| 399 | return nextPublishedStates; | ||
| 400 | }); | ||
| 401 | }); | ||
| 402 | |||
| 403 | const publishThread: AgentAwarenessRelayShape["publishThread"] = (threadId) => | ||
| 404 | publishThreadUnsafe(threadId).pipe( | ||
| 405 | Effect.catchCause((cause) => { | ||
| 406 | return Effect.logWarning("agent activity publish failed", { | ||
| 407 | threadId, | ||
| 408 | cause: Cause.pretty(cause), | ||
| 409 | }); | ||
| 410 | }), | ||
| 411 | Effect.withSpan("AgentAwarenessRelay.publishThread"), | ||
| 412 | ); | ||
| 413 | |||
| 414 | const publishActiveThreadsUnsafe = Effect.gen(function* () { | ||
| 415 | const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( | ||
| 416 | Effect.orElseSucceed(() => false), | ||
| 417 | ); | ||
| 418 | if (!publishAgentActivity) { | ||
| 419 | yield* Effect.logDebug("agent activity snapshot skipped; publication disabled"); | ||
| 420 | return false; | ||
| 421 | } | ||
| 422 | const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); | ||
| 423 | if (!relayConfig) { | ||
| 424 | yield* Effect.logDebug("agent activity snapshot skipped; T3 Cloud config missing"); | ||
| 425 | return false; | ||
| 426 | } | ||
| 427 | const environmentId = yield* serverEnvironment.getEnvironmentId; | ||
| 428 | const snapshot = yield* snapshotQuery.getShellSnapshot(); | ||
| 429 | const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ | ||
| 430 | environmentId, | ||
| 431 | projects: snapshot.projects, | ||
| 432 | threads: snapshot.threads, | ||
| 433 | }); | ||
| 434 | if (activeThreadIds.length === 0) { | ||
| 435 | yield* Effect.logDebug("agent activity snapshot has no publishable threads"); | ||
| 436 | return true; | ||
| 437 | } | ||
| 438 | yield* Effect.logInfo("publishing active agent activity snapshot", { | ||
| 439 | count: activeThreadIds.length, | ||
| 440 | }); | ||
| 441 | yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true }); | ||
| 442 | return true; | ||
| 443 | }); | ||
| 444 | |||
| 445 | const publishActiveThreadsOnceWhenConfigured = Effect.gen(function* () { | ||
| 446 | while (!(yield* Ref.get(activeSnapshotPublishedRef))) { | ||
| 447 | const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); | ||
| 448 | if (published) { | ||
| 449 | yield* Ref.set(activeSnapshotPublishedRef, true); | ||
| 450 | return; | ||
| 451 | } | ||
| 452 | yield* Effect.sleep("5 seconds"); | ||
| 453 | } | ||
| 454 | }); | ||
| 455 | |||
| 456 | const worker = yield* makeDrainableWorker(publishThread); | ||
| 457 | |||
| 458 | const start: AgentAwarenessRelayShape["start"] = Effect.fn("AgentAwarenessRelay.start")( | ||
| 459 | function* () { | ||
| 460 | const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); | ||
| 461 | if (!relayConfig) { | ||
| 462 | yield* Effect.logInfo("agent activity publishing standby; T3 Cloud config missing"); | ||
| 463 | } else { | ||
| 464 | yield* Effect.logInfo("agent activity publishing enabled", { | ||
| 465 | relayUrl: relayConfig.url, | ||
| 466 | }); | ||
| 467 | } | ||
| 468 | yield* Effect.forkScoped( | ||
| 469 | Effect.sleep("1 second").pipe(Effect.andThen(publishActiveThreadsOnceWhenConfigured)), | ||
| 470 | ); | ||
| 471 | yield* Effect.forkScoped( | ||
| 472 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { | ||
| 473 | const threadId = eventThreadId(event); | ||
| 474 | if (threadId === null) { | ||
| 475 | return Effect.logDebug("agent activity publishing ignored event without thread id", { | ||
| 476 | eventType: event.type, | ||
| 477 | }); | ||
| 478 | } | ||
| 479 | if (!shouldPublishAgentAwarenessEvent(event)) { | ||
| 480 | return Effect.logDebug( | ||
| 481 | "agent activity publishing ignored event without activity changes", | ||
| 482 | { | ||
| 483 | eventType: event.type, | ||
| 484 | threadId, | ||
| 485 | }, | ||
| 486 | ); | ||
| 487 | } | ||
| 488 | return Effect.logDebug("agent activity publishing queued thread publish", { | ||
| 489 | eventType: event.type, | ||
| 490 | threadId, | ||
| 491 | }).pipe(Effect.andThen(worker.enqueue(threadId))); | ||
| 492 | }), | ||
| 493 | ); | ||
| 494 | }, | ||
| 495 | ); | ||
| 496 | |||
| 497 | return { | ||
| 498 | publishThread, | ||
| 499 | start, | ||
| 500 | } satisfies AgentAwarenessRelayShape; | ||
| 501 | }); | ||
| 502 | |||
| 503 | export const layer = Layer.effect(AgentAwarenessRelay, make); | ||
apps/server/src/server.test.ts+2187-271| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 1 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; | 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import * as NodeCrypto from "node:crypto"; | ||
| 4 | 5 | ||
| 5 | import { | 6 | import { |
| 6 | AuthAccessTokenType, | 7 | AuthAccessTokenType, |
| ... | @@ -28,6 +29,13 @@ import { | ... | @@ -28,6 +29,13 @@ import { |
| 28 | WsRpcGroup, | 29 | WsRpcGroup, |
| 29 | EditorId, | 30 | EditorId, |
| 30 | } from "@t3tools/contracts"; | 31 | } from "@t3tools/contracts"; |
| 32 | import { | ||
| 33 | computeDpopAccessTokenHash, | ||
| 34 | computeDpopJwkThumbprint, | ||
| 35 | type DpopPublicJwk, | ||
| 36 | } from "@t3tools/shared/dpop"; | ||
| 37 | import { RELAY_HEALTH_REQUEST_TYP, RELAY_MINT_REQUEST_TYP } from "@t3tools/shared/relayJwt"; | ||
| 38 | import * as RelayClient from "@t3tools/shared/relayClient"; | ||
| 31 | import { assert, it } from "@effect/vitest"; | 39 | import { assert, it } from "@effect/vitest"; |
| 32 | import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; | 40 | import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; |
| 33 | import * as Clock from "effect/Clock"; | 41 | import * as Clock from "effect/Clock"; |
| ... | @@ -43,13 +51,13 @@ import * as Path from "effect/Path"; | ... | @@ -43,13 +51,13 @@ import * as Path from "effect/Path"; |
| 43 | import * as Stream from "effect/Stream"; | 51 | import * as Stream from "effect/Stream"; |
| 44 | import { ChildProcessSpawner } from "effect/unstable/process"; | 52 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 45 | import { | 53 | import { |
| 46 | Cookies, | ||
| 47 | FetchHttpClient, | 54 | FetchHttpClient, |
| 48 | HttpBody, | 55 | HttpBody, |
| 49 | HttpClient, | 56 | HttpClient, |
| 57 | HttpClientRequest, | ||
| 58 | HttpClientResponse, | ||
| 50 | HttpRouter, | 59 | HttpRouter, |
| 51 | HttpServer, | 60 | HttpServer, |
| 52 | UrlParams, | ||
| 53 | } from "effect/unstable/http"; | 61 | } from "effect/unstable/http"; |
| 54 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | 62 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 55 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | 63 | import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; |
| ... | @@ -120,6 +128,11 @@ import * as ReviewService from "./review/ReviewService.ts"; | ... | @@ -120,6 +128,11 @@ import * as ReviewService from "./review/ReviewService.ts"; |
| 120 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; | 128 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 121 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 129 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 122 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 130 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 131 | import { | ||
| 132 | CloudManagedEndpointRuntime, | ||
| 133 | type CloudManagedEndpointRuntimeShape, | ||
| 134 | } from "./cloud/ManagedEndpointRuntime.ts"; | ||
| 135 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; | ||
| 123 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 136 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 124 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 137 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 125 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 138 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| ... | @@ -347,6 +360,9 @@ const buildAppUnderTest = (options?: { | ... | @@ -347,6 +360,9 @@ const buildAppUnderTest = (options?: { |
| 347 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; | 360 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; |
| 348 | serverEnvironment?: Partial<ServerEnvironmentShape>; | 361 | serverEnvironment?: Partial<ServerEnvironmentShape>; |
| 349 | repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>; | 362 | repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>; |
| 363 | cloudManagedEndpointRuntime?: Partial<CloudManagedEndpointRuntimeShape>; | ||
| 364 | relayClient?: Partial<RelayClient.RelayClientShape>; | ||
| 365 | cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManagerShape>; | ||
| 350 | }; | 366 | }; |
| 351 | }) => | 367 | }) => |
| 352 | Effect.gen(function* () { | 368 | Effect.gen(function* () { |
| ... | @@ -741,7 +757,40 @@ const buildAppUnderTest = (options?: { | ... | @@ -741,7 +757,40 @@ const buildAppUnderTest = (options?: { |
| 741 | ...options?.layers?.repositoryIdentityResolver, | 757 | ...options?.layers?.repositoryIdentityResolver, |
| 742 | }), | 758 | }), |
| 743 | ), | 759 | ), |
| 760 | Layer.provide( | ||
| 761 | Layer.succeed( | ||
| 762 | CloudManagedEndpointRuntime, | ||
| 763 | CloudManagedEndpointRuntime.of({ | ||
| 764 | applyConfig: () => Effect.succeed({ status: "disabled" }), | ||
| 765 | ...options?.layers?.cloudManagedEndpointRuntime, | ||
| 766 | }), | ||
| 767 | ), | ||
| 768 | ), | ||
| 769 | Layer.provide( | ||
| 770 | Layer.succeed( | ||
| 771 | RelayClient.RelayClient, | ||
| 772 | RelayClient.RelayClient.of({ | ||
| 773 | resolve: Effect.succeed({ | ||
| 774 | status: "missing", | ||
| 775 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 776 | }), | ||
| 777 | install: Effect.die("unused relay-client install"), | ||
| 778 | installWithProgress: () => Effect.die("unused relay-client install"), | ||
| 779 | ...options?.layers?.relayClient, | ||
| 780 | }), | ||
| 781 | ), | ||
| 782 | ), | ||
| 783 | Layer.provide( | ||
| 784 | Layer.mock(CloudCliTokenManager.CloudCliTokenManager)({ | ||
| 785 | get: Effect.die(new Error("Unexpected T3 Cloud CLI authorization request.")), | ||
| 786 | getExisting: Effect.succeed(Option.none()), | ||
| 787 | hasCredential: Effect.succeed(false), | ||
| 788 | clear: Effect.void, | ||
| 789 | ...options?.layers?.cloudCliTokenManager, | ||
| 790 | }), | ||
| 791 | ), | ||
| 744 | Layer.provideMerge(makeAuthTestLayer()), | 792 | Layer.provideMerge(makeAuthTestLayer()), |
| 793 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 745 | Layer.provide(workspaceAndProjectServicesLayer), | 794 | Layer.provide(workspaceAndProjectServicesLayer), |
| 746 | Layer.provideMerge(FetchHttpClient.layer), | 795 | Layer.provideMerge(FetchHttpClient.layer), |
| 747 | Layer.provide(layerConfig), | 796 | Layer.provide(layerConfig), |
| ... | @@ -799,6 +848,13 @@ const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => | ... | @@ -799,6 +848,13 @@ const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => |
| 799 | return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; | 848 | return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; |
| 800 | }; | 849 | }; |
| 801 | 850 | ||
| 851 | const getHttpServerUrl = (pathname = "") => | ||
| 852 | Effect.gen(function* () { | ||
| 853 | const server = yield* HttpServer.HttpServer; | ||
| 854 | const address = server.address as HttpServer.TcpAddress; | ||
| 855 | return `http://127.0.0.1:${address.port}${pathname}`; | ||
| 856 | }); | ||
| 857 | |||
| 802 | const bootstrapBrowserSession = ( | 858 | const bootstrapBrowserSession = ( |
| 803 | credential = defaultDesktopBootstrapToken, | 859 | credential = defaultDesktopBootstrapToken, |
| 804 | options?: { | 860 | options?: { |
| ... | @@ -806,19 +862,26 @@ const bootstrapBrowserSession = ( | ... | @@ -806,19 +862,26 @@ const bootstrapBrowserSession = ( |
| 806 | }, | 862 | }, |
| 807 | ) => | 863 | ) => |
| 808 | Effect.gen(function* () { | 864 | Effect.gen(function* () { |
| 809 | const response = yield* HttpClient.post("/api/auth/browser-session", { | 865 | const bootstrapUrl = yield* getHttpServerUrl("/api/auth/browser-session"); |
| 810 | headers: options?.headers, | 866 | const response = yield* fetchEffect(bootstrapUrl, { |
| 811 | body: yield* HttpBody.json({ credential }), | 867 | method: "POST", |
| 868 | headers: { | ||
| 869 | "content-type": "application/json", | ||
| 870 | ...options?.headers, | ||
| 871 | }, | ||
| 872 | body: jsonRequestBody({ | ||
| 873 | credential, | ||
| 874 | }), | ||
| 812 | }); | 875 | }); |
| 813 | const body = (yield* response.json) as { | 876 | const body = yield* responseJsonEffect<{ |
| 814 | readonly authenticated: boolean; | 877 | readonly authenticated: boolean; |
| 815 | readonly sessionMethod: string; | 878 | readonly sessionMethod: string; |
| 816 | readonly expiresAt: string; | 879 | readonly expiresAt: string; |
| 817 | }; | 880 | }>(response); |
| 818 | return { | 881 | return { |
| 819 | response, | 882 | response, |
| 820 | body, | 883 | body, |
| 821 | cookie: Cookies.toSetCookieHeaders(response.cookies)[0] ?? null, | 884 | cookie: response.headers["set-cookie"], |
| 822 | }; | 885 | }; |
| 823 | }); | 886 | }); |
| 824 | 887 | ||
| ... | @@ -835,48 +898,227 @@ const exchangeAccessToken = ( | ... | @@ -835,48 +898,227 @@ const exchangeAccessToken = ( |
| 835 | }, | 898 | }, |
| 836 | ) => | 899 | ) => |
| 837 | Effect.gen(function* () { | 900 | Effect.gen(function* () { |
| 838 | const response = yield* HttpClient.post("/oauth/token", { | 901 | const tokenUrl = yield* getHttpServerUrl("/oauth/token"); |
| 839 | headers: options?.headers, | 902 | const response = yield* fetchEffect(tokenUrl, { |
| 840 | body: HttpBody.urlParams( | 903 | method: "POST", |
| 841 | UrlParams.fromInput({ | 904 | headers: { |
| 842 | grant_type: AuthTokenExchangeGrantType, | 905 | "content-type": "application/x-www-form-urlencoded", |
| 843 | subject_token: credential, | 906 | ...options?.headers, |
| 844 | subject_token_type: AuthEnvironmentBootstrapTokenType, | 907 | }, |
| 845 | requested_token_type: AuthAccessTokenType, | 908 | body: new URLSearchParams({ |
| 846 | scope: | 909 | grant_type: AuthTokenExchangeGrantType, |
| 847 | options?.scope ?? | 910 | subject_token: credential, |
| 848 | "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", | 911 | subject_token_type: AuthEnvironmentBootstrapTokenType, |
| 849 | ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), | 912 | requested_token_type: AuthAccessTokenType, |
| 850 | ...(options?.clientMetadata?.deviceType | 913 | scope: |
| 851 | ? { client_device_type: options.clientMetadata.deviceType } | 914 | options?.scope ?? |
| 852 | : {}), | 915 | "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", |
| 853 | ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}), | 916 | ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}), |
| 854 | }), | 917 | ...(options?.clientMetadata?.deviceType |
| 855 | ), | 918 | ? { client_device_type: options.clientMetadata.deviceType } |
| 919 | : {}), | ||
| 920 | ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}), | ||
| 921 | }).toString(), | ||
| 856 | }); | 922 | }); |
| 857 | const body = (yield* response.json) as { | 923 | const body = yield* responseJsonEffect<{ |
| 858 | readonly access_token?: string; | 924 | readonly access_token?: string; |
| 859 | readonly issued_token_type?: string; | 925 | readonly issued_token_type?: string; |
| 860 | readonly token_type?: string; | 926 | readonly token_type?: string; |
| 861 | readonly expires_in?: number; | 927 | readonly expires_in?: number; |
| 862 | readonly scope?: string; | 928 | readonly scope?: string; |
| 863 | readonly _tag?: string; | 929 | readonly _tag?: string; |
| 864 | readonly message?: string; | 930 | readonly code?: string; |
| 865 | }; | 931 | readonly reason?: string; |
| 932 | readonly traceId?: string; | ||
| 933 | }>(response); | ||
| 866 | return { | 934 | return { |
| 867 | response, | 935 | response, |
| 868 | body, | 936 | body, |
| 869 | }; | 937 | }; |
| 870 | }); | 938 | }); |
| 871 | 939 | ||
| 940 | const makeDpopProof = (input: { | ||
| 941 | readonly method: string; | ||
| 942 | readonly url: string; | ||
| 943 | readonly iat: number; | ||
| 944 | readonly accessToken?: string; | ||
| 945 | readonly jti?: string; | ||
| 946 | readonly privateKey?: NodeCrypto.KeyObject; | ||
| 947 | readonly publicJwk?: DpopPublicJwk; | ||
| 948 | }) => { | ||
| 949 | const keyPair = | ||
| 950 | input.privateKey && input.publicJwk | ||
| 951 | ? { privateKey: input.privateKey, publicJwk: input.publicJwk } | ||
| 952 | : (() => { | ||
| 953 | const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { | ||
| 954 | namedCurve: "P-256", | ||
| 955 | }); | ||
| 956 | return { privateKey, publicJwk: publicKey.export({ format: "jwk" }) as DpopPublicJwk }; | ||
| 957 | })(); | ||
| 958 | const header = Buffer.from( | ||
| 959 | JSON.stringify({ | ||
| 960 | typ: "dpop+jwt", | ||
| 961 | alg: "ES256", | ||
| 962 | jwk: keyPair.publicJwk, | ||
| 963 | }), | ||
| 964 | ).toString("base64url"); | ||
| 965 | const payload = Buffer.from( | ||
| 966 | JSON.stringify({ | ||
| 967 | htm: input.method, | ||
| 968 | htu: input.url, | ||
| 969 | jti: input.jti ?? "proof-1", | ||
| 970 | iat: input.iat, | ||
| 971 | ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), | ||
| 972 | }), | ||
| 973 | ).toString("base64url"); | ||
| 974 | const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { | ||
| 975 | key: keyPair.privateKey, | ||
| 976 | dsaEncoding: "ieee-p1363", | ||
| 977 | }).toString("base64url"); | ||
| 978 | return { | ||
| 979 | proof: `${header}.${payload}.${signature}`, | ||
| 980 | thumbprint: computeDpopJwkThumbprint(keyPair.publicJwk), | ||
| 981 | privateKey: keyPair.privateKey, | ||
| 982 | publicJwk: keyPair.publicJwk, | ||
| 983 | }; | ||
| 984 | }; | ||
| 985 | |||
| 986 | const makeCloudMintCredentialRequest = (input: { | ||
| 987 | readonly privateKey: string; | ||
| 988 | readonly environmentId: EnvironmentId; | ||
| 989 | readonly clientProofKeyThumbprint: string; | ||
| 990 | readonly issuer?: string; | ||
| 991 | readonly audience?: string; | ||
| 992 | readonly subject?: string; | ||
| 993 | readonly jti?: string; | ||
| 994 | readonly nonce: string; | ||
| 995 | readonly issuedAt: string; | ||
| 996 | readonly expiresAt: string; | ||
| 997 | readonly scope?: ReadonlyArray<"environment:connect">; | ||
| 998 | }) => { | ||
| 999 | const payload = { | ||
| 1000 | iss: input.issuer ?? "https://relay.example.test", | ||
| 1001 | aud: input.audience ?? `t3-env:${input.environmentId}`, | ||
| 1002 | sub: input.subject ?? "user_123", | ||
| 1003 | jti: input.jti ?? "cloud-mint-jti-1", | ||
| 1004 | environmentId: input.environmentId, | ||
| 1005 | clientProofKeyThumbprint: input.clientProofKeyThumbprint, | ||
| 1006 | cnf: { | ||
| 1007 | jkt: input.clientProofKeyThumbprint, | ||
| 1008 | }, | ||
| 1009 | nonce: input.nonce, | ||
| 1010 | iat: Math.floor(DateTime.makeUnsafe(input.issuedAt).epochMilliseconds / 1_000), | ||
| 1011 | exp: Math.floor(DateTime.makeUnsafe(input.expiresAt).epochMilliseconds / 1_000), | ||
| 1012 | scope: input.scope ?? ["environment:connect"], | ||
| 1013 | } as const; | ||
| 1014 | const header = Buffer.from( | ||
| 1015 | JSON.stringify({ alg: "EdDSA", typ: RELAY_MINT_REQUEST_TYP }), | ||
| 1016 | ).toString("base64url"); | ||
| 1017 | const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); | ||
| 1018 | const signingInput = `${header}.${encodedPayload}`; | ||
| 1019 | return { | ||
| 1020 | proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, | ||
| 1021 | }; | ||
| 1022 | }; | ||
| 1023 | |||
| 1024 | const makeCloudEnvironmentHealthRequest = (input: { | ||
| 1025 | readonly privateKey: string; | ||
| 1026 | readonly environmentId: EnvironmentId; | ||
| 1027 | readonly issuer?: string; | ||
| 1028 | readonly audience?: string; | ||
| 1029 | readonly subject?: string; | ||
| 1030 | readonly jti?: string; | ||
| 1031 | readonly nonce: string; | ||
| 1032 | readonly issuedAt: string; | ||
| 1033 | readonly expiresAt: string; | ||
| 1034 | readonly scope?: ReadonlyArray<"environment:status">; | ||
| 1035 | }) => { | ||
| 1036 | const payload = { | ||
| 1037 | iss: input.issuer ?? "https://relay.example.test", | ||
| 1038 | aud: input.audience ?? `t3-env:${input.environmentId}`, | ||
| 1039 | sub: input.subject ?? "user_123", | ||
| 1040 | jti: input.jti ?? "cloud-health-jti-1", | ||
| 1041 | environmentId: input.environmentId, | ||
| 1042 | nonce: input.nonce, | ||
| 1043 | iat: Math.floor(DateTime.makeUnsafe(input.issuedAt).epochMilliseconds / 1_000), | ||
| 1044 | exp: Math.floor(DateTime.makeUnsafe(input.expiresAt).epochMilliseconds / 1_000), | ||
| 1045 | scope: input.scope ?? ["environment:status"], | ||
| 1046 | } as const; | ||
| 1047 | const header = Buffer.from( | ||
| 1048 | JSON.stringify({ alg: "EdDSA", typ: RELAY_HEALTH_REQUEST_TYP }), | ||
| 1049 | ).toString("base64url"); | ||
| 1050 | const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); | ||
| 1051 | const signingInput = `${header}.${encodedPayload}`; | ||
| 1052 | return { | ||
| 1053 | proof: `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), input.privateKey).toString("base64url")}`, | ||
| 1054 | }; | ||
| 1055 | }; | ||
| 1056 | |||
| 1057 | const decodeCompactJwtPayload = <A>(token: string): A => { | ||
| 1058 | const encodedPayload = token.split(".")[1]; | ||
| 1059 | if (!encodedPayload) { | ||
| 1060 | throw new Error("JWT does not contain a payload."); | ||
| 1061 | } | ||
| 1062 | return JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")) as A; | ||
| 1063 | }; | ||
| 1064 | |||
| 872 | class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{ | 1065 | class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{ |
| 873 | readonly message: string; | 1066 | readonly message: string; |
| 874 | }> {} | 1067 | }> {} |
| 875 | 1068 | ||
| 1069 | class TestHttpRequestError extends Data.TaggedError("TestHttpRequestError")<{ | ||
| 1070 | readonly cause: unknown; | ||
| 1071 | }> {} | ||
| 1072 | |||
| 1073 | const testRequestUrl = (input: Parameters<typeof fetch>[0]): string => { | ||
| 1074 | const value = input.toString(); | ||
| 1075 | if (!/^https?:\/\//i.test(value)) { | ||
| 1076 | return value; | ||
| 1077 | } | ||
| 1078 | const url = new URL(value); | ||
| 1079 | return `${url.pathname}${url.search}`; | ||
| 1080 | }; | ||
| 1081 | |||
| 1082 | const fetchEffect = (input: Parameters<typeof fetch>[0], init?: RequestInit) => { | ||
| 1083 | const request = HttpClientRequest.make((init?.method ?? "GET") as "GET" | "POST")( | ||
| 1084 | testRequestUrl(input), | ||
| 1085 | { | ||
| 1086 | headers: init?.headers as Record<string, string> | undefined, | ||
| 1087 | }, | ||
| 1088 | ).pipe( | ||
| 1089 | typeof init?.body === "string" | ||
| 1090 | ? HttpClientRequest.bodyText( | ||
| 1091 | init.body, | ||
| 1092 | (init.headers as Record<string, string> | undefined)?.["content-type"] ?? | ||
| 1093 | "application/json", | ||
| 1094 | ) | ||
| 1095 | : (request) => request, | ||
| 1096 | ); | ||
| 1097 | const effect = HttpClient.execute(request); | ||
| 1098 | return ( | ||
| 1099 | init?.redirect === "manual" | ||
| 1100 | ? effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })) | ||
| 1101 | : effect | ||
| 1102 | ).pipe(Effect.mapError((cause) => new TestHttpRequestError({ cause }))); | ||
| 1103 | }; | ||
| 1104 | |||
| 1105 | const jsonRequestBody = (value: unknown): string => { | ||
| 1106 | return JSON.stringify(value); | ||
| 1107 | }; | ||
| 1108 | |||
| 1109 | const responseJsonEffect = <A>(response: HttpClientResponse.HttpClientResponse) => | ||
| 1110 | response.json.pipe( | ||
| 1111 | Effect.map((json) => json as A), | ||
| 1112 | Effect.mapError((cause) => new TestHttpRequestError({ cause })), | ||
| 1113 | ); | ||
| 1114 | |||
| 1115 | const responseOk = (response: HttpClientResponse.HttpClientResponse) => | ||
| 1116 | response.status >= 200 && response.status < 300; | ||
| 1117 | |||
| 876 | const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => | 1118 | const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => |
| 877 | Effect.gen(function* () { | 1119 | Effect.gen(function* () { |
| 878 | const { response, cookie } = yield* bootstrapBrowserSession(credential); | 1120 | const { response, cookie } = yield* bootstrapBrowserSession(credential); |
| 879 | if (response.status !== 200) { | 1121 | if (!responseOk(response)) { |
| 880 | return yield* new AuthenticationGetterError({ | 1122 | return yield* new AuthenticationGetterError({ |
| 881 | message: `Expected bootstrap session response to succeed, got ${response.status}`, | 1123 | message: `Expected bootstrap session response to succeed, got ${response.status}`, |
| 882 | }); | 1124 | }); |
| ... | @@ -894,7 +1136,7 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra | ... | @@ -894,7 +1136,7 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra |
| 894 | const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => | 1136 | const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => |
| 895 | Effect.gen(function* () { | 1137 | Effect.gen(function* () { |
| 896 | const { response, body } = yield* exchangeAccessToken(credential); | 1138 | const { response, body } = yield* exchangeAccessToken(credential); |
| 897 | if (response.status !== 200) { | 1139 | if (!responseOk(response)) { |
| 898 | return yield* new AuthenticationGetterError({ | 1140 | return yield* new AuthenticationGetterError({ |
| 899 | message: `Expected bearer bootstrap response to succeed, got ${response.status}`, | 1141 | message: `Expected bearer bootstrap response to succeed, got ${response.status}`, |
| 900 | }); | 1142 | }); |
| ... | @@ -918,7 +1160,7 @@ const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { | ... | @@ -918,7 +1160,7 @@ const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { |
| 918 | return token; | 1160 | return token; |
| 919 | }; | 1161 | }; |
| 920 | 1162 | ||
| 921 | const splitHeaderTokens = (value: string | null) => | 1163 | const splitHeaderTokens = (value: string | null | undefined) => |
| 922 | (value ?? "") | 1164 | (value ?? "") |
| 923 | .split(",") | 1165 | .split(",") |
| 924 | .map((token) => token.trim()) | 1166 | .map((token) => token.trim()) |
| ... | @@ -927,23 +1169,36 @@ const splitHeaderTokens = (value: string | null) => | ... | @@ -927,23 +1169,36 @@ const splitHeaderTokens = (value: string | null) => |
| 927 | 1169 | ||
| 928 | const assertBrowserApiCorsResponseHeaders = ( | 1170 | const assertBrowserApiCorsResponseHeaders = ( |
| 929 | headers: Readonly<Record<string, string | undefined>>, | 1171 | headers: Readonly<Record<string, string | undefined>>, |
| 1172 | options?: { | ||
| 1173 | readonly origin?: string; | ||
| 1174 | readonly credentials?: boolean; | ||
| 1175 | }, | ||
| 930 | ) => { | 1176 | ) => { |
| 931 | assert.equal(headers["access-control-allow-origin"], "*"); | 1177 | assert.equal(headers["access-control-allow-origin"], options?.origin ?? "*"); |
| 1178 | assert.equal( | ||
| 1179 | headers["access-control-allow-credentials"], | ||
| 1180 | options?.credentials ? "true" : undefined, | ||
| 1181 | ); | ||
| 932 | }; | 1182 | }; |
| 933 | 1183 | ||
| 934 | const assertBrowserApiCorsPreflightHeaders = ( | 1184 | const assertBrowserApiCorsPreflightHeaders = ( |
| 935 | headers: Readonly<Record<string, string | undefined>>, | 1185 | headers: Readonly<Record<string, string | undefined>>, |
| 1186 | options?: { | ||
| 1187 | readonly origin?: string; | ||
| 1188 | readonly credentials?: boolean; | ||
| 1189 | }, | ||
| 936 | ) => { | 1190 | ) => { |
| 937 | assertBrowserApiCorsResponseHeaders(headers); | 1191 | assertBrowserApiCorsResponseHeaders(headers, options); |
| 938 | assert.deepEqual(splitHeaderTokens(headers["access-control-allow-methods"] ?? null), [ | 1192 | assert.deepEqual(splitHeaderTokens(headers["access-control-allow-methods"] ?? null), [ |
| 939 | "GET", | 1193 | "GET", |
| 940 | "OPTIONS", | 1194 | "OPTIONS", |
| 941 | "POST", | 1195 | "POST", |
| 942 | ]); | 1196 | ]); |
| 943 | assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"] ?? null), [ | 1197 | assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"]), [ |
| 944 | "authorization", | 1198 | "authorization", |
| 945 | "b3", | 1199 | "b3", |
| 946 | "content-type", | 1200 | "content-type", |
| 1201 | "dpop", | ||
| 947 | "traceparent", | 1202 | "traceparent", |
| 948 | ]); | 1203 | ]); |
| 949 | }; | 1204 | }; |
| ... | @@ -989,9 +1244,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -989,9 +1244,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 989 | config: { devUrl: new URL("http://127.0.0.1:5173") }, | 1244 | config: { devUrl: new URL("http://127.0.0.1:5173") }, |
| 990 | }); | 1245 | }); |
| 991 | 1246 | ||
| 992 | const response = yield* HttpClient.get("/foo/bar?token=test-token").pipe( | 1247 | const url = yield* getHttpServerUrl("/foo/bar?token=test-token"); |
| 993 | Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), | 1248 | const response = yield* fetchEffect(url, { redirect: "manual" }); |
| 994 | ); | ||
| 995 | 1249 | ||
| 996 | assert.equal(response.status, 302); | 1250 | assert.equal(response.status, 302); |
| 997 | assert.equal(response.headers.location, "http://127.0.0.1:5173/foo/bar?token=test-token"); | 1251 | assert.equal(response.headers.location, "http://127.0.0.1:5173/foo/bar?token=test-token"); |
| ... | @@ -1057,8 +1311,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1057,8 +1311,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1057 | Effect.gen(function* () { | 1311 | Effect.gen(function* () { |
| 1058 | yield* buildAppUnderTest(); | 1312 | yield* buildAppUnderTest(); |
| 1059 | 1313 | ||
| 1060 | const response = yield* HttpClient.get("/.well-known/t3/environment"); | 1314 | const url = yield* getHttpServerUrl("/.well-known/t3/environment"); |
| 1061 | const body = (yield* response.json) as typeof testEnvironmentDescriptor; | 1315 | const response = yield* fetchEffect(url); |
| 1316 | const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response); | ||
| 1062 | 1317 | ||
| 1063 | assert.equal(response.status, 200); | 1318 | assert.equal(response.status, 200); |
| 1064 | assert.deepEqual(body, testEnvironmentDescriptor); | 1319 | assert.deepEqual(body, testEnvironmentDescriptor); |
| ... | @@ -1069,12 +1324,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1069,12 +1324,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1069 | Effect.gen(function* () { | 1324 | Effect.gen(function* () { |
| 1070 | yield* buildAppUnderTest(); | 1325 | yield* buildAppUnderTest(); |
| 1071 | 1326 | ||
| 1072 | const response = yield* HttpClient.get("/.well-known/t3/environment", { | 1327 | const url = yield* getHttpServerUrl("/.well-known/t3/environment"); |
| 1328 | const response = yield* fetchEffect(url, { | ||
| 1073 | headers: { | 1329 | headers: { |
| 1074 | origin: crossOriginClientOrigin, | 1330 | origin: crossOriginClientOrigin, |
| 1075 | }, | 1331 | }, |
| 1076 | }); | 1332 | }); |
| 1077 | const body = (yield* response.json) as typeof testEnvironmentDescriptor; | 1333 | const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response); |
| 1078 | 1334 | ||
| 1079 | assert.equal(response.status, 200); | 1335 | assert.equal(response.status, 200); |
| 1080 | assertBrowserApiCorsResponseHeaders(response.headers); | 1336 | assertBrowserApiCorsResponseHeaders(response.headers); |
| ... | @@ -1086,8 +1342,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1086,8 +1342,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1086 | Effect.gen(function* () { | 1342 | Effect.gen(function* () { |
| 1087 | yield* buildAppUnderTest(); | 1343 | yield* buildAppUnderTest(); |
| 1088 | 1344 | ||
| 1089 | const response = yield* HttpClient.get("/api/auth/session"); | 1345 | const url = yield* getHttpServerUrl("/api/auth/session"); |
| 1090 | const body = (yield* response.json) as { | 1346 | const response = yield* fetchEffect(url); |
| 1347 | const body = yield* responseJsonEffect<{ | ||
| 1091 | readonly authenticated: boolean; | 1348 | readonly authenticated: boolean; |
| 1092 | readonly auth: { | 1349 | readonly auth: { |
| 1093 | readonly policy: string; | 1350 | readonly policy: string; |
| ... | @@ -1095,13 +1352,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1095,13 +1352,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1095 | readonly sessionMethods: ReadonlyArray<string>; | 1352 | readonly sessionMethods: ReadonlyArray<string>; |
| 1096 | readonly sessionCookieName: string; | 1353 | readonly sessionCookieName: string; |
| 1097 | }; | 1354 | }; |
| 1098 | }; | 1355 | }>(response); |
| 1099 | 1356 | ||
| 1100 | assert.equal(response.status, 200); | 1357 | assert.equal(response.status, 200); |
| 1101 | assert.equal(body.authenticated, false); | 1358 | assert.equal(body.authenticated, false); |
| 1102 | assert.equal(body.auth.policy, "desktop-managed-local"); | 1359 | assert.equal(body.auth.policy, "desktop-managed-local"); |
| 1103 | assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); | 1360 | assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); |
| 1104 | assert.deepEqual(body.auth.sessionMethods, ["browser-session-cookie", "bearer-access-token"]); | 1361 | assert.deepEqual(body.auth.sessionMethods, [ |
| 1362 | "browser-session-cookie", | ||
| 1363 | "bearer-access-token", | ||
| 1364 | "dpop-access-token", | ||
| 1365 | ]); | ||
| 1105 | assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); | 1366 | assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); |
| 1106 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1367 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1107 | ); | 1368 | ); |
| ... | @@ -1122,15 +1383,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1122,15 +1383,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1122 | assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken); | 1383 | assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken); |
| 1123 | assert.isDefined(setCookie); | 1384 | assert.isDefined(setCookie); |
| 1124 | 1385 | ||
| 1125 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { | 1386 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); |
| 1387 | const sessionResponse = yield* fetchEffect(sessionUrl, { | ||
| 1126 | headers: { | 1388 | headers: { |
| 1127 | cookie: setCookie?.split(";")[0] ?? "", | 1389 | cookie: setCookie?.split(";")[0] ?? "", |
| 1128 | }, | 1390 | }, |
| 1129 | }); | 1391 | }); |
| 1130 | const sessionBody = (yield* sessionResponse.json) as { | 1392 | const sessionBody = yield* responseJsonEffect<{ |
| 1131 | readonly authenticated: boolean; | 1393 | readonly authenticated: boolean; |
| 1132 | readonly sessionMethod?: string; | 1394 | readonly sessionMethod?: string; |
| 1133 | }; | 1395 | }>(sessionResponse); |
| 1134 | 1396 | ||
| 1135 | assert.equal(sessionResponse.status, 200); | 1397 | assert.equal(sessionResponse.status, 200); |
| 1136 | assert.equal(sessionBody.authenticated, true); | 1398 | assert.equal(sessionBody.authenticated, true); |
| ... | @@ -1142,9 +1404,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1142,9 +1404,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1142 | Effect.gen(function* () { | 1404 | Effect.gen(function* () { |
| 1143 | yield* buildAppUnderTest(); | 1405 | yield* buildAppUnderTest(); |
| 1144 | 1406 | ||
| 1145 | const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken(); | 1407 | const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken(); |
| 1146 | 1408 | ||
| 1147 | assert.equal(bootstrapResponse.status, 200); | 1409 | assert.equal(tokenResponse.status, 200); |
| 1148 | assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); | 1410 | assert.equal(tokenBody.issued_token_type, AuthAccessTokenType); |
| 1149 | assert.equal(tokenBody.token_type, "Bearer"); | 1411 | assert.equal(tokenBody.token_type, "Bearer"); |
| 1150 | assert.equal( | 1412 | assert.equal( |
| ... | @@ -1152,18 +1414,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1152,18 +1414,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1152 | "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", | 1414 | "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write", |
| 1153 | ); | 1415 | ); |
| 1154 | assert.equal(typeof tokenBody.access_token, "string"); | 1416 | assert.equal(typeof tokenBody.access_token, "string"); |
| 1155 | assert.isTrue((tokenBody.access_token?.length ?? 0) > 0); | ||
| 1156 | 1417 | ||
| 1157 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { | 1418 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); |
| 1419 | const sessionResponse = yield* fetchEffect(sessionUrl, { | ||
| 1158 | headers: { | 1420 | headers: { |
| 1159 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | 1421 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, |
| 1160 | }, | 1422 | }, |
| 1161 | }); | 1423 | }); |
| 1162 | const sessionBody = (yield* sessionResponse.json) as { | 1424 | const sessionBody = yield* responseJsonEffect<{ |
| 1163 | readonly authenticated: boolean; | 1425 | readonly authenticated: boolean; |
| 1164 | readonly sessionMethod?: string; | 1426 | readonly sessionMethod?: string; |
| 1165 | readonly scopes?: ReadonlyArray<string>; | 1427 | readonly scopes?: ReadonlyArray<string>; |
| 1166 | }; | 1428 | }>(sessionResponse); |
| 1167 | 1429 | ||
| 1168 | assert.equal(sessionResponse.status, 200); | 1430 | assert.equal(sessionResponse.status, 200); |
| 1169 | assert.equal(sessionBody.authenticated, true); | 1431 | assert.equal(sessionBody.authenticated, true); |
| ... | @@ -1181,187 +1443,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1181,187 +1443,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1181 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1443 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1182 | ); | 1444 | ); |
| 1183 | 1445 | ||
| 1184 | it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => | ||
| 1185 | Effect.gen(function* () { | ||
| 1186 | yield* buildAppUnderTest(); | ||
| 1187 | |||
| 1188 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | ||
| 1189 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | ||
| 1190 | headers: { | ||
| 1191 | authorization: `Bearer ${bearerToken}`, | ||
| 1192 | }, | ||
| 1193 | }); | ||
| 1194 | const wsTicketBody = (yield* wsTicketResponse.json) as { | ||
| 1195 | readonly ticket: string; | ||
| 1196 | readonly expiresAt: string; | ||
| 1197 | }; | ||
| 1198 | |||
| 1199 | assert.equal(wsTicketResponse.status, 200); | ||
| 1200 | assert.equal(typeof wsTicketBody.ticket, "string"); | ||
| 1201 | assert.isTrue(wsTicketBody.ticket.length > 0); | ||
| 1202 | assert.equal(typeof wsTicketBody.expiresAt, "string"); | ||
| 1203 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1204 | ); | ||
| 1205 | |||
| 1206 | it.effect("does not allow management-only access tokens to operate the environment", () => | ||
| 1207 | Effect.gen(function* () { | ||
| 1208 | yield* buildAppUnderTest(); | ||
| 1209 | |||
| 1210 | const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken( | ||
| 1211 | defaultDesktopBootstrapToken, | ||
| 1212 | { scope: "access:write" }, | ||
| 1213 | ); | ||
| 1214 | assert.equal(exchangeResponse.status, 200); | ||
| 1215 | assert.equal(tokenBody.scope, "access:write"); | ||
| 1216 | assert.isDefined(tokenBody.access_token); | ||
| 1217 | |||
| 1218 | const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1219 | headers: { | ||
| 1220 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1221 | }, | ||
| 1222 | body: yield* HttpBody.json({}), | ||
| 1223 | }); | ||
| 1224 | const overbroadPairingBody = (yield* overbroadPairingResponse.json) as { | ||
| 1225 | readonly requiredScope: string; | ||
| 1226 | }; | ||
| 1227 | const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1228 | headers: { | ||
| 1229 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1230 | }, | ||
| 1231 | body: yield* HttpBody.json({ scopes: ["access:write"] }), | ||
| 1232 | }); | ||
| 1233 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | ||
| 1234 | headers: { | ||
| 1235 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1236 | }, | ||
| 1237 | }); | ||
| 1238 | const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; | ||
| 1239 | const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { | ||
| 1240 | headers: { | ||
| 1241 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1242 | }, | ||
| 1243 | }); | ||
| 1244 | const faviconBody = (yield* faviconResponse.json) as { | ||
| 1245 | readonly _tag: string; | ||
| 1246 | readonly code: string; | ||
| 1247 | readonly requiredScope: string; | ||
| 1248 | readonly traceId: string; | ||
| 1249 | }; | ||
| 1250 | |||
| 1251 | assert.equal(overbroadPairingResponse.status, 403); | ||
| 1252 | assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); | ||
| 1253 | assert.equal(pairingResponse.status, 200); | ||
| 1254 | assert.equal(wsTicketResponse.status, 200); | ||
| 1255 | assert.equal(faviconResponse.status, 403); | ||
| 1256 | assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); | ||
| 1257 | assert.equal(faviconBody.code, "insufficient_scope"); | ||
| 1258 | assert.equal(faviconBody.requiredScope, "orchestration:read"); | ||
| 1259 | assert.equal(typeof faviconBody.traceId, "string"); | ||
| 1260 | |||
| 1261 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; | ||
| 1262 | const rpcError = yield* Effect.flip( | ||
| 1263 | Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), | ||
| 1264 | ); | ||
| 1265 | assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); | ||
| 1266 | if (rpcError._tag === "EnvironmentAuthorizationError") { | ||
| 1267 | assert.equal(rpcError.requiredScope, "orchestration:read"); | ||
| 1268 | } | ||
| 1269 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1270 | ); | ||
| 1271 | |||
| 1272 | it.effect("includes CORS headers on remote auth success responses", () => | ||
| 1273 | Effect.gen(function* () { | ||
| 1274 | yield* buildAppUnderTest(); | ||
| 1275 | |||
| 1276 | const origin = crossOriginClientOrigin; | ||
| 1277 | const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken( | ||
| 1278 | defaultDesktopBootstrapToken, | ||
| 1279 | { | ||
| 1280 | headers: { origin }, | ||
| 1281 | }, | ||
| 1282 | ); | ||
| 1283 | |||
| 1284 | assert.equal(bootstrapResponse.status, 200); | ||
| 1285 | assertBrowserApiCorsResponseHeaders(bootstrapResponse.headers); | ||
| 1286 | assert.equal(tokenBody.token_type, "Bearer"); | ||
| 1287 | assert.equal(typeof tokenBody.access_token, "string"); | ||
| 1288 | |||
| 1289 | const sessionResponse = yield* HttpClient.get("/api/auth/session", { | ||
| 1290 | headers: { | ||
| 1291 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1292 | origin, | ||
| 1293 | }, | ||
| 1294 | }); | ||
| 1295 | const sessionBody = (yield* sessionResponse.json) as { | ||
| 1296 | readonly authenticated: boolean; | ||
| 1297 | readonly sessionMethod?: string; | ||
| 1298 | }; | ||
| 1299 | |||
| 1300 | assert.equal(sessionResponse.status, 200); | ||
| 1301 | assertBrowserApiCorsResponseHeaders(sessionResponse.headers); | ||
| 1302 | assert.equal(sessionBody.authenticated, true); | ||
| 1303 | assert.equal(sessionBody.sessionMethod, "bearer-access-token"); | ||
| 1304 | |||
| 1305 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | ||
| 1306 | headers: { | ||
| 1307 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 1308 | origin, | ||
| 1309 | }, | ||
| 1310 | }); | ||
| 1311 | const wsTicketBody = (yield* wsTicketResponse.json) as { | ||
| 1312 | readonly ticket: string; | ||
| 1313 | }; | ||
| 1314 | |||
| 1315 | assert.equal(wsTicketResponse.status, 200); | ||
| 1316 | assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers); | ||
| 1317 | assert.equal(typeof wsTicketBody.ticket, "string"); | ||
| 1318 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1319 | ); | ||
| 1320 | |||
| 1321 | it.effect( | ||
| 1322 | "responds to remote auth websocket-ticket preflight requests with authorization CORS headers", | ||
| 1323 | () => | ||
| 1324 | Effect.gen(function* () { | ||
| 1325 | yield* buildAppUnderTest(); | ||
| 1326 | |||
| 1327 | const response = yield* HttpClient.options("/api/auth/websocket-ticket", { | ||
| 1328 | headers: { | ||
| 1329 | origin: crossOriginClientOrigin, | ||
| 1330 | "access-control-request-method": "POST", | ||
| 1331 | "access-control-request-headers": "authorization", | ||
| 1332 | }, | ||
| 1333 | }); | ||
| 1334 | |||
| 1335 | assert.equal(response.status, 204); | ||
| 1336 | assertBrowserApiCorsPreflightHeaders(response.headers); | ||
| 1337 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1338 | ); | ||
| 1339 | |||
| 1340 | it.effect("includes CORS headers on remote websocket-ticket auth failures", () => | ||
| 1341 | Effect.gen(function* () { | ||
| 1342 | yield* buildAppUnderTest(); | ||
| 1343 | |||
| 1344 | const response = yield* HttpClient.post("/api/auth/websocket-ticket", { | ||
| 1345 | headers: { | ||
| 1346 | origin: crossOriginClientOrigin, | ||
| 1347 | }, | ||
| 1348 | }); | ||
| 1349 | const body = (yield* response.json) as { | ||
| 1350 | readonly _tag?: string; | ||
| 1351 | readonly code?: string; | ||
| 1352 | readonly reason?: string; | ||
| 1353 | readonly traceId?: string; | ||
| 1354 | }; | ||
| 1355 | |||
| 1356 | assert.equal(response.status, 401); | ||
| 1357 | assertBrowserApiCorsResponseHeaders(response.headers); | ||
| 1358 | assert.equal(body._tag, "EnvironmentAuthInvalidError"); | ||
| 1359 | assert.equal(body.code, "auth_invalid"); | ||
| 1360 | assert.equal(body.reason, "missing_credential"); | ||
| 1361 | assert.equal(typeof body.traceId, "string"); | ||
| 1362 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1363 | ); | ||
| 1364 | |||
| 1365 | it.effect("persists token exchange client display metadata for authorized-client listings", () => | 1446 | it.effect("persists token exchange client display metadata for authorized-client listings", () => |
| 1366 | Effect.gen(function* () { | 1447 | Effect.gen(function* () { |
| 1367 | yield* buildAppUnderTest({ | 1448 | yield* buildAppUnderTest({ |
| ... | @@ -1385,7 +1466,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1385,7 +1466,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1385 | headers: { | 1466 | headers: { |
| 1386 | "user-agent": "undici", | 1467 | "user-agent": "undici", |
| 1387 | }, | 1468 | }, |
| 1388 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | 1469 | scope: "orchestration:read orchestration:operate terminal:operate review:write", |
| 1389 | clientMetadata: { | 1470 | clientMetadata: { |
| 1390 | label: "T3 Code Mobile", | 1471 | label: "T3 Code Mobile", |
| 1391 | deviceType: "mobile", | 1472 | deviceType: "mobile", |
| ... | @@ -1423,19 +1504,1840 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1423,19 +1504,1840 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1423 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1504 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1424 | ); | 1505 | ); |
| 1425 | 1506 | ||
| 1426 | it.effect("issues authenticated one-time pairing credentials for additional clients", () => | 1507 | it.effect( |
| 1427 | Effect.gen(function* () { | 1508 | "exchanges a bootstrap credential for a DPoP-bound access token without bearer downgrade", |
| 1428 | yield* buildAppUnderTest(); | 1509 | () => |
| 1510 | Effect.gen(function* () { | ||
| 1511 | yield* buildAppUnderTest(); | ||
| 1429 | 1512 | ||
| 1430 | const response = yield* HttpClient.post("/api/auth/pairing-token", { | 1513 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); |
| 1431 | headers: { | 1514 | const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { |
| 1432 | cookie: yield* getAuthenticatedSessionCookieHeader(), | 1515 | headers: { cookie: ownerCookie }, |
| 1433 | }, | 1516 | body: yield* HttpBody.json({}), |
| 1434 | body: yield* HttpBody.json({}), | 1517 | }); |
| 1435 | }); | 1518 | const credential = (yield* credentialResponse.json) as { readonly credential: string }; |
| 1436 | const body = (yield* response.json) as { | 1519 | const tokenUrl = yield* getHttpServerUrl("/oauth/token"); |
| 1437 | readonly credential: string; | 1520 | const now = yield* DateTime.now; |
| 1438 | readonly expiresAt: string; | 1521 | const tokenProof = makeDpopProof({ |
| 1522 | method: "POST", | ||
| 1523 | url: tokenUrl, | ||
| 1524 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 1525 | jti: "token-exchange-proof", | ||
| 1526 | }); | ||
| 1527 | const tokenResponse = yield* fetchEffect(tokenUrl, { | ||
| 1528 | method: "POST", | ||
| 1529 | headers: { | ||
| 1530 | "content-type": "application/x-www-form-urlencoded", | ||
| 1531 | dpop: tokenProof.proof, | ||
| 1532 | }, | ||
| 1533 | body: new URLSearchParams({ | ||
| 1534 | grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", | ||
| 1535 | subject_token: credential.credential, | ||
| 1536 | subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap", | ||
| 1537 | requested_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 1538 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 1539 | }).toString(), | ||
| 1540 | }); | ||
| 1541 | const token = yield* responseJsonEffect<{ | ||
| 1542 | readonly access_token: string; | ||
| 1543 | readonly token_type: string; | ||
| 1544 | }>(tokenResponse); | ||
| 1545 | |||
| 1546 | assert.equal(tokenResponse.status, 200); | ||
| 1547 | assert.equal(tokenResponse.headers["cache-control"], "no-store"); | ||
| 1548 | assert.equal(token.token_type, "DPoP"); | ||
| 1549 | |||
| 1550 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); | ||
| 1551 | const bearerResponse = yield* fetchEffect(sessionUrl, { | ||
| 1552 | headers: { authorization: `Bearer ${token.access_token}` }, | ||
| 1553 | }); | ||
| 1554 | const bearerState = yield* responseJsonEffect<{ readonly authenticated: boolean }>( | ||
| 1555 | bearerResponse, | ||
| 1556 | ); | ||
| 1557 | assert.equal(bearerState.authenticated, false); | ||
| 1558 | |||
| 1559 | const sessionProof = makeDpopProof({ | ||
| 1560 | method: "GET", | ||
| 1561 | url: sessionUrl, | ||
| 1562 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 1563 | jti: "session-proof", | ||
| 1564 | accessToken: token.access_token, | ||
| 1565 | privateKey: tokenProof.privateKey, | ||
| 1566 | publicJwk: tokenProof.publicJwk, | ||
| 1567 | }); | ||
| 1568 | const dpopResponse = yield* fetchEffect(sessionUrl, { | ||
| 1569 | headers: { | ||
| 1570 | authorization: `DPoP ${token.access_token}`, | ||
| 1571 | dpop: sessionProof.proof, | ||
| 1572 | }, | ||
| 1573 | }); | ||
| 1574 | const dpopState = yield* responseJsonEffect<{ | ||
| 1575 | readonly authenticated: boolean; | ||
| 1576 | readonly sessionMethod?: string; | ||
| 1577 | }>(dpopResponse); | ||
| 1578 | assert.equal(dpopState.authenticated, true); | ||
| 1579 | assert.equal(dpopState.sessionMethod, "dpop-access-token"); | ||
| 1580 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1581 | ); | ||
| 1582 | |||
| 1583 | it.effect("rejects replayed DPoP proofs across token exchanges", () => | ||
| 1584 | Effect.gen(function* () { | ||
| 1585 | yield* buildAppUnderTest(); | ||
| 1586 | |||
| 1587 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1588 | const firstCredentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1589 | headers: { | ||
| 1590 | cookie: ownerCookie, | ||
| 1591 | }, | ||
| 1592 | body: yield* HttpBody.json({}), | ||
| 1593 | }); | ||
| 1594 | const firstCredential = (yield* firstCredentialResponse.json) as { | ||
| 1595 | readonly credential: string; | ||
| 1596 | }; | ||
| 1597 | const secondCredentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1598 | headers: { | ||
| 1599 | cookie: ownerCookie, | ||
| 1600 | }, | ||
| 1601 | body: yield* HttpBody.json({}), | ||
| 1602 | }); | ||
| 1603 | const secondCredential = (yield* secondCredentialResponse.json) as { | ||
| 1604 | readonly credential: string; | ||
| 1605 | }; | ||
| 1606 | const tokenUrl = yield* getHttpServerUrl("/oauth/token"); | ||
| 1607 | const now = yield* DateTime.now; | ||
| 1608 | const dpop = makeDpopProof({ | ||
| 1609 | method: "POST", | ||
| 1610 | url: tokenUrl, | ||
| 1611 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 1612 | }); | ||
| 1613 | |||
| 1614 | const firstBootstrap = yield* exchangeAccessToken(firstCredential.credential, { | ||
| 1615 | headers: { | ||
| 1616 | dpop: dpop.proof, | ||
| 1617 | }, | ||
| 1618 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 1619 | }); | ||
| 1620 | const replayBootstrap = yield* exchangeAccessToken(secondCredential.credential, { | ||
| 1621 | headers: { | ||
| 1622 | dpop: dpop.proof, | ||
| 1623 | }, | ||
| 1624 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 1625 | }); | ||
| 1626 | |||
| 1627 | assert.equal(firstBootstrap.response.status, 200); | ||
| 1628 | assert.equal(replayBootstrap.response.status, 401); | ||
| 1629 | assert.equal(replayBootstrap.body._tag, "EnvironmentAuthInvalidError"); | ||
| 1630 | assert.equal(replayBootstrap.body.code, "auth_invalid"); | ||
| 1631 | assert.equal(replayBootstrap.body.reason, "invalid_credential"); | ||
| 1632 | assert.equal(typeof replayBootstrap.body.traceId, "string"); | ||
| 1633 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1634 | ); | ||
| 1635 | |||
| 1636 | it.effect("ignores forwarded host headers when validating token exchange DPoP URLs", () => | ||
| 1637 | Effect.gen(function* () { | ||
| 1638 | yield* buildAppUnderTest(); | ||
| 1639 | |||
| 1640 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1641 | const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1642 | headers: { | ||
| 1643 | cookie: ownerCookie, | ||
| 1644 | }, | ||
| 1645 | body: yield* HttpBody.json({}), | ||
| 1646 | }); | ||
| 1647 | const credential = (yield* credentialResponse.json) as { | ||
| 1648 | readonly credential: string; | ||
| 1649 | }; | ||
| 1650 | const tokenUrl = yield* getHttpServerUrl("/oauth/token"); | ||
| 1651 | const now = yield* DateTime.now; | ||
| 1652 | const dpop = makeDpopProof({ | ||
| 1653 | method: "POST", | ||
| 1654 | url: tokenUrl, | ||
| 1655 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 1656 | }); | ||
| 1657 | |||
| 1658 | const bootstrap = yield* exchangeAccessToken(credential.credential, { | ||
| 1659 | headers: { | ||
| 1660 | dpop: dpop.proof, | ||
| 1661 | "x-forwarded-host": "environment.example.test", | ||
| 1662 | }, | ||
| 1663 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 1664 | }); | ||
| 1665 | |||
| 1666 | assert.equal(bootstrap.response.status, 200); | ||
| 1667 | assert.equal(bootstrap.body.token_type, "DPoP"); | ||
| 1668 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1669 | ); | ||
| 1670 | |||
| 1671 | it.effect("rejects token exchange DPoP proofs bound to spoofed forwarded hosts", () => | ||
| 1672 | Effect.gen(function* () { | ||
| 1673 | yield* buildAppUnderTest(); | ||
| 1674 | |||
| 1675 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1676 | const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1677 | headers: { | ||
| 1678 | cookie: ownerCookie, | ||
| 1679 | }, | ||
| 1680 | body: yield* HttpBody.json({}), | ||
| 1681 | }); | ||
| 1682 | const credential = (yield* credentialResponse.json) as { | ||
| 1683 | readonly credential: string; | ||
| 1684 | }; | ||
| 1685 | const tokenUrl = yield* getHttpServerUrl("/oauth/token"); | ||
| 1686 | const spoofedUrl = new URL(tokenUrl); | ||
| 1687 | spoofedUrl.hostname = "environment.example.test"; | ||
| 1688 | const now = yield* DateTime.now; | ||
| 1689 | const dpop = makeDpopProof({ | ||
| 1690 | method: "POST", | ||
| 1691 | url: spoofedUrl.href, | ||
| 1692 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 1693 | }); | ||
| 1694 | |||
| 1695 | const bootstrap = yield* exchangeAccessToken(credential.credential, { | ||
| 1696 | headers: { | ||
| 1697 | dpop: dpop.proof, | ||
| 1698 | "x-forwarded-host": spoofedUrl.host, | ||
| 1699 | }, | ||
| 1700 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 1701 | }); | ||
| 1702 | |||
| 1703 | assert.equal(bootstrap.response.status, 401); | ||
| 1704 | assert.equal(bootstrap.body._tag, "EnvironmentAuthInvalidError"); | ||
| 1705 | assert.equal(bootstrap.body.code, "auth_invalid"); | ||
| 1706 | assert.equal(bootstrap.body.reason, "invalid_credential"); | ||
| 1707 | assert.equal(typeof bootstrap.body.traceId, "string"); | ||
| 1708 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1709 | ); | ||
| 1710 | |||
| 1711 | it.effect("rejects cloud link proofs for non-loopback managed endpoint origins", () => | ||
| 1712 | Effect.gen(function* () { | ||
| 1713 | yield* buildAppUnderTest(); | ||
| 1714 | |||
| 1715 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1716 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1717 | const linkProofResponse = yield* fetchEffect(linkProofUrl, { | ||
| 1718 | method: "POST", | ||
| 1719 | headers: { | ||
| 1720 | cookie: ownerCookie, | ||
| 1721 | "content-type": "application/json", | ||
| 1722 | }, | ||
| 1723 | body: jsonRequestBody({ | ||
| 1724 | challenge: "relay-link-challenge", | ||
| 1725 | relayIssuer: "https://relay.example.test", | ||
| 1726 | endpoint: { | ||
| 1727 | httpBaseUrl: "https://environment.example.test/", | ||
| 1728 | wsBaseUrl: "wss://environment.example.test/ws", | ||
| 1729 | providerKind: "manual", | ||
| 1730 | }, | ||
| 1731 | origin: { | ||
| 1732 | localHttpHost: "192.168.1.42", | ||
| 1733 | localHttpPort: 3773, | ||
| 1734 | }, | ||
| 1735 | }), | ||
| 1736 | }); | ||
| 1737 | const body = yield* responseJsonEffect<{ | ||
| 1738 | readonly _tag?: string; | ||
| 1739 | readonly message?: string; | ||
| 1740 | }>(linkProofResponse); | ||
| 1741 | |||
| 1742 | assert.equal(linkProofResponse.status, 400); | ||
| 1743 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1744 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1745 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1746 | ); | ||
| 1747 | |||
| 1748 | it.effect("rejects managed cloud link proofs for manual endpoint providers", () => | ||
| 1749 | Effect.gen(function* () { | ||
| 1750 | yield* buildAppUnderTest(); | ||
| 1751 | |||
| 1752 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1753 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1754 | const serverPort = Number(new URL(linkProofUrl).port); | ||
| 1755 | const linkProofResponse = yield* fetchEffect(linkProofUrl, { | ||
| 1756 | method: "POST", | ||
| 1757 | headers: { | ||
| 1758 | cookie: ownerCookie, | ||
| 1759 | "content-type": "application/json", | ||
| 1760 | }, | ||
| 1761 | body: jsonRequestBody({ | ||
| 1762 | challenge: "relay-link-challenge", | ||
| 1763 | relayIssuer: "https://relay.example.test", | ||
| 1764 | endpoint: { | ||
| 1765 | httpBaseUrl: linkProofUrl.replace("/api/cloud/link-proof", ""), | ||
| 1766 | wsBaseUrl: linkProofUrl | ||
| 1767 | .replace("http://", "ws://") | ||
| 1768 | .replace("/api/cloud/link-proof", "/ws"), | ||
| 1769 | providerKind: "manual", | ||
| 1770 | }, | ||
| 1771 | origin: { | ||
| 1772 | localHttpHost: "127.0.0.1", | ||
| 1773 | localHttpPort: serverPort, | ||
| 1774 | }, | ||
| 1775 | }), | ||
| 1776 | }); | ||
| 1777 | const body = yield* responseJsonEffect<{ | ||
| 1778 | readonly _tag?: string; | ||
| 1779 | readonly message?: string; | ||
| 1780 | }>(linkProofResponse); | ||
| 1781 | |||
| 1782 | assert.equal(linkProofResponse.status, 400); | ||
| 1783 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1784 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1785 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1786 | ); | ||
| 1787 | |||
| 1788 | it.effect("rejects cloud link proofs requested through a public managed endpoint", () => | ||
| 1789 | Effect.gen(function* () { | ||
| 1790 | yield* buildAppUnderTest(); | ||
| 1791 | |||
| 1792 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1793 | const serverPort = Number(new URL(linkProofUrl).port); | ||
| 1794 | const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { | ||
| 1795 | headers: { | ||
| 1796 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 1797 | "content-type": "application/json", | ||
| 1798 | host: "environment.example.test", | ||
| 1799 | "x-forwarded-host": "environment.example.test", | ||
| 1800 | "x-forwarded-proto": "https", | ||
| 1801 | }, | ||
| 1802 | body: HttpBody.text( | ||
| 1803 | jsonRequestBody({ | ||
| 1804 | challenge: "relay-link-challenge", | ||
| 1805 | relayIssuer: "https://relay.example.test", | ||
| 1806 | endpoint: { | ||
| 1807 | httpBaseUrl: "https://environment.example.test/", | ||
| 1808 | wsBaseUrl: "wss://environment.example.test/ws", | ||
| 1809 | providerKind: "manual", | ||
| 1810 | }, | ||
| 1811 | origin: { | ||
| 1812 | localHttpHost: "127.0.0.1", | ||
| 1813 | localHttpPort: serverPort, | ||
| 1814 | }, | ||
| 1815 | }), | ||
| 1816 | "application/json", | ||
| 1817 | ), | ||
| 1818 | }); | ||
| 1819 | const body = (yield* linkProofResponse.json) as { | ||
| 1820 | readonly _tag?: string; | ||
| 1821 | readonly message?: string; | ||
| 1822 | }; | ||
| 1823 | |||
| 1824 | assert.equal(linkProofResponse.status, 400); | ||
| 1825 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1826 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1827 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1828 | ); | ||
| 1829 | |||
| 1830 | it.effect( | ||
| 1831 | "rejects cloud link proofs when a public request spoofs loopback forwarded headers", | ||
| 1832 | () => | ||
| 1833 | Effect.gen(function* () { | ||
| 1834 | yield* buildAppUnderTest(); | ||
| 1835 | |||
| 1836 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1837 | const serverPort = Number(new URL(linkProofUrl).port); | ||
| 1838 | const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { | ||
| 1839 | headers: { | ||
| 1840 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 1841 | "content-type": "application/json", | ||
| 1842 | host: "environment.example.test", | ||
| 1843 | "x-forwarded-host": `127.0.0.1:${serverPort}`, | ||
| 1844 | "x-forwarded-proto": "http", | ||
| 1845 | }, | ||
| 1846 | body: HttpBody.text( | ||
| 1847 | jsonRequestBody({ | ||
| 1848 | challenge: "relay-link-challenge", | ||
| 1849 | relayIssuer: "https://relay.example.test", | ||
| 1850 | endpoint: { | ||
| 1851 | httpBaseUrl: "https://environment.example.test/", | ||
| 1852 | wsBaseUrl: "wss://environment.example.test/ws", | ||
| 1853 | providerKind: "manual", | ||
| 1854 | }, | ||
| 1855 | origin: { | ||
| 1856 | localHttpHost: "127.0.0.1", | ||
| 1857 | localHttpPort: serverPort, | ||
| 1858 | }, | ||
| 1859 | }), | ||
| 1860 | "application/json", | ||
| 1861 | ), | ||
| 1862 | }); | ||
| 1863 | const body = (yield* linkProofResponse.json) as { | ||
| 1864 | readonly _tag?: string; | ||
| 1865 | readonly message?: string; | ||
| 1866 | }; | ||
| 1867 | |||
| 1868 | assert.equal(linkProofResponse.status, 400); | ||
| 1869 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1870 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1871 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1872 | ); | ||
| 1873 | |||
| 1874 | it.effect("rejects cloud link proofs with malformed forwarded request hosts", () => | ||
| 1875 | Effect.gen(function* () { | ||
| 1876 | yield* buildAppUnderTest(); | ||
| 1877 | |||
| 1878 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1879 | const serverPort = Number(new URL(linkProofUrl).port); | ||
| 1880 | const linkProofResponse = yield* HttpClient.post("/api/cloud/link-proof", { | ||
| 1881 | headers: { | ||
| 1882 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 1883 | "content-type": "application/json", | ||
| 1884 | host: "bad host", | ||
| 1885 | "x-forwarded-host": "bad host", | ||
| 1886 | "x-forwarded-proto": "https", | ||
| 1887 | }, | ||
| 1888 | body: HttpBody.text( | ||
| 1889 | jsonRequestBody({ | ||
| 1890 | challenge: "relay-link-challenge", | ||
| 1891 | relayIssuer: "https://relay.example.test", | ||
| 1892 | endpoint: { | ||
| 1893 | httpBaseUrl: "https://environment.example.test/", | ||
| 1894 | wsBaseUrl: "wss://environment.example.test/ws", | ||
| 1895 | providerKind: "manual", | ||
| 1896 | }, | ||
| 1897 | origin: { | ||
| 1898 | localHttpHost: "127.0.0.1", | ||
| 1899 | localHttpPort: serverPort, | ||
| 1900 | }, | ||
| 1901 | }), | ||
| 1902 | "application/json", | ||
| 1903 | ), | ||
| 1904 | }); | ||
| 1905 | const body = (yield* linkProofResponse.json) as { | ||
| 1906 | readonly _tag?: string; | ||
| 1907 | readonly message?: string; | ||
| 1908 | }; | ||
| 1909 | |||
| 1910 | assert.equal(linkProofResponse.status, 400); | ||
| 1911 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1912 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1913 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1914 | ); | ||
| 1915 | |||
| 1916 | it.effect("rejects local cloud link proofs for a different loopback port", () => | ||
| 1917 | Effect.gen(function* () { | ||
| 1918 | yield* buildAppUnderTest(); | ||
| 1919 | |||
| 1920 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1921 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 1922 | const serverPort = Number(new URL(linkProofUrl).port); | ||
| 1923 | const linkProofResponse = yield* fetchEffect(linkProofUrl, { | ||
| 1924 | method: "POST", | ||
| 1925 | headers: { | ||
| 1926 | cookie: ownerCookie, | ||
| 1927 | "content-type": "application/json", | ||
| 1928 | }, | ||
| 1929 | body: jsonRequestBody({ | ||
| 1930 | challenge: "relay-link-challenge", | ||
| 1931 | relayIssuer: "https://relay.example.test", | ||
| 1932 | endpoint: { | ||
| 1933 | httpBaseUrl: "https://environment.example.test/", | ||
| 1934 | wsBaseUrl: "wss://environment.example.test/ws", | ||
| 1935 | providerKind: "manual", | ||
| 1936 | }, | ||
| 1937 | origin: { | ||
| 1938 | localHttpHost: "127.0.0.1", | ||
| 1939 | localHttpPort: serverPort === 65_535 ? serverPort - 1 : serverPort + 1, | ||
| 1940 | }, | ||
| 1941 | }), | ||
| 1942 | }); | ||
| 1943 | const body = yield* responseJsonEffect<{ | ||
| 1944 | readonly _tag?: string; | ||
| 1945 | readonly message?: string; | ||
| 1946 | }>(linkProofResponse); | ||
| 1947 | |||
| 1948 | assert.equal(linkProofResponse.status, 400); | ||
| 1949 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 1950 | assert.equal(body.message, "Invalid managed endpoint origin."); | ||
| 1951 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1952 | ); | ||
| 1953 | |||
| 1954 | it.effect("allows standard clients to read managed relay configuration state", () => | ||
| 1955 | Effect.gen(function* () { | ||
| 1956 | yield* buildAppUnderTest(); | ||
| 1957 | |||
| 1958 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 1959 | const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 1960 | headers: { cookie: ownerCookie }, | ||
| 1961 | body: yield* HttpBody.json({}), | ||
| 1962 | }); | ||
| 1963 | const credential = (yield* credentialResponse.json) as { readonly credential: string }; | ||
| 1964 | const pairedCookie = yield* getAuthenticatedSessionCookieHeader(credential.credential); | ||
| 1965 | const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); | ||
| 1966 | const response = yield* fetchEffect(linkStateUrl, { | ||
| 1967 | headers: { cookie: pairedCookie }, | ||
| 1968 | }); | ||
| 1969 | const body = yield* responseJsonEffect<{ | ||
| 1970 | readonly linked?: boolean; | ||
| 1971 | readonly publishAgentActivity?: boolean; | ||
| 1972 | }>(response); | ||
| 1973 | |||
| 1974 | assert.equal(response.status, 200); | ||
| 1975 | assert.equal(body.linked, false); | ||
| 1976 | assert.equal(body.publishAgentActivity, false); | ||
| 1977 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1978 | ); | ||
| 1979 | |||
| 1980 | it.effect( | ||
| 1981 | "reports relay client status and streams installation progress over environment RPC", | ||
| 1982 | () => | ||
| 1983 | Effect.gen(function* () { | ||
| 1984 | const installedRelayClient = { | ||
| 1985 | status: "available" as const, | ||
| 1986 | executablePath: "/tmp/t3/tools/cloudflared", | ||
| 1987 | source: "managed" as const, | ||
| 1988 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 1989 | }; | ||
| 1990 | yield* buildAppUnderTest({ | ||
| 1991 | layers: { | ||
| 1992 | relayClient: { | ||
| 1993 | resolve: Effect.succeed({ | ||
| 1994 | status: "missing", | ||
| 1995 | version: RelayClient.CLOUDFLARED_VERSION, | ||
| 1996 | }), | ||
| 1997 | install: Effect.succeed(installedRelayClient), | ||
| 1998 | installWithProgress: (report) => | ||
| 1999 | report({ type: "progress", stage: "checking" }).pipe( | ||
| 2000 | Effect.andThen(report({ type: "progress", stage: "downloading" })), | ||
| 2001 | Effect.as(installedRelayClient), | ||
| 2002 | ), | ||
| 2003 | }, | ||
| 2004 | }, | ||
| 2005 | }); | ||
| 2006 | |||
| 2007 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 2008 | const status = yield* Effect.scoped( | ||
| 2009 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.cloudGetRelayClientStatus]({})), | ||
| 2010 | ); | ||
| 2011 | const installEvents = yield* Effect.scoped( | ||
| 2012 | withWsRpcClient(wsUrl, (client) => | ||
| 2013 | client[WS_METHODS.cloudInstallRelayClient]({}).pipe(Stream.runCollect), | ||
| 2014 | ), | ||
| 2015 | ); | ||
| 2016 | |||
| 2017 | assert.equal(status.status, "missing"); | ||
| 2018 | assert.deepEqual(Array.from(installEvents), [ | ||
| 2019 | { type: "progress", stage: "checking" }, | ||
| 2020 | { type: "progress", stage: "downloading" }, | ||
| 2021 | { type: "complete", status: installedRelayClient }, | ||
| 2022 | ]); | ||
| 2023 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2024 | ); | ||
| 2025 | |||
| 2026 | it.effect("requires relay write scope to update agent activity publication", () => | ||
| 2027 | Effect.gen(function* () { | ||
| 2028 | yield* buildAppUnderTest(); | ||
| 2029 | |||
| 2030 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2031 | const preferencesUrl = yield* getHttpServerUrl("/api/cloud/preferences"); | ||
| 2032 | const ownerResponse = yield* fetchEffect(preferencesUrl, { | ||
| 2033 | method: "POST", | ||
| 2034 | headers: { | ||
| 2035 | cookie: ownerCookie, | ||
| 2036 | "content-type": "application/json", | ||
| 2037 | }, | ||
| 2038 | body: jsonRequestBody({ publishAgentActivity: true }), | ||
| 2039 | }); | ||
| 2040 | const ownerBody = yield* responseJsonEffect<{ | ||
| 2041 | readonly publishAgentActivity?: boolean; | ||
| 2042 | }>(ownerResponse); | ||
| 2043 | assert.equal(ownerResponse.status, 200); | ||
| 2044 | assert.equal(ownerBody.publishAgentActivity, true); | ||
| 2045 | |||
| 2046 | const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 2047 | headers: { cookie: ownerCookie }, | ||
| 2048 | body: yield* HttpBody.json({}), | ||
| 2049 | }); | ||
| 2050 | const credential = (yield* credentialResponse.json) as { readonly credential: string }; | ||
| 2051 | const pairedCookie = yield* getAuthenticatedSessionCookieHeader(credential.credential); | ||
| 2052 | const pairedResponse = yield* fetchEffect(preferencesUrl, { | ||
| 2053 | method: "POST", | ||
| 2054 | headers: { | ||
| 2055 | cookie: pairedCookie, | ||
| 2056 | "content-type": "application/json", | ||
| 2057 | }, | ||
| 2058 | body: jsonRequestBody({ publishAgentActivity: false }), | ||
| 2059 | }); | ||
| 2060 | const pairedBody = yield* responseJsonEffect<{ | ||
| 2061 | readonly _tag?: string; | ||
| 2062 | readonly requiredScope?: string; | ||
| 2063 | }>(pairedResponse); | ||
| 2064 | assert.equal(pairedResponse.status, 403); | ||
| 2065 | assert.equal(pairedBody._tag, "EnvironmentScopeRequiredError"); | ||
| 2066 | assert.equal(pairedBody.requiredScope, "relay:write"); | ||
| 2067 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2068 | ); | ||
| 2069 | |||
| 2070 | it.effect("rejects relay config with an invalid cloud mint public key", () => | ||
| 2071 | Effect.gen(function* () { | ||
| 2072 | yield* buildAppUnderTest(); | ||
| 2073 | |||
| 2074 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2075 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2076 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2077 | method: "POST", | ||
| 2078 | headers: { | ||
| 2079 | cookie: ownerCookie, | ||
| 2080 | "content-type": "application/json", | ||
| 2081 | }, | ||
| 2082 | body: jsonRequestBody({ | ||
| 2083 | relayUrl: "https://relay.example.test", | ||
| 2084 | cloudUserId: "user_123", | ||
| 2085 | environmentCredential: "t3env_test_credential", | ||
| 2086 | cloudMintPublicKey: "not-a-public-key", | ||
| 2087 | endpointRuntime: null, | ||
| 2088 | }), | ||
| 2089 | }); | ||
| 2090 | const body = yield* responseJsonEffect<{ | ||
| 2091 | readonly _tag?: string; | ||
| 2092 | readonly message?: string; | ||
| 2093 | }>(relayConfigResponse); | ||
| 2094 | |||
| 2095 | assert.equal(relayConfigResponse.status, 400); | ||
| 2096 | assert.equal(body._tag, "EnvironmentHttpBadRequestError"); | ||
| 2097 | assert.equal(body.message, "Cloud mint public key must be a valid Ed25519 public key."); | ||
| 2098 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2099 | ); | ||
| 2100 | |||
| 2101 | it.effect("rejects relay config with insecure relay metadata or empty credentials", () => | ||
| 2102 | Effect.gen(function* () { | ||
| 2103 | yield* buildAppUnderTest(); | ||
| 2104 | |||
| 2105 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2106 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2107 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2108 | }); | ||
| 2109 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2110 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2111 | const postRelayConfig = (body: { | ||
| 2112 | readonly relayUrl: string; | ||
| 2113 | readonly relayIssuer?: string; | ||
| 2114 | readonly cloudUserId: string; | ||
| 2115 | readonly environmentCredential: string; | ||
| 2116 | }) => | ||
| 2117 | fetchEffect(relayConfigUrl, { | ||
| 2118 | method: "POST", | ||
| 2119 | headers: { | ||
| 2120 | cookie: ownerCookie, | ||
| 2121 | "content-type": "application/json", | ||
| 2122 | }, | ||
| 2123 | body: jsonRequestBody({ | ||
| 2124 | ...body, | ||
| 2125 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2126 | endpointRuntime: null, | ||
| 2127 | }), | ||
| 2128 | }); | ||
| 2129 | |||
| 2130 | const insecureRelayUrl = yield* postRelayConfig({ | ||
| 2131 | relayUrl: "http://relay.example.test", | ||
| 2132 | cloudUserId: "user_123", | ||
| 2133 | environmentCredential: "t3env_test_credential", | ||
| 2134 | }); | ||
| 2135 | const insecureRelayIssuer = yield* postRelayConfig({ | ||
| 2136 | relayUrl: "https://relay.example.test", | ||
| 2137 | cloudUserId: "user_123", | ||
| 2138 | relayIssuer: "http://relay.example.test", | ||
| 2139 | environmentCredential: "t3env_test_credential", | ||
| 2140 | }); | ||
| 2141 | const nonOriginRelayUrl = yield* postRelayConfig({ | ||
| 2142 | relayUrl: "https://relay.example.test/path", | ||
| 2143 | cloudUserId: "user_123", | ||
| 2144 | environmentCredential: "t3env_test_credential", | ||
| 2145 | }); | ||
| 2146 | const emptyCredential = yield* postRelayConfig({ | ||
| 2147 | relayUrl: "https://relay.example.test", | ||
| 2148 | cloudUserId: "user_123", | ||
| 2149 | environmentCredential: " ", | ||
| 2150 | }); | ||
| 2151 | const insecureRelayUrlBody = yield* responseJsonEffect<{ readonly message?: string }>( | ||
| 2152 | insecureRelayUrl, | ||
| 2153 | ); | ||
| 2154 | const insecureRelayIssuerBody = yield* responseJsonEffect<{ readonly message?: string }>( | ||
| 2155 | insecureRelayIssuer, | ||
| 2156 | ); | ||
| 2157 | const nonOriginRelayUrlBody = yield* responseJsonEffect<{ readonly message?: string }>( | ||
| 2158 | nonOriginRelayUrl, | ||
| 2159 | ); | ||
| 2160 | const emptyCredentialBody = yield* responseJsonEffect<{ readonly message?: string }>( | ||
| 2161 | emptyCredential, | ||
| 2162 | ); | ||
| 2163 | |||
| 2164 | assert.equal(insecureRelayUrl.status, 400); | ||
| 2165 | assert.equal(insecureRelayUrlBody.message, "Relay URL must be a secure absolute HTTPS URL."); | ||
| 2166 | assert.equal(insecureRelayIssuer.status, 400); | ||
| 2167 | assert.equal( | ||
| 2168 | insecureRelayIssuerBody.message, | ||
| 2169 | "Relay issuer must be a secure absolute HTTPS URL.", | ||
| 2170 | ); | ||
| 2171 | assert.equal(nonOriginRelayUrl.status, 400); | ||
| 2172 | assert.equal(nonOriginRelayUrlBody.message, "Relay URL must be a secure absolute HTTPS URL."); | ||
| 2173 | assert.equal(emptyCredential.status, 400); | ||
| 2174 | assert.equal(emptyCredentialBody.message, "Relay environment credential is required."); | ||
| 2175 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2176 | ); | ||
| 2177 | |||
| 2178 | it.effect("rejects relay config replacement from a different cloud account", () => | ||
| 2179 | Effect.gen(function* () { | ||
| 2180 | yield* buildAppUnderTest(); | ||
| 2181 | |||
| 2182 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2183 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2184 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2185 | }); | ||
| 2186 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2187 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2188 | const postRelayConfig = (cloudUserId: string, environmentCredential: string) => | ||
| 2189 | fetchEffect(relayConfigUrl, { | ||
| 2190 | method: "POST", | ||
| 2191 | headers: { | ||
| 2192 | cookie: ownerCookie, | ||
| 2193 | "content-type": "application/json", | ||
| 2194 | }, | ||
| 2195 | body: jsonRequestBody({ | ||
| 2196 | relayUrl: "https://relay.example.test", | ||
| 2197 | cloudUserId, | ||
| 2198 | environmentCredential, | ||
| 2199 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2200 | endpointRuntime: null, | ||
| 2201 | }), | ||
| 2202 | }); | ||
| 2203 | |||
| 2204 | const firstResponse = yield* postRelayConfig("user_123", "t3env_first_credential"); | ||
| 2205 | const replacementResponse = yield* postRelayConfig("user_456", "t3env_second_credential"); | ||
| 2206 | const replacementBody = yield* responseJsonEffect<{ | ||
| 2207 | readonly _tag?: string; | ||
| 2208 | readonly message?: string; | ||
| 2209 | }>(replacementResponse); | ||
| 2210 | |||
| 2211 | assert.equal(firstResponse.status, 200); | ||
| 2212 | assert.equal(replacementResponse.status, 409); | ||
| 2213 | assert.equal(replacementBody._tag, "EnvironmentHttpConflictError"); | ||
| 2214 | assert.equal( | ||
| 2215 | replacementBody.message, | ||
| 2216 | "This environment is already linked to a different cloud account. Unlink it before switching accounts.", | ||
| 2217 | ); | ||
| 2218 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2219 | ); | ||
| 2220 | |||
| 2221 | it.effect("reports local cloud link state from persisted relay config", () => | ||
| 2222 | Effect.gen(function* () { | ||
| 2223 | yield* buildAppUnderTest(); | ||
| 2224 | |||
| 2225 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2226 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2227 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2228 | }); | ||
| 2229 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2230 | const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); | ||
| 2231 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2232 | |||
| 2233 | const initialResponse = yield* fetchEffect(linkStateUrl, { | ||
| 2234 | headers: { | ||
| 2235 | cookie: ownerCookie, | ||
| 2236 | }, | ||
| 2237 | }); | ||
| 2238 | const initialBody = yield* responseJsonEffect<{ | ||
| 2239 | readonly linked?: boolean; | ||
| 2240 | readonly cloudUserId?: string | null; | ||
| 2241 | }>(initialResponse); | ||
| 2242 | assert.equal(initialResponse.status, 200); | ||
| 2243 | assert.equal(initialBody.linked, false); | ||
| 2244 | assert.equal(initialBody.cloudUserId, null); | ||
| 2245 | |||
| 2246 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2247 | method: "POST", | ||
| 2248 | headers: { | ||
| 2249 | cookie: ownerCookie, | ||
| 2250 | "content-type": "application/json", | ||
| 2251 | }, | ||
| 2252 | body: jsonRequestBody({ | ||
| 2253 | relayUrl: "https://transport.example.test", | ||
| 2254 | relayIssuer: "https://relay.example.test", | ||
| 2255 | cloudUserId: "user_123", | ||
| 2256 | environmentCredential: "t3env_test_credential", | ||
| 2257 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2258 | endpointRuntime: null, | ||
| 2259 | }), | ||
| 2260 | }); | ||
| 2261 | assert.equal(relayConfigResponse.status, 200); | ||
| 2262 | |||
| 2263 | const linkedResponse = yield* fetchEffect(linkStateUrl, { | ||
| 2264 | headers: { | ||
| 2265 | cookie: ownerCookie, | ||
| 2266 | }, | ||
| 2267 | }); | ||
| 2268 | const linkedBody = yield* responseJsonEffect<{ | ||
| 2269 | readonly linked?: boolean; | ||
| 2270 | readonly cloudUserId?: string | null; | ||
| 2271 | readonly relayUrl?: string | null; | ||
| 2272 | readonly relayIssuer?: string | null; | ||
| 2273 | }>(linkedResponse); | ||
| 2274 | |||
| 2275 | assert.equal(linkedResponse.status, 200); | ||
| 2276 | assert.equal(linkedBody.linked, true); | ||
| 2277 | assert.equal(linkedBody.cloudUserId, "user_123"); | ||
| 2278 | assert.equal(linkedBody.relayUrl, "https://transport.example.test"); | ||
| 2279 | assert.equal(linkedBody.relayIssuer, "https://relay.example.test"); | ||
| 2280 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2281 | ); | ||
| 2282 | |||
| 2283 | it.effect("does not expose internal cloud reconciliation over HTTP", () => | ||
| 2284 | Effect.gen(function* () { | ||
| 2285 | yield* buildAppUnderTest(); | ||
| 2286 | |||
| 2287 | const reconcileUrl = yield* getHttpServerUrl("/api/cloud/reconcile"); | ||
| 2288 | const response = yield* fetchEffect(reconcileUrl, { | ||
| 2289 | method: "POST", | ||
| 2290 | }); | ||
| 2291 | |||
| 2292 | assert.equal(response.status, 404); | ||
| 2293 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2294 | ); | ||
| 2295 | |||
| 2296 | it.effect("unlinks local cloud state and disables the managed endpoint runtime", () => | ||
| 2297 | Effect.gen(function* () { | ||
| 2298 | const appliedRuntimeConfigs: Array<unknown> = []; | ||
| 2299 | yield* buildAppUnderTest({ | ||
| 2300 | layers: { | ||
| 2301 | cloudManagedEndpointRuntime: { | ||
| 2302 | applyConfig: (config) => { | ||
| 2303 | appliedRuntimeConfigs.push(config); | ||
| 2304 | if (!config) { | ||
| 2305 | return Effect.succeed({ status: "disabled" }); | ||
| 2306 | } | ||
| 2307 | return Effect.succeed({ | ||
| 2308 | status: "running", | ||
| 2309 | providerKind: "cloudflare_tunnel", | ||
| 2310 | pid: 123, | ||
| 2311 | ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}), | ||
| 2312 | ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}), | ||
| 2313 | }); | ||
| 2314 | }, | ||
| 2315 | }, | ||
| 2316 | }, | ||
| 2317 | }); | ||
| 2318 | |||
| 2319 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2320 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2321 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2322 | }); | ||
| 2323 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2324 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2325 | const unlinkUrl = yield* getHttpServerUrl("/api/cloud/unlink"); | ||
| 2326 | const linkStateUrl = yield* getHttpServerUrl("/api/cloud/link-state"); | ||
| 2327 | |||
| 2328 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2329 | method: "POST", | ||
| 2330 | headers: { | ||
| 2331 | cookie: ownerCookie, | ||
| 2332 | "content-type": "application/json", | ||
| 2333 | }, | ||
| 2334 | body: jsonRequestBody({ | ||
| 2335 | relayUrl: "https://transport.example.test", | ||
| 2336 | relayIssuer: "https://relay.example.test", | ||
| 2337 | cloudUserId: "user_123", | ||
| 2338 | environmentCredential: "t3env_test_credential", | ||
| 2339 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2340 | endpointRuntime: { | ||
| 2341 | providerKind: "cloudflare_tunnel", | ||
| 2342 | connectorToken: "connector-token", | ||
| 2343 | tunnelId: "tunnel-id", | ||
| 2344 | tunnelName: "tunnel-name", | ||
| 2345 | }, | ||
| 2346 | }), | ||
| 2347 | }); | ||
| 2348 | assert.equal(relayConfigResponse.status, 200); | ||
| 2349 | |||
| 2350 | const unlinkResponse = yield* fetchEffect(unlinkUrl, { | ||
| 2351 | method: "POST", | ||
| 2352 | headers: { | ||
| 2353 | cookie: ownerCookie, | ||
| 2354 | }, | ||
| 2355 | }); | ||
| 2356 | const unlinkBody = yield* responseJsonEffect<{ | ||
| 2357 | readonly ok?: boolean; | ||
| 2358 | readonly endpointRuntimeStatus?: { readonly status?: string }; | ||
| 2359 | }>(unlinkResponse); | ||
| 2360 | assert.equal(unlinkResponse.status, 200); | ||
| 2361 | assert.equal(unlinkBody.ok, true); | ||
| 2362 | assert.equal(unlinkBody.endpointRuntimeStatus?.status, "disabled"); | ||
| 2363 | |||
| 2364 | const linkStateResponse = yield* fetchEffect(linkStateUrl, { | ||
| 2365 | headers: { | ||
| 2366 | cookie: ownerCookie, | ||
| 2367 | }, | ||
| 2368 | }); | ||
| 2369 | const linkStateBody = yield* responseJsonEffect<{ | ||
| 2370 | readonly linked?: boolean; | ||
| 2371 | readonly cloudUserId?: string | null; | ||
| 2372 | readonly relayUrl?: string | null; | ||
| 2373 | readonly relayIssuer?: string | null; | ||
| 2374 | }>(linkStateResponse); | ||
| 2375 | assert.equal(linkStateResponse.status, 200); | ||
| 2376 | assert.equal(linkStateBody.linked, false); | ||
| 2377 | assert.equal(linkStateBody.cloudUserId, null); | ||
| 2378 | assert.equal(linkStateBody.relayUrl, null); | ||
| 2379 | assert.equal(linkStateBody.relayIssuer, null); | ||
| 2380 | assert.deepEqual(appliedRuntimeConfigs, [ | ||
| 2381 | { | ||
| 2382 | providerKind: "cloudflare_tunnel", | ||
| 2383 | connectorToken: "connector-token", | ||
| 2384 | tunnelId: "tunnel-id", | ||
| 2385 | tunnelName: "tunnel-name", | ||
| 2386 | }, | ||
| 2387 | null, | ||
| 2388 | ]); | ||
| 2389 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2390 | ); | ||
| 2391 | |||
| 2392 | it.effect("rejects replayed cloud mint requests atomically", () => | ||
| 2393 | Effect.gen(function* () { | ||
| 2394 | yield* buildAppUnderTest(); | ||
| 2395 | |||
| 2396 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2397 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2398 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2399 | }); | ||
| 2400 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2401 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2402 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2403 | method: "POST", | ||
| 2404 | headers: { | ||
| 2405 | cookie: ownerCookie, | ||
| 2406 | "content-type": "application/json", | ||
| 2407 | }, | ||
| 2408 | body: jsonRequestBody({ | ||
| 2409 | relayUrl: "https://relay.example.test", | ||
| 2410 | cloudUserId: "user_123", | ||
| 2411 | environmentCredential: "t3env_test_credential", | ||
| 2412 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2413 | endpointRuntime: null, | ||
| 2414 | }), | ||
| 2415 | }); | ||
| 2416 | assert.equal(relayConfigResponse.status, 200); | ||
| 2417 | |||
| 2418 | const now = yield* DateTime.now; | ||
| 2419 | const request = makeCloudMintCredentialRequest({ | ||
| 2420 | privateKey: cloudKeyPair.privateKey, | ||
| 2421 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2422 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2423 | nonce: "cloud-mint-nonce-1", | ||
| 2424 | issuedAt: DateTime.formatIso(now), | ||
| 2425 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2426 | }); | ||
| 2427 | const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential"); | ||
| 2428 | const postMint = () => | ||
| 2429 | fetchEffect(mintUrl, { | ||
| 2430 | method: "POST", | ||
| 2431 | headers: { | ||
| 2432 | "content-type": "application/json", | ||
| 2433 | }, | ||
| 2434 | body: jsonRequestBody(request), | ||
| 2435 | }); | ||
| 2436 | |||
| 2437 | const firstResponse = yield* postMint(); | ||
| 2438 | const replayResponse = yield* postMint(); | ||
| 2439 | const replayBody = yield* responseJsonEffect<{ | ||
| 2440 | readonly _tag?: string; | ||
| 2441 | readonly message?: string; | ||
| 2442 | }>(replayResponse); | ||
| 2443 | |||
| 2444 | assert.equal(firstResponse.status, 200); | ||
| 2445 | assert.equal(replayResponse.status, 409); | ||
| 2446 | assert.equal(replayBody._tag, "EnvironmentHttpConflictError"); | ||
| 2447 | assert.equal(replayBody.message, "Cloud mint request was already consumed."); | ||
| 2448 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2449 | ); | ||
| 2450 | |||
| 2451 | it.effect("serves the documented T3 Cloud mint credential endpoint", () => | ||
| 2452 | Effect.gen(function* () { | ||
| 2453 | yield* buildAppUnderTest(); | ||
| 2454 | |||
| 2455 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2456 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2457 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2458 | }); | ||
| 2459 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2460 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2461 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2462 | method: "POST", | ||
| 2463 | headers: { | ||
| 2464 | cookie: ownerCookie, | ||
| 2465 | "content-type": "application/json", | ||
| 2466 | }, | ||
| 2467 | body: jsonRequestBody({ | ||
| 2468 | relayUrl: "https://relay.example.test", | ||
| 2469 | cloudUserId: "user_123", | ||
| 2470 | environmentCredential: "t3env_test_credential", | ||
| 2471 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2472 | endpointRuntime: null, | ||
| 2473 | }), | ||
| 2474 | }); | ||
| 2475 | assert.equal(relayConfigResponse.status, 200); | ||
| 2476 | |||
| 2477 | const now = yield* DateTime.now; | ||
| 2478 | const request = makeCloudMintCredentialRequest({ | ||
| 2479 | privateKey: cloudKeyPair.privateKey, | ||
| 2480 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2481 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2482 | jti: "cloud-mint-jti-documented-endpoint", | ||
| 2483 | nonce: "cloud-mint-nonce-documented-endpoint", | ||
| 2484 | issuedAt: DateTime.formatIso(now), | ||
| 2485 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2486 | }); | ||
| 2487 | const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); | ||
| 2488 | const response = yield* fetchEffect(mintUrl, { | ||
| 2489 | method: "POST", | ||
| 2490 | headers: { | ||
| 2491 | "content-type": "application/json", | ||
| 2492 | }, | ||
| 2493 | body: jsonRequestBody(request), | ||
| 2494 | }); | ||
| 2495 | |||
| 2496 | assert.equal(response.status, 200); | ||
| 2497 | const body = yield* responseJsonEffect<{ | ||
| 2498 | readonly credential?: string; | ||
| 2499 | readonly proof?: string; | ||
| 2500 | }>(response); | ||
| 2501 | assert.equal(typeof body.credential, "string"); | ||
| 2502 | assert.equal(typeof body.proof, "string"); | ||
| 2503 | assert.equal( | ||
| 2504 | decodeCompactJwtPayload<{ readonly requestNonce?: string }>(body.proof!).requestNonce, | ||
| 2505 | "cloud-mint-nonce-documented-endpoint", | ||
| 2506 | ); | ||
| 2507 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2508 | ); | ||
| 2509 | |||
| 2510 | it.effect("serves signed T3 Cloud environment health checks", () => | ||
| 2511 | Effect.gen(function* () { | ||
| 2512 | yield* buildAppUnderTest(); | ||
| 2513 | |||
| 2514 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2515 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2516 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2517 | }); | ||
| 2518 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2519 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2520 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2521 | method: "POST", | ||
| 2522 | headers: { | ||
| 2523 | cookie: ownerCookie, | ||
| 2524 | "content-type": "application/json", | ||
| 2525 | }, | ||
| 2526 | body: jsonRequestBody({ | ||
| 2527 | relayUrl: "https://relay.example.test", | ||
| 2528 | cloudUserId: "user_123", | ||
| 2529 | environmentCredential: "t3env_test_credential", | ||
| 2530 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2531 | endpointRuntime: null, | ||
| 2532 | }), | ||
| 2533 | }); | ||
| 2534 | assert.equal(relayConfigResponse.status, 200); | ||
| 2535 | |||
| 2536 | const now = yield* DateTime.now; | ||
| 2537 | const request = makeCloudEnvironmentHealthRequest({ | ||
| 2538 | privateKey: cloudKeyPair.privateKey, | ||
| 2539 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2540 | jti: "cloud-health-jti-documented-endpoint", | ||
| 2541 | nonce: "cloud-health-nonce-documented-endpoint", | ||
| 2542 | issuedAt: DateTime.formatIso(now), | ||
| 2543 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2544 | }); | ||
| 2545 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 2546 | const response = yield* fetchEffect(healthUrl, { | ||
| 2547 | method: "POST", | ||
| 2548 | headers: { | ||
| 2549 | "content-type": "application/json", | ||
| 2550 | }, | ||
| 2551 | body: jsonRequestBody(request), | ||
| 2552 | }); | ||
| 2553 | |||
| 2554 | assert.equal(response.status, 200); | ||
| 2555 | const body = yield* responseJsonEffect<{ | ||
| 2556 | readonly status?: string; | ||
| 2557 | readonly descriptor?: { readonly environmentId?: string }; | ||
| 2558 | readonly proof?: string; | ||
| 2559 | }>(response); | ||
| 2560 | assert.equal(body.status, "online"); | ||
| 2561 | assert.equal(body.descriptor?.environmentId, testEnvironmentDescriptor.environmentId); | ||
| 2562 | assert.equal(typeof body.proof, "string"); | ||
| 2563 | assert.equal( | ||
| 2564 | decodeCompactJwtPayload<{ readonly requestNonce?: string }>(body.proof!).requestNonce, | ||
| 2565 | "cloud-health-nonce-documented-endpoint", | ||
| 2566 | ); | ||
| 2567 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2568 | ); | ||
| 2569 | |||
| 2570 | it.effect("rejects replayed cloud health requests atomically", () => | ||
| 2571 | Effect.gen(function* () { | ||
| 2572 | yield* buildAppUnderTest(); | ||
| 2573 | |||
| 2574 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2575 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2576 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2577 | }); | ||
| 2578 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2579 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2580 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2581 | method: "POST", | ||
| 2582 | headers: { | ||
| 2583 | cookie: ownerCookie, | ||
| 2584 | "content-type": "application/json", | ||
| 2585 | }, | ||
| 2586 | body: jsonRequestBody({ | ||
| 2587 | relayUrl: "https://relay.example.test", | ||
| 2588 | cloudUserId: "user_123", | ||
| 2589 | environmentCredential: "t3env_test_credential", | ||
| 2590 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2591 | endpointRuntime: null, | ||
| 2592 | }), | ||
| 2593 | }); | ||
| 2594 | assert.equal(relayConfigResponse.status, 200); | ||
| 2595 | |||
| 2596 | const now = yield* DateTime.now; | ||
| 2597 | const request = makeCloudEnvironmentHealthRequest({ | ||
| 2598 | privateKey: cloudKeyPair.privateKey, | ||
| 2599 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2600 | jti: "cloud-health-jti-replay", | ||
| 2601 | nonce: "cloud-health-nonce-replay", | ||
| 2602 | issuedAt: DateTime.formatIso(now), | ||
| 2603 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2604 | }); | ||
| 2605 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 2606 | const postHealth = () => | ||
| 2607 | fetchEffect(healthUrl, { | ||
| 2608 | method: "POST", | ||
| 2609 | headers: { | ||
| 2610 | "content-type": "application/json", | ||
| 2611 | }, | ||
| 2612 | body: jsonRequestBody(request), | ||
| 2613 | }); | ||
| 2614 | |||
| 2615 | const firstResponse = yield* postHealth(); | ||
| 2616 | const replayResponse = yield* postHealth(); | ||
| 2617 | const replayBody = yield* responseJsonEffect<{ | ||
| 2618 | readonly _tag?: string; | ||
| 2619 | readonly message?: string; | ||
| 2620 | }>(replayResponse); | ||
| 2621 | |||
| 2622 | assert.equal(firstResponse.status, 200); | ||
| 2623 | assert.equal(replayResponse.status, 409); | ||
| 2624 | assert.equal(replayBody._tag, "EnvironmentHttpConflictError"); | ||
| 2625 | assert.equal(replayBody.message, "Cloud health request was already consumed."); | ||
| 2626 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2627 | ); | ||
| 2628 | |||
| 2629 | it.effect( | ||
| 2630 | "validates cloud proofs against the configured relay issuer, not the transport URL", | ||
| 2631 | () => | ||
| 2632 | Effect.gen(function* () { | ||
| 2633 | yield* buildAppUnderTest(); | ||
| 2634 | |||
| 2635 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2636 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2637 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2638 | }); | ||
| 2639 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2640 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2641 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2642 | method: "POST", | ||
| 2643 | headers: { | ||
| 2644 | cookie: ownerCookie, | ||
| 2645 | "content-type": "application/json", | ||
| 2646 | }, | ||
| 2647 | body: jsonRequestBody({ | ||
| 2648 | relayUrl: "https://transport.example.test", | ||
| 2649 | cloudUserId: "user_123", | ||
| 2650 | relayIssuer: "https://relay.example.test", | ||
| 2651 | environmentCredential: "t3env_test_credential", | ||
| 2652 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2653 | endpointRuntime: null, | ||
| 2654 | }), | ||
| 2655 | }); | ||
| 2656 | assert.equal(relayConfigResponse.status, 200); | ||
| 2657 | |||
| 2658 | const now = yield* DateTime.now; | ||
| 2659 | const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); | ||
| 2660 | const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) => | ||
| 2661 | fetchEffect(mintUrl, { | ||
| 2662 | method: "POST", | ||
| 2663 | headers: { | ||
| 2664 | "content-type": "application/json", | ||
| 2665 | }, | ||
| 2666 | body: jsonRequestBody(request), | ||
| 2667 | }); | ||
| 2668 | |||
| 2669 | const acceptedResponse = yield* postMint( | ||
| 2670 | makeCloudMintCredentialRequest({ | ||
| 2671 | privateKey: cloudKeyPair.privateKey, | ||
| 2672 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2673 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2674 | issuer: "https://relay.example.test", | ||
| 2675 | jti: "cloud-mint-jti-explicit-relay-issuer", | ||
| 2676 | nonce: "cloud-mint-nonce-explicit-relay-issuer", | ||
| 2677 | issuedAt: DateTime.formatIso(now), | ||
| 2678 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2679 | }), | ||
| 2680 | ); | ||
| 2681 | const rejectedResponse = yield* postMint( | ||
| 2682 | makeCloudMintCredentialRequest({ | ||
| 2683 | privateKey: cloudKeyPair.privateKey, | ||
| 2684 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2685 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2686 | issuer: "https://transport.example.test", | ||
| 2687 | jti: "cloud-mint-jti-transport-url", | ||
| 2688 | nonce: "cloud-mint-nonce-transport-url", | ||
| 2689 | issuedAt: DateTime.formatIso(now), | ||
| 2690 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2691 | }), | ||
| 2692 | ); | ||
| 2693 | |||
| 2694 | assert.equal(acceptedResponse.status, 200); | ||
| 2695 | assert.equal(rejectedResponse.status, 401); | ||
| 2696 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2697 | ); | ||
| 2698 | |||
| 2699 | it.effect("fails relay config when the managed endpoint connector cannot start", () => | ||
| 2700 | Effect.gen(function* () { | ||
| 2701 | yield* buildAppUnderTest({ | ||
| 2702 | layers: { | ||
| 2703 | cloudManagedEndpointRuntime: { | ||
| 2704 | applyConfig: () => | ||
| 2705 | Effect.succeed({ | ||
| 2706 | status: "failed", | ||
| 2707 | providerKind: "cloudflare_tunnel", | ||
| 2708 | reason: "cloudflared missing", | ||
| 2709 | tunnelId: "tunnel-1", | ||
| 2710 | }), | ||
| 2711 | }, | ||
| 2712 | }, | ||
| 2713 | }); | ||
| 2714 | |||
| 2715 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2716 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2717 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2718 | }); | ||
| 2719 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2720 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2721 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2722 | method: "POST", | ||
| 2723 | headers: { | ||
| 2724 | cookie: ownerCookie, | ||
| 2725 | "content-type": "application/json", | ||
| 2726 | }, | ||
| 2727 | body: jsonRequestBody({ | ||
| 2728 | relayUrl: "https://relay.example.test", | ||
| 2729 | cloudUserId: "user_123", | ||
| 2730 | environmentCredential: "t3env_test_credential", | ||
| 2731 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2732 | endpointRuntime: { | ||
| 2733 | providerKind: "cloudflare_tunnel", | ||
| 2734 | connectorToken: "connector-token", | ||
| 2735 | tunnelId: "tunnel-1", | ||
| 2736 | }, | ||
| 2737 | }), | ||
| 2738 | }); | ||
| 2739 | |||
| 2740 | assert.equal(relayConfigResponse.status, 503); | ||
| 2741 | const relayConfigBody = yield* responseJsonEffect<{ | ||
| 2742 | _tag?: string; | ||
| 2743 | message?: string; | ||
| 2744 | endpointRuntimeStatus?: { status?: string; reason?: string }; | ||
| 2745 | }>(relayConfigResponse); | ||
| 2746 | assert.equal(relayConfigBody._tag, "EnvironmentCloudEndpointUnavailableError"); | ||
| 2747 | assert.equal(relayConfigBody.message, "Managed endpoint runtime could not be started."); | ||
| 2748 | assert.equal(relayConfigBody.endpointRuntimeStatus?.status, "failed"); | ||
| 2749 | assert.equal(relayConfigBody.endpointRuntimeStatus?.reason, "cloudflared missing"); | ||
| 2750 | |||
| 2751 | const now = yield* DateTime.now; | ||
| 2752 | const healthRequest = makeCloudEnvironmentHealthRequest({ | ||
| 2753 | privateKey: cloudKeyPair.privateKey, | ||
| 2754 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2755 | nonce: "cloud-health-after-failed-runtime", | ||
| 2756 | issuedAt: DateTime.formatIso(now), | ||
| 2757 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2758 | }); | ||
| 2759 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 2760 | const healthResponse = yield* fetchEffect(healthUrl, { | ||
| 2761 | method: "POST", | ||
| 2762 | headers: { | ||
| 2763 | "content-type": "application/json", | ||
| 2764 | }, | ||
| 2765 | body: jsonRequestBody(healthRequest), | ||
| 2766 | }); | ||
| 2767 | const healthBody = yield* responseJsonEffect<{ | ||
| 2768 | _tag?: string; | ||
| 2769 | message?: string; | ||
| 2770 | }>(healthResponse); | ||
| 2771 | assert.equal(healthResponse.status, 500); | ||
| 2772 | assert.equal(healthBody._tag, "EnvironmentHttpInternalServerError"); | ||
| 2773 | assert.equal( | ||
| 2774 | healthBody.message, | ||
| 2775 | "Cloud mint public key is not installed for this environment.", | ||
| 2776 | ); | ||
| 2777 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2778 | ); | ||
| 2779 | |||
| 2780 | it.effect("rejects cloud mint requests with the wrong issuer or audience", () => | ||
| 2781 | Effect.gen(function* () { | ||
| 2782 | yield* buildAppUnderTest(); | ||
| 2783 | |||
| 2784 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2785 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2786 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2787 | }); | ||
| 2788 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2789 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2790 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2791 | method: "POST", | ||
| 2792 | headers: { | ||
| 2793 | cookie: ownerCookie, | ||
| 2794 | "content-type": "application/json", | ||
| 2795 | }, | ||
| 2796 | body: jsonRequestBody({ | ||
| 2797 | relayUrl: "https://relay.example.test/", | ||
| 2798 | cloudUserId: "user_123", | ||
| 2799 | environmentCredential: "t3env_test_credential", | ||
| 2800 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2801 | endpointRuntime: null, | ||
| 2802 | }), | ||
| 2803 | }); | ||
| 2804 | assert.equal(relayConfigResponse.status, 200); | ||
| 2805 | |||
| 2806 | const now = yield* DateTime.now; | ||
| 2807 | const mintUrl = yield* getHttpServerUrl("/api/cloud/mint-credential"); | ||
| 2808 | const postMint = (request: ReturnType<typeof makeCloudMintCredentialRequest>) => | ||
| 2809 | fetchEffect(mintUrl, { | ||
| 2810 | method: "POST", | ||
| 2811 | headers: { | ||
| 2812 | "content-type": "application/json", | ||
| 2813 | }, | ||
| 2814 | body: jsonRequestBody(request), | ||
| 2815 | }); | ||
| 2816 | |||
| 2817 | const wrongIssuer = yield* postMint( | ||
| 2818 | makeCloudMintCredentialRequest({ | ||
| 2819 | privateKey: cloudKeyPair.privateKey, | ||
| 2820 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2821 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2822 | issuer: "https://attacker.example.test", | ||
| 2823 | jti: "cloud-mint-jti-wrong-issuer", | ||
| 2824 | nonce: "cloud-mint-nonce-wrong-issuer", | ||
| 2825 | issuedAt: DateTime.formatIso(now), | ||
| 2826 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2827 | }), | ||
| 2828 | ); | ||
| 2829 | const wrongAudience = yield* postMint( | ||
| 2830 | makeCloudMintCredentialRequest({ | ||
| 2831 | privateKey: cloudKeyPair.privateKey, | ||
| 2832 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2833 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2834 | audience: "t3-env:other-environment", | ||
| 2835 | jti: "cloud-mint-jti-wrong-audience", | ||
| 2836 | nonce: "cloud-mint-nonce-wrong-audience", | ||
| 2837 | issuedAt: DateTime.formatIso(now), | ||
| 2838 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2839 | }), | ||
| 2840 | ); | ||
| 2841 | |||
| 2842 | assert.equal(wrongIssuer.status, 401); | ||
| 2843 | assert.equal(wrongAudience.status, 401); | ||
| 2844 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2845 | ); | ||
| 2846 | |||
| 2847 | it.effect("rejects cloud mint requests for a cloud subject other than the linked user", () => | ||
| 2848 | Effect.gen(function* () { | ||
| 2849 | yield* buildAppUnderTest(); | ||
| 2850 | |||
| 2851 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2852 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2853 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2854 | }); | ||
| 2855 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2856 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2857 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2858 | method: "POST", | ||
| 2859 | headers: { | ||
| 2860 | cookie: ownerCookie, | ||
| 2861 | "content-type": "application/json", | ||
| 2862 | }, | ||
| 2863 | body: jsonRequestBody({ | ||
| 2864 | relayUrl: "https://relay.example.test/", | ||
| 2865 | cloudUserId: "user_123", | ||
| 2866 | environmentCredential: "t3env_test_credential", | ||
| 2867 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2868 | endpointRuntime: null, | ||
| 2869 | }), | ||
| 2870 | }); | ||
| 2871 | assert.equal(relayConfigResponse.status, 200); | ||
| 2872 | |||
| 2873 | const now = yield* DateTime.now; | ||
| 2874 | const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); | ||
| 2875 | const response = yield* fetchEffect(mintUrl, { | ||
| 2876 | method: "POST", | ||
| 2877 | headers: { | ||
| 2878 | "content-type": "application/json", | ||
| 2879 | }, | ||
| 2880 | body: jsonRequestBody( | ||
| 2881 | makeCloudMintCredentialRequest({ | ||
| 2882 | privateKey: cloudKeyPair.privateKey, | ||
| 2883 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2884 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2885 | subject: "user_other", | ||
| 2886 | jti: "cloud-mint-jti-wrong-subject", | ||
| 2887 | nonce: "cloud-mint-nonce-wrong-subject", | ||
| 2888 | issuedAt: DateTime.formatIso(now), | ||
| 2889 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2890 | }), | ||
| 2891 | ), | ||
| 2892 | }); | ||
| 2893 | |||
| 2894 | assert.equal(response.status, 401); | ||
| 2895 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2896 | ); | ||
| 2897 | |||
| 2898 | it.effect("rejects cloud mint requests without the exact connect scope", () => | ||
| 2899 | Effect.gen(function* () { | ||
| 2900 | yield* buildAppUnderTest(); | ||
| 2901 | |||
| 2902 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2903 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2904 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2905 | }); | ||
| 2906 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2907 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2908 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2909 | method: "POST", | ||
| 2910 | headers: { | ||
| 2911 | cookie: ownerCookie, | ||
| 2912 | "content-type": "application/json", | ||
| 2913 | }, | ||
| 2914 | body: jsonRequestBody({ | ||
| 2915 | relayUrl: "https://relay.example.test/", | ||
| 2916 | cloudUserId: "user_123", | ||
| 2917 | environmentCredential: "t3env_test_credential", | ||
| 2918 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2919 | endpointRuntime: null, | ||
| 2920 | }), | ||
| 2921 | }); | ||
| 2922 | assert.equal(relayConfigResponse.status, 200); | ||
| 2923 | |||
| 2924 | const now = yield* DateTime.now; | ||
| 2925 | const mintUrl = yield* getHttpServerUrl("/api/t3-cloud/mint-credential"); | ||
| 2926 | const response = yield* fetchEffect(mintUrl, { | ||
| 2927 | method: "POST", | ||
| 2928 | headers: { | ||
| 2929 | "content-type": "application/json", | ||
| 2930 | }, | ||
| 2931 | body: jsonRequestBody( | ||
| 2932 | makeCloudMintCredentialRequest({ | ||
| 2933 | privateKey: cloudKeyPair.privateKey, | ||
| 2934 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2935 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 2936 | jti: "cloud-mint-jti-duplicate-scope", | ||
| 2937 | nonce: "cloud-mint-nonce-duplicate-scope", | ||
| 2938 | issuedAt: DateTime.formatIso(now), | ||
| 2939 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2940 | scope: ["environment:connect", "environment:connect"], | ||
| 2941 | }), | ||
| 2942 | ), | ||
| 2943 | }); | ||
| 2944 | |||
| 2945 | assert.equal(response.status, 401); | ||
| 2946 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 2947 | ); | ||
| 2948 | |||
| 2949 | it.effect("rejects cloud health requests with the wrong issuer or audience", () => | ||
| 2950 | Effect.gen(function* () { | ||
| 2951 | yield* buildAppUnderTest(); | ||
| 2952 | |||
| 2953 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 2954 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 2955 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 2956 | }); | ||
| 2957 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 2958 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 2959 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 2960 | method: "POST", | ||
| 2961 | headers: { | ||
| 2962 | cookie: ownerCookie, | ||
| 2963 | "content-type": "application/json", | ||
| 2964 | }, | ||
| 2965 | body: jsonRequestBody({ | ||
| 2966 | relayUrl: "https://relay.example.test/", | ||
| 2967 | cloudUserId: "user_123", | ||
| 2968 | environmentCredential: "t3env_test_credential", | ||
| 2969 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 2970 | endpointRuntime: null, | ||
| 2971 | }), | ||
| 2972 | }); | ||
| 2973 | assert.equal(relayConfigResponse.status, 200); | ||
| 2974 | |||
| 2975 | const now = yield* DateTime.now; | ||
| 2976 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 2977 | const postHealth = (request: ReturnType<typeof makeCloudEnvironmentHealthRequest>) => | ||
| 2978 | fetchEffect(healthUrl, { | ||
| 2979 | method: "POST", | ||
| 2980 | headers: { | ||
| 2981 | "content-type": "application/json", | ||
| 2982 | }, | ||
| 2983 | body: jsonRequestBody(request), | ||
| 2984 | }); | ||
| 2985 | |||
| 2986 | const wrongIssuer = yield* postHealth( | ||
| 2987 | makeCloudEnvironmentHealthRequest({ | ||
| 2988 | privateKey: cloudKeyPair.privateKey, | ||
| 2989 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 2990 | issuer: "https://attacker.example.test", | ||
| 2991 | jti: "cloud-health-jti-wrong-issuer", | ||
| 2992 | nonce: "cloud-health-nonce-wrong-issuer", | ||
| 2993 | issuedAt: DateTime.formatIso(now), | ||
| 2994 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 2995 | }), | ||
| 2996 | ); | ||
| 2997 | const wrongAudience = yield* postHealth( | ||
| 2998 | makeCloudEnvironmentHealthRequest({ | ||
| 2999 | privateKey: cloudKeyPair.privateKey, | ||
| 3000 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 3001 | audience: "t3-env:other-environment", | ||
| 3002 | jti: "cloud-health-jti-wrong-audience", | ||
| 3003 | nonce: "cloud-health-nonce-wrong-audience", | ||
| 3004 | issuedAt: DateTime.formatIso(now), | ||
| 3005 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 3006 | }), | ||
| 3007 | ); | ||
| 3008 | |||
| 3009 | assert.equal(wrongIssuer.status, 401); | ||
| 3010 | assert.equal(wrongAudience.status, 401); | ||
| 3011 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3012 | ); | ||
| 3013 | |||
| 3014 | it.effect("rejects cloud health requests for a cloud subject other than the linked user", () => | ||
| 3015 | Effect.gen(function* () { | ||
| 3016 | yield* buildAppUnderTest(); | ||
| 3017 | |||
| 3018 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 3019 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 3020 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 3021 | }); | ||
| 3022 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 3023 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 3024 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 3025 | method: "POST", | ||
| 3026 | headers: { | ||
| 3027 | cookie: ownerCookie, | ||
| 3028 | "content-type": "application/json", | ||
| 3029 | }, | ||
| 3030 | body: jsonRequestBody({ | ||
| 3031 | relayUrl: "https://relay.example.test/", | ||
| 3032 | cloudUserId: "user_123", | ||
| 3033 | environmentCredential: "t3env_test_credential", | ||
| 3034 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 3035 | endpointRuntime: null, | ||
| 3036 | }), | ||
| 3037 | }); | ||
| 3038 | assert.equal(relayConfigResponse.status, 200); | ||
| 3039 | |||
| 3040 | const now = yield* DateTime.now; | ||
| 3041 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 3042 | const response = yield* fetchEffect(healthUrl, { | ||
| 3043 | method: "POST", | ||
| 3044 | headers: { | ||
| 3045 | "content-type": "application/json", | ||
| 3046 | }, | ||
| 3047 | body: jsonRequestBody( | ||
| 3048 | makeCloudEnvironmentHealthRequest({ | ||
| 3049 | privateKey: cloudKeyPair.privateKey, | ||
| 3050 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 3051 | subject: "user_other", | ||
| 3052 | jti: "cloud-health-jti-wrong-subject", | ||
| 3053 | nonce: "cloud-health-nonce-wrong-subject", | ||
| 3054 | issuedAt: DateTime.formatIso(now), | ||
| 3055 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 3056 | }), | ||
| 3057 | ), | ||
| 3058 | }); | ||
| 3059 | |||
| 3060 | assert.equal(response.status, 401); | ||
| 3061 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3062 | ); | ||
| 3063 | |||
| 3064 | it.effect("rejects cloud health requests without the exact status scope", () => | ||
| 3065 | Effect.gen(function* () { | ||
| 3066 | yield* buildAppUnderTest(); | ||
| 3067 | |||
| 3068 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 3069 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 3070 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 3071 | }); | ||
| 3072 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | ||
| 3073 | const relayConfigUrl = yield* getHttpServerUrl("/api/cloud/relay-config"); | ||
| 3074 | const relayConfigResponse = yield* fetchEffect(relayConfigUrl, { | ||
| 3075 | method: "POST", | ||
| 3076 | headers: { | ||
| 3077 | cookie: ownerCookie, | ||
| 3078 | "content-type": "application/json", | ||
| 3079 | }, | ||
| 3080 | body: jsonRequestBody({ | ||
| 3081 | relayUrl: "https://relay.example.test/", | ||
| 3082 | cloudUserId: "user_123", | ||
| 3083 | environmentCredential: "t3env_test_credential", | ||
| 3084 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 3085 | endpointRuntime: null, | ||
| 3086 | }), | ||
| 3087 | }); | ||
| 3088 | assert.equal(relayConfigResponse.status, 200); | ||
| 3089 | |||
| 3090 | const now = yield* DateTime.now; | ||
| 3091 | const healthUrl = yield* getHttpServerUrl("/api/t3-cloud/health"); | ||
| 3092 | const response = yield* fetchEffect(healthUrl, { | ||
| 3093 | method: "POST", | ||
| 3094 | headers: { | ||
| 3095 | "content-type": "application/json", | ||
| 3096 | }, | ||
| 3097 | body: jsonRequestBody( | ||
| 3098 | makeCloudEnvironmentHealthRequest({ | ||
| 3099 | privateKey: cloudKeyPair.privateKey, | ||
| 3100 | environmentId: testEnvironmentDescriptor.environmentId, | ||
| 3101 | jti: "cloud-health-jti-duplicate-scope", | ||
| 3102 | nonce: "cloud-health-nonce-duplicate-scope", | ||
| 3103 | issuedAt: DateTime.formatIso(now), | ||
| 3104 | expiresAt: DateTime.formatIso(DateTime.add(now, { minutes: 5 })), | ||
| 3105 | scope: ["environment:status", "environment:status"], | ||
| 3106 | }), | ||
| 3107 | ), | ||
| 3108 | }); | ||
| 3109 | |||
| 3110 | assert.equal(response.status, 401); | ||
| 3111 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3112 | ); | ||
| 3113 | |||
| 3114 | it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => | ||
| 3115 | Effect.gen(function* () { | ||
| 3116 | yield* buildAppUnderTest(); | ||
| 3117 | |||
| 3118 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | ||
| 3119 | const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); | ||
| 3120 | const wsTicketResponse = yield* fetchEffect(wsTicketUrl, { | ||
| 3121 | method: "POST", | ||
| 3122 | headers: { | ||
| 3123 | authorization: `Bearer ${bearerToken}`, | ||
| 3124 | }, | ||
| 3125 | }); | ||
| 3126 | const wsTicketBody = yield* responseJsonEffect<{ | ||
| 3127 | readonly ticket: string; | ||
| 3128 | readonly expiresAt: string; | ||
| 3129 | }>(wsTicketResponse); | ||
| 3130 | |||
| 3131 | assert.equal(wsTicketResponse.status, 200); | ||
| 3132 | assert.equal(typeof wsTicketBody.ticket, "string"); | ||
| 3133 | assert.isTrue(wsTicketBody.ticket.length > 0); | ||
| 3134 | assert.equal(typeof wsTicketBody.expiresAt, "string"); | ||
| 3135 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3136 | ); | ||
| 3137 | |||
| 3138 | it.effect("does not allow management-only access tokens to operate the environment", () => | ||
| 3139 | Effect.gen(function* () { | ||
| 3140 | yield* buildAppUnderTest(); | ||
| 3141 | |||
| 3142 | const { response: exchangeResponse, body: tokenBody } = yield* exchangeAccessToken( | ||
| 3143 | defaultDesktopBootstrapToken, | ||
| 3144 | { scope: "access:write" }, | ||
| 3145 | ); | ||
| 3146 | assert.equal(exchangeResponse.status, 200); | ||
| 3147 | assert.equal(tokenBody.scope, "access:write"); | ||
| 3148 | assert.isDefined(tokenBody.access_token); | ||
| 3149 | |||
| 3150 | const overbroadPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 3151 | headers: { | ||
| 3152 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3153 | }, | ||
| 3154 | body: yield* HttpBody.json({}), | ||
| 3155 | }); | ||
| 3156 | const overbroadPairingBody = (yield* overbroadPairingResponse.json) as { | ||
| 3157 | readonly requiredScope: string; | ||
| 3158 | }; | ||
| 3159 | const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 3160 | headers: { | ||
| 3161 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3162 | }, | ||
| 3163 | body: yield* HttpBody.json({ scopes: ["access:write"] }), | ||
| 3164 | }); | ||
| 3165 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | ||
| 3166 | headers: { | ||
| 3167 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3168 | }, | ||
| 3169 | }); | ||
| 3170 | const wsTicketBody = (yield* wsTicketResponse.json) as { readonly ticket: string }; | ||
| 3171 | const faviconResponse = yield* HttpClient.get("/api/project-favicon?cwd=/tmp", { | ||
| 3172 | headers: { | ||
| 3173 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3174 | }, | ||
| 3175 | }); | ||
| 3176 | const faviconBody = (yield* faviconResponse.json) as { | ||
| 3177 | readonly _tag: string; | ||
| 3178 | readonly code: string; | ||
| 3179 | readonly requiredScope: string; | ||
| 3180 | readonly traceId: string; | ||
| 3181 | }; | ||
| 3182 | |||
| 3183 | assert.equal(overbroadPairingResponse.status, 403); | ||
| 3184 | assert.equal(overbroadPairingBody.requiredScope, "orchestration:read"); | ||
| 3185 | assert.equal(pairingResponse.status, 200); | ||
| 3186 | assert.equal(wsTicketResponse.status, 200); | ||
| 3187 | assert.equal(faviconResponse.status, 403); | ||
| 3188 | assert.equal(faviconBody._tag, "EnvironmentScopeRequiredError"); | ||
| 3189 | assert.equal(faviconBody.code, "insufficient_scope"); | ||
| 3190 | assert.equal(faviconBody.requiredScope, "orchestration:read"); | ||
| 3191 | assert.equal(typeof faviconBody.traceId, "string"); | ||
| 3192 | |||
| 3193 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; | ||
| 3194 | const rpcError = yield* Effect.flip( | ||
| 3195 | Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), | ||
| 3196 | ); | ||
| 3197 | assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); | ||
| 3198 | if (rpcError._tag === "EnvironmentAuthorizationError") { | ||
| 3199 | assert.equal(rpcError.requiredScope, "orchestration:read"); | ||
| 3200 | } | ||
| 3201 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3202 | ); | ||
| 3203 | |||
| 3204 | it.effect("includes CORS headers on remote auth success responses", () => | ||
| 3205 | Effect.gen(function* () { | ||
| 3206 | yield* buildAppUnderTest(); | ||
| 3207 | |||
| 3208 | const origin = crossOriginClientOrigin; | ||
| 3209 | const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken( | ||
| 3210 | defaultDesktopBootstrapToken, | ||
| 3211 | { | ||
| 3212 | headers: { origin }, | ||
| 3213 | }, | ||
| 3214 | ); | ||
| 3215 | |||
| 3216 | assert.equal(tokenResponse.status, 200); | ||
| 3217 | assertBrowserApiCorsResponseHeaders(tokenResponse.headers); | ||
| 3218 | assert.equal(tokenBody.token_type, "Bearer"); | ||
| 3219 | assert.equal(typeof tokenBody.access_token, "string"); | ||
| 3220 | |||
| 3221 | const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); | ||
| 3222 | const sessionResponse = yield* fetchEffect(sessionUrl, { | ||
| 3223 | headers: { | ||
| 3224 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3225 | origin, | ||
| 3226 | }, | ||
| 3227 | }); | ||
| 3228 | const sessionBody = yield* responseJsonEffect<{ | ||
| 3229 | readonly authenticated: boolean; | ||
| 3230 | readonly sessionMethod?: string; | ||
| 3231 | }>(sessionResponse); | ||
| 3232 | |||
| 3233 | assert.equal(sessionResponse.status, 200); | ||
| 3234 | assertBrowserApiCorsResponseHeaders(sessionResponse.headers); | ||
| 3235 | assert.equal(sessionBody.authenticated, true); | ||
| 3236 | assert.equal(sessionBody.sessionMethod, "bearer-access-token"); | ||
| 3237 | |||
| 3238 | const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); | ||
| 3239 | const wsTicketResponse = yield* fetchEffect(wsTicketUrl, { | ||
| 3240 | method: "POST", | ||
| 3241 | headers: { | ||
| 3242 | authorization: `Bearer ${tokenBody.access_token ?? ""}`, | ||
| 3243 | origin, | ||
| 3244 | }, | ||
| 3245 | }); | ||
| 3246 | const wsTicketBody = yield* responseJsonEffect<{ | ||
| 3247 | readonly ticket: string; | ||
| 3248 | }>(wsTicketResponse); | ||
| 3249 | |||
| 3250 | assert.equal(wsTicketResponse.status, 200); | ||
| 3251 | assertBrowserApiCorsResponseHeaders(wsTicketResponse.headers); | ||
| 3252 | assert.equal(typeof wsTicketBody.ticket, "string"); | ||
| 3253 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3254 | ); | ||
| 3255 | |||
| 3256 | it.effect( | ||
| 3257 | "responds to remote auth websocket-ticket preflight requests with authorization CORS headers", | ||
| 3258 | () => | ||
| 3259 | Effect.gen(function* () { | ||
| 3260 | yield* buildAppUnderTest(); | ||
| 3261 | |||
| 3262 | const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); | ||
| 3263 | const response = yield* fetchEffect(wsTicketUrl, { | ||
| 3264 | method: "OPTIONS", | ||
| 3265 | headers: { | ||
| 3266 | origin: crossOriginClientOrigin, | ||
| 3267 | "access-control-request-method": "POST", | ||
| 3268 | "access-control-request-headers": "authorization", | ||
| 3269 | }, | ||
| 3270 | }); | ||
| 3271 | |||
| 3272 | assert.equal(response.status, 204); | ||
| 3273 | assertBrowserApiCorsPreflightHeaders(response.headers); | ||
| 3274 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3275 | ); | ||
| 3276 | |||
| 3277 | it.effect("allows credentialed cloud link proof preflights from the configured dev UI", () => | ||
| 3278 | Effect.gen(function* () { | ||
| 3279 | yield* buildAppUnderTest({ | ||
| 3280 | config: { devUrl: new URL(crossOriginClientOrigin) }, | ||
| 3281 | }); | ||
| 3282 | |||
| 3283 | const linkProofUrl = yield* getHttpServerUrl("/api/cloud/link-proof"); | ||
| 3284 | const response = yield* fetchEffect(linkProofUrl, { | ||
| 3285 | method: "OPTIONS", | ||
| 3286 | headers: { | ||
| 3287 | origin: crossOriginClientOrigin, | ||
| 3288 | "access-control-request-method": "POST", | ||
| 3289 | "access-control-request-headers": "content-type", | ||
| 3290 | }, | ||
| 3291 | }); | ||
| 3292 | |||
| 3293 | assert.equal(response.status, 204); | ||
| 3294 | assertBrowserApiCorsPreflightHeaders(response.headers, { | ||
| 3295 | origin: crossOriginClientOrigin, | ||
| 3296 | credentials: true, | ||
| 3297 | }); | ||
| 3298 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3299 | ); | ||
| 3300 | |||
| 3301 | it.effect("includes CORS headers on remote websocket-ticket auth failures", () => | ||
| 3302 | Effect.gen(function* () { | ||
| 3303 | yield* buildAppUnderTest(); | ||
| 3304 | |||
| 3305 | const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); | ||
| 3306 | const response = yield* fetchEffect(wsTicketUrl, { | ||
| 3307 | method: "POST", | ||
| 3308 | headers: { | ||
| 3309 | origin: crossOriginClientOrigin, | ||
| 3310 | }, | ||
| 3311 | }); | ||
| 3312 | const body = yield* responseJsonEffect<{ | ||
| 3313 | readonly _tag?: string; | ||
| 3314 | readonly code?: string; | ||
| 3315 | readonly reason?: string; | ||
| 3316 | readonly traceId?: string; | ||
| 3317 | }>(response); | ||
| 3318 | |||
| 3319 | assert.equal(response.status, 401); | ||
| 3320 | assertBrowserApiCorsResponseHeaders(response.headers); | ||
| 3321 | assert.equal(body._tag, "EnvironmentAuthInvalidError"); | ||
| 3322 | assert.equal(body.code, "auth_invalid"); | ||
| 3323 | assert.equal(body.reason, "missing_credential"); | ||
| 3324 | assert.equal(typeof body.traceId, "string"); | ||
| 3325 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 3326 | ); | ||
| 3327 | |||
| 3328 | it.effect("issues authenticated one-time pairing credentials for additional clients", () => | ||
| 3329 | Effect.gen(function* () { | ||
| 3330 | yield* buildAppUnderTest(); | ||
| 3331 | |||
| 3332 | const response = yield* HttpClient.post("/api/auth/pairing-token", { | ||
| 3333 | headers: { | ||
| 3334 | cookie: yield* getAuthenticatedSessionCookieHeader(), | ||
| 3335 | }, | ||
| 3336 | body: yield* HttpBody.json({}), | ||
| 3337 | }); | ||
| 3338 | const body = (yield* response.json) as { | ||
| 3339 | readonly credential: string; | ||
| 3340 | readonly expiresAt: string; | ||
| 1439 | }; | 3341 | }; |
| 1440 | 3342 | ||
| 1441 | assert.equal(response.status, 200); | 3343 | assert.equal(response.status, 200); |
| ... | @@ -1538,8 +3440,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1538,8 +3440,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1538 | const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", { | 3440 | const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", { |
| 1539 | headers: { | 3441 | headers: { |
| 1540 | cookie: ownerCookie, | 3442 | cookie: ownerCookie, |
| 3443 | "content-type": "application/json", | ||
| 1541 | }, | 3444 | }, |
| 1542 | body: yield* HttpBody.json({ id: createdBody.id }), | 3445 | body: HttpBody.text(jsonRequestBody({ id: createdBody.id }), "application/json"), |
| 1543 | }); | 3446 | }); |
| 1544 | const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential); | 3447 | const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential); |
| 1545 | 3448 | ||
| ... | @@ -1601,16 +3504,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1601,16 +3504,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1601 | }); | 3504 | }); |
| 1602 | 3505 | ||
| 1603 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); | 3506 | const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); |
| 1604 | const ownerPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | 3507 | const pairingTokenUrl = yield* getHttpServerUrl("/api/auth/pairing-token"); |
| 3508 | const ownerPairingResponse = yield* fetchEffect(pairingTokenUrl, { | ||
| 3509 | method: "POST", | ||
| 1605 | headers: { | 3510 | headers: { |
| 1606 | cookie: ownerCookie, | 3511 | cookie: ownerCookie, |
| 3512 | "content-type": "application/json", | ||
| 1607 | }, | 3513 | }, |
| 1608 | body: yield* HttpBody.json({ label: "Julius iPhone" }), | 3514 | body: jsonRequestBody({ |
| 3515 | label: "Julius iPhone", | ||
| 3516 | }), | ||
| 1609 | }); | 3517 | }); |
| 1610 | const ownerPairingBody = (yield* ownerPairingResponse.json) as { | 3518 | const ownerPairingBody = yield* responseJsonEffect<{ |
| 1611 | readonly credential: string; | 3519 | readonly credential: string; |
| 1612 | readonly label?: string; | 3520 | readonly label?: string; |
| 1613 | }; | 3521 | }>(ownerPairingResponse); |
| 1614 | assert.equal(ownerPairingResponse.status, 200); | 3522 | assert.equal(ownerPairingResponse.status, 200); |
| 1615 | const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, { | 3523 | const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, { |
| 1616 | headers: { | 3524 | headers: { |
| ... | @@ -1794,8 +3702,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1794,8 +3702,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1794 | const revokeResponse = yield* HttpClient.post("/api/auth/clients/revoke", { | 3702 | const revokeResponse = yield* HttpClient.post("/api/auth/clients/revoke", { |
| 1795 | headers: { | 3703 | headers: { |
| 1796 | cookie: ownerCookie, | 3704 | cookie: ownerCookie, |
| 3705 | "content-type": "application/json", | ||
| 1797 | }, | 3706 | }, |
| 1798 | body: yield* HttpBody.json({ sessionId: pairedSessionId }), | 3707 | body: HttpBody.text(jsonRequestBody({ sessionId: pairedSessionId }), "application/json"), |
| 1799 | }); | 3708 | }); |
| 1800 | const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { | 3709 | const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { |
| 1801 | headers: { | 3710 | headers: { |
| ... | @@ -1890,20 +3799,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1890,20 +3799,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1890 | ); | 3799 | ); |
| 1891 | 3800 | ||
| 1892 | it.effect( | 3801 | it.effect( |
| 1893 | "accepts websocket rpc handshake with a dedicated websocket token in the query string", | 3802 | "accepts websocket rpc handshake with a dedicated websocket ticket in the query string", |
| 1894 | () => | 3803 | () => |
| 1895 | Effect.gen(function* () { | 3804 | Effect.gen(function* () { |
| 1896 | yield* buildAppUnderTest(); | 3805 | yield* buildAppUnderTest(); |
| 1897 | 3806 | ||
| 1898 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); | 3807 | const bearerToken = yield* getAuthenticatedBearerSessionToken(); |
| 1899 | const wsTicketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", { | 3808 | const wsTicketUrl = yield* getHttpServerUrl("/api/auth/websocket-ticket"); |
| 3809 | const wsTicketResponse = yield* fetchEffect(wsTicketUrl, { | ||
| 3810 | method: "POST", | ||
| 1900 | headers: { | 3811 | headers: { |
| 1901 | authorization: `Bearer ${bearerToken}`, | 3812 | authorization: `Bearer ${bearerToken}`, |
| 1902 | }, | 3813 | }, |
| 1903 | }); | 3814 | }); |
| 1904 | const wsTicketBody = (yield* wsTicketResponse.json) as { | 3815 | const wsTicketBody = yield* responseJsonEffect<{ |
| 1905 | readonly ticket: string; | 3816 | readonly ticket: string; |
| 1906 | }; | 3817 | }>(wsTicketResponse); |
| 1907 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; | 3818 | const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`; |
| 1908 | 3819 | ||
| 1909 | const response = yield* Effect.scoped( | 3820 | const response = yield* Effect.scoped( |
| ... | @@ -2103,7 +4014,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2103,7 +4014,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2103 | "content-type": "application/json", | 4014 | "content-type": "application/json", |
| 2104 | origin: "http://localhost:5733", | 4015 | origin: "http://localhost:5733", |
| 2105 | }, | 4016 | }, |
| 2106 | body: yield* HttpBody.json(payload), | 4017 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| 4018 | body: HttpBody.text(JSON.stringify(payload), "application/json"), | ||
| 2107 | }); | 4019 | }); |
| 2108 | 4020 | ||
| 2109 | assert.equal(response.status, 204); | 4021 | assert.equal(response.status, 204); |
| ... | @@ -2148,8 +4060,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2148,8 +4060,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2148 | ]); | 4060 | ]); |
| 2149 | assert.deepEqual(upstreamRequests, [ | 4061 | assert.deepEqual(upstreamRequests, [ |
| 2150 | { | 4062 | { |
| 2151 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 4063 | body: jsonRequestBody(payload), |
| 2152 | body: JSON.stringify(payload), | ||
| 2153 | contentType: "application/json", | 4064 | contentType: "application/json", |
| 2154 | }, | 4065 | }, |
| 2155 | ]); | 4066 | ]); |
| ... | @@ -2170,14 +4081,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2170,14 +4081,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2170 | 4081 | ||
| 2171 | assert.equal(response.status, 204); | 4082 | assert.equal(response.status, 204); |
| 2172 | assert.equal(response.headers["access-control-allow-origin"], "*"); | 4083 | assert.equal(response.headers["access-control-allow-origin"], "*"); |
| 2173 | assert.deepEqual( | 4084 | assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-methods"]), [ |
| 2174 | splitHeaderTokens(response.headers["access-control-allow-methods"] ?? null), | 4085 | "GET", |
| 2175 | ["GET", "OPTIONS", "POST"], | 4086 | "OPTIONS", |
| 2176 | ); | 4087 | "POST", |
| 2177 | assert.deepEqual( | 4088 | ]); |
| 2178 | splitHeaderTokens(response.headers["access-control-allow-headers"] ?? null), | 4089 | assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-headers"]), [ |
| 2179 | ["authorization", "b3", "content-type", "traceparent"], | 4090 | "authorization", |
| 2180 | ); | 4091 | "b3", |
| 4092 | "content-type", | ||
| 4093 | "dpop", | ||
| 4094 | "traceparent", | ||
| 4095 | ]); | ||
| 2181 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 4096 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 2182 | ); | 4097 | ); |
| 2183 | 4098 | ||
| ... | @@ -2214,7 +4129,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2214,7 +4129,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2214 | cookie: yield* getAuthenticatedSessionCookieHeader(), | 4129 | cookie: yield* getAuthenticatedSessionCookieHeader(), |
| 2215 | "content-type": "application/json", | 4130 | "content-type": "application/json", |
| 2216 | }, | 4131 | }, |
| 2217 | body: yield* HttpBody.json(payload), | 4132 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
| 4133 | body: HttpBody.text(JSON.stringify(payload), "application/json"), | ||
| 2218 | }); | 4134 | }); |
| 2219 | 4135 | ||
| 2220 | assert.equal(response.status, 204); | 4136 | assert.equal(response.status, 204); |
apps/server/src/server.ts+53| ... | @@ -44,6 +44,8 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun | ... | @@ -44,6 +44,8 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun |
| 44 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; | 44 | import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; |
| 45 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; | 45 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; |
| 46 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; | 46 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; |
| 47 | import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; | ||
| 48 | import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; | ||
| 47 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; | 49 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; |
| 48 | import { ServerSettingsLive } from "./serverSettings.ts"; | 50 | import { ServerSettingsLive } from "./serverSettings.ts"; |
| 49 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; | 51 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; |
| ... | @@ -67,6 +69,10 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts | ... | @@ -67,6 +69,10 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts |
| 67 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; | 69 | import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; |
| 68 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; | 70 | import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; |
| 69 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; | 71 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 72 | import { cloudHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts"; | ||
| 73 | import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; | ||
| 74 | import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; | ||
| 75 | import * as CloudCliState from "./cloud/CliState.ts"; | ||
| 70 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; | 76 | import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; |
| 71 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; | 77 | import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; |
| 72 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; | 78 | import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; |
| ... | @@ -78,6 +84,7 @@ import { | ... | @@ -78,6 +84,7 @@ import { |
| 78 | } from "./serverRuntimeState.ts"; | 84 | } from "./serverRuntimeState.ts"; |
| 79 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; | 85 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; |
| 80 | import * as NetService from "@t3tools/shared/Net"; | 86 | import * as NetService from "@t3tools/shared/Net"; |
| 87 | import * as RelayClient from "@t3tools/shared/relayClient"; | ||
| 81 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; | 88 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; |
| 82 | 89 | ||
| 83 | const PtyAdapterLive = Layer.unwrap( | 90 | const PtyAdapterLive = Layer.unwrap( |
| ... | @@ -92,6 +99,13 @@ const PtyAdapterLive = Layer.unwrap( | ... | @@ -92,6 +99,13 @@ const PtyAdapterLive = Layer.unwrap( |
| 92 | }), | 99 | }), |
| 93 | ); | 100 | ); |
| 94 | 101 | ||
| 102 | const RelayClientLive = Layer.unwrap( | ||
| 103 | Effect.gen(function* () { | ||
| 104 | const config = yield* ServerConfig; | ||
| 105 | return RelayClient.layerCloudflared({ baseDir: config.baseDir }); | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | |||
| 95 | const HttpServerLive = Layer.unwrap( | 109 | const HttpServerLive = Layer.unwrap( |
| 96 | Effect.gen(function* () { | 110 | Effect.gen(function* () { |
| 97 | const config = yield* ServerConfig; | 111 | const config = yield* ServerConfig; |
| ... | @@ -134,6 +148,7 @@ const ReactorLayerLive = Layer.empty.pipe( | ... | @@ -134,6 +148,7 @@ const ReactorLayerLive = Layer.empty.pipe( |
| 134 | Layer.provideMerge(ProviderCommandReactorLive), | 148 | Layer.provideMerge(ProviderCommandReactorLive), |
| 135 | Layer.provideMerge(CheckpointReactorLive), | 149 | Layer.provideMerge(CheckpointReactorLive), |
| 136 | Layer.provideMerge(ThreadDeletionReactorLive), | 150 | Layer.provideMerge(ThreadDeletionReactorLive), |
| 151 | Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), | ||
| 137 | Layer.provideMerge(RuntimeReceiptBusLive), | 152 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 138 | ); | 153 | ); |
| 139 | 154 | ||
| ... | @@ -231,6 +246,14 @@ const AuthLayerLive = EnvironmentAuth.layer.pipe( | ... | @@ -231,6 +246,14 @@ const AuthLayerLive = EnvironmentAuth.layer.pipe( |
| 231 | Layer.provide(ServerSecretStore.layer), | 246 | Layer.provide(ServerSecretStore.layer), |
| 232 | ); | 247 | ); |
| 233 | 248 | ||
| 249 | const CloudManagedEndpointRuntimeLive = Layer.mergeAll( | ||
| 250 | RelayClientLive, | ||
| 251 | CloudManagedEndpointRuntime.layer.pipe( | ||
| 252 | Layer.provide(ServerSecretStore.layer), | ||
| 253 | Layer.provide(RelayClientLive), | ||
| 254 | ), | ||
| 255 | ); | ||
| 256 | |||
| 234 | const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( | 257 | const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( |
| 235 | Layer.provideMerge(ProviderLayerLive), | 258 | Layer.provideMerge(ProviderLayerLive), |
| 236 | Layer.provideMerge(OrchestrationLayerLive), | 259 | Layer.provideMerge(OrchestrationLayerLive), |
| ... | @@ -271,6 +294,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -271,6 +294,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 271 | Layer.provideMerge(RepositoryIdentityResolverLive), | 294 | Layer.provideMerge(RepositoryIdentityResolverLive), |
| 272 | Layer.provideMerge(ServerEnvironmentLive), | 295 | Layer.provideMerge(ServerEnvironmentLive), |
| 273 | Layer.provideMerge(AuthLayerLive), | 296 | Layer.provideMerge(AuthLayerLive), |
| 297 | Layer.provideMerge(ServerSecretStore.layer), | ||
| 298 | Layer.provideMerge( | ||
| 299 | Layer.mergeAll( | ||
| 300 | CloudCliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)), | ||
| 301 | CloudManagedEndpointRuntimeLive, | ||
| 302 | ), | ||
| 303 | ), | ||
| 274 | ); | 304 | ); |
| 275 | 305 | ||
| 276 | const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( | 306 | const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( |
| ... | @@ -291,6 +321,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( | ... | @@ -291,6 +321,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( |
| 291 | export const makeRoutesLayer = Layer.mergeAll( | 321 | export const makeRoutesLayer = Layer.mergeAll( |
| 292 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( | 322 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( |
| 293 | Layer.provide(authHttpApiLayer), | 323 | Layer.provide(authHttpApiLayer), |
| 324 | Layer.provide(cloudHttpApiLayer), | ||
| 294 | Layer.provide(orchestrationHttpApiLayer), | 325 | Layer.provide(orchestrationHttpApiLayer), |
| 295 | Layer.provide(serverEnvironmentHttpApiLayer), | 326 | Layer.provide(serverEnvironmentHttpApiLayer), |
| 296 | Layer.provide(environmentAuthenticatedAuthLayer), | 327 | Layer.provide(environmentAuthenticatedAuthLayer), |
| ... | @@ -387,6 +418,27 @@ export const makeServerLayer = Layer.unwrap( | ... | @@ -387,6 +418,27 @@ export const makeServerLayer = Layer.unwrap( |
| 387 | ), | 418 | ), |
| 388 | ) | 419 | ) |
| 389 | : Layer.empty; | 420 | : Layer.empty; |
| 421 | const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( | ||
| 422 | Effect.gen(function* () { | ||
| 423 | if (!hasCloudPublicConfig) return; | ||
| 424 | if (!(yield* CloudCliState.readCliDesiredCloudLink)) return; | ||
| 425 | const server = yield* HttpServer.HttpServer; | ||
| 426 | const address = server.address; | ||
| 427 | if (typeof address === "string" || !("port" in address)) return; | ||
| 428 | yield* Effect.forkScoped( | ||
| 429 | Effect.sleep("250 millis").pipe( | ||
| 430 | Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), | ||
| 431 | Effect.retry({ times: 4 }), | ||
| 432 | Effect.tap(() => Effect.logInfo("T3 Cloud desired link reconciled on startup")), | ||
| 433 | Effect.catch((cause) => | ||
| 434 | Effect.logWarning("Failed to reconcile T3 Cloud desired link on startup", { | ||
| 435 | cause, | ||
| 436 | }), | ||
| 437 | ), | ||
| 438 | ), | ||
| 439 | ); | ||
| 440 | }), | ||
| 441 | ); | ||
| 390 | 442 | ||
| 391 | const serverApplicationLayer = Layer.mergeAll( | 443 | const serverApplicationLayer = Layer.mergeAll( |
| 392 | HttpRouter.serve(makeRoutesLayer, { | 444 | HttpRouter.serve(makeRoutesLayer, { |
| ... | @@ -395,6 +447,7 @@ export const makeServerLayer = Layer.unwrap( | ... | @@ -395,6 +447,7 @@ export const makeServerLayer = Layer.unwrap( |
| 395 | httpListeningLayer, | 447 | httpListeningLayer, |
| 396 | runtimeStateLayer, | 448 | runtimeStateLayer, |
| 397 | tailscaleServeLayer, | 449 | tailscaleServeLayer, |
| 450 | cloudDesiredLinkReconcileLayer, | ||
| 398 | ); | 451 | ); |
| 399 | 452 | ||
| 400 | return serverApplicationLayer.pipe( | 453 | return serverApplicationLayer.pipe( |
apps/server/src/serverRuntimeStartup.ts+5-2| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | ThreadId, | 8 | ThreadId, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import * as Data from "effect/Data"; | 10 | import * as Data from "effect/Data"; |
| 11 | import * as Crypto from "effect/Crypto"; | ||
| 11 | import * as Deferred from "effect/Deferred"; | 12 | import * as Deferred from "effect/Deferred"; |
| 12 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | 14 | import * as Exit from "effect/Exit"; |
| ... | @@ -19,7 +20,6 @@ import * as Ref from "effect/Ref"; | ... | @@ -19,7 +20,6 @@ import * as Ref from "effect/Ref"; |
| 19 | import * as Scope from "effect/Scope"; | 20 | import * as Scope from "effect/Scope"; |
| 20 | import * as Context from "effect/Context"; | 21 | import * as Context from "effect/Context"; |
| 21 | import * as Console from "effect/Console"; | 22 | import * as Console from "effect/Console"; |
| 22 | import * as Crypto from "effect/Crypto"; | ||
| 23 | import * as DateTime from "effect/DateTime"; | 23 | import * as DateTime from "effect/DateTime"; |
| 24 | 24 | ||
| 25 | import { ServerConfig } from "./config.ts"; | 25 | import { ServerConfig } from "./config.ts"; |
| ... | @@ -289,6 +289,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { | ... | @@ -289,6 +289,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { |
| 289 | const lifecycleEvents = yield* ServerLifecycleEvents; | 289 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 290 | const serverSettings = yield* ServerSettingsService; | 290 | const serverSettings = yield* ServerSettingsService; |
| 291 | const serverEnvironment = yield* ServerEnvironment; | 291 | const serverEnvironment = yield* ServerEnvironment; |
| 292 | const crypto = yield* Crypto.Crypto; | ||
| 292 | 293 | ||
| 293 | const commandGate = yield* makeCommandGate; | 294 | const commandGate = yield* makeCommandGate; |
| 294 | const httpListening = yield* Deferred.make<void>(); | 295 | const httpListening = yield* Deferred.make<void>(); |
| ... | @@ -361,7 +362,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { | ... | @@ -361,7 +362,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { |
| 361 | runStartupPhase( | 362 | runStartupPhase( |
| 362 | "welcome.autobootstrap", | 363 | "welcome.autobootstrap", |
| 363 | Effect.gen(function* () { | 364 | Effect.gen(function* () { |
| 364 | const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets; | 365 | const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( |
| 366 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 367 | ); | ||
| 365 | if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { | 368 | if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { |
| 366 | return; | 369 | return; |
| 367 | } | 370 | } |
apps/server/src/sourceControl/AzureDevOpsCli.ts+1-1| ... | @@ -21,7 +21,7 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE | ... | @@ -21,7 +21,7 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE |
| 21 | { | 21 | { |
| 22 | operation: Schema.String, | 22 | operation: Schema.String, |
| 23 | detail: Schema.String, | 23 | detail: Schema.String, |
| 24 | cause: Schema.optional(Schema.Defect), | 24 | cause: Schema.optional(Schema.Defect()), |
| 25 | }, | 25 | }, |
| 26 | ) { | 26 | ) { |
| 27 | override get message(): string { | 27 | override get message(): string { |
apps/server/src/sourceControl/BitbucketApi.ts+1-1| ... | @@ -37,7 +37,7 @@ export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError | ... | @@ -37,7 +37,7 @@ export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError |
| 37 | operation: Schema.String, | 37 | operation: Schema.String, |
| 38 | detail: Schema.String, | 38 | detail: Schema.String, |
| 39 | status: Schema.optional(Schema.Number), | 39 | status: Schema.optional(Schema.Number), |
| 40 | cause: Schema.optional(Schema.Defect), | 40 | cause: Schema.optional(Schema.Defect()), |
| 41 | }, | 41 | }, |
| 42 | ) { | 42 | ) { |
| 43 | override get message(): string { | 43 | override get message(): string { |
apps/server/src/sourceControl/GitHubCli.ts+1-1| ... | @@ -19,7 +19,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; | ... | @@ -19,7 +19,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; |
| 19 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { | 19 | export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", { |
| 20 | operation: Schema.String, | 20 | operation: Schema.String, |
| 21 | detail: Schema.String, | 21 | detail: Schema.String, |
| 22 | cause: Schema.optional(Schema.Defect), | 22 | cause: Schema.optional(Schema.Defect()), |
| 23 | }) { | 23 | }) { |
| 24 | override get message(): string { | 24 | override get message(): string { |
| 25 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; | 25 | return `GitHub CLI failed in ${this.operation}: ${this.detail}`; |
apps/server/src/sourceControl/GitLabCli.ts+1-1| ... | @@ -18,7 +18,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; | ... | @@ -18,7 +18,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; |
| 18 | export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", { | 18 | export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", { |
| 19 | operation: Schema.String, | 19 | operation: Schema.String, |
| 20 | detail: Schema.String, | 20 | detail: Schema.String, |
| 21 | cause: Schema.optional(Schema.Defect), | 21 | cause: Schema.optional(Schema.Defect()), |
| 22 | }) { | 22 | }) { |
| 23 | override get message(): string { | 23 | override get message(): string { |
| 24 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; | 24 | return `GitLab CLI failed in ${this.operation}: ${this.detail}`; |
apps/server/src/telemetry/Identify.ts+1-1| ... | @@ -19,7 +19,7 @@ const ClaudeJsonSchema = Schema.Struct({ | ... | @@ -19,7 +19,7 @@ const ClaudeJsonSchema = Schema.Struct({ |
| 19 | 19 | ||
| 20 | class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", { | 20 | class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", { |
| 21 | message: Schema.String, | 21 | message: Schema.String, |
| 22 | cause: Schema.optional(Schema.Defect), | 22 | cause: Schema.optional(Schema.Defect()), |
| 23 | }) {} | 23 | }) {} |
| 24 | 24 | ||
| 25 | const hash = (value: string) => | 25 | const hash = (value: string) => |
apps/server/src/terminal/Layers/Manager.ts+2-2| ... | @@ -64,7 +64,7 @@ class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubpr | ... | @@ -64,7 +64,7 @@ class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubpr |
| 64 | "TerminalSubprocessCheckError", | 64 | "TerminalSubprocessCheckError", |
| 65 | { | 65 | { |
| 66 | message: Schema.String, | 66 | message: Schema.String, |
| 67 | cause: Schema.optional(Schema.Defect), | 67 | cause: Schema.optional(Schema.Defect()), |
| 68 | terminalPid: Schema.Number, | 68 | terminalPid: Schema.Number, |
| 69 | command: Schema.Literals(["powershell", "pgrep", "ps"]), | 69 | command: Schema.Literals(["powershell", "pgrep", "ps"]), |
| 70 | }, | 70 | }, |
| ... | @@ -74,7 +74,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess | ... | @@ -74,7 +74,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess |
| 74 | "TerminalProcessSignalError", | 74 | "TerminalProcessSignalError", |
| 75 | { | 75 | { |
| 76 | message: Schema.String, | 76 | message: Schema.String, |
| 77 | cause: Schema.optional(Schema.Defect), | 77 | cause: Schema.optional(Schema.Defect()), |
| 78 | signal: Schema.Literals(["SIGTERM", "SIGKILL"]), | 78 | signal: Schema.Literals(["SIGTERM", "SIGKILL"]), |
| 79 | }, | 79 | }, |
| 80 | ) {} | 80 | ) {} |
apps/server/src/terminal/Services/PTY.ts+1-1| ... | @@ -16,7 +16,7 @@ import * as Context from "effect/Context"; | ... | @@ -16,7 +16,7 @@ import * as Context from "effect/Context"; |
| 16 | export class PtySpawnError extends Schema.TaggedErrorClass<PtySpawnError>()("PtySpawnError", { | 16 | export class PtySpawnError extends Schema.TaggedErrorClass<PtySpawnError>()("PtySpawnError", { |
| 17 | adapter: Schema.String, | 17 | adapter: Schema.String, |
| 18 | message: Schema.String, | 18 | message: Schema.String, |
| 19 | cause: Schema.optional(Schema.Defect), | 19 | cause: Schema.optional(Schema.Defect()), |
| 20 | }) {} | 20 | }) {} |
| 21 | 21 | ||
| 22 | export interface PtyExitEvent { | 22 | export interface PtyExitEvent { |
apps/server/src/workspace/Services/WorkspaceEntries.ts+2-2| ... | @@ -23,7 +23,7 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntr | ... | @@ -23,7 +23,7 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntr |
| 23 | cwd: Schema.String, | 23 | cwd: Schema.String, |
| 24 | operation: Schema.String, | 24 | operation: Schema.String, |
| 25 | detail: Schema.String, | 25 | detail: Schema.String, |
| 26 | cause: Schema.optional(Schema.Defect), | 26 | cause: Schema.optional(Schema.Defect()), |
| 27 | }, | 27 | }, |
| 28 | ) {} | 28 | ) {} |
| 29 | 29 | ||
| ... | @@ -34,7 +34,7 @@ export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass<Workspa | ... | @@ -34,7 +34,7 @@ export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass<Workspa |
| 34 | partialPath: Schema.String, | 34 | partialPath: Schema.String, |
| 35 | operation: Schema.String, | 35 | operation: Schema.String, |
| 36 | detail: Schema.String, | 36 | detail: Schema.String, |
| 37 | cause: Schema.optional(Schema.Defect), | 37 | cause: Schema.optional(Schema.Defect()), |
| 38 | }, | 38 | }, |
| 39 | ) {} | 39 | ) {} |
| 40 | 40 |
apps/server/src/workspace/Services/WorkspaceFileSystem.ts+1-1| ... | @@ -20,7 +20,7 @@ export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceF | ... | @@ -20,7 +20,7 @@ export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceF |
| 20 | relativePath: Schema.optional(Schema.String), | 20 | relativePath: Schema.optional(Schema.String), |
| 21 | operation: Schema.String, | 21 | operation: Schema.String, |
| 22 | detail: Schema.String, | 22 | detail: Schema.String, |
| 23 | cause: Schema.optional(Schema.Defect), | 23 | cause: Schema.optional(Schema.Defect()), |
| 24 | }, | 24 | }, |
| 25 | ) {} | 25 | ) {} |
| 26 | 26 |
apps/server/src/ws.ts+40| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | AuthOrchestrationOperateScope, | 14 | AuthOrchestrationOperateScope, |
| 15 | AuthOrchestrationReadScope, | 15 | AuthOrchestrationReadScope, |
| 16 | AuthReviewWriteScope, | 16 | AuthReviewWriteScope, |
| 17 | AuthRelayWriteScope, | ||
| 17 | AuthTerminalOperateScope, | 18 | AuthTerminalOperateScope, |
| 18 | AuthAccessReadScope, | 19 | AuthAccessReadScope, |
| 19 | AuthAccessStreamError, | 20 | AuthAccessStreamError, |
| ... | @@ -34,6 +35,8 @@ import { | ... | @@ -34,6 +35,8 @@ import { |
| 34 | ORCHESTRATION_WS_METHODS, | 35 | ORCHESTRATION_WS_METHODS, |
| 35 | ProjectSearchEntriesError, | 36 | ProjectSearchEntriesError, |
| 36 | ProjectWriteFileError, | 37 | ProjectWriteFileError, |
| 38 | RelayClientInstallFailedError, | ||
| 39 | type RelayClientInstallProgressEvent, | ||
| 37 | OrchestrationReplayEventsError, | 40 | OrchestrationReplayEventsError, |
| 38 | FilesystemBrowseError, | 41 | FilesystemBrowseError, |
| 39 | EnvironmentAuthorizationError, | 42 | EnvironmentAuthorizationError, |
| ... | @@ -96,6 +99,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; | ... | @@ -96,6 +99,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; |
| 96 | import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; | 99 | import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; |
| 97 | import * as SessionStore from "./auth/SessionStore.ts"; | 100 | import * as SessionStore from "./auth/SessionStore.ts"; |
| 98 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; | 101 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; |
| 102 | import * as RelayClient from "@t3tools/shared/relayClient"; | ||
| 99 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); | 103 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); |
| 100 | const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); | 104 | const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); |
| 101 | 105 | ||
| ... | @@ -145,6 +149,8 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ | ... | @@ -145,6 +149,8 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ |
| 145 | [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], | 149 | [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], |
| 146 | [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], | 150 | [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], |
| 147 | [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], | 151 | [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], |
| 152 | [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], | ||
| 153 | [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], | ||
| 148 | [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], | 154 | [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], |
| 149 | [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], | 155 | [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], |
| 150 | [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], | 156 | [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], |
| ... | @@ -260,6 +266,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -260,6 +266,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 260 | const sessions = yield* SessionStore.SessionStore; | 266 | const sessions = yield* SessionStore.SessionStore; |
| 261 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; | 267 | const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; |
| 262 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; | 268 | const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; |
| 269 | const relayClient = yield* RelayClient.RelayClient; | ||
| 263 | const authorizationError = (requiredScope: AuthEnvironmentScope) => | 270 | const authorizationError = (requiredScope: AuthEnvironmentScope) => |
| 264 | new EnvironmentAuthorizationError({ | 271 | new EnvironmentAuthorizationError({ |
| 265 | message: `The authenticated token is missing required scope: ${requiredScope}.`, | 272 | message: `The authenticated token is missing required scope: ${requiredScope}.`, |
| ... | @@ -1070,6 +1077,39 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -1070,6 +1077,39 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1070 | observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { | 1077 | observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { |
| 1071 | "rpc.aggregate": "server", | 1078 | "rpc.aggregate": "server", |
| 1072 | }), | 1079 | }), |
| 1080 | [WS_METHODS.cloudGetRelayClientStatus]: (_input) => | ||
| 1081 | observeRpcEffect(WS_METHODS.cloudGetRelayClientStatus, relayClient.resolve, { | ||
| 1082 | "rpc.aggregate": "cloud", | ||
| 1083 | }), | ||
| 1084 | [WS_METHODS.cloudInstallRelayClient]: (_input) => | ||
| 1085 | observeRpcStream( | ||
| 1086 | WS_METHODS.cloudInstallRelayClient, | ||
| 1087 | Stream.callback<RelayClientInstallProgressEvent, RelayClientInstallFailedError>( | ||
| 1088 | (queue) => | ||
| 1089 | relayClient | ||
| 1090 | .installWithProgress((event) => Queue.offer(queue, event).pipe(Effect.asVoid)) | ||
| 1091 | .pipe( | ||
| 1092 | Effect.flatMap((status) => | ||
| 1093 | Queue.offer(queue, { | ||
| 1094 | type: "complete", | ||
| 1095 | status, | ||
| 1096 | }), | ||
| 1097 | ), | ||
| 1098 | Effect.catchTag("RelayClientInstallError", (error) => | ||
| 1099 | Queue.fail( | ||
| 1100 | queue, | ||
| 1101 | new RelayClientInstallFailedError({ | ||
| 1102 | reason: error.reason, | ||
| 1103 | message: error.message, | ||
| 1104 | }), | ||
| 1105 | ), | ||
| 1106 | ), | ||
| 1107 | Effect.andThen(Queue.end(queue)), | ||
| 1108 | Effect.forkScoped, | ||
| 1109 | ), | ||
| 1110 | ), | ||
| 1111 | { "rpc.aggregate": "cloud" }, | ||
| 1112 | ), | ||
| 1073 | [WS_METHODS.sourceControlLookupRepository]: (input) => | 1113 | [WS_METHODS.sourceControlLookupRepository]: (input) => |
| 1074 | observeRpcEffect( | 1114 | observeRpcEffect( |
| 1075 | WS_METHODS.sourceControlLookupRepository, | 1115 | WS_METHODS.sourceControlLookupRepository, |
apps/server/vite.config.ts+12| ... | @@ -2,6 +2,7 @@ import "vite-plus/test/config"; | ... | @@ -2,6 +2,7 @@ import "vite-plus/test/config"; |
| 2 | import { defineConfig, mergeConfig } from "vite-plus"; | 2 | import { defineConfig, mergeConfig } from "vite-plus"; |
| 3 | 3 | ||
| 4 | import baseConfig from "../../vite.config.ts"; | 4 | import baseConfig from "../../vite.config.ts"; |
| 5 | import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; | ||
| 5 | 6 | ||
| 6 | const bundledPackagePrefixes = [ | 7 | const bundledPackagePrefixes = [ |
| 7 | "@pierre/diffs", | 8 | "@pierre/diffs", |
| ... | @@ -14,6 +15,8 @@ export function shouldBundleCliDependency(id: string): boolean { | ... | @@ -14,6 +15,8 @@ export function shouldBundleCliDependency(id: string): boolean { |
| 14 | return bundledPackagePrefixes.some((prefix) => id.startsWith(prefix)); | 15 | return bundledPackagePrefixes.some((prefix) => id.startsWith(prefix)); |
| 15 | } | 16 | } |
| 16 | 17 | ||
| 18 | const repoEnv = loadRepoEnv(); | ||
| 19 | |||
| 17 | export default mergeConfig( | 20 | export default mergeConfig( |
| 18 | baseConfig, | 21 | baseConfig, |
| 19 | defineConfig({ | 22 | defineConfig({ |
| ... | @@ -38,6 +41,15 @@ export default mergeConfig( | ... | @@ -38,6 +41,15 @@ export default mergeConfig( |
| 38 | banner: { | 41 | banner: { |
| 39 | js: "#!/usr/bin/env node\n", | 42 | js: "#!/usr/bin/env node\n", |
| 40 | }, | 43 | }, |
| 44 | define: { | ||
| 45 | __T3CODE_BUILD_RELAY_URL__: JSON.stringify(repoEnv.T3CODE_RELAY_URL?.trim() ?? ""), | ||
| 46 | __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify( | ||
| 47 | repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "", | ||
| 48 | ), | ||
| 49 | __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: JSON.stringify( | ||
| 50 | repoEnv.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() ?? "", | ||
| 51 | ), | ||
| 52 | }, | ||
| 41 | }, | 53 | }, |
| 42 | test: { | 54 | test: { |
| 43 | // The server suite exercises sqlite, git, temp worktrees, and orchestration | 55 | // The server suite exercises sqlite, git, temp worktrees, and orchestration |
apps/web/package.json+4| ... | @@ -14,6 +14,8 @@ | ... | @@ -14,6 +14,8 @@ |
| 14 | }, | 14 | }, |
| 15 | "dependencies": { | 15 | "dependencies": { |
| 16 | "@base-ui/react": "^1.4.1", | 16 | "@base-ui/react": "^1.4.1", |
| 17 | "@clerk/clerk-js": "^6.13.0", | ||
| 18 | "@clerk/react": "^6.7.2", | ||
| 17 | "@dnd-kit/core": "^6.3.1", | 19 | "@dnd-kit/core": "^6.3.1", |
| 18 | "@dnd-kit/modifiers": "^9.0.0", | 20 | "@dnd-kit/modifiers": "^9.0.0", |
| 19 | "@dnd-kit/sortable": "^10.0.0", | 21 | "@dnd-kit/sortable": "^10.0.0", |
| ... | @@ -33,6 +35,7 @@ | ... | @@ -33,6 +35,7 @@ |
| 33 | "@xterm/xterm": "^6.0.0", | 35 | "@xterm/xterm": "^6.0.0", |
| 34 | "class-variance-authority": "^0.7.1", | 36 | "class-variance-authority": "^0.7.1", |
| 35 | "effect": "catalog:", | 37 | "effect": "catalog:", |
| 38 | "jose": "catalog:", | ||
| 36 | "lexical": "^0.41.0", | 39 | "lexical": "^0.41.0", |
| 37 | "lucide-react": "^0.564.0", | 40 | "lucide-react": "^0.564.0", |
| 38 | "react": "19.2.6", | 41 | "react": "19.2.6", |
| ... | @@ -44,6 +47,7 @@ | ... | @@ -44,6 +47,7 @@ |
| 44 | }, | 47 | }, |
| 45 | "devDependencies": { | 48 | "devDependencies": { |
| 46 | "@effect/platform-node": "catalog:", | 49 | "@effect/platform-node": "catalog:", |
| 50 | "@effect/vitest": "catalog:", | ||
| 47 | "@rolldown/plugin-babel": "^0.2.0", | 51 | "@rolldown/plugin-babel": "^0.2.0", |
| 48 | "@tailwindcss/vite": "^4.0.0", | 52 | "@tailwindcss/vite": "^4.0.0", |
| 49 | "@tanstack/router-plugin": "^1.161.0", | 53 | "@tanstack/router-plugin": "^1.161.0", |
apps/web/src/clientPersistenceStorage.ts+14-2| ... | @@ -27,6 +27,7 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ | ... | @@ -27,6 +27,7 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ |
| 27 | port: Schema.NullOr(Schema.Number), | 27 | port: Schema.NullOr(Schema.Number), |
| 28 | }), | 28 | }), |
| 29 | ), | 29 | ), |
| 30 | relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })), | ||
| 30 | bearerToken: Schema.optionalKey(Schema.String), | 31 | bearerToken: Schema.optionalKey(Schema.String), |
| 31 | }); | 32 | }); |
| 32 | type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; | 33 | type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; |
| ... | @@ -53,7 +54,11 @@ function toPersistedSavedEnvironmentRecord( | ... | @@ -53,7 +54,11 @@ function toPersistedSavedEnvironmentRecord( |
| 53 | createdAt: record.createdAt, | 54 | createdAt: record.createdAt, |
| 54 | lastConnectedAt: record.lastConnectedAt, | 55 | lastConnectedAt: record.lastConnectedAt, |
| 55 | }; | 56 | }; |
| 56 | return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | 57 | return { |
| 58 | ...nextRecord, | ||
| 59 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 60 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 61 | }; | ||
| 57 | } | 62 | } |
| 58 | 63 | ||
| 59 | export function readBrowserClientSettings(): ClientSettings | null { | 64 | export function readBrowserClientSettings(): ClientSettings | null { |
| ... | @@ -145,6 +150,7 @@ export function writeBrowserSavedEnvironmentRegistry( | ... | @@ -145,6 +150,7 @@ export function writeBrowserSavedEnvironmentRegistry( |
| 145 | createdAt: record.createdAt, | 150 | createdAt: record.createdAt, |
| 146 | lastConnectedAt: record.lastConnectedAt, | 151 | lastConnectedAt: record.lastConnectedAt, |
| 147 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | 152 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), |
| 153 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 148 | bearerToken, | 154 | bearerToken, |
| 149 | } | 155 | } |
| 150 | : toPersistedSavedEnvironmentRecord(record); | 156 | : toPersistedSavedEnvironmentRecord(record); |
| ... | @@ -171,6 +177,8 @@ export function writeBrowserSavedEnvironmentSecret( | ... | @@ -171,6 +177,8 @@ export function writeBrowserSavedEnvironmentSecret( |
| 171 | let found = false; | 177 | let found = false; |
| 172 | writeBrowserSavedEnvironmentRegistryDocument({ | 178 | writeBrowserSavedEnvironmentRegistryDocument({ |
| 173 | version: document.version ?? 1, | 179 | version: document.version ?? 1, |
| 180 | // The persistence update is copy-on-write so storage subscribers observe a new document. | ||
| 181 | // oxlint-disable-next-line oxc/no-map-spread | ||
| 174 | records: records.map((record) => { | 182 | records: records.map((record) => { |
| 175 | if (record.environmentId !== environmentId) { | 183 | if (record.environmentId !== environmentId) { |
| 176 | return record; | 184 | return record; |
| ... | @@ -185,7 +193,11 @@ export function writeBrowserSavedEnvironmentSecret( | ... | @@ -185,7 +193,11 @@ export function writeBrowserSavedEnvironmentSecret( |
| 185 | lastConnectedAt: record.lastConnectedAt, | 193 | lastConnectedAt: record.lastConnectedAt, |
| 186 | bearerToken: secret, | 194 | bearerToken: secret, |
| 187 | }; | 195 | }; |
| 188 | return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; | 196 | return { |
| 197 | ...nextRecord, | ||
| 198 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | ||
| 199 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 200 | }; | ||
| 189 | }), | 201 | }), |
| 190 | }); | 202 | }); |
| 191 | return found; | 203 | return found; |
apps/web/src/cloud/desktopAuth.test.ts created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { resolveDesktopCloudAuthOAuthOptions } from "./desktopAuth"; | ||
| 4 | |||
| 5 | describe("resolveDesktopCloudAuthOAuthOptions", () => { | ||
| 6 | it("ignores absent social provider settings", () => { | ||
| 7 | expect( | ||
| 8 | resolveDesktopCloudAuthOAuthOptions({ | ||
| 9 | environment: { | ||
| 10 | userSettings: { | ||
| 11 | social: { | ||
| 12 | github: null, | ||
| 13 | google: { | ||
| 14 | strategy: "oauth_google", | ||
| 15 | enabled: true, | ||
| 16 | authenticatable: true, | ||
| 17 | }, | ||
| 18 | }, | ||
| 19 | }, | ||
| 20 | }, | ||
| 21 | }), | ||
| 22 | ).toEqual([ | ||
| 23 | { | ||
| 24 | strategy: "oauth_google", | ||
| 25 | label: "Google", | ||
| 26 | providerId: "google", | ||
| 27 | iconUrl: null, | ||
| 28 | }, | ||
| 29 | ]); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("preserves provider display metadata when Clerk exposes the strategy list", () => { | ||
| 33 | expect( | ||
| 34 | resolveDesktopCloudAuthOAuthOptions({ | ||
| 35 | environment: { | ||
| 36 | userSettings: { | ||
| 37 | authenticatableSocialStrategies: ["oauth_google"], | ||
| 38 | social: { | ||
| 39 | oauth_google: { | ||
| 40 | strategy: "oauth_google", | ||
| 41 | enabled: true, | ||
| 42 | authenticatable: true, | ||
| 43 | name: "Google", | ||
| 44 | logo_url: "https://img.clerk.com/static/google.png", | ||
| 45 | }, | ||
| 46 | }, | ||
| 47 | }, | ||
| 48 | }, | ||
| 49 | }), | ||
| 50 | ).toEqual([ | ||
| 51 | { | ||
| 52 | strategy: "oauth_google", | ||
| 53 | label: "Google", | ||
| 54 | providerId: "google", | ||
| 55 | iconUrl: "https://img.clerk.com/static/google.png", | ||
| 56 | }, | ||
| 57 | ]); | ||
| 58 | }); | ||
| 59 | }); | ||
apps/web/src/cloud/desktopAuth.ts created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | export type DesktopCloudAuthOAuthStrategy = `oauth_${string}`; | ||
| 2 | |||
| 3 | export interface DesktopCloudAuthOAuthOption { | ||
| 4 | readonly strategy: DesktopCloudAuthOAuthStrategy; | ||
| 5 | readonly label: string; | ||
| 6 | readonly providerId: string; | ||
| 7 | readonly iconUrl: string | null; | ||
| 8 | } | ||
| 9 | |||
| 10 | interface ClerkOAuthProviderSetting { | ||
| 11 | readonly enabled?: unknown; | ||
| 12 | readonly authenticatable?: unknown; | ||
| 13 | readonly strategy?: unknown; | ||
| 14 | readonly name?: unknown; | ||
| 15 | readonly logo_url?: unknown; | ||
| 16 | } | ||
| 17 | |||
| 18 | interface ClerkUserSettingsLike { | ||
| 19 | readonly authenticatableSocialStrategies?: unknown; | ||
| 20 | readonly social?: unknown; | ||
| 21 | } | ||
| 22 | |||
| 23 | interface ClerkEnvironmentLike { | ||
| 24 | readonly userSettings?: ClerkUserSettingsLike; | ||
| 25 | } | ||
| 26 | |||
| 27 | interface ClerkLike { | ||
| 28 | readonly __internal_environment?: ClerkEnvironmentLike; | ||
| 29 | readonly environment?: ClerkEnvironmentLike; | ||
| 30 | } | ||
| 31 | |||
| 32 | const isClerkOAuthProviderSetting = (value: unknown): value is ClerkOAuthProviderSetting => | ||
| 33 | typeof value === "object" && value !== null; | ||
| 34 | |||
| 35 | const OAUTH_LABELS: Readonly<Record<string, string>> = { | ||
| 36 | oauth_apple: "Apple", | ||
| 37 | oauth_discord: "Discord", | ||
| 38 | oauth_github: "GitHub", | ||
| 39 | oauth_gitlab: "GitLab", | ||
| 40 | oauth_google: "Google", | ||
| 41 | oauth_linear: "Linear", | ||
| 42 | oauth_microsoft: "Microsoft", | ||
| 43 | oauth_slack: "Slack", | ||
| 44 | oauth_x: "X", | ||
| 45 | }; | ||
| 46 | |||
| 47 | // Mirrors Clerk UI's enabled-provider projection for the local desktop replacement: | ||
| 48 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/hooks/useEnabledThirdPartyProviders.tsx | ||
| 49 | export function isDesktopCloudAuthOAuthStrategy( | ||
| 50 | value: unknown, | ||
| 51 | ): value is DesktopCloudAuthOAuthStrategy { | ||
| 52 | return typeof value === "string" && value.startsWith("oauth_"); | ||
| 53 | } | ||
| 54 | |||
| 55 | export function getDesktopCloudAuthOAuthStrategyLabel( | ||
| 56 | strategy: DesktopCloudAuthOAuthStrategy, | ||
| 57 | ): string { | ||
| 58 | const mapped = OAUTH_LABELS[strategy]; | ||
| 59 | if (mapped) return mapped; | ||
| 60 | return strategy | ||
| 61 | .replace(/^oauth_custom_/, "") | ||
| 62 | .replace(/^oauth_/, "") | ||
| 63 | .split("_") | ||
| 64 | .filter(Boolean) | ||
| 65 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) | ||
| 66 | .join(" "); | ||
| 67 | } | ||
| 68 | |||
| 69 | export function resolveDesktopCloudAuthOAuthOptions( | ||
| 70 | clerk: unknown, | ||
| 71 | ): readonly DesktopCloudAuthOAuthOption[] { | ||
| 72 | const environment = | ||
| 73 | (clerk as ClerkLike | null | undefined)?.__internal_environment ?? | ||
| 74 | (clerk as ClerkLike | null | undefined)?.environment; | ||
| 75 | const userSettings = environment?.userSettings; | ||
| 76 | const strategies = userSettings?.authenticatableSocialStrategies; | ||
| 77 | if (Array.isArray(strategies)) { | ||
| 78 | return uniqueOptions( | ||
| 79 | strategies | ||
| 80 | .filter(isDesktopCloudAuthOAuthStrategy) | ||
| 81 | .map((strategy) => | ||
| 82 | createOAuthOption(strategy, findProviderSetting(userSettings, strategy)), | ||
| 83 | ), | ||
| 84 | ); | ||
| 85 | } | ||
| 86 | |||
| 87 | const social = userSettings?.social; | ||
| 88 | if (!social || typeof social !== "object") { | ||
| 89 | return []; | ||
| 90 | } | ||
| 91 | |||
| 92 | return uniqueOptions( | ||
| 93 | Object.values(social as Record<string, ClerkOAuthProviderSetting>) | ||
| 94 | .filter(isClerkOAuthProviderSetting) | ||
| 95 | .filter((provider) => provider.enabled !== false && provider.authenticatable !== false) | ||
| 96 | .map((provider) => { | ||
| 97 | const strategy = isDesktopCloudAuthOAuthStrategy(provider.strategy) | ||
| 98 | ? provider.strategy | ||
| 99 | : null; | ||
| 100 | if (!strategy) return null; | ||
| 101 | return createOAuthOption(strategy, provider); | ||
| 102 | }) | ||
| 103 | .filter((option): option is DesktopCloudAuthOAuthOption => option !== null), | ||
| 104 | ); | ||
| 105 | } | ||
| 106 | |||
| 107 | function findProviderSetting( | ||
| 108 | userSettings: ClerkUserSettingsLike | undefined, | ||
| 109 | strategy: DesktopCloudAuthOAuthStrategy, | ||
| 110 | ): ClerkOAuthProviderSetting | undefined { | ||
| 111 | const social = userSettings?.social; | ||
| 112 | if (!social || typeof social !== "object") return undefined; | ||
| 113 | |||
| 114 | return Object.values(social as Record<string, ClerkOAuthProviderSetting>) | ||
| 115 | .filter(isClerkOAuthProviderSetting) | ||
| 116 | .find((provider) => provider.strategy === strategy); | ||
| 117 | } | ||
| 118 | |||
| 119 | function createOAuthOption( | ||
| 120 | strategy: DesktopCloudAuthOAuthStrategy, | ||
| 121 | provider?: ClerkOAuthProviderSetting, | ||
| 122 | ): DesktopCloudAuthOAuthOption { | ||
| 123 | return { | ||
| 124 | strategy, | ||
| 125 | label: | ||
| 126 | typeof provider?.name === "string" && provider.name.trim() | ||
| 127 | ? provider.name | ||
| 128 | : getDesktopCloudAuthOAuthStrategyLabel(strategy), | ||
| 129 | providerId: strategy.replace(/^oauth_/, ""), | ||
| 130 | iconUrl: | ||
| 131 | typeof provider?.logo_url === "string" && provider.logo_url.trim() ? provider.logo_url : null, | ||
| 132 | }; | ||
| 133 | } | ||
| 134 | |||
| 135 | function uniqueOptions( | ||
| 136 | options: readonly DesktopCloudAuthOAuthOption[], | ||
| 137 | ): readonly DesktopCloudAuthOAuthOption[] { | ||
| 138 | const seen = new Set<string>(); | ||
| 139 | return options.filter((option) => { | ||
| 140 | if (seen.has(option.strategy)) return false; | ||
| 141 | seen.add(option.strategy); | ||
| 142 | return true; | ||
| 143 | }); | ||
| 144 | } | ||
apps/web/src/cloud/desktopClerk.tsx created+322| ... | @@ -0,0 +1,322 @@ | ||
| 1 | import { Clerk } from "@clerk/clerk-js"; | ||
| 2 | import { | ||
| 3 | buildClerkUIScriptAttributes, | ||
| 4 | clerkUIScriptUrl, | ||
| 5 | InternalClerkProvider, | ||
| 6 | } from "@clerk/react/internal"; | ||
| 7 | import type { ClerkProviderProps } from "@clerk/react"; | ||
| 8 | import { | ||
| 9 | clerkFrontendApiHostnameFromPublishableKey, | ||
| 10 | isAllowedClerkFrontendApiHostname, | ||
| 11 | } from "@t3tools/shared/relayAuth"; | ||
| 12 | import React, { useEffect, useState } from "react"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | makeDesktopClerkExternalAccountAdapter, | ||
| 16 | type DesktopClerkUser, | ||
| 17 | } from "./desktopClerkExternalAccounts"; | ||
| 18 | |||
| 19 | type DesktopClerkUiCtor = NonNullable<Window["__internal_ClerkUICtor"]>; | ||
| 20 | |||
| 21 | interface ClerkFrontendApiRequest { | ||
| 22 | credentials?: RequestCredentials; | ||
| 23 | headers?: Headers; | ||
| 24 | url?: URL; | ||
| 25 | } | ||
| 26 | |||
| 27 | interface ClerkFrontendApiResponse { | ||
| 28 | headers: Headers; | ||
| 29 | payload?: { | ||
| 30 | errors?: readonly { | ||
| 31 | code?: string; | ||
| 32 | }[]; | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | interface NativeRequestClerk { | ||
| 37 | readonly publishableKey?: string; | ||
| 38 | __internal_onBeforeRequest?: ( | ||
| 39 | listener: (request: ClerkFrontendApiRequest) => void | Promise<void>, | ||
| 40 | ) => void; | ||
| 41 | __internal_onAfterResponse?: ( | ||
| 42 | listener: ( | ||
| 43 | request: ClerkFrontendApiRequest, | ||
| 44 | response?: ClerkFrontendApiResponse, | ||
| 45 | ) => void | Promise<void>, | ||
| 46 | ) => void; | ||
| 47 | __unstable__onBeforeRequest?: ( | ||
| 48 | listener: (request: ClerkFrontendApiRequest) => void | Promise<void>, | ||
| 49 | ) => void; | ||
| 50 | __unstable__onAfterResponse?: ( | ||
| 51 | listener: ( | ||
| 52 | request: ClerkFrontendApiRequest, | ||
| 53 | response?: ClerkFrontendApiResponse, | ||
| 54 | ) => void | Promise<void>, | ||
| 55 | ) => void; | ||
| 56 | } | ||
| 57 | |||
| 58 | interface DesktopClerkProviderProps { | ||
| 59 | readonly children: React.ReactNode; | ||
| 60 | readonly publishableKey: string; | ||
| 61 | } | ||
| 62 | |||
| 63 | let desktopClerk: Clerk | null = null; | ||
| 64 | let desktopClerkFetchInstalled = false; | ||
| 65 | let desktopClerkUiLoad: Promise<DesktopClerkUiCtor> | null = null; | ||
| 66 | let desktopClerkFrontendApiHostname: string | null = null; | ||
| 67 | let desktopClerkExternalAccountCleanup: (() => void) | null = null; | ||
| 68 | |||
| 69 | const isNativeRequestClerk = (value: unknown): value is NativeRequestClerk => { | ||
| 70 | if (typeof value !== "object" || value === null) return false; | ||
| 71 | const candidate = value as { | ||
| 72 | __internal_onBeforeRequest?: unknown; | ||
| 73 | __internal_onAfterResponse?: unknown; | ||
| 74 | __unstable__onBeforeRequest?: unknown; | ||
| 75 | __unstable__onAfterResponse?: unknown; | ||
| 76 | }; | ||
| 77 | return ( | ||
| 78 | (typeof candidate.__internal_onBeforeRequest === "function" || | ||
| 79 | typeof candidate.__unstable__onBeforeRequest === "function") && | ||
| 80 | (typeof candidate.__internal_onAfterResponse === "function" || | ||
| 81 | typeof candidate.__unstable__onAfterResponse === "function") | ||
| 82 | ); | ||
| 83 | }; | ||
| 84 | |||
| 85 | const getStoredClientJwt = (): Promise<string | null> => | ||
| 86 | window.desktopBridge?.getCloudAuthToken() ?? Promise.resolve(null); | ||
| 87 | |||
| 88 | const setStoredClientJwt = (token: string): Promise<boolean> => | ||
| 89 | window.desktopBridge?.setCloudAuthToken(token) ?? Promise.resolve(false); | ||
| 90 | |||
| 91 | const clearStoredClientJwt = (): Promise<void> => | ||
| 92 | window.desktopBridge?.clearCloudAuthToken() ?? Promise.resolve(); | ||
| 93 | |||
| 94 | const isClerkFrontendApiUrl = (url: URL): boolean => | ||
| 95 | url.protocol === "https:" && | ||
| 96 | isAllowedClerkFrontendApiHostname(url.hostname, desktopClerkFrontendApiHostname); | ||
| 97 | |||
| 98 | const headersToRecord = (headers: Headers): Record<string, string> => { | ||
| 99 | const record: Record<string, string> = {}; | ||
| 100 | headers.forEach((value, key) => { | ||
| 101 | record[key] = value; | ||
| 102 | }); | ||
| 103 | return record; | ||
| 104 | }; | ||
| 105 | |||
| 106 | function installDesktopClerkFetchProxy(publishableKey: string): void { | ||
| 107 | desktopClerkFrontendApiHostname = clerkFrontendApiHostnameFromPublishableKey(publishableKey); | ||
| 108 | if (desktopClerkFetchInstalled) return; | ||
| 109 | const bridge = window.desktopBridge; | ||
| 110 | if (!bridge) return; | ||
| 111 | |||
| 112 | const browserFetch = window.fetch.bind(window); | ||
| 113 | window.fetch = async (input, init) => { | ||
| 114 | const request = new Request(input, init); | ||
| 115 | const url = new URL(request.url); | ||
| 116 | if (!isClerkFrontendApiUrl(url)) { | ||
| 117 | return browserFetch(input, init); | ||
| 118 | } | ||
| 119 | |||
| 120 | const body = | ||
| 121 | request.method === "GET" || request.method === "HEAD" | ||
| 122 | ? undefined | ||
| 123 | : await request.clone().text(); | ||
| 124 | const result = await bridge.fetchCloudAuth({ | ||
| 125 | url: request.url, | ||
| 126 | method: request.method, | ||
| 127 | headers: headersToRecord(request.headers), | ||
| 128 | ...(body === undefined ? {} : { body }), | ||
| 129 | }); | ||
| 130 | |||
| 131 | return new Response(result.body, { | ||
| 132 | status: result.status, | ||
| 133 | statusText: result.statusText, | ||
| 134 | headers: result.headers, | ||
| 135 | }); | ||
| 136 | }; | ||
| 137 | desktopClerkFetchInstalled = true; | ||
| 138 | } | ||
| 139 | |||
| 140 | function installDesktopClerkExternalAccounts(clerk: Clerk): void { | ||
| 141 | desktopClerkExternalAccountCleanup?.(); | ||
| 142 | desktopClerkExternalAccountCleanup = null; | ||
| 143 | |||
| 144 | const bridge = window.desktopBridge; | ||
| 145 | if (!bridge) return; | ||
| 146 | |||
| 147 | const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); | ||
| 148 | const unsubscribe = clerk.addListener(({ user }) => { | ||
| 149 | if (user) { | ||
| 150 | adapter.installUser(user as DesktopClerkUser); | ||
| 151 | } | ||
| 152 | }); | ||
| 153 | desktopClerkExternalAccountCleanup = () => { | ||
| 154 | unsubscribe(); | ||
| 155 | adapter.dispose(); | ||
| 156 | }; | ||
| 157 | } | ||
| 158 | |||
| 159 | function loadDesktopClerkUi(publishableKey: string): Promise<DesktopClerkUiCtor> { | ||
| 160 | if (window.__internal_ClerkUICtor) { | ||
| 161 | return Promise.resolve(window.__internal_ClerkUICtor); | ||
| 162 | } | ||
| 163 | if (desktopClerkUiLoad) { | ||
| 164 | return desktopClerkUiLoad; | ||
| 165 | } | ||
| 166 | |||
| 167 | const load = new Promise<DesktopClerkUiCtor>((resolve, reject) => { | ||
| 168 | const scriptUrl = clerkUIScriptUrl({ publishableKey }); | ||
| 169 | const existingScript = document.querySelector<HTMLScriptElement>( | ||
| 170 | "script[data-clerk-ui-script]", | ||
| 171 | ); | ||
| 172 | |||
| 173 | const resolveLoadedUi = () => { | ||
| 174 | const ClerkUI = window.__internal_ClerkUICtor; | ||
| 175 | if (ClerkUI) { | ||
| 176 | resolve(ClerkUI); | ||
| 177 | return true; | ||
| 178 | } | ||
| 179 | return false; | ||
| 180 | }; | ||
| 181 | if (resolveLoadedUi()) { | ||
| 182 | return; | ||
| 183 | } | ||
| 184 | |||
| 185 | const script = existingScript ?? document.createElement("script"); | ||
| 186 | script.async = true; | ||
| 187 | script.crossOrigin = "anonymous"; | ||
| 188 | script.src = scriptUrl; | ||
| 189 | script.dataset.clerkUiScript = "true"; | ||
| 190 | const attributes = buildClerkUIScriptAttributes({ publishableKey }); | ||
| 191 | for (const [name, value] of Object.entries(attributes)) { | ||
| 192 | script.setAttribute(name, value); | ||
| 193 | } | ||
| 194 | |||
| 195 | const timeoutId = window.setTimeout(() => { | ||
| 196 | reject(new Error("Timed out loading Clerk UI for desktop auth.")); | ||
| 197 | }, 15_000); | ||
| 198 | script.addEventListener("load", () => { | ||
| 199 | window.clearTimeout(timeoutId); | ||
| 200 | if (!resolveLoadedUi()) { | ||
| 201 | reject(new Error("Clerk UI loaded without exposing the UI constructor.")); | ||
| 202 | } | ||
| 203 | }); | ||
| 204 | script.addEventListener("error", () => { | ||
| 205 | window.clearTimeout(timeoutId); | ||
| 206 | reject(new Error("Failed to load Clerk UI for desktop auth.")); | ||
| 207 | }); | ||
| 208 | if (!existingScript) { | ||
| 209 | document.head.append(script); | ||
| 210 | } | ||
| 211 | }).catch((error: unknown) => { | ||
| 212 | desktopClerkUiLoad = null; | ||
| 213 | throw error; | ||
| 214 | }); | ||
| 215 | |||
| 216 | desktopClerkUiLoad = load; | ||
| 217 | return load; | ||
| 218 | } | ||
| 219 | |||
| 220 | function getDesktopClerkInstance(publishableKey: string): Clerk { | ||
| 221 | installDesktopClerkFetchProxy(publishableKey); | ||
| 222 | |||
| 223 | const hasKeyChanged = desktopClerk !== null && desktopClerk.publishableKey !== publishableKey; | ||
| 224 | if (hasKeyChanged) { | ||
| 225 | void clearStoredClientJwt(); | ||
| 226 | desktopClerkExternalAccountCleanup?.(); | ||
| 227 | desktopClerkExternalAccountCleanup = null; | ||
| 228 | desktopClerk = null; | ||
| 229 | } | ||
| 230 | |||
| 231 | if (desktopClerk !== null) { | ||
| 232 | return desktopClerk; | ||
| 233 | } | ||
| 234 | |||
| 235 | const nextClerk = new Clerk(publishableKey); | ||
| 236 | installDesktopClerkExternalAccounts(nextClerk); | ||
| 237 | if (!isNativeRequestClerk(nextClerk)) { | ||
| 238 | desktopClerk = nextClerk; | ||
| 239 | return nextClerk; | ||
| 240 | } | ||
| 241 | |||
| 242 | const onBeforeRequest = | ||
| 243 | nextClerk.__internal_onBeforeRequest ?? nextClerk.__unstable__onBeforeRequest; | ||
| 244 | const onAfterResponse = | ||
| 245 | nextClerk.__internal_onAfterResponse ?? nextClerk.__unstable__onAfterResponse; | ||
| 246 | |||
| 247 | // Keep this aligned with Clerk Expo's native FAPI adapter: | ||
| 248 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/expo/src/provider/singleton/createClerkInstance.ts | ||
| 249 | onBeforeRequest(async (request) => { | ||
| 250 | request.credentials = "omit"; | ||
| 251 | request.url?.searchParams.append("_is_native", "1"); | ||
| 252 | const headers = new Headers(request.headers); | ||
| 253 | |||
| 254 | const clientJwt = await getStoredClientJwt(); | ||
| 255 | headers.set("authorization", clientJwt ?? ""); | ||
| 256 | headers.set("x-mobile", "1"); | ||
| 257 | request.headers = headers; | ||
| 258 | }); | ||
| 259 | |||
| 260 | onAfterResponse(async (_request, response) => { | ||
| 261 | const clientJwt = response?.headers.get("authorization"); | ||
| 262 | if (clientJwt) { | ||
| 263 | await setStoredClientJwt(clientJwt); | ||
| 264 | } | ||
| 265 | |||
| 266 | const errorCode = response?.payload?.errors?.[0]?.code; | ||
| 267 | if (errorCode === "native_api_disabled") { | ||
| 268 | console.error( | ||
| 269 | "Clerk Native API is disabled. Enable Native applications in the Clerk dashboard for desktop sign-in.", | ||
| 270 | ); | ||
| 271 | } | ||
| 272 | }); | ||
| 273 | |||
| 274 | desktopClerk = nextClerk; | ||
| 275 | return nextClerk; | ||
| 276 | } | ||
| 277 | |||
| 278 | export function DesktopClerkProvider({ children, publishableKey }: DesktopClerkProviderProps) { | ||
| 279 | const [clerkUiCtor, setClerkUiCtor] = useState<DesktopClerkUiCtor | undefined>( | ||
| 280 | () => window.__internal_ClerkUICtor, | ||
| 281 | ); | ||
| 282 | const [clerkUiError, setClerkUiError] = useState<unknown>(null); | ||
| 283 | |||
| 284 | useEffect(() => { | ||
| 285 | let isCurrent = true; | ||
| 286 | void loadDesktopClerkUi(publishableKey).then( | ||
| 287 | (ClerkUI) => { | ||
| 288 | if (isCurrent) { | ||
| 289 | setClerkUiCtor(() => ClerkUI); | ||
| 290 | } | ||
| 291 | }, | ||
| 292 | (error: unknown) => { | ||
| 293 | if (isCurrent) { | ||
| 294 | setClerkUiError(error); | ||
| 295 | } | ||
| 296 | }, | ||
| 297 | ); | ||
| 298 | return () => { | ||
| 299 | isCurrent = false; | ||
| 300 | }; | ||
| 301 | }, [publishableKey]); | ||
| 302 | |||
| 303 | if (!clerkUiCtor) { | ||
| 304 | if (clerkUiError) { | ||
| 305 | console.error("Failed to load Clerk UI for desktop auth.", clerkUiError); | ||
| 306 | } | ||
| 307 | return null; | ||
| 308 | } | ||
| 309 | |||
| 310 | const clerk = getDesktopClerkInstance(publishableKey); | ||
| 311 | return ( | ||
| 312 | <InternalClerkProvider | ||
| 313 | key={publishableKey} | ||
| 314 | publishableKey={publishableKey} | ||
| 315 | Clerk={clerk as ClerkProviderProps["Clerk"]} | ||
| 316 | ui={{ ClerkUI: clerkUiCtor }} | ||
| 317 | standardBrowser={false} | ||
| 318 | > | ||
| 319 | {children} | ||
| 320 | </InternalClerkProvider> | ||
| 321 | ); | ||
| 322 | } | ||
apps/web/src/cloud/desktopClerkExternalAccounts.test.ts created+78| ... | @@ -0,0 +1,78 @@ | ||
| 1 | import { describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | makeDesktopClerkExternalAccountAdapter, | ||
| 5 | type DesktopClerkUser, | ||
| 6 | } from "./desktopClerkExternalAccounts"; | ||
| 7 | |||
| 8 | describe("desktop Clerk external account adapter", () => { | ||
| 9 | it("replaces renderer redirects with native callbacks and reloads the user on return", async () => { | ||
| 10 | const callbacks: ((rawUrl: string) => void)[] = []; | ||
| 11 | const callbackCleanup = vi.fn(); | ||
| 12 | const bridge = { | ||
| 13 | createCloudAuthRequest: vi | ||
| 14 | .fn() | ||
| 15 | .mockResolvedValueOnce("t3code://auth/callback?t3_state=add") | ||
| 16 | .mockResolvedValueOnce("t3code://auth/callback?t3_state=reconnect"), | ||
| 17 | onCloudAuthCallback: vi.fn((listener: (rawUrl: string) => void) => { | ||
| 18 | callbacks.push(listener); | ||
| 19 | return callbackCleanup; | ||
| 20 | }), | ||
| 21 | }; | ||
| 22 | const reauthorize = vi.fn(async (_params: Record<string, unknown>) => account); | ||
| 23 | const account = { reauthorize }; | ||
| 24 | const createExternalAccount = vi.fn(async (_params: Record<string, unknown>) => account); | ||
| 25 | const reload = vi.fn(async () => undefined); | ||
| 26 | const user = { | ||
| 27 | externalAccounts: [], | ||
| 28 | createExternalAccount, | ||
| 29 | reload, | ||
| 30 | } satisfies DesktopClerkUser; | ||
| 31 | const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); | ||
| 32 | adapter.installUser(user); | ||
| 33 | |||
| 34 | await user.createExternalAccount({ | ||
| 35 | redirectUrl: "http://127.0.0.1:3773/?__clerk_modal_state=state", | ||
| 36 | strategy: "oauth_microsoft", | ||
| 37 | }); | ||
| 38 | |||
| 39 | expect(createExternalAccount).toHaveBeenCalledWith({ | ||
| 40 | redirectUrl: "t3code://auth/callback?t3_state=add", | ||
| 41 | strategy: "oauth_microsoft", | ||
| 42 | }); | ||
| 43 | |||
| 44 | callbacks[0]?.("t3code://auth/callback?t3_state=add"); | ||
| 45 | await Promise.resolve(); | ||
| 46 | expect(reload).toHaveBeenCalledOnce(); | ||
| 47 | |||
| 48 | await account.reauthorize({ | ||
| 49 | redirectUrl: "http://127.0.0.1:3773/?__clerk_modal_state=state", | ||
| 50 | }); | ||
| 51 | expect(reauthorize).toHaveBeenCalledWith({ | ||
| 52 | redirectUrl: "t3code://auth/callback?t3_state=reconnect", | ||
| 53 | }); | ||
| 54 | }); | ||
| 55 | |||
| 56 | it("cleans up the pending callback when Clerk rejects account creation", async () => { | ||
| 57 | const callbackCleanup = vi.fn(); | ||
| 58 | const bridge = { | ||
| 59 | createCloudAuthRequest: vi.fn().mockResolvedValue("t3code://auth/callback?t3_state=failed"), | ||
| 60 | onCloudAuthCallback: vi.fn(() => callbackCleanup), | ||
| 61 | }; | ||
| 62 | const createError = new Error("oauth provider unavailable"); | ||
| 63 | const user = { | ||
| 64 | externalAccounts: [], | ||
| 65 | createExternalAccount: vi.fn(async (_params: Record<string, unknown>) => { | ||
| 66 | throw createError; | ||
| 67 | }), | ||
| 68 | reload: vi.fn(async () => undefined), | ||
| 69 | } satisfies DesktopClerkUser; | ||
| 70 | const adapter = makeDesktopClerkExternalAccountAdapter({ bridge }); | ||
| 71 | adapter.installUser(user); | ||
| 72 | |||
| 73 | await expect(user.createExternalAccount({ strategy: "oauth_microsoft" })).rejects.toBe( | ||
| 74 | createError, | ||
| 75 | ); | ||
| 76 | expect(callbackCleanup).toHaveBeenCalledOnce(); | ||
| 77 | }); | ||
| 78 | }); | ||
apps/web/src/cloud/desktopClerkExternalAccounts.ts created+112| ... | @@ -0,0 +1,112 @@ | ||
| 1 | interface DesktopClerkExternalAccountParams { | ||
| 2 | readonly redirectUrl?: string; | ||
| 3 | readonly [key: string]: unknown; | ||
| 4 | } | ||
| 5 | |||
| 6 | interface DesktopClerkExternalAccount { | ||
| 7 | reauthorize: (params: DesktopClerkExternalAccountParams) => Promise<DesktopClerkExternalAccount>; | ||
| 8 | } | ||
| 9 | |||
| 10 | interface DesktopClerkUser { | ||
| 11 | readonly externalAccounts: readonly DesktopClerkExternalAccount[]; | ||
| 12 | createExternalAccount: ( | ||
| 13 | params: DesktopClerkExternalAccountParams, | ||
| 14 | ) => Promise<DesktopClerkExternalAccount>; | ||
| 15 | reload: () => Promise<unknown>; | ||
| 16 | } | ||
| 17 | |||
| 18 | interface DesktopClerkExternalAccountBridge { | ||
| 19 | readonly createCloudAuthRequest: () => Promise<string>; | ||
| 20 | readonly onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; | ||
| 21 | } | ||
| 22 | |||
| 23 | interface DesktopClerkExternalAccountAdapter { | ||
| 24 | readonly dispose: () => void; | ||
| 25 | readonly installUser: (user: DesktopClerkUser) => void; | ||
| 26 | } | ||
| 27 | |||
| 28 | interface MakeDesktopClerkExternalAccountAdapterInput { | ||
| 29 | readonly bridge: DesktopClerkExternalAccountBridge; | ||
| 30 | readonly reportError?: (message: string, error: unknown) => void; | ||
| 31 | } | ||
| 32 | |||
| 33 | // Clerk's profile component uses window.location.href as the OAuth callback and navigates the | ||
| 34 | // current window to the provider. Keep the upstream component intact while adapting its resource | ||
| 35 | // calls to the native callback bridge: | ||
| 36 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/UserProfile/ConnectedAccountsMenu.tsx | ||
| 37 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx | ||
| 38 | export function makeDesktopClerkExternalAccountAdapter({ | ||
| 39 | bridge, | ||
| 40 | reportError = console.error, | ||
| 41 | }: MakeDesktopClerkExternalAccountAdapterInput): DesktopClerkExternalAccountAdapter { | ||
| 42 | const installedAccounts = new WeakSet<object>(); | ||
| 43 | const installedUsers = new WeakSet<object>(); | ||
| 44 | let callbackGeneration = 0; | ||
| 45 | let callbackCleanup: (() => void) | null = null; | ||
| 46 | |||
| 47 | const clearCallback = () => { | ||
| 48 | callbackGeneration += 1; | ||
| 49 | callbackCleanup?.(); | ||
| 50 | callbackCleanup = null; | ||
| 51 | }; | ||
| 52 | |||
| 53 | const createRedirectUrl = async (user: DesktopClerkUser): Promise<string> => { | ||
| 54 | clearCallback(); | ||
| 55 | const redirectUrl = await bridge.createCloudAuthRequest(); | ||
| 56 | const generation = callbackGeneration; | ||
| 57 | callbackCleanup = bridge.onCloudAuthCallback(() => { | ||
| 58 | if (generation !== callbackGeneration) return; | ||
| 59 | clearCallback(); | ||
| 60 | void user.reload().catch((error: unknown) => { | ||
| 61 | reportError("Failed to reload Clerk after desktop account linking.", error); | ||
| 62 | }); | ||
| 63 | }); | ||
| 64 | return redirectUrl; | ||
| 65 | }; | ||
| 66 | |||
| 67 | const installAccount = (user: DesktopClerkUser, account: DesktopClerkExternalAccount): void => { | ||
| 68 | if (installedAccounts.has(account)) return; | ||
| 69 | installedAccounts.add(account); | ||
| 70 | |||
| 71 | const reauthorize = account.reauthorize.bind(account); | ||
| 72 | account.reauthorize = async (params) => { | ||
| 73 | const redirectUrl = await createRedirectUrl(user); | ||
| 74 | try { | ||
| 75 | const nextAccount = await reauthorize({ ...params, redirectUrl }); | ||
| 76 | installAccount(user, nextAccount); | ||
| 77 | return nextAccount; | ||
| 78 | } catch (error) { | ||
| 79 | clearCallback(); | ||
| 80 | throw error; | ||
| 81 | } | ||
| 82 | }; | ||
| 83 | }; | ||
| 84 | |||
| 85 | const installUser = (user: DesktopClerkUser): void => { | ||
| 86 | for (const account of user.externalAccounts) { | ||
| 87 | installAccount(user, account); | ||
| 88 | } | ||
| 89 | if (installedUsers.has(user)) return; | ||
| 90 | installedUsers.add(user); | ||
| 91 | |||
| 92 | const createExternalAccount = user.createExternalAccount.bind(user); | ||
| 93 | user.createExternalAccount = async (params) => { | ||
| 94 | const redirectUrl = await createRedirectUrl(user); | ||
| 95 | try { | ||
| 96 | const account = await createExternalAccount({ ...params, redirectUrl }); | ||
| 97 | installAccount(user, account); | ||
| 98 | return account; | ||
| 99 | } catch (error) { | ||
| 100 | clearCallback(); | ||
| 101 | throw error; | ||
| 102 | } | ||
| 103 | }; | ||
| 104 | }; | ||
| 105 | |||
| 106 | return { | ||
| 107 | dispose: clearCallback, | ||
| 108 | installUser, | ||
| 109 | }; | ||
| 110 | } | ||
| 111 | |||
| 112 | export type { DesktopClerkExternalAccountAdapter, DesktopClerkUser }; | ||
apps/web/src/cloud/dpop.test.ts created+32| ... | @@ -0,0 +1,32 @@ | ||
| 1 | import { verifyDpopProof } from "@t3tools/shared/dpop"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import { describe, expect, it, vi } from "vitest"; | ||
| 4 | |||
| 5 | import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; | ||
| 6 | |||
| 7 | describe("browser DPoP proofs", () => { | ||
| 8 | it("signs relay resource proofs with an access-token hash", async () => { | ||
| 9 | vi.stubGlobal("indexedDB", undefined); | ||
| 10 | const issuedAt = Math.floor(Date.now() / 1_000); | ||
| 11 | const proofKey = await Effect.runPromise(generateBrowserDpopKey); | ||
| 12 | const proof = await Effect.runPromise( | ||
| 13 | createBrowserDpopProof({ | ||
| 14 | method: "POST", | ||
| 15 | url: "https://relay.example.test/v1/environments/env-1/connect?ignored=true", | ||
| 16 | accessToken: "relay-access-token", | ||
| 17 | proofKey, | ||
| 18 | }).pipe(Effect.provide(browserCryptoLayer)), | ||
| 19 | ); | ||
| 20 | |||
| 21 | expect( | ||
| 22 | verifyDpopProof({ | ||
| 23 | proof: proof.proof, | ||
| 24 | method: "POST", | ||
| 25 | url: "https://relay.example.test/v1/environments/env-1/connect", | ||
| 26 | expectedThumbprint: proof.thumbprint, | ||
| 27 | expectedAccessToken: "relay-access-token", | ||
| 28 | nowEpochSeconds: issuedAt, | ||
| 29 | }), | ||
| 30 | ).toMatchObject({ ok: true }); | ||
| 31 | }); | ||
| 32 | }); | ||
apps/web/src/cloud/dpop.ts created+188| ... | @@ -0,0 +1,188 @@ | ||
| 1 | import { | ||
| 2 | computeDpopAccessTokenHash, | ||
| 3 | computeDpopJwkThumbprint, | ||
| 4 | DpopPublicJwk, | ||
| 5 | } from "@t3tools/shared/dpop"; | ||
| 6 | import * as Crypto from "effect/Crypto"; | ||
| 7 | import * as Data from "effect/Data"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import { importJWK, SignJWT, type JWK } from "jose"; | ||
| 12 | |||
| 13 | export interface BrowserDpopKey { | ||
| 14 | readonly privateKey: CryptoKey; | ||
| 15 | readonly publicJwk: DpopPublicJwk; | ||
| 16 | readonly thumbprint: string; | ||
| 17 | } | ||
| 18 | |||
| 19 | export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{ | ||
| 20 | readonly message: string; | ||
| 21 | readonly cause?: unknown; | ||
| 22 | }> {} | ||
| 23 | |||
| 24 | const DPOP_DATABASE_NAME = "t3code:cloud-auth"; | ||
| 25 | const DPOP_DATABASE_VERSION = 1; | ||
| 26 | const DPOP_KEY_STORE_NAME = "keys"; | ||
| 27 | const DPOP_KEY_ID = "relay-dpop-proof-key"; | ||
| 28 | const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk); | ||
| 29 | |||
| 30 | export const browserCryptoLayer = Layer.succeed( | ||
| 31 | Crypto.Crypto, | ||
| 32 | Crypto.make({ | ||
| 33 | randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)), | ||
| 34 | digest: (algorithm, data) => | ||
| 35 | Effect.promise(async () => { | ||
| 36 | const input = new Uint8Array(data.length); | ||
| 37 | input.set(data); | ||
| 38 | return new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, input.buffer)); | ||
| 39 | }), | ||
| 40 | }), | ||
| 41 | ); | ||
| 42 | |||
| 43 | function dpopError(message: string, cause?: unknown) { | ||
| 44 | return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) }); | ||
| 45 | } | ||
| 46 | |||
| 47 | function openDpopDatabase(): Effect.Effect<IDBDatabase, BrowserDpopError> { | ||
| 48 | return Effect.callback<IDBDatabase, BrowserDpopError>((resume) => { | ||
| 49 | const request = indexedDB.open(DPOP_DATABASE_NAME, DPOP_DATABASE_VERSION); | ||
| 50 | request.addEventListener("error", () => | ||
| 51 | resume( | ||
| 52 | Effect.fail(dpopError("Could not open DPoP key storage.", request.error ?? undefined)), | ||
| 53 | ), | ||
| 54 | ); | ||
| 55 | request.addEventListener("upgradeneeded", () => { | ||
| 56 | if (!request.result.objectStoreNames.contains(DPOP_KEY_STORE_NAME)) { | ||
| 57 | request.result.createObjectStore(DPOP_KEY_STORE_NAME); | ||
| 58 | } | ||
| 59 | }); | ||
| 60 | request.addEventListener("success", () => resume(Effect.succeed(request.result))); | ||
| 61 | }); | ||
| 62 | } | ||
| 63 | |||
| 64 | export function readStoredBrowserDpopKey(): Effect.Effect<BrowserDpopKey | null, BrowserDpopError> { | ||
| 65 | if (typeof indexedDB === "undefined") { | ||
| 66 | return Effect.succeed(null); | ||
| 67 | } | ||
| 68 | return Effect.acquireUseRelease( | ||
| 69 | openDpopDatabase(), | ||
| 70 | (database) => | ||
| 71 | Effect.callback<BrowserDpopKey | null, BrowserDpopError>((resume) => { | ||
| 72 | const request = database | ||
| 73 | .transaction(DPOP_KEY_STORE_NAME, "readonly") | ||
| 74 | .objectStore(DPOP_KEY_STORE_NAME) | ||
| 75 | .get(DPOP_KEY_ID); | ||
| 76 | request.addEventListener("error", () => | ||
| 77 | resume(Effect.fail(dpopError("Could not read DPoP key.", request.error ?? undefined))), | ||
| 78 | ); | ||
| 79 | request.addEventListener("success", () => | ||
| 80 | resume(Effect.succeed((request.result as BrowserDpopKey | undefined) ?? null)), | ||
| 81 | ); | ||
| 82 | }), | ||
| 83 | (database) => Effect.sync(() => database.close()), | ||
| 84 | ); | ||
| 85 | } | ||
| 86 | |||
| 87 | export function writeStoredBrowserDpopKey( | ||
| 88 | key: BrowserDpopKey, | ||
| 89 | ): Effect.Effect<void, BrowserDpopError> { | ||
| 90 | if (typeof indexedDB === "undefined") { | ||
| 91 | return Effect.void; | ||
| 92 | } | ||
| 93 | return Effect.acquireUseRelease( | ||
| 94 | openDpopDatabase(), | ||
| 95 | (database) => | ||
| 96 | Effect.callback<void, BrowserDpopError>((resume) => { | ||
| 97 | const transaction = database.transaction(DPOP_KEY_STORE_NAME, "readwrite"); | ||
| 98 | transaction.addEventListener("error", () => | ||
| 99 | resume( | ||
| 100 | Effect.fail(dpopError("Could not write DPoP key.", transaction.error ?? undefined)), | ||
| 101 | ), | ||
| 102 | ); | ||
| 103 | transaction.addEventListener("complete", () => resume(Effect.void)); | ||
| 104 | transaction.objectStore(DPOP_KEY_STORE_NAME).put(key, DPOP_KEY_ID); | ||
| 105 | }), | ||
| 106 | (database) => Effect.sync(() => database.close()), | ||
| 107 | ); | ||
| 108 | } | ||
| 109 | |||
| 110 | export const generateBrowserDpopKey: Effect.Effect<BrowserDpopKey, BrowserDpopError> = Effect.gen( | ||
| 111 | function* () { | ||
| 112 | const generated = yield* Effect.tryPromise({ | ||
| 113 | try: () => | ||
| 114 | crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ | ||
| 115 | "sign", | ||
| 116 | "verify", | ||
| 117 | ]) as Promise<CryptoKeyPair>, | ||
| 118 | catch: (cause) => dpopError("Could not generate DPoP proof key.", cause), | ||
| 119 | }); | ||
| 120 | const privateJwk = yield* Effect.tryPromise({ | ||
| 121 | try: () => crypto.subtle.exportKey("jwk", generated.privateKey), | ||
| 122 | catch: (cause) => dpopError("Could not export DPoP private key.", cause), | ||
| 123 | }); | ||
| 124 | const publicJwk = yield* Effect.tryPromise({ | ||
| 125 | try: () => crypto.subtle.exportKey("jwk", generated.publicKey), | ||
| 126 | catch: (cause) => dpopError("Could not export DPoP public key.", cause), | ||
| 127 | }).pipe( | ||
| 128 | Effect.flatMap((jwk) => decodeDpopPublicJwk(jwk)), | ||
| 129 | Effect.mapError((cause) => | ||
| 130 | cause instanceof BrowserDpopError | ||
| 131 | ? cause | ||
| 132 | : dpopError("Generated DPoP public key is invalid.", cause), | ||
| 133 | ), | ||
| 134 | ); | ||
| 135 | const privateKey = yield* Effect.tryPromise({ | ||
| 136 | try: () => | ||
| 137 | importJWK(privateJwk as JWK, "ES256", { extractable: false }) as Promise<CryptoKey>, | ||
| 138 | catch: (cause) => dpopError("Could not import DPoP private key.", cause), | ||
| 139 | }); | ||
| 140 | return { | ||
| 141 | privateKey, | ||
| 142 | publicJwk, | ||
| 143 | thumbprint: computeDpopJwkThumbprint(publicJwk), | ||
| 144 | }; | ||
| 145 | }, | ||
| 146 | ); | ||
| 147 | |||
| 148 | export function createBrowserDpopProof(input: { | ||
| 149 | readonly method: string; | ||
| 150 | readonly url: string; | ||
| 151 | readonly accessToken?: string; | ||
| 152 | readonly proofKey: BrowserDpopKey; | ||
| 153 | }): Effect.Effect< | ||
| 154 | { readonly proof: string; readonly thumbprint: string }, | ||
| 155 | BrowserDpopError, | ||
| 156 | Crypto.Crypto | ||
| 157 | > { | ||
| 158 | return Effect.gen(function* () { | ||
| 159 | const normalizedUrl = yield* Effect.try({ | ||
| 160 | try: () => new URL(input.url), | ||
| 161 | catch: (cause) => dpopError("Could not normalize DPoP proof URL.", cause), | ||
| 162 | }); | ||
| 163 | normalizedUrl.search = ""; | ||
| 164 | normalizedUrl.hash = ""; | ||
| 165 | const jti = yield* Crypto.Crypto.pipe( | ||
| 166 | Effect.flatMap((crypto) => crypto.randomUUIDv4), | ||
| 167 | Effect.mapError((cause) => dpopError("Could not generate DPoP proof identifier.", cause)), | ||
| 168 | ); | ||
| 169 | const proof = yield* Effect.tryPromise({ | ||
| 170 | try: () => | ||
| 171 | new SignJWT({ | ||
| 172 | htm: input.method.toUpperCase(), | ||
| 173 | htu: normalizedUrl.toString(), | ||
| 174 | jti, | ||
| 175 | ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), | ||
| 176 | }) | ||
| 177 | .setProtectedHeader({ | ||
| 178 | typ: "dpop+jwt", | ||
| 179 | alg: "ES256", | ||
| 180 | jwk: input.proofKey.publicJwk, | ||
| 181 | }) | ||
| 182 | .setIssuedAt() | ||
| 183 | .sign(input.proofKey.privateKey), | ||
| 184 | catch: (cause) => dpopError("Could not sign DPoP proof.", cause), | ||
| 185 | }); | ||
| 186 | return { proof, thumbprint: input.proofKey.thumbprint }; | ||
| 187 | }); | ||
| 188 | } | ||
apps/web/src/cloud/linkEnvironment.test.ts created+903| ... | @@ -0,0 +1,903 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { RelayWebClientId } from "@t3tools/contracts/relay"; | ||
| 3 | import { afterEach, beforeEach, vi } from "vitest"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import { HttpClient } from "effect/unstable/http"; | ||
| 8 | import { | ||
| 9 | managedRelayClientLayer, | ||
| 10 | ManagedRelayClient, | ||
| 11 | ManagedRelayDpopSigner, | ||
| 12 | remoteHttpClientLayer, | ||
| 13 | } from "@t3tools/client-runtime"; | ||
| 14 | |||
| 15 | import type { SavedEnvironmentRecord } from "../environments/runtime"; | ||
| 16 | import { | ||
| 17 | connectManagedCloudEnvironment, | ||
| 18 | linkEnvironmentToCloud, | ||
| 19 | linkPrimaryEnvironmentToCloud, | ||
| 20 | listManagedCloudEnvironments, | ||
| 21 | normalizeRelayBaseUrl, | ||
| 22 | readPrimaryCloudLinkState, | ||
| 23 | unlinkPrimaryEnvironmentFromCloud, | ||
| 24 | } from "./linkEnvironment"; | ||
| 25 | import { | ||
| 26 | readPrimaryEnvironmentDescriptor, | ||
| 27 | readPrimaryEnvironmentTarget, | ||
| 28 | resolvePrimaryEnvironmentHttpUrl, | ||
| 29 | } from "../environments/primary"; | ||
| 30 | |||
| 31 | const getSavedEnvironmentSecretMock = vi.fn(); | ||
| 32 | const relayClientInstallDialogHarness = vi.hoisted(() => ({ | ||
| 33 | requestConfirmation: vi.fn(), | ||
| 34 | reportProgress: vi.fn(), | ||
| 35 | finish: vi.fn(), | ||
| 36 | })); | ||
| 37 | const getRelayClientStatusMock = vi.fn(); | ||
| 38 | const installRelayClientMock = vi.fn(); | ||
| 39 | const environmentConnectionMock = { | ||
| 40 | client: { | ||
| 41 | cloud: { | ||
| 42 | getRelayClientStatus: getRelayClientStatusMock, | ||
| 43 | installRelayClient: installRelayClientMock, | ||
| 44 | }, | ||
| 45 | }, | ||
| 46 | }; | ||
| 47 | |||
| 48 | const createProofMock = vi.fn( | ||
| 49 | (_input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => | ||
| 50 | Effect.succeed("web-dpop-proof"), | ||
| 51 | ); | ||
| 52 | const testDpopSignerLayer = Layer.succeed( | ||
| 53 | ManagedRelayDpopSigner, | ||
| 54 | ManagedRelayDpopSigner.of({ | ||
| 55 | thumbprint: Effect.succeed("web-thumbprint"), | ||
| 56 | createProof: (input) => createProofMock(input), | ||
| 57 | }), | ||
| 58 | ); | ||
| 59 | |||
| 60 | function cloudClientLayer() { | ||
| 61 | const httpClientLayer = remoteHttpClientLayer(globalThis.fetch); | ||
| 62 | return Layer.mergeAll( | ||
| 63 | httpClientLayer, | ||
| 64 | managedRelayClientLayer({ | ||
| 65 | relayUrl: "https://relay.example.test", | ||
| 66 | clientId: RelayWebClientId, | ||
| 67 | }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)), | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | const withCloudServices = <A, E>( | ||
| 72 | effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>, | ||
| 73 | ) => effect.pipe(Effect.provide(cloudClientLayer())); | ||
| 74 | |||
| 75 | vi.mock("../localApi", () => ({ | ||
| 76 | ensureLocalApi: () => ({ | ||
| 77 | persistence: { | ||
| 78 | getSavedEnvironmentSecret: getSavedEnvironmentSecretMock, | ||
| 79 | }, | ||
| 80 | }), | ||
| 81 | })); | ||
| 82 | |||
| 83 | vi.mock("./relayClientInstallDialog", () => ({ | ||
| 84 | requestRelayClientInstallConfirmation: relayClientInstallDialogHarness.requestConfirmation, | ||
| 85 | reportRelayClientInstallProgress: relayClientInstallDialogHarness.reportProgress, | ||
| 86 | finishRelayClientInstall: relayClientInstallDialogHarness.finish, | ||
| 87 | })); | ||
| 88 | |||
| 89 | vi.mock("../environments/primary", () => ({ | ||
| 90 | readPrimaryEnvironmentDescriptor: vi.fn(() => null), | ||
| 91 | readPrimaryEnvironmentTarget: vi.fn(() => null), | ||
| 92 | resolvePrimaryEnvironmentHttpUrl: vi.fn((path: string) => `http://127.0.0.1:3000${path}`), | ||
| 93 | })); | ||
| 94 | |||
| 95 | vi.mock("../environments/runtime", () => ({ | ||
| 96 | getPrimaryEnvironmentConnection: () => environmentConnectionMock, | ||
| 97 | readEnvironmentConnection: () => environmentConnectionMock, | ||
| 98 | })); | ||
| 99 | |||
| 100 | const savedEnvironment: SavedEnvironmentRecord = { | ||
| 101 | environmentId: EnvironmentId.make("env-1"), | ||
| 102 | label: "Desktop", | ||
| 103 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 104 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 105 | createdAt: "2026-05-25T00:00:00.000Z", | ||
| 106 | lastConnectedAt: null, | ||
| 107 | }; | ||
| 108 | |||
| 109 | function validProof() { | ||
| 110 | return "signed-environment-link-jwt"; | ||
| 111 | } | ||
| 112 | |||
| 113 | function validChallenge() { | ||
| 114 | return { | ||
| 115 | challenge: "link-challenge", | ||
| 116 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 117 | }; | ||
| 118 | } | ||
| 119 | |||
| 120 | function availableRelayClient() { | ||
| 121 | return { | ||
| 122 | status: "available", | ||
| 123 | executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", | ||
| 124 | source: "managed", | ||
| 125 | version: "2026.5.2", | ||
| 126 | }; | ||
| 127 | } | ||
| 128 | |||
| 129 | function requestBodyText(body: BodyInit | null | undefined): string { | ||
| 130 | return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); | ||
| 131 | } | ||
| 132 | |||
| 133 | describe("web cloud link environment client", () => { | ||
| 134 | afterEach(() => { | ||
| 135 | if ("window" in globalThis) { | ||
| 136 | Reflect.deleteProperty(window, "desktopBridge"); | ||
| 137 | } | ||
| 138 | vi.unstubAllGlobals(); | ||
| 139 | }); | ||
| 140 | |||
| 141 | beforeEach(() => { | ||
| 142 | vi.restoreAllMocks(); | ||
| 143 | vi.clearAllMocks(); | ||
| 144 | createProofMock.mockClear(); | ||
| 145 | vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); | ||
| 146 | getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer"); | ||
| 147 | relayClientInstallDialogHarness.requestConfirmation.mockResolvedValue(true); | ||
| 148 | getRelayClientStatusMock.mockResolvedValue(availableRelayClient()); | ||
| 149 | installRelayClientMock.mockResolvedValue(availableRelayClient()); | ||
| 150 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue(null); | ||
| 151 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue(null); | ||
| 152 | vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( | ||
| 153 | (path: string) => `http://127.0.0.1:3000${path}`, | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | |||
| 157 | it("normalizes configured relay base URLs before building relay requests", () => { | ||
| 158 | expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( | ||
| 159 | "https://relay.example.test", | ||
| 160 | ); | ||
| 161 | expect(normalizeRelayBaseUrl(" ")).toBeNull(); | ||
| 162 | }); | ||
| 163 | |||
| 164 | it.effect( | ||
| 165 | "installs the relay client over environment RPC before requesting a cloud challenge", | ||
| 166 | () => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | getRelayClientStatusMock.mockResolvedValue({ | ||
| 169 | status: "missing", | ||
| 170 | version: "2026.5.2", | ||
| 171 | }); | ||
| 172 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ | ||
| 173 | environmentId: EnvironmentId.make("env-1"), | ||
| 174 | label: "Desktop", | ||
| 175 | platform: { os: "darwin", arch: "arm64" }, | ||
| 176 | serverVersion: "0.0.0-test", | ||
| 177 | capabilities: { repositoryIdentity: true }, | ||
| 178 | }); | ||
| 179 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ | ||
| 180 | source: "desktop-managed", | ||
| 181 | target: { | ||
| 182 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 183 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 184 | }, | ||
| 185 | }); | ||
| 186 | const fetchMock = vi | ||
| 187 | .fn() | ||
| 188 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 189 | .mockResolvedValueOnce(Response.json({ malformed: true })); | ||
| 190 | vi.stubGlobal("fetch", fetchMock); | ||
| 191 | installRelayClientMock.mockImplementationOnce(async (onProgress) => { | ||
| 192 | onProgress({ type: "progress", stage: "downloading" }); | ||
| 193 | return availableRelayClient(); | ||
| 194 | }); | ||
| 195 | |||
| 196 | yield* withCloudServices( | ||
| 197 | linkPrimaryEnvironmentToCloud({ | ||
| 198 | clerkToken: "clerk-token", | ||
| 199 | }), | ||
| 200 | ).pipe(Effect.flip); | ||
| 201 | |||
| 202 | expect(relayClientInstallDialogHarness.requestConfirmation).toHaveBeenCalledWith( | ||
| 203 | "2026.5.2", | ||
| 204 | ); | ||
| 205 | expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); | ||
| 206 | expect(installRelayClientMock).toHaveBeenCalledOnce(); | ||
| 207 | expect(relayClientInstallDialogHarness.reportProgress).toHaveBeenCalledWith({ | ||
| 208 | type: "progress", | ||
| 209 | stage: "downloading", | ||
| 210 | }); | ||
| 211 | expect(relayClientInstallDialogHarness.finish).toHaveBeenCalledOnce(); | ||
| 212 | expect(installRelayClientMock.mock.invocationCallOrder[0]).toBeLessThan( | ||
| 213 | fetchMock.mock.invocationCallOrder[0]!, | ||
| 214 | ); | ||
| 215 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe( | ||
| 216 | "https://relay.example.test/v1/client/environment-link-challenges", | ||
| 217 | ); | ||
| 218 | }), | ||
| 219 | ); | ||
| 220 | |||
| 221 | it.effect("lists relay-managed environments for hosted and served web clients", () => | ||
| 222 | Effect.gen(function* () { | ||
| 223 | const fetchMock = vi.fn().mockResolvedValueOnce( | ||
| 224 | Response.json({ | ||
| 225 | environments: [ | ||
| 226 | { | ||
| 227 | environmentId: "env-1", | ||
| 228 | label: "Managed desktop", | ||
| 229 | endpoint: { | ||
| 230 | httpBaseUrl: "https://managed.example.test", | ||
| 231 | wsBaseUrl: "wss://managed.example.test", | ||
| 232 | providerKind: "cloudflare_tunnel", | ||
| 233 | }, | ||
| 234 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 235 | }, | ||
| 236 | ], | ||
| 237 | }), | ||
| 238 | ); | ||
| 239 | vi.stubGlobal("fetch", fetchMock); | ||
| 240 | |||
| 241 | const environments = yield* withCloudServices( | ||
| 242 | listManagedCloudEnvironments({ clerkToken: "clerk-token" }), | ||
| 243 | ); | ||
| 244 | expect(environments).toHaveLength(1); | ||
| 245 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe( | ||
| 246 | "https://relay.example.test/v1/environments", | ||
| 247 | ); | ||
| 248 | expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); | ||
| 249 | expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | |||
| 253 | it.effect("connects web clients to managed environments with a tunnel-only DPoP token", () => | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const environment = { | ||
| 256 | environmentId: EnvironmentId.make("env-1"), | ||
| 257 | label: "Managed desktop", | ||
| 258 | endpoint: { | ||
| 259 | httpBaseUrl: "https://managed.example.test", | ||
| 260 | wsBaseUrl: "wss://managed.example.test", | ||
| 261 | providerKind: "cloudflare_tunnel" as const, | ||
| 262 | }, | ||
| 263 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 264 | }; | ||
| 265 | const fetchMock = vi | ||
| 266 | .fn() | ||
| 267 | .mockResolvedValueOnce( | ||
| 268 | Response.json({ | ||
| 269 | access_token: "relay-access-token", | ||
| 270 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 271 | token_type: "DPoP", | ||
| 272 | expires_in: 300, | ||
| 273 | scope: "environment:connect", | ||
| 274 | }), | ||
| 275 | ) | ||
| 276 | .mockResolvedValueOnce( | ||
| 277 | Response.json({ | ||
| 278 | environmentId: "env-1", | ||
| 279 | endpoint: environment.endpoint, | ||
| 280 | credential: "environment-bootstrap", | ||
| 281 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 282 | }), | ||
| 283 | ) | ||
| 284 | .mockResolvedValueOnce( | ||
| 285 | Response.json({ | ||
| 286 | environmentId: "env-1", | ||
| 287 | label: "Managed desktop", | ||
| 288 | platform: { os: "darwin", arch: "arm64" }, | ||
| 289 | serverVersion: "0.0.0-test", | ||
| 290 | capabilities: { repositoryIdentity: true }, | ||
| 291 | }), | ||
| 292 | ) | ||
| 293 | .mockResolvedValueOnce( | ||
| 294 | Response.json({ | ||
| 295 | access_token: "environment-access-token", | ||
| 296 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 297 | token_type: "DPoP", | ||
| 298 | expires_in: 3600, | ||
| 299 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 300 | }), | ||
| 301 | ); | ||
| 302 | vi.stubGlobal("fetch", fetchMock); | ||
| 303 | |||
| 304 | const connection = yield* withCloudServices( | ||
| 305 | connectManagedCloudEnvironment({ clerkToken: "clerk-token", environment }), | ||
| 306 | ); | ||
| 307 | expect(connection).toMatchObject({ | ||
| 308 | environmentId: "env-1", | ||
| 309 | accessToken: "environment-access-token", | ||
| 310 | }); | ||
| 311 | |||
| 312 | const tokenBody = requestBodyText(fetchMock.mock.calls[0]?.[1]?.body); | ||
| 313 | expect(new URLSearchParams(tokenBody).get("client_id")).toBe("t3-web"); | ||
| 314 | expect(new URLSearchParams(tokenBody).get("scope")).toBe("environment:connect"); | ||
| 315 | expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("DPoP relay-access-token"); | ||
| 316 | expect(fetchMock.mock.calls[1]?.[1]?.headers.dpop).toBe("web-dpop-proof"); | ||
| 317 | expect(createProofMock).toHaveBeenCalledWith({ | ||
| 318 | method: "POST", | ||
| 319 | url: "https://managed.example.test/oauth/token", | ||
| 320 | }); | ||
| 321 | }), | ||
| 322 | ); | ||
| 323 | |||
| 324 | it.effect("rejects a stored managed connection for another relay origin", () => | ||
| 325 | Effect.gen(function* () { | ||
| 326 | const environment = { | ||
| 327 | environmentId: EnvironmentId.make("env-1"), | ||
| 328 | label: "Managed desktop", | ||
| 329 | endpoint: { | ||
| 330 | httpBaseUrl: "https://managed.example.test", | ||
| 331 | wsBaseUrl: "wss://managed.example.test", | ||
| 332 | providerKind: "cloudflare_tunnel" as const, | ||
| 333 | }, | ||
| 334 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 335 | }; | ||
| 336 | |||
| 337 | const error = yield* withCloudServices( | ||
| 338 | connectManagedCloudEnvironment({ | ||
| 339 | clerkToken: "clerk-token", | ||
| 340 | environment, | ||
| 341 | relayUrl: "https://old-relay.example.test", | ||
| 342 | }), | ||
| 343 | ).pipe(Effect.flip); | ||
| 344 | expect(error).toMatchObject({ | ||
| 345 | message: "The saved environment is linked through a different configured relay.", | ||
| 346 | }); | ||
| 347 | }), | ||
| 348 | ); | ||
| 349 | |||
| 350 | it.effect("rejects malformed local environment link proofs", () => | ||
| 351 | Effect.gen(function* () { | ||
| 352 | vi.stubGlobal( | ||
| 353 | "fetch", | ||
| 354 | vi | ||
| 355 | .fn() | ||
| 356 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 357 | .mockResolvedValueOnce( | ||
| 358 | Response.json({ | ||
| 359 | payload: { | ||
| 360 | environmentId: "env-1", | ||
| 361 | }, | ||
| 362 | signature: "signature-1", | ||
| 363 | }), | ||
| 364 | ), | ||
| 365 | ); | ||
| 366 | |||
| 367 | const error = yield* withCloudServices( | ||
| 368 | linkEnvironmentToCloud({ | ||
| 369 | environment: savedEnvironment, | ||
| 370 | clerkToken: "clerk-token", | ||
| 371 | }), | ||
| 372 | ).pipe(Effect.flip); | ||
| 373 | expect(error).toMatchObject({ | ||
| 374 | _tag: "CloudEnvironmentLinkError", | ||
| 375 | message: "Could not obtain environment link proof.", | ||
| 376 | }); | ||
| 377 | }), | ||
| 378 | ); | ||
| 379 | |||
| 380 | it.effect("preserves typed local environment failures while obtaining a link proof", () => | ||
| 381 | Effect.gen(function* () { | ||
| 382 | vi.stubGlobal( | ||
| 383 | "fetch", | ||
| 384 | vi | ||
| 385 | .fn() | ||
| 386 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 387 | .mockResolvedValueOnce( | ||
| 388 | Response.json( | ||
| 389 | { | ||
| 390 | _tag: "EnvironmentHttpUnauthorizedError", | ||
| 391 | message: "Invalid environment bearer session.", | ||
| 392 | }, | ||
| 393 | { status: 401 }, | ||
| 394 | ), | ||
| 395 | ), | ||
| 396 | ); | ||
| 397 | |||
| 398 | const error = yield* withCloudServices( | ||
| 399 | linkEnvironmentToCloud({ | ||
| 400 | environment: savedEnvironment, | ||
| 401 | clerkToken: "clerk-token", | ||
| 402 | }), | ||
| 403 | ).pipe(Effect.flip); | ||
| 404 | expect(error._tag).toBe("CloudEnvironmentLinkError"); | ||
| 405 | expect(error.message).toBe( | ||
| 406 | "Could not obtain environment link proof: Invalid environment bearer session.", | ||
| 407 | ); | ||
| 408 | }), | ||
| 409 | ); | ||
| 410 | |||
| 411 | it.effect("rejects malformed relay environment link responses", () => | ||
| 412 | Effect.gen(function* () { | ||
| 413 | vi.stubGlobal( | ||
| 414 | "fetch", | ||
| 415 | vi | ||
| 416 | .fn() | ||
| 417 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 418 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 419 | .mockResolvedValueOnce( | ||
| 420 | Response.json({ | ||
| 421 | ok: true, | ||
| 422 | environmentId: "env-1", | ||
| 423 | endpoint: { | ||
| 424 | httpBaseUrl: "https://desktop.example.test", | ||
| 425 | wsBaseUrl: "wss://desktop.example.test", | ||
| 426 | providerKind: "cloudflare_tunnel", | ||
| 427 | }, | ||
| 428 | endpointRuntime: null, | ||
| 429 | relayIssuer: "https://issuer.example.test", | ||
| 430 | cloudUserId: "user_123", | ||
| 431 | environmentCredential: "", | ||
| 432 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 433 | }), | ||
| 434 | ), | ||
| 435 | ); | ||
| 436 | |||
| 437 | const error = yield* withCloudServices( | ||
| 438 | linkEnvironmentToCloud({ | ||
| 439 | environment: savedEnvironment, | ||
| 440 | clerkToken: "clerk-token", | ||
| 441 | }), | ||
| 442 | ).pipe(Effect.flip); | ||
| 443 | expect(error).toMatchObject({ | ||
| 444 | _tag: "CloudEnvironmentLinkError", | ||
| 445 | message: "https://relay.example.test/v1/client/environment-links failed", | ||
| 446 | }); | ||
| 447 | }), | ||
| 448 | ); | ||
| 449 | |||
| 450 | it.effect( | ||
| 451 | "links the primary local environment through the relay using the owner cookie session", | ||
| 452 | () => | ||
| 453 | Effect.gen(function* () { | ||
| 454 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ | ||
| 455 | environmentId: EnvironmentId.make("env-1"), | ||
| 456 | label: "Desktop", | ||
| 457 | platform: { os: "darwin", arch: "arm64" }, | ||
| 458 | serverVersion: "0.0.0-test", | ||
| 459 | capabilities: { repositoryIdentity: true }, | ||
| 460 | }); | ||
| 461 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ | ||
| 462 | source: "desktop-managed", | ||
| 463 | target: { | ||
| 464 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 465 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 466 | }, | ||
| 467 | }); | ||
| 468 | vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( | ||
| 469 | (path: string) => `http://127.0.0.1:3000${path}`, | ||
| 470 | ); | ||
| 471 | |||
| 472 | const fetchMock = vi | ||
| 473 | .fn() | ||
| 474 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 475 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 476 | .mockResolvedValueOnce( | ||
| 477 | Response.json({ | ||
| 478 | ok: true, | ||
| 479 | environmentId: "env-1", | ||
| 480 | endpoint: { | ||
| 481 | httpBaseUrl: "https://desktop.example.test", | ||
| 482 | wsBaseUrl: "wss://desktop.example.test", | ||
| 483 | providerKind: "cloudflare_tunnel", | ||
| 484 | }, | ||
| 485 | endpointRuntime: { | ||
| 486 | providerKind: "cloudflare_tunnel", | ||
| 487 | connectorToken: "connector-token", | ||
| 488 | tunnelId: "tunnel-id", | ||
| 489 | tunnelName: "tunnel-name", | ||
| 490 | }, | ||
| 491 | relayIssuer: "https://issuer.example.test", | ||
| 492 | cloudUserId: "user_123", | ||
| 493 | environmentCredential: "t3env_test_credential", | ||
| 494 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 495 | }), | ||
| 496 | ) | ||
| 497 | .mockResolvedValueOnce( | ||
| 498 | Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), | ||
| 499 | ); | ||
| 500 | vi.stubGlobal("fetch", fetchMock); | ||
| 501 | |||
| 502 | yield* withCloudServices( | ||
| 503 | linkPrimaryEnvironmentToCloud({ | ||
| 504 | clerkToken: "clerk-token", | ||
| 505 | }), | ||
| 506 | ); | ||
| 507 | |||
| 508 | expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); | ||
| 509 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe( | ||
| 510 | "https://relay.example.test/v1/client/environment-link-challenges", | ||
| 511 | ); | ||
| 512 | expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); | ||
| 513 | expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); | ||
| 514 | expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); | ||
| 515 | |||
| 516 | expect(String(fetchMock.mock.calls[1]?.[0])).toBe( | ||
| 517 | "http://127.0.0.1:3000/api/cloud/link-proof", | ||
| 518 | ); | ||
| 519 | expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ | ||
| 520 | method: "POST", | ||
| 521 | credentials: "include", | ||
| 522 | headers: expect.objectContaining({ | ||
| 523 | "content-type": "application/json", | ||
| 524 | }), | ||
| 525 | }); | ||
| 526 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 527 | expect(JSON.parse(requestBodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ | ||
| 528 | challenge: "link-challenge", | ||
| 529 | endpoint: { | ||
| 530 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 531 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 532 | providerKind: "cloudflare_tunnel", | ||
| 533 | }, | ||
| 534 | origin: { | ||
| 535 | localHttpHost: "127.0.0.1", | ||
| 536 | localHttpPort: 3000, | ||
| 537 | }, | ||
| 538 | }); | ||
| 539 | |||
| 540 | expect(String(fetchMock.mock.calls[2]?.[0])).toBe( | ||
| 541 | "https://relay.example.test/v1/client/environment-links", | ||
| 542 | ); | ||
| 543 | expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST"); | ||
| 544 | expect(fetchMock.mock.calls[2]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); | ||
| 545 | expect(fetchMock.mock.calls[2]?.[1]?.credentials).not.toBe("include"); | ||
| 546 | expect(fetchMock.mock.calls[2]?.[1]?.headers["content-type"]).toBe("application/json"); | ||
| 547 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 548 | expect(JSON.parse(requestBodyText(fetchMock.mock.calls[2]?.[1]?.body))).toMatchObject({ | ||
| 549 | proof: validProof(), | ||
| 550 | notificationsEnabled: true, | ||
| 551 | liveActivitiesEnabled: true, | ||
| 552 | managedTunnelsEnabled: true, | ||
| 553 | }); | ||
| 554 | |||
| 555 | expect(String(fetchMock.mock.calls[3]?.[0])).toBe( | ||
| 556 | "http://127.0.0.1:3000/api/cloud/relay-config", | ||
| 557 | ); | ||
| 558 | expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({ | ||
| 559 | method: "POST", | ||
| 560 | credentials: "include", | ||
| 561 | headers: expect.objectContaining({ | ||
| 562 | "content-type": "application/json", | ||
| 563 | }), | ||
| 564 | }); | ||
| 565 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 566 | expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ | ||
| 567 | relayUrl: "https://relay.example.test", | ||
| 568 | relayIssuer: "https://issuer.example.test", | ||
| 569 | cloudUserId: "user_123", | ||
| 570 | environmentCredential: "t3env_test_credential", | ||
| 571 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 572 | endpointRuntime: { | ||
| 573 | providerKind: "cloudflare_tunnel", | ||
| 574 | connectorToken: "connector-token", | ||
| 575 | tunnelId: "tunnel-id", | ||
| 576 | tunnelName: "tunnel-name", | ||
| 577 | }, | ||
| 578 | }); | ||
| 579 | }), | ||
| 580 | ); | ||
| 581 | |||
| 582 | it.effect("reads the primary local cloud link state with the owner cookie session", () => | ||
| 583 | Effect.gen(function* () { | ||
| 584 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ | ||
| 585 | environmentId: EnvironmentId.make("env-1"), | ||
| 586 | label: "Desktop", | ||
| 587 | platform: { os: "darwin", arch: "arm64" }, | ||
| 588 | serverVersion: "0.0.0-test", | ||
| 589 | capabilities: { repositoryIdentity: true }, | ||
| 590 | }); | ||
| 591 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ | ||
| 592 | source: "desktop-managed", | ||
| 593 | target: { | ||
| 594 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 595 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 596 | }, | ||
| 597 | }); | ||
| 598 | const fetchMock = vi.fn().mockResolvedValueOnce( | ||
| 599 | Response.json({ | ||
| 600 | linked: true, | ||
| 601 | cloudUserId: "user_123", | ||
| 602 | relayUrl: "https://relay.example.test", | ||
| 603 | relayIssuer: "https://issuer.example.test", | ||
| 604 | publishAgentActivity: false, | ||
| 605 | }), | ||
| 606 | ); | ||
| 607 | vi.stubGlobal("fetch", fetchMock); | ||
| 608 | |||
| 609 | const state = yield* withCloudServices(readPrimaryCloudLinkState()); | ||
| 610 | expect(state).toEqual({ | ||
| 611 | linked: true, | ||
| 612 | cloudUserId: "user_123", | ||
| 613 | relayUrl: "https://relay.example.test", | ||
| 614 | relayIssuer: "https://issuer.example.test", | ||
| 615 | publishAgentActivity: false, | ||
| 616 | }); | ||
| 617 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe( | ||
| 618 | "http://127.0.0.1:3000/api/cloud/link-state", | ||
| 619 | ); | ||
| 620 | expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ | ||
| 621 | method: "GET", | ||
| 622 | credentials: "include", | ||
| 623 | }); | ||
| 624 | }), | ||
| 625 | ); | ||
| 626 | |||
| 627 | it.effect("clears local relay credentials before revoking the primary cloud link", () => | ||
| 628 | Effect.gen(function* () { | ||
| 629 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ | ||
| 630 | environmentId: EnvironmentId.make("env-1"), | ||
| 631 | label: "Desktop", | ||
| 632 | platform: { os: "darwin", arch: "arm64" }, | ||
| 633 | serverVersion: "0.0.0-test", | ||
| 634 | capabilities: { repositoryIdentity: true }, | ||
| 635 | }); | ||
| 636 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ | ||
| 637 | source: "desktop-managed", | ||
| 638 | target: { | ||
| 639 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 640 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 641 | }, | ||
| 642 | }); | ||
| 643 | const fetchMock = vi | ||
| 644 | .fn() | ||
| 645 | .mockResolvedValueOnce( | ||
| 646 | Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), | ||
| 647 | ) | ||
| 648 | .mockResolvedValueOnce(Response.json({ ok: true })); | ||
| 649 | vi.stubGlobal("fetch", fetchMock); | ||
| 650 | |||
| 651 | yield* withCloudServices( | ||
| 652 | unlinkPrimaryEnvironmentFromCloud({ | ||
| 653 | clerkToken: "clerk-token", | ||
| 654 | }), | ||
| 655 | ); | ||
| 656 | |||
| 657 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink"); | ||
| 658 | expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ | ||
| 659 | method: "POST", | ||
| 660 | credentials: "include", | ||
| 661 | }); | ||
| 662 | expect(String(fetchMock.mock.calls[1]?.[0])).toBe( | ||
| 663 | "https://relay.example.test/v1/client/environment-links/env-1", | ||
| 664 | ); | ||
| 665 | expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE"); | ||
| 666 | expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); | ||
| 667 | }), | ||
| 668 | ); | ||
| 669 | |||
| 670 | it.effect("still clears local relay credentials when relay revocation fails", () => | ||
| 671 | Effect.gen(function* () { | ||
| 672 | vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ | ||
| 673 | environmentId: EnvironmentId.make("env-1"), | ||
| 674 | label: "Desktop", | ||
| 675 | platform: { os: "darwin", arch: "arm64" }, | ||
| 676 | serverVersion: "0.0.0-test", | ||
| 677 | capabilities: { repositoryIdentity: true }, | ||
| 678 | }); | ||
| 679 | vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ | ||
| 680 | source: "desktop-managed", | ||
| 681 | target: { | ||
| 682 | httpBaseUrl: "http://127.0.0.1:3000", | ||
| 683 | wsBaseUrl: "ws://127.0.0.1:3000", | ||
| 684 | }, | ||
| 685 | }); | ||
| 686 | const fetchMock = vi | ||
| 687 | .fn() | ||
| 688 | .mockResolvedValueOnce( | ||
| 689 | Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), | ||
| 690 | ) | ||
| 691 | .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })); | ||
| 692 | vi.stubGlobal("fetch", fetchMock); | ||
| 693 | |||
| 694 | yield* withCloudServices( | ||
| 695 | unlinkPrimaryEnvironmentFromCloud({ | ||
| 696 | clerkToken: "clerk-token", | ||
| 697 | }), | ||
| 698 | ); | ||
| 699 | |||
| 700 | expect(fetchMock).toHaveBeenCalledTimes(2); | ||
| 701 | expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/cloud/unlink"); | ||
| 702 | expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ | ||
| 703 | method: "POST", | ||
| 704 | credentials: "include", | ||
| 705 | }); | ||
| 706 | }), | ||
| 707 | ); | ||
| 708 | |||
| 709 | it.effect("rejects primary environment linking when the local environment is not ready", () => | ||
| 710 | Effect.gen(function* () { | ||
| 711 | vi.stubGlobal("fetch", vi.fn()); | ||
| 712 | |||
| 713 | const error = yield* withCloudServices( | ||
| 714 | linkPrimaryEnvironmentToCloud({ | ||
| 715 | clerkToken: "clerk-token", | ||
| 716 | }), | ||
| 717 | ).pipe(Effect.flip); | ||
| 718 | expect(error).toMatchObject({ | ||
| 719 | _tag: "CloudEnvironmentLinkError", | ||
| 720 | message: "Local environment is not ready yet.", | ||
| 721 | }); | ||
| 722 | expect(fetch).not.toHaveBeenCalled(); | ||
| 723 | }), | ||
| 724 | ); | ||
| 725 | |||
| 726 | it.effect("preserves relay transport failures while linking environments", () => | ||
| 727 | Effect.gen(function* () { | ||
| 728 | vi.stubGlobal( | ||
| 729 | "fetch", | ||
| 730 | vi | ||
| 731 | .fn() | ||
| 732 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 733 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 734 | .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })), | ||
| 735 | ); | ||
| 736 | |||
| 737 | const error = yield* withCloudServices( | ||
| 738 | linkEnvironmentToCloud({ | ||
| 739 | environment: savedEnvironment, | ||
| 740 | clerkToken: "clerk-token", | ||
| 741 | }), | ||
| 742 | ).pipe(Effect.flip); | ||
| 743 | expect(error).toMatchObject({ | ||
| 744 | _tag: "CloudEnvironmentLinkError", | ||
| 745 | message: "https://relay.example.test/v1/client/environment-links failed", | ||
| 746 | }); | ||
| 747 | }), | ||
| 748 | ); | ||
| 749 | |||
| 750 | it.effect("preserves typed relay error bodies while linking environments", () => | ||
| 751 | Effect.gen(function* () { | ||
| 752 | vi.stubGlobal( | ||
| 753 | "fetch", | ||
| 754 | vi | ||
| 755 | .fn() | ||
| 756 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 757 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 758 | .mockResolvedValueOnce( | ||
| 759 | Response.json( | ||
| 760 | { | ||
| 761 | _tag: "RelayEnvironmentLinkProofInvalidError", | ||
| 762 | code: "environment_link_proof_invalid", | ||
| 763 | reason: "origin_not_allowed", | ||
| 764 | traceId: "trace-test", | ||
| 765 | }, | ||
| 766 | { status: 400 }, | ||
| 767 | ), | ||
| 768 | ), | ||
| 769 | ); | ||
| 770 | |||
| 771 | const error = yield* withCloudServices( | ||
| 772 | linkEnvironmentToCloud({ | ||
| 773 | environment: savedEnvironment, | ||
| 774 | clerkToken: "clerk-token", | ||
| 775 | }), | ||
| 776 | ).pipe(Effect.flip); | ||
| 777 | expect(error).toMatchObject({ | ||
| 778 | _tag: "CloudEnvironmentLinkError", | ||
| 779 | message: | ||
| 780 | "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", | ||
| 781 | }); | ||
| 782 | }), | ||
| 783 | ); | ||
| 784 | |||
| 785 | it.effect("rejects relay credentials for a different environment", () => | ||
| 786 | Effect.gen(function* () { | ||
| 787 | const fetchMock = vi | ||
| 788 | .fn() | ||
| 789 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 790 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 791 | .mockResolvedValueOnce( | ||
| 792 | Response.json({ | ||
| 793 | ok: true, | ||
| 794 | environmentId: "env-2", | ||
| 795 | endpoint: { | ||
| 796 | httpBaseUrl: "https://desktop.example.test", | ||
| 797 | wsBaseUrl: "wss://desktop.example.test", | ||
| 798 | providerKind: "cloudflare_tunnel", | ||
| 799 | }, | ||
| 800 | endpointRuntime: null, | ||
| 801 | relayIssuer: "https://issuer.example.test", | ||
| 802 | cloudUserId: "user_123", | ||
| 803 | environmentCredential: "t3env_test_credential", | ||
| 804 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 805 | }), | ||
| 806 | ); | ||
| 807 | vi.stubGlobal("fetch", fetchMock); | ||
| 808 | |||
| 809 | const error = yield* withCloudServices( | ||
| 810 | linkEnvironmentToCloud({ | ||
| 811 | environment: savedEnvironment, | ||
| 812 | clerkToken: "clerk-token", | ||
| 813 | }), | ||
| 814 | ).pipe(Effect.flip); | ||
| 815 | expect(error).toMatchObject({ | ||
| 816 | _tag: "CloudEnvironmentLinkError", | ||
| 817 | message: "Relay returned credentials for a different environment.", | ||
| 818 | }); | ||
| 819 | expect(fetchMock).toHaveBeenCalledTimes(3); | ||
| 820 | }), | ||
| 821 | ); | ||
| 822 | |||
| 823 | it.effect("rejects relay credentials for a different managed endpoint provider", () => | ||
| 824 | Effect.gen(function* () { | ||
| 825 | const fetchMock = vi | ||
| 826 | .fn() | ||
| 827 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 828 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 829 | .mockResolvedValueOnce( | ||
| 830 | Response.json({ | ||
| 831 | ok: true, | ||
| 832 | environmentId: "env-1", | ||
| 833 | endpoint: { | ||
| 834 | httpBaseUrl: "https://desktop.example.test", | ||
| 835 | wsBaseUrl: "wss://desktop.example.test", | ||
| 836 | providerKind: "manual", | ||
| 837 | }, | ||
| 838 | endpointRuntime: null, | ||
| 839 | relayIssuer: "https://issuer.example.test", | ||
| 840 | cloudUserId: "user_123", | ||
| 841 | environmentCredential: "t3env_test_credential", | ||
| 842 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 843 | }), | ||
| 844 | ); | ||
| 845 | vi.stubGlobal("fetch", fetchMock); | ||
| 846 | |||
| 847 | const error = yield* withCloudServices( | ||
| 848 | linkEnvironmentToCloud({ | ||
| 849 | environment: savedEnvironment, | ||
| 850 | clerkToken: "clerk-token", | ||
| 851 | }), | ||
| 852 | ).pipe(Effect.flip); | ||
| 853 | expect(error).toMatchObject({ | ||
| 854 | _tag: "CloudEnvironmentLinkError", | ||
| 855 | message: "Relay returned credentials for a different endpoint provider.", | ||
| 856 | }); | ||
| 857 | expect(fetchMock).toHaveBeenCalledTimes(3); | ||
| 858 | }), | ||
| 859 | ); | ||
| 860 | |||
| 861 | it.effect("passes the relay issuer from the link response into local relay config", () => | ||
| 862 | Effect.gen(function* () { | ||
| 863 | const fetchMock = vi | ||
| 864 | .fn() | ||
| 865 | .mockResolvedValueOnce(Response.json(validChallenge())) | ||
| 866 | .mockResolvedValueOnce(Response.json(validProof())) | ||
| 867 | .mockResolvedValueOnce( | ||
| 868 | Response.json({ | ||
| 869 | ok: true, | ||
| 870 | environmentId: "env-1", | ||
| 871 | endpoint: { | ||
| 872 | httpBaseUrl: "https://desktop.example.test", | ||
| 873 | wsBaseUrl: "wss://desktop.example.test", | ||
| 874 | providerKind: "cloudflare_tunnel", | ||
| 875 | }, | ||
| 876 | endpointRuntime: null, | ||
| 877 | relayIssuer: "https://issuer.example.test", | ||
| 878 | cloudUserId: "user_123", | ||
| 879 | environmentCredential: "t3env_test_credential", | ||
| 880 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 881 | }), | ||
| 882 | ) | ||
| 883 | .mockResolvedValueOnce( | ||
| 884 | Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), | ||
| 885 | ); | ||
| 886 | vi.stubGlobal("fetch", fetchMock); | ||
| 887 | |||
| 888 | yield* withCloudServices( | ||
| 889 | linkEnvironmentToCloud({ | ||
| 890 | environment: savedEnvironment, | ||
| 891 | clerkToken: "clerk-token", | ||
| 892 | }), | ||
| 893 | ); | ||
| 894 | |||
| 895 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 896 | expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ | ||
| 897 | relayUrl: "https://relay.example.test", | ||
| 898 | relayIssuer: "https://issuer.example.test", | ||
| 899 | cloudUserId: "user_123", | ||
| 900 | }); | ||
| 901 | }), | ||
| 902 | ); | ||
| 903 | }); | ||
apps/web/src/cloud/linkEnvironment.ts created+715| ... | @@ -0,0 +1,715 @@ | ||
| 1 | import * as Data from "effect/Data"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import { HttpClient } from "effect/unstable/http"; | ||
| 5 | import { | ||
| 6 | EnvironmentCloudEndpointUnavailableError, | ||
| 7 | type EnvironmentCloudLinkStateResult, | ||
| 8 | EnvironmentHttpBadRequestError, | ||
| 9 | EnvironmentHttpConflictError, | ||
| 10 | EnvironmentHttpForbiddenError, | ||
| 11 | EnvironmentHttpInternalServerError, | ||
| 12 | EnvironmentHttpUnauthorizedError, | ||
| 13 | EnvironmentId, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | import { | ||
| 16 | RelayEnvironmentConnectScope, | ||
| 17 | type RelayClientDeviceRecord, | ||
| 18 | type RelayEnvironmentLinkResponse, | ||
| 19 | RelayProtectedError, | ||
| 20 | type RelayClientEnvironmentRecord, | ||
| 21 | type RelayProtectedError as RelayProtectedErrorType, | ||
| 22 | type RelayManagedEndpointProviderKind, | ||
| 23 | } from "@t3tools/contracts/relay"; | ||
| 24 | import { | ||
| 25 | exchangeRemoteDpopAccessToken, | ||
| 26 | fetchRemoteEnvironmentDescriptor, | ||
| 27 | makeEnvironmentHttpApiClient, | ||
| 28 | ManagedRelayClient, | ||
| 29 | ManagedRelayDpopSigner, | ||
| 30 | type WsRpcClient, | ||
| 31 | } from "@t3tools/client-runtime"; | ||
| 32 | |||
| 33 | import { ensureLocalApi } from "../localApi"; | ||
| 34 | import { | ||
| 35 | getPrimaryEnvironmentConnection, | ||
| 36 | readEnvironmentConnection, | ||
| 37 | type SavedEnvironmentRecord, | ||
| 38 | } from "../environments/runtime"; | ||
| 39 | import { | ||
| 40 | readPrimaryEnvironmentDescriptor, | ||
| 41 | readPrimaryEnvironmentTarget, | ||
| 42 | resolvePrimaryEnvironmentHttpUrl, | ||
| 43 | } from "../environments/primary"; | ||
| 44 | import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit"; | ||
| 45 | import { resolveCloudPublicConfig } from "./publicConfig"; | ||
| 46 | import { | ||
| 47 | finishRelayClientInstall, | ||
| 48 | reportRelayClientInstallProgress, | ||
| 49 | requestRelayClientInstallConfirmation, | ||
| 50 | } from "./relayClientInstallDialog"; | ||
| 51 | |||
| 52 | export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { | ||
| 53 | const trimmed = value?.trim(); | ||
| 54 | if (!trimmed) { | ||
| 55 | return null; | ||
| 56 | } | ||
| 57 | return trimmed.replace(/\/+$/g, ""); | ||
| 58 | } | ||
| 59 | |||
| 60 | function relayUrl(): string | null { | ||
| 61 | return resolveCloudPublicConfig().relayUrl; | ||
| 62 | } | ||
| 63 | |||
| 64 | export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ | ||
| 65 | readonly message: string; | ||
| 66 | readonly cause?: unknown; | ||
| 67 | }> {} | ||
| 68 | |||
| 69 | const relayClientRpcError = (message: string) => (cause: unknown) => | ||
| 70 | new CloudEnvironmentLinkError({ | ||
| 71 | message, | ||
| 72 | cause, | ||
| 73 | }); | ||
| 74 | |||
| 75 | function ensureRelayClientAvailable( | ||
| 76 | client: WsRpcClient, | ||
| 77 | ): Effect.Effect<void, CloudEnvironmentLinkError> { | ||
| 78 | return Effect.gen(function* () { | ||
| 79 | const status = yield* Effect.tryPromise({ | ||
| 80 | try: () => client.cloud.getRelayClientStatus(), | ||
| 81 | catch: relayClientRpcError("Could not check relay client availability."), | ||
| 82 | }); | ||
| 83 | if (status.status === "available") return; | ||
| 84 | if (status.status === "unsupported") { | ||
| 85 | return yield* new CloudEnvironmentLinkError({ | ||
| 86 | message: `T3 Code cannot install the relay client automatically on ${status.platform}-${status.arch}.`, | ||
| 87 | }); | ||
| 88 | } | ||
| 89 | |||
| 90 | const confirmed = yield* Effect.tryPromise({ | ||
| 91 | try: () => requestRelayClientInstallConfirmation(status.version), | ||
| 92 | catch: relayClientRpcError("Could not confirm relay client installation."), | ||
| 93 | }); | ||
| 94 | if (!confirmed) { | ||
| 95 | return yield* new CloudEnvironmentLinkError({ | ||
| 96 | message: "Relay client installation was cancelled.", | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | |||
| 100 | const installed = yield* Effect.tryPromise({ | ||
| 101 | try: () => client.cloud.installRelayClient(reportRelayClientInstallProgress), | ||
| 102 | catch: relayClientRpcError("Could not install the relay client."), | ||
| 103 | }).pipe(Effect.ensuring(Effect.sync(finishRelayClientInstall))); | ||
| 104 | if (installed.status !== "available") { | ||
| 105 | return yield* new CloudEnvironmentLinkError({ | ||
| 106 | message: | ||
| 107 | installed.status === "unsupported" | ||
| 108 | ? `T3 Code cannot install the relay client automatically on ${installed.platform}-${installed.arch}.` | ||
| 109 | : "The relay client is still unavailable after installation.", | ||
| 110 | }); | ||
| 111 | } | ||
| 112 | }); | ||
| 113 | } | ||
| 114 | |||
| 115 | const isRelayProtectedError = Schema.is(RelayProtectedError); | ||
| 116 | const isEnvironmentCloudApiError = Schema.is( | ||
| 117 | Schema.Union([ | ||
| 118 | EnvironmentHttpBadRequestError, | ||
| 119 | EnvironmentHttpUnauthorizedError, | ||
| 120 | EnvironmentHttpForbiddenError, | ||
| 121 | EnvironmentHttpConflictError, | ||
| 122 | EnvironmentHttpInternalServerError, | ||
| 123 | EnvironmentCloudEndpointUnavailableError, | ||
| 124 | ]), | ||
| 125 | ); | ||
| 126 | |||
| 127 | function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { | ||
| 128 | switch (error._tag) { | ||
| 129 | case "RelayAuthInvalidError": | ||
| 130 | switch (error.reason) { | ||
| 131 | case "missing_bearer": | ||
| 132 | case "invalid_bearer": | ||
| 133 | return "Relay rejected the cloud session token."; | ||
| 134 | case "invalid_dpop": | ||
| 135 | return "Relay rejected the DPoP proof."; | ||
| 136 | case "not_authorized": | ||
| 137 | return "Relay rejected the authenticated request."; | ||
| 138 | } | ||
| 139 | case "RelayEnvironmentLinkProofExpiredError": | ||
| 140 | return "Relay rejected an expired environment link proof."; | ||
| 141 | case "RelayEnvironmentLinkProofInvalidError": | ||
| 142 | return `Relay rejected the environment link proof (${error.reason}).`; | ||
| 143 | case "RelayEnvironmentConnectNotAuthorizedError": | ||
| 144 | return "Relay rejected the environment connection request."; | ||
| 145 | case "RelayEnvironmentEndpointUnavailableError": | ||
| 146 | return `Relay could not reach the environment endpoint (${error.reason}).`; | ||
| 147 | case "RelayEnvironmentEndpointTimedOutError": | ||
| 148 | return "Relay timed out while contacting the environment endpoint."; | ||
| 149 | case "RelayEnvironmentLinkFailedError": | ||
| 150 | return `Relay could not link the environment (${error.reason}).`; | ||
| 151 | case "RelayEnvironmentLinkUnavailableError": | ||
| 152 | return `Relay cannot provision the managed endpoint (${error.reason}).`; | ||
| 153 | case "RelayAgentActivityPublishProofExpiredError": | ||
| 154 | return "Relay rejected an expired agent activity publish proof."; | ||
| 155 | case "RelayAgentActivityPublishProofInvalidError": | ||
| 156 | return `Relay rejected the agent activity publish proof (${error.reason}).`; | ||
| 157 | case "RelayInternalError": | ||
| 158 | return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | function decodedRelayClientError(message: string) { | ||
| 163 | return (cause: unknown) => { | ||
| 164 | const relayError = findRelayProtectedError(cause); | ||
| 165 | const detail = relayError ? relayProtectedErrorMessage(relayError) : null; | ||
| 166 | return new CloudEnvironmentLinkError({ | ||
| 167 | message: detail ? `${message}: ${detail}` : message, | ||
| 168 | cause, | ||
| 169 | }); | ||
| 170 | }; | ||
| 171 | } | ||
| 172 | |||
| 173 | function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { | ||
| 174 | if (isRelayProtectedError(cause)) { | ||
| 175 | return cause; | ||
| 176 | } | ||
| 177 | if (typeof cause !== "object" || cause === null) { | ||
| 178 | return null; | ||
| 179 | } | ||
| 180 | return "cause" in cause ? findRelayProtectedError(cause.cause) : null; | ||
| 181 | } | ||
| 182 | |||
| 183 | function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { | ||
| 184 | if (isEnvironmentCloudApiError(cause)) { | ||
| 185 | return cause; | ||
| 186 | } | ||
| 187 | if (typeof cause !== "object" || cause === null) { | ||
| 188 | return null; | ||
| 189 | } | ||
| 190 | return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; | ||
| 191 | } | ||
| 192 | |||
| 193 | const environmentApiError = (message: string) => (cause: unknown) => { | ||
| 194 | const environmentError = findEnvironmentCloudApiError(cause); | ||
| 195 | return new CloudEnvironmentLinkError({ | ||
| 196 | message: environmentError | ||
| 197 | ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` | ||
| 198 | : message, | ||
| 199 | cause, | ||
| 200 | }); | ||
| 201 | }; | ||
| 202 | |||
| 203 | function endpointOrigin(httpBaseUrl: string) { | ||
| 204 | const url = new URL(httpBaseUrl); | ||
| 205 | return { | ||
| 206 | localHttpHost: "127.0.0.1", | ||
| 207 | localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), | ||
| 208 | }; | ||
| 209 | } | ||
| 210 | |||
| 211 | const MANAGED_ENDPOINT_PROVIDER_KIND = | ||
| 212 | "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; | ||
| 213 | |||
| 214 | function ensureLinkedEnvironmentMatches(input: { | ||
| 215 | readonly expectedEnvironmentId: string; | ||
| 216 | readonly expectedProviderKind: RelayManagedEndpointProviderKind; | ||
| 217 | readonly link: RelayEnvironmentLinkResponse; | ||
| 218 | }): Effect.Effect<void, CloudEnvironmentLinkError> { | ||
| 219 | if (input.link.environmentId !== input.expectedEnvironmentId) { | ||
| 220 | return new CloudEnvironmentLinkError({ | ||
| 221 | message: "Relay returned credentials for a different environment.", | ||
| 222 | }); | ||
| 223 | } | ||
| 224 | if (input.link.endpoint.providerKind !== input.expectedProviderKind) { | ||
| 225 | return new CloudEnvironmentLinkError({ | ||
| 226 | message: "Relay returned credentials for a different endpoint provider.", | ||
| 227 | }); | ||
| 228 | } | ||
| 229 | return Effect.void; | ||
| 230 | } | ||
| 231 | |||
| 232 | export interface CloudLinkTarget { | ||
| 233 | readonly environmentId: string; | ||
| 234 | readonly label: string; | ||
| 235 | readonly httpBaseUrl: string; | ||
| 236 | readonly wsBaseUrl: string; | ||
| 237 | } | ||
| 238 | |||
| 239 | export type CloudLinkState = EnvironmentCloudLinkStateResult; | ||
| 240 | |||
| 241 | export interface CloudManagedConnection { | ||
| 242 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 243 | readonly label: string; | ||
| 244 | readonly httpBaseUrl: string; | ||
| 245 | readonly wsBaseUrl: string; | ||
| 246 | readonly relayUrl: string; | ||
| 247 | readonly accessToken: string; | ||
| 248 | } | ||
| 249 | |||
| 250 | export function collectCloudLinkTargets(input: { | ||
| 251 | readonly primary: CloudLinkTarget | null; | ||
| 252 | readonly saved: ReadonlyArray<CloudLinkTarget>; | ||
| 253 | }): ReadonlyArray<CloudLinkTarget> { | ||
| 254 | const byId = new Map<string, CloudLinkTarget>(); | ||
| 255 | if (input.primary) { | ||
| 256 | byId.set(input.primary.environmentId, input.primary); | ||
| 257 | } | ||
| 258 | for (const environment of input.saved) { | ||
| 259 | if (!byId.has(environment.environmentId)) { | ||
| 260 | byId.set(environment.environmentId, environment); | ||
| 261 | } | ||
| 262 | } | ||
| 263 | return [...byId.values()]; | ||
| 264 | } | ||
| 265 | |||
| 266 | export function readPrimaryCloudLinkTarget(): CloudLinkTarget | null { | ||
| 267 | const descriptor = readPrimaryEnvironmentDescriptor(); | ||
| 268 | const target = readPrimaryEnvironmentTarget(); | ||
| 269 | if (!descriptor || !target) { | ||
| 270 | return null; | ||
| 271 | } | ||
| 272 | return { | ||
| 273 | environmentId: descriptor.environmentId, | ||
| 274 | label: descriptor.label, | ||
| 275 | httpBaseUrl: target.target.httpBaseUrl, | ||
| 276 | wsBaseUrl: target.target.wsBaseUrl, | ||
| 277 | }; | ||
| 278 | } | ||
| 279 | |||
| 280 | export function listManagedCloudEnvironments(input: { | ||
| 281 | readonly clerkToken: string; | ||
| 282 | }): Effect.Effect< | ||
| 283 | ReadonlyArray<RelayClientEnvironmentRecord>, | ||
| 284 | CloudEnvironmentLinkError, | ||
| 285 | ManagedRelayClient | ||
| 286 | > { | ||
| 287 | return Effect.gen(function* () { | ||
| 288 | const configuredRelayUrl = relayUrl(); | ||
| 289 | if (!configuredRelayUrl) { | ||
| 290 | return yield* new CloudEnvironmentLinkError({ | ||
| 291 | message: "T3CODE_RELAY_URL is not configured.", | ||
| 292 | }); | ||
| 293 | } | ||
| 294 | const relayClient = yield* ManagedRelayClient; | ||
| 295 | return yield* relayClient | ||
| 296 | .listEnvironments({ | ||
| 297 | clerkToken: input.clerkToken, | ||
| 298 | }) | ||
| 299 | .pipe( | ||
| 300 | Effect.mapError( | ||
| 301 | (cause) => | ||
| 302 | new CloudEnvironmentLinkError({ | ||
| 303 | message: "Could not list relay-managed environments.", | ||
| 304 | cause, | ||
| 305 | }), | ||
| 306 | ), | ||
| 307 | ); | ||
| 308 | }); | ||
| 309 | } | ||
| 310 | |||
| 311 | export function listCloudDevices(input: { | ||
| 312 | readonly clerkToken: string; | ||
| 313 | }): Effect.Effect< | ||
| 314 | ReadonlyArray<RelayClientDeviceRecord>, | ||
| 315 | CloudEnvironmentLinkError, | ||
| 316 | ManagedRelayClient | ||
| 317 | > { | ||
| 318 | return Effect.gen(function* () { | ||
| 319 | if (!relayUrl()) { | ||
| 320 | return yield* new CloudEnvironmentLinkError({ | ||
| 321 | message: "T3CODE_RELAY_URL is not configured.", | ||
| 322 | }); | ||
| 323 | } | ||
| 324 | const relayClient = yield* ManagedRelayClient; | ||
| 325 | return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe( | ||
| 326 | Effect.mapError( | ||
| 327 | (cause) => | ||
| 328 | new CloudEnvironmentLinkError({ | ||
| 329 | message: "Could not list cloud devices.", | ||
| 330 | cause, | ||
| 331 | }), | ||
| 332 | ), | ||
| 333 | ); | ||
| 334 | }); | ||
| 335 | } | ||
| 336 | |||
| 337 | export function connectManagedCloudEnvironment(input: { | ||
| 338 | readonly clerkToken: string; | ||
| 339 | readonly environment: RelayClientEnvironmentRecord; | ||
| 340 | readonly relayUrl?: string; | ||
| 341 | }): Effect.Effect< | ||
| 342 | CloudManagedConnection, | ||
| 343 | CloudEnvironmentLinkError, | ||
| 344 | HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner | ||
| 345 | > { | ||
| 346 | return Effect.gen(function* () { | ||
| 347 | const configuredRelayUrl = relayUrl(); | ||
| 348 | if (!configuredRelayUrl) { | ||
| 349 | return yield* new CloudEnvironmentLinkError({ | ||
| 350 | message: "T3CODE_RELAY_URL is not configured.", | ||
| 351 | }); | ||
| 352 | } | ||
| 353 | const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl); | ||
| 354 | if (persistedRelayUrl && persistedRelayUrl !== configuredRelayUrl) { | ||
| 355 | return yield* new CloudEnvironmentLinkError({ | ||
| 356 | message: "The saved environment is linked through a different configured relay.", | ||
| 357 | }); | ||
| 358 | } | ||
| 359 | const relayClient = yield* ManagedRelayClient; | ||
| 360 | const connected = yield* relayClient | ||
| 361 | .connectEnvironment({ | ||
| 362 | clerkToken: input.clerkToken, | ||
| 363 | scopes: [RelayEnvironmentConnectScope], | ||
| 364 | environmentId: input.environment.environmentId, | ||
| 365 | }) | ||
| 366 | .pipe( | ||
| 367 | Effect.mapError( | ||
| 368 | (cause) => | ||
| 369 | new CloudEnvironmentLinkError({ | ||
| 370 | message: "Could not connect to relay-managed environment.", | ||
| 371 | cause, | ||
| 372 | }), | ||
| 373 | ), | ||
| 374 | ); | ||
| 375 | if (connected.environmentId !== input.environment.environmentId) { | ||
| 376 | return yield* new CloudEnvironmentLinkError({ | ||
| 377 | message: "Relay returned credentials for a different environment.", | ||
| 378 | }); | ||
| 379 | } | ||
| 380 | if ( | ||
| 381 | connected.endpoint.httpBaseUrl !== input.environment.endpoint.httpBaseUrl || | ||
| 382 | connected.endpoint.wsBaseUrl !== input.environment.endpoint.wsBaseUrl || | ||
| 383 | connected.endpoint.providerKind !== input.environment.endpoint.providerKind | ||
| 384 | ) { | ||
| 385 | return yield* new CloudEnvironmentLinkError({ | ||
| 386 | message: "Relay returned credentials for a different endpoint.", | ||
| 387 | }); | ||
| 388 | } | ||
| 389 | const descriptor = yield* fetchRemoteEnvironmentDescriptor({ | ||
| 390 | httpBaseUrl: connected.endpoint.httpBaseUrl, | ||
| 391 | }).pipe( | ||
| 392 | Effect.mapError( | ||
| 393 | (cause) => | ||
| 394 | new CloudEnvironmentLinkError({ | ||
| 395 | message: "Could not read connected environment descriptor.", | ||
| 396 | cause, | ||
| 397 | }), | ||
| 398 | ), | ||
| 399 | ); | ||
| 400 | if (descriptor.environmentId !== connected.environmentId) { | ||
| 401 | return yield* new CloudEnvironmentLinkError({ | ||
| 402 | message: "Connected endpoint does not match the selected environment.", | ||
| 403 | }); | ||
| 404 | } | ||
| 405 | const signer = yield* ManagedRelayDpopSigner; | ||
| 406 | const bootstrapProof = yield* signer | ||
| 407 | .createProof({ | ||
| 408 | method: "POST", | ||
| 409 | url: new URL("/oauth/token", connected.endpoint.httpBaseUrl).toString(), | ||
| 410 | }) | ||
| 411 | .pipe( | ||
| 412 | Effect.mapError( | ||
| 413 | (cause) => | ||
| 414 | new CloudEnvironmentLinkError({ | ||
| 415 | message: "Could not create environment DPoP proof.", | ||
| 416 | cause, | ||
| 417 | }), | ||
| 418 | ), | ||
| 419 | ); | ||
| 420 | const session = yield* exchangeRemoteDpopAccessToken({ | ||
| 421 | httpBaseUrl: connected.endpoint.httpBaseUrl, | ||
| 422 | credential: connected.credential, | ||
| 423 | dpopProof: bootstrapProof, | ||
| 424 | }).pipe( | ||
| 425 | Effect.mapError( | ||
| 426 | (cause) => | ||
| 427 | new CloudEnvironmentLinkError({ | ||
| 428 | message: "Could not authorize managed environment.", | ||
| 429 | cause, | ||
| 430 | }), | ||
| 431 | ), | ||
| 432 | ); | ||
| 433 | return { | ||
| 434 | environmentId: descriptor.environmentId, | ||
| 435 | label: descriptor.label, | ||
| 436 | httpBaseUrl: connected.endpoint.httpBaseUrl, | ||
| 437 | wsBaseUrl: connected.endpoint.wsBaseUrl, | ||
| 438 | relayUrl: configuredRelayUrl, | ||
| 439 | accessToken: session.access_token, | ||
| 440 | }; | ||
| 441 | }); | ||
| 442 | } | ||
| 443 | |||
| 444 | export function readPrimaryCloudLinkState(): Effect.Effect< | ||
| 445 | CloudLinkState | null, | ||
| 446 | CloudEnvironmentLinkError, | ||
| 447 | HttpClient.HttpClient | ||
| 448 | > { | ||
| 449 | return Effect.gen(function* () { | ||
| 450 | if (!readPrimaryCloudLinkTarget()) { | ||
| 451 | return null; | ||
| 452 | } | ||
| 453 | const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); | ||
| 454 | return yield* client.cloud | ||
| 455 | .linkState({ headers: {} }) | ||
| 456 | .pipe( | ||
| 457 | withPrimaryEnvironmentRequestInit, | ||
| 458 | Effect.mapError(environmentApiError("Could not read environment cloud link state.")), | ||
| 459 | ); | ||
| 460 | }); | ||
| 461 | } | ||
| 462 | |||
| 463 | export function updatePrimaryCloudPreferences(input: { | ||
| 464 | readonly publishAgentActivity: boolean; | ||
| 465 | }): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> { | ||
| 466 | return Effect.gen(function* () { | ||
| 467 | const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); | ||
| 468 | return yield* client.cloud | ||
| 469 | .preferences({ | ||
| 470 | headers: {}, | ||
| 471 | payload: input, | ||
| 472 | }) | ||
| 473 | .pipe( | ||
| 474 | withPrimaryEnvironmentRequestInit, | ||
| 475 | Effect.mapError(environmentApiError("Could not update environment cloud preferences.")), | ||
| 476 | ); | ||
| 477 | }); | ||
| 478 | } | ||
| 479 | |||
| 480 | export function unlinkPrimaryEnvironmentFromCloud(input: { | ||
| 481 | readonly clerkToken: string | null; | ||
| 482 | }): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> { | ||
| 483 | return Effect.gen(function* () { | ||
| 484 | const target = readPrimaryCloudLinkTarget(); | ||
| 485 | if (!target) { | ||
| 486 | return yield* new CloudEnvironmentLinkError({ | ||
| 487 | message: "Local environment is not ready yet.", | ||
| 488 | }); | ||
| 489 | } | ||
| 490 | const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); | ||
| 491 | yield* client.cloud | ||
| 492 | .unlink({ headers: {} }) | ||
| 493 | .pipe( | ||
| 494 | withPrimaryEnvironmentRequestInit, | ||
| 495 | Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")), | ||
| 496 | ); | ||
| 497 | |||
| 498 | const configuredRelayUrl = relayUrl(); | ||
| 499 | if (configuredRelayUrl && input.clerkToken) { | ||
| 500 | const relayClient = yield* ManagedRelayClient; | ||
| 501 | yield* relayClient | ||
| 502 | .unlinkEnvironment({ | ||
| 503 | clerkToken: input.clerkToken, | ||
| 504 | environmentId: EnvironmentId.make(target.environmentId), | ||
| 505 | }) | ||
| 506 | .pipe( | ||
| 507 | Effect.catch((cause) => | ||
| 508 | Effect.logWarning("Could not revoke cloud environment link after local unlink.", { | ||
| 509 | cause, | ||
| 510 | }), | ||
| 511 | ), | ||
| 512 | ); | ||
| 513 | } | ||
| 514 | }); | ||
| 515 | } | ||
| 516 | |||
| 517 | export function linkEnvironmentToCloud(input: { | ||
| 518 | readonly environment: SavedEnvironmentRecord; | ||
| 519 | readonly clerkToken: string; | ||
| 520 | }): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> { | ||
| 521 | return Effect.gen(function* () { | ||
| 522 | const configuredRelayUrl = relayUrl(); | ||
| 523 | if (!configuredRelayUrl) { | ||
| 524 | return yield* new CloudEnvironmentLinkError({ | ||
| 525 | message: "T3CODE_RELAY_URL is not configured.", | ||
| 526 | }); | ||
| 527 | } | ||
| 528 | const relayClient = yield* ManagedRelayClient; | ||
| 529 | const bearerToken = yield* Effect.tryPromise({ | ||
| 530 | try: () => | ||
| 531 | ensureLocalApi().persistence.getSavedEnvironmentSecret(input.environment.environmentId), | ||
| 532 | catch: (cause) => | ||
| 533 | new CloudEnvironmentLinkError({ | ||
| 534 | message: `Could not read saved bearer token for ${input.environment.label}.`, | ||
| 535 | cause, | ||
| 536 | }), | ||
| 537 | }); | ||
| 538 | if (!bearerToken) { | ||
| 539 | return yield* new CloudEnvironmentLinkError({ | ||
| 540 | message: `No saved bearer token for ${input.environment.label}.`, | ||
| 541 | }); | ||
| 542 | } | ||
| 543 | |||
| 544 | const connection = readEnvironmentConnection(input.environment.environmentId); | ||
| 545 | if (!connection) { | ||
| 546 | return yield* new CloudEnvironmentLinkError({ | ||
| 547 | message: `${input.environment.label} is not connected.`, | ||
| 548 | }); | ||
| 549 | } | ||
| 550 | yield* ensureRelayClientAvailable(connection.client); | ||
| 551 | |||
| 552 | const environmentClient = yield* makeEnvironmentHttpApiClient(input.environment.httpBaseUrl); | ||
| 553 | const headers = { authorization: `Bearer ${bearerToken}` }; | ||
| 554 | |||
| 555 | const challenge = yield* relayClient | ||
| 556 | .createEnvironmentLinkChallenge({ | ||
| 557 | clerkToken: input.clerkToken, | ||
| 558 | payload: { | ||
| 559 | notificationsEnabled: true, | ||
| 560 | liveActivitiesEnabled: true, | ||
| 561 | managedTunnelsEnabled: true, | ||
| 562 | }, | ||
| 563 | }) | ||
| 564 | .pipe( | ||
| 565 | Effect.mapError( | ||
| 566 | decodedRelayClientError( | ||
| 567 | `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, | ||
| 568 | ), | ||
| 569 | ), | ||
| 570 | ); | ||
| 571 | const proof = yield* environmentClient.cloud | ||
| 572 | .linkProof({ | ||
| 573 | headers, | ||
| 574 | payload: { | ||
| 575 | challenge: challenge.challenge, | ||
| 576 | relayIssuer: configuredRelayUrl, | ||
| 577 | endpoint: { | ||
| 578 | httpBaseUrl: input.environment.httpBaseUrl, | ||
| 579 | wsBaseUrl: input.environment.wsBaseUrl, | ||
| 580 | providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, | ||
| 581 | }, | ||
| 582 | origin: endpointOrigin(input.environment.httpBaseUrl), | ||
| 583 | }, | ||
| 584 | }) | ||
| 585 | .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); | ||
| 586 | const link = yield* relayClient | ||
| 587 | .linkEnvironment({ | ||
| 588 | clerkToken: input.clerkToken, | ||
| 589 | payload: { | ||
| 590 | proof, | ||
| 591 | notificationsEnabled: true, | ||
| 592 | liveActivitiesEnabled: true, | ||
| 593 | managedTunnelsEnabled: true, | ||
| 594 | }, | ||
| 595 | }) | ||
| 596 | .pipe( | ||
| 597 | Effect.mapError( | ||
| 598 | decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), | ||
| 599 | ), | ||
| 600 | ); | ||
| 601 | yield* ensureLinkedEnvironmentMatches({ | ||
| 602 | expectedEnvironmentId: input.environment.environmentId, | ||
| 603 | expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, | ||
| 604 | link, | ||
| 605 | }); | ||
| 606 | |||
| 607 | yield* environmentClient.cloud | ||
| 608 | .relayConfig({ | ||
| 609 | headers, | ||
| 610 | payload: { | ||
| 611 | relayUrl: configuredRelayUrl, | ||
| 612 | relayIssuer: link.relayIssuer, | ||
| 613 | cloudUserId: link.cloudUserId, | ||
| 614 | environmentCredential: link.environmentCredential, | ||
| 615 | cloudMintPublicKey: link.cloudMintPublicKey, | ||
| 616 | endpointRuntime: link.endpointRuntime, | ||
| 617 | }, | ||
| 618 | }) | ||
| 619 | .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); | ||
| 620 | }); | ||
| 621 | } | ||
| 622 | |||
| 623 | export function linkPrimaryEnvironmentToCloud(input: { | ||
| 624 | readonly clerkToken: string; | ||
| 625 | }): Effect.Effect<void, CloudEnvironmentLinkError, HttpClient.HttpClient | ManagedRelayClient> { | ||
| 626 | return Effect.gen(function* () { | ||
| 627 | const configuredRelayUrl = relayUrl(); | ||
| 628 | if (!configuredRelayUrl) { | ||
| 629 | return yield* new CloudEnvironmentLinkError({ | ||
| 630 | message: "T3CODE_RELAY_URL is not configured.", | ||
| 631 | }); | ||
| 632 | } | ||
| 633 | const relayClient = yield* ManagedRelayClient; | ||
| 634 | const target = readPrimaryCloudLinkTarget(); | ||
| 635 | if (!target) { | ||
| 636 | return yield* new CloudEnvironmentLinkError({ | ||
| 637 | message: "Local environment is not ready yet.", | ||
| 638 | }); | ||
| 639 | } | ||
| 640 | const environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl); | ||
| 641 | yield* ensureRelayClientAvailable(getPrimaryEnvironmentConnection().client); | ||
| 642 | |||
| 643 | const challenge = yield* relayClient | ||
| 644 | .createEnvironmentLinkChallenge({ | ||
| 645 | clerkToken: input.clerkToken, | ||
| 646 | payload: { | ||
| 647 | notificationsEnabled: true, | ||
| 648 | liveActivitiesEnabled: true, | ||
| 649 | managedTunnelsEnabled: true, | ||
| 650 | }, | ||
| 651 | }) | ||
| 652 | .pipe( | ||
| 653 | Effect.mapError( | ||
| 654 | decodedRelayClientError( | ||
| 655 | `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, | ||
| 656 | ), | ||
| 657 | ), | ||
| 658 | ); | ||
| 659 | const proof = yield* environmentClient.cloud | ||
| 660 | .linkProof({ | ||
| 661 | headers: {}, | ||
| 662 | payload: { | ||
| 663 | challenge: challenge.challenge, | ||
| 664 | relayIssuer: configuredRelayUrl, | ||
| 665 | endpoint: { | ||
| 666 | httpBaseUrl: target.httpBaseUrl, | ||
| 667 | wsBaseUrl: target.wsBaseUrl, | ||
| 668 | providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, | ||
| 669 | }, | ||
| 670 | origin: endpointOrigin(target.httpBaseUrl), | ||
| 671 | }, | ||
| 672 | }) | ||
| 673 | .pipe( | ||
| 674 | withPrimaryEnvironmentRequestInit, | ||
| 675 | Effect.mapError(environmentApiError("Could not obtain environment link proof.")), | ||
| 676 | ); | ||
| 677 | const link = yield* relayClient | ||
| 678 | .linkEnvironment({ | ||
| 679 | clerkToken: input.clerkToken, | ||
| 680 | payload: { | ||
| 681 | proof, | ||
| 682 | notificationsEnabled: true, | ||
| 683 | liveActivitiesEnabled: true, | ||
| 684 | managedTunnelsEnabled: true, | ||
| 685 | }, | ||
| 686 | }) | ||
| 687 | .pipe( | ||
| 688 | Effect.mapError( | ||
| 689 | decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), | ||
| 690 | ), | ||
| 691 | ); | ||
| 692 | yield* ensureLinkedEnvironmentMatches({ | ||
| 693 | expectedEnvironmentId: target.environmentId, | ||
| 694 | expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, | ||
| 695 | link, | ||
| 696 | }); | ||
| 697 | |||
| 698 | yield* environmentClient.cloud | ||
| 699 | .relayConfig({ | ||
| 700 | headers: {}, | ||
| 701 | payload: { | ||
| 702 | relayUrl: configuredRelayUrl, | ||
| 703 | relayIssuer: link.relayIssuer, | ||
| 704 | cloudUserId: link.cloudUserId, | ||
| 705 | environmentCredential: link.environmentCredential, | ||
| 706 | cloudMintPublicKey: link.cloudMintPublicKey, | ||
| 707 | endpointRuntime: link.endpointRuntime, | ||
| 708 | }, | ||
| 709 | }) | ||
| 710 | .pipe( | ||
| 711 | withPrimaryEnvironmentRequestInit, | ||
| 712 | Effect.mapError(environmentApiError("Could not configure environment relay access.")), | ||
| 713 | ); | ||
| 714 | }); | ||
| 715 | } | ||
apps/web/src/cloud/managedAuth.tsx created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | import { useAuth } from "@clerk/react"; | ||
| 2 | import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; | ||
| 3 | import { useEffect, type ReactNode } from "react"; | ||
| 4 | |||
| 5 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 6 | import { resolveRelayClerkTokenOptions } from "./publicConfig"; | ||
| 7 | |||
| 8 | let relayTokenProvider: (() => Promise<string | null>) | null = null; | ||
| 9 | |||
| 10 | export async function readManagedRelayClerkToken(): Promise<string | null> { | ||
| 11 | return relayTokenProvider?.() ?? null; | ||
| 12 | } | ||
| 13 | |||
| 14 | export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) { | ||
| 15 | const { getToken, isSignedIn, userId } = useAuth(); | ||
| 16 | |||
| 17 | useEffect(() => { | ||
| 18 | relayTokenProvider = isSignedIn ? () => getToken(resolveRelayClerkTokenOptions()) : null; | ||
| 19 | setManagedRelaySession( | ||
| 20 | appAtomRegistry, | ||
| 21 | isSignedIn && userId | ||
| 22 | ? createManagedRelaySession({ | ||
| 23 | accountId: userId, | ||
| 24 | readClerkToken: () => getToken(resolveRelayClerkTokenOptions()), | ||
| 25 | }) | ||
| 26 | : null, | ||
| 27 | ); | ||
| 28 | return () => { | ||
| 29 | relayTokenProvider = null; | ||
| 30 | setManagedRelaySession(appAtomRegistry, null); | ||
| 31 | }; | ||
| 32 | }, [getToken, isSignedIn, userId]); | ||
| 33 | |||
| 34 | return children; | ||
| 35 | } | ||
apps/web/src/cloud/managedRelayLayer.ts created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | import { | ||
| 2 | managedRelayClientLayer, | ||
| 3 | ManagedRelayDpopSigner, | ||
| 4 | ManagedRelayDpopSignerError, | ||
| 5 | } from "@t3tools/client-runtime"; | ||
| 6 | import { RelayWebClientId } from "@t3tools/contracts/relay"; | ||
| 7 | import * as Crypto from "effect/Crypto"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import * as Semaphore from "effect/Semaphore"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | createBrowserDpopProof, | ||
| 14 | generateBrowserDpopKey, | ||
| 15 | readStoredBrowserDpopKey, | ||
| 16 | writeStoredBrowserDpopKey, | ||
| 17 | type BrowserDpopKey, | ||
| 18 | } from "./dpop"; | ||
| 19 | |||
| 20 | export const webRelayDpopSignerLayer = Layer.effect( | ||
| 21 | ManagedRelayDpopSigner, | ||
| 22 | Effect.gen(function* () { | ||
| 23 | const crypto = yield* Crypto.Crypto; | ||
| 24 | const keyLoadSemaphore = yield* Semaphore.make(1); | ||
| 25 | let loadedKey: BrowserDpopKey | null = null; | ||
| 26 | const loadOrCreateBrowserDpopKey = keyLoadSemaphore.withPermit( | ||
| 27 | Effect.gen(function* () { | ||
| 28 | if (loadedKey) { | ||
| 29 | return loadedKey; | ||
| 30 | } | ||
| 31 | const stored = yield* readStoredBrowserDpopKey(); | ||
| 32 | if (stored) { | ||
| 33 | loadedKey = stored; | ||
| 34 | return stored; | ||
| 35 | } | ||
| 36 | const generated = yield* generateBrowserDpopKey; | ||
| 37 | yield* writeStoredBrowserDpopKey(generated); | ||
| 38 | loadedKey = generated; | ||
| 39 | return generated; | ||
| 40 | }), | ||
| 41 | ); | ||
| 42 | const signerError = (cause: unknown) => new ManagedRelayDpopSignerError({ cause }); | ||
| 43 | return ManagedRelayDpopSigner.of({ | ||
| 44 | thumbprint: loadOrCreateBrowserDpopKey.pipe( | ||
| 45 | Effect.map((proofKey) => proofKey.thumbprint), | ||
| 46 | Effect.mapError(signerError), | ||
| 47 | ), | ||
| 48 | createProof: (input) => | ||
| 49 | loadOrCreateBrowserDpopKey.pipe( | ||
| 50 | Effect.flatMap((proofKey) => createBrowserDpopProof({ ...input, proofKey })), | ||
| 51 | Effect.provideService(Crypto.Crypto, crypto), | ||
| 52 | Effect.map((proof) => proof.proof), | ||
| 53 | Effect.mapError(signerError), | ||
| 54 | ), | ||
| 55 | }); | ||
| 56 | }), | ||
| 57 | ); | ||
| 58 | |||
| 59 | export const webManagedRelayClientLayer = (relayUrl: string) => | ||
| 60 | managedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( | ||
| 61 | Layer.provideMerge(webRelayDpopSignerLayer), | ||
| 62 | ); | ||
apps/web/src/cloud/managedRelayState.ts created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | createManagedRelayQueryManager, | ||
| 4 | ManagedRelayClient, | ||
| 5 | managedRelaySessionAtom, | ||
| 6 | readManagedRelaySnapshotState, | ||
| 7 | } from "@t3tools/client-runtime"; | ||
| 8 | import type { | ||
| 9 | RelayClientDeviceRecord, | ||
| 10 | RelayClientEnvironmentRecord, | ||
| 11 | } from "@t3tools/contracts/relay"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 16 | import { useCallback } from "react"; | ||
| 17 | |||
| 18 | import { webRuntime } from "../lib/runtime"; | ||
| 19 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 20 | |||
| 21 | const managedRelayAtomRuntime = Atom.runtime( | ||
| 22 | Layer.effect( | ||
| 23 | ManagedRelayClient, | ||
| 24 | webRuntime.contextEffect.pipe( | ||
| 25 | Effect.map((context) => Context.get(context, ManagedRelayClient)), | ||
| 26 | ), | ||
| 27 | ), | ||
| 28 | ); | ||
| 29 | |||
| 30 | export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); | ||
| 31 | |||
| 32 | const EMPTY_ENVIRONMENTS_ATOM = Atom.make( | ||
| 33 | AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]), | ||
| 34 | ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null")); | ||
| 35 | |||
| 36 | const EMPTY_DEVICES_ATOM = Atom.make( | ||
| 37 | AsyncResult.success<ReadonlyArray<RelayClientDeviceRecord>>([]), | ||
| 38 | ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:devices:null")); | ||
| 39 | |||
| 40 | export function useManagedRelayEnvironments() { | ||
| 41 | const session = useAtomValue(managedRelaySessionAtom); | ||
| 42 | const accountId = session?.accountId ?? null; | ||
| 43 | const atom = accountId | ||
| 44 | ? managedRelayQueryManager.environmentsAtom(accountId) | ||
| 45 | : EMPTY_ENVIRONMENTS_ATOM; | ||
| 46 | const result = useAtomValue(atom); | ||
| 47 | const refresh = useCallback(() => { | ||
| 48 | if (accountId) { | ||
| 49 | managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); | ||
| 50 | } | ||
| 51 | }, [accountId]); | ||
| 52 | |||
| 53 | return { | ||
| 54 | ...readManagedRelaySnapshotState(result), | ||
| 55 | accountId, | ||
| 56 | refresh, | ||
| 57 | }; | ||
| 58 | } | ||
| 59 | |||
| 60 | export function useManagedRelayDevices() { | ||
| 61 | const session = useAtomValue(managedRelaySessionAtom); | ||
| 62 | const accountId = session?.accountId ?? null; | ||
| 63 | const atom = accountId ? managedRelayQueryManager.devicesAtom(accountId) : EMPTY_DEVICES_ATOM; | ||
| 64 | const result = useAtomValue(atom); | ||
| 65 | const refresh = useCallback(() => { | ||
| 66 | if (accountId) { | ||
| 67 | managedRelayQueryManager.refreshDevices(appAtomRegistry, accountId); | ||
| 68 | } | ||
| 69 | }, [accountId]); | ||
| 70 | |||
| 71 | return { | ||
| 72 | ...readManagedRelaySnapshotState(result), | ||
| 73 | accountId, | ||
| 74 | refresh, | ||
| 75 | }; | ||
| 76 | } | ||
| 77 | |||
| 78 | export function refreshManagedRelayEnvironments(): void { | ||
| 79 | const session = appAtomRegistry.get(managedRelaySessionAtom); | ||
| 80 | if (session) { | ||
| 81 | managedRelayQueryManager.refreshEnvironments(appAtomRegistry, session.accountId); | ||
| 82 | } | ||
| 83 | } | ||
apps/web/src/cloud/primaryCloudLinkState.ts created+67| ... | @@ -0,0 +1,67 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import type { EnvironmentCloudLinkStateResult, EnvironmentId } from "@t3tools/contracts"; | ||
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Context from "effect/Context"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 9 | import { HttpClient } from "effect/unstable/http"; | ||
| 10 | import { useCallback } from "react"; | ||
| 11 | |||
| 12 | import { usePrimaryEnvironmentId } from "../environments/primary"; | ||
| 13 | import { webRuntime } from "../lib/runtime"; | ||
| 14 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 15 | import { readPrimaryCloudLinkState } from "./linkEnvironment"; | ||
| 16 | |||
| 17 | const primaryCloudLinkAtomRuntime = Atom.runtime( | ||
| 18 | Layer.effect( | ||
| 19 | HttpClient.HttpClient, | ||
| 20 | webRuntime.contextEffect.pipe( | ||
| 21 | Effect.map((context) => Context.get(context, HttpClient.HttpClient)), | ||
| 22 | ), | ||
| 23 | ), | ||
| 24 | ); | ||
| 25 | |||
| 26 | const primaryCloudLinkStateAtom = Atom.family((environmentId: EnvironmentId) => | ||
| 27 | primaryCloudLinkAtomRuntime | ||
| 28 | .atom(readPrimaryCloudLinkState()) | ||
| 29 | .pipe( | ||
| 30 | Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), | ||
| 31 | Atom.setIdleTTL(5 * 60_000), | ||
| 32 | Atom.withLabel(`primary-cloud-link:${environmentId}`), | ||
| 33 | ), | ||
| 34 | ); | ||
| 35 | |||
| 36 | const EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM = Atom.make( | ||
| 37 | AsyncResult.success<EnvironmentCloudLinkStateResult | null>(null), | ||
| 38 | ).pipe(Atom.keepAlive, Atom.withLabel("primary-cloud-link:null")); | ||
| 39 | |||
| 40 | export function refreshPrimaryCloudLinkState(environmentId: EnvironmentId | null): void { | ||
| 41 | if (environmentId) { | ||
| 42 | appAtomRegistry.refresh(primaryCloudLinkStateAtom(environmentId)); | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | export function usePrimaryCloudLinkState() { | ||
| 47 | const environmentId = usePrimaryEnvironmentId(); | ||
| 48 | const atom = environmentId | ||
| 49 | ? primaryCloudLinkStateAtom(environmentId) | ||
| 50 | : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; | ||
| 51 | const result = useAtomValue(atom); | ||
| 52 | const refresh = useCallback(() => { | ||
| 53 | refreshPrimaryCloudLinkState(environmentId); | ||
| 54 | }, [environmentId]); | ||
| 55 | let error: string | null = null; | ||
| 56 | if (result._tag === "Failure") { | ||
| 57 | const cause = Cause.squash(result.cause); | ||
| 58 | error = cause instanceof Error ? cause.message : "Could not read T3 Cloud link state."; | ||
| 59 | } | ||
| 60 | |||
| 61 | return { | ||
| 62 | data: Option.getOrNull(AsyncResult.value(result)), | ||
| 63 | error, | ||
| 64 | isPending: result.waiting, | ||
| 65 | refresh, | ||
| 66 | }; | ||
| 67 | } | ||
apps/web/src/cloud/publicConfig.test.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | import { hasCloudPublicConfig } from "./publicConfig.ts"; | ||
| 4 | |||
| 5 | afterEach(() => { | ||
| 6 | vi.unstubAllEnvs(); | ||
| 7 | }); | ||
| 8 | |||
| 9 | describe("hasCloudPublicConfig", () => { | ||
| 10 | it("requires both public cloud values", () => { | ||
| 11 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", ""); | ||
| 12 | vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", ""); | ||
| 13 | vi.stubEnv("VITE_T3CODE_RELAY_URL", ""); | ||
| 14 | expect(hasCloudPublicConfig()).toBe(false); | ||
| 15 | |||
| 16 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example"); | ||
| 17 | expect(hasCloudPublicConfig()).toBe(false); | ||
| 18 | |||
| 19 | vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); | ||
| 20 | expect(hasCloudPublicConfig()).toBe(false); | ||
| 21 | |||
| 22 | vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); | ||
| 23 | expect(hasCloudPublicConfig()).toBe(true); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("rejects an insecure relay URL", () => { | ||
| 27 | vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", "pk_test_example"); | ||
| 28 | vi.stubEnv("VITE_CLERK_JWT_TEMPLATE", "t3-relay"); | ||
| 29 | vi.stubEnv("VITE_T3CODE_RELAY_URL", "http://relay.example.test"); | ||
| 30 | |||
| 31 | expect(hasCloudPublicConfig()).toBe(false); | ||
| 32 | }); | ||
| 33 | }); | ||
apps/web/src/cloud/publicConfig.ts created+37| ... | @@ -0,0 +1,37 @@ | ||
| 1 | import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth"; | ||
| 2 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 3 | |||
| 4 | export interface CloudPublicConfig { | ||
| 5 | readonly clerkPublishableKey: string | null; | ||
| 6 | readonly clerkJwtTemplate: string | null; | ||
| 7 | readonly relayUrl: string | null; | ||
| 8 | } | ||
| 9 | |||
| 10 | function trimNonEmpty(value: string | undefined): string | null { | ||
| 11 | return value?.trim() || null; | ||
| 12 | } | ||
| 13 | |||
| 14 | export function resolveCloudPublicConfig(): CloudPublicConfig { | ||
| 15 | return { | ||
| 16 | clerkPublishableKey: trimNonEmpty( | ||
| 17 | import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined, | ||
| 18 | ), | ||
| 19 | clerkJwtTemplate: trimNonEmpty(import.meta.env.VITE_CLERK_JWT_TEMPLATE as string | undefined), | ||
| 20 | relayUrl: normalizeSecureRelayUrl( | ||
| 21 | (import.meta.env.VITE_T3CODE_RELAY_URL as string | undefined) ?? "", | ||
| 22 | ), | ||
| 23 | }; | ||
| 24 | } | ||
| 25 | |||
| 26 | export function hasCloudPublicConfig(): boolean { | ||
| 27 | const config = resolveCloudPublicConfig(); | ||
| 28 | return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl); | ||
| 29 | } | ||
| 30 | |||
| 31 | export function resolveRelayClerkTokenOptions() { | ||
| 32 | const { clerkJwtTemplate } = resolveCloudPublicConfig(); | ||
| 33 | if (!clerkJwtTemplate) { | ||
| 34 | throw new Error("T3CODE_CLERK_JWT_TEMPLATE is not configured."); | ||
| 35 | } | ||
| 36 | return relayClerkTokenOptions(clerkJwtTemplate); | ||
| 37 | } | ||
apps/web/src/cloud/relayClientInstallDialog.test.ts created+70| ... | @@ -0,0 +1,70 @@ | ||
| 1 | import { beforeEach, describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | completeRelayClientInstallDialogClose, | ||
| 5 | finishRelayClientInstall, | ||
| 6 | readRelayClientInstallDialogState, | ||
| 7 | reportRelayClientInstallProgress, | ||
| 8 | requestRelayClientInstallConfirmation, | ||
| 9 | resetRelayClientInstallDialogForTests, | ||
| 10 | respondToRelayClientInstallConfirmation, | ||
| 11 | } from "./relayClientInstallDialog"; | ||
| 12 | |||
| 13 | describe("relay client install dialog coordinator", () => { | ||
| 14 | beforeEach(() => { | ||
| 15 | resetRelayClientInstallDialogForTests(); | ||
| 16 | }); | ||
| 17 | |||
| 18 | it("moves a confirmed installation through streamed progress stages", async () => { | ||
| 19 | const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); | ||
| 20 | expect(readRelayClientInstallDialogState()).toEqual({ | ||
| 21 | status: "confirming", | ||
| 22 | version: "2026.5.2", | ||
| 23 | }); | ||
| 24 | |||
| 25 | respondToRelayClientInstallConfirmation(true); | ||
| 26 | await expect(confirmation).resolves.toBe(true); | ||
| 27 | expect(readRelayClientInstallDialogState()).toEqual({ | ||
| 28 | status: "installing", | ||
| 29 | version: "2026.5.2", | ||
| 30 | stage: "checking", | ||
| 31 | }); | ||
| 32 | |||
| 33 | reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); | ||
| 34 | expect(readRelayClientInstallDialogState()).toEqual({ | ||
| 35 | status: "installing", | ||
| 36 | version: "2026.5.2", | ||
| 37 | stage: "downloading", | ||
| 38 | }); | ||
| 39 | |||
| 40 | finishRelayClientInstall(); | ||
| 41 | expect(readRelayClientInstallDialogState()).toEqual({ | ||
| 42 | status: "closing", | ||
| 43 | view: { | ||
| 44 | status: "installing", | ||
| 45 | version: "2026.5.2", | ||
| 46 | stage: "downloading", | ||
| 47 | }, | ||
| 48 | }); | ||
| 49 | |||
| 50 | completeRelayClientInstallDialogClose(); | ||
| 51 | expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("returns to idle when installation is declined", async () => { | ||
| 55 | const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); | ||
| 56 | respondToRelayClientInstallConfirmation(false); | ||
| 57 | |||
| 58 | await expect(confirmation).resolves.toBe(false); | ||
| 59 | expect(readRelayClientInstallDialogState()).toEqual({ | ||
| 60 | status: "closing", | ||
| 61 | view: { | ||
| 62 | status: "confirming", | ||
| 63 | version: "2026.5.2", | ||
| 64 | }, | ||
| 65 | }); | ||
| 66 | |||
| 67 | completeRelayClientInstallDialogClose(); | ||
| 68 | expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); | ||
| 69 | }); | ||
| 70 | }); | ||
apps/web/src/cloud/relayClientInstallDialog.ts created+100| ... | @@ -0,0 +1,100 @@ | ||
| 1 | import type { | ||
| 2 | RelayClientInstallProgressEvent, | ||
| 3 | RelayClientInstallProgressStage, | ||
| 4 | } from "@t3tools/contracts"; | ||
| 5 | |||
| 6 | export type RelayClientInstallDialogState = | ||
| 7 | | { readonly status: "idle" } | ||
| 8 | | { readonly status: "confirming"; readonly version: string } | ||
| 9 | | { | ||
| 10 | readonly status: "installing"; | ||
| 11 | readonly version: string; | ||
| 12 | readonly stage: RelayClientInstallProgressStage; | ||
| 13 | } | ||
| 14 | | { | ||
| 15 | readonly status: "closing"; | ||
| 16 | readonly view: | ||
| 17 | | { readonly status: "confirming"; readonly version: string } | ||
| 18 | | { | ||
| 19 | readonly status: "installing"; | ||
| 20 | readonly version: string; | ||
| 21 | readonly stage: RelayClientInstallProgressStage; | ||
| 22 | }; | ||
| 23 | }; | ||
| 24 | |||
| 25 | const idleState: RelayClientInstallDialogState = { status: "idle" }; | ||
| 26 | let state: RelayClientInstallDialogState = idleState; | ||
| 27 | let resolveConfirmation: ((confirmed: boolean) => void) | null = null; | ||
| 28 | const listeners = new Set<() => void>(); | ||
| 29 | |||
| 30 | function publish(next: RelayClientInstallDialogState) { | ||
| 31 | state = next; | ||
| 32 | for (const listener of listeners) { | ||
| 33 | listener(); | ||
| 34 | } | ||
| 35 | } | ||
| 36 | |||
| 37 | export function readRelayClientInstallDialogState(): RelayClientInstallDialogState { | ||
| 38 | return state; | ||
| 39 | } | ||
| 40 | |||
| 41 | export function subscribeRelayClientInstallDialog(listener: () => void): () => void { | ||
| 42 | listeners.add(listener); | ||
| 43 | return () => { | ||
| 44 | listeners.delete(listener); | ||
| 45 | }; | ||
| 46 | } | ||
| 47 | |||
| 48 | export function requestRelayClientInstallConfirmation(version: string): Promise<boolean> { | ||
| 49 | if (state.status !== "idle") { | ||
| 50 | return Promise.reject(new Error("A relay client installation is already in progress.")); | ||
| 51 | } | ||
| 52 | |||
| 53 | publish({ status: "confirming", version }); | ||
| 54 | return new Promise<boolean>((resolve) => { | ||
| 55 | resolveConfirmation = resolve; | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | |||
| 59 | export function respondToRelayClientInstallConfirmation(confirmed: boolean): void { | ||
| 60 | if (state.status !== "confirming" || !resolveConfirmation) { | ||
| 61 | return; | ||
| 62 | } | ||
| 63 | |||
| 64 | const resolve = resolveConfirmation; | ||
| 65 | resolveConfirmation = null; | ||
| 66 | publish( | ||
| 67 | confirmed | ||
| 68 | ? { status: "installing", version: state.version, stage: "checking" } | ||
| 69 | : { status: "closing", view: state }, | ||
| 70 | ); | ||
| 71 | resolve(confirmed); | ||
| 72 | } | ||
| 73 | |||
| 74 | export function reportRelayClientInstallProgress(event: RelayClientInstallProgressEvent): void { | ||
| 75 | if (state.status !== "installing" || event.type !== "progress") { | ||
| 76 | return; | ||
| 77 | } | ||
| 78 | publish({ ...state, stage: event.stage }); | ||
| 79 | } | ||
| 80 | |||
| 81 | export function finishRelayClientInstall(): void { | ||
| 82 | resolveConfirmation?.(false); | ||
| 83 | resolveConfirmation = null; | ||
| 84 | if (state.status === "confirming" || state.status === "installing") { | ||
| 85 | publish({ status: "closing", view: state }); | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | export function completeRelayClientInstallDialogClose(): void { | ||
| 90 | if (state.status === "closing") { | ||
| 91 | publish(idleState); | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | export function resetRelayClientInstallDialogForTests(): void { | ||
| 96 | resolveConfirmation?.(false); | ||
| 97 | resolveConfirmation = null; | ||
| 98 | publish(idleState); | ||
| 99 | listeners.clear(); | ||
| 100 | } | ||
apps/web/src/components/clerk/DesktopClerkCard.tsx created+137| ... | @@ -0,0 +1,137 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | |||
| 3 | import { cn } from "../../lib/utils"; | ||
| 4 | |||
| 5 | // Mirrors Clerk's raised card/footer/branding composition for the desktop-native flow: | ||
| 6 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardRoot.tsx | ||
| 7 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardFooter.tsx | ||
| 8 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/Card/CardClerkAndPagesTag.tsx | ||
| 9 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/DevModeNotice.tsx | ||
| 10 | export function DesktopClerkCard({ | ||
| 11 | children, | ||
| 12 | footerAction, | ||
| 13 | }: { | ||
| 14 | children: ReactNode; | ||
| 15 | footerAction?: ReactNode; | ||
| 16 | }) { | ||
| 17 | return ( | ||
| 18 | <div className="isolate w-full max-w-[25rem] overflow-hidden rounded-xl border border-border/80 bg-card text-card-foreground shadow-[0_12px_28px_rgba(0,0,0,0.12)]"> | ||
| 19 | <div className="relative -m-px flex flex-col gap-8 rounded-lg border border-border/80 bg-card px-10 py-8 text-center"> | ||
| 20 | {children} | ||
| 21 | </div> | ||
| 22 | <div className="-mt-2 flex flex-col bg-muted/45 pt-2"> | ||
| 23 | {footerAction ? ( | ||
| 24 | <div className="border-t border-border/70 px-8 py-4">{footerAction}</div> | ||
| 25 | ) : null} | ||
| 26 | <DesktopClerkBranding /> | ||
| 27 | </div> | ||
| 28 | </div> | ||
| 29 | ); | ||
| 30 | } | ||
| 31 | |||
| 32 | export function DesktopClerkHeader({ title, subtitle }: { title: string; subtitle: string }) { | ||
| 33 | return ( | ||
| 34 | <div className="space-y-1"> | ||
| 35 | <h2 className="text-xl font-bold tracking-[-0.025em] text-foreground">{title}</h2> | ||
| 36 | <p className="text-sm leading-5 text-muted-foreground">{subtitle}</p> | ||
| 37 | </div> | ||
| 38 | ); | ||
| 39 | } | ||
| 40 | |||
| 41 | export function DesktopClerkFooterAction({ | ||
| 42 | children, | ||
| 43 | actionLabel, | ||
| 44 | onAction, | ||
| 45 | }: { | ||
| 46 | children: ReactNode; | ||
| 47 | actionLabel: string; | ||
| 48 | onAction: () => void; | ||
| 49 | }) { | ||
| 50 | return ( | ||
| 51 | <p className="flex items-center justify-center gap-1 text-sm text-muted-foreground"> | ||
| 52 | <span>{children}</span> | ||
| 53 | <button | ||
| 54 | type="button" | ||
| 55 | className="cursor-pointer font-semibold text-foreground outline-none hover:underline focus-visible:underline" | ||
| 56 | onClick={onAction} | ||
| 57 | > | ||
| 58 | {actionLabel} | ||
| 59 | </button> | ||
| 60 | </p> | ||
| 61 | ); | ||
| 62 | } | ||
| 63 | |||
| 64 | export function DesktopClerkAlert({ children }: { children?: ReactNode }) { | ||
| 65 | if (!children) return null; | ||
| 66 | |||
| 67 | return ( | ||
| 68 | <div | ||
| 69 | role="alert" | ||
| 70 | className="rounded-md border border-destructive/20 bg-destructive/8 px-3 py-2 text-left text-xs leading-5 text-destructive-foreground" | ||
| 71 | > | ||
| 72 | {children} | ||
| 73 | </div> | ||
| 74 | ); | ||
| 75 | } | ||
| 76 | |||
| 77 | export function DesktopClerkInput({ | ||
| 78 | className, | ||
| 79 | ...props | ||
| 80 | }: React.ComponentPropsWithoutRef<"input">) { | ||
| 81 | return ( | ||
| 82 | <input | ||
| 83 | {...props} | ||
| 84 | className={cn( | ||
| 85 | "h-10 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground shadow-xs outline-none placeholder:text-muted-foreground/80 focus:border-ring focus:ring-2 focus:ring-ring/15 disabled:opacity-64", | ||
| 86 | className, | ||
| 87 | )} | ||
| 88 | /> | ||
| 89 | ); | ||
| 90 | } | ||
| 91 | |||
| 92 | export function DesktopClerkPrimaryButton({ | ||
| 93 | children, | ||
| 94 | disabled, | ||
| 95 | }: { | ||
| 96 | children: ReactNode; | ||
| 97 | disabled?: boolean; | ||
| 98 | }) { | ||
| 99 | return ( | ||
| 100 | <button | ||
| 101 | type="submit" | ||
| 102 | disabled={disabled} | ||
| 103 | className="h-10 w-full cursor-pointer rounded-md border border-neutral-950 bg-neutral-900 px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-64 dark:border-neutral-200 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-white" | ||
| 104 | > | ||
| 105 | {children} | ||
| 106 | </button> | ||
| 107 | ); | ||
| 108 | } | ||
| 109 | |||
| 110 | function DesktopClerkBranding() { | ||
| 111 | const isDevelopmentMode = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY?.startsWith("pk_test_"); | ||
| 112 | |||
| 113 | return ( | ||
| 114 | <div | ||
| 115 | className={cn( | ||
| 116 | "flex flex-col items-center justify-center gap-2 border-t border-border/70 px-8 py-4 text-xs text-muted-foreground", | ||
| 117 | isDevelopmentMode && | ||
| 118 | "bg-[repeating-linear-gradient(-45deg,rgba(249,115,22,0.035),rgba(249,115,22,0.035)_8px,transparent_8px,transparent_16px)]", | ||
| 119 | )} | ||
| 120 | > | ||
| 121 | <span> | ||
| 122 | Secured by{" "} | ||
| 123 | <a | ||
| 124 | className="font-bold text-muted-foreground hover:text-foreground" | ||
| 125 | href="https://go.clerk.com/components" | ||
| 126 | rel="noreferrer" | ||
| 127 | target="_blank" | ||
| 128 | > | ||
| 129 | clerk | ||
| 130 | </a> | ||
| 131 | </span> | ||
| 132 | {isDevelopmentMode ? ( | ||
| 133 | <strong className="font-semibold text-orange-500">Development mode</strong> | ||
| 134 | ) : null} | ||
| 135 | </div> | ||
| 136 | ); | ||
| 137 | } | ||
apps/web/src/components/clerk/DesktopClerkSignIn.browser.tsx created+71| ... | @@ -0,0 +1,71 @@ | ||
| 1 | import "../../index.css"; | ||
| 2 | |||
| 3 | import { page, userEvent } from "vitest/browser"; | ||
| 4 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 5 | import { render } from "vitest-browser-react"; | ||
| 6 | |||
| 7 | import type { DesktopCloudAuthOAuthOption } from "../../cloud/desktopAuth"; | ||
| 8 | import { DesktopClerkSignInCard } from "./DesktopClerkSignIn"; | ||
| 9 | |||
| 10 | const GOOGLE: DesktopCloudAuthOAuthOption = { | ||
| 11 | strategy: "oauth_google", | ||
| 12 | label: "Google", | ||
| 13 | providerId: "google", | ||
| 14 | iconUrl: null, | ||
| 15 | }; | ||
| 16 | |||
| 17 | const PROVIDERS: readonly DesktopCloudAuthOAuthOption[] = [ | ||
| 18 | { | ||
| 19 | strategy: "oauth_apple", | ||
| 20 | label: "Apple", | ||
| 21 | providerId: "apple", | ||
| 22 | iconUrl: null, | ||
| 23 | }, | ||
| 24 | GOOGLE, | ||
| 25 | { | ||
| 26 | strategy: "oauth_microsoft", | ||
| 27 | label: "Microsoft", | ||
| 28 | providerId: "microsoft", | ||
| 29 | iconUrl: null, | ||
| 30 | }, | ||
| 31 | ]; | ||
| 32 | |||
| 33 | describe("DesktopClerkSignInCard", () => { | ||
| 34 | afterEach(() => { | ||
| 35 | document.body.innerHTML = ""; | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("uses Clerk's compact provider grid when more than two providers are enabled", async () => { | ||
| 39 | await render( | ||
| 40 | <DesktopClerkSignInCard | ||
| 41 | isStarting={false} | ||
| 42 | oauthOptions={PROVIDERS} | ||
| 43 | startingStrategy={null} | ||
| 44 | onJoinWaitlist={vi.fn()} | ||
| 45 | onStartOAuth={vi.fn()} | ||
| 46 | />, | ||
| 47 | ); | ||
| 48 | |||
| 49 | expect(document.querySelectorAll('button[aria-label^="Continue with "]')).toHaveLength(3); | ||
| 50 | expect(document.body.textContent).toContain("Want early access?"); | ||
| 51 | expect(document.body.textContent).not.toContain("Continue with Google"); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("renders a full provider label and starts OAuth for a single provider", async () => { | ||
| 55 | const onStartOAuth = vi.fn(); | ||
| 56 | await render( | ||
| 57 | <DesktopClerkSignInCard | ||
| 58 | isStarting={false} | ||
| 59 | oauthOptions={[GOOGLE]} | ||
| 60 | startingStrategy={null} | ||
| 61 | onJoinWaitlist={vi.fn()} | ||
| 62 | onStartOAuth={onStartOAuth} | ||
| 63 | />, | ||
| 64 | ); | ||
| 65 | |||
| 66 | await userEvent.click(page.getByRole("button", { name: "Continue with Google" })); | ||
| 67 | |||
| 68 | expect(document.body.textContent).toContain("Continue with Google"); | ||
| 69 | expect(onStartOAuth).toHaveBeenCalledWith("oauth_google"); | ||
| 70 | }); | ||
| 71 | }); | ||
apps/web/src/components/clerk/DesktopClerkSignIn.tsx created+150| ... | @@ -0,0 +1,150 @@ | ||
| 1 | import { LoaderCircleIcon } from "lucide-react"; | ||
| 2 | |||
| 3 | import type { | ||
| 4 | DesktopCloudAuthOAuthOption, | ||
| 5 | DesktopCloudAuthOAuthStrategy, | ||
| 6 | } from "../../cloud/desktopAuth"; | ||
| 7 | import { cn } from "../../lib/utils"; | ||
| 8 | import { | ||
| 9 | DesktopClerkAlert, | ||
| 10 | DesktopClerkCard, | ||
| 11 | DesktopClerkFooterAction, | ||
| 12 | DesktopClerkHeader, | ||
| 13 | } from "./DesktopClerkCard"; | ||
| 14 | import { useDesktopClerkSignIn } from "./useDesktopClerkSignIn"; | ||
| 15 | |||
| 16 | // Mirrors Clerk's compact social-button layout while delegating OAuth to the desktop bridge: | ||
| 17 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/elements/SocialButtons.tsx | ||
| 18 | export function DesktopClerkSignIn({ onJoinWaitlist }: { onJoinWaitlist: () => void }) { | ||
| 19 | const { isStarting, oauthOptions, startingStrategy, startOAuth } = useDesktopClerkSignIn(); | ||
| 20 | |||
| 21 | return ( | ||
| 22 | <DesktopClerkSignInCard | ||
| 23 | isStarting={isStarting} | ||
| 24 | oauthOptions={oauthOptions} | ||
| 25 | startingStrategy={startingStrategy} | ||
| 26 | onJoinWaitlist={onJoinWaitlist} | ||
| 27 | onStartOAuth={(strategy) => void startOAuth(strategy)} | ||
| 28 | /> | ||
| 29 | ); | ||
| 30 | } | ||
| 31 | |||
| 32 | export function DesktopClerkSignInCard({ | ||
| 33 | isStarting, | ||
| 34 | oauthOptions, | ||
| 35 | startingStrategy, | ||
| 36 | onJoinWaitlist, | ||
| 37 | onStartOAuth, | ||
| 38 | }: { | ||
| 39 | isStarting: boolean; | ||
| 40 | oauthOptions: readonly DesktopCloudAuthOAuthOption[]; | ||
| 41 | startingStrategy: DesktopCloudAuthOAuthStrategy | null; | ||
| 42 | onJoinWaitlist: () => void; | ||
| 43 | onStartOAuth: (strategy: DesktopCloudAuthOAuthStrategy) => void; | ||
| 44 | }) { | ||
| 45 | return ( | ||
| 46 | <DesktopClerkCard | ||
| 47 | footerAction={ | ||
| 48 | <DesktopClerkFooterAction actionLabel="Join waitlist" onAction={onJoinWaitlist}> | ||
| 49 | Want early access? | ||
| 50 | </DesktopClerkFooterAction> | ||
| 51 | } | ||
| 52 | > | ||
| 53 | <DesktopClerkHeader | ||
| 54 | title="Sign in to T3 Code" | ||
| 55 | subtitle="Welcome back! Please sign in to continue" | ||
| 56 | /> | ||
| 57 | {oauthOptions.length === 0 ? ( | ||
| 58 | <DesktopClerkAlert>No OAuth providers are enabled for desktop sign-in.</DesktopClerkAlert> | ||
| 59 | ) : ( | ||
| 60 | <DesktopClerkSocialButtons | ||
| 61 | isStarting={isStarting} | ||
| 62 | oauthOptions={oauthOptions} | ||
| 63 | startingStrategy={startingStrategy} | ||
| 64 | onStartOAuth={onStartOAuth} | ||
| 65 | /> | ||
| 66 | )} | ||
| 67 | </DesktopClerkCard> | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | function DesktopClerkSocialButtons({ | ||
| 72 | isStarting, | ||
| 73 | oauthOptions, | ||
| 74 | startingStrategy, | ||
| 75 | onStartOAuth, | ||
| 76 | }: { | ||
| 77 | isStarting: boolean; | ||
| 78 | oauthOptions: readonly DesktopCloudAuthOAuthOption[]; | ||
| 79 | startingStrategy: DesktopCloudAuthOAuthStrategy | null; | ||
| 80 | onStartOAuth: (strategy: DesktopCloudAuthOAuthStrategy) => void; | ||
| 81 | }) { | ||
| 82 | const useBlockButtons = oauthOptions.length <= 2; | ||
| 83 | |||
| 84 | return ( | ||
| 85 | <div className={cn("grid gap-2", useBlockButtons ? "grid-cols-1" : "grid-cols-3")}> | ||
| 86 | {oauthOptions.map((option) => { | ||
| 87 | const isCurrent = option.strategy === startingStrategy; | ||
| 88 | return ( | ||
| 89 | <button | ||
| 90 | key={option.strategy} | ||
| 91 | type="button" | ||
| 92 | aria-label={`Continue with ${option.label}`} | ||
| 93 | className={cn( | ||
| 94 | "flex h-10 cursor-pointer items-center justify-center rounded-md border border-input bg-popover px-3 text-sm font-semibold text-foreground shadow-xs outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring/20 disabled:pointer-events-none disabled:opacity-64", | ||
| 95 | useBlockButtons && "w-full", | ||
| 96 | )} | ||
| 97 | disabled={isStarting} | ||
| 98 | onClick={() => onStartOAuth(option.strategy)} | ||
| 99 | > | ||
| 100 | <span | ||
| 101 | className={cn( | ||
| 102 | "flex min-w-0 items-center justify-center gap-3", | ||
| 103 | useBlockButtons && "w-full", | ||
| 104 | )} | ||
| 105 | > | ||
| 106 | {isCurrent ? ( | ||
| 107 | <LoaderCircleIcon className="size-4 animate-spin opacity-70" /> | ||
| 108 | ) : ( | ||
| 109 | <DesktopClerkProviderIcon option={option} /> | ||
| 110 | )} | ||
| 111 | {useBlockButtons ? ( | ||
| 112 | <span className="truncate"> | ||
| 113 | {oauthOptions.length === 1 ? `Continue with ${option.label}` : option.label} | ||
| 114 | </span> | ||
| 115 | ) : null} | ||
| 116 | </span> | ||
| 117 | </button> | ||
| 118 | ); | ||
| 119 | })} | ||
| 120 | </div> | ||
| 121 | ); | ||
| 122 | } | ||
| 123 | |||
| 124 | function DesktopClerkProviderIcon({ option }: { option: DesktopCloudAuthOAuthOption }) { | ||
| 125 | if (!option.iconUrl) { | ||
| 126 | return ( | ||
| 127 | <span | ||
| 128 | aria-hidden | ||
| 129 | className="flex size-4 shrink-0 items-center justify-center rounded bg-muted text-[9px] font-bold text-muted-foreground" | ||
| 130 | > | ||
| 131 | {option.label.slice(0, 1).toUpperCase()} | ||
| 132 | </span> | ||
| 133 | ); | ||
| 134 | } | ||
| 135 | |||
| 136 | if (["apple", "github", "vercel"].includes(option.providerId)) { | ||
| 137 | return ( | ||
| 138 | <span | ||
| 139 | aria-hidden | ||
| 140 | className="size-4 shrink-0 bg-foreground" | ||
| 141 | style={{ | ||
| 142 | WebkitMask: `url(${option.iconUrl}) center / cover no-repeat`, | ||
| 143 | mask: `url(${option.iconUrl}) center / cover no-repeat`, | ||
| 144 | }} | ||
| 145 | /> | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | |||
| 149 | return <img alt="" aria-hidden className="size-4 shrink-0" src={option.iconUrl} />; | ||
| 150 | } | ||
apps/web/src/components/clerk/DesktopClerkWaitlist.tsx created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | import { useClerk } from "@clerk/react"; | ||
| 2 | import { useState } from "react"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | DesktopClerkAlert, | ||
| 6 | DesktopClerkCard, | ||
| 7 | DesktopClerkFooterAction, | ||
| 8 | DesktopClerkHeader, | ||
| 9 | DesktopClerkInput, | ||
| 10 | DesktopClerkPrimaryButton, | ||
| 11 | } from "./DesktopClerkCard"; | ||
| 12 | import { DesktopClerkSignIn } from "./DesktopClerkSignIn"; | ||
| 13 | |||
| 14 | type DesktopClerkScreen = "waitlist" | "sign-in"; | ||
| 15 | |||
| 16 | // Mirrors Clerk's waitlist card and form, replacing its router transition with the desktop sign-in flow: | ||
| 17 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/Waitlist/index.tsx | ||
| 18 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/ui/src/components/Waitlist/WaitlistForm.tsx | ||
| 19 | export function DesktopClerkWaitlist() { | ||
| 20 | const [screen, setScreen] = useState<DesktopClerkScreen>("waitlist"); | ||
| 21 | |||
| 22 | if (screen === "sign-in") { | ||
| 23 | return <DesktopClerkSignIn onJoinWaitlist={() => setScreen("waitlist")} />; | ||
| 24 | } | ||
| 25 | |||
| 26 | return <DesktopClerkWaitlistForm onSignIn={() => setScreen("sign-in")} />; | ||
| 27 | } | ||
| 28 | |||
| 29 | function DesktopClerkWaitlistForm({ onSignIn }: { onSignIn: () => void }) { | ||
| 30 | const clerk = useClerk(); | ||
| 31 | const [emailAddress, setEmailAddress] = useState(""); | ||
| 32 | const [error, setError] = useState<string | null>(null); | ||
| 33 | const [isSubmitting, setIsSubmitting] = useState(false); | ||
| 34 | const [didJoin, setDidJoin] = useState(false); | ||
| 35 | |||
| 36 | const submitWaitlist = async (event: React.FormEvent<HTMLFormElement>) => { | ||
| 37 | event.preventDefault(); | ||
| 38 | setError(null); | ||
| 39 | setIsSubmitting(true); | ||
| 40 | try { | ||
| 41 | await clerk.joinWaitlist({ emailAddress }); | ||
| 42 | setDidJoin(true); | ||
| 43 | } catch (cause) { | ||
| 44 | setError(getClerkErrorMessage(cause)); | ||
| 45 | } finally { | ||
| 46 | setIsSubmitting(false); | ||
| 47 | } | ||
| 48 | }; | ||
| 49 | |||
| 50 | if (didJoin) { | ||
| 51 | return ( | ||
| 52 | <DesktopClerkCard> | ||
| 53 | <DesktopClerkHeader | ||
| 54 | title="Thanks for joining the waitlist!" | ||
| 55 | subtitle="We’ll be in touch when your spot is ready" | ||
| 56 | /> | ||
| 57 | </DesktopClerkCard> | ||
| 58 | ); | ||
| 59 | } | ||
| 60 | |||
| 61 | return ( | ||
| 62 | <DesktopClerkCard | ||
| 63 | footerAction={ | ||
| 64 | <DesktopClerkFooterAction actionLabel="Sign in" onAction={onSignIn}> | ||
| 65 | Already have access? | ||
| 66 | </DesktopClerkFooterAction> | ||
| 67 | } | ||
| 68 | > | ||
| 69 | <DesktopClerkHeader | ||
| 70 | title="Join the waitlist" | ||
| 71 | subtitle="Enter your email address and we’ll let you know when your spot is ready" | ||
| 72 | /> | ||
| 73 | <DesktopClerkAlert>{error}</DesktopClerkAlert> | ||
| 74 | <form className="space-y-8 text-left" onSubmit={submitWaitlist}> | ||
| 75 | <label className="block space-y-2" htmlFor="desktop-clerk-waitlist-email"> | ||
| 76 | <span className="text-sm font-semibold text-foreground">Email address</span> | ||
| 77 | <DesktopClerkInput | ||
| 78 | required | ||
| 79 | autoComplete="email" | ||
| 80 | id="desktop-clerk-waitlist-email" | ||
| 81 | name="emailAddress" | ||
| 82 | placeholder="Enter your email address" | ||
| 83 | type="email" | ||
| 84 | value={emailAddress} | ||
| 85 | onChange={(event) => setEmailAddress(event.currentTarget.value)} | ||
| 86 | /> | ||
| 87 | </label> | ||
| 88 | <DesktopClerkPrimaryButton disabled={isSubmitting}> | ||
| 89 | {isSubmitting ? "Joining the waitlist…" : "Join the waitlist"} | ||
| 90 | </DesktopClerkPrimaryButton> | ||
| 91 | </form> | ||
| 92 | </DesktopClerkCard> | ||
| 93 | ); | ||
| 94 | } | ||
| 95 | |||
| 96 | function getClerkErrorMessage(error: unknown): string { | ||
| 97 | if (typeof error === "object" && error !== null && "errors" in error) { | ||
| 98 | const errors = (error as { errors?: Array<{ longMessage?: unknown; message?: unknown }> }) | ||
| 99 | .errors; | ||
| 100 | const firstError = errors?.[0]; | ||
| 101 | if (typeof firstError?.longMessage === "string") return firstError.longMessage; | ||
| 102 | if (typeof firstError?.message === "string") return firstError.message; | ||
| 103 | } | ||
| 104 | if (error instanceof Error && error.message) return error.message; | ||
| 105 | return "Could not join the waitlist. Please try again."; | ||
| 106 | } | ||
apps/web/src/components/clerk/useDesktopClerkSignIn.ts created+199| ... | @@ -0,0 +1,199 @@ | ||
| 1 | import { useClerk } from "@clerk/react"; | ||
| 2 | import { useSignIn, useSignUp } from "@clerk/react/legacy"; | ||
| 3 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | type DesktopCloudAuthOAuthStrategy, | ||
| 7 | resolveDesktopCloudAuthOAuthOptions, | ||
| 8 | } from "../../cloud/desktopAuth"; | ||
| 9 | import { toastManager } from "../ui/toast"; | ||
| 10 | |||
| 11 | // Mirrors Clerk Expo's browser-based native SSO flow, with Electron handling the external browser | ||
| 12 | // and callback transport: | ||
| 13 | // https://github.com/clerk/javascript/blob/52861184477bee99c71552000311a289e91d3b59/packages/expo/src/hooks/useSSO.ts | ||
| 14 | class DesktopClerkOperationError extends Error { | ||
| 15 | override readonly cause?: unknown; | ||
| 16 | |||
| 17 | constructor(message: string, cause?: unknown) { | ||
| 18 | super(message); | ||
| 19 | this.name = "DesktopClerkOperationError"; | ||
| 20 | this.cause = cause; | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | async function runDesktopClerkOperation<T>( | ||
| 25 | operation: () => Promise<T>, | ||
| 26 | message: string, | ||
| 27 | ): Promise<T> { | ||
| 28 | try { | ||
| 29 | return await operation(); | ||
| 30 | } catch (cause) { | ||
| 31 | throw new DesktopClerkOperationError(message, cause); | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | function desktopClerkErrorMessage(error: unknown, fallback: string): string { | ||
| 36 | if (error instanceof DesktopClerkOperationError) { | ||
| 37 | const cause = error.cause; | ||
| 38 | if (cause instanceof Error && cause.message && cause.message !== error.message) { | ||
| 39 | return `${error.message}: ${cause.message}`; | ||
| 40 | } | ||
| 41 | return error.message; | ||
| 42 | } | ||
| 43 | return error instanceof Error ? error.message : fallback; | ||
| 44 | } | ||
| 45 | |||
| 46 | export function useDesktopClerkSignIn() { | ||
| 47 | const clerk = useClerk(); | ||
| 48 | const { setActive } = clerk; | ||
| 49 | const { isLoaded: signInLoaded, signIn } = useSignIn(); | ||
| 50 | const { isLoaded: signUpLoaded, signUp } = useSignUp(); | ||
| 51 | const [startingStrategy, setStartingStrategy] = useState<DesktopCloudAuthOAuthStrategy | null>( | ||
| 52 | null, | ||
| 53 | ); | ||
| 54 | const oauthOptions = resolveDesktopCloudAuthOAuthOptions(clerk); | ||
| 55 | const callbackCleanupRef = useRef<(() => void) | null>(null); | ||
| 56 | |||
| 57 | const clearCallbackListener = useCallback(() => { | ||
| 58 | callbackCleanupRef.current?.(); | ||
| 59 | callbackCleanupRef.current = null; | ||
| 60 | }, []); | ||
| 61 | |||
| 62 | const completeOAuthCallback = useCallback( | ||
| 63 | async (rawUrl: string) => { | ||
| 64 | if (!signInLoaded || !signIn || !signUpLoaded || !signUp) { | ||
| 65 | toastManager.add({ | ||
| 66 | type: "error", | ||
| 67 | title: "Cloud sign-in failed", | ||
| 68 | description: "Clerk is still loading. Try signing in again.", | ||
| 69 | }); | ||
| 70 | return; | ||
| 71 | } | ||
| 72 | |||
| 73 | let rotatingTokenNonce: string | null = null; | ||
| 74 | let sessionId: string | null = null; | ||
| 75 | try { | ||
| 76 | const callbackUrl = new URL(rawUrl); | ||
| 77 | rotatingTokenNonce = callbackUrl.searchParams.get("rotating_token_nonce"); | ||
| 78 | sessionId = callbackUrl.searchParams.get("created_session_id"); | ||
| 79 | } catch { | ||
| 80 | // Handled by the explicit nonce check below. | ||
| 81 | } | ||
| 82 | if (!rotatingTokenNonce) { | ||
| 83 | toastManager.add({ | ||
| 84 | type: "error", | ||
| 85 | title: "Cloud sign-in failed", | ||
| 86 | description: | ||
| 87 | "Clerk did not return a native session nonce. Verify this redirect URL is allowlisted for native SSO redirects.", | ||
| 88 | }); | ||
| 89 | return; | ||
| 90 | } | ||
| 91 | |||
| 92 | try { | ||
| 93 | await runDesktopClerkOperation( | ||
| 94 | () => signIn.reload({ rotatingTokenNonce }), | ||
| 95 | "Could not reload the desktop sign-in session.", | ||
| 96 | ); | ||
| 97 | sessionId = sessionId || signIn.createdSessionId; | ||
| 98 | |||
| 99 | if (!sessionId && signIn.firstFactorVerification.status === "transferable") { | ||
| 100 | const signUpAttempt = await runDesktopClerkOperation( | ||
| 101 | () => signUp.create({ transfer: true }), | ||
| 102 | "Could not transfer the desktop sign-up session.", | ||
| 103 | ); | ||
| 104 | sessionId = signUpAttempt.createdSessionId; | ||
| 105 | } | ||
| 106 | |||
| 107 | if (!sessionId) { | ||
| 108 | throw new DesktopClerkOperationError("Clerk did not create a desktop session."); | ||
| 109 | } | ||
| 110 | |||
| 111 | await runDesktopClerkOperation( | ||
| 112 | () => setActive({ session: sessionId! }), | ||
| 113 | "Could not activate the desktop cloud session.", | ||
| 114 | ); | ||
| 115 | } catch (error) { | ||
| 116 | toastManager.add({ | ||
| 117 | type: "error", | ||
| 118 | title: "Cloud sign-in failed", | ||
| 119 | description: desktopClerkErrorMessage(error, "Could not complete cloud sign-in."), | ||
| 120 | }); | ||
| 121 | } | ||
| 122 | }, | ||
| 123 | [setActive, signIn, signInLoaded, signUp, signUpLoaded], | ||
| 124 | ); | ||
| 125 | |||
| 126 | useEffect(() => { | ||
| 127 | return () => { | ||
| 128 | clearCallbackListener(); | ||
| 129 | }; | ||
| 130 | }, [clearCallbackListener]); | ||
| 131 | |||
| 132 | const startOAuth = useCallback( | ||
| 133 | async (strategy: DesktopCloudAuthOAuthStrategy) => { | ||
| 134 | if (!signInLoaded || !signIn) { | ||
| 135 | toastManager.add({ | ||
| 136 | type: "error", | ||
| 137 | title: "Cloud sign-in failed", | ||
| 138 | description: "Clerk is still loading. Try signing in again.", | ||
| 139 | }); | ||
| 140 | return; | ||
| 141 | } | ||
| 142 | |||
| 143 | setStartingStrategy(strategy); | ||
| 144 | clearCallbackListener(); | ||
| 145 | try { | ||
| 146 | const redirectUrl = await runDesktopClerkOperation( | ||
| 147 | () => window.desktopBridge?.createCloudAuthRequest() ?? Promise.resolve(undefined), | ||
| 148 | "Desktop auth callback is unavailable.", | ||
| 149 | ); | ||
| 150 | if (!redirectUrl) { | ||
| 151 | throw new DesktopClerkOperationError("Desktop auth callback is unavailable."); | ||
| 152 | } | ||
| 153 | |||
| 154 | callbackCleanupRef.current = | ||
| 155 | window.desktopBridge?.onCloudAuthCallback((rawUrl) => { | ||
| 156 | clearCallbackListener(); | ||
| 157 | void completeOAuthCallback(rawUrl); | ||
| 158 | }) ?? null; | ||
| 159 | |||
| 160 | await runDesktopClerkOperation( | ||
| 161 | () => signIn.create({ strategy, redirectUrl } as never), | ||
| 162 | "Could not create the desktop OAuth request.", | ||
| 163 | ); | ||
| 164 | const externalUrl = | ||
| 165 | signIn.firstFactorVerification.externalVerificationRedirectURL?.toString(); | ||
| 166 | if (!externalUrl) { | ||
| 167 | throw new DesktopClerkOperationError( | ||
| 168 | "Clerk did not return an external OAuth redirect URL.", | ||
| 169 | ); | ||
| 170 | } | ||
| 171 | |||
| 172 | const opened = await runDesktopClerkOperation( | ||
| 173 | () => window.desktopBridge?.openExternal(externalUrl) ?? Promise.resolve(false), | ||
| 174 | "Could not open the system browser.", | ||
| 175 | ); | ||
| 176 | if (!opened) { | ||
| 177 | throw new DesktopClerkOperationError("Could not open the system browser."); | ||
| 178 | } | ||
| 179 | } catch (error) { | ||
| 180 | clearCallbackListener(); | ||
| 181 | toastManager.add({ | ||
| 182 | type: "error", | ||
| 183 | title: "Cloud sign-in failed", | ||
| 184 | description: desktopClerkErrorMessage(error, "Could not start cloud sign-in."), | ||
| 185 | }); | ||
| 186 | } finally { | ||
| 187 | setStartingStrategy(null); | ||
| 188 | } | ||
| 189 | }, | ||
| 190 | [clearCallbackListener, completeOAuthCallback, signIn, signInLoaded], | ||
| 191 | ); | ||
| 192 | |||
| 193 | return { | ||
| 194 | isStarting: startingStrategy !== null, | ||
| 195 | oauthOptions, | ||
| 196 | startingStrategy, | ||
| 197 | startOAuth, | ||
| 198 | }; | ||
| 199 | } | ||
apps/web/src/components/cloud/RelayClientInstallDialog.browser.tsx created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | import "../../index.css"; | ||
| 2 | |||
| 3 | import { page } from "vitest/browser"; | ||
| 4 | import { beforeEach, describe, expect, it } from "vitest"; | ||
| 5 | import { render } from "vitest-browser-react"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | finishRelayClientInstall, | ||
| 9 | readRelayClientInstallDialogState, | ||
| 10 | reportRelayClientInstallProgress, | ||
| 11 | requestRelayClientInstallConfirmation, | ||
| 12 | resetRelayClientInstallDialogForTests, | ||
| 13 | } from "../../cloud/relayClientInstallDialog"; | ||
| 14 | import { RelayClientInstallDialog } from "./RelayClientInstallDialog"; | ||
| 15 | |||
| 16 | describe("RelayClientInstallDialog", () => { | ||
| 17 | beforeEach(() => { | ||
| 18 | resetRelayClientInstallDialogForTests(); | ||
| 19 | }); | ||
| 20 | |||
| 21 | it("confirms installation and renders streamed progress", async () => { | ||
| 22 | render(<RelayClientInstallDialog />); | ||
| 23 | const confirmation = requestRelayClientInstallConfirmation("2026.5.2"); | ||
| 24 | |||
| 25 | await expect.element(page.getByText("Install relay client?")).toBeInTheDocument(); | ||
| 26 | await expect.element(page.getByText(/version 2026\.5\.2 locally/)).toBeInTheDocument(); | ||
| 27 | |||
| 28 | await page.getByRole("button", { name: "Download and install" }).click(); | ||
| 29 | await expect(confirmation).resolves.toBe(true); | ||
| 30 | await expect | ||
| 31 | .element(page.getByRole("heading", { name: "Installing relay client" })) | ||
| 32 | .toBeInTheDocument(); | ||
| 33 | |||
| 34 | reportRelayClientInstallProgress({ type: "progress", stage: "downloading" }); | ||
| 35 | await expect.element(page.getByText("Downloading relay client")).toBeInTheDocument(); | ||
| 36 | await expect | ||
| 37 | .element(page.getByRole("progressbar", { name: "Relay client installation progress" })) | ||
| 38 | .toHaveAttribute("value", "3"); | ||
| 39 | |||
| 40 | finishRelayClientInstall(); | ||
| 41 | expect(readRelayClientInstallDialogState().status).toBe("closing"); | ||
| 42 | await expect | ||
| 43 | .element(page.getByRole("heading", { name: "Installing relay client" })) | ||
| 44 | .not.toBeInTheDocument(); | ||
| 45 | expect(readRelayClientInstallDialogState()).toEqual({ status: "idle" }); | ||
| 46 | }); | ||
| 47 | }); | ||
apps/web/src/components/cloud/RelayClientInstallDialog.tsx created+123| ... | @@ -0,0 +1,123 @@ | ||
| 1 | import { DownloadIcon } from "lucide-react"; | ||
| 2 | import { useSyncExternalStore } from "react"; | ||
| 3 | import type { RelayClientInstallProgressStage } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | completeRelayClientInstallDialogClose, | ||
| 7 | readRelayClientInstallDialogState, | ||
| 8 | respondToRelayClientInstallConfirmation, | ||
| 9 | subscribeRelayClientInstallDialog, | ||
| 10 | } from "../../cloud/relayClientInstallDialog"; | ||
| 11 | import { Button } from "../ui/button"; | ||
| 12 | import { | ||
| 13 | Dialog, | ||
| 14 | DialogDescription, | ||
| 15 | DialogFooter, | ||
| 16 | DialogHeader, | ||
| 17 | DialogPanel, | ||
| 18 | DialogPopup, | ||
| 19 | DialogTitle, | ||
| 20 | } from "../ui/dialog"; | ||
| 21 | const installSteps: ReadonlyArray<{ | ||
| 22 | readonly stage: RelayClientInstallProgressStage; | ||
| 23 | readonly label: string; | ||
| 24 | }> = [ | ||
| 25 | { stage: "checking", label: "Checking current installation" }, | ||
| 26 | { stage: "waiting_for_lock", label: "Waiting for installer" }, | ||
| 27 | { stage: "downloading", label: "Downloading relay client" }, | ||
| 28 | { stage: "verifying", label: "Verifying download" }, | ||
| 29 | { stage: "installing", label: "Installing relay client" }, | ||
| 30 | { stage: "validating", label: "Validating executable" }, | ||
| 31 | { stage: "activating", label: "Activating installation" }, | ||
| 32 | ]; | ||
| 33 | |||
| 34 | export function RelayClientInstallDialog() { | ||
| 35 | const state = useSyncExternalStore( | ||
| 36 | subscribeRelayClientInstallDialog, | ||
| 37 | readRelayClientInstallDialogState, | ||
| 38 | readRelayClientInstallDialogState, | ||
| 39 | ); | ||
| 40 | const view = state.status === "closing" ? state.view : state; | ||
| 41 | const isConfirming = view.status === "confirming"; | ||
| 42 | const isInstalling = view.status === "installing"; | ||
| 43 | const activeStepIndex = isInstalling | ||
| 44 | ? installSteps.findIndex(({ stage }) => stage === view.stage) | ||
| 45 | : -1; | ||
| 46 | const activeStep = installSteps[activeStepIndex]; | ||
| 47 | |||
| 48 | return ( | ||
| 49 | <Dialog | ||
| 50 | open={state.status === "confirming" || state.status === "installing"} | ||
| 51 | onOpenChange={(open) => { | ||
| 52 | if (!open && isConfirming) { | ||
| 53 | respondToRelayClientInstallConfirmation(false); | ||
| 54 | } | ||
| 55 | }} | ||
| 56 | onOpenChangeComplete={(open) => { | ||
| 57 | if (!open) { | ||
| 58 | completeRelayClientInstallDialogClose(); | ||
| 59 | } | ||
| 60 | }} | ||
| 61 | > | ||
| 62 | <DialogPopup className="max-w-md" showCloseButton={isConfirming}> | ||
| 63 | <DialogHeader> | ||
| 64 | <div className="flex size-9 items-center justify-center rounded-lg border border-border/70 bg-muted/60"> | ||
| 65 | <DownloadIcon aria-hidden className="size-4.5 text-muted-foreground" /> | ||
| 66 | </div> | ||
| 67 | <DialogTitle> | ||
| 68 | {isInstalling ? "Installing relay client" : "Install relay client?"} | ||
| 69 | </DialogTitle> | ||
| 70 | <DialogDescription> | ||
| 71 | {isInstalling | ||
| 72 | ? "T3 Code is preparing this environment for secure access through T3 Cloud." | ||
| 73 | : "T3 Code needs the relay client to make this environment available through T3 Cloud."} | ||
| 74 | </DialogDescription> | ||
| 75 | </DialogHeader> | ||
| 76 | <DialogPanel scrollFade={false}> | ||
| 77 | {isInstalling ? ( | ||
| 78 | <div className="space-y-2.5"> | ||
| 79 | <div className="flex items-center justify-between gap-3 text-sm"> | ||
| 80 | <p aria-live="polite" className="font-medium text-foreground"> | ||
| 81 | {activeStep?.label} | ||
| 82 | </p> | ||
| 83 | <p className="shrink-0 tabular-nums text-muted-foreground"> | ||
| 84 | {activeStepIndex + 1} of {installSteps.length} | ||
| 85 | </p> | ||
| 86 | </div> | ||
| 87 | <progress | ||
| 88 | aria-label="Relay client installation progress" | ||
| 89 | className="h-2 w-full appearance-none overflow-hidden rounded-full bg-muted [&::-moz-progress-bar]:rounded-full [&::-moz-progress-bar]:bg-primary [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-muted [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-primary" | ||
| 90 | max={installSteps.length} | ||
| 91 | value={activeStepIndex + 1} | ||
| 92 | /> | ||
| 93 | <p className="text-xs leading-relaxed text-muted-foreground"> | ||
| 94 | Keep T3 Code open while the relay client is installed. | ||
| 95 | </p> | ||
| 96 | </div> | ||
| 97 | ) : ( | ||
| 98 | <div className="rounded-xl border border-border/70 bg-muted/35 p-3"> | ||
| 99 | <p className="text-sm font-medium text-foreground">Managed relay client</p> | ||
| 100 | <p className="mt-1 text-sm leading-relaxed text-muted-foreground"> | ||
| 101 | T3 Code will download and install version{" "} | ||
| 102 | {view.status === "confirming" ? view.version : ""} locally. | ||
| 103 | </p> | ||
| 104 | </div> | ||
| 105 | )} | ||
| 106 | </DialogPanel> | ||
| 107 | {isConfirming ? ( | ||
| 108 | <DialogFooter> | ||
| 109 | <Button | ||
| 110 | variant="outline" | ||
| 111 | onClick={() => respondToRelayClientInstallConfirmation(false)} | ||
| 112 | > | ||
| 113 | Cancel | ||
| 114 | </Button> | ||
| 115 | <Button onClick={() => respondToRelayClientInstallConfirmation(true)}> | ||
| 116 | Download and install | ||
| 117 | </Button> | ||
| 118 | </DialogFooter> | ||
| 119 | ) : null} | ||
| 120 | </DialogPopup> | ||
| 121 | </Dialog> | ||
| 122 | ); | ||
| 123 | } | ||
apps/web/src/components/settings/CloudSettings.tsx created+197| ... | @@ -0,0 +1,197 @@ | ||
| 1 | import { UserButton, Waitlist, useAuth } from "@clerk/react"; | ||
| 2 | import { AuthRelayWriteScope } from "@t3tools/contracts"; | ||
| 3 | import { CloudIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react"; | ||
| 4 | import { useEffect, useState } from "react"; | ||
| 5 | |||
| 6 | import { updatePrimaryCloudPreferences } from "../../cloud/linkEnvironment"; | ||
| 7 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; | ||
| 8 | import { useManagedRelayDevices } from "../../cloud/managedRelayState"; | ||
| 9 | import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; | ||
| 10 | import { isElectron } from "../../env"; | ||
| 11 | import { usePrimarySessionState } from "../../environments/primary"; | ||
| 12 | import { webRuntime } from "../../lib/runtime"; | ||
| 13 | import { cn } from "../../lib/utils"; | ||
| 14 | import { DesktopClerkWaitlist } from "../clerk/DesktopClerkWaitlist"; | ||
| 15 | import { Button } from "../ui/button"; | ||
| 16 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | ||
| 17 | import { Skeleton } from "../ui/skeleton"; | ||
| 18 | import { Switch } from "../ui/switch"; | ||
| 19 | import { toastManager } from "../ui/toast"; | ||
| 20 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 21 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; | ||
| 22 | |||
| 23 | const NOTIFICATION_DEVICE_SKELETON_ROWS = ["primary", "secondary"] as const; | ||
| 24 | |||
| 25 | function NotificationDevicesSkeleton() { | ||
| 26 | return NOTIFICATION_DEVICE_SKELETON_ROWS.map((row) => ( | ||
| 27 | <div key={row} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5"> | ||
| 28 | <div className="space-y-2"> | ||
| 29 | <Skeleton className="h-4 w-32 rounded-full" /> | ||
| 30 | <Skeleton className="h-3 w-44 rounded-full" /> | ||
| 31 | <Skeleton className="h-3 w-56 rounded-full" /> | ||
| 32 | </div> | ||
| 33 | </div> | ||
| 34 | )); | ||
| 35 | } | ||
| 36 | |||
| 37 | function EmptyNotificationDevices() { | ||
| 38 | return ( | ||
| 39 | <Empty className="min-h-52"> | ||
| 40 | <EmptyMedia variant="icon"> | ||
| 41 | <SmartphoneIcon /> | ||
| 42 | </EmptyMedia> | ||
| 43 | <EmptyHeader> | ||
| 44 | <EmptyTitle>No notification devices</EmptyTitle> | ||
| 45 | <EmptyDescription> | ||
| 46 | Sign in on the mobile app to register a device for account-level notifications. | ||
| 47 | </EmptyDescription> | ||
| 48 | </EmptyHeader> | ||
| 49 | </Empty> | ||
| 50 | ); | ||
| 51 | } | ||
| 52 | |||
| 53 | function cloudErrorMessage(error: unknown, fallback: string): string { | ||
| 54 | return error instanceof Error ? error.message : fallback; | ||
| 55 | } | ||
| 56 | |||
| 57 | export function CloudSettingsPanel() { | ||
| 58 | if (!hasCloudPublicConfig()) return null; | ||
| 59 | |||
| 60 | return <ConfiguredCloudSettingsPanel />; | ||
| 61 | } | ||
| 62 | |||
| 63 | function ConfiguredCloudSettingsPanel() { | ||
| 64 | const { isLoaded, isSignedIn } = useAuth(); | ||
| 65 | |||
| 66 | if (!isLoaded) { | ||
| 67 | return null; | ||
| 68 | } | ||
| 69 | |||
| 70 | return isSignedIn ? <CloudSettingsPanelInner /> : <CloudWaitlistPanel />; | ||
| 71 | } | ||
| 72 | |||
| 73 | function CloudWaitlistPanel() { | ||
| 74 | return ( | ||
| 75 | <SettingsPageContainer className="min-h-full items-center justify-center"> | ||
| 76 | {isElectron ? <DesktopClerkWaitlist /> : <Waitlist />} | ||
| 77 | </SettingsPageContainer> | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | |||
| 81 | function CloudSettingsPanelInner() { | ||
| 82 | const primaryLinkState = usePrimaryCloudLinkState(); | ||
| 83 | const primarySessionState = usePrimarySessionState(); | ||
| 84 | const devicesState = useManagedRelayDevices(); | ||
| 85 | const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); | ||
| 86 | const devices = devicesState.data ?? []; | ||
| 87 | const canManageRelay = | ||
| 88 | primarySessionState.data?.authenticated === true && | ||
| 89 | Boolean(primarySessionState.data.scopes?.includes(AuthRelayWriteScope)); | ||
| 90 | |||
| 91 | useEffect(() => { | ||
| 92 | if (devicesState.error) { | ||
| 93 | toastManager.add({ | ||
| 94 | type: "error", | ||
| 95 | title: "Cloud devices unavailable", | ||
| 96 | description: devicesState.error, | ||
| 97 | }); | ||
| 98 | } | ||
| 99 | }, [devicesState.error]); | ||
| 100 | |||
| 101 | const updatePublishAgentActivity = async (enabled: boolean) => { | ||
| 102 | setIsUpdatingPreference(true); | ||
| 103 | try { | ||
| 104 | await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled })); | ||
| 105 | primaryLinkState.refresh(); | ||
| 106 | toastManager.add({ | ||
| 107 | type: "success", | ||
| 108 | title: enabled ? "Agent activity enabled" : "Agent activity disabled", | ||
| 109 | description: enabled | ||
| 110 | ? "This environment can publish agent activity to your notification devices." | ||
| 111 | : "This environment will stop publishing agent activity.", | ||
| 112 | }); | ||
| 113 | } catch (error) { | ||
| 114 | toastManager.add({ | ||
| 115 | type: "error", | ||
| 116 | title: "Cloud preference update failed", | ||
| 117 | description: cloudErrorMessage(error, "Could not update cloud preferences."), | ||
| 118 | }); | ||
| 119 | } finally { | ||
| 120 | setIsUpdatingPreference(false); | ||
| 121 | } | ||
| 122 | }; | ||
| 123 | |||
| 124 | return ( | ||
| 125 | <SettingsPageContainer> | ||
| 126 | <SettingsSection title="T3 Cloud" icon={<CloudIcon className="size-3.5" />}> | ||
| 127 | <SettingsRow | ||
| 128 | title="Cloud account" | ||
| 129 | description="Manage your private-beta T3 Cloud session." | ||
| 130 | control={<UserButton />} | ||
| 131 | /> | ||
| 132 | </SettingsSection> | ||
| 133 | <SettingsSection title="Preferences"> | ||
| 134 | <SettingsRow | ||
| 135 | title="Publish agent activity" | ||
| 136 | description="Allow this environment to send agent activity to your notification devices." | ||
| 137 | status={ | ||
| 138 | !primaryLinkState.data?.linked ? "Link this environment from Connections first." : null | ||
| 139 | } | ||
| 140 | control={ | ||
| 141 | <Switch | ||
| 142 | aria-label="Publish agent activity" | ||
| 143 | checked={primaryLinkState.data?.publishAgentActivity ?? false} | ||
| 144 | disabled={!primaryLinkState.data?.linked || !canManageRelay || isUpdatingPreference} | ||
| 145 | onCheckedChange={(enabled) => void updatePublishAgentActivity(enabled)} | ||
| 146 | /> | ||
| 147 | } | ||
| 148 | /> | ||
| 149 | </SettingsSection> | ||
| 150 | <SettingsSection | ||
| 151 | title="Notification devices" | ||
| 152 | headerAction={ | ||
| 153 | <Tooltip> | ||
| 154 | <TooltipTrigger | ||
| 155 | render={ | ||
| 156 | <Button | ||
| 157 | size="icon-xs" | ||
| 158 | variant="ghost" | ||
| 159 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | ||
| 160 | disabled={devicesState.isPending} | ||
| 161 | onClick={devicesState.refresh} | ||
| 162 | aria-label="Refresh notification devices" | ||
| 163 | > | ||
| 164 | <RefreshCwIcon | ||
| 165 | className={cn("size-3", devicesState.isPending && "animate-spin")} | ||
| 166 | /> | ||
| 167 | </Button> | ||
| 168 | } | ||
| 169 | /> | ||
| 170 | <TooltipPopup side="top">Refresh notification devices</TooltipPopup> | ||
| 171 | </Tooltip> | ||
| 172 | } | ||
| 173 | > | ||
| 174 | {devicesState.data === null ? ( | ||
| 175 | <NotificationDevicesSkeleton /> | ||
| 176 | ) : devices.length > 0 ? ( | ||
| 177 | devices.map((device) => ( | ||
| 178 | <SettingsRow | ||
| 179 | key={device.deviceId} | ||
| 180 | title={device.label} | ||
| 181 | description={`iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`} | ||
| 182 | status={ | ||
| 183 | device.notifications.enabled | ||
| 184 | ? device.liveActivities.enabled | ||
| 185 | ? "Notifications and Live Activities enabled" | ||
| 186 | : "Notifications enabled · Live Activities disabled" | ||
| 187 | : "Notifications disabled on device" | ||
| 188 | } | ||
| 189 | /> | ||
| 190 | )) | ||
| 191 | ) : ( | ||
| 192 | <EmptyNotificationDevices /> | ||
| 193 | )} | ||
| 194 | </SettingsSection> | ||
| 195 | </SettingsPageContainer> | ||
| 196 | ); | ||
| 197 | } | ||
apps/web/src/components/settings/ConnectionsSettings.tsx+266-12| ... | @@ -7,6 +7,7 @@ import { | ... | @@ -7,6 +7,7 @@ import { |
| 7 | TerminalIcon, | 7 | TerminalIcon, |
| 8 | TriangleAlertIcon, | 8 | TriangleAlertIcon, |
| 9 | } from "lucide-react"; | 9 | } from "lucide-react"; |
| 10 | import { useAuth } from "@clerk/react"; | ||
| 10 | import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; | 11 | import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; |
| 11 | import { | 12 | import { |
| 12 | AuthAccessReadScope, | 13 | AuthAccessReadScope, |
| ... | @@ -29,12 +30,14 @@ import { | ... | @@ -29,12 +30,14 @@ import { |
| 29 | type EnvironmentId, | 30 | type EnvironmentId, |
| 30 | } from "@t3tools/contracts"; | 31 | } from "@t3tools/contracts"; |
| 31 | import { WsRpcClient } from "@t3tools/client-runtime"; | 32 | import { WsRpcClient } from "@t3tools/client-runtime"; |
| 33 | import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; | ||
| 32 | import * as DateTime from "effect/DateTime"; | 34 | import * as DateTime from "effect/DateTime"; |
| 33 | 35 | ||
| 34 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | 36 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 35 | import { cn } from "../../lib/utils"; | 37 | import { cn } from "../../lib/utils"; |
| 36 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; | 38 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; |
| 37 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; | 39 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; |
| 40 | import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig"; | ||
| 38 | import { | 41 | import { |
| 39 | SettingsPageContainer, | 42 | SettingsPageContainer, |
| 40 | SettingsRow, | 43 | SettingsRow, |
| ... | @@ -66,11 +69,13 @@ import { | ... | @@ -66,11 +69,13 @@ import { |
| 66 | } from "../ui/alert-dialog"; | 69 | } from "../ui/alert-dialog"; |
| 67 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 70 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 68 | import { QRCodeSvg } from "../ui/qr-code"; | 71 | import { QRCodeSvg } from "../ui/qr-code"; |
| 72 | import { Skeleton } from "../ui/skeleton"; | ||
| 69 | import { Spinner } from "../ui/spinner"; | 73 | import { Spinner } from "../ui/spinner"; |
| 70 | import { Switch } from "../ui/switch"; | 74 | import { Switch } from "../ui/switch"; |
| 71 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 75 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 72 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 76 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 73 | import { Button } from "../ui/button"; | 77 | import { Button } from "../ui/button"; |
| 78 | import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; | ||
| 74 | import { Group, GroupSeparator } from "../ui/group"; | 79 | import { Group, GroupSeparator } from "../ui/group"; |
| 75 | import { AnimatedHeight } from "../AnimatedHeight"; | 80 | import { AnimatedHeight } from "../AnimatedHeight"; |
| 76 | import { | 81 | import { |
| ... | @@ -91,6 +96,7 @@ import { | ... | @@ -91,6 +96,7 @@ import { |
| 91 | revokeServerClientSession, | 96 | revokeServerClientSession, |
| 92 | revokeServerPairingLink, | 97 | revokeServerPairingLink, |
| 93 | isLoopbackHostname, | 98 | isLoopbackHostname, |
| 99 | usePrimaryEnvironmentId, | ||
| 94 | usePrimarySessionState, | 100 | usePrimarySessionState, |
| 95 | type ServerClientSessionRecord, | 101 | type ServerClientSessionRecord, |
| 96 | type ServerPairingLinkRecord, | 102 | type ServerPairingLinkRecord, |
| ... | @@ -101,6 +107,7 @@ import { | ... | @@ -101,6 +107,7 @@ import { |
| 101 | useSavedEnvironmentRegistryStore, | 107 | useSavedEnvironmentRegistryStore, |
| 102 | useSavedEnvironmentRuntimeStore, | 108 | useSavedEnvironmentRuntimeStore, |
| 103 | addSavedEnvironment, | 109 | addSavedEnvironment, |
| 110 | addManagedRelayEnvironment, | ||
| 104 | connectDesktopSshEnvironment, | 111 | connectDesktopSshEnvironment, |
| 105 | disconnectSavedEnvironment, | 112 | disconnectSavedEnvironment, |
| 106 | getPrimaryEnvironmentConnection, | 113 | getPrimaryEnvironmentConnection, |
| ... | @@ -110,6 +117,18 @@ import { | ... | @@ -110,6 +117,18 @@ import { |
| 110 | import { useUiStateStore } from "~/uiStateStore"; | 117 | import { useUiStateStore } from "~/uiStateStore"; |
| 111 | import { resolveServerConfigVersionMismatch } from "~/versionSkew"; | 118 | import { resolveServerConfigVersionMismatch } from "~/versionSkew"; |
| 112 | import { useServerConfig } from "~/rpc/serverState"; | 119 | import { useServerConfig } from "~/rpc/serverState"; |
| 120 | import { | ||
| 121 | connectManagedCloudEnvironment, | ||
| 122 | linkPrimaryEnvironmentToCloud, | ||
| 123 | unlinkPrimaryEnvironmentFromCloud, | ||
| 124 | } from "~/cloud/linkEnvironment"; | ||
| 125 | import { | ||
| 126 | refreshManagedRelayEnvironments, | ||
| 127 | useManagedRelayEnvironments, | ||
| 128 | } from "~/cloud/managedRelayState"; | ||
| 129 | import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; | ||
| 130 | import { webRuntime } from "~/lib/runtime"; | ||
| 131 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | ||
| 113 | 132 | ||
| 114 | const DEFAULT_TAILSCALE_SERVE_PORT = 443; | 133 | const DEFAULT_TAILSCALE_SERVE_PORT = 443; |
| 115 | 134 | ||
| ... | @@ -1582,8 +1601,242 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({ | ... | @@ -1582,8 +1601,242 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({ |
| 1582 | ); | 1601 | ); |
| 1583 | }); | 1602 | }); |
| 1584 | 1603 | ||
| 1604 | function CloudLinkSwitch({ | ||
| 1605 | checked, | ||
| 1606 | disabled, | ||
| 1607 | disabledReason, | ||
| 1608 | onCheckedChange, | ||
| 1609 | }: { | ||
| 1610 | readonly checked: boolean; | ||
| 1611 | readonly disabled: boolean; | ||
| 1612 | readonly disabledReason: string | null; | ||
| 1613 | readonly onCheckedChange?: (enabled: boolean) => void; | ||
| 1614 | }) { | ||
| 1615 | const control = ( | ||
| 1616 | <Switch | ||
| 1617 | aria-label="Enable T3 Cloud" | ||
| 1618 | checked={checked} | ||
| 1619 | disabled={disabled} | ||
| 1620 | {...(onCheckedChange ? { onCheckedChange } : {})} | ||
| 1621 | /> | ||
| 1622 | ); | ||
| 1623 | return disabledReason ? ( | ||
| 1624 | <Tooltip> | ||
| 1625 | <TooltipTrigger render={<span className="inline-flex">{control}</span>} /> | ||
| 1626 | <TooltipPopup side="top">{disabledReason}</TooltipPopup> | ||
| 1627 | </Tooltip> | ||
| 1628 | ) : ( | ||
| 1629 | control | ||
| 1630 | ); | ||
| 1631 | } | ||
| 1632 | |||
| 1633 | function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { | ||
| 1634 | const { getToken, isSignedIn } = useAuth(); | ||
| 1635 | const primaryCloudLinkState = usePrimaryCloudLinkState(); | ||
| 1636 | const [operationError, setOperationError] = useState<string | null>(null); | ||
| 1637 | const [isUpdating, setIsUpdating] = useState(false); | ||
| 1638 | |||
| 1639 | const updateLink = async (enabled: boolean) => { | ||
| 1640 | setIsUpdating(true); | ||
| 1641 | setOperationError(null); | ||
| 1642 | try { | ||
| 1643 | const clerkToken = await getToken(resolveRelayClerkTokenOptions()); | ||
| 1644 | if (enabled) { | ||
| 1645 | if (!clerkToken) { | ||
| 1646 | throw new Error("Sign in from T3 Cloud settings before linking this environment."); | ||
| 1647 | } | ||
| 1648 | await webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken })); | ||
| 1649 | } else { | ||
| 1650 | await webRuntime.runPromise( | ||
| 1651 | unlinkPrimaryEnvironmentFromCloud({ clerkToken: clerkToken ?? null }), | ||
| 1652 | ); | ||
| 1653 | } | ||
| 1654 | primaryCloudLinkState.refresh(); | ||
| 1655 | refreshManagedRelayEnvironments(); | ||
| 1656 | toastManager.add({ | ||
| 1657 | type: "success", | ||
| 1658 | title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", | ||
| 1659 | description: enabled | ||
| 1660 | ? "This environment is available through T3 Cloud." | ||
| 1661 | : "This environment is no longer available through T3 Cloud.", | ||
| 1662 | }); | ||
| 1663 | } catch (cause) { | ||
| 1664 | const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; | ||
| 1665 | setOperationError(message); | ||
| 1666 | toastManager.add({ | ||
| 1667 | type: "error", | ||
| 1668 | title: "Could not update T3 Cloud", | ||
| 1669 | description: message, | ||
| 1670 | }); | ||
| 1671 | } finally { | ||
| 1672 | setIsUpdating(false); | ||
| 1673 | } | ||
| 1674 | }; | ||
| 1675 | const disabledReason = !isSignedIn | ||
| 1676 | ? "Sign in from T3 Cloud settings to manage this environment." | ||
| 1677 | : !canManageRelay | ||
| 1678 | ? "Your session does not have permission to manage T3 Cloud access." | ||
| 1679 | : null; | ||
| 1680 | const linked = primaryCloudLinkState.data?.linked ?? false; | ||
| 1681 | |||
| 1682 | return ( | ||
| 1683 | <SettingsRow | ||
| 1684 | title="T3 Cloud" | ||
| 1685 | description={ | ||
| 1686 | linked | ||
| 1687 | ? "This environment is available to your other devices through T3 Cloud." | ||
| 1688 | : "Make this environment available to your other devices through T3 Cloud." | ||
| 1689 | } | ||
| 1690 | status={operationError ?? primaryCloudLinkState.error} | ||
| 1691 | control={ | ||
| 1692 | <CloudLinkSwitch | ||
| 1693 | checked={linked} | ||
| 1694 | disabled={!canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isUpdating} | ||
| 1695 | disabledReason={disabledReason} | ||
| 1696 | onCheckedChange={(enabled) => void updateLink(enabled)} | ||
| 1697 | /> | ||
| 1698 | } | ||
| 1699 | /> | ||
| 1700 | ); | ||
| 1701 | } | ||
| 1702 | |||
| 1703 | function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { | ||
| 1704 | return hasCloudPublicConfig() ? <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> : null; | ||
| 1705 | } | ||
| 1706 | |||
| 1707 | function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) { | ||
| 1708 | return ( | ||
| 1709 | <Empty className="min-h-52"> | ||
| 1710 | <EmptyMedia variant="icon"> | ||
| 1711 | <ChevronsLeftRightEllipsisIcon /> | ||
| 1712 | </EmptyMedia> | ||
| 1713 | <EmptyHeader> | ||
| 1714 | <EmptyTitle>No saved remote environments</EmptyTitle> | ||
| 1715 | <EmptyDescription> | ||
| 1716 | {cloudEnabled | ||
| 1717 | ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." | ||
| 1718 | : "Click “Add environment” to pair another environment."} | ||
| 1719 | </EmptyDescription> | ||
| 1720 | </EmptyHeader> | ||
| 1721 | </Empty> | ||
| 1722 | ); | ||
| 1723 | } | ||
| 1724 | |||
| 1725 | function RemoteEnvironmentRowsSkeleton() { | ||
| 1726 | return ( | ||
| 1727 | <div className={ITEM_ROW_CLASSNAME}> | ||
| 1728 | <div className={ITEM_ROW_INNER_CLASSNAME}> | ||
| 1729 | <div className="min-w-0 flex-1 space-y-2"> | ||
| 1730 | <Skeleton className="h-4 w-32 rounded-full" /> | ||
| 1731 | <Skeleton className="h-3 w-20 rounded-full" /> | ||
| 1732 | </div> | ||
| 1733 | <Skeleton className="h-7 w-16 rounded-md" /> | ||
| 1734 | </div> | ||
| 1735 | </div> | ||
| 1736 | ); | ||
| 1737 | } | ||
| 1738 | |||
| 1739 | function ConfiguredCloudRemoteEnvironmentRows({ | ||
| 1740 | primaryEnvironmentId, | ||
| 1741 | savedEnvironmentIds, | ||
| 1742 | }: { | ||
| 1743 | readonly primaryEnvironmentId: EnvironmentId | null; | ||
| 1744 | readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>; | ||
| 1745 | }) { | ||
| 1746 | const { getToken } = useAuth(); | ||
| 1747 | const environmentsState = useManagedRelayEnvironments(); | ||
| 1748 | const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>( | ||
| 1749 | null, | ||
| 1750 | ); | ||
| 1751 | const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); | ||
| 1752 | |||
| 1753 | const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { | ||
| 1754 | setConnectingEnvironmentId(environment.environmentId); | ||
| 1755 | try { | ||
| 1756 | const clerkToken = await getToken(resolveRelayClerkTokenOptions()); | ||
| 1757 | if (!clerkToken) { | ||
| 1758 | throw new Error("Sign in from T3 Cloud settings before connecting this environment."); | ||
| 1759 | } | ||
| 1760 | const connection = await webRuntime.runPromise( | ||
| 1761 | connectManagedCloudEnvironment({ clerkToken, environment }), | ||
| 1762 | ); | ||
| 1763 | await addManagedRelayEnvironment(connection); | ||
| 1764 | toastManager.add({ | ||
| 1765 | type: "success", | ||
| 1766 | title: "Environment connected", | ||
| 1767 | description: `${connection.label} is available through T3 Cloud.`, | ||
| 1768 | }); | ||
| 1769 | } catch (cause) { | ||
| 1770 | toastManager.add({ | ||
| 1771 | type: "error", | ||
| 1772 | title: "Could not connect environment", | ||
| 1773 | description: | ||
| 1774 | cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment.", | ||
| 1775 | }); | ||
| 1776 | } finally { | ||
| 1777 | setConnectingEnvironmentId(null); | ||
| 1778 | } | ||
| 1779 | }; | ||
| 1780 | |||
| 1781 | const connectableEnvironments = (environmentsState.data ?? []).filter( | ||
| 1782 | (environment) => | ||
| 1783 | environment.environmentId !== primaryEnvironmentId && | ||
| 1784 | !savedIds.has(environment.environmentId), | ||
| 1785 | ); | ||
| 1786 | |||
| 1787 | if (savedEnvironmentIds.length === 0 && environmentsState.data === null) { | ||
| 1788 | return <RemoteEnvironmentRowsSkeleton />; | ||
| 1789 | } | ||
| 1790 | |||
| 1791 | if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) { | ||
| 1792 | return <EmptyRemoteEnvironments />; | ||
| 1793 | } | ||
| 1794 | |||
| 1795 | return connectableEnvironments.map((environment) => ( | ||
| 1796 | <div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}> | ||
| 1797 | <div className={ITEM_ROW_INNER_CLASSNAME}> | ||
| 1798 | <div className="min-w-0"> | ||
| 1799 | <div className="flex items-center gap-2"> | ||
| 1800 | <ConnectionStatusDot | ||
| 1801 | dotClassName="bg-muted-foreground/35" | ||
| 1802 | tooltipText="Available through T3 Cloud" | ||
| 1803 | /> | ||
| 1804 | <p className="truncate text-sm font-medium">{environment.label}</p> | ||
| 1805 | </div> | ||
| 1806 | <p className="mt-1 truncate text-xs text-muted-foreground">T3 Cloud</p> | ||
| 1807 | </div> | ||
| 1808 | <Button | ||
| 1809 | size="sm" | ||
| 1810 | disabled={connectingEnvironmentId !== null} | ||
| 1811 | onClick={() => void connectEnvironment(environment)} | ||
| 1812 | > | ||
| 1813 | {connectingEnvironmentId === environment.environmentId ? "Connecting…" : "Connect"} | ||
| 1814 | </Button> | ||
| 1815 | </div> | ||
| 1816 | </div> | ||
| 1817 | )); | ||
| 1818 | } | ||
| 1819 | |||
| 1820 | function CloudRemoteEnvironmentRows({ | ||
| 1821 | primaryEnvironmentId, | ||
| 1822 | savedEnvironmentIds, | ||
| 1823 | }: { | ||
| 1824 | readonly primaryEnvironmentId: EnvironmentId | null; | ||
| 1825 | readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>; | ||
| 1826 | }) { | ||
| 1827 | return hasCloudPublicConfig() ? ( | ||
| 1828 | <ConfiguredCloudRemoteEnvironmentRows | ||
| 1829 | primaryEnvironmentId={primaryEnvironmentId} | ||
| 1830 | savedEnvironmentIds={savedEnvironmentIds} | ||
| 1831 | /> | ||
| 1832 | ) : savedEnvironmentIds.length === 0 ? ( | ||
| 1833 | <EmptyRemoteEnvironments cloudEnabled={false} /> | ||
| 1834 | ) : null; | ||
| 1835 | } | ||
| 1836 | |||
| 1585 | export function ConnectionsSettings() { | 1837 | export function ConnectionsSettings() { |
| 1586 | const desktopBridge = window.desktopBridge; | 1838 | const desktopBridge = window.desktopBridge; |
| 1839 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | ||
| 1587 | const primarySessionState = usePrimarySessionState(); | 1840 | const primarySessionState = usePrimarySessionState(); |
| 1588 | const currentSessionScopes = desktopBridge | 1841 | const currentSessionScopes = desktopBridge |
| 1589 | ? AuthAdministrativeScopes | 1842 | ? AuthAdministrativeScopes |
| ... | @@ -1701,6 +1954,7 @@ export function ConnectionsSettings() { | ... | @@ -1701,6 +1954,7 @@ export function ConnectionsSettings() { |
| 1701 | (state) => state.setDefaultAdvertisedEndpointKey, | 1954 | (state) => state.setDefaultAdvertisedEndpointKey, |
| 1702 | ); | 1955 | ); |
| 1703 | const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; | 1956 | const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; |
| 1957 | const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; | ||
| 1704 | const isLocalBackendNetworkAccessible = desktopBridge | 1958 | const isLocalBackendNetworkAccessible = desktopBridge |
| 1705 | ? desktopServerExposureState?.mode === "network-accessible" | 1959 | ? desktopServerExposureState?.mode === "network-accessible" |
| 1706 | : currentAuthPolicy === "remote-reachable"; | 1960 | : currentAuthPolicy === "remote-reachable"; |
| ... | @@ -2607,7 +2861,7 @@ export function ConnectionsSettings() { | ... | @@ -2607,7 +2861,7 @@ export function ConnectionsSettings() { |
| 2607 | <SettingsPageContainer> | 2861 | <SettingsPageContainer> |
| 2608 | {canManageLocalBackend ? ( | 2862 | {canManageLocalBackend ? ( |
| 2609 | <> | 2863 | <> |
| 2610 | <SettingsSection title="Manage local backend"> | 2864 | <SettingsSection title="This environment"> |
| 2611 | {primaryVersionMismatch ? ( | 2865 | {primaryVersionMismatch ? ( |
| 2612 | <SettingsRow | 2866 | <SettingsRow |
| 2613 | title="Version drift" | 2867 | title="Version drift" |
| ... | @@ -2626,9 +2880,13 @@ export function ConnectionsSettings() { | ... | @@ -2626,9 +2880,13 @@ export function ConnectionsSettings() { |
| 2626 | {renderNetworkAccessRow()} | 2880 | {renderNetworkAccessRow()} |
| 2627 | {renderEndpointRows("endpoint-rail")} | 2881 | {renderEndpointRows("endpoint-rail")} |
| 2628 | {renderTailscaleRow()} | 2882 | {renderTailscaleRow()} |
| 2883 | <CloudLinkRow canManageRelay={canManageRelay} /> | ||
| 2629 | </> | 2884 | </> |
| 2630 | ) : ( | 2885 | ) : ( |
| 2631 | renderDisabledNetworkAccessRow() | 2886 | <> |
| 2887 | {renderDisabledNetworkAccessRow()} | ||
| 2888 | <CloudLinkRow canManageRelay={canManageRelay} /> | ||
| 2889 | </> | ||
| 2632 | )} | 2890 | )} |
| 2633 | </SettingsSection> | 2891 | </SettingsSection> |
| 2634 | 2892 | ||
| ... | @@ -2805,11 +3063,12 @@ export function ConnectionsSettings() { | ... | @@ -2805,11 +3063,12 @@ export function ConnectionsSettings() { |
| 2805 | </Dialog> | 3063 | </Dialog> |
| 2806 | </> | 3064 | </> |
| 2807 | ) : ( | 3065 | ) : ( |
| 2808 | <SettingsSection title="Local backend access"> | 3066 | <SettingsSection title="This environment"> |
| 2809 | <SettingsRow | 3067 | <SettingsRow |
| 2810 | title="Administrative access" | 3068 | title="Administrative access" |
| 2811 | description="Pairing links and client-session management require the access:write scope for this backend." | 3069 | description="Pairing links and client-session management require the access:write scope for this backend." |
| 2812 | /> | 3070 | /> |
| 3071 | <CloudLinkRow canManageRelay={canManageRelay} /> | ||
| 2813 | </SettingsSection> | 3072 | </SettingsSection> |
| 2814 | )} | 3073 | )} |
| 2815 | 3074 | ||
| ... | @@ -2889,15 +3148,10 @@ export function ConnectionsSettings() { | ... | @@ -2889,15 +3148,10 @@ export function ConnectionsSettings() { |
| 2889 | onRemove={handleRemoveSavedBackend} | 3148 | onRemove={handleRemoveSavedBackend} |
| 2890 | /> | 3149 | /> |
| 2891 | ))} | 3150 | ))} |
| 2892 | 3151 | <CloudRemoteEnvironmentRows | |
| 2893 | {savedEnvironmentIds.length === 0 ? ( | 3152 | primaryEnvironmentId={primaryEnvironmentId} |
| 2894 | <div className={ITEM_ROW_CLASSNAME}> | 3153 | savedEnvironmentIds={savedEnvironmentIds} |
| 2895 | <p className="text-xs text-muted-foreground"> | 3154 | /> |
| 2896 | No remote environments yet. Click &ldquo;Add environment&rdquo; to pair another | ||
| 2897 | environment. | ||
| 2898 | </p> | ||
| 2899 | </div> | ||
| 2900 | ) : null} | ||
| 2901 | </SettingsSection> | 3155 | </SettingsSection> |
| 2902 | </SettingsPageContainer> | 3156 | </SettingsPageContainer> |
| 2903 | ); | 3157 | ); |
apps/web/src/components/settings/SettingsPanels.browser.tsx+24-1| ... | @@ -148,6 +148,14 @@ const authAccessHarness = vi.hoisted(() => { | ... | @@ -148,6 +148,14 @@ const authAccessHarness = vi.hoisted(() => { |
| 148 | }); | 148 | }); |
| 149 | 149 | ||
| 150 | const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn()); | 150 | const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn()); |
| 151 | const mockGetClerkToken = vi.hoisted(() => vi.fn(async () => null)); | ||
| 152 | |||
| 153 | vi.mock("@clerk/react", () => ({ | ||
| 154 | useAuth: () => ({ | ||
| 155 | getToken: mockGetClerkToken, | ||
| 156 | isSignedIn: false, | ||
| 157 | }), | ||
| 158 | })); | ||
| 151 | 159 | ||
| 152 | vi.mock("../../environments/runtime", () => { | 160 | vi.mock("../../environments/runtime", () => { |
| 153 | const primaryConnection = { | 161 | const primaryConnection = { |
| ... | @@ -185,6 +193,7 @@ vi.mock("../../environments/runtime", () => { | ... | @@ -185,6 +193,7 @@ vi.mock("../../environments/runtime", () => { |
| 185 | resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => | 193 | resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => |
| 186 | new URL(path, "http://localhost:3000").toString(), | 194 | new URL(path, "http://localhost:3000").toString(), |
| 187 | waitForSavedEnvironmentRegistryHydration: async () => undefined, | 195 | waitForSavedEnvironmentRegistryHydration: async () => undefined, |
| 196 | addManagedRelayEnvironment: vi.fn(), | ||
| 188 | addSavedEnvironment: vi.fn(), | 197 | addSavedEnvironment: vi.fn(), |
| 189 | connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment, | 198 | connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment, |
| 190 | disconnectSavedEnvironment: vi.fn(), | 199 | disconnectSavedEnvironment: vi.fn(), |
| ... | @@ -450,6 +459,18 @@ const createDesktopBridgeStub = (overrides?: { | ... | @@ -450,6 +459,18 @@ const createDesktopBridgeStub = (overrides?: { |
| 450 | setTheme: vi.fn().mockResolvedValue(undefined), | 459 | setTheme: vi.fn().mockResolvedValue(undefined), |
| 451 | showContextMenu: vi.fn().mockResolvedValue(null), | 460 | showContextMenu: vi.fn().mockResolvedValue(null), |
| 452 | openExternal: vi.fn().mockResolvedValue(true), | 461 | openExternal: vi.fn().mockResolvedValue(true), |
| 462 | createCloudAuthRequest: vi.fn().mockResolvedValue("t3code-dev://auth/callback?t3_state=test"), | ||
| 463 | getCloudAuthToken: vi.fn().mockResolvedValue(null), | ||
| 464 | setCloudAuthToken: vi.fn().mockResolvedValue(true), | ||
| 465 | clearCloudAuthToken: vi.fn().mockResolvedValue(undefined), | ||
| 466 | fetchCloudAuth: vi.fn().mockResolvedValue({ | ||
| 467 | ok: true, | ||
| 468 | status: 200, | ||
| 469 | statusText: "OK", | ||
| 470 | headers: {}, | ||
| 471 | body: "", | ||
| 472 | }), | ||
| 473 | onCloudAuthCallback: () => () => {}, | ||
| 453 | onMenuAction: () => () => {}, | 474 | onMenuAction: () => () => {}, |
| 454 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), | 475 | getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), |
| 455 | setUpdateChannel: | 476 | setUpdateChannel: |
| ... | @@ -553,7 +574,9 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -553,7 +574,9 @@ describe("GeneralSettingsPanel observability", () => { |
| 553 | </AppAtomRegistryProvider>, | 574 | </AppAtomRegistryProvider>, |
| 554 | ); | 575 | ); |
| 555 | 576 | ||
| 556 | await expect.element(page.getByText("Manage local backend")).toBeInTheDocument(); | 577 | await expect |
| 578 | .element(page.getByRole("heading", { name: "This environment", exact: true })) | ||
| 579 | .toBeInTheDocument(); | ||
| 557 | await expect.element(page.getByLabelText("Enable network access")).toBeDisabled(); | 580 | await expect.element(page.getByLabelText("Enable network access")).toBeDisabled(); |
| 558 | await expect | 581 | await expect |
| 559 | .element( | 582 | .element( |
apps/web/src/components/settings/SettingsSidebarNav.tsx+14-1| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | ArchiveIcon, | 3 | ArchiveIcon, |
| 4 | ArrowLeftIcon, | 4 | ArrowLeftIcon, |
| 5 | BotIcon, | 5 | BotIcon, |
| 6 | CloudIcon, | ||
| 6 | GitBranchIcon, | 7 | GitBranchIcon, |
| 7 | KeyboardIcon, | 8 | KeyboardIcon, |
| 8 | Link2Icon, | 9 | Link2Icon, |
| ... | @@ -20,12 +21,15 @@ import { | ... | @@ -20,12 +21,15 @@ import { |
| 20 | SidebarSeparator, | 21 | SidebarSeparator, |
| 21 | useSidebar, | 22 | useSidebar, |
| 22 | } from "../ui/sidebar"; | 23 | } from "../ui/sidebar"; |
| 24 | import { Badge } from "../ui/badge"; | ||
| 25 | import { hasCloudPublicConfig } from "../../cloud/publicConfig"; | ||
| 23 | 26 | ||
| 24 | export type SettingsSectionPath = | 27 | export type SettingsSectionPath = |
| 25 | | "/settings/general" | 28 | | "/settings/general" |
| 26 | | "/settings/keybindings" | 29 | | "/settings/keybindings" |
| 27 | | "/settings/providers" | 30 | | "/settings/providers" |
| 28 | | "/settings/source-control" | 31 | | "/settings/source-control" |
| 32 | | "/settings/cloud" | ||
| 29 | | "/settings/connections" | 33 | | "/settings/connections" |
| 30 | | "/settings/archived"; | 34 | | "/settings/archived"; |
| 31 | 35 | ||
| ... | @@ -33,11 +37,13 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ | ... | @@ -33,11 +37,13 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 33 | label: string; | 37 | label: string; |
| 34 | to: SettingsSectionPath; | 38 | to: SettingsSectionPath; |
| 35 | icon: ComponentType<{ className?: string }>; | 39 | icon: ComponentType<{ className?: string }>; |
| 40 | badgeLabel?: string; | ||
| 36 | }> = [ | 41 | }> = [ |
| 37 | { label: "General", to: "/settings/general", icon: Settings2Icon }, | 42 | { label: "General", to: "/settings/general", icon: Settings2Icon }, |
| 38 | { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, | 43 | { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, |
| 39 | { label: "Providers", to: "/settings/providers", icon: BotIcon }, | 44 | { label: "Providers", to: "/settings/providers", icon: BotIcon }, |
| 40 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, | 45 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, |
| 46 | { label: "T3 Cloud", to: "/settings/cloud", icon: CloudIcon, badgeLabel: "Private Beta" }, | ||
| 41 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, | 47 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, |
| 42 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, | 48 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, |
| 43 | ]; | 49 | ]; |
| ... | @@ -71,7 +77,9 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -71,7 +77,9 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 71 | <SidebarContent className="overflow-x-hidden"> | 77 | <SidebarContent className="overflow-x-hidden"> |
| 72 | <SidebarGroup className="px-2 py-3"> | 78 | <SidebarGroup className="px-2 py-3"> |
| 73 | <SidebarMenu> | 79 | <SidebarMenu> |
| 74 | {SETTINGS_NAV_ITEMS.map((item) => { | 80 | {SETTINGS_NAV_ITEMS.filter( |
| 81 | (item) => item.to !== "/settings/cloud" || hasCloudPublicConfig(), | ||
| 82 | ).map((item) => { | ||
| 75 | const Icon = item.icon; | 83 | const Icon = item.icon; |
| 76 | const isActive = pathname === item.to; | 84 | const isActive = pathname === item.to; |
| 77 | return ( | 85 | return ( |
| ... | @@ -94,6 +102,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -94,6 +102,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 94 | } | 102 | } |
| 95 | /> | 103 | /> |
| 96 | <span className="truncate">{item.label}</span> | 104 | <span className="truncate">{item.label}</span> |
| 105 | {item.badgeLabel ? ( | ||
| 106 | <Badge variant="warning" size="sm" className="ml-auto"> | ||
| 107 | {item.badgeLabel} | ||
| 108 | </Badge> | ||
| 109 | ) : null} | ||
| 97 | </SidebarMenuButton> | 110 | </SidebarMenuButton> |
| 98 | </SidebarMenuItem> | 111 | </SidebarMenuItem> |
| 99 | ); | 112 | ); |
apps/web/src/environments/primary/auth.ts+2-1| ... | @@ -3,6 +3,7 @@ import type { | ... | @@ -3,6 +3,7 @@ import type { |
| 3 | AuthClientMetadata, | 3 | AuthClientMetadata, |
| 4 | AuthEnvironmentScope, | 4 | AuthEnvironmentScope, |
| 5 | AuthPairingCredentialResult, | 5 | AuthPairingCredentialResult, |
| 6 | ServerAuthSessionMethod, | ||
| 6 | AuthSessionId, | 7 | AuthSessionId, |
| 7 | AuthSessionState, | 8 | AuthSessionState, |
| 8 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| ... | @@ -45,7 +46,7 @@ export interface ServerClientSessionRecord { | ... | @@ -45,7 +46,7 @@ export interface ServerClientSessionRecord { |
| 45 | readonly sessionId: AuthSessionId; | 46 | readonly sessionId: AuthSessionId; |
| 46 | readonly subject: string; | 47 | readonly subject: string; |
| 47 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; | 48 | readonly scopes: ReadonlyArray<AuthEnvironmentScope>; |
| 48 | readonly method: "browser-session-cookie" | "bearer-access-token"; | 49 | readonly method: ServerAuthSessionMethod; |
| 49 | readonly client: AuthClientMetadata; | 50 | readonly client: AuthClientMetadata; |
| 50 | readonly issuedAt: string; | 51 | readonly issuedAt: string; |
| 51 | readonly expiresAt: string; | 52 | readonly expiresAt: string; |
apps/web/src/environments/primary/index.ts+5-1| ... | @@ -34,4 +34,8 @@ export { | ... | @@ -34,4 +34,8 @@ export { |
| 34 | 34 | ||
| 35 | export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState"; | 35 | export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState"; |
| 36 | 36 | ||
| 37 | export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target"; | 37 | export { |
| 38 | readPrimaryEnvironmentTarget, | ||
| 39 | resolvePrimaryEnvironmentHttpUrl, | ||
| 40 | isLoopbackHostname, | ||
| 41 | } from "./target"; |
apps/web/src/environments/primary/requestInit.ts created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 3 | |||
| 4 | export const primaryEnvironmentRequestInit = { credentials: "include" } as const; | ||
| 5 | |||
| 6 | export const withPrimaryEnvironmentRequestInit = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 7 | effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit)); | ||
apps/web/src/environments/runtime/catalog.test.ts+43| ... | @@ -6,11 +6,13 @@ import { | ... | @@ -6,11 +6,13 @@ import { |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| 9 | readSavedEnvironmentCredential, | ||
| 9 | resetSavedEnvironmentRegistryStoreForTests, | 10 | resetSavedEnvironmentRegistryStoreForTests, |
| 10 | resetSavedEnvironmentRuntimeStoreForTests, | 11 | resetSavedEnvironmentRuntimeStoreForTests, |
| 11 | useSavedEnvironmentRegistryStore, | 12 | useSavedEnvironmentRegistryStore, |
| 12 | useSavedEnvironmentRuntimeStore, | 13 | useSavedEnvironmentRuntimeStore, |
| 13 | waitForSavedEnvironmentRegistryHydration, | 14 | waitForSavedEnvironmentRegistryHydration, |
| 15 | writeSavedEnvironmentCredential, | ||
| 14 | } from "./catalog"; | 16 | } from "./catalog"; |
| 15 | 17 | ||
| 16 | describe("environment runtime catalog stores", () => { | 18 | describe("environment runtime catalog stores", () => { |
| ... | @@ -74,6 +76,47 @@ describe("environment runtime catalog stores", () => { | ... | @@ -74,6 +76,47 @@ describe("environment runtime catalog stores", () => { |
| 74 | expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); | 76 | expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); |
| 75 | }); | 77 | }); |
| 76 | 78 | ||
| 79 | it("decodes legacy bearer secrets and writes versioned DPoP credentials", async () => { | ||
| 80 | let storedSecret: string | null = "legacy-bearer-token"; | ||
| 81 | vi.stubGlobal("window", { | ||
| 82 | nativeApi: { | ||
| 83 | persistence: { | ||
| 84 | getClientSettings: async () => null, | ||
| 85 | setClientSettings: async () => undefined, | ||
| 86 | getSavedEnvironmentRegistry: async () => [], | ||
| 87 | setSavedEnvironmentRegistry: async () => undefined, | ||
| 88 | getSavedEnvironmentSecret: async () => storedSecret, | ||
| 89 | setSavedEnvironmentSecret: async (_environmentId, secret) => { | ||
| 90 | storedSecret = secret; | ||
| 91 | return true; | ||
| 92 | }, | ||
| 93 | removeSavedEnvironmentSecret: async () => undefined, | ||
| 94 | }, | ||
| 95 | } satisfies Pick<LocalApi, "persistence">, | ||
| 96 | }); | ||
| 97 | const { __resetLocalApiForTests } = await import("../../localApi"); | ||
| 98 | await __resetLocalApiForTests(); | ||
| 99 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 100 | |||
| 101 | await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ | ||
| 102 | version: 1, | ||
| 103 | method: "bearer", | ||
| 104 | token: "legacy-bearer-token", | ||
| 105 | }); | ||
| 106 | await expect( | ||
| 107 | writeSavedEnvironmentCredential(environmentId, { | ||
| 108 | version: 1, | ||
| 109 | method: "dpop", | ||
| 110 | accessToken: "managed-dpop-access-token", | ||
| 111 | }), | ||
| 112 | ).resolves.toBe(true); | ||
| 113 | await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ | ||
| 114 | version: 1, | ||
| 115 | method: "dpop", | ||
| 116 | accessToken: "managed-dpop-access-token", | ||
| 117 | }); | ||
| 118 | }); | ||
| 119 | |||
| 77 | it("does not throw when local api lookup fails during registry persistence", async () => { | 120 | it("does not throw when local api lookup fails during registry persistence", async () => { |
| 78 | vi.unstubAllGlobals(); | 121 | vi.unstubAllGlobals(); |
| 79 | const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); | 122 | const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); |
apps/web/src/environments/runtime/catalog.ts+49| ... | @@ -6,6 +6,8 @@ import type { | ... | @@ -6,6 +6,8 @@ import type { |
| 6 | PersistedSavedEnvironmentRecord, | 6 | PersistedSavedEnvironmentRecord, |
| 7 | ServerConfig, | 7 | ServerConfig, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 9 | import { create } from "zustand"; | 11 | import { create } from "zustand"; |
| 10 | 12 | ||
| 11 | import { ensureLocalApi } from "../../localApi"; | 13 | import { ensureLocalApi } from "../../localApi"; |
| ... | @@ -19,8 +21,29 @@ export interface SavedEnvironmentRecord { | ... | @@ -19,8 +21,29 @@ export interface SavedEnvironmentRecord { |
| 19 | readonly createdAt: string; | 21 | readonly createdAt: string; |
| 20 | readonly lastConnectedAt: string | null; | 22 | readonly lastConnectedAt: string | null; |
| 21 | readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"]; | 23 | readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"]; |
| 24 | readonly relayManaged?: PersistedSavedEnvironmentRecord["relayManaged"]; | ||
| 22 | } | 25 | } |
| 23 | 26 | ||
| 27 | export const SavedEnvironmentCredential = Schema.Union([ | ||
| 28 | Schema.Struct({ | ||
| 29 | version: Schema.Literal(1), | ||
| 30 | method: Schema.Literal("bearer"), | ||
| 31 | token: Schema.String, | ||
| 32 | }), | ||
| 33 | Schema.Struct({ | ||
| 34 | version: Schema.Literal(1), | ||
| 35 | method: Schema.Literal("dpop"), | ||
| 36 | accessToken: Schema.String, | ||
| 37 | }), | ||
| 38 | ]); | ||
| 39 | export type SavedEnvironmentCredential = typeof SavedEnvironmentCredential.Type; | ||
| 40 | |||
| 41 | const SavedEnvironmentCredentialJson = Schema.fromJsonString(SavedEnvironmentCredential); | ||
| 42 | const decodeSavedEnvironmentCredentialJson = Schema.decodeUnknownOption( | ||
| 43 | SavedEnvironmentCredentialJson, | ||
| 44 | ); | ||
| 45 | const encodeSavedEnvironmentCredentialJson = Schema.encodeSync(SavedEnvironmentCredentialJson); | ||
| 46 | |||
| 24 | interface SavedEnvironmentRegistryState { | 47 | interface SavedEnvironmentRegistryState { |
| 25 | readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>; | 48 | readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>; |
| 26 | } | 49 | } |
| ... | @@ -47,6 +70,7 @@ export function toPersistedSavedEnvironmentRecord( | ... | @@ -47,6 +70,7 @@ export function toPersistedSavedEnvironmentRecord( |
| 47 | createdAt: record.createdAt, | 70 | createdAt: record.createdAt, |
| 48 | lastConnectedAt: record.lastConnectedAt, | 71 | lastConnectedAt: record.lastConnectedAt, |
| 49 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), | 72 | ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), |
| 73 | ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), | ||
| 50 | }; | 74 | }; |
| 51 | } | 75 | } |
| 52 | 76 | ||
| ... | @@ -250,6 +274,31 @@ export async function readSavedEnvironmentBearerToken( | ... | @@ -250,6 +274,31 @@ export async function readSavedEnvironmentBearerToken( |
| 250 | return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); | 274 | return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); |
| 251 | } | 275 | } |
| 252 | 276 | ||
| 277 | export async function readSavedEnvironmentCredential( | ||
| 278 | environmentId: EnvironmentId, | ||
| 279 | ): Promise<SavedEnvironmentCredential | null> { | ||
| 280 | const secret = await ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); | ||
| 281 | if (!secret) { | ||
| 282 | return null; | ||
| 283 | } | ||
| 284 | const decoded = decodeSavedEnvironmentCredentialJson(secret); | ||
| 285 | if (Option.isSome(decoded)) { | ||
| 286 | return decoded.value; | ||
| 287 | } | ||
| 288 | // Legacy bearer secrets were stored directly as strings. | ||
| 289 | return { version: 1, method: "bearer", token: secret }; | ||
| 290 | } | ||
| 291 | |||
| 292 | export async function writeSavedEnvironmentCredential( | ||
| 293 | environmentId: EnvironmentId, | ||
| 294 | credential: SavedEnvironmentCredential, | ||
| 295 | ): Promise<boolean> { | ||
| 296 | return ensureLocalApi().persistence.setSavedEnvironmentSecret( | ||
| 297 | environmentId, | ||
| 298 | encodeSavedEnvironmentCredentialJson(credential), | ||
| 299 | ); | ||
| 300 | } | ||
| 301 | |||
| 253 | export async function writeSavedEnvironmentBearerToken( | 302 | export async function writeSavedEnvironmentBearerToken( |
| 254 | environmentId: EnvironmentId, | 303 | environmentId: EnvironmentId, |
| 255 | bearerToken: string, | 304 | bearerToken: string, |
apps/web/src/environments/runtime/index.ts+1| ... | @@ -16,6 +16,7 @@ export { | ... | @@ -16,6 +16,7 @@ export { |
| 16 | 16 | ||
| 17 | export { | 17 | export { |
| 18 | addSavedEnvironment, | 18 | addSavedEnvironment, |
| 19 | addManagedRelayEnvironment, | ||
| 19 | connectDesktopSshEnvironment, | 20 | connectDesktopSshEnvironment, |
| 20 | disconnectSavedEnvironment, | 21 | disconnectSavedEnvironment, |
| 21 | ensureEnvironmentConnectionBootstrapped, | 22 | ensureEnvironmentConnectionBootstrapped, |
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+159-19| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 5 | ||
| ... | @@ -10,17 +11,33 @@ const mockResolveRemotePairingTarget = vi.fn(); | ... | @@ -10,17 +11,33 @@ const mockResolveRemotePairingTarget = vi.fn(); |
| 10 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); | 11 | const mockFetchRemoteEnvironmentDescriptor = vi.fn(); |
| 11 | const mockBootstrapRemoteBearerSession = vi.fn(); | 12 | const mockBootstrapRemoteBearerSession = vi.fn(); |
| 12 | const mockFetchRemoteSessionState = vi.fn(); | 13 | const mockFetchRemoteSessionState = vi.fn(); |
| 14 | const mockFetchRemoteDpopSessionState = vi.fn(); | ||
| 13 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); | 15 | const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); |
| 14 | const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect); | 16 | let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner; |
| 17 | const mockRemoteHttpRunPromise = vi.fn(<A, E>(effect: Effect.Effect<A, E>) => | ||
| 18 | Effect.runPromise( | ||
| 19 | effect.pipe( | ||
| 20 | Effect.provideService( | ||
| 21 | managedRelayDpopSigner, | ||
| 22 | managedRelayDpopSigner.of({ | ||
| 23 | thumbprint: Effect.succeed("thumbprint"), | ||
| 24 | createProof: () => Effect.succeed("dpop-proof"), | ||
| 25 | }), | ||
| 26 | ), | ||
| 27 | ), | ||
| 28 | ), | ||
| 29 | ); | ||
| 15 | const mockBootstrapSshBearerSession = vi.fn(); | 30 | const mockBootstrapSshBearerSession = vi.fn(); |
| 16 | const mockFetchSshSessionState = vi.fn(); | 31 | const mockFetchSshSessionState = vi.fn(); |
| 17 | const mockPersistSavedEnvironmentRecord = vi.fn(); | 32 | const mockPersistSavedEnvironmentRecord = vi.fn(); |
| 18 | const mockWriteSavedEnvironmentBearerToken = vi.fn(); | 33 | const mockWriteSavedEnvironmentBearerToken = vi.fn(); |
| 34 | const mockWriteSavedEnvironmentCredential = vi.fn(); | ||
| 19 | const mockSetSavedEnvironmentRegistry = vi.fn(); | 35 | const mockSetSavedEnvironmentRegistry = vi.fn(); |
| 20 | const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { | 36 | const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { |
| 21 | return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; | 37 | return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; |
| 22 | }); | 38 | }); |
| 23 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | 39 | const mockReadSavedEnvironmentBearerToken = vi.fn(); |
| 40 | const mockReadSavedEnvironmentCredential = vi.fn(); | ||
| 24 | const mockRemoveSavedEnvironmentBearerToken = vi.fn(); | 41 | const mockRemoveSavedEnvironmentBearerToken = vi.fn(); |
| 25 | const mockPatchRuntime = vi.fn(); | 42 | const mockPatchRuntime = vi.fn(); |
| 26 | const mockClearRuntime = vi.fn(); | 43 | const mockClearRuntime = vi.fn(); |
| ... | @@ -58,6 +75,8 @@ const mockClientGetConfig = vi.fn(async () => ({ | ... | @@ -58,6 +75,8 @@ const mockClientGetConfig = vi.fn(async () => ({ |
| 58 | label: "Remote environment", | 75 | label: "Remote environment", |
| 59 | }, | 76 | }, |
| 60 | })); | 77 | })); |
| 78 | const mockConnectManagedCloudEnvironment = vi.fn(); | ||
| 79 | const mockReadManagedRelayClerkToken = vi.fn(); | ||
| 61 | 80 | ||
| 62 | vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ | 81 | vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ |
| 63 | ...(await importOriginal<typeof import("@t3tools/shared/remote")>()), | 82 | ...(await importOriginal<typeof import("@t3tools/shared/remote")>()), |
| ... | @@ -65,11 +84,19 @@ vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ | ... | @@ -65,11 +84,19 @@ vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ |
| 65 | })); | 84 | })); |
| 66 | 85 | ||
| 67 | vi.mock("../../lib/runtime", () => ({ | 86 | vi.mock("../../lib/runtime", () => ({ |
| 68 | remoteHttpRuntime: { | 87 | webRuntime: { |
| 69 | runPromise: mockRemoteHttpRunPromise, | 88 | runPromise: mockRemoteHttpRunPromise, |
| 70 | }, | 89 | }, |
| 71 | })); | 90 | })); |
| 72 | 91 | ||
| 92 | vi.mock("../../cloud/linkEnvironment", () => ({ | ||
| 93 | connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment, | ||
| 94 | })); | ||
| 95 | |||
| 96 | vi.mock("../../cloud/managedAuth", () => ({ | ||
| 97 | readManagedRelayClerkToken: mockReadManagedRelayClerkToken, | ||
| 98 | })); | ||
| 99 | |||
| 73 | vi.mock("~/localApi", () => ({ | 100 | vi.mock("~/localApi", () => ({ |
| 74 | ensureLocalApi: () => ({ | 101 | ensureLocalApi: () => ({ |
| 75 | persistence: { | 102 | persistence: { |
| ... | @@ -84,6 +111,7 @@ vi.mock("./catalog", () => ({ | ... | @@ -84,6 +111,7 @@ vi.mock("./catalog", () => ({ |
| 84 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | 111 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, |
| 85 | persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, | 112 | persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, |
| 86 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, | 113 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, |
| 114 | readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, | ||
| 87 | removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, | 115 | removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, |
| 88 | toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, | 116 | toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, |
| 89 | useSavedEnvironmentRegistryStore: { | 117 | useSavedEnvironmentRegistryStore: { |
| ... | @@ -105,6 +133,7 @@ vi.mock("./catalog", () => ({ | ... | @@ -105,6 +133,7 @@ vi.mock("./catalog", () => ({ |
| 105 | }, | 133 | }, |
| 106 | waitForSavedEnvironmentRegistryHydration: vi.fn(), | 134 | waitForSavedEnvironmentRegistryHydration: vi.fn(), |
| 107 | writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, | 135 | writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, |
| 136 | writeSavedEnvironmentCredential: mockWriteSavedEnvironmentCredential, | ||
| 108 | })); | 137 | })); |
| 109 | 138 | ||
| 110 | vi.mock("./connection", async (importOriginal) => ({ | 139 | vi.mock("./connection", async (importOriginal) => ({ |
| ... | @@ -114,6 +143,7 @@ vi.mock("./connection", async (importOriginal) => ({ | ... | @@ -114,6 +143,7 @@ vi.mock("./connection", async (importOriginal) => ({ |
| 114 | 143 | ||
| 115 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { | 144 | vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 116 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); | 145 | const actual = await importOriginal<typeof import("@t3tools/client-runtime")>(); |
| 146 | managedRelayDpopSigner = actual.ManagedRelayDpopSigner; | ||
| 117 | return { | 147 | return { |
| 118 | ...actual, | 148 | ...actual, |
| 119 | bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, | 149 | bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, |
| ... | @@ -130,6 +160,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { | ... | @@ -130,6 +160,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 130 | })), | 160 | })), |
| 131 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, | 161 | fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, |
| 132 | fetchRemoteSessionState: mockFetchRemoteSessionState, | 162 | fetchRemoteSessionState: mockFetchRemoteSessionState, |
| 163 | fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState, | ||
| 133 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, | 164 | resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, |
| 134 | }; | 165 | }; |
| 135 | }); | 166 | }); |
| ... | @@ -172,20 +203,36 @@ describe("addSavedEnvironment", () => { | ... | @@ -172,20 +203,36 @@ describe("addSavedEnvironment", () => { |
| 172 | credential: input.pairingCode ?? "pairing-code", | 203 | credential: input.pairingCode ?? "pairing-code", |
| 173 | }), | 204 | }), |
| 174 | ); | 205 | ); |
| 175 | mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({ | 206 | mockReadSavedEnvironmentCredential.mockImplementation(async () => { |
| 176 | environmentId: EnvironmentId.make("environment-1"), | 207 | const token = await mockReadSavedEnvironmentBearerToken(); |
| 177 | label: "Remote environment", | 208 | return token ? { version: 1, method: "bearer", token } : null; |
| 178 | }); | ||
| 179 | mockBootstrapRemoteBearerSession.mockResolvedValue({ | ||
| 180 | access_token: "bearer-token", | ||
| 181 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 182 | }); | 209 | }); |
| 183 | mockFetchRemoteSessionState.mockResolvedValue({ | 210 | mockFetchRemoteEnvironmentDescriptor.mockReturnValue( |
| 184 | authenticated: true, | 211 | Effect.succeed({ |
| 185 | scopes: ["orchestration:read", "access:write"], | 212 | environmentId: EnvironmentId.make("environment-1"), |
| 186 | }); | 213 | label: "Remote environment", |
| 187 | mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( | 214 | }), |
| 188 | "wss://remote.example.com/?wsTicket=remote-token", | 215 | ); |
| 216 | mockBootstrapRemoteBearerSession.mockReturnValue( | ||
| 217 | Effect.succeed({ | ||
| 218 | access_token: "bearer-token", | ||
| 219 | scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", | ||
| 220 | }), | ||
| 221 | ); | ||
| 222 | mockFetchRemoteSessionState.mockReturnValue( | ||
| 223 | Effect.succeed({ | ||
| 224 | authenticated: true, | ||
| 225 | scopes: ["orchestration:read", "access:write"], | ||
| 226 | }), | ||
| 227 | ); | ||
| 228 | mockFetchRemoteDpopSessionState.mockReturnValue( | ||
| 229 | Effect.succeed({ | ||
| 230 | authenticated: true, | ||
| 231 | scopes: ["orchestration:read", "access:write"], | ||
| 232 | }), | ||
| 233 | ); | ||
| 234 | mockResolveRemoteWebSocketConnectionUrl.mockReturnValue( | ||
| 235 | Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"), | ||
| 189 | ); | 236 | ); |
| 190 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ | 237 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ |
| 191 | environmentId: EnvironmentId.make("environment-1"), | 238 | environmentId: EnvironmentId.make("environment-1"), |
| ... | @@ -197,6 +244,8 @@ describe("addSavedEnvironment", () => { | ... | @@ -197,6 +244,8 @@ describe("addSavedEnvironment", () => { |
| 197 | }); | 244 | }); |
| 198 | mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); | 245 | mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); |
| 199 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); | 246 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); |
| 247 | mockWriteSavedEnvironmentCredential.mockResolvedValue(true); | ||
| 248 | mockReadManagedRelayClerkToken.mockResolvedValue(null); | ||
| 200 | mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); | 249 | mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); |
| 201 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | 250 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); |
| 202 | mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); | 251 | mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); |
| ... | @@ -323,6 +372,94 @@ describe("addSavedEnvironment", () => { | ... | @@ -323,6 +372,94 @@ describe("addSavedEnvironment", () => { |
| 323 | await resetEnvironmentServiceForTests(); | 372 | await resetEnvironmentServiceForTests(); |
| 324 | }); | 373 | }); |
| 325 | 374 | ||
| 375 | it("installs relay-managed environments with versioned DPoP credentials", async () => { | ||
| 376 | const { addManagedRelayEnvironment, resetEnvironmentServiceForTests } = | ||
| 377 | await import("./service"); | ||
| 378 | |||
| 379 | await addManagedRelayEnvironment({ | ||
| 380 | environmentId: EnvironmentId.make("environment-1"), | ||
| 381 | label: "Managed remote", | ||
| 382 | httpBaseUrl: "https://managed.example.com/", | ||
| 383 | wsBaseUrl: "wss://managed.example.com/", | ||
| 384 | relayUrl: "https://relay.example.com", | ||
| 385 | accessToken: "managed-access-token", | ||
| 386 | }); | ||
| 387 | |||
| 388 | expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith( | ||
| 389 | EnvironmentId.make("environment-1"), | ||
| 390 | { | ||
| 391 | version: 1, | ||
| 392 | method: "dpop", | ||
| 393 | accessToken: "managed-access-token", | ||
| 394 | }, | ||
| 395 | ); | ||
| 396 | expect(mockFetchRemoteDpopSessionState).toHaveBeenCalledWith({ | ||
| 397 | httpBaseUrl: "https://managed.example.com/", | ||
| 398 | accessToken: "managed-access-token", | ||
| 399 | dpopProof: "dpop-proof", | ||
| 400 | }); | ||
| 401 | await resetEnvironmentServiceForTests(); | ||
| 402 | }); | ||
| 403 | |||
| 404 | it("renews expired managed DPoP credentials through the relay", async () => { | ||
| 405 | const environmentId = EnvironmentId.make("environment-1"); | ||
| 406 | mockSavedRecords = [ | ||
| 407 | { | ||
| 408 | environmentId, | ||
| 409 | label: "Managed remote", | ||
| 410 | httpBaseUrl: "https://managed.example.com/", | ||
| 411 | wsBaseUrl: "wss://managed.example.com/", | ||
| 412 | createdAt: "2026-05-25T00:00:00.000Z", | ||
| 413 | lastConnectedAt: null, | ||
| 414 | relayManaged: { relayUrl: "https://relay.example.com" }, | ||
| 415 | }, | ||
| 416 | ]; | ||
| 417 | mockReadSavedEnvironmentCredential.mockResolvedValue({ | ||
| 418 | version: 1, | ||
| 419 | method: "dpop", | ||
| 420 | accessToken: "expired-access-token", | ||
| 421 | }); | ||
| 422 | mockFetchRemoteDpopSessionState | ||
| 423 | .mockReturnValueOnce( | ||
| 424 | Effect.fail( | ||
| 425 | decodeEnvironmentAuthInvalidError({ | ||
| 426 | _tag: "EnvironmentAuthInvalidError", | ||
| 427 | code: "auth_invalid", | ||
| 428 | reason: "invalid_credential", | ||
| 429 | traceId: "trace-auth-expired", | ||
| 430 | }), | ||
| 431 | ), | ||
| 432 | ) | ||
| 433 | .mockReturnValue(Effect.succeed({ authenticated: true, scopes: ["orchestration:read"] })); | ||
| 434 | mockReadManagedRelayClerkToken.mockResolvedValue("clerk-token"); | ||
| 435 | mockConnectManagedCloudEnvironment.mockReturnValue( | ||
| 436 | Effect.succeed({ | ||
| 437 | environmentId, | ||
| 438 | label: "Managed remote", | ||
| 439 | httpBaseUrl: "https://managed.example.com/", | ||
| 440 | wsBaseUrl: "wss://managed.example.com/", | ||
| 441 | relayUrl: "https://relay.example.com", | ||
| 442 | accessToken: "renewed-access-token", | ||
| 443 | }), | ||
| 444 | ); | ||
| 445 | |||
| 446 | const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = | ||
| 447 | await import("./service"); | ||
| 448 | await reconnectSavedEnvironment(environmentId); | ||
| 449 | |||
| 450 | expect(mockConnectManagedCloudEnvironment).toHaveBeenCalledWith({ | ||
| 451 | clerkToken: "clerk-token", | ||
| 452 | relayUrl: "https://relay.example.com", | ||
| 453 | environment: expect.objectContaining({ environmentId }), | ||
| 454 | }); | ||
| 455 | expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(environmentId, { | ||
| 456 | version: 1, | ||
| 457 | method: "dpop", | ||
| 458 | accessToken: "renewed-access-token", | ||
| 459 | }); | ||
| 460 | await resetEnvironmentServiceForTests(); | ||
| 461 | }); | ||
| 462 | |||
| 326 | it("removes an older ssh record when the same target returns a new environment id", async () => { | 463 | it("removes an older ssh record when the same target returns a new environment id", async () => { |
| 327 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); | 464 | mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); |
| 328 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ | 465 | mockFetchSshEnvironmentDescriptor.mockResolvedValue({ |
| ... | @@ -424,7 +561,7 @@ describe("addSavedEnvironment", () => { | ... | @@ -424,7 +561,7 @@ describe("addSavedEnvironment", () => { |
| 424 | reason: "invalid_credential", | 561 | reason: "invalid_credential", |
| 425 | traceId: "trace-auth-test", | 562 | traceId: "trace-auth-test", |
| 426 | }); | 563 | }); |
| 427 | mockFetchRemoteSessionState.mockRejectedValueOnce(authError); | 564 | mockFetchRemoteSessionState.mockReturnValueOnce(Effect.fail(authError)); |
| 428 | 565 | ||
| 429 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); | 566 | const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); |
| 430 | 567 | ||
| ... | @@ -747,9 +884,12 @@ describe("addSavedEnvironment", () => { | ... | @@ -747,9 +884,12 @@ describe("addSavedEnvironment", () => { |
| 747 | readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; | 884 | readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; |
| 748 | }) => void; | 885 | }) => void; |
| 749 | mockFetchRemoteSessionState.mockReturnValue( | 886 | mockFetchRemoteSessionState.mockReturnValue( |
| 750 | new Promise((resolve) => { | 887 | Effect.promise( |
| 751 | resolveSessionState = resolve; | 888 | () => |
| 752 | }), | 889 | new Promise((resolve) => { |
| 890 | resolveSessionState = resolve; | ||
| 891 | }), | ||
| 892 | ), | ||
| 753 | ); | 893 | ); |
| 754 | 894 | ||
| 755 | const { | 895 | const { |
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+8-1| ... | @@ -11,6 +11,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); | ... | @@ -11,6 +11,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); |
| 11 | const mockListSavedEnvironmentRecords = vi.fn(); | 11 | const mockListSavedEnvironmentRecords = vi.fn(); |
| 12 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); | 12 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); |
| 13 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | 13 | const mockReadSavedEnvironmentBearerToken = vi.fn(); |
| 14 | const mockReadSavedEnvironmentCredential = vi.fn(); | ||
| 14 | const mockGetSavedEnvironmentRecord = vi.fn(); | 15 | const mockGetSavedEnvironmentRecord = vi.fn(); |
| 15 | 16 | ||
| 16 | function MockWsTransport() { | 17 | function MockWsTransport() { |
| ... | @@ -31,7 +32,7 @@ vi.mock("../primary", () => ({ | ... | @@ -31,7 +32,7 @@ vi.mock("../primary", () => ({ |
| 31 | })); | 32 | })); |
| 32 | 33 | ||
| 33 | vi.mock("../../lib/runtime", () => ({ | 34 | vi.mock("../../lib/runtime", () => ({ |
| 34 | remoteHttpRuntime: { | 35 | webRuntime: { |
| 35 | runPromise: mockRemoteHttpRunPromise, | 36 | runPromise: mockRemoteHttpRunPromise, |
| 36 | }, | 37 | }, |
| 37 | })); | 38 | })); |
| ... | @@ -42,6 +43,7 @@ vi.mock("./catalog", () => ({ | ... | @@ -42,6 +43,7 @@ vi.mock("./catalog", () => ({ |
| 42 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | 43 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, |
| 43 | persistSavedEnvironmentRecord: vi.fn(), | 44 | persistSavedEnvironmentRecord: vi.fn(), |
| 44 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, | 45 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, |
| 46 | readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, | ||
| 45 | removeSavedEnvironmentBearerToken: vi.fn(), | 47 | removeSavedEnvironmentBearerToken: vi.fn(), |
| 46 | useSavedEnvironmentRegistryStore: { | 48 | useSavedEnvironmentRegistryStore: { |
| 47 | subscribe: mockSavedEnvironmentRegistrySubscribe, | 49 | subscribe: mockSavedEnvironmentRegistrySubscribe, |
| ... | @@ -61,6 +63,7 @@ vi.mock("./catalog", () => ({ | ... | @@ -61,6 +63,7 @@ vi.mock("./catalog", () => ({ |
| 61 | }, | 63 | }, |
| 62 | waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, | 64 | waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, |
| 63 | writeSavedEnvironmentBearerToken: vi.fn(), | 65 | writeSavedEnvironmentBearerToken: vi.fn(), |
| 66 | writeSavedEnvironmentCredential: vi.fn(), | ||
| 64 | })); | 67 | })); |
| 65 | 68 | ||
| 66 | vi.mock("./connection", async (importOriginal) => ({ | 69 | vi.mock("./connection", async (importOriginal) => ({ |
| ... | @@ -231,6 +234,10 @@ describe("saved environment startup", () => { | ... | @@ -231,6 +234,10 @@ describe("saved environment startup", () => { |
| 231 | mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); | 234 | mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); |
| 232 | mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); | 235 | mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); |
| 233 | mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token"); | 236 | mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token"); |
| 237 | mockReadSavedEnvironmentCredential.mockImplementation(async () => { | ||
| 238 | const token = await mockReadSavedEnvironmentBearerToken(); | ||
| 239 | return token ? { version: 1, method: "bearer", token } : null; | ||
| 240 | }); | ||
| 234 | mockCreateWsRpcClient.mockImplementation(() => createClient()); | 241 | mockCreateWsRpcClient.mockImplementation(() => createClient()); |
| 235 | mockCreateEnvironmentConnection.mockImplementation((input) => { | 242 | mockCreateEnvironmentConnection.mockImplementation((input) => { |
| 236 | if (input.kind === "saved") { | 243 | if (input.kind === "saved") { |
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+12-1| ... | @@ -18,6 +18,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); | ... | @@ -18,6 +18,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); |
| 18 | const mockListSavedEnvironmentRecords = vi.fn(); | 18 | const mockListSavedEnvironmentRecords = vi.fn(); |
| 19 | const mockGetSavedEnvironmentRecord = vi.fn(); | 19 | const mockGetSavedEnvironmentRecord = vi.fn(); |
| 20 | const mockReadSavedEnvironmentBearerToken = vi.fn(); | 20 | const mockReadSavedEnvironmentBearerToken = vi.fn(); |
| 21 | const mockReadSavedEnvironmentCredential = vi.fn(); | ||
| 21 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); | 22 | const mockSavedEnvironmentRegistrySubscribe = vi.fn(); |
| 22 | const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); | 23 | const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); |
| 23 | const mockFetchRemoteSessionState = vi.fn(); | 24 | const mockFetchRemoteSessionState = vi.fn(); |
| ... | @@ -35,7 +36,7 @@ vi.mock("../primary", () => ({ | ... | @@ -35,7 +36,7 @@ vi.mock("../primary", () => ({ |
| 35 | })); | 36 | })); |
| 36 | 37 | ||
| 37 | vi.mock("../../lib/runtime", () => ({ | 38 | vi.mock("../../lib/runtime", () => ({ |
| 38 | remoteHttpRuntime: { | 39 | webRuntime: { |
| 39 | runPromise: mockRemoteHttpRunPromise, | 40 | runPromise: mockRemoteHttpRunPromise, |
| 40 | }, | 41 | }, |
| 41 | })); | 42 | })); |
| ... | @@ -46,6 +47,7 @@ vi.mock("./catalog", () => ({ | ... | @@ -46,6 +47,7 @@ vi.mock("./catalog", () => ({ |
| 46 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, | 47 | listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, |
| 47 | persistSavedEnvironmentRecord: vi.fn(), | 48 | persistSavedEnvironmentRecord: vi.fn(), |
| 48 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, | 49 | readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, |
| 50 | readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, | ||
| 49 | removeSavedEnvironmentBearerToken: vi.fn(), | 51 | removeSavedEnvironmentBearerToken: vi.fn(), |
| 50 | useSavedEnvironmentRegistryStore: { | 52 | useSavedEnvironmentRegistryStore: { |
| 51 | subscribe: mockSavedEnvironmentRegistrySubscribe, | 53 | subscribe: mockSavedEnvironmentRegistrySubscribe, |
| ... | @@ -65,6 +67,7 @@ vi.mock("./catalog", () => ({ | ... | @@ -65,6 +67,7 @@ vi.mock("./catalog", () => ({ |
| 65 | }, | 67 | }, |
| 66 | waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, | 68 | waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, |
| 67 | writeSavedEnvironmentBearerToken: vi.fn(), | 69 | writeSavedEnvironmentBearerToken: vi.fn(), |
| 70 | writeSavedEnvironmentCredential: vi.fn(), | ||
| 68 | })); | 71 | })); |
| 69 | 72 | ||
| 70 | vi.mock("./connection", async (importOriginal) => ({ | 73 | vi.mock("./connection", async (importOriginal) => ({ |
| ... | @@ -78,6 +81,10 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { | ... | @@ -78,6 +81,10 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 78 | dispose: async () => undefined, | 81 | dispose: async () => undefined, |
| 79 | reconnect: async () => undefined, | 82 | reconnect: async () => undefined, |
| 80 | isHeartbeatFresh: () => false, | 83 | isHeartbeatFresh: () => false, |
| 84 | cloud: { | ||
| 85 | getRelayClientStatus: vi.fn(), | ||
| 86 | installRelayClient: vi.fn(), | ||
| 87 | }, | ||
| 81 | orchestration: { | 88 | orchestration: { |
| 82 | dispatchCommand: vi.fn(), | 89 | dispatchCommand: vi.fn(), |
| 83 | getTurnDiff: vi.fn(), | 90 | getTurnDiff: vi.fn(), |
| ... | @@ -301,6 +308,10 @@ describe("retainThreadDetailSubscription", () => { | ... | @@ -301,6 +308,10 @@ describe("retainThreadDetailSubscription", () => { |
| 301 | mockListSavedEnvironmentRecords.mockReturnValue([]); | 308 | mockListSavedEnvironmentRecords.mockReturnValue([]); |
| 302 | mockGetSavedEnvironmentRecord.mockReturnValue(null); | 309 | mockGetSavedEnvironmentRecord.mockReturnValue(null); |
| 303 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); | 310 | mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); |
| 311 | mockReadSavedEnvironmentCredential.mockImplementation(async () => { | ||
| 312 | const token = await mockReadSavedEnvironmentBearerToken(); | ||
| 313 | return token ? { version: 1, method: "bearer", token } : null; | ||
| 314 | }); | ||
| 304 | mockFetchRemoteSessionState.mockResolvedValue({ | 315 | mockFetchRemoteSessionState.mockResolvedValue({ |
| 305 | authenticated: true, | 316 | authenticated: true, |
| 306 | scopes: ["orchestration:read"], | 317 | scopes: ["orchestration:read"], |
apps/web/src/environments/runtime/service.ts+231-42| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | AuthEnvironmentScope, | 2 | AuthEnvironmentScope, |
| 3 | AuthStandardClientScopes, | ||
| 4 | type DesktopSshEnvironmentBootstrap, | 3 | type DesktopSshEnvironmentBootstrap, |
| 5 | type DesktopSshEnvironmentTarget, | 4 | type DesktopSshEnvironmentTarget, |
| 6 | type EnvironmentId, | 5 | type EnvironmentId, |
| ... | @@ -16,12 +15,17 @@ import { | ... | @@ -16,12 +15,17 @@ import { |
| 16 | type WsRpcClient, | 15 | type WsRpcClient, |
| 17 | bootstrapRemoteBearerSession, | 16 | bootstrapRemoteBearerSession, |
| 18 | fetchRemoteEnvironmentDescriptor, | 17 | fetchRemoteEnvironmentDescriptor, |
| 18 | fetchRemoteDpopSessionState, | ||
| 19 | fetchRemoteSessionState, | 19 | fetchRemoteSessionState, |
| 20 | type ManagedRelayDpopProofInput, | ||
| 21 | ManagedRelayDpopSigner, | ||
| 22 | resolveRemoteDpopWebSocketConnectionUrl, | ||
| 20 | resolveRemoteWebSocketConnectionUrl, | 23 | resolveRemoteWebSocketConnectionUrl, |
| 21 | } from "@t3tools/client-runtime"; | 24 | } from "@t3tools/client-runtime"; |
| 22 | 25 | ||
| 23 | import { type QueryClient } from "@tanstack/react-query"; | 26 | import { type QueryClient } from "@tanstack/react-query"; |
| 24 | import { Throttler } from "@tanstack/react-pacer"; | 27 | import { Throttler } from "@tanstack/react-pacer"; |
| 28 | import * as Effect from "effect/Effect"; | ||
| 25 | import * as Schema from "effect/Schema"; | 29 | import * as Schema from "effect/Schema"; |
| 26 | import { | 30 | import { |
| 27 | createKnownEnvironment, | 31 | createKnownEnvironment, |
| ... | @@ -40,21 +44,25 @@ import { ensureLocalApi } from "~/localApi"; | ... | @@ -40,21 +44,25 @@ import { ensureLocalApi } from "~/localApi"; |
| 40 | import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup"; | 44 | import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup"; |
| 41 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; | 45 | import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; |
| 42 | import { getPrimaryKnownEnvironment } from "../primary"; | 46 | import { getPrimaryKnownEnvironment } from "../primary"; |
| 43 | import { remoteHttpRuntime } from "../../lib/runtime"; | 47 | import { webRuntime } from "../../lib/runtime"; |
| 48 | import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment"; | ||
| 49 | import { readManagedRelayClerkToken } from "../../cloud/managedAuth"; | ||
| 44 | 50 | ||
| 45 | import { | 51 | import { |
| 46 | getSavedEnvironmentRecord, | 52 | getSavedEnvironmentRecord, |
| 47 | hasSavedEnvironmentRegistryHydrated, | 53 | hasSavedEnvironmentRegistryHydrated, |
| 48 | listSavedEnvironmentRecords, | 54 | listSavedEnvironmentRecords, |
| 49 | persistSavedEnvironmentRecord, | 55 | persistSavedEnvironmentRecord, |
| 50 | readSavedEnvironmentBearerToken, | 56 | readSavedEnvironmentCredential, |
| 51 | removeSavedEnvironmentBearerToken, | 57 | removeSavedEnvironmentBearerToken, |
| 52 | type SavedEnvironmentRecord, | 58 | type SavedEnvironmentRecord, |
| 59 | type SavedEnvironmentCredential, | ||
| 53 | toPersistedSavedEnvironmentRecord, | 60 | toPersistedSavedEnvironmentRecord, |
| 54 | useSavedEnvironmentRegistryStore, | 61 | useSavedEnvironmentRegistryStore, |
| 55 | useSavedEnvironmentRuntimeStore, | 62 | useSavedEnvironmentRuntimeStore, |
| 56 | waitForSavedEnvironmentRegistryHydration, | 63 | waitForSavedEnvironmentRegistryHydration, |
| 57 | writeSavedEnvironmentBearerToken, | 64 | writeSavedEnvironmentBearerToken, |
| 65 | writeSavedEnvironmentCredential, | ||
| 58 | } from "./catalog"; | 66 | } from "./catalog"; |
| 59 | import { | 67 | import { |
| 60 | createEnvironmentConnection, | 68 | createEnvironmentConnection, |
| ... | @@ -159,6 +167,12 @@ const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; | ... | @@ -159,6 +167,12 @@ const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; |
| 159 | const NOOP = () => undefined; | 167 | const NOOP = () => undefined; |
| 160 | const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; | 168 | const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; |
| 161 | 169 | ||
| 170 | const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) => | ||
| 171 | Effect.gen(function* () { | ||
| 172 | const signer = yield* ManagedRelayDpopSigner; | ||
| 173 | return yield* signer.createProof(input); | ||
| 174 | }); | ||
| 175 | |||
| 162 | function createDeferredPromise<T>() { | 176 | function createDeferredPromise<T>() { |
| 163 | let resolve: ((value: T) => void) | null = null; | 177 | let resolve: ((value: T) => void) | null = null; |
| 164 | const promise = new Promise<T>((nextResolve) => { | 178 | const promise = new Promise<T>((nextResolve) => { |
| ... | @@ -1156,7 +1170,7 @@ function createPrimaryEnvironmentClient( | ... | @@ -1156,7 +1170,7 @@ function createPrimaryEnvironmentClient( |
| 1156 | 1170 | ||
| 1157 | function createSavedEnvironmentClient( | 1171 | function createSavedEnvironmentClient( |
| 1158 | environmentId: EnvironmentId, | 1172 | environmentId: EnvironmentId, |
| 1159 | bearerToken: string, | 1173 | credentialRef: { current: SavedEnvironmentCredential }, |
| 1160 | ): WsRpcClient { | 1174 | ): WsRpcClient { |
| 1161 | useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); | 1175 | useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); |
| 1162 | 1176 | ||
| ... | @@ -1167,19 +1181,70 @@ function createSavedEnvironmentClient( | ... | @@ -1167,19 +1181,70 @@ function createSavedEnvironmentClient( |
| 1167 | if (!record) { | 1181 | if (!record) { |
| 1168 | throw new Error(`Saved environment ${environmentId} not found.`); | 1182 | throw new Error(`Saved environment ${environmentId} not found.`); |
| 1169 | } | 1183 | } |
| 1170 | return record.desktopSsh | 1184 | const credential = credentialRef.current; |
| 1171 | ? await resolveDesktopSshWebSocketConnectionUrl( | 1185 | if (record.desktopSsh) { |
| 1172 | record.wsBaseUrl, | 1186 | if (credential.method !== "bearer") { |
| 1173 | record.httpBaseUrl, | 1187 | throw new Error("SSH environments require bearer credentials."); |
| 1174 | bearerToken, | 1188 | } |
| 1175 | ) | 1189 | return await resolveDesktopSshWebSocketConnectionUrl( |
| 1176 | : await remoteHttpRuntime.runPromise( | 1190 | record.wsBaseUrl, |
| 1177 | resolveRemoteWebSocketConnectionUrl({ | 1191 | record.httpBaseUrl, |
| 1178 | wsBaseUrl: record.wsBaseUrl, | 1192 | credential.token, |
| 1179 | httpBaseUrl: record.httpBaseUrl, | 1193 | ); |
| 1180 | bearerToken, | 1194 | } |
| 1181 | }), | 1195 | if (credential.method === "dpop") { |
| 1196 | try { | ||
| 1197 | return await webRuntime.runPromise( | ||
| 1198 | createManagedRelayDpopProof({ | ||
| 1199 | method: "POST", | ||
| 1200 | url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(), | ||
| 1201 | accessToken: credential.accessToken, | ||
| 1202 | }).pipe( | ||
| 1203 | Effect.flatMap((proof) => | ||
| 1204 | resolveRemoteDpopWebSocketConnectionUrl({ | ||
| 1205 | wsBaseUrl: record.wsBaseUrl, | ||
| 1206 | httpBaseUrl: record.httpBaseUrl, | ||
| 1207 | accessToken: credential.accessToken, | ||
| 1208 | dpopProof: proof, | ||
| 1209 | }), | ||
| 1210 | ), | ||
| 1211 | ), | ||
| 1212 | ); | ||
| 1213 | } catch (error) { | ||
| 1214 | if (!isEnvironmentAuthInvalidError(error)) { | ||
| 1215 | throw error; | ||
| 1216 | } | ||
| 1217 | const renewed = await renewManagedRelayCredential(record); | ||
| 1218 | if (!renewed || renewed.credential.method !== "dpop") { | ||
| 1219 | throw error; | ||
| 1220 | } | ||
| 1221 | const renewedCredential = renewed.credential; | ||
| 1222 | credentialRef.current = renewedCredential; | ||
| 1223 | return await webRuntime.runPromise( | ||
| 1224 | createManagedRelayDpopProof({ | ||
| 1225 | method: "POST", | ||
| 1226 | url: new URL("/api/auth/websocket-ticket", renewed.record.httpBaseUrl).toString(), | ||
| 1227 | accessToken: renewedCredential.accessToken, | ||
| 1228 | }).pipe( | ||
| 1229 | Effect.flatMap((proof) => | ||
| 1230 | resolveRemoteDpopWebSocketConnectionUrl({ | ||
| 1231 | wsBaseUrl: renewed.record.wsBaseUrl, | ||
| 1232 | httpBaseUrl: renewed.record.httpBaseUrl, | ||
| 1233 | accessToken: renewedCredential.accessToken, | ||
| 1234 | dpopProof: proof, | ||
| 1235 | }), | ||
| 1236 | ), | ||
| 1237 | ), | ||
| 1182 | ); | 1238 | ); |
| 1239 | } | ||
| 1240 | } | ||
| 1241 | return await webRuntime.runPromise( | ||
| 1242 | resolveRemoteWebSocketConnectionUrl({ | ||
| 1243 | wsBaseUrl: record.wsBaseUrl, | ||
| 1244 | httpBaseUrl: record.httpBaseUrl, | ||
| 1245 | bearerToken: credential.token, | ||
| 1246 | }), | ||
| 1247 | ); | ||
| 1183 | }, | 1248 | }, |
| 1184 | { | 1249 | { |
| 1185 | getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, | 1250 | getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, |
| ... | @@ -1221,7 +1286,7 @@ function createSavedEnvironmentClient( | ... | @@ -1221,7 +1286,7 @@ function createSavedEnvironmentClient( |
| 1221 | 1286 | ||
| 1222 | async function refreshSavedEnvironmentMetadata( | 1287 | async function refreshSavedEnvironmentMetadata( |
| 1223 | environmentId: EnvironmentId, | 1288 | environmentId: EnvironmentId, |
| 1224 | bearerToken: string, | 1289 | credential: SavedEnvironmentCredential, |
| 1225 | client: WsRpcClient, | 1290 | client: WsRpcClient, |
| 1226 | scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null, | 1291 | scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null, |
| 1227 | configHint?: ServerConfig | null, | 1292 | configHint?: ServerConfig | null, |
| ... | @@ -1234,13 +1299,31 @@ async function refreshSavedEnvironmentMetadata( | ... | @@ -1234,13 +1299,31 @@ async function refreshSavedEnvironmentMetadata( |
| 1234 | const [serverConfig, sessionState] = await Promise.all([ | 1299 | const [serverConfig, sessionState] = await Promise.all([ |
| 1235 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), | 1300 | configHint ? Promise.resolve(configHint) : client.server.getConfig(), |
| 1236 | record.desktopSsh | 1301 | record.desktopSsh |
| 1237 | ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken) | 1302 | ? credential.method === "bearer" |
| 1238 | : remoteHttpRuntime.runPromise( | 1303 | ? fetchDesktopSshSessionState(record.httpBaseUrl, credential.token) |
| 1239 | fetchRemoteSessionState({ | 1304 | : Promise.reject(new Error("SSH environments require bearer credentials.")) |
| 1240 | httpBaseUrl: record.httpBaseUrl, | 1305 | : credential.method === "dpop" |
| 1241 | bearerToken, | 1306 | ? webRuntime.runPromise( |
| 1242 | }), | 1307 | createManagedRelayDpopProof({ |
| 1243 | ), | 1308 | method: "GET", |
| 1309 | url: new URL("/api/auth/session", record.httpBaseUrl).toString(), | ||
| 1310 | accessToken: credential.accessToken, | ||
| 1311 | }).pipe( | ||
| 1312 | Effect.flatMap((proof) => | ||
| 1313 | fetchRemoteDpopSessionState({ | ||
| 1314 | httpBaseUrl: record.httpBaseUrl, | ||
| 1315 | accessToken: credential.accessToken, | ||
| 1316 | dpopProof: proof, | ||
| 1317 | }), | ||
| 1318 | ), | ||
| 1319 | ), | ||
| 1320 | ) | ||
| 1321 | : webRuntime.runPromise( | ||
| 1322 | fetchRemoteSessionState({ | ||
| 1323 | httpBaseUrl: record.httpBaseUrl, | ||
| 1324 | bearerToken: credential.token, | ||
| 1325 | }), | ||
| 1326 | ), | ||
| 1244 | ]); | 1327 | ]); |
| 1245 | 1328 | ||
| 1246 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 1329 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| ... | @@ -1254,6 +1337,52 @@ async function refreshSavedEnvironmentMetadata( | ... | @@ -1254,6 +1337,52 @@ async function refreshSavedEnvironmentMetadata( |
| 1254 | .rename(record.environmentId, serverConfig.environment.label); | 1337 | .rename(record.environmentId, serverConfig.environment.label); |
| 1255 | } | 1338 | } |
| 1256 | 1339 | ||
| 1340 | async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{ | ||
| 1341 | readonly record: SavedEnvironmentRecord; | ||
| 1342 | readonly credential: SavedEnvironmentCredential; | ||
| 1343 | } | null> { | ||
| 1344 | if (!record.relayManaged) { | ||
| 1345 | return null; | ||
| 1346 | } | ||
| 1347 | const clerkToken = await readManagedRelayClerkToken(); | ||
| 1348 | if (!clerkToken) { | ||
| 1349 | return null; | ||
| 1350 | } | ||
| 1351 | const connected = await webRuntime.runPromise( | ||
| 1352 | connectManagedCloudEnvironment({ | ||
| 1353 | clerkToken, | ||
| 1354 | relayUrl: record.relayManaged.relayUrl, | ||
| 1355 | environment: { | ||
| 1356 | environmentId: record.environmentId, | ||
| 1357 | label: record.label, | ||
| 1358 | linkedAt: record.createdAt, | ||
| 1359 | endpoint: { | ||
| 1360 | httpBaseUrl: record.httpBaseUrl, | ||
| 1361 | wsBaseUrl: record.wsBaseUrl, | ||
| 1362 | providerKind: "cloudflare_tunnel", | ||
| 1363 | }, | ||
| 1364 | }, | ||
| 1365 | }), | ||
| 1366 | ); | ||
| 1367 | const nextRecord: SavedEnvironmentRecord = { | ||
| 1368 | ...record, | ||
| 1369 | label: connected.label, | ||
| 1370 | httpBaseUrl: connected.httpBaseUrl, | ||
| 1371 | wsBaseUrl: connected.wsBaseUrl, | ||
| 1372 | }; | ||
| 1373 | const credential: SavedEnvironmentCredential = { | ||
| 1374 | version: 1, | ||
| 1375 | method: "dpop", | ||
| 1376 | accessToken: connected.accessToken, | ||
| 1377 | }; | ||
| 1378 | await persistSavedEnvironmentRecord(nextRecord); | ||
| 1379 | if (!(await writeSavedEnvironmentCredential(nextRecord.environmentId, credential))) { | ||
| 1380 | throw new Error("Unable to persist refreshed managed environment credentials."); | ||
| 1381 | } | ||
| 1382 | useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); | ||
| 1383 | return { record: nextRecord, credential }; | ||
| 1384 | } | ||
| 1385 | |||
| 1257 | function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { | 1386 | function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { |
| 1258 | const existing = environmentConnections.get(connection.environmentId); | 1387 | const existing = environmentConnections.get(connection.environmentId); |
| 1259 | if (existing && existing !== connection) { | 1388 | if (existing && existing !== connection) { |
| ... | @@ -1320,8 +1449,10 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1320,8 +1449,10 @@ async function ensureSavedEnvironmentConnection( |
| 1320 | options?: { | 1449 | options?: { |
| 1321 | readonly client?: WsRpcClient; | 1450 | readonly client?: WsRpcClient; |
| 1322 | readonly bearerToken?: string; | 1451 | readonly bearerToken?: string; |
| 1452 | readonly credential?: SavedEnvironmentCredential; | ||
| 1323 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null; | 1453 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null; |
| 1324 | readonly serverConfig?: ServerConfig | null; | 1454 | readonly serverConfig?: ServerConfig | null; |
| 1455 | readonly allowManagedRenewal?: boolean; | ||
| 1325 | }, | 1456 | }, |
| 1326 | ): Promise<EnvironmentConnection> { | 1457 | ): Promise<EnvironmentConnection> { |
| 1327 | const existing = environmentConnections.get(record.environmentId); | 1458 | const existing = environmentConnections.get(record.environmentId); |
| ... | @@ -1340,14 +1471,17 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1340,14 +1471,17 @@ async function ensureSavedEnvironmentConnection( |
| 1340 | promise: Promise.resolve().then(async () => { | 1471 | promise: Promise.resolve().then(async () => { |
| 1341 | let activeRecord = record; | 1472 | let activeRecord = record; |
| 1342 | let scopeHint = options?.scopes ?? null; | 1473 | let scopeHint = options?.scopes ?? null; |
| 1343 | let bearerToken = | 1474 | let credential = |
| 1344 | options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); | 1475 | options?.credential ?? |
| 1345 | if (!bearerToken) { | 1476 | (options?.bearerToken |
| 1477 | ? ({ version: 1, method: "bearer", token: options.bearerToken } as const) | ||
| 1478 | : await readSavedEnvironmentCredential(record.environmentId)); | ||
| 1479 | if (!credential) { | ||
| 1346 | if (record.desktopSsh) { | 1480 | if (record.desktopSsh) { |
| 1347 | const issued = await issueDesktopSshBearerSession(record); | 1481 | const issued = await issueDesktopSshBearerSession(record); |
| 1348 | activeRecord = issued.record; | 1482 | activeRecord = issued.record; |
| 1349 | bearerToken = issued.bearerToken; | 1483 | credential = { version: 1, method: "bearer", token: issued.bearerToken }; |
| 1350 | scopeHint = [...AuthStandardClientScopes]; | 1484 | scopeHint = issued.scopes; |
| 1351 | } else { | 1485 | } else { |
| 1352 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { | 1486 | useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { |
| 1353 | authState: "requires-auth", | 1487 | authState: "requires-auth", |
| ... | @@ -1363,10 +1497,10 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1363,10 +1497,10 @@ async function ensureSavedEnvironmentConnection( |
| 1363 | activeRecord = prepared.record; | 1497 | activeRecord = prepared.record; |
| 1364 | } | 1498 | } |
| 1365 | 1499 | ||
| 1366 | const activeBearerToken = bearerToken; | 1500 | const activeCredential = { current: credential }; |
| 1367 | const client = | 1501 | const client = |
| 1368 | options?.client ?? | 1502 | options?.client ?? |
| 1369 | createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken); | 1503 | createSavedEnvironmentClient(activeRecord.environmentId, activeCredential); |
| 1370 | const initialConfigSnapshot = createDeferredPromise<ServerConfig>(); | 1504 | const initialConfigSnapshot = createDeferredPromise<ServerConfig>(); |
| 1371 | const knownEnvironment = createKnownEnvironment({ | 1505 | const knownEnvironment = createKnownEnvironment({ |
| 1372 | id: activeRecord.environmentId, | 1506 | id: activeRecord.environmentId, |
| ... | @@ -1387,7 +1521,7 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1387,7 +1521,7 @@ async function ensureSavedEnvironmentConnection( |
| 1387 | refreshMetadata: async () => { | 1521 | refreshMetadata: async () => { |
| 1388 | await refreshSavedEnvironmentMetadata( | 1522 | await refreshSavedEnvironmentMetadata( |
| 1389 | activeRecord.environmentId, | 1523 | activeRecord.environmentId, |
| 1390 | activeBearerToken, | 1524 | activeCredential.current, |
| 1391 | client, | 1525 | client, |
| 1392 | ); | 1526 | ); |
| 1393 | }, | 1527 | }, |
| ... | @@ -1416,7 +1550,7 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1416,7 +1550,7 @@ async function ensureSavedEnvironmentConnection( |
| 1416 | )); | 1550 | )); |
| 1417 | await refreshSavedEnvironmentMetadata( | 1551 | await refreshSavedEnvironmentMetadata( |
| 1418 | activeRecord.environmentId, | 1552 | activeRecord.environmentId, |
| 1419 | activeBearerToken, | 1553 | activeCredential.current, |
| 1420 | client, | 1554 | client, |
| 1421 | scopeHint, | 1555 | scopeHint, |
| 1422 | initialServerConfig, | 1556 | initialServerConfig, |
| ... | @@ -1429,20 +1563,41 @@ async function ensureSavedEnvironmentConnection( | ... | @@ -1429,20 +1563,41 @@ async function ensureSavedEnvironmentConnection( |
| 1429 | throw error; | 1563 | throw error; |
| 1430 | } | 1564 | } |
| 1431 | if (!activeRecord.desktopSsh) { | 1565 | if (!activeRecord.desktopSsh) { |
| 1566 | if ( | ||
| 1567 | activeCredential.current.method === "dpop" && | ||
| 1568 | options?.allowManagedRenewal !== false | ||
| 1569 | ) { | ||
| 1570 | const renewed = await renewManagedRelayCredential(activeRecord); | ||
| 1571 | if (renewed) { | ||
| 1572 | await connection.dispose().catch(() => undefined); | ||
| 1573 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); | ||
| 1574 | return await ensureSavedEnvironmentConnection(renewed.record, { | ||
| 1575 | credential: renewed.credential, | ||
| 1576 | scopes: scopeHint, | ||
| 1577 | serverConfig: options?.serverConfig ?? null, | ||
| 1578 | allowManagedRenewal: false, | ||
| 1579 | }); | ||
| 1580 | } | ||
| 1581 | } | ||
| 1432 | await removeSavedEnvironmentBearerToken(activeRecord.environmentId); | 1582 | await removeSavedEnvironmentBearerToken(activeRecord.environmentId); |
| 1433 | throw new Error("Saved environment credential expired. Pair it again.", { | 1583 | throw new Error( |
| 1434 | cause: error, | 1584 | activeCredential.current.method === "dpop" |
| 1435 | }); | 1585 | ? "Managed tunnel credential expired. Connect it again from T3 Cloud." |
| 1586 | : "Saved environment credential expired. Pair it again.", | ||
| 1587 | { | ||
| 1588 | cause: error, | ||
| 1589 | }, | ||
| 1590 | ); | ||
| 1436 | } | 1591 | } |
| 1437 | 1592 | ||
| 1438 | const issued = await issueDesktopSshBearerSession(activeRecord); | 1593 | const issued = await issueDesktopSshBearerSession(activeRecord); |
| 1439 | activeRecord = issued.record; | 1594 | activeRecord = issued.record; |
| 1440 | bearerToken = issued.bearerToken; | 1595 | credential = { version: 1, method: "bearer", token: issued.bearerToken }; |
| 1441 | scopeHint = [...AuthStandardClientScopes]; | 1596 | scopeHint = issued.scopes; |
| 1442 | await connection.dispose().catch(() => undefined); | 1597 | await connection.dispose().catch(() => undefined); |
| 1443 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); | 1598 | pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); |
| 1444 | return await ensureSavedEnvironmentConnection(activeRecord, { | 1599 | return await ensureSavedEnvironmentConnection(activeRecord, { |
| 1445 | bearerToken, | 1600 | credential, |
| 1446 | scopes: scopeHint, | 1601 | scopes: scopeHint, |
| 1447 | serverConfig: options?.serverConfig ?? null, | 1602 | serverConfig: options?.serverConfig ?? null, |
| 1448 | }); | 1603 | }); |
| ... | @@ -1647,7 +1802,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P | ... | @@ -1647,7 +1802,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P |
| 1647 | await removeConnection(environmentId).catch(() => false); | 1802 | await removeConnection(environmentId).catch(() => false); |
| 1648 | await ensureSavedEnvironmentConnection(issued.record, { | 1803 | await ensureSavedEnvironmentConnection(issued.record, { |
| 1649 | bearerToken: issued.bearerToken, | 1804 | bearerToken: issued.bearerToken, |
| 1650 | scopes: [...AuthStandardClientScopes], | 1805 | scopes: issued.scopes, |
| 1651 | }); | 1806 | }); |
| 1652 | return; | 1807 | return; |
| 1653 | } catch (recoveryError) { | 1808 | } catch (recoveryError) { |
| ... | @@ -1686,7 +1841,7 @@ export async function addSavedEnvironment(input: { | ... | @@ -1686,7 +1841,7 @@ export async function addSavedEnvironment(input: { |
| 1686 | }); | 1841 | }); |
| 1687 | const descriptor = input.desktopSsh | 1842 | const descriptor = input.desktopSsh |
| 1688 | ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) | 1843 | ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) |
| 1689 | : await remoteHttpRuntime.runPromise( | 1844 | : await webRuntime.runPromise( |
| 1690 | fetchRemoteEnvironmentDescriptor({ | 1845 | fetchRemoteEnvironmentDescriptor({ |
| 1691 | httpBaseUrl: resolvedTarget.httpBaseUrl, | 1846 | httpBaseUrl: resolvedTarget.httpBaseUrl, |
| 1692 | }), | 1847 | }), |
| ... | @@ -1701,7 +1856,7 @@ export async function addSavedEnvironment(input: { | ... | @@ -1701,7 +1856,7 @@ export async function addSavedEnvironment(input: { |
| 1701 | 1856 | ||
| 1702 | const bearerSession = input.desktopSsh | 1857 | const bearerSession = input.desktopSsh |
| 1703 | ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) | 1858 | ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) |
| 1704 | : await remoteHttpRuntime.runPromise( | 1859 | : await webRuntime.runPromise( |
| 1705 | bootstrapRemoteBearerSession({ | 1860 | bootstrapRemoteBearerSession({ |
| 1706 | httpBaseUrl: resolvedTarget.httpBaseUrl, | 1861 | httpBaseUrl: resolvedTarget.httpBaseUrl, |
| 1707 | credential: resolvedTarget.credential, | 1862 | credential: resolvedTarget.credential, |
| ... | @@ -1741,6 +1896,40 @@ export async function addSavedEnvironment(input: { | ... | @@ -1741,6 +1896,40 @@ export async function addSavedEnvironment(input: { |
| 1741 | return record; | 1896 | return record; |
| 1742 | } | 1897 | } |
| 1743 | 1898 | ||
| 1899 | export async function addManagedRelayEnvironment(input: { | ||
| 1900 | readonly environmentId: EnvironmentId; | ||
| 1901 | readonly label: string; | ||
| 1902 | readonly httpBaseUrl: string; | ||
| 1903 | readonly wsBaseUrl: string; | ||
| 1904 | readonly relayUrl: string; | ||
| 1905 | readonly accessToken: string; | ||
| 1906 | }): Promise<SavedEnvironmentRecord> { | ||
| 1907 | const existingRecord = getSavedEnvironmentRecord(input.environmentId); | ||
| 1908 | const record: SavedEnvironmentRecord = { | ||
| 1909 | environmentId: input.environmentId, | ||
| 1910 | label: input.label.trim() || existingRecord?.label || "Managed environment", | ||
| 1911 | httpBaseUrl: input.httpBaseUrl, | ||
| 1912 | wsBaseUrl: input.wsBaseUrl, | ||
| 1913 | createdAt: existingRecord?.createdAt ?? isoNow(), | ||
| 1914 | lastConnectedAt: isoNow(), | ||
| 1915 | relayManaged: { relayUrl: input.relayUrl }, | ||
| 1916 | }; | ||
| 1917 | const credential: SavedEnvironmentCredential = { | ||
| 1918 | version: 1, | ||
| 1919 | method: "dpop", | ||
| 1920 | accessToken: input.accessToken, | ||
| 1921 | }; | ||
| 1922 | |||
| 1923 | await persistSavedEnvironmentRecord(record); | ||
| 1924 | if (!(await writeSavedEnvironmentCredential(record.environmentId, credential))) { | ||
| 1925 | throw new Error("Unable to persist managed environment credentials."); | ||
| 1926 | } | ||
| 1927 | useSavedEnvironmentRegistryStore.getState().upsert(record); | ||
| 1928 | await removeConnection(record.environmentId).catch(() => false); | ||
| 1929 | await ensureSavedEnvironmentConnection(record, { credential }); | ||
| 1930 | return record; | ||
| 1931 | } | ||
| 1932 | |||
| 1744 | export async function connectDesktopSshEnvironment( | 1933 | export async function connectDesktopSshEnvironment( |
| 1745 | target: DesktopSshEnvironmentTarget, | 1934 | target: DesktopSshEnvironmentTarget, |
| 1746 | options?: { label?: string }, | 1935 | options?: { label?: string }, |
apps/web/src/hooks/useTheme.ts+1-1| ... | @@ -109,7 +109,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { | ... | @@ -109,7 +109,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { |
| 109 | function syncDesktopTheme(theme: Theme) { | 109 | function syncDesktopTheme(theme: Theme) { |
| 110 | if (typeof window === "undefined") return; | 110 | if (typeof window === "undefined") return; |
| 111 | const bridge = window.desktopBridge; | 111 | const bridge = window.desktopBridge; |
| 112 | if (!bridge || lastDesktopTheme === theme) { | 112 | if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { |
| 113 | return; | 113 | return; |
| 114 | } | 114 | } |
| 115 | 115 |
apps/web/src/hostedPairing.ts+1-1| ... | @@ -10,7 +10,7 @@ export interface HostedPairingRequest { | ... | @@ -10,7 +10,7 @@ export interface HostedPairingRequest { |
| 10 | 10 | ||
| 11 | export type HostedAppChannel = "latest" | "nightly"; | 11 | export type HostedAppChannel = "latest" | "nightly"; |
| 12 | 12 | ||
| 13 | function configuredHostedAppUrl(): string { | 13 | export function configuredHostedAppUrl(): string { |
| 14 | return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; | 14 | return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; |
| 15 | } | 15 | } |
| 16 | 16 |
apps/web/src/lib/runtime.ts+23-2| ... | @@ -8,15 +8,26 @@ import { | ... | @@ -8,15 +8,26 @@ import { |
| 8 | PrimaryEnvironmentHttpClient, | 8 | PrimaryEnvironmentHttpClient, |
| 9 | primaryEnvironmentHttpClientLive, | 9 | primaryEnvironmentHttpClientLive, |
| 10 | } from "../environments/primary/httpClient"; | 10 | } from "../environments/primary/httpClient"; |
| 11 | import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; | ||
| 11 | 12 | ||
| 12 | export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch)); | 13 | import { browserCryptoLayer } from "../cloud/dpop"; |
| 14 | import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer"; | ||
| 15 | import { resolveCloudPublicConfig } from "../cloud/publicConfig"; | ||
| 16 | |||
| 17 | function configuredRelayUrl(): string { | ||
| 18 | return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; | ||
| 19 | } | ||
| 20 | |||
| 21 | const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch); | ||
| 22 | |||
| 23 | export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer); | ||
| 13 | 24 | ||
| 14 | const primaryHttpRuntime = ManagedRuntime.make( | 25 | const primaryHttpRuntime = ManagedRuntime.make( |
| 15 | primaryEnvironmentHttpClientLive.pipe( | 26 | primaryEnvironmentHttpClientLive.pipe( |
| 16 | Layer.provide( | 27 | Layer.provide( |
| 17 | Layer.mergeAll( | 28 | Layer.mergeAll( |
| 18 | remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), | 29 | remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)), |
| 19 | Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }), | 30 | Layer.succeed(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), |
| 20 | ), | 31 | ), |
| 21 | ), | 32 | ), |
| 22 | ), | 33 | ), |
| ... | @@ -37,3 +48,13 @@ export const runPrimaryHttp = <A, E>(effect: Effect.Effect<A, E, PrimaryEnvironm | ... | @@ -37,3 +48,13 @@ export const runPrimaryHttp = <A, E>(effect: Effect.Effect<A, E, PrimaryEnvironm |
| 37 | export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void { | 48 | export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void { |
| 38 | primaryHttpRunner = runner ?? livePrimaryHttpRunner; | 49 | primaryHttpRunner = runner ?? livePrimaryHttpRunner; |
| 39 | } | 50 | } |
| 51 | |||
| 52 | export const webRuntime = ManagedRuntime.make( | ||
| 53 | Layer.mergeAll( | ||
| 54 | webHttpClientLayer, | ||
| 55 | browserCryptoLayer, | ||
| 56 | webManagedRelayClientLayer(configuredRelayUrl()).pipe( | ||
| 57 | Layer.provide(Layer.mergeAll(webHttpClientLayer, browserCryptoLayer)), | ||
| 58 | ), | ||
| 59 | ), | ||
| 60 | ); |
apps/web/src/localApi.test.ts+12| ... | @@ -234,6 +234,18 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg | ... | @@ -234,6 +234,18 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg |
| 234 | setTheme: async () => undefined, | 234 | setTheme: async () => undefined, |
| 235 | showContextMenu: async () => null, | 235 | showContextMenu: async () => null, |
| 236 | openExternal: async () => true, | 236 | openExternal: async () => true, |
| 237 | createCloudAuthRequest: async () => "t3code-dev://auth/callback?t3_state=test", | ||
| 238 | getCloudAuthToken: async () => null, | ||
| 239 | setCloudAuthToken: async () => true, | ||
| 240 | clearCloudAuthToken: async () => undefined, | ||
| 241 | fetchCloudAuth: async () => ({ | ||
| 242 | ok: true, | ||
| 243 | status: 200, | ||
| 244 | statusText: "OK", | ||
| 245 | headers: {}, | ||
| 246 | body: "", | ||
| 247 | }), | ||
| 248 | onCloudAuthCallback: () => () => undefined, | ||
| 237 | onMenuAction: () => () => undefined, | 249 | onMenuAction: () => () => undefined, |
| 238 | getUpdateState: async () => { | 250 | getUpdateState: async () => { |
| 239 | throw new Error("getUpdateState not implemented in test"); | 251 | throw new Error("getUpdateState not implemented in test"); |
apps/web/src/main.tsx+22-1| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import React from "react"; | 1 | import React from "react"; |
| 2 | import ReactDOM from "react-dom/client"; | 2 | import ReactDOM from "react-dom/client"; |
| 3 | import { ClerkProvider } from "@clerk/react"; | ||
| 3 | import { RouterProvider } from "@tanstack/react-router"; | 4 | import { RouterProvider } from "@tanstack/react-router"; |
| 4 | import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; | 5 | import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; |
| 5 | 6 | ||
| ... | @@ -7,6 +8,9 @@ import "@xterm/xterm/css/xterm.css"; | ... | @@ -7,6 +8,9 @@ import "@xterm/xterm/css/xterm.css"; |
| 7 | import "./index.css"; | 8 | import "./index.css"; |
| 8 | 9 | ||
| 9 | import { isElectron } from "./env"; | 10 | import { isElectron } from "./env"; |
| 11 | import { DesktopClerkProvider } from "./cloud/desktopClerk"; | ||
| 12 | import { ManagedRelayAuthProvider } from "./cloud/managedAuth"; | ||
| 13 | import { hasCloudPublicConfig } from "./cloud/publicConfig"; | ||
| 10 | import { getRouter } from "./router"; | 14 | import { getRouter } from "./router"; |
| 11 | import { APP_DISPLAY_NAME } from "./branding"; | 15 | import { APP_DISPLAY_NAME } from "./branding"; |
| 12 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; | 16 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; |
| ... | @@ -22,8 +26,25 @@ if (isElectron) { | ... | @@ -22,8 +26,25 @@ if (isElectron) { |
| 22 | 26 | ||
| 23 | document.title = APP_DISPLAY_NAME; | 27 | document.title = APP_DISPLAY_NAME; |
| 24 | 28 | ||
| 29 | const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; | ||
| 30 | const cloudWaitlistUrl = "/settings/cloud"; | ||
| 31 | |||
| 32 | const app = <RouterProvider router={router} />; | ||
| 33 | |||
| 25 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | 34 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( |
| 26 | <React.StrictMode> | 35 | <React.StrictMode> |
| 27 | <RouterProvider router={router} /> | 36 | {clerkPublishableKey && hasCloudPublicConfig() ? ( |
| 37 | isElectron ? ( | ||
| 38 | <DesktopClerkProvider publishableKey={clerkPublishableKey}> | ||
| 39 | <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider> | ||
| 40 | </DesktopClerkProvider> | ||
| 41 | ) : ( | ||
| 42 | <ClerkProvider publishableKey={clerkPublishableKey} waitlistUrl={cloudWaitlistUrl}> | ||
| 43 | <ManagedRelayAuthProvider>{app}</ManagedRelayAuthProvider> | ||
| 44 | </ClerkProvider> | ||
| 45 | ) | ||
| 46 | ) : ( | ||
| 47 | app | ||
| 48 | )} | ||
| 28 | </React.StrictMode>, | 49 | </React.StrictMode>, |
| 29 | ); | 50 | ); |
apps/web/src/routeTree.gen.ts+21| ... | @@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi | ... | @@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi |
| 19 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' | 19 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 20 | import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' | 20 | import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' |
| 21 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' | 21 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' |
| 22 | import { Route as SettingsCloudRouteImport } from './routes/settings.cloud' | ||
| 22 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' | 23 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| 23 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' | 24 | import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' |
| 24 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' | 25 | import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' |
| ... | @@ -72,6 +73,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ | ... | @@ -72,6 +73,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ |
| 72 | path: '/connections', | 73 | path: '/connections', |
| 73 | getParentRoute: () => SettingsRoute, | 74 | getParentRoute: () => SettingsRoute, |
| 74 | } as any) | 75 | } as any) |
| 76 | const SettingsCloudRoute = SettingsCloudRouteImport.update({ | ||
| 77 | id: '/cloud', | ||
| 78 | path: '/cloud', | ||
| 79 | getParentRoute: () => SettingsRoute, | ||
| 80 | } as any) | ||
| 75 | const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ | 81 | const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ |
| 76 | id: '/archived', | 82 | id: '/archived', |
| 77 | path: '/archived', | 83 | path: '/archived', |
| ... | @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { | ... | @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { |
| 94 | '/pair': typeof PairRoute | 100 | '/pair': typeof PairRoute |
| 95 | '/settings': typeof SettingsRouteWithChildren | 101 | '/settings': typeof SettingsRouteWithChildren |
| 96 | '/settings/archived': typeof SettingsArchivedRoute | 102 | '/settings/archived': typeof SettingsArchivedRoute |
| 103 | '/settings/cloud': typeof SettingsCloudRoute | ||
| 97 | '/settings/connections': typeof SettingsConnectionsRoute | 104 | '/settings/connections': typeof SettingsConnectionsRoute |
| 98 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute | 105 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 99 | '/settings/general': typeof SettingsGeneralRoute | 106 | '/settings/general': typeof SettingsGeneralRoute |
| ... | @@ -107,6 +114,7 @@ export interface FileRoutesByTo { | ... | @@ -107,6 +114,7 @@ export interface FileRoutesByTo { |
| 107 | '/pair': typeof PairRoute | 114 | '/pair': typeof PairRoute |
| 108 | '/settings': typeof SettingsRouteWithChildren | 115 | '/settings': typeof SettingsRouteWithChildren |
| 109 | '/settings/archived': typeof SettingsArchivedRoute | 116 | '/settings/archived': typeof SettingsArchivedRoute |
| 117 | '/settings/cloud': typeof SettingsCloudRoute | ||
| 110 | '/settings/connections': typeof SettingsConnectionsRoute | 118 | '/settings/connections': typeof SettingsConnectionsRoute |
| 111 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute | 119 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 112 | '/settings/general': typeof SettingsGeneralRoute | 120 | '/settings/general': typeof SettingsGeneralRoute |
| ... | @@ -123,6 +131,7 @@ export interface FileRoutesById { | ... | @@ -123,6 +131,7 @@ export interface FileRoutesById { |
| 123 | '/pair': typeof PairRoute | 131 | '/pair': typeof PairRoute |
| 124 | '/settings': typeof SettingsRouteWithChildren | 132 | '/settings': typeof SettingsRouteWithChildren |
| 125 | '/settings/archived': typeof SettingsArchivedRoute | 133 | '/settings/archived': typeof SettingsArchivedRoute |
| 134 | '/settings/cloud': typeof SettingsCloudRoute | ||
| 126 | '/settings/connections': typeof SettingsConnectionsRoute | 135 | '/settings/connections': typeof SettingsConnectionsRoute |
| 127 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute | 136 | '/settings/diagnostics': typeof SettingsDiagnosticsRoute |
| 128 | '/settings/general': typeof SettingsGeneralRoute | 137 | '/settings/general': typeof SettingsGeneralRoute |
| ... | @@ -140,6 +149,7 @@ export interface FileRouteTypes { | ... | @@ -140,6 +149,7 @@ export interface FileRouteTypes { |
| 140 | | '/pair' | 149 | | '/pair' |
| 141 | | '/settings' | 150 | | '/settings' |
| 142 | | '/settings/archived' | 151 | | '/settings/archived' |
| 152 | | '/settings/cloud' | ||
| 143 | | '/settings/connections' | 153 | | '/settings/connections' |
| 144 | | '/settings/diagnostics' | 154 | | '/settings/diagnostics' |
| 145 | | '/settings/general' | 155 | | '/settings/general' |
| ... | @@ -153,6 +163,7 @@ export interface FileRouteTypes { | ... | @@ -153,6 +163,7 @@ export interface FileRouteTypes { |
| 153 | | '/pair' | 163 | | '/pair' |
| 154 | | '/settings' | 164 | | '/settings' |
| 155 | | '/settings/archived' | 165 | | '/settings/archived' |
| 166 | | '/settings/cloud' | ||
| 156 | | '/settings/connections' | 167 | | '/settings/connections' |
| 157 | | '/settings/diagnostics' | 168 | | '/settings/diagnostics' |
| 158 | | '/settings/general' | 169 | | '/settings/general' |
| ... | @@ -168,6 +179,7 @@ export interface FileRouteTypes { | ... | @@ -168,6 +179,7 @@ export interface FileRouteTypes { |
| 168 | | '/pair' | 179 | | '/pair' |
| 169 | | '/settings' | 180 | | '/settings' |
| 170 | | '/settings/archived' | 181 | | '/settings/archived' |
| 182 | | '/settings/cloud' | ||
| 171 | | '/settings/connections' | 183 | | '/settings/connections' |
| 172 | | '/settings/diagnostics' | 184 | | '/settings/diagnostics' |
| 173 | | '/settings/general' | 185 | | '/settings/general' |
| ... | @@ -257,6 +269,13 @@ declare module '@tanstack/react-router' { | ... | @@ -257,6 +269,13 @@ declare module '@tanstack/react-router' { |
| 257 | preLoaderRoute: typeof SettingsConnectionsRouteImport | 269 | preLoaderRoute: typeof SettingsConnectionsRouteImport |
| 258 | parentRoute: typeof SettingsRoute | 270 | parentRoute: typeof SettingsRoute |
| 259 | } | 271 | } |
| 272 | '/settings/cloud': { | ||
| 273 | id: '/settings/cloud' | ||
| 274 | path: '/cloud' | ||
| 275 | fullPath: '/settings/cloud' | ||
| 276 | preLoaderRoute: typeof SettingsCloudRouteImport | ||
| 277 | parentRoute: typeof SettingsRoute | ||
| 278 | } | ||
| 260 | '/settings/archived': { | 279 | '/settings/archived': { |
| 261 | id: '/settings/archived' | 280 | id: '/settings/archived' |
| 262 | path: '/archived' | 281 | path: '/archived' |
| ... | @@ -297,6 +316,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) | ... | @@ -297,6 +316,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) |
| 297 | 316 | ||
| 298 | interface SettingsRouteChildren { | 317 | interface SettingsRouteChildren { |
| 299 | SettingsArchivedRoute: typeof SettingsArchivedRoute | 318 | SettingsArchivedRoute: typeof SettingsArchivedRoute |
| 319 | SettingsCloudRoute: typeof SettingsCloudRoute | ||
| 300 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute | 320 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute |
| 301 | SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute | 321 | SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute |
| 302 | SettingsGeneralRoute: typeof SettingsGeneralRoute | 322 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| ... | @@ -307,6 +327,7 @@ interface SettingsRouteChildren { | ... | @@ -307,6 +327,7 @@ interface SettingsRouteChildren { |
| 307 | 327 | ||
| 308 | const SettingsRouteChildren: SettingsRouteChildren = { | 328 | const SettingsRouteChildren: SettingsRouteChildren = { |
| 309 | SettingsArchivedRoute: SettingsArchivedRoute, | 329 | SettingsArchivedRoute: SettingsArchivedRoute, |
| 330 | SettingsCloudRoute: SettingsCloudRoute, | ||
| 310 | SettingsConnectionsRoute: SettingsConnectionsRoute, | 331 | SettingsConnectionsRoute: SettingsConnectionsRoute, |
| 311 | SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, | 332 | SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, |
| 312 | SettingsGeneralRoute: SettingsGeneralRoute, | 333 | SettingsGeneralRoute: SettingsGeneralRoute, |
apps/web/src/routes/__root.tsx+2| ... | @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; | ... | @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; |
| 13 | import { APP_DISPLAY_NAME } from "../branding"; | 13 | import { APP_DISPLAY_NAME } from "../branding"; |
| 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; | 14 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 15 | import { CommandPalette } from "../components/CommandPalette"; | 15 | import { CommandPalette } from "../components/CommandPalette"; |
| 16 | import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; | ||
| 16 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; | 17 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; |
| 17 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; | 18 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 18 | import { | 19 | import { |
| ... | @@ -135,6 +136,7 @@ function RootRouteView() { | ... | @@ -135,6 +136,7 @@ function RootRouteView() { |
| 135 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} | 136 | {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} |
| 136 | {primaryEnvironmentAuthenticated ? <ServerStateBootstrap /> : null} | 137 | {primaryEnvironmentAuthenticated ? <ServerStateBootstrap /> : null} |
| 137 | <EnvironmentConnectionManagerBootstrap /> | 138 | <EnvironmentConnectionManagerBootstrap /> |
| 139 | <RelayClientInstallDialog /> | ||
| 138 | <SshPasswordPromptDialog /> | 140 | <SshPasswordPromptDialog /> |
| 139 | <HostedStaticEnvironmentBootstrap /> | 141 | <HostedStaticEnvironmentBootstrap /> |
| 140 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} | 142 | {primaryEnvironmentAuthenticated ? <EventRouter /> : null} |
apps/web/src/routes/_chat.index.tsx+11-4| ... | @@ -7,6 +7,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ | ... | @@ -7,6 +7,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ |
| 7 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; | 7 | import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; |
| 8 | import { useSavedEnvironmentRegistryStore } from "../environments/runtime"; | 8 | import { useSavedEnvironmentRegistryStore } from "../environments/runtime"; |
| 9 | import { APP_DISPLAY_NAME } from "~/branding"; | 9 | import { APP_DISPLAY_NAME } from "~/branding"; |
| 10 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | ||
| 10 | 11 | ||
| 11 | function ChatIndexRouteView() { | 12 | function ChatIndexRouteView() { |
| 12 | const { authGateState } = Route.useRouteContext(); | 13 | const { authGateState } = Route.useRouteContext(); |
| ... | @@ -26,6 +27,8 @@ export const Route = createFileRoute("/_chat/")({ | ... | @@ -26,6 +27,8 @@ export const Route = createFileRoute("/_chat/")({ |
| 26 | }); | 27 | }); |
| 27 | 28 | ||
| 28 | function HostedStaticOnboardingState() { | 29 | function HostedStaticOnboardingState() { |
| 30 | const cloudEnabled = hasCloudPublicConfig(); | ||
| 31 | |||
| 29 | return ( | 32 | return ( |
| 30 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> | 33 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 31 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> | 34 | <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background"> |
| ... | @@ -48,13 +51,17 @@ function HostedStaticOnboardingState() { | ... | @@ -48,13 +51,17 @@ function HostedStaticOnboardingState() { |
| 48 | Connect an environment to get started | 51 | Connect an environment to get started |
| 49 | </EmptyTitle> | 52 | </EmptyTitle> |
| 50 | <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78"> | 53 | <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78"> |
| 51 | Open a pairing link from your T3 Code desktop app or add a reachable backend | 54 | {cloudEnabled |
| 52 | manually. Your saved environments stay in this browser. | 55 | ? "Sign in to T3 Cloud to connect a linked environment through its managed tunnel, or add a reachable backend manually." |
| 56 | : "Add a reachable backend manually to start working from this browser."} | ||
| 53 | </EmptyDescription> | 57 | </EmptyDescription> |
| 54 | <div className="mt-6 flex justify-center"> | 58 | <div className="mt-6 flex justify-center"> |
| 55 | <Button render={<a href="/settings/connections" />} size="sm"> | 59 | <Button |
| 60 | render={<a href={cloudEnabled ? "/settings/cloud" : "/settings/connections"} />} | ||
| 61 | size="sm" | ||
| 62 | > | ||
| 56 | <PlusIcon className="size-4" /> | 63 | <PlusIcon className="size-4" /> |
| 57 | Add environment | 64 | {cloudEnabled ? "Open T3 Cloud" : "Add environment"} |
| 58 | </Button> | 65 | </Button> |
| 59 | </div> | 66 | </div> |
| 60 | </EmptyHeader> | 67 | </EmptyHeader> |
apps/web/src/routes/settings.cloud.tsx created+13| ... | @@ -0,0 +1,13 @@ | ||
| 1 | import { createFileRoute, redirect } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { CloudSettingsPanel } from "../components/settings/CloudSettings"; | ||
| 4 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | ||
| 5 | |||
| 6 | export const Route = createFileRoute("/settings/cloud")({ | ||
| 7 | beforeLoad: () => { | ||
| 8 | if (!hasCloudPublicConfig()) { | ||
| 9 | throw redirect({ to: "/settings/general", replace: true }); | ||
| 10 | } | ||
| 11 | }, | ||
| 12 | component: CloudSettingsPanel, | ||
| 13 | }); | ||
apps/web/src/vite-env.d.ts+2| ... | @@ -7,6 +7,8 @@ interface ImportMetaEnv { | ... | @@ -7,6 +7,8 @@ interface ImportMetaEnv { |
| 7 | readonly VITE_WS_URL: string; | 7 | readonly VITE_WS_URL: string; |
| 8 | readonly VITE_HOSTED_APP_URL: string; | 8 | readonly VITE_HOSTED_APP_URL: string; |
| 9 | readonly VITE_HOSTED_APP_CHANNEL: string; | 9 | readonly VITE_HOSTED_APP_CHANNEL: string; |
| 10 | readonly VITE_CLERK_PUBLISHABLE_KEY: string; | ||
| 11 | readonly VITE_CLERK_JWT_TEMPLATE: string; | ||
| 10 | readonly APP_VERSION: string; | 12 | readonly APP_VERSION: string; |
| 11 | } | 13 | } |
| 12 | 14 |
apps/web/tsconfig.json+1-1| ... | @@ -26,5 +26,5 @@ | ... | @@ -26,5 +26,5 @@ |
| 26 | } | 26 | } |
| 27 | ] | 27 | ] |
| 28 | }, | 28 | }, |
| 29 | "include": ["src", "vite.config.ts", "vercel.ts", "test"] | 29 | "include": ["src", "vite.config.ts", "vercel.ts", "test", "../../scripts/lib/public-config.ts"] |
| 30 | } | 30 | } |
apps/web/vite.config.ts+11| ... | @@ -8,9 +8,17 @@ import "vite-plus/test/config"; | ... | @@ -8,9 +8,17 @@ import "vite-plus/test/config"; |
| 8 | import { defineConfig } from "vite-plus"; | 8 | import { defineConfig } from "vite-plus"; |
| 9 | import pkg from "./package.json" with { type: "json" }; | 9 | import pkg from "./package.json" with { type: "json" }; |
| 10 | 10 | ||
| 11 | import { loadRepoEnv } from "../../scripts/lib/public-config"; | ||
| 12 | |||
| 13 | const repoEnv = loadRepoEnv(); | ||
| 14 | Object.assign(process.env, repoEnv); | ||
| 15 | |||
| 11 | const port = Number(process.env.PORT ?? 5733); | 16 | const port = Number(process.env.PORT ?? 5733); |
| 12 | const host = process.env.HOST?.trim() || "localhost"; | 17 | const host = process.env.HOST?.trim() || "localhost"; |
| 13 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); | 18 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); |
| 19 | const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; | ||
| 20 | const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; | ||
| 21 | const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; | ||
| 14 | const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; | 22 | const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; |
| 15 | const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; | 23 | const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; |
| 16 | const configuredHostedAppUrl = (() => { | 24 | const configuredHostedAppUrl = (() => { |
| ... | @@ -123,6 +131,9 @@ export default defineConfig(() => { | ... | @@ -123,6 +131,9 @@ export default defineConfig(() => { |
| 123 | define: { | 131 | define: { |
| 124 | // In dev mode, tell the web app where the WebSocket server lives | 132 | // In dev mode, tell the web app where the WebSocket server lives |
| 125 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), | 133 | "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), |
| 134 | "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl), | ||
| 135 | "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey), | ||
| 136 | "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate), | ||
| 126 | "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), | 137 | "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), |
| 127 | "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), | 138 | "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), |
| 128 | "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), | 139 | "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), |
docs/relay-observability.md created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | # Relay observability | ||
| 2 | |||
| 3 | The relay Alchemy stack owns a focused Axiom trace setup: | ||
| 4 | |||
| 5 | - `t3-code-relay-traces-prod`, an OpenTelemetry trace dataset for Worker requests | ||
| 6 | - `t3-code-relay-otel-ingest-prod`, a dataset-scoped ingest token bound to the Worker | ||
| 7 | - `t3-code-relay-recent-spans-prod`, a view of recent request and endpoint spans | ||
| 8 | |||
| 9 | Alchemy stages append their sanitized stage name to isolate resources, for example | ||
| 10 | `t3-code-relay-traces-dev-julius` for a personal stage. | ||
| 11 | |||
| 12 | Deploy from `infra/relay` with the normal Alchemy workflow: | ||
| 13 | |||
| 14 | ```sh | ||
| 15 | vp run deploy | ||
| 16 | ``` | ||
| 17 | |||
| 18 | Alchemy resolves Axiom deployment credentials through its provider. At runtime, the Worker | ||
| 19 | receives only the scoped ingest token; it does not receive the diagnostics query token. | ||
| 20 | |||
| 21 | The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans. | ||
| 22 | Effect's OpenTelemetry exporter stores semantic HTTP attributes below the `attributes.` prefix. | ||
| 23 | For example: | ||
| 24 | |||
| 25 | ```apl | ||
| 26 | ['t3-code-relay-traces-prod'] | ||
| 27 | | where name startswith 'http.server' | ||
| 28 | | project _time, name, trace_id, duration, | ||
| 29 | ['attributes.http.request.method'], | ||
| 30 | ['attributes.url.path'], | ||
| 31 | ['attributes.http.response.status_code'] | ||
| 32 | | order by _time desc | ||
| 33 | | limit 200 | ||
| 34 | ``` | ||
| 35 | |||
| 36 | Endpoint failure annotations and other relay-specific attributes are also emitted in the | ||
| 37 | `attributes.custom` map when present on a span, for example | ||
| 38 | `['attributes.custom']['relay.endpoint']`. | ||
| 39 | |||
| 40 | Agents should prefer the provisioned view or APL queries for completed incidents instead of | ||
| 41 | tailing the Cloudflare Worker. Use the read-only query token when scripted access is needed; | ||
| 42 | keep the ingest token reserved for the Worker. | ||
docs/release.md+61-2| ... | @@ -7,9 +7,10 @@ This document covers the unified release workflow for stable and nightly desktop | ... | @@ -7,9 +7,10 @@ This document covers the unified release workflow for stable and nightly desktop |
| 7 | - Workflow: `.github/workflows/release.yml` | 7 | - Workflow: `.github/workflows/release.yml` |
| 8 | - Triggers: | 8 | - Triggers: |
| 9 | - push tag matching `v*.*.*` for stable releases | 9 | - push tag matching `v*.*.*` for stable releases |
| 10 | - scheduled nightly at `09:00 UTC` | 10 | - scheduled nightly check every three hours |
| 11 | - manual `workflow_dispatch` for either channel | 11 | - manual `workflow_dispatch` for either channel |
| 12 | - Runs quality gates first: lint, typecheck, test. | 12 | - Runs quality gates first: lint, typecheck, test. |
| 13 | - Reads the shared production T3 Cloud relay URL and Clerk client configuration before packaging clients. | ||
| 13 | - Builds four artifacts in parallel for both channels: | 14 | - Builds four artifacts in parallel for both channels: |
| 14 | - macOS `arm64` DMG | 15 | - macOS `arm64` DMG |
| 15 | - macOS `x64` DMG | 16 | - macOS `x64` DMG |
| ... | @@ -29,6 +30,64 @@ This document covers the unified release workflow for stable and nightly desktop | ... | @@ -29,6 +30,64 @@ This document covers the unified release workflow for stable and nightly desktop |
| 29 | - nightly releases are aliased to the `nightly` hosted app channel | 30 | - nightly releases are aliased to the `nightly` hosted app channel |
| 30 | - Signing is optional and auto-detected per platform from secrets. | 31 | - Signing is optional and auto-detected per platform from secrets. |
| 31 | 32 | ||
| 33 | ## T3 Cloud relay deployment | ||
| 34 | |||
| 35 | The relay is a shared control plane versioned separately from client releases. Stable and nightly | ||
| 36 | client builds must point at the same relay so users see the same linked environments when switching | ||
| 37 | release channels. | ||
| 38 | |||
| 39 | `.github/workflows/deploy-relay.yml` deploys Alchemy stage `prod` on every push to `main`. The | ||
| 40 | release workflow reads the relay URL and Clerk client configuration from the existing `production` | ||
| 41 | GitHub Actions environment before building desktop, CLI, or hosted web artifacts. | ||
| 42 | |||
| 43 | Required repository variables shared by relay deployments: | ||
| 44 | |||
| 45 | - `CLOUDFLARE_ACCOUNT_ID` | ||
| 46 | - `PLANETSCALE_ORGANIZATION` | ||
| 47 | - `AXIOM_ORG_ID` | ||
| 48 | |||
| 49 | Required repository secrets shared by relay deployments: | ||
| 50 | |||
| 51 | - `CLOUDFLARE_API_TOKEN` | ||
| 52 | - `PLANETSCALE_API_TOKEN_ID` | ||
| 53 | - `PLANETSCALE_API_TOKEN` | ||
| 54 | - `AXIOM_TOKEN` | ||
| 55 | |||
| 56 | Required `production` environment variables: | ||
| 57 | |||
| 58 | - `RELAY_API_ZONE_NAME` | ||
| 59 | - `RELAY_TUNNEL_ZONE_NAME` | ||
| 60 | - `CLERK_PUBLISHABLE_KEY` | ||
| 61 | - `CLERK_JWT_AUDIENCE` | ||
| 62 | - `CLERK_JWT_TEMPLATE` | ||
| 63 | - `CLERK_CLI_OAUTH_CLIENT_ID` | ||
| 64 | - `APNS_ENVIRONMENT` | ||
| 65 | - `APNS_TEAM_ID` | ||
| 66 | - `APNS_KEY_ID` | ||
| 67 | - `APNS_BUNDLE_ID` | ||
| 68 | |||
| 69 | Optional `production` environment variables: | ||
| 70 | |||
| 71 | - `RELAY_DOMAIN` when overriding the derived `relay.<RELAY_API_ZONE_NAME>` domain | ||
| 72 | |||
| 73 | Required `production` environment secrets: | ||
| 74 | |||
| 75 | - `CLERK_SECRET_KEY` | ||
| 76 | - `APNS_PRIVATE_KEY` | ||
| 77 | |||
| 78 | The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they | ||
| 79 | are not bound into the relay Worker. The production deployment uses an Axiom personal access token, | ||
| 80 | so `AXIOM_ORG_ID` must accompany `AXIOM_TOKEN`. The `prod` stage owns the retained PlanetScale | ||
| 81 | database. Local personal stages provision isolated branches from it and are never deployed by CI. | ||
| 82 | Production adopts the configured relay API and tunnel DNS zones as retained Cloudflare resources. | ||
| 83 | Personal stages reference the production-owned zones. | ||
| 84 | |||
| 85 | Developers deploy personal stages locally rather than through pull-request automation: | ||
| 86 | |||
| 87 | ```sh | ||
| 88 | vp run --filter t3code-relay deploy -- --stage "$USER" --env-file .env.local | ||
| 89 | ``` | ||
| 90 | |||
| 32 | ## Hosted web app release deployment | 91 | ## Hosted web app release deployment |
| 33 | 92 | ||
| 34 | The hosted app is intentionally not deployed by Vercel's Git integration. The | 93 | The hosted app is intentionally not deployed by Vercel's Git integration. The |
| ... | @@ -85,7 +144,7 @@ One-time Vercel dashboard setup: | ... | @@ -85,7 +144,7 @@ One-time Vercel dashboard setup: |
| 85 | 144 | ||
| 86 | - Workflow: `.github/workflows/release.yml` | 145 | - Workflow: `.github/workflows/release.yml` |
| 87 | - Triggers: | 146 | - Triggers: |
| 88 | - scheduled every day at `09:00 UTC` | 147 | - scheduled check every three hours |
| 89 | - manual `workflow_dispatch` with `channel=nightly` | 148 | - manual `workflow_dispatch` with `channel=nightly` |
| 90 | - Runs the same desktop quality gates and artifact matrix as the tagged release flow. | 149 | - Runs the same desktop quality gates and artifact matrix as the tagged release flow. |
| 91 | - Publishes a GitHub prerelease only: | 150 | - Publishes a GitHub prerelease only: |
docs/t3-cloud-clerk.md created+174| ... | @@ -0,0 +1,174 @@ | ||
| 1 | # T3 Cloud Clerk Setup | ||
| 2 | |||
| 3 | T3 Cloud uses one Clerk application for web, desktop, and mobile authentication. The relay accepts | ||
| 4 | Clerk JWTs only when they are generated from the `t3-relay` template with the shared | ||
| 5 | `t3-code-relay` audience. | ||
| 6 | |||
| 7 | ## Application Keys | ||
| 8 | |||
| 9 | T3 Cloud is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env` | ||
| 10 | or `.env.local` file: | ||
| 11 | |||
| 12 | ```dotenv | ||
| 13 | T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key> | ||
| 14 | T3CODE_CLERK_JWT_TEMPLATE=<JWT template name> | ||
| 15 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=<public OAuth application client ID> | ||
| 16 | T3CODE_RELAY_URL=https://relay.example.com | ||
| 17 | ``` | ||
| 18 | |||
| 19 | The shared client loader projects these canonical values into framework-specific `VITE_*` and | ||
| 20 | `EXPO_PUBLIC_*` aliases. Existing aliases remain accepted as overrides for compatibility, but new | ||
| 21 | client configuration should use the canonical names. | ||
| 22 | |||
| 23 | Configuration precedence is: | ||
| 24 | |||
| 25 | 1. Process or CI environment variables. | ||
| 26 | 2. Repository-root `.env.local`. | ||
| 27 | 3. Repository-root `.env`. | ||
| 28 | |||
| 29 | The Clerk publishable key, JWT template name, CLI OAuth client ID, and relay URL are public | ||
| 30 | identifiers, not secrets. | ||
| 31 | Web, desktop, mobile, and bundled server builds statically inject the values they consume during | ||
| 32 | their build step. A built artifact does not need an environment file at runtime. CI release builds | ||
| 33 | should set `T3CODE_CLERK_PUBLISHABLE_KEY`, `T3CODE_CLERK_JWT_TEMPLATE`, | ||
| 34 | `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID`, and `T3CODE_RELAY_URL` before building. EAS preview and | ||
| 35 | production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS | ||
| 36 | environment. | ||
| 37 | |||
| 38 | When any client-facing public value is absent, cloud UI is omitted. When the CLI public values are | ||
| 39 | absent, the `t3 cloud` CLI command group is omitted. The bundled server still accepts runtime | ||
| 40 | overrides for self-hosted or operator-managed | ||
| 41 | deployments. | ||
| 42 | |||
| 43 | For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay | ||
| 44 | deployment reads `RELAY_DOMAIN`, `RELAY_API_ZONE_NAME`, `RELAY_TUNNEL_ZONE_NAME`, | ||
| 45 | `CLERK_PUBLISHABLE_KEY`, and `CLERK_JWT_AUDIENCE` through Effect `Config`. There are no checked-in | ||
| 46 | deployment defaults. | ||
| 47 | `vp run --filter t3code-relay deploy` invokes Alchemy from the relay directory, so Alchemy loads | ||
| 48 | `infra/relay/.env`. After a successful deployment, the wrapper updates the repository-root `.env` | ||
| 49 | with the deployed HTTPS relay URL. The relay still requires | ||
| 50 | `CLERK_SECRET_KEY` as an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application | ||
| 51 | environment or commit it to the repository. | ||
| 52 | |||
| 53 | The `prod` Alchemy stage owns the retained PlanetScale database. Non-production stages reference | ||
| 54 | that database and provision isolated PlanetScale branches, so deploy `prod` before creating a | ||
| 55 | personal developer stage. | ||
| 56 | |||
| 57 | ## Headless CLI OAuth Application | ||
| 58 | |||
| 59 | The `t3 cloud` commands authorize a headless environment with a separate Clerk OAuth application. | ||
| 60 | This uses an OAuth public client with PKCE, so the CLI stores no client secret. | ||
| 61 | |||
| 62 | In **Clerk Dashboard > OAuth applications**: | ||
| 63 | |||
| 64 | 1. Create an OAuth application for the T3 CLI. | ||
| 65 | 2. Enable the **Public** option so authorization-code exchange uses PKCE. | ||
| 66 | 3. Add `http://127.0.0.1:34338/callback` as an allowed redirect URI. | ||
| 67 | 4. Enable the `openid`, `profile`, and `email` scopes. | ||
| 68 | 5. Set `T3CODE_CLERK_CLI_OAUTH_CLIENT_ID` in the repository-root `.env` file and release build | ||
| 69 | environment to the generated public client ID. | ||
| 70 | |||
| 71 | The CLI derives Clerk's frontend API URL from the publishable key and calls Clerk's | ||
| 72 | `/oauth/authorize` and `/oauth/token` endpoints directly. The relay is not involved in the OAuth | ||
| 73 | handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link. | ||
| 74 | |||
| 75 | The CLI supports these headless operations: | ||
| 76 | |||
| 77 | ```sh | ||
| 78 | t3 cloud login | ||
| 79 | t3 cloud link | ||
| 80 | t3 cloud status | ||
| 81 | t3 cloud unlink | ||
| 82 | t3 cloud logout | ||
| 83 | t3 serve | ||
| 84 | ``` | ||
| 85 | |||
| 86 | `t3 cloud login` opens the Clerk authorization flow and stores the CLI credential without enabling | ||
| 87 | cloud exposure. `t3 cloud link` installs the pinned managed `cloudflared` binary when needed, | ||
| 88 | authorizes when needed, and records durable intent to expose the environment. It works without a | ||
| 89 | running T3 server. The next `t3 serve` or `t3 start` reconciles the relay link and launches the | ||
| 90 | managed tunnel. `t3 cloud unlink` records disabled intent immediately, stops a reachable running | ||
| 91 | connector, and attempts to revoke the relay-side environment record. It retains the stored CLI | ||
| 92 | authorization so `t3 cloud link` can re-enable exposure without another browser flow. `t3 cloud | ||
| 93 | logout` performs the same cleanup and removes the stored CLI authorization. | ||
| 94 | |||
| 95 | The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH, | ||
| 96 | forward that port before running `t3 cloud login` or `t3 cloud link`: | ||
| 97 | |||
| 98 | ```sh | ||
| 99 | ssh -L 34338:127.0.0.1:34338 <host> | ||
| 100 | ``` | ||
| 101 | |||
| 102 | A relay-hosted callback broker can remove this port-forward requirement later without changing the | ||
| 103 | stored PKCE token model. | ||
| 104 | |||
| 105 | ## JWT Template | ||
| 106 | |||
| 107 | In **Clerk Dashboard > JWT templates**, create a template with: | ||
| 108 | |||
| 109 | | Setting | Value | | ||
| 110 | | ------- | ---------------------------- | | ||
| 111 | | Name | `t3-relay` | | ||
| 112 | | Claims | `{ "aud": "t3-code-relay" }` | | ||
| 113 | |||
| 114 | Set `T3CODE_CLERK_JWT_TEMPLATE=t3-relay` in the repository-root `.env`, and set | ||
| 115 | `CLERK_JWT_AUDIENCE=t3-code-relay` in `infra/relay/.env`. Define `CLERK_JWT_TEMPLATE` and | ||
| 116 | `CLERK_JWT_AUDIENCE` in the production relay deployment environment as well. The stable `aud` value | ||
| 117 | is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still | ||
| 118 | selects the concrete relay deployment, but changing that URL does not require a JWT template change. | ||
| 119 | |||
| 120 | ## Desktop OAuth Redirect Allowlist | ||
| 121 | |||
| 122 | The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme. | ||
| 123 | In **Clerk Dashboard > Native applications**, enable native application support and add these | ||
| 124 | entries under the mobile SSO redirect allowlist: | ||
| 125 | |||
| 126 | ```text | ||
| 127 | t3code-dev://auth/callback | ||
| 128 | t3code://auth/callback | ||
| 129 | ``` | ||
| 130 | |||
| 131 | The first entry is for local desktop development. The second is for packaged desktop builds. | ||
| 132 | The app also adds a request-scoped `t3_state` query parameter and validates it on callback. Initial | ||
| 133 | sign-in and linked-account OAuth flows both return through this bridge. The desktop provider keeps | ||
| 134 | Clerk's stock profile component, replaces its renderer-page callback with the custom-scheme callback, | ||
| 135 | and opens the provider URL in the system browser. Do not add the local renderer URL as an OAuth | ||
| 136 | redirect: an external browser cannot use it to reopen the packaged app. | ||
| 137 | |||
| 138 | The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth | ||
| 139 | flow uses a custom redirect URI, add that exact URI to the same allowlist. | ||
| 140 | |||
| 141 | ## Enable Waitlist Access | ||
| 142 | |||
| 143 | For a private beta where people should request access, use **Clerk Dashboard > Waitlist**: | ||
| 144 | |||
| 145 | 1. Toggle on **Enable waitlist** and save. | ||
| 146 | 2. Review requests on the same page and select **Invite** or **Deny**. | ||
| 147 | |||
| 148 | Signed-out web and desktop users see Clerk's waitlist enrollment as the T3 Cloud page content, | ||
| 149 | while approved signed-in users see cloud settings. The browser app also uses `/settings/cloud` as | ||
| 150 | its Clerk waitlist URL. | ||
| 151 | |||
| 152 | On mobile, signed-out users open **Settings > T3 Account** to reach `/settings/waitlist` within the | ||
| 153 | Settings form sheet. It submits enrollment through Clerk's `useWaitlist()` flow because the prebuilt | ||
| 154 | `<Waitlist />` component is web-only in the Expo SDK. Approved users can use **Sign in** from that | ||
| 155 | screen. | ||
| 156 | |||
| 157 | ## Alternative: Known-User Allowlist | ||
| 158 | |||
| 159 | For a closed beta where all permitted users are known in advance, use an allowlist instead of a | ||
| 160 | request-and-approval waitlist: | ||
| 161 | |||
| 162 | To restrict the beta to permitted email addresses or domains: | ||
| 163 | |||
| 164 | 1. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email | ||
| 165 | domain. | ||
| 166 | 2. Enable the allowlist and save. | ||
| 167 | 3. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or | ||
| 168 | manually created without a waitlist request flow. | ||
| 169 | |||
| 170 | Do not enable an empty allowlist: it blocks all new sign-ups. | ||
| 171 | |||
| 172 | Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud | ||
| 173 | access. To remove an already-created user's access, ban that user in Clerk so their active | ||
| 174 | sessions are ended and future sign-ins are rejected. | ||
docs/t3-code-cloud-auth-flow.html created+1160| ... | @@ -0,0 +1,1160 @@ | ||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="utf-8" /> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| 6 | <title>T3 Code Cloud Architecture</title> | ||
| 7 | <style> | ||
| 8 | :root { | ||
| 9 | color-scheme: light; | ||
| 10 | --ink: #171717; | ||
| 11 | --muted: #62646b; | ||
| 12 | --hairline: #d8d8dd; | ||
| 13 | --panel: #ffffff; | ||
| 14 | --paper: #f5f5f2; | ||
| 15 | --blue: #1457d9; | ||
| 16 | --green: #1d7d4d; | ||
| 17 | --amber: #a65f00; | ||
| 18 | --red: #b8242c; | ||
| 19 | --violet: #6150b8; | ||
| 20 | --shadow: 0 18px 55px rgba(25, 25, 28, 0.08); | ||
| 21 | } | ||
| 22 | |||
| 23 | * { | ||
| 24 | box-sizing: border-box; | ||
| 25 | } | ||
| 26 | |||
| 27 | body { | ||
| 28 | margin: 0; | ||
| 29 | background: | ||
| 30 | linear-gradient(90deg, rgba(0, 0, 0, 0.035) 1px, transparent 1px), | ||
| 31 | linear-gradient(rgba(0, 0, 0, 0.035) 1px, transparent 1px), var(--paper); | ||
| 32 | background-size: 34px 34px; | ||
| 33 | color: var(--ink); | ||
| 34 | font-family: | ||
| 35 | ui-sans-serif, | ||
| 36 | -apple-system, | ||
| 37 | BlinkMacSystemFont, | ||
| 38 | "Segoe UI", | ||
| 39 | sans-serif; | ||
| 40 | line-height: 1.48; | ||
| 41 | } | ||
| 42 | |||
| 43 | .shell { | ||
| 44 | width: min(1540px, calc(100vw - 40px)); | ||
| 45 | margin: 0 auto; | ||
| 46 | padding: 38px 0 72px; | ||
| 47 | } | ||
| 48 | |||
| 49 | header { | ||
| 50 | border-bottom: 2px solid var(--ink); | ||
| 51 | padding-bottom: 24px; | ||
| 52 | margin-bottom: 28px; | ||
| 53 | } | ||
| 54 | |||
| 55 | .eyebrow { | ||
| 56 | color: var(--blue); | ||
| 57 | font-size: 12px; | ||
| 58 | font-weight: 800; | ||
| 59 | letter-spacing: 0.08em; | ||
| 60 | text-transform: uppercase; | ||
| 61 | } | ||
| 62 | |||
| 63 | h1 { | ||
| 64 | max-width: 980px; | ||
| 65 | margin: 8px 0 12px; | ||
| 66 | font-size: clamp(34px, 5vw, 66px); | ||
| 67 | line-height: 0.98; | ||
| 68 | } | ||
| 69 | |||
| 70 | h2 { | ||
| 71 | margin: 0 0 14px; | ||
| 72 | font-size: 26px; | ||
| 73 | line-height: 1.08; | ||
| 74 | } | ||
| 75 | |||
| 76 | h3 { | ||
| 77 | margin: 22px 0 8px; | ||
| 78 | font-size: 17px; | ||
| 79 | } | ||
| 80 | |||
| 81 | p { | ||
| 82 | margin: 0 0 12px; | ||
| 83 | } | ||
| 84 | |||
| 85 | code, | ||
| 86 | .mono { | ||
| 87 | font-family: | ||
| 88 | ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; | ||
| 89 | } | ||
| 90 | |||
| 91 | code { | ||
| 92 | border-radius: 4px; | ||
| 93 | background: #f0f0ed; | ||
| 94 | padding: 1px 4px; | ||
| 95 | font-size: 0.92em; | ||
| 96 | } | ||
| 97 | |||
| 98 | .lede { | ||
| 99 | max-width: 1000px; | ||
| 100 | margin: 0; | ||
| 101 | color: var(--muted); | ||
| 102 | font-size: 18px; | ||
| 103 | } | ||
| 104 | |||
| 105 | .grid { | ||
| 106 | display: grid; | ||
| 107 | grid-template-columns: repeat(12, 1fr); | ||
| 108 | gap: 18px; | ||
| 109 | } | ||
| 110 | |||
| 111 | .panel { | ||
| 112 | grid-column: span 12; | ||
| 113 | border: 1px solid var(--hairline); | ||
| 114 | border-radius: 10px; | ||
| 115 | background: var(--panel); | ||
| 116 | box-shadow: var(--shadow); | ||
| 117 | padding: 22px; | ||
| 118 | } | ||
| 119 | |||
| 120 | .half { | ||
| 121 | grid-column: span 6; | ||
| 122 | } | ||
| 123 | |||
| 124 | .callout { | ||
| 125 | border-left: 5px solid var(--blue); | ||
| 126 | background: #eef4ff; | ||
| 127 | padding: 14px 16px; | ||
| 128 | margin-top: 14px; | ||
| 129 | } | ||
| 130 | |||
| 131 | .callout.warning { | ||
| 132 | border-left-color: var(--amber); | ||
| 133 | background: #fff6e6; | ||
| 134 | } | ||
| 135 | |||
| 136 | .badge-row { | ||
| 137 | display: flex; | ||
| 138 | flex-wrap: wrap; | ||
| 139 | gap: 8px; | ||
| 140 | margin-top: 14px; | ||
| 141 | } | ||
| 142 | |||
| 143 | .badge { | ||
| 144 | border: 1px solid var(--hairline); | ||
| 145 | border-radius: 999px; | ||
| 146 | background: #fff; | ||
| 147 | padding: 5px 9px; | ||
| 148 | font-size: 12px; | ||
| 149 | font-weight: 700; | ||
| 150 | } | ||
| 151 | |||
| 152 | .badge.user { | ||
| 153 | border-color: rgba(20, 87, 217, 0.3); | ||
| 154 | color: var(--blue); | ||
| 155 | } | ||
| 156 | |||
| 157 | .badge.env { | ||
| 158 | border-color: rgba(29, 125, 77, 0.3); | ||
| 159 | color: var(--green); | ||
| 160 | } | ||
| 161 | |||
| 162 | .badge.local { | ||
| 163 | border-color: rgba(166, 95, 0, 0.35); | ||
| 164 | color: var(--amber); | ||
| 165 | } | ||
| 166 | |||
| 167 | .diagram { | ||
| 168 | overflow-x: auto; | ||
| 169 | margin: 12px 0 0; | ||
| 170 | border: 1px solid var(--hairline); | ||
| 171 | border-radius: 8px; | ||
| 172 | background: #fbfbf8; | ||
| 173 | color: var(--ink); | ||
| 174 | padding: 18px; | ||
| 175 | } | ||
| 176 | |||
| 177 | .diagram svg { | ||
| 178 | display: block; | ||
| 179 | width: auto !important; | ||
| 180 | min-width: 100%; | ||
| 181 | max-width: none !important; | ||
| 182 | height: auto; | ||
| 183 | margin: 0 auto; | ||
| 184 | } | ||
| 185 | |||
| 186 | table { | ||
| 187 | width: 100%; | ||
| 188 | border-collapse: collapse; | ||
| 189 | font-size: 14px; | ||
| 190 | } | ||
| 191 | |||
| 192 | th, | ||
| 193 | td { | ||
| 194 | border-bottom: 1px solid var(--hairline); | ||
| 195 | padding: 11px 9px; | ||
| 196 | text-align: left; | ||
| 197 | vertical-align: top; | ||
| 198 | } | ||
| 199 | |||
| 200 | th { | ||
| 201 | color: var(--muted); | ||
| 202 | font-size: 12px; | ||
| 203 | letter-spacing: 0.05em; | ||
| 204 | text-transform: uppercase; | ||
| 205 | } | ||
| 206 | |||
| 207 | ul { | ||
| 208 | margin: 8px 0 0; | ||
| 209 | padding-left: 20px; | ||
| 210 | } | ||
| 211 | |||
| 212 | li + li { | ||
| 213 | margin-top: 6px; | ||
| 214 | } | ||
| 215 | |||
| 216 | .flow { | ||
| 217 | display: grid; | ||
| 218 | gap: 10px; | ||
| 219 | margin-top: 12px; | ||
| 220 | } | ||
| 221 | |||
| 222 | .step { | ||
| 223 | border: 1px solid var(--hairline); | ||
| 224 | border-left: 5px solid var(--green); | ||
| 225 | border-radius: 8px; | ||
| 226 | background: #fff; | ||
| 227 | padding: 12px 14px; | ||
| 228 | } | ||
| 229 | |||
| 230 | .step strong { | ||
| 231 | display: block; | ||
| 232 | margin-bottom: 3px; | ||
| 233 | } | ||
| 234 | |||
| 235 | .source-list a { | ||
| 236 | color: var(--blue); | ||
| 237 | font-weight: 700; | ||
| 238 | } | ||
| 239 | |||
| 240 | @media (max-width: 860px) { | ||
| 241 | .half { | ||
| 242 | grid-column: span 12; | ||
| 243 | } | ||
| 244 | |||
| 245 | .shell { | ||
| 246 | width: min(100vw - 24px, 1540px); | ||
| 247 | padding-top: 24px; | ||
| 248 | } | ||
| 249 | } | ||
| 250 | </style> | ||
| 251 | </head> | ||
| 252 | <body> | ||
| 253 | <main class="shell"> | ||
| 254 | <header> | ||
| 255 | <div class="eyebrow">Implemented architecture</div> | ||
| 256 | <h1>T3 Code Cloud Control Plane and Managed Endpoint Flow</h1> | ||
| 257 | <p class="lede"> | ||
| 258 | T3 Cloud links a locally authorized T3 environment to a signed-in cloud user, provisions a | ||
| 259 | managed HTTPS/WSS endpoint for that environment, and brokers proof-bound remote | ||
| 260 | connections. The local environment remains the authority that issues environment access | ||
| 261 | credentials. The hosted relay stores links, reconciles managed endpoint resources, | ||
| 262 | validates signed proofs, and delivers agent-activity notifications. | ||
| 263 | </p> | ||
| 264 | <div class="badge-row"> | ||
| 265 | <span class="badge user">Client -> Relay: Clerk bearer or relay DPoP</span> | ||
| 266 | <span class="badge env">Relay -> Environment: relay-signed health and mint proofs</span> | ||
| 267 | <span class="badge env" | ||
| 268 | >Environment -> Relay: environment bearer plus signed activity proof</span | ||
| 269 | > | ||
| 270 | <span class="badge local">Client -> Environment: environment access token plus DPoP</span> | ||
| 271 | </div> | ||
| 272 | </header> | ||
| 273 | |||
| 274 | <section class="grid" aria-label="Architecture summary"> | ||
| 275 | <article class="panel half"> | ||
| 276 | <h2>Security Invariant</h2> | ||
| 277 | <p> | ||
| 278 | A cloud identity is not an environment login. Remote access requires all of the | ||
| 279 | following: | ||
| 280 | </p> | ||
| 281 | <ul> | ||
| 282 | <li>a Clerk-authenticated T3 Cloud user;</li> | ||
| 283 | <li>an active relay link for that user and environment;</li> | ||
| 284 | <li>a DPoP proof key held by the remote client;</li> | ||
| 285 | <li>a relay-signed mint request accepted by the linked local environment; and</li> | ||
| 286 | <li>an environment-issued credential bound to the remote client's DPoP thumbprint.</li> | ||
| 287 | </ul> | ||
| 288 | <div class="callout"> | ||
| 289 | The relay cannot mint an environment access token by itself. It can only ask the local | ||
| 290 | environment to mint a short-lived bootstrap credential for an authorized cloud user. | ||
| 291 | </div> | ||
| 292 | <p>During an honest connection flow, the relay cannot act as the remote client:</p> | ||
| 293 | <ul> | ||
| 294 | <li> | ||
| 295 | Normal environment API traffic goes directly from the remote client through the | ||
| 296 | managed endpoint. The relay does not receive the resulting environment access token. | ||
| 297 | </li> | ||
| 298 | <li> | ||
| 299 | The one-time bootstrap credential and resulting environment access token are bound to | ||
| 300 | the remote client's DPoP public-key thumbprint. Redeeming or using them requires | ||
| 301 | request proofs signed by the corresponding private key, which stays on the client. | ||
| 302 | </li> | ||
| 303 | <li> | ||
| 304 | Relay-signed health and mint proofs are short-lived, audience-bound, scoped, and | ||
| 305 | replay-guarded. They are accepted only by the narrow cloud endpoints, not as normal | ||
| 306 | environment API credentials. | ||
| 307 | </li> | ||
| 308 | <li> | ||
| 309 | Environment health and mint responses are signed by the linked environment and bound | ||
| 310 | to the request nonce, so a different process behind the tunnel cannot impersonate the | ||
| 311 | linked environment. | ||
| 312 | </li> | ||
| 313 | </ul> | ||
| 314 | <div class="callout warning"> | ||
| 315 | The hosted relay remains a trusted mint broker: it holds the cloud-mint signing key. | ||
| 316 | DPoP prevents credential reuse by the relay or tunnel transport during an honest flow, | ||
| 317 | but it does not make compromise of the relay signing authority harmless. | ||
| 318 | </div> | ||
| 319 | </article> | ||
| 320 | |||
| 321 | <article class="panel half"> | ||
| 322 | <h2>Product Boundaries</h2> | ||
| 323 | <p> | ||
| 324 | The generic local connector is called the <strong>relay client</strong>. The current | ||
| 325 | implementation layer uses <code>cloudflared</code>, but that is an internal transport | ||
| 326 | detail rather than the product contract. | ||
| 327 | </p> | ||
| 328 | <ul> | ||
| 329 | <li> | ||
| 330 | The relay Worker owns cloud links, managed endpoint allocations, and notifications. | ||
| 331 | </li> | ||
| 332 | <li> | ||
| 333 | The environment server owns local sessions, environment keys, and access tokens. | ||
| 334 | </li> | ||
| 335 | <li>The relay client exposes only the environment's loopback HTTP server.</li> | ||
| 336 | <li> | ||
| 337 | Web and mobile clients connect directly to the managed environment endpoint after | ||
| 338 | bootstrap. | ||
| 339 | </li> | ||
| 340 | </ul> | ||
| 341 | </article> | ||
| 342 | </section> | ||
| 343 | |||
| 344 | <section class="panel" aria-label="Topology"> | ||
| 345 | <h2>Current Topology</h2> | ||
| 346 | <pre class="diagram mermaid"> | ||
| 347 | flowchart LR | ||
| 348 | subgraph Clients["User-facing clients"] | ||
| 349 | Web["Desktop / hosted web UI"] | ||
| 350 | Mobile["Mobile app"] | ||
| 351 | CLI["Headless CLI"] | ||
| 352 | end | ||
| 353 | |||
| 354 | Clerk["Clerk<br/>user identity and OAuth"] | ||
| 355 | |||
| 356 | subgraph Relay["T3 Code Relay on Cloudflare"] | ||
| 357 | Worker["Relay Worker<br/>HTTP API"] | ||
| 358 | Queue["APNs delivery queue<br/>with dead-letter queue"] | ||
| 359 | Hyperdrive["Hyperdrive"] | ||
| 360 | CF["Cloudflare tunnel + DNS APIs"] | ||
| 361 | end | ||
| 362 | |||
| 363 | DB["PlanetScale Postgres<br/>prod database + stage branches"] | ||
| 364 | APNs["Apple Push Notification service"] | ||
| 365 | Traces["Axiom OTLP traces"] | ||
| 366 | |||
| 367 | subgraph Local["Linked T3 environment"] | ||
| 368 | Env["Environment server<br/>local auth authority"] | ||
| 369 | RelayClient["Relay client<br/>cloudflared implementation"] | ||
| 370 | Secrets["Server secret store<br/>keys + relay config"] | ||
| 371 | end | ||
| 372 | |||
| 373 | Endpoint["Managed HTTPS/WSS endpoint"] | ||
| 374 | |||
| 375 | Web -->|"Clerk bearer / relay DPoP"| Worker | ||
| 376 | Mobile -->|"Clerk bearer / relay DPoP"| Worker | ||
| 377 | CLI -->|"Clerk OAuth bearer"| Worker | ||
| 378 | Web --> Clerk | ||
| 379 | Mobile --> Clerk | ||
| 380 | CLI -->|"PKCE authorization code flow"| Clerk | ||
| 381 | Worker --> Hyperdrive --> DB | ||
| 382 | Worker --> Traces | ||
| 383 | Worker --> CF | ||
| 384 | Worker --> Queue --> APNs | ||
| 385 | Worker --> Endpoint | ||
| 386 | Endpoint --> RelayClient -->|"http://127.0.0.1:port"| Env | ||
| 387 | Env --> Secrets | ||
| 388 | Web -->|"environment token + DPoP<br/>HTTPS / WSS"| Endpoint | ||
| 389 | Mobile -->|"environment token + DPoP<br/>HTTPS / WSS"| Endpoint | ||
| 390 | Env -->|"environment bearer + signed activity proof"| Worker | ||
| 391 | </pre> | ||
| 392 | </section> | ||
| 393 | |||
| 394 | <section class="panel" aria-label="Authentication matrix"> | ||
| 395 | <h2>Authentication and Transport Matrix</h2> | ||
| 396 | <table> | ||
| 397 | <thead> | ||
| 398 | <tr> | ||
| 399 | <th>Boundary</th> | ||
| 400 | <th>Transport</th> | ||
| 401 | <th>Authentication</th> | ||
| 402 | <th>Purpose</th> | ||
| 403 | </tr> | ||
| 404 | </thead> | ||
| 405 | <tbody> | ||
| 406 | <tr> | ||
| 407 | <td>Web or mobile -> relay link management</td> | ||
| 408 | <td>HTTPS</td> | ||
| 409 | <td>Clerk session-template bearer token</td> | ||
| 410 | <td>Create a link challenge, submit an environment proof, list links, or unlink.</td> | ||
| 411 | </tr> | ||
| 412 | <tr> | ||
| 413 | <td>CLI -> relay link management</td> | ||
| 414 | <td>HTTPS</td> | ||
| 415 | <td>Clerk OAuth access token obtained with PKCE</td> | ||
| 416 | <td>Reconcile the desired headless cloud link when the server starts.</td> | ||
| 417 | </tr> | ||
| 418 | <tr> | ||
| 419 | <td>Web or mobile -> relay protected client endpoints</td> | ||
| 420 | <td>HTTPS</td> | ||
| 421 | <td>Relay-issued DPoP token plus per-request DPoP proof</td> | ||
| 422 | <td> | ||
| 423 | Check environment status, request a remote connection, and register mobile devices. | ||
| 424 | </td> | ||
| 425 | </tr> | ||
| 426 | <tr> | ||
| 427 | <td>Relay -> managed environment endpoint</td> | ||
| 428 | <td>HTTPS over the managed tunnel</td> | ||
| 429 | <td>Short-lived relay-signed JWT request proof</td> | ||
| 430 | <td> | ||
| 431 | Request a signed health response or ask the local environment to mint a credential. | ||
| 432 | </td> | ||
| 433 | </tr> | ||
| 434 | <tr> | ||
| 435 | <td>Environment -> relay activity publication</td> | ||
| 436 | <td>HTTPS</td> | ||
| 437 | <td>Relay-issued environment bearer credential plus environment-signed JWT proof</td> | ||
| 438 | <td> | ||
| 439 | Publish redacted agent-activity state for push notifications and Live Activities. | ||
| 440 | </td> | ||
| 441 | </tr> | ||
| 442 | <tr> | ||
| 443 | <td>Remote client -> environment</td> | ||
| 444 | <td>HTTPS and WSS over the managed tunnel</td> | ||
| 445 | <td>Environment-issued access token bound to the client DPoP key</td> | ||
| 446 | <td>Use the normal environment HTTP APIs and request a one-time WebSocket ticket.</td> | ||
| 447 | </tr> | ||
| 448 | </tbody> | ||
| 449 | </table> | ||
| 450 | </section> | ||
| 451 | |||
| 452 | <section class="panel" aria-label="Credential ownership"> | ||
| 453 | <h2>Credential Ownership</h2> | ||
| 454 | <table> | ||
| 455 | <thead> | ||
| 456 | <tr> | ||
| 457 | <th>Credential</th> | ||
| 458 | <th>Created by</th> | ||
| 459 | <th>Stored by</th> | ||
| 460 | <th>Use</th> | ||
| 461 | </tr> | ||
| 462 | </thead> | ||
| 463 | <tbody> | ||
| 464 | <tr> | ||
| 465 | <td>Clerk session-template JWT</td> | ||
| 466 | <td>Clerk</td> | ||
| 467 | <td>Web or mobile client</td> | ||
| 468 | <td> | ||
| 469 | Authenticate cloud-user relay requests and bootstrap relay DPoP token exchange. | ||
| 470 | </td> | ||
| 471 | </tr> | ||
| 472 | <tr> | ||
| 473 | <td>CLI OAuth access and refresh token</td> | ||
| 474 | <td>Clerk OAuth</td> | ||
| 475 | <td>Local server secret store</td> | ||
| 476 | <td>Authorize headless CLI link reconciliation.</td> | ||
| 477 | </tr> | ||
| 478 | <tr> | ||
| 479 | <td>Environment Ed25519 key pair</td> | ||
| 480 | <td>Local environment server</td> | ||
| 481 | <td>Local server secret store</td> | ||
| 482 | <td> | ||
| 483 | Sign link proofs, health responses, mint responses, and activity publication proofs. | ||
| 484 | </td> | ||
| 485 | </tr> | ||
| 486 | <tr> | ||
| 487 | <td>Relay cloud-mint key pair</td> | ||
| 488 | <td>Relay deployment</td> | ||
| 489 | <td>Private key in relay Worker config; public key installed into the environment</td> | ||
| 490 | <td>Sign relay-to-environment health and mint requests.</td> | ||
| 491 | </tr> | ||
| 492 | <tr> | ||
| 493 | <td>Relay environment bearer credential</td> | ||
| 494 | <td>Relay</td> | ||
| 495 | <td>Hashed in relay Postgres; plaintext in local server secret store</td> | ||
| 496 | <td>Authenticate environment-to-relay agent-activity publication.</td> | ||
| 497 | </tr> | ||
| 498 | <tr> | ||
| 499 | <td>Relay DPoP access token</td> | ||
| 500 | <td>Relay</td> | ||
| 501 | <td>Remote client memory cache</td> | ||
| 502 | <td> | ||
| 503 | Authorize status, connect, and mobile registration endpoints with proof of | ||
| 504 | possession. | ||
| 505 | </td> | ||
| 506 | </tr> | ||
| 507 | <tr> | ||
| 508 | <td>Environment bootstrap credential</td> | ||
| 509 | <td>Local environment server</td> | ||
| 510 | <td>Passed relay -> remote client</td> | ||
| 511 | <td>One-time exchange at the environment <code>/oauth/token</code> endpoint.</td> | ||
| 512 | </tr> | ||
| 513 | <tr> | ||
| 514 | <td>Environment access token</td> | ||
| 515 | <td>Local environment server</td> | ||
| 516 | <td>Remote client</td> | ||
| 517 | <td>Authorize environment HTTP operations and WebSocket ticket issuance.</td> | ||
| 518 | </tr> | ||
| 519 | <tr> | ||
| 520 | <td>WebSocket ticket</td> | ||
| 521 | <td>Local environment server</td> | ||
| 522 | <td>Remote client until consumed</td> | ||
| 523 | <td>Short-lived one-time credential for the WSS upgrade.</td> | ||
| 524 | </tr> | ||
| 525 | <tr> | ||
| 526 | <td>Relay client connector token</td> | ||
| 527 | <td>Cloudflare tunnel API</td> | ||
| 528 | <td>Local server secret store</td> | ||
| 529 | <td> | ||
| 530 | Start the local relay client. It is passed through <code>TUNNEL_TOKEN</code>, not a | ||
| 531 | shell argument. | ||
| 532 | </td> | ||
| 533 | </tr> | ||
| 534 | </tbody> | ||
| 535 | </table> | ||
| 536 | </section> | ||
| 537 | |||
| 538 | <section class="grid" aria-label="Core flows"> | ||
| 539 | <article class="panel"> | ||
| 540 | <h2>Flow 1: Link From the Desktop Web UI</h2> | ||
| 541 | <pre class="diagram mermaid"> | ||
| 542 | sequenceDiagram | ||
| 543 | participant U as Signed-in user | ||
| 544 | participant C as Desktop web UI | ||
| 545 | participant E as Local environment | ||
| 546 | participant R as Relay Worker | ||
| 547 | participant CF as Cloudflare APIs | ||
| 548 | participant RC as Local relay client | ||
| 549 | |||
| 550 | U->>C: Enable T3 Cloud for a locally paired environment | ||
| 551 | C->>E: Check relay client availability | ||
| 552 | alt relay client is missing | ||
| 553 | C->>U: Prompt before download and install | ||
| 554 | U->>C: Confirm | ||
| 555 | C->>E: Stream relay-client install RPC | ||
| 556 | E-->>C: Progress stages | ||
| 557 | end | ||
| 558 | C->>R: POST /v1/client/environment-link-challenges (Clerk bearer) | ||
| 559 | R-->>C: Short-lived challenge | ||
| 560 | C->>E: POST /api/cloud/link-proof (local relay:write bearer) | ||
| 561 | E->>E: Authenticate, reject forwarded authority, get or create key pair, validate loopback origin | ||
| 562 | E-->>C: Environment-signed link proof | ||
| 563 | C->>R: POST /v1/client/environment-links (Clerk bearer + proof) | ||
| 564 | R->>R: Verify challenge, proof, capabilities, nonce, and loopback origin | ||
| 565 | R->>CF: Reconcile tunnel, ingress, and CNAME | ||
| 566 | R->>R: Upsert user/environment link and issue environment bearer | ||
| 567 | R-->>C: Managed endpoint, relay config, and connector token | ||
| 568 | C->>E: POST /api/cloud/relay-config (local relay:write bearer) | ||
| 569 | E->>RC: Start relay client with TUNNEL_TOKEN | ||
| 570 | </pre> | ||
| 571 | <div class="callout"> | ||
| 572 | The local link-proof endpoint requires <code>relay:write</code>, rejects forwarded | ||
| 573 | authority headers, and accepts only an exact loopback origin. Environment keypair | ||
| 574 | persistence is atomic across concurrent link attempts. | ||
| 575 | </div> | ||
| 576 | <p> | ||
| 577 | A locally paired mobile client uses the same relay challenge, local proof, relay link, | ||
| 578 | and local relay-config endpoints. The desktop web UI is the surface that checks relay | ||
| 579 | client availability and offers the managed install dialog. | ||
| 580 | </p> | ||
| 581 | </article> | ||
| 582 | |||
| 583 | <article class="panel"> | ||
| 584 | <h2>Flow 2: Headless CLI Link</h2> | ||
| 585 | <pre class="diagram mermaid"> | ||
| 586 | sequenceDiagram | ||
| 587 | participant U as Operator | ||
| 588 | participant CLI as t3 cloud CLI | ||
| 589 | participant Clerk as Clerk OAuth | ||
| 590 | participant S as Environment server on next start | ||
| 591 | participant R as Relay Worker | ||
| 592 | participant RC as Local relay client | ||
| 593 | |||
| 594 | U->>CLI: t3 cloud link | ||
| 595 | CLI->>CLI: Resolve relay client | ||
| 596 | alt relay client is missing | ||
| 597 | CLI->>U: Confirm managed relay-client install | ||
| 598 | U->>CLI: Confirm | ||
| 599 | CLI->>CLI: Install with terminal progress updates | ||
| 600 | end | ||
| 601 | CLI->>Clerk: Browser PKCE authorization-code flow | ||
| 602 | Clerk-->>CLI: OAuth access and refresh tokens | ||
| 603 | CLI->>CLI: Persist desired cloud-link state | ||
| 604 | U->>S: Start T3 environment server | ||
| 605 | S->>R: Create challenge and submit local signed proof | ||
| 606 | R-->>S: Managed endpoint config and connector token | ||
| 607 | S->>RC: Start relay client | ||
| 608 | </pre> | ||
| 609 | <p> | ||
| 610 | <code>t3 cloud login</code> stores authorization without enabling exposure. | ||
| 611 | <code>t3 cloud unlink</code> disables exposure while retaining authorization. | ||
| 612 | <code>t3 cloud logout</code> also removes the stored CLI authorization. | ||
| 613 | </p> | ||
| 614 | </article> | ||
| 615 | |||
| 616 | <article class="panel"> | ||
| 617 | <h2>Flow 3: Remote Connect Bootstrap</h2> | ||
| 618 | <pre class="diagram mermaid"> | ||
| 619 | sequenceDiagram | ||
| 620 | participant C as Web or mobile client | ||
| 621 | participant Clerk as Clerk | ||
| 622 | participant R as Relay Worker | ||
| 623 | participant E as Managed environment endpoint | ||
| 624 | |||
| 625 | C->>Clerk: Get Clerk session-template JWT | ||
| 626 | C->>R: POST /v1/client/dpop-token (Clerk JWT + DPoP proof) | ||
| 627 | R-->>C: Relay DPoP access token | ||
| 628 | C->>R: POST /v1/environments/:id/connect (relay DPoP) | ||
| 629 | R->>E: POST /api/t3-cloud/mint-credential (relay-signed proof) | ||
| 630 | E->>E: Verify relay signer, linked user, scope, lifetime, cnf, and replay guards | ||
| 631 | E-->>R: Bootstrap credential + environment-signed proof | ||
| 632 | R->>R: Verify environment signature, nonce, endpoint, and DPoP binding | ||
| 633 | R-->>C: Managed endpoint + bootstrap credential | ||
| 634 | C->>E: POST /oauth/token (bootstrap credential + DPoP proof) | ||
| 635 | E-->>C: Environment DPoP-bound access token | ||
| 636 | C->>E: POST /api/auth/websocket-ticket | ||
| 637 | E-->>C: One-time WebSocket ticket | ||
| 638 | C->>E: WSS /ws with ticket | ||
| 639 | </pre> | ||
| 640 | <div class="callout"> | ||
| 641 | Relay-to-environment control requests disable redirects, use only a reconciled managed | ||
| 642 | endpoint allocation, and require a signed environment response before the relay returns | ||
| 643 | data to the client. | ||
| 644 | </div> | ||
| 645 | </article> | ||
| 646 | |||
| 647 | <article class="panel"> | ||
| 648 | <h2>Flow 4: Agent Activity Notifications</h2> | ||
| 649 | <pre class="diagram mermaid"> | ||
| 650 | sequenceDiagram | ||
| 651 | participant E as Local environment | ||
| 652 | participant R as Relay Worker | ||
| 653 | participant DB as Relay Postgres | ||
| 654 | participant Q as APNs queue | ||
| 655 | participant APNs as Apple Push Notification service | ||
| 656 | participant M as Mobile app | ||
| 657 | |||
| 658 | E->>E: Project publishable thread state and redact failure detail | ||
| 659 | E->>R: POST /v1/environments/:environmentId/threads/:threadId/agent-activity | ||
| 660 | Note over E,R: Environment bearer credential + environment-signed per-state proof | ||
| 661 | R->>R: Verify credential, signature, scope, expiry, and nonce | ||
| 662 | R->>DB: Store current activity row | ||
| 663 | R->>Q: Enqueue push or Live Activity delivery jobs | ||
| 664 | Q->>APNs: Deliver signed APNs request | ||
| 665 | APNs-->>M: Push notification or Live Activity update | ||
| 666 | </pre> | ||
| 667 | <p> | ||
| 668 | The local server publishes a deliberately narrow projection. Failed runs use a fixed | ||
| 669 | redacted summary, and detail strings are capped before publication. | ||
| 670 | </p> | ||
| 671 | </article> | ||
| 672 | </section> | ||
| 673 | |||
| 674 | <section class="grid" aria-label="Managed endpoint lifecycle"> | ||
| 675 | <article class="panel half"> | ||
| 676 | <h2>Managed Endpoint Reconciliation</h2> | ||
| 677 | <p> | ||
| 678 | Managed endpoint resources are keyed by <code>(userId, environmentId)</code>. The relay | ||
| 679 | reserves a stable hostname and tunnel name before provisioning, then checkpoints the | ||
| 680 | tunnel ID, DNS record ID, and ready state in Postgres. | ||
| 681 | </p> | ||
| 682 | <div class="flow"> | ||
| 683 | <div class="step"> | ||
| 684 | <strong>1. Reserve deterministic allocation</strong> | ||
| 685 | Hostname and tunnel name derive from relay stage, cloud user, and environment ID. | ||
| 686 | </div> | ||
| 687 | <div class="step"> | ||
| 688 | <strong>2. Reuse or create tunnel</strong> | ||
| 689 | Existing named tunnels are reused. Tunnel ingress is rewritten to the validated | ||
| 690 | loopback origin. | ||
| 691 | </div> | ||
| 692 | <div class="step"> | ||
| 693 | <strong>3. Reconcile DNS</strong> | ||
| 694 | Existing CNAMEs are updated and duplicate records are removed. Create conflicts are | ||
| 695 | recovered by listing and reconciling the winning record. | ||
| 696 | </div> | ||
| 697 | <div class="step"> | ||
| 698 | <strong>4. Mark ready</strong> | ||
| 699 | Connect and status requests use only ready allocations whose hostname still matches | ||
| 700 | the relay-managed namespace. | ||
| 701 | </div> | ||
| 702 | </div> | ||
| 703 | </article> | ||
| 704 | |||
| 705 | <article class="panel half"> | ||
| 706 | <h2>Unlink Cleanup</h2> | ||
| 707 | <p> | ||
| 708 | Relay unlink removes the user's managed endpoint allocation before revoking the | ||
| 709 | user/environment link: | ||
| 710 | </p> | ||
| 711 | <ul> | ||
| 712 | <li>delete the managed DNS record if present;</li> | ||
| 713 | <li>delete the Cloudflare tunnel if present;</li> | ||
| 714 | <li>remove the allocation row;</li> | ||
| 715 | <li>revoke the user's environment link; and</li> | ||
| 716 | <li> | ||
| 717 | revoke the environment publication credential only after the final active link for | ||
| 718 | that environment key disappears. | ||
| 719 | </li> | ||
| 720 | </ul> | ||
| 721 | <p> | ||
| 722 | Local unlink stops the relay client and removes the locally persisted relay URL, issuer, | ||
| 723 | linked user, environment publication credential, cloud-mint public key, connector | ||
| 724 | config, and agent-activity preference. | ||
| 725 | </p> | ||
| 726 | </article> | ||
| 727 | </section> | ||
| 728 | |||
| 729 | <section class="panel" aria-label="Cloudflare tunnel operational profile"> | ||
| 730 | <h2>Cloudflare Tunnel Operational Profile</h2> | ||
| 731 | <p> | ||
| 732 | Managed tunnels are private application backends, not general-purpose public hosting. The | ||
| 733 | relay provisions one named Cloudflare tunnel and one DNS record per active | ||
| 734 | <code>(userId, environmentId)</code> allocation. | ||
| 735 | </p> | ||
| 736 | <ul> | ||
| 737 | <li> | ||
| 738 | <strong>Lifecycle:</strong> the tunnel is created when an environment is linked, reused | ||
| 739 | across retry-safe reconciliation, kept for the lifetime of that link, and deleted on | ||
| 740 | unlink. If teardown fails, the allocation checkpoint remains so cleanup can be retried. | ||
| 741 | </li> | ||
| 742 | <li> | ||
| 743 | <strong>Origin:</strong> tunnel ingress is restricted to the linked environment server's | ||
| 744 | validated loopback HTTP origin. Arbitrary upstream hosts and raw TCP origins are | ||
| 745 | rejected. | ||
| 746 | </li> | ||
| 747 | <li> | ||
| 748 | <strong>Protocols:</strong> managed endpoints carry HTTPS request/response traffic and | ||
| 749 | WSS connections only. Remote clients use the normal environment HTTP APIs and | ||
| 750 | authenticated WebSocket RPC transport through the tunnel. | ||
| 751 | </li> | ||
| 752 | <li> | ||
| 753 | <strong>Relay-generated traffic:</strong> the hosted relay sends sparse HTTPS health | ||
| 754 | checks and credential-mint requests during status checks and remote connection | ||
| 755 | bootstrap. It does not proxy steady-state remote sessions. | ||
| 756 | </li> | ||
| 757 | <li> | ||
| 758 | <strong>Traffic shape:</strong> expected load is interactive and user-driven. Active | ||
| 759 | remote sessions may keep WebSockets open and exchange terminal, agent, and UI updates; | ||
| 760 | inactive linked environments should carry little or no tunnel traffic. | ||
| 761 | </li> | ||
| 762 | <li> | ||
| 763 | <strong>Capacity planning:</strong> the project is pre-launch, so there is not yet a | ||
| 764 | production bandwidth baseline. Tunnel count grows with active linked user/environment | ||
| 765 | allocations, while bandwidth and concurrent WebSockets grow with active remote use. | ||
| 766 | </li> | ||
| 767 | </ul> | ||
| 768 | </section> | ||
| 769 | |||
| 770 | <section class="panel" aria-label="SSRF and tunnel hardening"> | ||
| 771 | <h2>SSRF and Tunnel Hardening</h2> | ||
| 772 | <table> | ||
| 773 | <thead> | ||
| 774 | <tr> | ||
| 775 | <th>Risk</th> | ||
| 776 | <th>Implemented control</th> | ||
| 777 | </tr> | ||
| 778 | </thead> | ||
| 779 | <tbody> | ||
| 780 | <tr> | ||
| 781 | <td>A client asks the relay to tunnel an arbitrary host.</td> | ||
| 782 | <td> | ||
| 783 | The environment and relay both accept managed tunnel origins only for loopback hosts | ||
| 784 | and valid ports. | ||
| 785 | </td> | ||
| 786 | </tr> | ||
| 787 | <tr> | ||
| 788 | <td>A forwarded request tricks local origin validation.</td> | ||
| 789 | <td> | ||
| 790 | The local link-proof handler requires an exact loopback request URL and rejects | ||
| 791 | forwarded host or protocol headers. | ||
| 792 | </td> | ||
| 793 | </tr> | ||
| 794 | <tr> | ||
| 795 | <td>A stored endpoint is replaced with an arbitrary external URL.</td> | ||
| 796 | <td> | ||
| 797 | Connect and status resolve a ready allocation from Postgres and require a hostname | ||
| 798 | under the configured managed endpoint zone. | ||
| 799 | </td> | ||
| 800 | </tr> | ||
| 801 | <tr> | ||
| 802 | <td>A managed endpoint redirects relay egress elsewhere.</td> | ||
| 803 | <td> | ||
| 804 | Relay-to-environment status and mint requests set redirect handling to | ||
| 805 | <code>manual</code>. | ||
| 806 | </td> | ||
| 807 | </tr> | ||
| 808 | <tr> | ||
| 809 | <td>A process behind the tunnel returns attacker-controlled data.</td> | ||
| 810 | <td> | ||
| 811 | The relay verifies environment-signed health and mint responses against the linked | ||
| 812 | environment public key, expected nonce, and request binding. | ||
| 813 | </td> | ||
| 814 | </tr> | ||
| 815 | <tr> | ||
| 816 | <td>A stolen remote access token is replayed.</td> | ||
| 817 | <td> | ||
| 818 | Relay and environment access tokens are DPoP-bound; per-request proofs and replay | ||
| 819 | guards are required. | ||
| 820 | </td> | ||
| 821 | </tr> | ||
| 822 | <tr> | ||
| 823 | <td>The relay client token leaks through process listings or shell parsing.</td> | ||
| 824 | <td> | ||
| 825 | The relay client is spawned without a shell and receives its connector token in | ||
| 826 | <code>TUNNEL_TOKEN</code>. | ||
| 827 | </td> | ||
| 828 | </tr> | ||
| 829 | </tbody> | ||
| 830 | </table> | ||
| 831 | <div class="callout warning"> | ||
| 832 | Managed tunnel hostnames live under the configured tunnel DNS zone. Serving them from a | ||
| 833 | dedicated registrable domain with a Public Suffix List entry remains an operational | ||
| 834 | isolation requirement before broad untrusted use. | ||
| 835 | </div> | ||
| 836 | </section> | ||
| 837 | |||
| 838 | <section class="panel" aria-label="Relay HTTP API"> | ||
| 839 | <h2>Relay HTTP API</h2> | ||
| 840 | <p> | ||
| 841 | The relay publishes an OpenAPI document at <code>/openapi.json</code>, interactive API | ||
| 842 | docs at <code>/docs</code>, and redirects <code>/</code> to the docs. | ||
| 843 | </p> | ||
| 844 | <table> | ||
| 845 | <thead> | ||
| 846 | <tr> | ||
| 847 | <th>Endpoint</th> | ||
| 848 | <th>Authentication</th> | ||
| 849 | <th>Purpose</th> | ||
| 850 | </tr> | ||
| 851 | </thead> | ||
| 852 | <tbody> | ||
| 853 | <tr> | ||
| 854 | <td><code>GET /health</code></td> | ||
| 855 | <td>None</td> | ||
| 856 | <td>Relay health check.</td> | ||
| 857 | </tr> | ||
| 858 | <tr> | ||
| 859 | <td><code>GET /.well-known/oauth-authorization-server</code></td> | ||
| 860 | <td>None</td> | ||
| 861 | <td>Relay OAuth token-exchange discovery metadata.</td> | ||
| 862 | </tr> | ||
| 863 | <tr> | ||
| 864 | <td><code>GET /.well-known/oauth-protected-resource</code></td> | ||
| 865 | <td>None</td> | ||
| 866 | <td>Relay DPoP protected-resource discovery metadata.</td> | ||
| 867 | </tr> | ||
| 868 | <tr> | ||
| 869 | <td><code>GET /v1/environments</code></td> | ||
| 870 | <td>Clerk bearer</td> | ||
| 871 | <td>List environments linked to the signed-in user.</td> | ||
| 872 | </tr> | ||
| 873 | <tr> | ||
| 874 | <td><code>POST /v1/client/environment-link-challenges</code></td> | ||
| 875 | <td>Clerk bearer</td> | ||
| 876 | <td>Create a short-lived user-bound environment link challenge.</td> | ||
| 877 | </tr> | ||
| 878 | <tr> | ||
| 879 | <td><code>POST /v1/client/environment-links</code></td> | ||
| 880 | <td>Clerk bearer</td> | ||
| 881 | <td> | ||
| 882 | Verify a local environment proof, reconcile a managed endpoint, and upsert the user | ||
| 883 | link. | ||
| 884 | </td> | ||
| 885 | </tr> | ||
| 886 | <tr> | ||
| 887 | <td><code>DELETE /v1/client/environment-links/:environmentId</code></td> | ||
| 888 | <td>Clerk bearer</td> | ||
| 889 | <td>Remove managed endpoint resources and revoke the user's link.</td> | ||
| 890 | </tr> | ||
| 891 | <tr> | ||
| 892 | <td><code>POST /v1/client/dpop-token</code></td> | ||
| 893 | <td>Clerk token in token-exchange payload plus DPoP proof</td> | ||
| 894 | <td>Issue a relay DPoP access token for the requested supported scopes.</td> | ||
| 895 | </tr> | ||
| 896 | <tr> | ||
| 897 | <td><code>POST /v1/environments/:environmentId/status</code></td> | ||
| 898 | <td>Relay DPoP</td> | ||
| 899 | <td>Request and validate a signed environment health response.</td> | ||
| 900 | </tr> | ||
| 901 | <tr> | ||
| 902 | <td><code>POST /v1/environments/:environmentId/connect</code></td> | ||
| 903 | <td>Relay DPoP</td> | ||
| 904 | <td>Request and validate an environment bootstrap credential.</td> | ||
| 905 | </tr> | ||
| 906 | <tr> | ||
| 907 | <td><code>POST /v1/mobile/devices</code></td> | ||
| 908 | <td>Relay DPoP</td> | ||
| 909 | <td>Register or update a mobile device for notifications.</td> | ||
| 910 | </tr> | ||
| 911 | <tr> | ||
| 912 | <td><code>POST /v1/mobile/live-activities</code></td> | ||
| 913 | <td>Relay DPoP</td> | ||
| 914 | <td>Register a Live Activity push token.</td> | ||
| 915 | </tr> | ||
| 916 | <tr> | ||
| 917 | <td><code>DELETE /v1/mobile/devices/:deviceId</code></td> | ||
| 918 | <td>Relay DPoP</td> | ||
| 919 | <td>Unregister a mobile device.</td> | ||
| 920 | </tr> | ||
| 921 | <tr> | ||
| 922 | <td> | ||
| 923 | <code>POST /v1/environments/:environmentId/threads/:threadId/agent-activity</code> | ||
| 924 | </td> | ||
| 925 | <td>Environment bearer plus signed publish proof</td> | ||
| 926 | <td>Publish redacted current agent activity for notification delivery.</td> | ||
| 927 | </tr> | ||
| 928 | </tbody> | ||
| 929 | </table> | ||
| 930 | </section> | ||
| 931 | |||
| 932 | <section class="panel" aria-label="Environment HTTP API"> | ||
| 933 | <h2>Environment Cloud HTTP API</h2> | ||
| 934 | <table> | ||
| 935 | <thead> | ||
| 936 | <tr> | ||
| 937 | <th>Endpoint</th> | ||
| 938 | <th>Authentication</th> | ||
| 939 | <th>Purpose</th> | ||
| 940 | </tr> | ||
| 941 | </thead> | ||
| 942 | <tbody> | ||
| 943 | <tr> | ||
| 944 | <td><code>POST /api/cloud/link-proof</code></td> | ||
| 945 | <td>Local environment token with <code>relay:write</code></td> | ||
| 946 | <td>Validate local origin and sign an environment link proof.</td> | ||
| 947 | </tr> | ||
| 948 | <tr> | ||
| 949 | <td><code>POST /api/cloud/relay-config</code></td> | ||
| 950 | <td>Local environment token with <code>relay:write</code></td> | ||
| 951 | <td>Persist linked relay configuration and start the relay client.</td> | ||
| 952 | </tr> | ||
| 953 | <tr> | ||
| 954 | <td><code>GET /api/cloud/link-state</code></td> | ||
| 955 | <td>Local environment token with <code>relay:read</code></td> | ||
| 956 | <td>Read the environment's current T3 Cloud link state.</td> | ||
| 957 | </tr> | ||
| 958 | <tr> | ||
| 959 | <td><code>POST /api/cloud/preferences</code></td> | ||
| 960 | <td>Local environment token with <code>relay:write</code></td> | ||
| 961 | <td>Enable or disable agent-activity publication.</td> | ||
| 962 | </tr> | ||
| 963 | <tr> | ||
| 964 | <td><code>POST /api/cloud/unlink</code></td> | ||
| 965 | <td>Local environment token with <code>relay:write</code></td> | ||
| 966 | <td>Stop the relay client and clear local T3 Cloud configuration.</td> | ||
| 967 | </tr> | ||
| 968 | <tr> | ||
| 969 | <td><code>POST /api/t3-cloud/health</code></td> | ||
| 970 | <td>Relay-signed proof</td> | ||
| 971 | <td>Return a signed environment health response.</td> | ||
| 972 | </tr> | ||
| 973 | <tr> | ||
| 974 | <td><code>POST /api/t3-cloud/mint-credential</code></td> | ||
| 975 | <td>Relay-signed proof</td> | ||
| 976 | <td>Mint a DPoP-bound one-time bootstrap credential for a remote client.</td> | ||
| 977 | </tr> | ||
| 978 | <tr> | ||
| 979 | <td><code>POST /oauth/token</code></td> | ||
| 980 | <td>Bootstrap credential plus DPoP proof</td> | ||
| 981 | <td>Exchange a bootstrap credential for an environment access token.</td> | ||
| 982 | </tr> | ||
| 983 | <tr> | ||
| 984 | <td><code>POST /api/auth/websocket-ticket</code></td> | ||
| 985 | <td>Environment access token</td> | ||
| 986 | <td>Issue a one-time ticket for the WSS upgrade.</td> | ||
| 987 | </tr> | ||
| 988 | </tbody> | ||
| 989 | </table> | ||
| 990 | </section> | ||
| 991 | |||
| 992 | <section class="grid" aria-label="Runtime and infrastructure"> | ||
| 993 | <article class="panel half"> | ||
| 994 | <h2>Relay Client Installation</h2> | ||
| 995 | <p> | ||
| 996 | Desktop web UI availability checks and installs are local WebSocket RPC methods: | ||
| 997 | <code>cloud.getRelayClientStatus</code> and streaming | ||
| 998 | <code>cloud.installRelayClient</code>. The install stream emits the current stage: | ||
| 999 | checking, waiting for lock, downloading, verifying, installing, validating, and | ||
| 1000 | activating. | ||
| 1001 | </p> | ||
| 1002 | <p> | ||
| 1003 | The web UI asks for confirmation before installation and displays a custom progress | ||
| 1004 | dialog. The CLI invokes the same local relay-client service directly and reports the | ||
| 1005 | same progress stages in the terminal. | ||
| 1006 | </p> | ||
| 1007 | </article> | ||
| 1008 | |||
| 1009 | <article class="panel half"> | ||
| 1010 | <h2>Relay Infrastructure</h2> | ||
| 1011 | <ul> | ||
| 1012 | <li>Cloudflare Worker for the hosted relay HTTP API.</li> | ||
| 1013 | <li>PlanetScale Postgres database named <code>t3coderelay</code>.</li> | ||
| 1014 | <li> | ||
| 1015 | Production uses the shared database; non-production stages use database branches. | ||
| 1016 | </li> | ||
| 1017 | <li> | ||
| 1018 | Cloudflare Hyperdrive with caching disabled and a constrained origin connection limit. | ||
| 1019 | </li> | ||
| 1020 | <li>Cloudflare tunnel and DNS bindings for managed endpoint reconciliation.</li> | ||
| 1021 | <li>Cloudflare APNs delivery queue plus dead-letter queue.</li> | ||
| 1022 | <li>A five-minute cron job that prunes expired DPoP replay rows.</li> | ||
| 1023 | <li>Axiom OTLP trace dataset, scoped ingest token, and recent-spans view per stage.</li> | ||
| 1024 | </ul> | ||
| 1025 | </article> | ||
| 1026 | </section> | ||
| 1027 | |||
| 1028 | <section class="panel" aria-label="Deployment configuration"> | ||
| 1029 | <h2>Deployment Configuration</h2> | ||
| 1030 | <table> | ||
| 1031 | <thead> | ||
| 1032 | <tr> | ||
| 1033 | <th>Boundary</th> | ||
| 1034 | <th>Configuration</th> | ||
| 1035 | </tr> | ||
| 1036 | </thead> | ||
| 1037 | <tbody> | ||
| 1038 | <tr> | ||
| 1039 | <td>Relay infrastructure</td> | ||
| 1040 | <td> | ||
| 1041 | <code>RELAY_API_ZONE_NAME</code>, <code>RELAY_TUNNEL_ZONE_NAME</code>, optional | ||
| 1042 | <code>RELAY_DOMAIN</code>, <code>CLERK_PUBLISHABLE_KEY</code>, | ||
| 1043 | <code>CLERK_SECRET_KEY</code>, <code>CLERK_JWT_AUDIENCE</code>, and APNs | ||
| 1044 | credentials. | ||
| 1045 | </td> | ||
| 1046 | </tr> | ||
| 1047 | <tr> | ||
| 1048 | <td>Source-built desktop/server cloud features</td> | ||
| 1049 | <td> | ||
| 1050 | Optional <code>T3CODE_RELAY_URL</code>, <code>T3CODE_CLERK_PUBLISHABLE_KEY</code>, | ||
| 1051 | and <code>T3CODE_CLERK_CLI_OAUTH_CLIENT_ID</code>. Release builds inject public | ||
| 1052 | values at build time. | ||
| 1053 | </td> | ||
| 1054 | </tr> | ||
| 1055 | <tr> | ||
| 1056 | <td>Web and mobile cloud clients</td> | ||
| 1057 | <td> | ||
| 1058 | Public Clerk publishable key, Clerk JWT template name, and relay URL. Relay URLs | ||
| 1059 | must normalize to an absolute HTTPS origin without credentials, query, fragment, or | ||
| 1060 | non-root path. | ||
| 1061 | </td> | ||
| 1062 | </tr> | ||
| 1063 | </tbody> | ||
| 1064 | </table> | ||
| 1065 | </section> | ||
| 1066 | |||
| 1067 | <section class="panel" aria-label="Implementation checklist"> | ||
| 1068 | <h2>Implementation Checklist</h2> | ||
| 1069 | <ul> | ||
| 1070 | <li> | ||
| 1071 | Keep the relay cloud-mint private key hosted-only; install only its public key into | ||
| 1072 | environments. | ||
| 1073 | </li> | ||
| 1074 | <li> | ||
| 1075 | Keep environment signing private keys local and persist keypair creation atomically. | ||
| 1076 | </li> | ||
| 1077 | <li> | ||
| 1078 | Require a local <code>relay:write</code> session before linking or changing relay | ||
| 1079 | config. | ||
| 1080 | </li> | ||
| 1081 | <li>Validate relay URLs as secure absolute HTTPS origins before sending credentials.</li> | ||
| 1082 | <li>Require loopback-only tunnel origins on both sides of the link-proof boundary.</li> | ||
| 1083 | <li> | ||
| 1084 | Resolve managed endpoint egress from ready allocation rows, not client-supplied URLs. | ||
| 1085 | </li> | ||
| 1086 | <li>Disable redirects on relay-to-environment requests.</li> | ||
| 1087 | <li>Validate environment-signed response proofs before consuming tunneled responses.</li> | ||
| 1088 | <li>Keep tunnel and DNS provisioning retry-safe and deprovision them on unlink.</li> | ||
| 1089 | <li> | ||
| 1090 | Revoke shared environment publication credentials only after the final active link | ||
| 1091 | disappears. | ||
| 1092 | </li> | ||
| 1093 | <li>Redact and cap local agent failure details before publishing them externally.</li> | ||
| 1094 | </ul> | ||
| 1095 | </section> | ||
| 1096 | |||
| 1097 | <section class="panel" aria-label="Standards references"> | ||
| 1098 | <h2>Standards References</h2> | ||
| 1099 | <ul class="source-list"> | ||
| 1100 | <li> | ||
| 1101 | <a href="https://www.rfc-editor.org/rfc/rfc8252">RFC 8252</a>: browser-based OAuth for | ||
| 1102 | native apps, used by the CLI PKCE authorization flow. | ||
| 1103 | </li> | ||
| 1104 | <li> | ||
| 1105 | <a href="https://www.rfc-editor.org/rfc/rfc8693">RFC 8693</a>: OAuth token exchange, | ||
| 1106 | used for Clerk-token to relay-DPoP-token exchange. | ||
| 1107 | </li> | ||
| 1108 | <li> | ||
| 1109 | <a href="https://www.rfc-editor.org/rfc/rfc9449">RFC 9449</a>: DPoP proof of possession, | ||
| 1110 | used for relay and environment access tokens. | ||
| 1111 | </li> | ||
| 1112 | </ul> | ||
| 1113 | </section> | ||
| 1114 | </main> | ||
| 1115 | <script type="module"> | ||
| 1116 | import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"; | ||
| 1117 | |||
| 1118 | mermaid.initialize({ | ||
| 1119 | startOnLoad: false, | ||
| 1120 | securityLevel: "strict", | ||
| 1121 | theme: "base", | ||
| 1122 | themeVariables: { | ||
| 1123 | fontFamily: 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', | ||
| 1124 | primaryColor: "#eef4ff", | ||
| 1125 | primaryBorderColor: "#1457d9", | ||
| 1126 | primaryTextColor: "#171717", | ||
| 1127 | lineColor: "#62646b", | ||
| 1128 | signalColor: "#171717", | ||
| 1129 | signalTextColor: "#171717", | ||
| 1130 | noteBkgColor: "#fff6e6", | ||
| 1131 | noteTextColor: "#171717", | ||
| 1132 | actorBkg: "#fbfbf8", | ||
| 1133 | actorBorder: "#62646b", | ||
| 1134 | actorTextColor: "#171717", | ||
| 1135 | }, | ||
| 1136 | flowchart: { | ||
| 1137 | htmlLabels: true, | ||
| 1138 | curve: "basis", | ||
| 1139 | padding: 18, | ||
| 1140 | }, | ||
| 1141 | sequence: { | ||
| 1142 | showSequenceNumbers: true, | ||
| 1143 | mirrorActors: false, | ||
| 1144 | rightAngles: false, | ||
| 1145 | }, | ||
| 1146 | }); | ||
| 1147 | |||
| 1148 | await mermaid.run({ querySelector: ".diagram.mermaid" }); | ||
| 1149 | |||
| 1150 | for (const svg of document.querySelectorAll(".diagram.mermaid svg")) { | ||
| 1151 | const viewBox = svg.getAttribute("viewBox"); | ||
| 1152 | const width = viewBox?.split(/\s+/).map(Number)[2]; | ||
| 1153 | if (Number.isFinite(width) && width > 0) { | ||
| 1154 | svg.style.setProperty("width", `${Math.ceil(width)}px`, "important"); | ||
| 1155 | svg.style.setProperty("max-width", "none", "important"); | ||
| 1156 | } | ||
| 1157 | } | ||
| 1158 | </script> | ||
| 1159 | </body> | ||
| 1160 | </html> | ||
infra/relay/.env.example created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | # Required: Relay API and managed tunnel domains | ||
| 2 | # Production adopts retained DNS zones in your Cloudflare account. The API | ||
| 3 | # zone serves relay.<zone> in production and relay-<stage>.<zone> for personal | ||
| 4 | # stages. Managed environment endpoints are provisioned below the tunnel zone. | ||
| 5 | RELAY_API_ZONE_NAME=example.com | ||
| 6 | RELAY_TUNNEL_ZONE_NAME=tunnels.example.com | ||
| 7 | |||
| 8 | # Optional: Relay domain override | ||
| 9 | # Set this only when the derived relay hostname should not be used. | ||
| 10 | # RELAY_DOMAIN=relay.example.com | ||
| 11 | |||
| 12 | # Required: Clerk | ||
| 13 | # Get the keys from the Clerk Dashboard under API keys. Set the JWT audience to | ||
| 14 | # the `aud` claim configured on the `t3-relay` JWT template. | ||
| 15 | CLERK_PUBLISHABLE_KEY=pk_test_... | ||
| 16 | CLERK_SECRET_KEY=sk_test_... | ||
| 17 | CLERK_JWT_AUDIENCE=t3-code-relay | ||
| 18 | |||
| 19 | # Required: Apple Push Notification service | ||
| 20 | # Get these values from your Apple Developer account. Use `sandbox` for | ||
| 21 | # development APNs credentials and `production` for production credentials. | ||
| 22 | APNS_ENVIRONMENT=sandbox | ||
| 23 | APNS_TEAM_ID=... | ||
| 24 | APNS_KEY_ID=... | ||
| 25 | APNS_BUNDLE_ID=... | ||
| 26 | APNS_PRIVATE_KEY=... | ||
infra/relay/README.md created+166| ... | @@ -0,0 +1,166 @@ | ||
| 1 | # T3 Code Cloud Relay | ||
| 2 | |||
| 3 | > [!WARNING] | ||
| 4 | > T3 Code Cloud is currently in private beta. Join the waitlist in the app under Settings > T3 Cloud. | ||
| 5 | |||
| 6 | The relay is the hosted control plane for T3 Code Cloud. It helps clients discover and connect to | ||
| 7 | remote environments, manages the cloud-side records needed for those connections, and delivers | ||
| 8 | optional mobile notifications and Live Activities. | ||
| 9 | |||
| 10 | The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects, | ||
| 11 | regular API and WebSocket traffic goes directly between that client and the selected environment. | ||
| 12 | See the [cloud architecture overview](../../docs/t3-code-cloud-auth-flow.html) for the larger system | ||
| 13 | design. | ||
| 14 | |||
| 15 | ## Responsibilities | ||
| 16 | |||
| 17 | The relay currently owns: | ||
| 18 | |||
| 19 | - Linking T3 Code environments to a cloud account. | ||
| 20 | - Provisioning and tracking managed environment endpoints. | ||
| 21 | - Issuing short-lived credentials used to connect clients to linked environments. | ||
| 22 | - Listing linked environments and registered mobile devices for an account. | ||
| 23 | - Registering mobile notification preferences and APNs tokens. | ||
| 24 | - Receiving published agent activity and delivering notifications or Live Activity updates. | ||
| 25 | - Persisting relay state and exposing relay-specific traces for diagnostics. | ||
| 26 | |||
| 27 | The environment server and relay have separate credentials and trust boundaries. Read | ||
| 28 | [Environment Authentication Profile](../../docs/environment-auth.md) before changing token, | ||
| 29 | credential, or authorization behavior. | ||
| 30 | |||
| 31 | ## Code Map | ||
| 32 | |||
| 33 | - [`alchemy.run.ts`](./alchemy.run.ts) defines the deployed Alchemy stack. | ||
| 34 | - [`src/worker.ts`](./src/worker.ts) wires Cloudflare bindings, runtime layers, queues, and HTTP APIs. | ||
| 35 | - [`src/http/Api.ts`](./src/http/Api.ts) contains the relay HTTP handlers and authentication | ||
| 36 | boundaries. | ||
| 37 | - [`src/environments`](./src/environments) contains environment linking, credentials, endpoint | ||
| 38 | provisioning, and connection flows. | ||
| 39 | - [`src/agentActivity`](./src/agentActivity) contains mobile device registration, activity state, | ||
| 40 | APNs delivery, and queue processing. | ||
| 41 | - [`src/auth`](./src/auth) contains relay token and DPoP proof handling. | ||
| 42 | - [`src/persistence/schema.ts`](./src/persistence/schema.ts) defines persisted relay state. Keep | ||
| 43 | schema and migration changes together. | ||
| 44 | |||
| 45 | Shared request and response schemas live in | ||
| 46 | [`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay | ||
| 47 | calls live in | ||
| 48 | [`packages/client-runtime/src/managedRelay.ts`](../../packages/client-runtime/src/managedRelay.ts). | ||
| 49 | |||
| 50 | ## Working Locally | ||
| 51 | |||
| 52 | Install dependencies from the repository root, then run relay-focused checks from this directory: | ||
| 53 | |||
| 54 | ```sh | ||
| 55 | vp install | ||
| 56 | cd infra/relay | ||
| 57 | vp test run | ||
| 58 | vp run typecheck | ||
| 59 | ``` | ||
| 60 | |||
| 61 | To run a smaller test set while iterating: | ||
| 62 | |||
| 63 | ```sh | ||
| 64 | vp test run src/environments/EnvironmentLinker.test.ts | ||
| 65 | ``` | ||
| 66 | |||
| 67 | Before considering a change complete, run the repository-wide checks from the root: | ||
| 68 | |||
| 69 | ```sh | ||
| 70 | vp check | ||
| 71 | vp run typecheck | ||
| 72 | ``` | ||
| 73 | |||
| 74 | Backend changes should include tests. Prefer testing the real business logic with external | ||
| 75 | dependencies represented at their boundary rather than mocking internal behavior. | ||
| 76 | |||
| 77 | ## Deployment | ||
| 78 | |||
| 79 | The relay deploys through Alchemy: | ||
| 80 | |||
| 81 | ```sh | ||
| 82 | vp run --filter t3code-relay deploy | ||
| 83 | ``` | ||
| 84 | |||
| 85 | The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database | ||
| 86 | connectivity, and relay tracing resources. Copy [`infra/relay/.env.example`](./.env.example) to | ||
| 87 | `infra/relay/.env` and fill in the deployment-specific values before deploying. Alchemy loads that | ||
| 88 | file from the relay directory. Runtime secrets include Clerk and APNs credentials. Production adopts | ||
| 89 | the configured API and tunnel DNS zones as retained Cloudflare resources. Personal stages reference | ||
| 90 | the production-owned zones. | ||
| 91 | |||
| 92 | The `prod` Alchemy stage owns the retained PlanetScale database and is the shared hosted relay for | ||
| 93 | stable and nightly clients. Every other stage references that database and provisions an isolated | ||
| 94 | PlanetScale branch and runtime role for local development, so deploy `prod` before creating | ||
| 95 | developer stages: | ||
| 96 | |||
| 97 | ```sh | ||
| 98 | vp run --filter t3code-relay deploy -- --stage prod | ||
| 99 | vp run --filter t3code-relay deploy -- --env-file .env.local | ||
| 100 | ``` | ||
| 101 | |||
| 102 | Alchemy defaults personal deployments to the `dev_$USER` stage. Relay custom domains apply the same | ||
| 103 | DNS-safe sanitization as Alchemy physical resource names, so `prod` uses | ||
| 104 | `relay.<RELAY_API_ZONE_NAME>` and `dev_julius` uses | ||
| 105 | `relay-dev-julius.<RELAY_API_ZONE_NAME>`. Managed environment endpoints are provisioned below | ||
| 106 | `RELAY_TUNNEL_ZONE_NAME`, which may be a different Cloudflare zone. Production tunnel hostnames use | ||
| 107 | `prod-<digest>.<RELAY_TUNNEL_ZONE_NAME>`; personal stages use | ||
| 108 | `<stage>-<digest>.<RELAY_TUNNEL_ZONE_NAME>`. `RELAY_DOMAIN` remains available as an explicit API | ||
| 109 | domain override. | ||
| 110 | |||
| 111 | After a successful deploy, the wrapper updates the repository-root `.env` file with the derived relay | ||
| 112 | URL. That makes subsequent source builds point at the relay that was just deployed without copying | ||
| 113 | the URL manually. | ||
| 114 | |||
| 115 | ### Deployment CI | ||
| 116 | |||
| 117 | The relay is versioned separately from client releases. `.github/workflows/deploy-relay.yml` deploys | ||
| 118 | the shared Alchemy `prod` stage on every push to `main`. Stable and nightly release builds both | ||
| 119 | resolve their static public config from the same | ||
| 120 | `production` GitHub environment. Pull requests do not deploy relay stages. Developers can | ||
| 121 | deploy personal non-production stages locally with any stage name other than `prod`. | ||
| 122 | |||
| 123 | The repository must define these Actions variables shared by relay deployments: | ||
| 124 | |||
| 125 | - `CLOUDFLARE_ACCOUNT_ID` | ||
| 126 | - `PLANETSCALE_ORGANIZATION` | ||
| 127 | - `AXIOM_ORG_ID` | ||
| 128 | |||
| 129 | The repository must define these Actions secrets shared by relay deployments: | ||
| 130 | |||
| 131 | - `CLOUDFLARE_API_TOKEN` | ||
| 132 | - `PLANETSCALE_API_TOKEN_ID` | ||
| 133 | - `PLANETSCALE_API_TOKEN` | ||
| 134 | - `AXIOM_TOKEN` | ||
| 135 | |||
| 136 | The `production` GitHub environment must define these Actions variables: | ||
| 137 | |||
| 138 | - `RELAY_API_ZONE_NAME` | ||
| 139 | - `RELAY_TUNNEL_ZONE_NAME` | ||
| 140 | - `RELAY_DOMAIN` if overriding the derived production relay domain | ||
| 141 | - `CLERK_PUBLISHABLE_KEY` | ||
| 142 | - `CLERK_JWT_AUDIENCE` | ||
| 143 | - `CLERK_JWT_TEMPLATE` | ||
| 144 | - `APNS_ENVIRONMENT` | ||
| 145 | - `APNS_TEAM_ID` | ||
| 146 | - `APNS_KEY_ID` | ||
| 147 | - `APNS_BUNDLE_ID` | ||
| 148 | |||
| 149 | The `production` GitHub environment must define these Actions secrets: | ||
| 150 | |||
| 151 | - `CLERK_SECRET_KEY` | ||
| 152 | - `APNS_PRIVATE_KEY` | ||
| 153 | |||
| 154 | The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they | ||
| 155 | are not bound into the relay Worker. The production deployment uses an Axiom personal access token, | ||
| 156 | so `AXIOM_ORG_ID` must accompany `AXIOM_TOKEN`. The release workflow reads the production relay's | ||
| 157 | derived public URL and Clerk publishable key from the same environment for downstream desktop, CLI, | ||
| 158 | and hosted web builds. | ||
| 159 | |||
| 160 | See: | ||
| 161 | |||
| 162 | - [T3 Cloud Clerk Setup](../../docs/t3-cloud-clerk.md) for Clerk keys, JWT templates, and waitlist | ||
| 163 | setup. | ||
| 164 | - [Relay Observability](../../docs/relay-observability.md) for deployment tracing and diagnostics. | ||
| 165 | - [Cloud Architecture Overview](../../docs/t3-code-cloud-auth-flow.html) for the full link, | ||
| 166 | connect, endpoint, and notification flows. | ||
infra/relay/alchemy.run.ts created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | // @effect-diagnostics anyUnknownInErrorContext:off layerMergeAllWithDependencies:off - Alchemy provider helpers expose framework-owned any requirements. | ||
| 2 | import * as Alchemy from "alchemy"; | ||
| 3 | import * as Axiom from "alchemy/Axiom"; | ||
| 4 | import * as Cloudflare from "alchemy/Cloudflare"; | ||
| 5 | import * as Drizzle from "alchemy/Drizzle"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Planetscale from "alchemy/Planetscale"; | ||
| 9 | |||
| 10 | import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts"; | ||
| 11 | import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts"; | ||
| 12 | import Api from "./src/worker.ts"; | ||
| 13 | |||
| 14 | export default Alchemy.Stack( | ||
| 15 | "T3CodeRelay", | ||
| 16 | { | ||
| 17 | providers: Layer.mergeAll( | ||
| 18 | Axiom.providers(), | ||
| 19 | Cloudflare.providers(), | ||
| 20 | Drizzle.providers(), | ||
| 21 | Planetscale.providers(), | ||
| 22 | ), | ||
| 23 | state: Cloudflare.state(), | ||
| 24 | }, | ||
| 25 | Effect.gen(function* () { | ||
| 26 | const db = yield* PlanetscaleDatabase; | ||
| 27 | const hyperdrive = yield* RelayHyperdrive; | ||
| 28 | const managedEndpointZone = yield* ManagedEndpointZone.pipe(Effect.orDie); | ||
| 29 | const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie); | ||
| 30 | const api = yield* Api; | ||
| 31 | |||
| 32 | return { | ||
| 33 | databaseName: db.database.name, | ||
| 34 | databaseBranchName: db.branch?.name ?? "main", | ||
| 35 | hyperdriveName: hyperdrive.name, | ||
| 36 | workerName: api.workerName, | ||
| 37 | url: api.url, | ||
| 38 | relayApiZoneId: relayApiZone.zoneId, | ||
| 39 | managedEndpointZoneId: managedEndpointZone.zoneId, | ||
| 40 | }; | ||
| 41 | }), | ||
| 42 | ); | ||
infra/relay/migrations/postgres/20260527044716_baseline/migration.sql created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | CREATE TABLE "relay_agent_activity_rows" ( | ||
| 2 | 	"environment_id" varchar(191), | ||
| 3 | 	"environment_public_key" text, | ||
| 4 | 	"thread_id" varchar(191), | ||
| 5 | 	"state_json" jsonb NOT NULL, | ||
| 6 | 	"updated_at" varchar(64) NOT NULL, | ||
| 7 | 	"created_at" varchar(64) NOT NULL, | ||
| 8 | 	CONSTRAINT "relay_agent_activity_rows_pkey" PRIMARY KEY("environment_id","environment_public_key","thread_id") | ||
| 9 | ); | ||
| 10 | --> statement-breakpoint | ||
| 11 | CREATE TABLE "relay_delivery_attempts" ( | ||
| 12 | 	"id" varchar(36) PRIMARY KEY, | ||
| 13 | 	"created_at" varchar(64) NOT NULL, | ||
| 14 | 	"user_id" varchar(255), | ||
| 15 | 	"environment_id" varchar(191), | ||
| 16 | 	"thread_id" varchar(191), | ||
| 17 | 	"device_id" varchar(255), | ||
| 18 | 	"kind" varchar(64) NOT NULL, | ||
| 19 | 	"source_job_id" varchar(64), | ||
| 20 | 	"token_suffix" varchar(16), | ||
| 21 | 	"apns_status" integer, | ||
| 22 | 	"apns_reason" text, | ||
| 23 | 	"apns_id" varchar(128), | ||
| 24 | 	"transport_error" text | ||
| 25 | ); | ||
| 26 | --> statement-breakpoint | ||
| 27 | CREATE TABLE "relay_dpop_proofs" ( | ||
| 28 | 	"thumbprint" varchar(128), | ||
| 29 | 	"jti" varchar(255), | ||
| 30 | 	"iat" integer NOT NULL, | ||
| 31 | 	"expires_at" varchar(64) NOT NULL, | ||
| 32 | 	"created_at" varchar(64) NOT NULL, | ||
| 33 | 	CONSTRAINT "relay_dpop_proofs_pkey" PRIMARY KEY("thumbprint","jti") | ||
| 34 | ); | ||
| 35 | --> statement-breakpoint | ||
| 36 | CREATE TABLE "relay_environment_credentials" ( | ||
| 37 | 	"credential_id" varchar(64) PRIMARY KEY, | ||
| 38 | 	"environment_id" varchar(191) NOT NULL, | ||
| 39 | 	"environment_public_key" text NOT NULL, | ||
| 40 | 	"credential_hash" varchar(191) NOT NULL, | ||
| 41 | 	"revoked_at" varchar(64), | ||
| 42 | 	"created_at" varchar(64) NOT NULL, | ||
| 43 | 	"updated_at" varchar(64) NOT NULL | ||
| 44 | ); | ||
| 45 | --> statement-breakpoint | ||
| 46 | CREATE TABLE "relay_environment_links" ( | ||
| 47 | 	"user_id" varchar(191), | ||
| 48 | 	"environment_id" varchar(191), | ||
| 49 | 	"environment_label" text DEFAULT 'T3 Environment' NOT NULL, | ||
| 50 | 	"environment_public_key" text NOT NULL, | ||
| 51 | 	"endpoint_http_base_url" text NOT NULL, | ||
| 52 | 	"endpoint_ws_base_url" text NOT NULL, | ||
| 53 | 	"endpoint_provider_kind" varchar(32) NOT NULL, | ||
| 54 | 	"notifications_enabled" boolean DEFAULT true NOT NULL, | ||
| 55 | 	"live_activities_enabled" boolean DEFAULT true NOT NULL, | ||
| 56 | 	"managed_tunnels_enabled" boolean DEFAULT false NOT NULL, | ||
| 57 | 	"created_by_device_id" varchar(191), | ||
| 58 | 	"revoked_at" varchar(64), | ||
| 59 | 	"created_at" varchar(64) NOT NULL, | ||
| 60 | 	"updated_at" varchar(64) NOT NULL, | ||
| 61 | 	CONSTRAINT "relay_environment_links_pkey" PRIMARY KEY("user_id","environment_id") | ||
| 62 | ); | ||
| 63 | --> statement-breakpoint | ||
| 64 | CREATE TABLE "relay_live_activities" ( | ||
| 65 | 	"user_id" varchar(255), | ||
| 66 | 	"device_id" varchar(255), | ||
| 67 | 	"activity_push_token" text, | ||
| 68 | 	"remote_start_queued_at" varchar(64), | ||
| 69 | 	"remote_started_at" varchar(64), | ||
| 70 | 	"ended_at" varchar(64), | ||
| 71 | 	"last_aggregate_json" jsonb, | ||
| 72 | 	"last_live_activity_delivery_at" varchar(64), | ||
| 73 | 	"created_at" varchar(64) NOT NULL, | ||
| 74 | 	"updated_at" varchar(64) NOT NULL, | ||
| 75 | 	CONSTRAINT "relay_live_activities_pkey" PRIMARY KEY("user_id","device_id") | ||
| 76 | ); | ||
| 77 | --> statement-breakpoint | ||
| 78 | CREATE TABLE "relay_mobile_devices" ( | ||
| 79 | 	"user_id" varchar(255), | ||
| 80 | 	"device_id" varchar(255), | ||
| 81 | 	"platform" varchar(16) NOT NULL, | ||
| 82 | 	"ios_major_version" integer NOT NULL, | ||
| 83 | 	"app_version" varchar(64), | ||
| 84 | 	"push_token" text, | ||
| 85 | 	"push_to_start_token" text, | ||
| 86 | 	"preferences_json" jsonb NOT NULL, | ||
| 87 | 	"created_at" varchar(64) NOT NULL, | ||
| 88 | 	"updated_at" varchar(64) NOT NULL, | ||
| 89 | 	CONSTRAINT "relay_mobile_devices_pkey" PRIMARY KEY("user_id","device_id") | ||
| 90 | ); | ||
| 91 | --> statement-breakpoint | ||
| 92 | CREATE INDEX "idx_relay_agent_activity_rows_updated" ON "relay_agent_activity_rows" ("updated_at");--> statement-breakpoint | ||
| 93 | CREATE INDEX "idx_relay_delivery_attempts_environment" ON "relay_delivery_attempts" ("environment_id","thread_id","created_at");--> statement-breakpoint | ||
| 94 | CREATE UNIQUE INDEX "idx_relay_delivery_attempts_source_job" ON "relay_delivery_attempts" ("source_job_id");--> statement-breakpoint | ||
| 95 | CREATE INDEX "idx_relay_dpop_proofs_expires_at" ON "relay_dpop_proofs" ("expires_at");--> statement-breakpoint | ||
| 96 | CREATE UNIQUE INDEX "idx_relay_environment_credentials_hash" ON "relay_environment_credentials" ("credential_hash");--> statement-breakpoint | ||
| 97 | CREATE INDEX "idx_relay_environment_credentials_environment" ON "relay_environment_credentials" ("environment_id","revoked_at");--> statement-breakpoint | ||
| 98 | CREATE INDEX "idx_relay_environment_credentials_environment_key" ON "relay_environment_credentials" ("environment_id","environment_public_key","revoked_at");--> statement-breakpoint | ||
| 99 | CREATE INDEX "idx_relay_environment_links_environment" ON "relay_environment_links" ("environment_id","revoked_at");--> statement-breakpoint | ||
| 100 | CREATE INDEX "idx_relay_live_activities_user" ON "relay_live_activities" ("user_id");--> statement-breakpoint | ||
| 101 | CREATE UNIQUE INDEX "idx_relay_live_activities_activity_push_token" ON "relay_live_activities" ("activity_push_token");--> statement-breakpoint | ||
| 102 | CREATE INDEX "idx_relay_mobile_devices_user" ON "relay_mobile_devices" ("user_id");--> statement-breakpoint | ||
| 103 | CREATE UNIQUE INDEX "idx_relay_mobile_devices_push_token" ON "relay_mobile_devices" ("push_token");--> statement-breakpoint | ||
| 104 | 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 | |||
infra/relay/migrations/postgres/20260527044716_baseline/snapshot.json created+1267| ... | @@ -0,0 +1,1267 @@ | ||
| 1 | { | ||
| 2 | "version": "8", | ||
| 3 | "dialect": "postgres", | ||
| 4 | "id": "ad620f05-3fd1-43b8-a3ca-d4c02543d892", | ||
| 5 | "prevIds": ["00000000-0000-0000-0000-000000000000"], | ||
| 6 | "ddl": [ | ||
| 7 | { | ||
| 8 | "isRlsEnabled": false, | ||
| 9 | "name": "relay_agent_activity_rows", | ||
| 10 | "entityType": "tables", | ||
| 11 | "schema": "public" | ||
| 12 | }, | ||
| 13 | { | ||
| 14 | "isRlsEnabled": false, | ||
| 15 | "name": "relay_delivery_attempts", | ||
| 16 | "entityType": "tables", | ||
| 17 | "schema": "public" | ||
| 18 | }, | ||
| 19 | { | ||
| 20 | "isRlsEnabled": false, | ||
| 21 | "name": "relay_dpop_proofs", | ||
| 22 | "entityType": "tables", | ||
| 23 | "schema": "public" | ||
| 24 | }, | ||
| 25 | { | ||
| 26 | "isRlsEnabled": false, | ||
| 27 | "name": "relay_environment_credentials", | ||
| 28 | "entityType": "tables", | ||
| 29 | "schema": "public" | ||
| 30 | }, | ||
| 31 | { | ||
| 32 | "isRlsEnabled": false, | ||
| 33 | "name": "relay_environment_links", | ||
| 34 | "entityType": "tables", | ||
| 35 | "schema": "public" | ||
| 36 | }, | ||
| 37 | { | ||
| 38 | "isRlsEnabled": false, | ||
| 39 | "name": "relay_live_activities", | ||
| 40 | "entityType": "tables", | ||
| 41 | "schema": "public" | ||
| 42 | }, | ||
| 43 | { | ||
| 44 | "isRlsEnabled": false, | ||
| 45 | "name": "relay_mobile_devices", | ||
| 46 | "entityType": "tables", | ||
| 47 | "schema": "public" | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | "type": "varchar(191)", | ||
| 51 | "typeSchema": null, | ||
| 52 | "notNull": true, | ||
| 53 | "dimensions": 0, | ||
| 54 | "default": null, | ||
| 55 | "generated": null, | ||
| 56 | "identity": null, | ||
| 57 | "name": "environment_id", | ||
| 58 | "entityType": "columns", | ||
| 59 | "schema": "public", | ||
| 60 | "table": "relay_agent_activity_rows" | ||
| 61 | }, | ||
| 62 | { | ||
| 63 | "type": "text", | ||
| 64 | "typeSchema": null, | ||
| 65 | "notNull": true, | ||
| 66 | "dimensions": 0, | ||
| 67 | "default": null, | ||
| 68 | "generated": null, | ||
| 69 | "identity": null, | ||
| 70 | "name": "environment_public_key", | ||
| 71 | "entityType": "columns", | ||
| 72 | "schema": "public", | ||
| 73 | "table": "relay_agent_activity_rows" | ||
| 74 | }, | ||
| 75 | { | ||
| 76 | "type": "varchar(191)", | ||
| 77 | "typeSchema": null, | ||
| 78 | "notNull": true, | ||
| 79 | "dimensions": 0, | ||
| 80 | "default": null, | ||
| 81 | "generated": null, | ||
| 82 | "identity": null, | ||
| 83 | "name": "thread_id", | ||
| 84 | "entityType": "columns", | ||
| 85 | "schema": "public", | ||
| 86 | "table": "relay_agent_activity_rows" | ||
| 87 | }, | ||
| 88 | { | ||
| 89 | "type": "jsonb", | ||
| 90 | "typeSchema": null, | ||
| 91 | "notNull": true, | ||
| 92 | "dimensions": 0, | ||
| 93 | "default": null, | ||
| 94 | "generated": null, | ||
| 95 | "identity": null, | ||
| 96 | "name": "state_json", | ||
| 97 | "entityType": "columns", | ||
| 98 | "schema": "public", | ||
| 99 | "table": "relay_agent_activity_rows" | ||
| 100 | }, | ||
| 101 | { | ||
| 102 | "type": "varchar(64)", | ||
| 103 | "typeSchema": null, | ||
| 104 | "notNull": true, | ||
| 105 | "dimensions": 0, | ||
| 106 | "default": null, | ||
| 107 | "generated": null, | ||
| 108 | "identity": null, | ||
| 109 | "name": "updated_at", | ||
| 110 | "entityType": "columns", | ||
| 111 | "schema": "public", | ||
| 112 | "table": "relay_agent_activity_rows" | ||
| 113 | }, | ||
| 114 | { | ||
| 115 | "type": "varchar(64)", | ||
| 116 | "typeSchema": null, | ||
| 117 | "notNull": true, | ||
| 118 | "dimensions": 0, | ||
| 119 | "default": null, | ||
| 120 | "generated": null, | ||
| 121 | "identity": null, | ||
| 122 | "name": "created_at", | ||
| 123 | "entityType": "columns", | ||
| 124 | "schema": "public", | ||
| 125 | "table": "relay_agent_activity_rows" | ||
| 126 | }, | ||
| 127 | { | ||
| 128 | "type": "varchar(36)", | ||
| 129 | "typeSchema": null, | ||
| 130 | "notNull": true, | ||
| 131 | "dimensions": 0, | ||
| 132 | "default": null, | ||
| 133 | "generated": null, | ||
| 134 | "identity": null, | ||
| 135 | "name": "id", | ||
| 136 | "entityType": "columns", | ||
| 137 | "schema": "public", | ||
| 138 | "table": "relay_delivery_attempts" | ||
| 139 | }, | ||
| 140 | { | ||
| 141 | "type": "varchar(64)", | ||
| 142 | "typeSchema": null, | ||
| 143 | "notNull": true, | ||
| 144 | "dimensions": 0, | ||
| 145 | "default": null, | ||
| 146 | "generated": null, | ||
| 147 | "identity": null, | ||
| 148 | "name": "created_at", | ||
| 149 | "entityType": "columns", | ||
| 150 | "schema": "public", | ||
| 151 | "table": "relay_delivery_attempts" | ||
| 152 | }, | ||
| 153 | { | ||
| 154 | "type": "varchar(255)", | ||
| 155 | "typeSchema": null, | ||
| 156 | "notNull": false, | ||
| 157 | "dimensions": 0, | ||
| 158 | "default": null, | ||
| 159 | "generated": null, | ||
| 160 | "identity": null, | ||
| 161 | "name": "user_id", | ||
| 162 | "entityType": "columns", | ||
| 163 | "schema": "public", | ||
| 164 | "table": "relay_delivery_attempts" | ||
| 165 | }, | ||
| 166 | { | ||
| 167 | "type": "varchar(191)", | ||
| 168 | "typeSchema": null, | ||
| 169 | "notNull": false, | ||
| 170 | "dimensions": 0, | ||
| 171 | "default": null, | ||
| 172 | "generated": null, | ||
| 173 | "identity": null, | ||
| 174 | "name": "environment_id", | ||
| 175 | "entityType": "columns", | ||
| 176 | "schema": "public", | ||
| 177 | "table": "relay_delivery_attempts" | ||
| 178 | }, | ||
| 179 | { | ||
| 180 | "type": "varchar(191)", | ||
| 181 | "typeSchema": null, | ||
| 182 | "notNull": false, | ||
| 183 | "dimensions": 0, | ||
| 184 | "default": null, | ||
| 185 | "generated": null, | ||
| 186 | "identity": null, | ||
| 187 | "name": "thread_id", | ||
| 188 | "entityType": "columns", | ||
| 189 | "schema": "public", | ||
| 190 | "table": "relay_delivery_attempts" | ||
| 191 | }, | ||
| 192 | { | ||
| 193 | "type": "varchar(255)", | ||
| 194 | "typeSchema": null, | ||
| 195 | "notNull": false, | ||
| 196 | "dimensions": 0, | ||
| 197 | "default": null, | ||
| 198 | "generated": null, | ||
| 199 | "identity": null, | ||
| 200 | "name": "device_id", | ||
| 201 | "entityType": "columns", | ||
| 202 | "schema": "public", | ||
| 203 | "table": "relay_delivery_attempts" | ||
| 204 | }, | ||
| 205 | { | ||
| 206 | "type": "varchar(64)", | ||
| 207 | "typeSchema": null, | ||
| 208 | "notNull": true, | ||
| 209 | "dimensions": 0, | ||
| 210 | "default": null, | ||
| 211 | "generated": null, | ||
| 212 | "identity": null, | ||
| 213 | "name": "kind", | ||
| 214 | "entityType": "columns", | ||
| 215 | "schema": "public", | ||
| 216 | "table": "relay_delivery_attempts" | ||
| 217 | }, | ||
| 218 | { | ||
| 219 | "type": "varchar(64)", | ||
| 220 | "typeSchema": null, | ||
| 221 | "notNull": false, | ||
| 222 | "dimensions": 0, | ||
| 223 | "default": null, | ||
| 224 | "generated": null, | ||
| 225 | "identity": null, | ||
| 226 | "name": "source_job_id", | ||
| 227 | "entityType": "columns", | ||
| 228 | "schema": "public", | ||
| 229 | "table": "relay_delivery_attempts" | ||
| 230 | }, | ||
| 231 | { | ||
| 232 | "type": "varchar(16)", | ||
| 233 | "typeSchema": null, | ||
| 234 | "notNull": false, | ||
| 235 | "dimensions": 0, | ||
| 236 | "default": null, | ||
| 237 | "generated": null, | ||
| 238 | "identity": null, | ||
| 239 | "name": "token_suffix", | ||
| 240 | "entityType": "columns", | ||
| 241 | "schema": "public", | ||
| 242 | "table": "relay_delivery_attempts" | ||
| 243 | }, | ||
| 244 | { | ||
| 245 | "type": "integer", | ||
| 246 | "typeSchema": null, | ||
| 247 | "notNull": false, | ||
| 248 | "dimensions": 0, | ||
| 249 | "default": null, | ||
| 250 | "generated": null, | ||
| 251 | "identity": null, | ||
| 252 | "name": "apns_status", | ||
| 253 | "entityType": "columns", | ||
| 254 | "schema": "public", | ||
| 255 | "table": "relay_delivery_attempts" | ||
| 256 | }, | ||
| 257 | { | ||
| 258 | "type": "text", | ||
| 259 | "typeSchema": null, | ||
| 260 | "notNull": false, | ||
| 261 | "dimensions": 0, | ||
| 262 | "default": null, | ||
| 263 | "generated": null, | ||
| 264 | "identity": null, | ||
| 265 | "name": "apns_reason", | ||
| 266 | "entityType": "columns", | ||
| 267 | "schema": "public", | ||
| 268 | "table": "relay_delivery_attempts" | ||
| 269 | }, | ||
| 270 | { | ||
| 271 | "type": "varchar(128)", | ||
| 272 | "typeSchema": null, | ||
| 273 | "notNull": false, | ||
| 274 | "dimensions": 0, | ||
| 275 | "default": null, | ||
| 276 | "generated": null, | ||
| 277 | "identity": null, | ||
| 278 | "name": "apns_id", | ||
| 279 | "entityType": "columns", | ||
| 280 | "schema": "public", | ||
| 281 | "table": "relay_delivery_attempts" | ||
| 282 | }, | ||
| 283 | { | ||
| 284 | "type": "text", | ||
| 285 | "typeSchema": null, | ||
| 286 | "notNull": false, | ||
| 287 | "dimensions": 0, | ||
| 288 | "default": null, | ||
| 289 | "generated": null, | ||
| 290 | "identity": null, | ||
| 291 | "name": "transport_error", | ||
| 292 | "entityType": "columns", | ||
| 293 | "schema": "public", | ||
| 294 | "table": "relay_delivery_attempts" | ||
| 295 | }, | ||
| 296 | { | ||
| 297 | "type": "varchar(128)", | ||
| 298 | "typeSchema": null, | ||
| 299 | "notNull": true, | ||
| 300 | "dimensions": 0, | ||
| 301 | "default": null, | ||
| 302 | "generated": null, | ||
| 303 | "identity": null, | ||
| 304 | "name": "thumbprint", | ||
| 305 | "entityType": "columns", | ||
| 306 | "schema": "public", | ||
| 307 | "table": "relay_dpop_proofs" | ||
| 308 | }, | ||
| 309 | { | ||
| 310 | "type": "varchar(255)", | ||
| 311 | "typeSchema": null, | ||
| 312 | "notNull": true, | ||
| 313 | "dimensions": 0, | ||
| 314 | "default": null, | ||
| 315 | "generated": null, | ||
| 316 | "identity": null, | ||
| 317 | "name": "jti", | ||
| 318 | "entityType": "columns", | ||
| 319 | "schema": "public", | ||
| 320 | "table": "relay_dpop_proofs" | ||
| 321 | }, | ||
| 322 | { | ||
| 323 | "type": "integer", | ||
| 324 | "typeSchema": null, | ||
| 325 | "notNull": true, | ||
| 326 | "dimensions": 0, | ||
| 327 | "default": null, | ||
| 328 | "generated": null, | ||
| 329 | "identity": null, | ||
| 330 | "name": "iat", | ||
| 331 | "entityType": "columns", | ||
| 332 | "schema": "public", | ||
| 333 | "table": "relay_dpop_proofs" | ||
| 334 | }, | ||
| 335 | { | ||
| 336 | "type": "varchar(64)", | ||
| 337 | "typeSchema": null, | ||
| 338 | "notNull": true, | ||
| 339 | "dimensions": 0, | ||
| 340 | "default": null, | ||
| 341 | "generated": null, | ||
| 342 | "identity": null, | ||
| 343 | "name": "expires_at", | ||
| 344 | "entityType": "columns", | ||
| 345 | "schema": "public", | ||
| 346 | "table": "relay_dpop_proofs" | ||
| 347 | }, | ||
| 348 | { | ||
| 349 | "type": "varchar(64)", | ||
| 350 | "typeSchema": null, | ||
| 351 | "notNull": true, | ||
| 352 | "dimensions": 0, | ||
| 353 | "default": null, | ||
| 354 | "generated": null, | ||
| 355 | "identity": null, | ||
| 356 | "name": "created_at", | ||
| 357 | "entityType": "columns", | ||
| 358 | "schema": "public", | ||
| 359 | "table": "relay_dpop_proofs" | ||
| 360 | }, | ||
| 361 | { | ||
| 362 | "type": "varchar(64)", | ||
| 363 | "typeSchema": null, | ||
| 364 | "notNull": true, | ||
| 365 | "dimensions": 0, | ||
| 366 | "default": null, | ||
| 367 | "generated": null, | ||
| 368 | "identity": null, | ||
| 369 | "name": "credential_id", | ||
| 370 | "entityType": "columns", | ||
| 371 | "schema": "public", | ||
| 372 | "table": "relay_environment_credentials" | ||
| 373 | }, | ||
| 374 | { | ||
| 375 | "type": "varchar(191)", | ||
| 376 | "typeSchema": null, | ||
| 377 | "notNull": true, | ||
| 378 | "dimensions": 0, | ||
| 379 | "default": null, | ||
| 380 | "generated": null, | ||
| 381 | "identity": null, | ||
| 382 | "name": "environment_id", | ||
| 383 | "entityType": "columns", | ||
| 384 | "schema": "public", | ||
| 385 | "table": "relay_environment_credentials" | ||
| 386 | }, | ||
| 387 | { | ||
| 388 | "type": "text", | ||
| 389 | "typeSchema": null, | ||
| 390 | "notNull": true, | ||
| 391 | "dimensions": 0, | ||
| 392 | "default": null, | ||
| 393 | "generated": null, | ||
| 394 | "identity": null, | ||
| 395 | "name": "environment_public_key", | ||
| 396 | "entityType": "columns", | ||
| 397 | "schema": "public", | ||
| 398 | "table": "relay_environment_credentials" | ||
| 399 | }, | ||
| 400 | { | ||
| 401 | "type": "varchar(191)", | ||
| 402 | "typeSchema": null, | ||
| 403 | "notNull": true, | ||
| 404 | "dimensions": 0, | ||
| 405 | "default": null, | ||
| 406 | "generated": null, | ||
| 407 | "identity": null, | ||
| 408 | "name": "credential_hash", | ||
| 409 | "entityType": "columns", | ||
| 410 | "schema": "public", | ||
| 411 | "table": "relay_environment_credentials" | ||
| 412 | }, | ||
| 413 | { | ||
| 414 | "type": "varchar(64)", | ||
| 415 | "typeSchema": null, | ||
| 416 | "notNull": false, | ||
| 417 | "dimensions": 0, | ||
| 418 | "default": null, | ||
| 419 | "generated": null, | ||
| 420 | "identity": null, | ||
| 421 | "name": "revoked_at", | ||
| 422 | "entityType": "columns", | ||
| 423 | "schema": "public", | ||
| 424 | "table": "relay_environment_credentials" | ||
| 425 | }, | ||
| 426 | { | ||
| 427 | "type": "varchar(64)", | ||
| 428 | "typeSchema": null, | ||
| 429 | "notNull": true, | ||
| 430 | "dimensions": 0, | ||
| 431 | "default": null, | ||
| 432 | "generated": null, | ||
| 433 | "identity": null, | ||
| 434 | "name": "created_at", | ||
| 435 | "entityType": "columns", | ||
| 436 | "schema": "public", | ||
| 437 | "table": "relay_environment_credentials" | ||
| 438 | }, | ||
| 439 | { | ||
| 440 | "type": "varchar(64)", | ||
| 441 | "typeSchema": null, | ||
| 442 | "notNull": true, | ||
| 443 | "dimensions": 0, | ||
| 444 | "default": null, | ||
| 445 | "generated": null, | ||
| 446 | "identity": null, | ||
| 447 | "name": "updated_at", | ||
| 448 | "entityType": "columns", | ||
| 449 | "schema": "public", | ||
| 450 | "table": "relay_environment_credentials" | ||
| 451 | }, | ||
| 452 | { | ||
| 453 | "type": "varchar(191)", | ||
| 454 | "typeSchema": null, | ||
| 455 | "notNull": true, | ||
| 456 | "dimensions": 0, | ||
| 457 | "default": null, | ||
| 458 | "generated": null, | ||
| 459 | "identity": null, | ||
| 460 | "name": "user_id", | ||
| 461 | "entityType": "columns", | ||
| 462 | "schema": "public", | ||
| 463 | "table": "relay_environment_links" | ||
| 464 | }, | ||
| 465 | { | ||
| 466 | "type": "varchar(191)", | ||
| 467 | "typeSchema": null, | ||
| 468 | "notNull": true, | ||
| 469 | "dimensions": 0, | ||
| 470 | "default": null, | ||
| 471 | "generated": null, | ||
| 472 | "identity": null, | ||
| 473 | "name": "environment_id", | ||
| 474 | "entityType": "columns", | ||
| 475 | "schema": "public", | ||
| 476 | "table": "relay_environment_links" | ||
| 477 | }, | ||
| 478 | { | ||
| 479 | "type": "text", | ||
| 480 | "typeSchema": null, | ||
| 481 | "notNull": true, | ||
| 482 | "dimensions": 0, | ||
| 483 | "default": "'T3 Environment'", | ||
| 484 | "generated": null, | ||
| 485 | "identity": null, | ||
| 486 | "name": "environment_label", | ||
| 487 | "entityType": "columns", | ||
| 488 | "schema": "public", | ||
| 489 | "table": "relay_environment_links" | ||
| 490 | }, | ||
| 491 | { | ||
| 492 | "type": "text", | ||
| 493 | "typeSchema": null, | ||
| 494 | "notNull": true, | ||
| 495 | "dimensions": 0, | ||
| 496 | "default": null, | ||
| 497 | "generated": null, | ||
| 498 | "identity": null, | ||
| 499 | "name": "environment_public_key", | ||
| 500 | "entityType": "columns", | ||
| 501 | "schema": "public", | ||
| 502 | "table": "relay_environment_links" | ||
| 503 | }, | ||
| 504 | { | ||
| 505 | "type": "text", | ||
| 506 | "typeSchema": null, | ||
| 507 | "notNull": true, | ||
| 508 | "dimensions": 0, | ||
| 509 | "default": null, | ||
| 510 | "generated": null, | ||
| 511 | "identity": null, | ||
| 512 | "name": "endpoint_http_base_url", | ||
| 513 | "entityType": "columns", | ||
| 514 | "schema": "public", | ||
| 515 | "table": "relay_environment_links" | ||
| 516 | }, | ||
| 517 | { | ||
| 518 | "type": "text", | ||
| 519 | "typeSchema": null, | ||
| 520 | "notNull": true, | ||
| 521 | "dimensions": 0, | ||
| 522 | "default": null, | ||
| 523 | "generated": null, | ||
| 524 | "identity": null, | ||
| 525 | "name": "endpoint_ws_base_url", | ||
| 526 | "entityType": "columns", | ||
| 527 | "schema": "public", | ||
| 528 | "table": "relay_environment_links" | ||
| 529 | }, | ||
| 530 | { | ||
| 531 | "type": "varchar(32)", | ||
| 532 | "typeSchema": null, | ||
| 533 | "notNull": true, | ||
| 534 | "dimensions": 0, | ||
| 535 | "default": null, | ||
| 536 | "generated": null, | ||
| 537 | "identity": null, | ||
| 538 | "name": "endpoint_provider_kind", | ||
| 539 | "entityType": "columns", | ||
| 540 | "schema": "public", | ||
| 541 | "table": "relay_environment_links" | ||
| 542 | }, | ||
| 543 | { | ||
| 544 | "type": "boolean", | ||
| 545 | "typeSchema": null, | ||
| 546 | "notNull": true, | ||
| 547 | "dimensions": 0, | ||
| 548 | "default": "true", | ||
| 549 | "generated": null, | ||
| 550 | "identity": null, | ||
| 551 | "name": "notifications_enabled", | ||
| 552 | "entityType": "columns", | ||
| 553 | "schema": "public", | ||
| 554 | "table": "relay_environment_links" | ||
| 555 | }, | ||
| 556 | { | ||
| 557 | "type": "boolean", | ||
| 558 | "typeSchema": null, | ||
| 559 | "notNull": true, | ||
| 560 | "dimensions": 0, | ||
| 561 | "default": "true", | ||
| 562 | "generated": null, | ||
| 563 | "identity": null, | ||
| 564 | "name": "live_activities_enabled", | ||
| 565 | "entityType": "columns", | ||
| 566 | "schema": "public", | ||
| 567 | "table": "relay_environment_links" | ||
| 568 | }, | ||
| 569 | { | ||
| 570 | "type": "boolean", | ||
| 571 | "typeSchema": null, | ||
| 572 | "notNull": true, | ||
| 573 | "dimensions": 0, | ||
| 574 | "default": "false", | ||
| 575 | "generated": null, | ||
| 576 | "identity": null, | ||
| 577 | "name": "managed_tunnels_enabled", | ||
| 578 | "entityType": "columns", | ||
| 579 | "schema": "public", | ||
| 580 | "table": "relay_environment_links" | ||
| 581 | }, | ||
| 582 | { | ||
| 583 | "type": "varchar(191)", | ||
| 584 | "typeSchema": null, | ||
| 585 | "notNull": false, | ||
| 586 | "dimensions": 0, | ||
| 587 | "default": null, | ||
| 588 | "generated": null, | ||
| 589 | "identity": null, | ||
| 590 | "name": "created_by_device_id", | ||
| 591 | "entityType": "columns", | ||
| 592 | "schema": "public", | ||
| 593 | "table": "relay_environment_links" | ||
| 594 | }, | ||
| 595 | { | ||
| 596 | "type": "varchar(64)", | ||
| 597 | "typeSchema": null, | ||
| 598 | "notNull": false, | ||
| 599 | "dimensions": 0, | ||
| 600 | "default": null, | ||
| 601 | "generated": null, | ||
| 602 | "identity": null, | ||
| 603 | "name": "revoked_at", | ||
| 604 | "entityType": "columns", | ||
| 605 | "schema": "public", | ||
| 606 | "table": "relay_environment_links" | ||
| 607 | }, | ||
| 608 | { | ||
| 609 | "type": "varchar(64)", | ||
| 610 | "typeSchema": null, | ||
| 611 | "notNull": true, | ||
| 612 | "dimensions": 0, | ||
| 613 | "default": null, | ||
| 614 | "generated": null, | ||
| 615 | "identity": null, | ||
| 616 | "name": "created_at", | ||
| 617 | "entityType": "columns", | ||
| 618 | "schema": "public", | ||
| 619 | "table": "relay_environment_links" | ||
| 620 | }, | ||
| 621 | { | ||
| 622 | "type": "varchar(64)", | ||
| 623 | "typeSchema": null, | ||
| 624 | "notNull": true, | ||
| 625 | "dimensions": 0, | ||
| 626 | "default": null, | ||
| 627 | "generated": null, | ||
| 628 | "identity": null, | ||
| 629 | "name": "updated_at", | ||
| 630 | "entityType": "columns", | ||
| 631 | "schema": "public", | ||
| 632 | "table": "relay_environment_links" | ||
| 633 | }, | ||
| 634 | { | ||
| 635 | "type": "varchar(255)", | ||
| 636 | "typeSchema": null, | ||
| 637 | "notNull": true, | ||
| 638 | "dimensions": 0, | ||
| 639 | "default": null, | ||
| 640 | "generated": null, | ||
| 641 | "identity": null, | ||
| 642 | "name": "user_id", | ||
| 643 | "entityType": "columns", | ||
| 644 | "schema": "public", | ||
| 645 | "table": "relay_live_activities" | ||
| 646 | }, | ||
| 647 | { | ||
| 648 | "type": "varchar(255)", | ||
| 649 | "typeSchema": null, | ||
| 650 | "notNull": true, | ||
| 651 | "dimensions": 0, | ||
| 652 | "default": null, | ||
| 653 | "generated": null, | ||
| 654 | "identity": null, | ||
| 655 | "name": "device_id", | ||
| 656 | "entityType": "columns", | ||
| 657 | "schema": "public", | ||
| 658 | "table": "relay_live_activities" | ||
| 659 | }, | ||
| 660 | { | ||
| 661 | "type": "text", | ||
| 662 | "typeSchema": null, | ||
| 663 | "notNull": false, | ||
| 664 | "dimensions": 0, | ||
| 665 | "default": null, | ||
| 666 | "generated": null, | ||
| 667 | "identity": null, | ||
| 668 | "name": "activity_push_token", | ||
| 669 | "entityType": "columns", | ||
| 670 | "schema": "public", | ||
| 671 | "table": "relay_live_activities" | ||
| 672 | }, | ||
| 673 | { | ||
| 674 | "type": "varchar(64)", | ||
| 675 | "typeSchema": null, | ||
| 676 | "notNull": false, | ||
| 677 | "dimensions": 0, | ||
| 678 | "default": null, | ||
| 679 | "generated": null, | ||
| 680 | "identity": null, | ||
| 681 | "name": "remote_start_queued_at", | ||
| 682 | "entityType": "columns", | ||
| 683 | "schema": "public", | ||
| 684 | "table": "relay_live_activities" | ||
| 685 | }, | ||
| 686 | { | ||
| 687 | "type": "varchar(64)", | ||
| 688 | "typeSchema": null, | ||
| 689 | "notNull": false, | ||
| 690 | "dimensions": 0, | ||
| 691 | "default": null, | ||
| 692 | "generated": null, | ||
| 693 | "identity": null, | ||
| 694 | "name": "remote_started_at", | ||
| 695 | "entityType": "columns", | ||
| 696 | "schema": "public", | ||
| 697 | "table": "relay_live_activities" | ||
| 698 | }, | ||
| 699 | { | ||
| 700 | "type": "varchar(64)", | ||
| 701 | "typeSchema": null, | ||
| 702 | "notNull": false, | ||
| 703 | "dimensions": 0, | ||
| 704 | "default": null, | ||
| 705 | "generated": null, | ||
| 706 | "identity": null, | ||
| 707 | "name": "ended_at", | ||
| 708 | "entityType": "columns", | ||
| 709 | "schema": "public", | ||
| 710 | "table": "relay_live_activities" | ||
| 711 | }, | ||
| 712 | { | ||
| 713 | "type": "jsonb", | ||
| 714 | "typeSchema": null, | ||
| 715 | "notNull": false, | ||
| 716 | "dimensions": 0, | ||
| 717 | "default": null, | ||
| 718 | "generated": null, | ||
| 719 | "identity": null, | ||
| 720 | "name": "last_aggregate_json", | ||
| 721 | "entityType": "columns", | ||
| 722 | "schema": "public", | ||
| 723 | "table": "relay_live_activities" | ||
| 724 | }, | ||
| 725 | { | ||
| 726 | "type": "varchar(64)", | ||
| 727 | "typeSchema": null, | ||
| 728 | "notNull": false, | ||
| 729 | "dimensions": 0, | ||
| 730 | "default": null, | ||
| 731 | "generated": null, | ||
| 732 | "identity": null, | ||
| 733 | "name": "last_live_activity_delivery_at", | ||
| 734 | "entityType": "columns", | ||
| 735 | "schema": "public", | ||
| 736 | "table": "relay_live_activities" | ||
| 737 | }, | ||
| 738 | { | ||
| 739 | "type": "varchar(64)", | ||
| 740 | "typeSchema": null, | ||
| 741 | "notNull": true, | ||
| 742 | "dimensions": 0, | ||
| 743 | "default": null, | ||
| 744 | "generated": null, | ||
| 745 | "identity": null, | ||
| 746 | "name": "created_at", | ||
| 747 | "entityType": "columns", | ||
| 748 | "schema": "public", | ||
| 749 | "table": "relay_live_activities" | ||
| 750 | }, | ||
| 751 | { | ||
| 752 | "type": "varchar(64)", | ||
| 753 | "typeSchema": null, | ||
| 754 | "notNull": true, | ||
| 755 | "dimensions": 0, | ||
| 756 | "default": null, | ||
| 757 | "generated": null, | ||
| 758 | "identity": null, | ||
| 759 | "name": "updated_at", | ||
| 760 | "entityType": "columns", | ||
| 761 | "schema": "public", | ||
| 762 | "table": "relay_live_activities" | ||
| 763 | }, | ||
| 764 | { | ||
| 765 | "type": "varchar(255)", | ||
| 766 | "typeSchema": null, | ||
| 767 | "notNull": true, | ||
| 768 | "dimensions": 0, | ||
| 769 | "default": null, | ||
| 770 | "generated": null, | ||
| 771 | "identity": null, | ||
| 772 | "name": "user_id", | ||
| 773 | "entityType": "columns", | ||
| 774 | "schema": "public", | ||
| 775 | "table": "relay_mobile_devices" | ||
| 776 | }, | ||
| 777 | { | ||
| 778 | "type": "varchar(255)", | ||
| 779 | "typeSchema": null, | ||
| 780 | "notNull": true, | ||
| 781 | "dimensions": 0, | ||
| 782 | "default": null, | ||
| 783 | "generated": null, | ||
| 784 | "identity": null, | ||
| 785 | "name": "device_id", | ||
| 786 | "entityType": "columns", | ||
| 787 | "schema": "public", | ||
| 788 | "table": "relay_mobile_devices" | ||
| 789 | }, | ||
| 790 | { | ||
| 791 | "type": "varchar(16)", | ||
| 792 | "typeSchema": null, | ||
| 793 | "notNull": true, | ||
| 794 | "dimensions": 0, | ||
| 795 | "default": null, | ||
| 796 | "generated": null, | ||
| 797 | "identity": null, | ||
| 798 | "name": "platform", | ||
| 799 | "entityType": "columns", | ||
| 800 | "schema": "public", | ||
| 801 | "table": "relay_mobile_devices" | ||
| 802 | }, | ||
| 803 | { | ||
| 804 | "type": "integer", | ||
| 805 | "typeSchema": null, | ||
| 806 | "notNull": true, | ||
| 807 | "dimensions": 0, | ||
| 808 | "default": null, | ||
| 809 | "generated": null, | ||
| 810 | "identity": null, | ||
| 811 | "name": "ios_major_version", | ||
| 812 | "entityType": "columns", | ||
| 813 | "schema": "public", | ||
| 814 | "table": "relay_mobile_devices" | ||
| 815 | }, | ||
| 816 | { | ||
| 817 | "type": "varchar(64)", | ||
| 818 | "typeSchema": null, | ||
| 819 | "notNull": false, | ||
| 820 | "dimensions": 0, | ||
| 821 | "default": null, | ||
| 822 | "generated": null, | ||
| 823 | "identity": null, | ||
| 824 | "name": "app_version", | ||
| 825 | "entityType": "columns", | ||
| 826 | "schema": "public", | ||
| 827 | "table": "relay_mobile_devices" | ||
| 828 | }, | ||
| 829 | { | ||
| 830 | "type": "text", | ||
| 831 | "typeSchema": null, | ||
| 832 | "notNull": false, | ||
| 833 | "dimensions": 0, | ||
| 834 | "default": null, | ||
| 835 | "generated": null, | ||
| 836 | "identity": null, | ||
| 837 | "name": "push_token", | ||
| 838 | "entityType": "columns", | ||
| 839 | "schema": "public", | ||
| 840 | "table": "relay_mobile_devices" | ||
| 841 | }, | ||
| 842 | { | ||
| 843 | "type": "text", | ||
| 844 | "typeSchema": null, | ||
| 845 | "notNull": false, | ||
| 846 | "dimensions": 0, | ||
| 847 | "default": null, | ||
| 848 | "generated": null, | ||
| 849 | "identity": null, | ||
| 850 | "name": "push_to_start_token", | ||
| 851 | "entityType": "columns", | ||
| 852 | "schema": "public", | ||
| 853 | "table": "relay_mobile_devices" | ||
| 854 | }, | ||
| 855 | { | ||
| 856 | "type": "jsonb", | ||
| 857 | "typeSchema": null, | ||
| 858 | "notNull": true, | ||
| 859 | "dimensions": 0, | ||
| 860 | "default": null, | ||
| 861 | "generated": null, | ||
| 862 | "identity": null, | ||
| 863 | "name": "preferences_json", | ||
| 864 | "entityType": "columns", | ||
| 865 | "schema": "public", | ||
| 866 | "table": "relay_mobile_devices" | ||
| 867 | }, | ||
| 868 | { | ||
| 869 | "type": "varchar(64)", | ||
| 870 | "typeSchema": null, | ||
| 871 | "notNull": true, | ||
| 872 | "dimensions": 0, | ||
| 873 | "default": null, | ||
| 874 | "generated": null, | ||
| 875 | "identity": null, | ||
| 876 | "name": "created_at", | ||
| 877 | "entityType": "columns", | ||
| 878 | "schema": "public", | ||
| 879 | "table": "relay_mobile_devices" | ||
| 880 | }, | ||
| 881 | { | ||
| 882 | "type": "varchar(64)", | ||
| 883 | "typeSchema": null, | ||
| 884 | "notNull": true, | ||
| 885 | "dimensions": 0, | ||
| 886 | "default": null, | ||
| 887 | "generated": null, | ||
| 888 | "identity": null, | ||
| 889 | "name": "updated_at", | ||
| 890 | "entityType": "columns", | ||
| 891 | "schema": "public", | ||
| 892 | "table": "relay_mobile_devices" | ||
| 893 | }, | ||
| 894 | { | ||
| 895 | "nameExplicit": true, | ||
| 896 | "columns": [ | ||
| 897 | { | ||
| 898 | "value": "updated_at", | ||
| 899 | "isExpression": false, | ||
| 900 | "asc": true, | ||
| 901 | "nullsFirst": false, | ||
| 902 | "opclass": null | ||
| 903 | } | ||
| 904 | ], | ||
| 905 | "isUnique": false, | ||
| 906 | "where": null, | ||
| 907 | "with": "", | ||
| 908 | "method": "btree", | ||
| 909 | "concurrently": false, | ||
| 910 | "name": "idx_relay_agent_activity_rows_updated", | ||
| 911 | "entityType": "indexes", | ||
| 912 | "schema": "public", | ||
| 913 | "table": "relay_agent_activity_rows" | ||
| 914 | }, | ||
| 915 | { | ||
| 916 | "nameExplicit": true, | ||
| 917 | "columns": [ | ||
| 918 | { | ||
| 919 | "value": "environment_id", | ||
| 920 | "isExpression": false, | ||
| 921 | "asc": true, | ||
| 922 | "nullsFirst": false, | ||
| 923 | "opclass": null | ||
| 924 | }, | ||
| 925 | { | ||
| 926 | "value": "thread_id", | ||
| 927 | "isExpression": false, | ||
| 928 | "asc": true, | ||
| 929 | "nullsFirst": false, | ||
| 930 | "opclass": null | ||
| 931 | }, | ||
| 932 | { | ||
| 933 | "value": "created_at", | ||
| 934 | "isExpression": false, | ||
| 935 | "asc": true, | ||
| 936 | "nullsFirst": false, | ||
| 937 | "opclass": null | ||
| 938 | } | ||
| 939 | ], | ||
| 940 | "isUnique": false, | ||
| 941 | "where": null, | ||
| 942 | "with": "", | ||
| 943 | "method": "btree", | ||
| 944 | "concurrently": false, | ||
| 945 | "name": "idx_relay_delivery_attempts_environment", | ||
| 946 | "entityType": "indexes", | ||
| 947 | "schema": "public", | ||
| 948 | "table": "relay_delivery_attempts" | ||
| 949 | }, | ||
| 950 | { | ||
| 951 | "nameExplicit": true, | ||
| 952 | "columns": [ | ||
| 953 | { | ||
| 954 | "value": "source_job_id", | ||
| 955 | "isExpression": false, | ||
| 956 | "asc": true, | ||
| 957 | "nullsFirst": false, | ||
| 958 | "opclass": null | ||
| 959 | } | ||
| 960 | ], | ||
| 961 | "isUnique": true, | ||
| 962 | "where": null, | ||
| 963 | "with": "", | ||
| 964 | "method": "btree", | ||
| 965 | "concurrently": false, | ||
| 966 | "name": "idx_relay_delivery_attempts_source_job", | ||
| 967 | "entityType": "indexes", | ||
| 968 | "schema": "public", | ||
| 969 | "table": "relay_delivery_attempts" | ||
| 970 | }, | ||
| 971 | { | ||
| 972 | "nameExplicit": true, | ||
| 973 | "columns": [ | ||
| 974 | { | ||
| 975 | "value": "expires_at", | ||
| 976 | "isExpression": false, | ||
| 977 | "asc": true, | ||
| 978 | "nullsFirst": false, | ||
| 979 | "opclass": null | ||
| 980 | } | ||
| 981 | ], | ||
| 982 | "isUnique": false, | ||
| 983 | "where": null, | ||
| 984 | "with": "", | ||
| 985 | "method": "btree", | ||
| 986 | "concurrently": false, | ||
| 987 | "name": "idx_relay_dpop_proofs_expires_at", | ||
| 988 | "entityType": "indexes", | ||
| 989 | "schema": "public", | ||
| 990 | "table": "relay_dpop_proofs" | ||
| 991 | }, | ||
| 992 | { | ||
| 993 | "nameExplicit": true, | ||
| 994 | "columns": [ | ||
| 995 | { | ||
| 996 | "value": "credential_hash", | ||
| 997 | "isExpression": false, | ||
| 998 | "asc": true, | ||
| 999 | "nullsFirst": false, | ||
| 1000 | "opclass": null | ||
| 1001 | } | ||
| 1002 | ], | ||
| 1003 | "isUnique": true, | ||
| 1004 | "where": null, | ||
| 1005 | "with": "", | ||
| 1006 | "method": "btree", | ||
| 1007 | "concurrently": false, | ||
| 1008 | "name": "idx_relay_environment_credentials_hash", | ||
| 1009 | "entityType": "indexes", | ||
| 1010 | "schema": "public", | ||
| 1011 | "table": "relay_environment_credentials" | ||
| 1012 | }, | ||
| 1013 | { | ||
| 1014 | "nameExplicit": true, | ||
| 1015 | "columns": [ | ||
| 1016 | { | ||
| 1017 | "value": "environment_id", | ||
| 1018 | "isExpression": false, | ||
| 1019 | "asc": true, | ||
| 1020 | "nullsFirst": false, | ||
| 1021 | "opclass": null | ||
| 1022 | }, | ||
| 1023 | { | ||
| 1024 | "value": "revoked_at", | ||
| 1025 | "isExpression": false, | ||
| 1026 | "asc": true, | ||
| 1027 | "nullsFirst": false, | ||
| 1028 | "opclass": null | ||
| 1029 | } | ||
| 1030 | ], | ||
| 1031 | "isUnique": false, | ||
| 1032 | "where": null, | ||
| 1033 | "with": "", | ||
| 1034 | "method": "btree", | ||
| 1035 | "concurrently": false, | ||
| 1036 | "name": "idx_relay_environment_credentials_environment", | ||
| 1037 | "entityType": "indexes", | ||
| 1038 | "schema": "public", | ||
| 1039 | "table": "relay_environment_credentials" | ||
| 1040 | }, | ||
| 1041 | { | ||
| 1042 | "nameExplicit": true, | ||
| 1043 | "columns": [ | ||
| 1044 | { | ||
| 1045 | "value": "environment_id", | ||
| 1046 | "isExpression": false, | ||
| 1047 | "asc": true, | ||
| 1048 | "nullsFirst": false, | ||
| 1049 | "opclass": null | ||
| 1050 | }, | ||
| 1051 | { | ||
| 1052 | "value": "environment_public_key", | ||
| 1053 | "isExpression": false, | ||
| 1054 | "asc": true, | ||
| 1055 | "nullsFirst": false, | ||
| 1056 | "opclass": null | ||
| 1057 | }, | ||
| 1058 | { | ||
| 1059 | "value": "revoked_at", | ||
| 1060 | "isExpression": false, | ||
| 1061 | "asc": true, | ||
| 1062 | "nullsFirst": false, | ||
| 1063 | "opclass": null | ||
| 1064 | } | ||
| 1065 | ], | ||
| 1066 | "isUnique": false, | ||
| 1067 | "where": null, | ||
| 1068 | "with": "", | ||
| 1069 | "method": "btree", | ||
| 1070 | "concurrently": false, | ||
| 1071 | "name": "idx_relay_environment_credentials_environment_key", | ||
| 1072 | "entityType": "indexes", | ||
| 1073 | "schema": "public", | ||
| 1074 | "table": "relay_environment_credentials" | ||
| 1075 | }, | ||
| 1076 | { | ||
| 1077 | "nameExplicit": true, | ||
| 1078 | "columns": [ | ||
| 1079 | { | ||
| 1080 | "value": "environment_id", | ||
| 1081 | "isExpression": false, | ||
| 1082 | "asc": true, | ||
| 1083 | "nullsFirst": false, | ||
| 1084 | "opclass": null | ||
| 1085 | }, | ||
| 1086 | { | ||
| 1087 | "value": "revoked_at", | ||
| 1088 | "isExpression": false, | ||
| 1089 | "asc": true, | ||
| 1090 | "nullsFirst": false, | ||
| 1091 | "opclass": null | ||
| 1092 | } | ||
| 1093 | ], | ||
| 1094 | "isUnique": false, | ||
| 1095 | "where": null, | ||
| 1096 | "with": "", | ||
| 1097 | "method": "btree", | ||
| 1098 | "concurrently": false, | ||
| 1099 | "name": "idx_relay_environment_links_environment", | ||
| 1100 | "entityType": "indexes", | ||
| 1101 | "schema": "public", | ||
| 1102 | "table": "relay_environment_links" | ||
| 1103 | }, | ||
| 1104 | { | ||
| 1105 | "nameExplicit": true, | ||
| 1106 | "columns": [ | ||
| 1107 | { | ||
| 1108 | "value": "user_id", | ||
| 1109 | "isExpression": false, | ||
| 1110 | "asc": true, | ||
| 1111 | "nullsFirst": false, | ||
| 1112 | "opclass": null | ||
| 1113 | } | ||
| 1114 | ], | ||
| 1115 | "isUnique": false, | ||
| 1116 | "where": null, | ||
| 1117 | "with": "", | ||
| 1118 | "method": "btree", | ||
| 1119 | "concurrently": false, | ||
| 1120 | "name": "idx_relay_live_activities_user", | ||
| 1121 | "entityType": "indexes", | ||
| 1122 | "schema": "public", | ||
| 1123 | "table": "relay_live_activities" | ||
| 1124 | }, | ||
| 1125 | { | ||
| 1126 | "nameExplicit": true, | ||
| 1127 | "columns": [ | ||
| 1128 | { | ||
| 1129 | "value": "activity_push_token", | ||
| 1130 | "isExpression": false, | ||
| 1131 | "asc": true, | ||
| 1132 | "nullsFirst": false, | ||
| 1133 | "opclass": null | ||
| 1134 | } | ||
| 1135 | ], | ||
| 1136 | "isUnique": true, | ||
| 1137 | "where": null, | ||
| 1138 | "with": "", | ||
| 1139 | "method": "btree", | ||
| 1140 | "concurrently": false, | ||
| 1141 | "name": "idx_relay_live_activities_activity_push_token", | ||
| 1142 | "entityType": "indexes", | ||
| 1143 | "schema": "public", | ||
| 1144 | "table": "relay_live_activities" | ||
| 1145 | }, | ||
| 1146 | { | ||
| 1147 | "nameExplicit": true, | ||
| 1148 | "columns": [ | ||
| 1149 | { | ||
| 1150 | "value": "user_id", | ||
| 1151 | "isExpression": false, | ||
| 1152 | "asc": true, | ||
| 1153 | "nullsFirst": false, | ||
| 1154 | "opclass": null | ||
| 1155 | } | ||
| 1156 | ], | ||
| 1157 | "isUnique": false, | ||
| 1158 | "where": null, | ||
| 1159 | "with": "", | ||
| 1160 | "method": "btree", | ||
| 1161 | "concurrently": false, | ||
| 1162 | "name": "idx_relay_mobile_devices_user", | ||
| 1163 | "entityType": "indexes", | ||
| 1164 | "schema": "public", | ||
| 1165 | "table": "relay_mobile_devices" | ||
| 1166 | }, | ||
| 1167 | { | ||
| 1168 | "nameExplicit": true, | ||
| 1169 | "columns": [ | ||
| 1170 | { | ||
| 1171 | "value": "push_token", | ||
| 1172 | "isExpression": false, | ||
| 1173 | "asc": true, | ||
| 1174 | "nullsFirst": false, | ||
| 1175 | "opclass": null | ||
| 1176 | } | ||
| 1177 | ], | ||
| 1178 | "isUnique": true, | ||
| 1179 | "where": null, | ||
| 1180 | "with": "", | ||
| 1181 | "method": "btree", | ||
| 1182 | "concurrently": false, | ||
| 1183 | "name": "idx_relay_mobile_devices_push_token", | ||
| 1184 | "entityType": "indexes", | ||
| 1185 | "schema": "public", | ||
| 1186 | "table": "relay_mobile_devices" | ||
| 1187 | }, | ||
| 1188 | { | ||
| 1189 | "nameExplicit": true, | ||
| 1190 | "columns": [ | ||
| 1191 | { | ||
| 1192 | "value": "push_to_start_token", | ||
| 1193 | "isExpression": false, | ||
| 1194 | "asc": true, | ||
| 1195 | "nullsFirst": false, | ||
| 1196 | "opclass": null | ||
| 1197 | } | ||
| 1198 | ], | ||
| 1199 | "isUnique": true, | ||
| 1200 | "where": null, | ||
| 1201 | "with": "", | ||
| 1202 | "method": "btree", | ||
| 1203 | "concurrently": false, | ||
| 1204 | "name": "idx_relay_mobile_devices_push_to_start_token", | ||
| 1205 | "entityType": "indexes", | ||
| 1206 | "schema": "public", | ||
| 1207 | "table": "relay_mobile_devices" | ||
| 1208 | }, | ||
| 1209 | { | ||
| 1210 | "columns": ["environment_id", "environment_public_key", "thread_id"], | ||
| 1211 | "nameExplicit": false, | ||
| 1212 | "name": "relay_agent_activity_rows_pkey", | ||
| 1213 | "entityType": "pks", | ||
| 1214 | "schema": "public", | ||
| 1215 | "table": "relay_agent_activity_rows" | ||
| 1216 | }, | ||
| 1217 | { | ||
| 1218 | "columns": ["thumbprint", "jti"], | ||
| 1219 | "nameExplicit": false, | ||
| 1220 | "name": "relay_dpop_proofs_pkey", | ||
| 1221 | "entityType": "pks", | ||
| 1222 | "schema": "public", | ||
| 1223 | "table": "relay_dpop_proofs" | ||
| 1224 | }, | ||
| 1225 | { | ||
| 1226 | "columns": ["user_id", "environment_id"], | ||
| 1227 | "nameExplicit": false, | ||
| 1228 | "name": "relay_environment_links_pkey", | ||
| 1229 | "entityType": "pks", | ||
| 1230 | "schema": "public", | ||
| 1231 | "table": "relay_environment_links" | ||
| 1232 | }, | ||
| 1233 | { | ||
| 1234 | "columns": ["user_id", "device_id"], | ||
| 1235 | "nameExplicit": false, | ||
| 1236 | "name": "relay_live_activities_pkey", | ||
| 1237 | "entityType": "pks", | ||
| 1238 | "schema": "public", | ||
| 1239 | "table": "relay_live_activities" | ||
| 1240 | }, | ||
| 1241 | { | ||
| 1242 | "columns": ["user_id", "device_id"], | ||
| 1243 | "nameExplicit": false, | ||
| 1244 | "name": "relay_mobile_devices_pkey", | ||
| 1245 | "entityType": "pks", | ||
| 1246 | "schema": "public", | ||
| 1247 | "table": "relay_mobile_devices" | ||
| 1248 | }, | ||
| 1249 | { | ||
| 1250 | "columns": ["id"], | ||
| 1251 | "nameExplicit": false, | ||
| 1252 | "name": "relay_delivery_attempts_pkey", | ||
| 1253 | "schema": "public", | ||
| 1254 | "table": "relay_delivery_attempts", | ||
| 1255 | "entityType": "pks" | ||
| 1256 | }, | ||
| 1257 | { | ||
| 1258 | "columns": ["credential_id"], | ||
| 1259 | "nameExplicit": false, | ||
| 1260 | "name": "relay_environment_credentials_pkey", | ||
| 1261 | "schema": "public", | ||
| 1262 | "table": "relay_environment_credentials", | ||
| 1263 | "entityType": "pks" | ||
| 1264 | } | ||
| 1265 | ], | ||
| 1266 | "renames": [] | ||
| 1267 | } | ||
infra/relay/migrations/postgres/20260601225421_add_mobile_device_label/migration.sql created+1| ... | @@ -0,0 +1 @@ | ||
| 1 | ALTER TABLE "relay_mobile_devices" ADD COLUMN "label" text DEFAULT 'iOS device' NOT NULL; | ||
infra/relay/migrations/postgres/20260601225421_add_mobile_device_label/snapshot.json created+1280| ... | @@ -0,0 +1,1280 @@ | ||
| 1 | { | ||
| 2 | "dialect": "postgres", | ||
| 3 | "id": "e189638b-4700-4656-b86e-d8baa63f5fe4", | ||
| 4 | "prevIds": ["ad620f05-3fd1-43b8-a3ca-d4c02543d892"], | ||
| 5 | "version": "8", | ||
| 6 | "ddl": [ | ||
| 7 | { | ||
| 8 | "isRlsEnabled": false, | ||
| 9 | "name": "relay_agent_activity_rows", | ||
| 10 | "entityType": "tables", | ||
| 11 | "schema": "public" | ||
| 12 | }, | ||
| 13 | { | ||
| 14 | "isRlsEnabled": false, | ||
| 15 | "name": "relay_delivery_attempts", | ||
| 16 | "entityType": "tables", | ||
| 17 | "schema": "public" | ||
| 18 | }, | ||
| 19 | { | ||
| 20 | "isRlsEnabled": false, | ||
| 21 | "name": "relay_dpop_proofs", | ||
| 22 | "entityType": "tables", | ||
| 23 | "schema": "public" | ||
| 24 | }, | ||
| 25 | { | ||
| 26 | "isRlsEnabled": false, | ||
| 27 | "name": "relay_environment_credentials", | ||
| 28 | "entityType": "tables", | ||
| 29 | "schema": "public" | ||
| 30 | }, | ||
| 31 | { | ||
| 32 | "isRlsEnabled": false, | ||
| 33 | "name": "relay_environment_links", | ||
| 34 | "entityType": "tables", | ||
| 35 | "schema": "public" | ||
| 36 | }, | ||
| 37 | { | ||
| 38 | "isRlsEnabled": false, | ||
| 39 | "name": "relay_live_activities", | ||
| 40 | "entityType": "tables", | ||
| 41 | "schema": "public" | ||
| 42 | }, | ||
| 43 | { | ||
| 44 | "isRlsEnabled": false, | ||
| 45 | "name": "relay_mobile_devices", | ||
| 46 | "entityType": "tables", | ||
| 47 | "schema": "public" | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | "type": "varchar(191)", | ||
| 51 | "typeSchema": null, | ||
| 52 | "notNull": true, | ||
| 53 | "dimensions": 0, | ||
| 54 | "default": null, | ||
| 55 | "generated": null, | ||
| 56 | "identity": null, | ||
| 57 | "name": "environment_id", | ||
| 58 | "entityType": "columns", | ||
| 59 | "schema": "public", | ||
| 60 | "table": "relay_agent_activity_rows" | ||
| 61 | }, | ||
| 62 | { | ||
| 63 | "type": "text", | ||
| 64 | "typeSchema": null, | ||
| 65 | "notNull": true, | ||
| 66 | "dimensions": 0, | ||
| 67 | "default": null, | ||
| 68 | "generated": null, | ||
| 69 | "identity": null, | ||
| 70 | "name": "environment_public_key", | ||
| 71 | "entityType": "columns", | ||
| 72 | "schema": "public", | ||
| 73 | "table": "relay_agent_activity_rows" | ||
| 74 | }, | ||
| 75 | { | ||
| 76 | "type": "varchar(191)", | ||
| 77 | "typeSchema": null, | ||
| 78 | "notNull": true, | ||
| 79 | "dimensions": 0, | ||
| 80 | "default": null, | ||
| 81 | "generated": null, | ||
| 82 | "identity": null, | ||
| 83 | "name": "thread_id", | ||
| 84 | "entityType": "columns", | ||
| 85 | "schema": "public", | ||
| 86 | "table": "relay_agent_activity_rows" | ||
| 87 | }, | ||
| 88 | { | ||
| 89 | "type": "jsonb", | ||
| 90 | "typeSchema": null, | ||
| 91 | "notNull": true, | ||
| 92 | "dimensions": 0, | ||
| 93 | "default": null, | ||
| 94 | "generated": null, | ||
| 95 | "identity": null, | ||
| 96 | "name": "state_json", | ||
| 97 | "entityType": "columns", | ||
| 98 | "schema": "public", | ||
| 99 | "table": "relay_agent_activity_rows" | ||
| 100 | }, | ||
| 101 | { | ||
| 102 | "type": "varchar(64)", | ||
| 103 | "typeSchema": null, | ||
| 104 | "notNull": true, | ||
| 105 | "dimensions": 0, | ||
| 106 | "default": null, | ||
| 107 | "generated": null, | ||
| 108 | "identity": null, | ||
| 109 | "name": "updated_at", | ||
| 110 | "entityType": "columns", | ||
| 111 | "schema": "public", | ||
| 112 | "table": "relay_agent_activity_rows" | ||
| 113 | }, | ||
| 114 | { | ||
| 115 | "type": "varchar(64)", | ||
| 116 | "typeSchema": null, | ||
| 117 | "notNull": true, | ||
| 118 | "dimensions": 0, | ||
| 119 | "default": null, | ||
| 120 | "generated": null, | ||
| 121 | "identity": null, | ||
| 122 | "name": "created_at", | ||
| 123 | "entityType": "columns", | ||
| 124 | "schema": "public", | ||
| 125 | "table": "relay_agent_activity_rows" | ||
| 126 | }, | ||
| 127 | { | ||
| 128 | "type": "varchar(36)", | ||
| 129 | "typeSchema": null, | ||
| 130 | "notNull": true, | ||
| 131 | "dimensions": 0, | ||
| 132 | "default": null, | ||
| 133 | "generated": null, | ||
| 134 | "identity": null, | ||
| 135 | "name": "id", | ||
| 136 | "entityType": "columns", | ||
| 137 | "schema": "public", | ||
| 138 | "table": "relay_delivery_attempts" | ||
| 139 | }, | ||
| 140 | { | ||
| 141 | "type": "varchar(64)", | ||
| 142 | "typeSchema": null, | ||
| 143 | "notNull": true, | ||
| 144 | "dimensions": 0, | ||
| 145 | "default": null, | ||
| 146 | "generated": null, | ||
| 147 | "identity": null, | ||
| 148 | "name": "created_at", | ||
| 149 | "entityType": "columns", | ||
| 150 | "schema": "public", | ||
| 151 | "table": "relay_delivery_attempts" | ||
| 152 | }, | ||
| 153 | { | ||
| 154 | "type": "varchar(255)", | ||
| 155 | "typeSchema": null, | ||
| 156 | "notNull": false, | ||
| 157 | "dimensions": 0, | ||
| 158 | "default": null, | ||
| 159 | "generated": null, | ||
| 160 | "identity": null, | ||
| 161 | "name": "user_id", | ||
| 162 | "entityType": "columns", | ||
| 163 | "schema": "public", | ||
| 164 | "table": "relay_delivery_attempts" | ||
| 165 | }, | ||
| 166 | { | ||
| 167 | "type": "varchar(191)", | ||
| 168 | "typeSchema": null, | ||
| 169 | "notNull": false, | ||
| 170 | "dimensions": 0, | ||
| 171 | "default": null, | ||
| 172 | "generated": null, | ||
| 173 | "identity": null, | ||
| 174 | "name": "environment_id", | ||
| 175 | "entityType": "columns", | ||
| 176 | "schema": "public", | ||
| 177 | "table": "relay_delivery_attempts" | ||
| 178 | }, | ||
| 179 | { | ||
| 180 | "type": "varchar(191)", | ||
| 181 | "typeSchema": null, | ||
| 182 | "notNull": false, | ||
| 183 | "dimensions": 0, | ||
| 184 | "default": null, | ||
| 185 | "generated": null, | ||
| 186 | "identity": null, | ||
| 187 | "name": "thread_id", | ||
| 188 | "entityType": "columns", | ||
| 189 | "schema": "public", | ||
| 190 | "table": "relay_delivery_attempts" | ||
| 191 | }, | ||
| 192 | { | ||
| 193 | "type": "varchar(255)", | ||
| 194 | "typeSchema": null, | ||
| 195 | "notNull": false, | ||
| 196 | "dimensions": 0, | ||
| 197 | "default": null, | ||
| 198 | "generated": null, | ||
| 199 | "identity": null, | ||
| 200 | "name": "device_id", | ||
| 201 | "entityType": "columns", | ||
| 202 | "schema": "public", | ||
| 203 | "table": "relay_delivery_attempts" | ||
| 204 | }, | ||
| 205 | { | ||
| 206 | "type": "varchar(64)", | ||
| 207 | "typeSchema": null, | ||
| 208 | "notNull": true, | ||
| 209 | "dimensions": 0, | ||
| 210 | "default": null, | ||
| 211 | "generated": null, | ||
| 212 | "identity": null, | ||
| 213 | "name": "kind", | ||
| 214 | "entityType": "columns", | ||
| 215 | "schema": "public", | ||
| 216 | "table": "relay_delivery_attempts" | ||
| 217 | }, | ||
| 218 | { | ||
| 219 | "type": "varchar(64)", | ||
| 220 | "typeSchema": null, | ||
| 221 | "notNull": false, | ||
| 222 | "dimensions": 0, | ||
| 223 | "default": null, | ||
| 224 | "generated": null, | ||
| 225 | "identity": null, | ||
| 226 | "name": "source_job_id", | ||
| 227 | "entityType": "columns", | ||
| 228 | "schema": "public", | ||
| 229 | "table": "relay_delivery_attempts" | ||
| 230 | }, | ||
| 231 | { | ||
| 232 | "type": "varchar(16)", | ||
| 233 | "typeSchema": null, | ||
| 234 | "notNull": false, | ||
| 235 | "dimensions": 0, | ||
| 236 | "default": null, | ||
| 237 | "generated": null, | ||
| 238 | "identity": null, | ||
| 239 | "name": "token_suffix", | ||
| 240 | "entityType": "columns", | ||
| 241 | "schema": "public", | ||
| 242 | "table": "relay_delivery_attempts" | ||
| 243 | }, | ||
| 244 | { | ||
| 245 | "type": "integer", | ||
| 246 | "typeSchema": null, | ||
| 247 | "notNull": false, | ||
| 248 | "dimensions": 0, | ||
| 249 | "default": null, | ||
| 250 | "generated": null, | ||
| 251 | "identity": null, | ||
| 252 | "name": "apns_status", | ||
| 253 | "entityType": "columns", | ||
| 254 | "schema": "public", | ||
| 255 | "table": "relay_delivery_attempts" | ||
| 256 | }, | ||
| 257 | { | ||
| 258 | "type": "text", | ||
| 259 | "typeSchema": null, | ||
| 260 | "notNull": false, | ||
| 261 | "dimensions": 0, | ||
| 262 | "default": null, | ||
| 263 | "generated": null, | ||
| 264 | "identity": null, | ||
| 265 | "name": "apns_reason", | ||
| 266 | "entityType": "columns", | ||
| 267 | "schema": "public", | ||
| 268 | "table": "relay_delivery_attempts" | ||
| 269 | }, | ||
| 270 | { | ||
| 271 | "type": "varchar(128)", | ||
| 272 | "typeSchema": null, | ||
| 273 | "notNull": false, | ||
| 274 | "dimensions": 0, | ||
| 275 | "default": null, | ||
| 276 | "generated": null, | ||
| 277 | "identity": null, | ||
| 278 | "name": "apns_id", | ||
| 279 | "entityType": "columns", | ||
| 280 | "schema": "public", | ||
| 281 | "table": "relay_delivery_attempts" | ||
| 282 | }, | ||
| 283 | { | ||
| 284 | "type": "text", | ||
| 285 | "typeSchema": null, | ||
| 286 | "notNull": false, | ||
| 287 | "dimensions": 0, | ||
| 288 | "default": null, | ||
| 289 | "generated": null, | ||
| 290 | "identity": null, | ||
| 291 | "name": "transport_error", | ||
| 292 | "entityType": "columns", | ||
| 293 | "schema": "public", | ||
| 294 | "table": "relay_delivery_attempts" | ||
| 295 | }, | ||
| 296 | { | ||
| 297 | "type": "varchar(128)", | ||
| 298 | "typeSchema": null, | ||
| 299 | "notNull": true, | ||
| 300 | "dimensions": 0, | ||
| 301 | "default": null, | ||
| 302 | "generated": null, | ||
| 303 | "identity": null, | ||
| 304 | "name": "thumbprint", | ||
| 305 | "entityType": "columns", | ||
| 306 | "schema": "public", | ||
| 307 | "table": "relay_dpop_proofs" | ||
| 308 | }, | ||
| 309 | { | ||
| 310 | "type": "varchar(255)", | ||
| 311 | "typeSchema": null, | ||
| 312 | "notNull": true, | ||
| 313 | "dimensions": 0, | ||
| 314 | "default": null, | ||
| 315 | "generated": null, | ||
| 316 | "identity": null, | ||
| 317 | "name": "jti", | ||
| 318 | "entityType": "columns", | ||
| 319 | "schema": "public", | ||
| 320 | "table": "relay_dpop_proofs" | ||
| 321 | }, | ||
| 322 | { | ||
| 323 | "type": "integer", | ||
| 324 | "typeSchema": null, | ||
| 325 | "notNull": true, | ||
| 326 | "dimensions": 0, | ||
| 327 | "default": null, | ||
| 328 | "generated": null, | ||
| 329 | "identity": null, | ||
| 330 | "name": "iat", | ||
| 331 | "entityType": "columns", | ||
| 332 | "schema": "public", | ||
| 333 | "table": "relay_dpop_proofs" | ||
| 334 | }, | ||
| 335 | { | ||
| 336 | "type": "varchar(64)", | ||
| 337 | "typeSchema": null, | ||
| 338 | "notNull": true, | ||
| 339 | "dimensions": 0, | ||
| 340 | "default": null, | ||
| 341 | "generated": null, | ||
| 342 | "identity": null, | ||
| 343 | "name": "expires_at", | ||
| 344 | "entityType": "columns", | ||
| 345 | "schema": "public", | ||
| 346 | "table": "relay_dpop_proofs" | ||
| 347 | }, | ||
| 348 | { | ||
| 349 | "type": "varchar(64)", | ||
| 350 | "typeSchema": null, | ||
| 351 | "notNull": true, | ||
| 352 | "dimensions": 0, | ||
| 353 | "default": null, | ||
| 354 | "generated": null, | ||
| 355 | "identity": null, | ||
| 356 | "name": "created_at", | ||
| 357 | "entityType": "columns", | ||
| 358 | "schema": "public", | ||
| 359 | "table": "relay_dpop_proofs" | ||
| 360 | }, | ||
| 361 | { | ||
| 362 | "type": "varchar(64)", | ||
| 363 | "typeSchema": null, | ||
| 364 | "notNull": true, | ||
| 365 | "dimensions": 0, | ||
| 366 | "default": null, | ||
| 367 | "generated": null, | ||
| 368 | "identity": null, | ||
| 369 | "name": "credential_id", | ||
| 370 | "entityType": "columns", | ||
| 371 | "schema": "public", | ||
| 372 | "table": "relay_environment_credentials" | ||
| 373 | }, | ||
| 374 | { | ||
| 375 | "type": "varchar(191)", | ||
| 376 | "typeSchema": null, | ||
| 377 | "notNull": true, | ||
| 378 | "dimensions": 0, | ||
| 379 | "default": null, | ||
| 380 | "generated": null, | ||
| 381 | "identity": null, | ||
| 382 | "name": "environment_id", | ||
| 383 | "entityType": "columns", | ||
| 384 | "schema": "public", | ||
| 385 | "table": "relay_environment_credentials" | ||
| 386 | }, | ||
| 387 | { | ||
| 388 | "type": "text", | ||
| 389 | "typeSchema": null, | ||
| 390 | "notNull": true, | ||
| 391 | "dimensions": 0, | ||
| 392 | "default": null, | ||
| 393 | "generated": null, | ||
| 394 | "identity": null, | ||
| 395 | "name": "environment_public_key", | ||
| 396 | "entityType": "columns", | ||
| 397 | "schema": "public", | ||
| 398 | "table": "relay_environment_credentials" | ||
| 399 | }, | ||
| 400 | { | ||
| 401 | "type": "varchar(191)", | ||
| 402 | "typeSchema": null, | ||
| 403 | "notNull": true, | ||
| 404 | "dimensions": 0, | ||
| 405 | "default": null, | ||
| 406 | "generated": null, | ||
| 407 | "identity": null, | ||
| 408 | "name": "credential_hash", | ||
| 409 | "entityType": "columns", | ||
| 410 | "schema": "public", | ||
| 411 | "table": "relay_environment_credentials" | ||
| 412 | }, | ||
| 413 | { | ||
| 414 | "type": "varchar(64)", | ||
| 415 | "typeSchema": null, | ||
| 416 | "notNull": false, | ||
| 417 | "dimensions": 0, | ||
| 418 | "default": null, | ||
| 419 | "generated": null, | ||
| 420 | "identity": null, | ||
| 421 | "name": "revoked_at", | ||
| 422 | "entityType": "columns", | ||
| 423 | "schema": "public", | ||
| 424 | "table": "relay_environment_credentials" | ||
| 425 | }, | ||
| 426 | { | ||
| 427 | "type": "varchar(64)", | ||
| 428 | "typeSchema": null, | ||
| 429 | "notNull": true, | ||
| 430 | "dimensions": 0, | ||
| 431 | "default": null, | ||
| 432 | "generated": null, | ||
| 433 | "identity": null, | ||
| 434 | "name": "created_at", | ||
| 435 | "entityType": "columns", | ||
| 436 | "schema": "public", | ||
| 437 | "table": "relay_environment_credentials" | ||
| 438 | }, | ||
| 439 | { | ||
| 440 | "type": "varchar(64)", | ||
| 441 | "typeSchema": null, | ||
| 442 | "notNull": true, | ||
| 443 | "dimensions": 0, | ||
| 444 | "default": null, | ||
| 445 | "generated": null, | ||
| 446 | "identity": null, | ||
| 447 | "name": "updated_at", | ||
| 448 | "entityType": "columns", | ||
| 449 | "schema": "public", | ||
| 450 | "table": "relay_environment_credentials" | ||
| 451 | }, | ||
| 452 | { | ||
| 453 | "type": "varchar(191)", | ||
| 454 | "typeSchema": null, | ||
| 455 | "notNull": true, | ||
| 456 | "dimensions": 0, | ||
| 457 | "default": null, | ||
| 458 | "generated": null, | ||
| 459 | "identity": null, | ||
| 460 | "name": "user_id", | ||
| 461 | "entityType": "columns", | ||
| 462 | "schema": "public", | ||
| 463 | "table": "relay_environment_links" | ||
| 464 | }, | ||
| 465 | { | ||
| 466 | "type": "varchar(191)", | ||
| 467 | "typeSchema": null, | ||
| 468 | "notNull": true, | ||
| 469 | "dimensions": 0, | ||
| 470 | "default": null, | ||
| 471 | "generated": null, | ||
| 472 | "identity": null, | ||
| 473 | "name": "environment_id", | ||
| 474 | "entityType": "columns", | ||
| 475 | "schema": "public", | ||
| 476 | "table": "relay_environment_links" | ||
| 477 | }, | ||
| 478 | { | ||
| 479 | "type": "text", | ||
| 480 | "typeSchema": null, | ||
| 481 | "notNull": true, | ||
| 482 | "dimensions": 0, | ||
| 483 | "default": "'T3 Environment'", | ||
| 484 | "generated": null, | ||
| 485 | "identity": null, | ||
| 486 | "name": "environment_label", | ||
| 487 | "entityType": "columns", | ||
| 488 | "schema": "public", | ||
| 489 | "table": "relay_environment_links" | ||
| 490 | }, | ||
| 491 | { | ||
| 492 | "type": "text", | ||
| 493 | "typeSchema": null, | ||
| 494 | "notNull": true, | ||
| 495 | "dimensions": 0, | ||
| 496 | "default": null, | ||
| 497 | "generated": null, | ||
| 498 | "identity": null, | ||
| 499 | "name": "environment_public_key", | ||
| 500 | "entityType": "columns", | ||
| 501 | "schema": "public", | ||
| 502 | "table": "relay_environment_links" | ||
| 503 | }, | ||
| 504 | { | ||
| 505 | "type": "text", | ||
| 506 | "typeSchema": null, | ||
| 507 | "notNull": true, | ||
| 508 | "dimensions": 0, | ||
| 509 | "default": null, | ||
| 510 | "generated": null, | ||
| 511 | "identity": null, | ||
| 512 | "name": "endpoint_http_base_url", | ||
| 513 | "entityType": "columns", | ||
| 514 | "schema": "public", | ||
| 515 | "table": "relay_environment_links" | ||
| 516 | }, | ||
| 517 | { | ||
| 518 | "type": "text", | ||
| 519 | "typeSchema": null, | ||
| 520 | "notNull": true, | ||
| 521 | "dimensions": 0, | ||
| 522 | "default": null, | ||
| 523 | "generated": null, | ||
| 524 | "identity": null, | ||
| 525 | "name": "endpoint_ws_base_url", | ||
| 526 | "entityType": "columns", | ||
| 527 | "schema": "public", | ||
| 528 | "table": "relay_environment_links" | ||
| 529 | }, | ||
| 530 | { | ||
| 531 | "type": "varchar(32)", | ||
| 532 | "typeSchema": null, | ||
| 533 | "notNull": true, | ||
| 534 | "dimensions": 0, | ||
| 535 | "default": null, | ||
| 536 | "generated": null, | ||
| 537 | "identity": null, | ||
| 538 | "name": "endpoint_provider_kind", | ||
| 539 | "entityType": "columns", | ||
| 540 | "schema": "public", | ||
| 541 | "table": "relay_environment_links" | ||
| 542 | }, | ||
| 543 | { | ||
| 544 | "type": "boolean", | ||
| 545 | "typeSchema": null, | ||
| 546 | "notNull": true, | ||
| 547 | "dimensions": 0, | ||
| 548 | "default": "true", | ||
| 549 | "generated": null, | ||
| 550 | "identity": null, | ||
| 551 | "name": "notifications_enabled", | ||
| 552 | "entityType": "columns", | ||
| 553 | "schema": "public", | ||
| 554 | "table": "relay_environment_links" | ||
| 555 | }, | ||
| 556 | { | ||
| 557 | "type": "boolean", | ||
| 558 | "typeSchema": null, | ||
| 559 | "notNull": true, | ||
| 560 | "dimensions": 0, | ||
| 561 | "default": "true", | ||
| 562 | "generated": null, | ||
| 563 | "identity": null, | ||
| 564 | "name": "live_activities_enabled", | ||
| 565 | "entityType": "columns", | ||
| 566 | "schema": "public", | ||
| 567 | "table": "relay_environment_links" | ||
| 568 | }, | ||
| 569 | { | ||
| 570 | "type": "boolean", | ||
| 571 | "typeSchema": null, | ||
| 572 | "notNull": true, | ||
| 573 | "dimensions": 0, | ||
| 574 | "default": "false", | ||
| 575 | "generated": null, | ||
| 576 | "identity": null, | ||
| 577 | "name": "managed_tunnels_enabled", | ||
| 578 | "entityType": "columns", | ||
| 579 | "schema": "public", | ||
| 580 | "table": "relay_environment_links" | ||
| 581 | }, | ||
| 582 | { | ||
| 583 | "type": "varchar(191)", | ||
| 584 | "typeSchema": null, | ||
| 585 | "notNull": false, | ||
| 586 | "dimensions": 0, | ||
| 587 | "default": null, | ||
| 588 | "generated": null, | ||
| 589 | "identity": null, | ||
| 590 | "name": "created_by_device_id", | ||
| 591 | "entityType": "columns", | ||
| 592 | "schema": "public", | ||
| 593 | "table": "relay_environment_links" | ||
| 594 | }, | ||
| 595 | { | ||
| 596 | "type": "varchar(64)", | ||
| 597 | "typeSchema": null, | ||
| 598 | "notNull": false, | ||
| 599 | "dimensions": 0, | ||
| 600 | "default": null, | ||
| 601 | "generated": null, | ||
| 602 | "identity": null, | ||
| 603 | "name": "revoked_at", | ||
| 604 | "entityType": "columns", | ||
| 605 | "schema": "public", | ||
| 606 | "table": "relay_environment_links" | ||
| 607 | }, | ||
| 608 | { | ||
| 609 | "type": "varchar(64)", | ||
| 610 | "typeSchema": null, | ||
| 611 | "notNull": true, | ||
| 612 | "dimensions": 0, | ||
| 613 | "default": null, | ||
| 614 | "generated": null, | ||
| 615 | "identity": null, | ||
| 616 | "name": "created_at", | ||
| 617 | "entityType": "columns", | ||
| 618 | "schema": "public", | ||
| 619 | "table": "relay_environment_links" | ||
| 620 | }, | ||
| 621 | { | ||
| 622 | "type": "varchar(64)", | ||
| 623 | "typeSchema": null, | ||
| 624 | "notNull": true, | ||
| 625 | "dimensions": 0, | ||
| 626 | "default": null, | ||
| 627 | "generated": null, | ||
| 628 | "identity": null, | ||
| 629 | "name": "updated_at", | ||
| 630 | "entityType": "columns", | ||
| 631 | "schema": "public", | ||
| 632 | "table": "relay_environment_links" | ||
| 633 | }, | ||
| 634 | { | ||
| 635 | "type": "varchar(255)", | ||
| 636 | "typeSchema": null, | ||
| 637 | "notNull": true, | ||
| 638 | "dimensions": 0, | ||
| 639 | "default": null, | ||
| 640 | "generated": null, | ||
| 641 | "identity": null, | ||
| 642 | "name": "user_id", | ||
| 643 | "entityType": "columns", | ||
| 644 | "schema": "public", | ||
| 645 | "table": "relay_live_activities" | ||
| 646 | }, | ||
| 647 | { | ||
| 648 | "type": "varchar(255)", | ||
| 649 | "typeSchema": null, | ||
| 650 | "notNull": true, | ||
| 651 | "dimensions": 0, | ||
| 652 | "default": null, | ||
| 653 | "generated": null, | ||
| 654 | "identity": null, | ||
| 655 | "name": "device_id", | ||
| 656 | "entityType": "columns", | ||
| 657 | "schema": "public", | ||
| 658 | "table": "relay_live_activities" | ||
| 659 | }, | ||
| 660 | { | ||
| 661 | "type": "text", | ||
| 662 | "typeSchema": null, | ||
| 663 | "notNull": false, | ||
| 664 | "dimensions": 0, | ||
| 665 | "default": null, | ||
| 666 | "generated": null, | ||
| 667 | "identity": null, | ||
| 668 | "name": "activity_push_token", | ||
| 669 | "entityType": "columns", | ||
| 670 | "schema": "public", | ||
| 671 | "table": "relay_live_activities" | ||
| 672 | }, | ||
| 673 | { | ||
| 674 | "type": "varchar(64)", | ||
| 675 | "typeSchema": null, | ||
| 676 | "notNull": false, | ||
| 677 | "dimensions": 0, | ||
| 678 | "default": null, | ||
| 679 | "generated": null, | ||
| 680 | "identity": null, | ||
| 681 | "name": "remote_start_queued_at", | ||
| 682 | "entityType": "columns", | ||
| 683 | "schema": "public", | ||
| 684 | "table": "relay_live_activities" | ||
| 685 | }, | ||
| 686 | { | ||
| 687 | "type": "varchar(64)", | ||
| 688 | "typeSchema": null, | ||
| 689 | "notNull": false, | ||
| 690 | "dimensions": 0, | ||
| 691 | "default": null, | ||
| 692 | "generated": null, | ||
| 693 | "identity": null, | ||
| 694 | "name": "remote_started_at", | ||
| 695 | "entityType": "columns", | ||
| 696 | "schema": "public", | ||
| 697 | "table": "relay_live_activities" | ||
| 698 | }, | ||
| 699 | { | ||
| 700 | "type": "varchar(64)", | ||
| 701 | "typeSchema": null, | ||
| 702 | "notNull": false, | ||
| 703 | "dimensions": 0, | ||
| 704 | "default": null, | ||
| 705 | "generated": null, | ||
| 706 | "identity": null, | ||
| 707 | "name": "ended_at", | ||
| 708 | "entityType": "columns", | ||
| 709 | "schema": "public", | ||
| 710 | "table": "relay_live_activities" | ||
| 711 | }, | ||
| 712 | { | ||
| 713 | "type": "jsonb", | ||
| 714 | "typeSchema": null, | ||
| 715 | "notNull": false, | ||
| 716 | "dimensions": 0, | ||
| 717 | "default": null, | ||
| 718 | "generated": null, | ||
| 719 | "identity": null, | ||
| 720 | "name": "last_aggregate_json", | ||
| 721 | "entityType": "columns", | ||
| 722 | "schema": "public", | ||
| 723 | "table": "relay_live_activities" | ||
| 724 | }, | ||
| 725 | { | ||
| 726 | "type": "varchar(64)", | ||
| 727 | "typeSchema": null, | ||
| 728 | "notNull": false, | ||
| 729 | "dimensions": 0, | ||
| 730 | "default": null, | ||
| 731 | "generated": null, | ||
| 732 | "identity": null, | ||
| 733 | "name": "last_live_activity_delivery_at", | ||
| 734 | "entityType": "columns", | ||
| 735 | "schema": "public", | ||
| 736 | "table": "relay_live_activities" | ||
| 737 | }, | ||
| 738 | { | ||
| 739 | "type": "varchar(64)", | ||
| 740 | "typeSchema": null, | ||
| 741 | "notNull": true, | ||
| 742 | "dimensions": 0, | ||
| 743 | "default": null, | ||
| 744 | "generated": null, | ||
| 745 | "identity": null, | ||
| 746 | "name": "created_at", | ||
| 747 | "entityType": "columns", | ||
| 748 | "schema": "public", | ||
| 749 | "table": "relay_live_activities" | ||
| 750 | }, | ||
| 751 | { | ||
| 752 | "type": "varchar(64)", | ||
| 753 | "typeSchema": null, | ||
| 754 | "notNull": true, | ||
| 755 | "dimensions": 0, | ||
| 756 | "default": null, | ||
| 757 | "generated": null, | ||
| 758 | "identity": null, | ||
| 759 | "name": "updated_at", | ||
| 760 | "entityType": "columns", | ||
| 761 | "schema": "public", | ||
| 762 | "table": "relay_live_activities" | ||
| 763 | }, | ||
| 764 | { | ||
| 765 | "type": "varchar(255)", | ||
| 766 | "typeSchema": null, | ||
| 767 | "notNull": true, | ||
| 768 | "dimensions": 0, | ||
| 769 | "default": null, | ||
| 770 | "generated": null, | ||
| 771 | "identity": null, | ||
| 772 | "name": "user_id", | ||
| 773 | "entityType": "columns", | ||
| 774 | "schema": "public", | ||
| 775 | "table": "relay_mobile_devices" | ||
| 776 | }, | ||
| 777 | { | ||
| 778 | "type": "varchar(255)", | ||
| 779 | "typeSchema": null, | ||
| 780 | "notNull": true, | ||
| 781 | "dimensions": 0, | ||
| 782 | "default": null, | ||
| 783 | "generated": null, | ||
| 784 | "identity": null, | ||
| 785 | "name": "device_id", | ||
| 786 | "entityType": "columns", | ||
| 787 | "schema": "public", | ||
| 788 | "table": "relay_mobile_devices" | ||
| 789 | }, | ||
| 790 | { | ||
| 791 | "type": "text", | ||
| 792 | "typeSchema": null, | ||
| 793 | "notNull": true, | ||
| 794 | "dimensions": 0, | ||
| 795 | "default": "'iOS device'", | ||
| 796 | "generated": null, | ||
| 797 | "identity": null, | ||
| 798 | "name": "label", | ||
| 799 | "entityType": "columns", | ||
| 800 | "schema": "public", | ||
| 801 | "table": "relay_mobile_devices" | ||
| 802 | }, | ||
| 803 | { | ||
| 804 | "type": "varchar(16)", | ||
| 805 | "typeSchema": null, | ||
| 806 | "notNull": true, | ||
| 807 | "dimensions": 0, | ||
| 808 | "default": null, | ||
| 809 | "generated": null, | ||
| 810 | "identity": null, | ||
| 811 | "name": "platform", | ||
| 812 | "entityType": "columns", | ||
| 813 | "schema": "public", | ||
| 814 | "table": "relay_mobile_devices" | ||
| 815 | }, | ||
| 816 | { | ||
| 817 | "type": "integer", | ||
| 818 | "typeSchema": null, | ||
| 819 | "notNull": true, | ||
| 820 | "dimensions": 0, | ||
| 821 | "default": null, | ||
| 822 | "generated": null, | ||
| 823 | "identity": null, | ||
| 824 | "name": "ios_major_version", | ||
| 825 | "entityType": "columns", | ||
| 826 | "schema": "public", | ||
| 827 | "table": "relay_mobile_devices" | ||
| 828 | }, | ||
| 829 | { | ||
| 830 | "type": "varchar(64)", | ||
| 831 | "typeSchema": null, | ||
| 832 | "notNull": false, | ||
| 833 | "dimensions": 0, | ||
| 834 | "default": null, | ||
| 835 | "generated": null, | ||
| 836 | "identity": null, | ||
| 837 | "name": "app_version", | ||
| 838 | "entityType": "columns", | ||
| 839 | "schema": "public", | ||
| 840 | "table": "relay_mobile_devices" | ||
| 841 | }, | ||
| 842 | { | ||
| 843 | "type": "text", | ||
| 844 | "typeSchema": null, | ||
| 845 | "notNull": false, | ||
| 846 | "dimensions": 0, | ||
| 847 | "default": null, | ||
| 848 | "generated": null, | ||
| 849 | "identity": null, | ||
| 850 | "name": "push_token", | ||
| 851 | "entityType": "columns", | ||
| 852 | "schema": "public", | ||
| 853 | "table": "relay_mobile_devices" | ||
| 854 | }, | ||
| 855 | { | ||
| 856 | "type": "text", | ||
| 857 | "typeSchema": null, | ||
| 858 | "notNull": false, | ||
| 859 | "dimensions": 0, | ||
| 860 | "default": null, | ||
| 861 | "generated": null, | ||
| 862 | "identity": null, | ||
| 863 | "name": "push_to_start_token", | ||
| 864 | "entityType": "columns", | ||
| 865 | "schema": "public", | ||
| 866 | "table": "relay_mobile_devices" | ||
| 867 | }, | ||
| 868 | { | ||
| 869 | "type": "jsonb", | ||
| 870 | "typeSchema": null, | ||
| 871 | "notNull": true, | ||
| 872 | "dimensions": 0, | ||
| 873 | "default": null, | ||
| 874 | "generated": null, | ||
| 875 | "identity": null, | ||
| 876 | "name": "preferences_json", | ||
| 877 | "entityType": "columns", | ||
| 878 | "schema": "public", | ||
| 879 | "table": "relay_mobile_devices" | ||
| 880 | }, | ||
| 881 | { | ||
| 882 | "type": "varchar(64)", | ||
| 883 | "typeSchema": null, | ||
| 884 | "notNull": true, | ||
| 885 | "dimensions": 0, | ||
| 886 | "default": null, | ||
| 887 | "generated": null, | ||
| 888 | "identity": null, | ||
| 889 | "name": "created_at", | ||
| 890 | "entityType": "columns", | ||
| 891 | "schema": "public", | ||
| 892 | "table": "relay_mobile_devices" | ||
| 893 | }, | ||
| 894 | { | ||
| 895 | "type": "varchar(64)", | ||
| 896 | "typeSchema": null, | ||
| 897 | "notNull": true, | ||
| 898 | "dimensions": 0, | ||
| 899 | "default": null, | ||
| 900 | "generated": null, | ||
| 901 | "identity": null, | ||
| 902 | "name": "updated_at", | ||
| 903 | "entityType": "columns", | ||
| 904 | "schema": "public", | ||
| 905 | "table": "relay_mobile_devices" | ||
| 906 | }, | ||
| 907 | { | ||
| 908 | "nameExplicit": true, | ||
| 909 | "columns": [ | ||
| 910 | { | ||
| 911 | "value": "updated_at", | ||
| 912 | "isExpression": false, | ||
| 913 | "asc": true, | ||
| 914 | "nullsFirst": false, | ||
| 915 | "opclass": null | ||
| 916 | } | ||
| 917 | ], | ||
| 918 | "isUnique": false, | ||
| 919 | "where": null, | ||
| 920 | "with": "", | ||
| 921 | "method": "btree", | ||
| 922 | "concurrently": false, | ||
| 923 | "name": "idx_relay_agent_activity_rows_updated", | ||
| 924 | "entityType": "indexes", | ||
| 925 | "schema": "public", | ||
| 926 | "table": "relay_agent_activity_rows" | ||
| 927 | }, | ||
| 928 | { | ||
| 929 | "nameExplicit": true, | ||
| 930 | "columns": [ | ||
| 931 | { | ||
| 932 | "value": "environment_id", | ||
| 933 | "isExpression": false, | ||
| 934 | "asc": true, | ||
| 935 | "nullsFirst": false, | ||
| 936 | "opclass": null | ||
| 937 | }, | ||
| 938 | { | ||
| 939 | "value": "thread_id", | ||
| 940 | "isExpression": false, | ||
| 941 | "asc": true, | ||
| 942 | "nullsFirst": false, | ||
| 943 | "opclass": null | ||
| 944 | }, | ||
| 945 | { | ||
| 946 | "value": "created_at", | ||
| 947 | "isExpression": false, | ||
| 948 | "asc": true, | ||
| 949 | "nullsFirst": false, | ||
| 950 | "opclass": null | ||
| 951 | } | ||
| 952 | ], | ||
| 953 | "isUnique": false, | ||
| 954 | "where": null, | ||
| 955 | "with": "", | ||
| 956 | "method": "btree", | ||
| 957 | "concurrently": false, | ||
| 958 | "name": "idx_relay_delivery_attempts_environment", | ||
| 959 | "entityType": "indexes", | ||
| 960 | "schema": "public", | ||
| 961 | "table": "relay_delivery_attempts" | ||
| 962 | }, | ||
| 963 | { | ||
| 964 | "nameExplicit": true, | ||
| 965 | "columns": [ | ||
| 966 | { | ||
| 967 | "value": "source_job_id", | ||
| 968 | "isExpression": false, | ||
| 969 | "asc": true, | ||
| 970 | "nullsFirst": false, | ||
| 971 | "opclass": null | ||
| 972 | } | ||
| 973 | ], | ||
| 974 | "isUnique": true, | ||
| 975 | "where": null, | ||
| 976 | "with": "", | ||
| 977 | "method": "btree", | ||
| 978 | "concurrently": false, | ||
| 979 | "name": "idx_relay_delivery_attempts_source_job", | ||
| 980 | "entityType": "indexes", | ||
| 981 | "schema": "public", | ||
| 982 | "table": "relay_delivery_attempts" | ||
| 983 | }, | ||
| 984 | { | ||
| 985 | "nameExplicit": true, | ||
| 986 | "columns": [ | ||
| 987 | { | ||
| 988 | "value": "expires_at", | ||
| 989 | "isExpression": false, | ||
| 990 | "asc": true, | ||
| 991 | "nullsFirst": false, | ||
| 992 | "opclass": null | ||
| 993 | } | ||
| 994 | ], | ||
| 995 | "isUnique": false, | ||
| 996 | "where": null, | ||
| 997 | "with": "", | ||
| 998 | "method": "btree", | ||
| 999 | "concurrently": false, | ||
| 1000 | "name": "idx_relay_dpop_proofs_expires_at", | ||
| 1001 | "entityType": "indexes", | ||
| 1002 | "schema": "public", | ||
| 1003 | "table": "relay_dpop_proofs" | ||
| 1004 | }, | ||
| 1005 | { | ||
| 1006 | "nameExplicit": true, | ||
| 1007 | "columns": [ | ||
| 1008 | { | ||
| 1009 | "value": "credential_hash", | ||
| 1010 | "isExpression": false, | ||
| 1011 | "asc": true, | ||
| 1012 | "nullsFirst": false, | ||
| 1013 | "opclass": null | ||
| 1014 | } | ||
| 1015 | ], | ||
| 1016 | "isUnique": true, | ||
| 1017 | "where": null, | ||
| 1018 | "with": "", | ||
| 1019 | "method": "btree", | ||
| 1020 | "concurrently": false, | ||
| 1021 | "name": "idx_relay_environment_credentials_hash", | ||
| 1022 | "entityType": "indexes", | ||
| 1023 | "schema": "public", | ||
| 1024 | "table": "relay_environment_credentials" | ||
| 1025 | }, | ||
| 1026 | { | ||
| 1027 | "nameExplicit": true, | ||
| 1028 | "columns": [ | ||
| 1029 | { | ||
| 1030 | "value": "environment_id", | ||
| 1031 | "isExpression": false, | ||
| 1032 | "asc": true, | ||
| 1033 | "nullsFirst": false, | ||
| 1034 | "opclass": null | ||
| 1035 | }, | ||
| 1036 | { | ||
| 1037 | "value": "revoked_at", | ||
| 1038 | "isExpression": false, | ||
| 1039 | "asc": true, | ||
| 1040 | "nullsFirst": false, | ||
| 1041 | "opclass": null | ||
| 1042 | } | ||
| 1043 | ], | ||
| 1044 | "isUnique": false, | ||
| 1045 | "where": null, | ||
| 1046 | "with": "", | ||
| 1047 | "method": "btree", | ||
| 1048 | "concurrently": false, | ||
| 1049 | "name": "idx_relay_environment_credentials_environment", | ||
| 1050 | "entityType": "indexes", | ||
| 1051 | "schema": "public", | ||
| 1052 | "table": "relay_environment_credentials" | ||
| 1053 | }, | ||
| 1054 | { | ||
| 1055 | "nameExplicit": true, | ||
| 1056 | "columns": [ | ||
| 1057 | { | ||
| 1058 | "value": "environment_id", | ||
| 1059 | "isExpression": false, | ||
| 1060 | "asc": true, | ||
| 1061 | "nullsFirst": false, | ||
| 1062 | "opclass": null | ||
| 1063 | }, | ||
| 1064 | { | ||
| 1065 | "value": "environment_public_key", | ||
| 1066 | "isExpression": false, | ||
| 1067 | "asc": true, | ||
| 1068 | "nullsFirst": false, | ||
| 1069 | "opclass": null | ||
| 1070 | }, | ||
| 1071 | { | ||
| 1072 | "value": "revoked_at", | ||
| 1073 | "isExpression": false, | ||
| 1074 | "asc": true, | ||
| 1075 | "nullsFirst": false, | ||
| 1076 | "opclass": null | ||
| 1077 | } | ||
| 1078 | ], | ||
| 1079 | "isUnique": false, | ||
| 1080 | "where": null, | ||
| 1081 | "with": "", | ||
| 1082 | "method": "btree", | ||
| 1083 | "concurrently": false, | ||
| 1084 | "name": "idx_relay_environment_credentials_environment_key", | ||
| 1085 | "entityType": "indexes", | ||
| 1086 | "schema": "public", | ||
| 1087 | "table": "relay_environment_credentials" | ||
| 1088 | }, | ||
| 1089 | { | ||
| 1090 | "nameExplicit": true, | ||
| 1091 | "columns": [ | ||
| 1092 | { | ||
| 1093 | "value": "environment_id", | ||
| 1094 | "isExpression": false, | ||
| 1095 | "asc": true, | ||
| 1096 | "nullsFirst": false, | ||
| 1097 | "opclass": null | ||
| 1098 | }, | ||
| 1099 | { | ||
| 1100 | "value": "revoked_at", | ||
| 1101 | "isExpression": false, | ||
| 1102 | "asc": true, | ||
| 1103 | "nullsFirst": false, | ||
| 1104 | "opclass": null | ||
| 1105 | } | ||
| 1106 | ], | ||
| 1107 | "isUnique": false, | ||
| 1108 | "where": null, | ||
| 1109 | "with": "", | ||
| 1110 | "method": "btree", | ||
| 1111 | "concurrently": false, | ||
| 1112 | "name": "idx_relay_environment_links_environment", | ||
| 1113 | "entityType": "indexes", | ||
| 1114 | "schema": "public", | ||
| 1115 | "table": "relay_environment_links" | ||
| 1116 | }, | ||
| 1117 | { | ||
| 1118 | "nameExplicit": true, | ||
| 1119 | "columns": [ | ||
| 1120 | { | ||
| 1121 | "value": "user_id", | ||
| 1122 | "isExpression": false, | ||
| 1123 | "asc": true, | ||
| 1124 | "nullsFirst": false, | ||
| 1125 | "opclass": null | ||
| 1126 | } | ||
| 1127 | ], | ||
| 1128 | "isUnique": false, | ||
| 1129 | "where": null, | ||
| 1130 | "with": "", | ||
| 1131 | "method": "btree", | ||
| 1132 | "concurrently": false, | ||
| 1133 | "name": "idx_relay_live_activities_user", | ||
| 1134 | "entityType": "indexes", | ||
| 1135 | "schema": "public", | ||
| 1136 | "table": "relay_live_activities" | ||
| 1137 | }, | ||
| 1138 | { | ||
| 1139 | "nameExplicit": true, | ||
| 1140 | "columns": [ | ||
| 1141 | { | ||
| 1142 | "value": "activity_push_token", | ||
| 1143 | "isExpression": false, | ||
| 1144 | "asc": true, | ||
| 1145 | "nullsFirst": false, | ||
| 1146 | "opclass": null | ||
| 1147 | } | ||
| 1148 | ], | ||
| 1149 | "isUnique": true, | ||
| 1150 | "where": null, | ||
| 1151 | "with": "", | ||
| 1152 | "method": "btree", | ||
| 1153 | "concurrently": false, | ||
| 1154 | "name": "idx_relay_live_activities_activity_push_token", | ||
| 1155 | "entityType": "indexes", | ||
| 1156 | "schema": "public", | ||
| 1157 | "table": "relay_live_activities" | ||
| 1158 | }, | ||
| 1159 | { | ||
| 1160 | "nameExplicit": true, | ||
| 1161 | "columns": [ | ||
| 1162 | { | ||
| 1163 | "value": "user_id", | ||
| 1164 | "isExpression": false, | ||
| 1165 | "asc": true, | ||
| 1166 | "nullsFirst": false, | ||
| 1167 | "opclass": null | ||
| 1168 | } | ||
| 1169 | ], | ||
| 1170 | "isUnique": false, | ||
| 1171 | "where": null, | ||
| 1172 | "with": "", | ||
| 1173 | "method": "btree", | ||
| 1174 | "concurrently": false, | ||
| 1175 | "name": "idx_relay_mobile_devices_user", | ||
| 1176 | "entityType": "indexes", | ||
| 1177 | "schema": "public", | ||
| 1178 | "table": "relay_mobile_devices" | ||
| 1179 | }, | ||
| 1180 | { | ||
| 1181 | "nameExplicit": true, | ||
| 1182 | "columns": [ | ||
| 1183 | { | ||
| 1184 | "value": "push_token", | ||
| 1185 | "isExpression": false, | ||
| 1186 | "asc": true, | ||
| 1187 | "nullsFirst": false, | ||
| 1188 | "opclass": null | ||
| 1189 | } | ||
| 1190 | ], | ||
| 1191 | "isUnique": true, | ||
| 1192 | "where": null, | ||
| 1193 | "with": "", | ||
| 1194 | "method": "btree", | ||
| 1195 | "concurrently": false, | ||
| 1196 | "name": "idx_relay_mobile_devices_push_token", | ||
| 1197 | "entityType": "indexes", | ||
| 1198 | "schema": "public", | ||
| 1199 | "table": "relay_mobile_devices" | ||
| 1200 | }, | ||
| 1201 | { | ||
| 1202 | "nameExplicit": true, | ||
| 1203 | "columns": [ | ||
| 1204 | { | ||
| 1205 | "value": "push_to_start_token", | ||
| 1206 | "isExpression": false, | ||
| 1207 | "asc": true, | ||
| 1208 | "nullsFirst": false, | ||
| 1209 | "opclass": null | ||
| 1210 | } | ||
| 1211 | ], | ||
| 1212 | "isUnique": true, | ||
| 1213 | "where": null, | ||
| 1214 | "with": "", | ||
| 1215 | "method": "btree", | ||
| 1216 | "concurrently": false, | ||
| 1217 | "name": "idx_relay_mobile_devices_push_to_start_token", | ||
| 1218 | "entityType": "indexes", | ||
| 1219 | "schema": "public", | ||
| 1220 | "table": "relay_mobile_devices" | ||
| 1221 | }, | ||
| 1222 | { | ||
| 1223 | "columns": ["environment_id", "environment_public_key", "thread_id"], | ||
| 1224 | "nameExplicit": false, | ||
| 1225 | "name": "relay_agent_activity_rows_pkey", | ||
| 1226 | "entityType": "pks", | ||
| 1227 | "schema": "public", | ||
| 1228 | "table": "relay_agent_activity_rows" | ||
| 1229 | }, | ||
| 1230 | { | ||
| 1231 | "columns": ["thumbprint", "jti"], | ||
| 1232 | "nameExplicit": false, | ||
| 1233 | "name": "relay_dpop_proofs_pkey", | ||
| 1234 | "entityType": "pks", | ||
| 1235 | "schema": "public", | ||
| 1236 | "table": "relay_dpop_proofs" | ||
| 1237 | }, | ||
| 1238 | { | ||
| 1239 | "columns": ["user_id", "environment_id"], | ||
| 1240 | "nameExplicit": false, | ||
| 1241 | "name": "relay_environment_links_pkey", | ||
| 1242 | "entityType": "pks", | ||
| 1243 | "schema": "public", | ||
| 1244 | "table": "relay_environment_links" | ||
| 1245 | }, | ||
| 1246 | { | ||
| 1247 | "columns": ["user_id", "device_id"], | ||
| 1248 | "nameExplicit": false, | ||
| 1249 | "name": "relay_live_activities_pkey", | ||
| 1250 | "entityType": "pks", | ||
| 1251 | "schema": "public", | ||
| 1252 | "table": "relay_live_activities" | ||
| 1253 | }, | ||
| 1254 | { | ||
| 1255 | "columns": ["user_id", "device_id"], | ||
| 1256 | "nameExplicit": false, | ||
| 1257 | "name": "relay_mobile_devices_pkey", | ||
| 1258 | "entityType": "pks", | ||
| 1259 | "schema": "public", | ||
| 1260 | "table": "relay_mobile_devices" | ||
| 1261 | }, | ||
| 1262 | { | ||
| 1263 | "columns": ["id"], | ||
| 1264 | "nameExplicit": false, | ||
| 1265 | "name": "relay_delivery_attempts_pkey", | ||
| 1266 | "schema": "public", | ||
| 1267 | "table": "relay_delivery_attempts", | ||
| 1268 | "entityType": "pks" | ||
| 1269 | }, | ||
| 1270 | { | ||
| 1271 | "columns": ["credential_id"], | ||
| 1272 | "nameExplicit": false, | ||
| 1273 | "name": "relay_environment_credentials_pkey", | ||
| 1274 | "schema": "public", | ||
| 1275 | "table": "relay_environment_credentials", | ||
| 1276 | "entityType": "pks" | ||
| 1277 | } | ||
| 1278 | ], | ||
| 1279 | "renames": [] | ||
| 1280 | } | ||
infra/relay/migrations/postgres/20260603035812_managed_endpoint_allocations/migration.sql created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | CREATE TABLE "relay_managed_endpoint_allocations" ( | ||
| 2 | 	"user_id" varchar(191), | ||
| 3 | 	"environment_id" varchar(191), | ||
| 4 | 	"hostname" text NOT NULL, | ||
| 5 | 	"tunnel_id" varchar(191), | ||
| 6 | 	"tunnel_name" text NOT NULL, | ||
| 7 | 	"dns_record_id" varchar(191), | ||
| 8 | 	"ready_at" varchar(64), | ||
| 9 | 	"created_at" varchar(64) NOT NULL, | ||
| 10 | 	"updated_at" varchar(64) NOT NULL, | ||
| 11 | 	CONSTRAINT "relay_managed_endpoint_allocations_pkey" PRIMARY KEY("user_id","environment_id") | ||
| 12 | ); | ||
| 13 | --> statement-breakpoint | ||
| 14 | CREATE UNIQUE INDEX "idx_relay_managed_endpoint_allocations_hostname" ON "relay_managed_endpoint_allocations" ("hostname");--> statement-breakpoint | ||
| 15 | CREATE UNIQUE INDEX "idx_relay_managed_endpoint_allocations_tunnel_name" ON "relay_managed_endpoint_allocations" ("tunnel_name"); | ||
| \ No newline at end of file | |||
infra/relay/migrations/postgres/20260603035812_managed_endpoint_allocations/snapshot.json created+1453| ... | @@ -0,0 +1,1453 @@ | ||
| 1 | { | ||
| 2 | "version": "8", | ||
| 3 | "dialect": "postgres", | ||
| 4 | "id": "385d476b-d4f6-48a3-99e6-0a95af4ee4e4", | ||
| 5 | "prevIds": ["e189638b-4700-4656-b86e-d8baa63f5fe4"], | ||
| 6 | "ddl": [ | ||
| 7 | { | ||
| 8 | "isRlsEnabled": false, | ||
| 9 | "name": "relay_agent_activity_rows", | ||
| 10 | "entityType": "tables", | ||
| 11 | "schema": "public" | ||
| 12 | }, | ||
| 13 | { | ||
| 14 | "isRlsEnabled": false, | ||
| 15 | "name": "relay_delivery_attempts", | ||
| 16 | "entityType": "tables", | ||
| 17 | "schema": "public" | ||
| 18 | }, | ||
| 19 | { | ||
| 20 | "isRlsEnabled": false, | ||
| 21 | "name": "relay_dpop_proofs", | ||
| 22 | "entityType": "tables", | ||
| 23 | "schema": "public" | ||
| 24 | }, | ||
| 25 | { | ||
| 26 | "isRlsEnabled": false, | ||
| 27 | "name": "relay_environment_credentials", | ||
| 28 | "entityType": "tables", | ||
| 29 | "schema": "public" | ||
| 30 | }, | ||
| 31 | { | ||
| 32 | "isRlsEnabled": false, | ||
| 33 | "name": "relay_environment_links", | ||
| 34 | "entityType": "tables", | ||
| 35 | "schema": "public" | ||
| 36 | }, | ||
| 37 | { | ||
| 38 | "isRlsEnabled": false, | ||
| 39 | "name": "relay_live_activities", | ||
| 40 | "entityType": "tables", | ||
| 41 | "schema": "public" | ||
| 42 | }, | ||
| 43 | { | ||
| 44 | "isRlsEnabled": false, | ||
| 45 | "name": "relay_managed_endpoint_allocations", | ||
| 46 | "entityType": "tables", | ||
| 47 | "schema": "public" | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | "isRlsEnabled": false, | ||
| 51 | "name": "relay_mobile_devices", | ||
| 52 | "entityType": "tables", | ||
| 53 | "schema": "public" | ||
| 54 | }, | ||
| 55 | { | ||
| 56 | "type": "varchar(191)", | ||
| 57 | "typeSchema": null, | ||
| 58 | "notNull": true, | ||
| 59 | "dimensions": 0, | ||
| 60 | "default": null, | ||
| 61 | "generated": null, | ||
| 62 | "identity": null, | ||
| 63 | "name": "environment_id", | ||
| 64 | "entityType": "columns", | ||
| 65 | "schema": "public", | ||
| 66 | "table": "relay_agent_activity_rows" | ||
| 67 | }, | ||
| 68 | { | ||
| 69 | "type": "text", | ||
| 70 | "typeSchema": null, | ||
| 71 | "notNull": true, | ||
| 72 | "dimensions": 0, | ||
| 73 | "default": null, | ||
| 74 | "generated": null, | ||
| 75 | "identity": null, | ||
| 76 | "name": "environment_public_key", | ||
| 77 | "entityType": "columns", | ||
| 78 | "schema": "public", | ||
| 79 | "table": "relay_agent_activity_rows" | ||
| 80 | }, | ||
| 81 | { | ||
| 82 | "type": "varchar(191)", | ||
| 83 | "typeSchema": null, | ||
| 84 | "notNull": true, | ||
| 85 | "dimensions": 0, | ||
| 86 | "default": null, | ||
| 87 | "generated": null, | ||
| 88 | "identity": null, | ||
| 89 | "name": "thread_id", | ||
| 90 | "entityType": "columns", | ||
| 91 | "schema": "public", | ||
| 92 | "table": "relay_agent_activity_rows" | ||
| 93 | }, | ||
| 94 | { | ||
| 95 | "type": "jsonb", | ||
| 96 | "typeSchema": null, | ||
| 97 | "notNull": true, | ||
| 98 | "dimensions": 0, | ||
| 99 | "default": null, | ||
| 100 | "generated": null, | ||
| 101 | "identity": null, | ||
| 102 | "name": "state_json", | ||
| 103 | "entityType": "columns", | ||
| 104 | "schema": "public", | ||
| 105 | "table": "relay_agent_activity_rows" | ||
| 106 | }, | ||
| 107 | { | ||
| 108 | "type": "varchar(64)", | ||
| 109 | "typeSchema": null, | ||
| 110 | "notNull": true, | ||
| 111 | "dimensions": 0, | ||
| 112 | "default": null, | ||
| 113 | "generated": null, | ||
| 114 | "identity": null, | ||
| 115 | "name": "updated_at", | ||
| 116 | "entityType": "columns", | ||
| 117 | "schema": "public", | ||
| 118 | "table": "relay_agent_activity_rows" | ||
| 119 | }, | ||
| 120 | { | ||
| 121 | "type": "varchar(64)", | ||
| 122 | "typeSchema": null, | ||
| 123 | "notNull": true, | ||
| 124 | "dimensions": 0, | ||
| 125 | "default": null, | ||
| 126 | "generated": null, | ||
| 127 | "identity": null, | ||
| 128 | "name": "created_at", | ||
| 129 | "entityType": "columns", | ||
| 130 | "schema": "public", | ||
| 131 | "table": "relay_agent_activity_rows" | ||
| 132 | }, | ||
| 133 | { | ||
| 134 | "type": "varchar(36)", | ||
| 135 | "typeSchema": null, | ||
| 136 | "notNull": true, | ||
| 137 | "dimensions": 0, | ||
| 138 | "default": null, | ||
| 139 | "generated": null, | ||
| 140 | "identity": null, | ||
| 141 | "name": "id", | ||
| 142 | "entityType": "columns", | ||
| 143 | "schema": "public", | ||
| 144 | "table": "relay_delivery_attempts" | ||
| 145 | }, | ||
| 146 | { | ||
| 147 | "type": "varchar(64)", | ||
| 148 | "typeSchema": null, | ||
| 149 | "notNull": true, | ||
| 150 | "dimensions": 0, | ||
| 151 | "default": null, | ||
| 152 | "generated": null, | ||
| 153 | "identity": null, | ||
| 154 | "name": "created_at", | ||
| 155 | "entityType": "columns", | ||
| 156 | "schema": "public", | ||
| 157 | "table": "relay_delivery_attempts" | ||
| 158 | }, | ||
| 159 | { | ||
| 160 | "type": "varchar(255)", | ||
| 161 | "typeSchema": null, | ||
| 162 | "notNull": false, | ||
| 163 | "dimensions": 0, | ||
| 164 | "default": null, | ||
| 165 | "generated": null, | ||
| 166 | "identity": null, | ||
| 167 | "name": "user_id", | ||
| 168 | "entityType": "columns", | ||
| 169 | "schema": "public", | ||
| 170 | "table": "relay_delivery_attempts" | ||
| 171 | }, | ||
| 172 | { | ||
| 173 | "type": "varchar(191)", | ||
| 174 | "typeSchema": null, | ||
| 175 | "notNull": false, | ||
| 176 | "dimensions": 0, | ||
| 177 | "default": null, | ||
| 178 | "generated": null, | ||
| 179 | "identity": null, | ||
| 180 | "name": "environment_id", | ||
| 181 | "entityType": "columns", | ||
| 182 | "schema": "public", | ||
| 183 | "table": "relay_delivery_attempts" | ||
| 184 | }, | ||
| 185 | { | ||
| 186 | "type": "varchar(191)", | ||
| 187 | "typeSchema": null, | ||
| 188 | "notNull": false, | ||
| 189 | "dimensions": 0, | ||
| 190 | "default": null, | ||
| 191 | "generated": null, | ||
| 192 | "identity": null, | ||
| 193 | "name": "thread_id", | ||
| 194 | "entityType": "columns", | ||
| 195 | "schema": "public", | ||
| 196 | "table": "relay_delivery_attempts" | ||
| 197 | }, | ||
| 198 | { | ||
| 199 | "type": "varchar(255)", | ||
| 200 | "typeSchema": null, | ||
| 201 | "notNull": false, | ||
| 202 | "dimensions": 0, | ||
| 203 | "default": null, | ||
| 204 | "generated": null, | ||
| 205 | "identity": null, | ||
| 206 | "name": "device_id", | ||
| 207 | "entityType": "columns", | ||
| 208 | "schema": "public", | ||
| 209 | "table": "relay_delivery_attempts" | ||
| 210 | }, | ||
| 211 | { | ||
| 212 | "type": "varchar(64)", | ||
| 213 | "typeSchema": null, | ||
| 214 | "notNull": true, | ||
| 215 | "dimensions": 0, | ||
| 216 | "default": null, | ||
| 217 | "generated": null, | ||
| 218 | "identity": null, | ||
| 219 | "name": "kind", | ||
| 220 | "entityType": "columns", | ||
| 221 | "schema": "public", | ||
| 222 | "table": "relay_delivery_attempts" | ||
| 223 | }, | ||
| 224 | { | ||
| 225 | "type": "varchar(64)", | ||
| 226 | "typeSchema": null, | ||
| 227 | "notNull": false, | ||
| 228 | "dimensions": 0, | ||
| 229 | "default": null, | ||
| 230 | "generated": null, | ||
| 231 | "identity": null, | ||
| 232 | "name": "source_job_id", | ||
| 233 | "entityType": "columns", | ||
| 234 | "schema": "public", | ||
| 235 | "table": "relay_delivery_attempts" | ||
| 236 | }, | ||
| 237 | { | ||
| 238 | "type": "varchar(16)", | ||
| 239 | "typeSchema": null, | ||
| 240 | "notNull": false, | ||
| 241 | "dimensions": 0, | ||
| 242 | "default": null, | ||
| 243 | "generated": null, | ||
| 244 | "identity": null, | ||
| 245 | "name": "token_suffix", | ||
| 246 | "entityType": "columns", | ||
| 247 | "schema": "public", | ||
| 248 | "table": "relay_delivery_attempts" | ||
| 249 | }, | ||
| 250 | { | ||
| 251 | "type": "integer", | ||
| 252 | "typeSchema": null, | ||
| 253 | "notNull": false, | ||
| 254 | "dimensions": 0, | ||
| 255 | "default": null, | ||
| 256 | "generated": null, | ||
| 257 | "identity": null, | ||
| 258 | "name": "apns_status", | ||
| 259 | "entityType": "columns", | ||
| 260 | "schema": "public", | ||
| 261 | "table": "relay_delivery_attempts" | ||
| 262 | }, | ||
| 263 | { | ||
| 264 | "type": "text", | ||
| 265 | "typeSchema": null, | ||
| 266 | "notNull": false, | ||
| 267 | "dimensions": 0, | ||
| 268 | "default": null, | ||
| 269 | "generated": null, | ||
| 270 | "identity": null, | ||
| 271 | "name": "apns_reason", | ||
| 272 | "entityType": "columns", | ||
| 273 | "schema": "public", | ||
| 274 | "table": "relay_delivery_attempts" | ||
| 275 | }, | ||
| 276 | { | ||
| 277 | "type": "varchar(128)", | ||
| 278 | "typeSchema": null, | ||
| 279 | "notNull": false, | ||
| 280 | "dimensions": 0, | ||
| 281 | "default": null, | ||
| 282 | "generated": null, | ||
| 283 | "identity": null, | ||
| 284 | "name": "apns_id", | ||
| 285 | "entityType": "columns", | ||
| 286 | "schema": "public", | ||
| 287 | "table": "relay_delivery_attempts" | ||
| 288 | }, | ||
| 289 | { | ||
| 290 | "type": "text", | ||
| 291 | "typeSchema": null, | ||
| 292 | "notNull": false, | ||
| 293 | "dimensions": 0, | ||
| 294 | "default": null, | ||
| 295 | "generated": null, | ||
| 296 | "identity": null, | ||
| 297 | "name": "transport_error", | ||
| 298 | "entityType": "columns", | ||
| 299 | "schema": "public", | ||
| 300 | "table": "relay_delivery_attempts" | ||
| 301 | }, | ||
| 302 | { | ||
| 303 | "type": "varchar(128)", | ||
| 304 | "typeSchema": null, | ||
| 305 | "notNull": true, | ||
| 306 | "dimensions": 0, | ||
| 307 | "default": null, | ||
| 308 | "generated": null, | ||
| 309 | "identity": null, | ||
| 310 | "name": "thumbprint", | ||
| 311 | "entityType": "columns", | ||
| 312 | "schema": "public", | ||
| 313 | "table": "relay_dpop_proofs" | ||
| 314 | }, | ||
| 315 | { | ||
| 316 | "type": "varchar(255)", | ||
| 317 | "typeSchema": null, | ||
| 318 | "notNull": true, | ||
| 319 | "dimensions": 0, | ||
| 320 | "default": null, | ||
| 321 | "generated": null, | ||
| 322 | "identity": null, | ||
| 323 | "name": "jti", | ||
| 324 | "entityType": "columns", | ||
| 325 | "schema": "public", | ||
| 326 | "table": "relay_dpop_proofs" | ||
| 327 | }, | ||
| 328 | { | ||
| 329 | "type": "integer", | ||
| 330 | "typeSchema": null, | ||
| 331 | "notNull": true, | ||
| 332 | "dimensions": 0, | ||
| 333 | "default": null, | ||
| 334 | "generated": null, | ||
| 335 | "identity": null, | ||
| 336 | "name": "iat", | ||
| 337 | "entityType": "columns", | ||
| 338 | "schema": "public", | ||
| 339 | "table": "relay_dpop_proofs" | ||
| 340 | }, | ||
| 341 | { | ||
| 342 | "type": "varchar(64)", | ||
| 343 | "typeSchema": null, | ||
| 344 | "notNull": true, | ||
| 345 | "dimensions": 0, | ||
| 346 | "default": null, | ||
| 347 | "generated": null, | ||
| 348 | "identity": null, | ||
| 349 | "name": "expires_at", | ||
| 350 | "entityType": "columns", | ||
| 351 | "schema": "public", | ||
| 352 | "table": "relay_dpop_proofs" | ||
| 353 | }, | ||
| 354 | { | ||
| 355 | "type": "varchar(64)", | ||
| 356 | "typeSchema": null, | ||
| 357 | "notNull": true, | ||
| 358 | "dimensions": 0, | ||
| 359 | "default": null, | ||
| 360 | "generated": null, | ||
| 361 | "identity": null, | ||
| 362 | "name": "created_at", | ||
| 363 | "entityType": "columns", | ||
| 364 | "schema": "public", | ||
| 365 | "table": "relay_dpop_proofs" | ||
| 366 | }, | ||
| 367 | { | ||
| 368 | "type": "varchar(64)", | ||
| 369 | "typeSchema": null, | ||
| 370 | "notNull": true, | ||
| 371 | "dimensions": 0, | ||
| 372 | "default": null, | ||
| 373 | "generated": null, | ||
| 374 | "identity": null, | ||
| 375 | "name": "credential_id", | ||
| 376 | "entityType": "columns", | ||
| 377 | "schema": "public", | ||
| 378 | "table": "relay_environment_credentials" | ||
| 379 | }, | ||
| 380 | { | ||
| 381 | "type": "varchar(191)", | ||
| 382 | "typeSchema": null, | ||
| 383 | "notNull": true, | ||
| 384 | "dimensions": 0, | ||
| 385 | "default": null, | ||
| 386 | "generated": null, | ||
| 387 | "identity": null, | ||
| 388 | "name": "environment_id", | ||
| 389 | "entityType": "columns", | ||
| 390 | "schema": "public", | ||
| 391 | "table": "relay_environment_credentials" | ||
| 392 | }, | ||
| 393 | { | ||
| 394 | "type": "text", | ||
| 395 | "typeSchema": null, | ||
| 396 | "notNull": true, | ||
| 397 | "dimensions": 0, | ||
| 398 | "default": null, | ||
| 399 | "generated": null, | ||
| 400 | "identity": null, | ||
| 401 | "name": "environment_public_key", | ||
| 402 | "entityType": "columns", | ||
| 403 | "schema": "public", | ||
| 404 | "table": "relay_environment_credentials" | ||
| 405 | }, | ||
| 406 | { | ||
| 407 | "type": "varchar(191)", | ||
| 408 | "typeSchema": null, | ||
| 409 | "notNull": true, | ||
| 410 | "dimensions": 0, | ||
| 411 | "default": null, | ||
| 412 | "generated": null, | ||
| 413 | "identity": null, | ||
| 414 | "name": "credential_hash", | ||
| 415 | "entityType": "columns", | ||
| 416 | "schema": "public", | ||
| 417 | "table": "relay_environment_credentials" | ||
| 418 | }, | ||
| 419 | { | ||
| 420 | "type": "varchar(64)", | ||
| 421 | "typeSchema": null, | ||
| 422 | "notNull": false, | ||
| 423 | "dimensions": 0, | ||
| 424 | "default": null, | ||
| 425 | "generated": null, | ||
| 426 | "identity": null, | ||
| 427 | "name": "revoked_at", | ||
| 428 | "entityType": "columns", | ||
| 429 | "schema": "public", | ||
| 430 | "table": "relay_environment_credentials" | ||
| 431 | }, | ||
| 432 | { | ||
| 433 | "type": "varchar(64)", | ||
| 434 | "typeSchema": null, | ||
| 435 | "notNull": true, | ||
| 436 | "dimensions": 0, | ||
| 437 | "default": null, | ||
| 438 | "generated": null, | ||
| 439 | "identity": null, | ||
| 440 | "name": "created_at", | ||
| 441 | "entityType": "columns", | ||
| 442 | "schema": "public", | ||
| 443 | "table": "relay_environment_credentials" | ||
| 444 | }, | ||
| 445 | { | ||
| 446 | "type": "varchar(64)", | ||
| 447 | "typeSchema": null, | ||
| 448 | "notNull": true, | ||
| 449 | "dimensions": 0, | ||
| 450 | "default": null, | ||
| 451 | "generated": null, | ||
| 452 | "identity": null, | ||
| 453 | "name": "updated_at", | ||
| 454 | "entityType": "columns", | ||
| 455 | "schema": "public", | ||
| 456 | "table": "relay_environment_credentials" | ||
| 457 | }, | ||
| 458 | { | ||
| 459 | "type": "varchar(191)", | ||
| 460 | "typeSchema": null, | ||
| 461 | "notNull": true, | ||
| 462 | "dimensions": 0, | ||
| 463 | "default": null, | ||
| 464 | "generated": null, | ||
| 465 | "identity": null, | ||
| 466 | "name": "user_id", | ||
| 467 | "entityType": "columns", | ||
| 468 | "schema": "public", | ||
| 469 | "table": "relay_environment_links" | ||
| 470 | }, | ||
| 471 | { | ||
| 472 | "type": "varchar(191)", | ||
| 473 | "typeSchema": null, | ||
| 474 | "notNull": true, | ||
| 475 | "dimensions": 0, | ||
| 476 | "default": null, | ||
| 477 | "generated": null, | ||
| 478 | "identity": null, | ||
| 479 | "name": "environment_id", | ||
| 480 | "entityType": "columns", | ||
| 481 | "schema": "public", | ||
| 482 | "table": "relay_environment_links" | ||
| 483 | }, | ||
| 484 | { | ||
| 485 | "type": "text", | ||
| 486 | "typeSchema": null, | ||
| 487 | "notNull": true, | ||
| 488 | "dimensions": 0, | ||
| 489 | "default": "'T3 Environment'", | ||
| 490 | "generated": null, | ||
| 491 | "identity": null, | ||
| 492 | "name": "environment_label", | ||
| 493 | "entityType": "columns", | ||
| 494 | "schema": "public", | ||
| 495 | "table": "relay_environment_links" | ||
| 496 | }, | ||
| 497 | { | ||
| 498 | "type": "text", | ||
| 499 | "typeSchema": null, | ||
| 500 | "notNull": true, | ||
| 501 | "dimensions": 0, | ||
| 502 | "default": null, | ||
| 503 | "generated": null, | ||
| 504 | "identity": null, | ||
| 505 | "name": "environment_public_key", | ||
| 506 | "entityType": "columns", | ||
| 507 | "schema": "public", | ||
| 508 | "table": "relay_environment_links" | ||
| 509 | }, | ||
| 510 | { | ||
| 511 | "type": "text", | ||
| 512 | "typeSchema": null, | ||
| 513 | "notNull": true, | ||
| 514 | "dimensions": 0, | ||
| 515 | "default": null, | ||
| 516 | "generated": null, | ||
| 517 | "identity": null, | ||
| 518 | "name": "endpoint_http_base_url", | ||
| 519 | "entityType": "columns", | ||
| 520 | "schema": "public", | ||
| 521 | "table": "relay_environment_links" | ||
| 522 | }, | ||
| 523 | { | ||
| 524 | "type": "text", | ||
| 525 | "typeSchema": null, | ||
| 526 | "notNull": true, | ||
| 527 | "dimensions": 0, | ||
| 528 | "default": null, | ||
| 529 | "generated": null, | ||
| 530 | "identity": null, | ||
| 531 | "name": "endpoint_ws_base_url", | ||
| 532 | "entityType": "columns", | ||
| 533 | "schema": "public", | ||
| 534 | "table": "relay_environment_links" | ||
| 535 | }, | ||
| 536 | { | ||
| 537 | "type": "varchar(32)", | ||
| 538 | "typeSchema": null, | ||
| 539 | "notNull": true, | ||
| 540 | "dimensions": 0, | ||
| 541 | "default": null, | ||
| 542 | "generated": null, | ||
| 543 | "identity": null, | ||
| 544 | "name": "endpoint_provider_kind", | ||
| 545 | "entityType": "columns", | ||
| 546 | "schema": "public", | ||
| 547 | "table": "relay_environment_links" | ||
| 548 | }, | ||
| 549 | { | ||
| 550 | "type": "boolean", | ||
| 551 | "typeSchema": null, | ||
| 552 | "notNull": true, | ||
| 553 | "dimensions": 0, | ||
| 554 | "default": "true", | ||
| 555 | "generated": null, | ||
| 556 | "identity": null, | ||
| 557 | "name": "notifications_enabled", | ||
| 558 | "entityType": "columns", | ||
| 559 | "schema": "public", | ||
| 560 | "table": "relay_environment_links" | ||
| 561 | }, | ||
| 562 | { | ||
| 563 | "type": "boolean", | ||
| 564 | "typeSchema": null, | ||
| 565 | "notNull": true, | ||
| 566 | "dimensions": 0, | ||
| 567 | "default": "true", | ||
| 568 | "generated": null, | ||
| 569 | "identity": null, | ||
| 570 | "name": "live_activities_enabled", | ||
| 571 | "entityType": "columns", | ||
| 572 | "schema": "public", | ||
| 573 | "table": "relay_environment_links" | ||
| 574 | }, | ||
| 575 | { | ||
| 576 | "type": "boolean", | ||
| 577 | "typeSchema": null, | ||
| 578 | "notNull": true, | ||
| 579 | "dimensions": 0, | ||
| 580 | "default": "false", | ||
| 581 | "generated": null, | ||
| 582 | "identity": null, | ||
| 583 | "name": "managed_tunnels_enabled", | ||
| 584 | "entityType": "columns", | ||
| 585 | "schema": "public", | ||
| 586 | "table": "relay_environment_links" | ||
| 587 | }, | ||
| 588 | { | ||
| 589 | "type": "varchar(191)", | ||
| 590 | "typeSchema": null, | ||
| 591 | "notNull": false, | ||
| 592 | "dimensions": 0, | ||
| 593 | "default": null, | ||
| 594 | "generated": null, | ||
| 595 | "identity": null, | ||
| 596 | "name": "created_by_device_id", | ||
| 597 | "entityType": "columns", | ||
| 598 | "schema": "public", | ||
| 599 | "table": "relay_environment_links" | ||
| 600 | }, | ||
| 601 | { | ||
| 602 | "type": "varchar(64)", | ||
| 603 | "typeSchema": null, | ||
| 604 | "notNull": false, | ||
| 605 | "dimensions": 0, | ||
| 606 | "default": null, | ||
| 607 | "generated": null, | ||
| 608 | "identity": null, | ||
| 609 | "name": "revoked_at", | ||
| 610 | "entityType": "columns", | ||
| 611 | "schema": "public", | ||
| 612 | "table": "relay_environment_links" | ||
| 613 | }, | ||
| 614 | { | ||
| 615 | "type": "varchar(64)", | ||
| 616 | "typeSchema": null, | ||
| 617 | "notNull": true, | ||
| 618 | "dimensions": 0, | ||
| 619 | "default": null, | ||
| 620 | "generated": null, | ||
| 621 | "identity": null, | ||
| 622 | "name": "created_at", | ||
| 623 | "entityType": "columns", | ||
| 624 | "schema": "public", | ||
| 625 | "table": "relay_environment_links" | ||
| 626 | }, | ||
| 627 | { | ||
| 628 | "type": "varchar(64)", | ||
| 629 | "typeSchema": null, | ||
| 630 | "notNull": true, | ||
| 631 | "dimensions": 0, | ||
| 632 | "default": null, | ||
| 633 | "generated": null, | ||
| 634 | "identity": null, | ||
| 635 | "name": "updated_at", | ||
| 636 | "entityType": "columns", | ||
| 637 | "schema": "public", | ||
| 638 | "table": "relay_environment_links" | ||
| 639 | }, | ||
| 640 | { | ||
| 641 | "type": "varchar(255)", | ||
| 642 | "typeSchema": null, | ||
| 643 | "notNull": true, | ||
| 644 | "dimensions": 0, | ||
| 645 | "default": null, | ||
| 646 | "generated": null, | ||
| 647 | "identity": null, | ||
| 648 | "name": "user_id", | ||
| 649 | "entityType": "columns", | ||
| 650 | "schema": "public", | ||
| 651 | "table": "relay_live_activities" | ||
| 652 | }, | ||
| 653 | { | ||
| 654 | "type": "varchar(255)", | ||
| 655 | "typeSchema": null, | ||
| 656 | "notNull": true, | ||
| 657 | "dimensions": 0, | ||
| 658 | "default": null, | ||
| 659 | "generated": null, | ||
| 660 | "identity": null, | ||
| 661 | "name": "device_id", | ||
| 662 | "entityType": "columns", | ||
| 663 | "schema": "public", | ||
| 664 | "table": "relay_live_activities" | ||
| 665 | }, | ||
| 666 | { | ||
| 667 | "type": "text", | ||
| 668 | "typeSchema": null, | ||
| 669 | "notNull": false, | ||
| 670 | "dimensions": 0, | ||
| 671 | "default": null, | ||
| 672 | "generated": null, | ||
| 673 | "identity": null, | ||
| 674 | "name": "activity_push_token", | ||
| 675 | "entityType": "columns", | ||
| 676 | "schema": "public", | ||
| 677 | "table": "relay_live_activities" | ||
| 678 | }, | ||
| 679 | { | ||
| 680 | "type": "varchar(64)", | ||
| 681 | "typeSchema": null, | ||
| 682 | "notNull": false, | ||
| 683 | "dimensions": 0, | ||
| 684 | "default": null, | ||
| 685 | "generated": null, | ||
| 686 | "identity": null, | ||
| 687 | "name": "remote_start_queued_at", | ||
| 688 | "entityType": "columns", | ||
| 689 | "schema": "public", | ||
| 690 | "table": "relay_live_activities" | ||
| 691 | }, | ||
| 692 | { | ||
| 693 | "type": "varchar(64)", | ||
| 694 | "typeSchema": null, | ||
| 695 | "notNull": false, | ||
| 696 | "dimensions": 0, | ||
| 697 | "default": null, | ||
| 698 | "generated": null, | ||
| 699 | "identity": null, | ||
| 700 | "name": "remote_started_at", | ||
| 701 | "entityType": "columns", | ||
| 702 | "schema": "public", | ||
| 703 | "table": "relay_live_activities" | ||
| 704 | }, | ||
| 705 | { | ||
| 706 | "type": "varchar(64)", | ||
| 707 | "typeSchema": null, | ||
| 708 | "notNull": false, | ||
| 709 | "dimensions": 0, | ||
| 710 | "default": null, | ||
| 711 | "generated": null, | ||
| 712 | "identity": null, | ||
| 713 | "name": "ended_at", | ||
| 714 | "entityType": "columns", | ||
| 715 | "schema": "public", | ||
| 716 | "table": "relay_live_activities" | ||
| 717 | }, | ||
| 718 | { | ||
| 719 | "type": "jsonb", | ||
| 720 | "typeSchema": null, | ||
| 721 | "notNull": false, | ||
| 722 | "dimensions": 0, | ||
| 723 | "default": null, | ||
| 724 | "generated": null, | ||
| 725 | "identity": null, | ||
| 726 | "name": "last_aggregate_json", | ||
| 727 | "entityType": "columns", | ||
| 728 | "schema": "public", | ||
| 729 | "table": "relay_live_activities" | ||
| 730 | }, | ||
| 731 | { | ||
| 732 | "type": "varchar(64)", | ||
| 733 | "typeSchema": null, | ||
| 734 | "notNull": false, | ||
| 735 | "dimensions": 0, | ||
| 736 | "default": null, | ||
| 737 | "generated": null, | ||
| 738 | "identity": null, | ||
| 739 | "name": "last_live_activity_delivery_at", | ||
| 740 | "entityType": "columns", | ||
| 741 | "schema": "public", | ||
| 742 | "table": "relay_live_activities" | ||
| 743 | }, | ||
| 744 | { | ||
| 745 | "type": "varchar(64)", | ||
| 746 | "typeSchema": null, | ||
| 747 | "notNull": true, | ||
| 748 | "dimensions": 0, | ||
| 749 | "default": null, | ||
| 750 | "generated": null, | ||
| 751 | "identity": null, | ||
| 752 | "name": "created_at", | ||
| 753 | "entityType": "columns", | ||
| 754 | "schema": "public", | ||
| 755 | "table": "relay_live_activities" | ||
| 756 | }, | ||
| 757 | { | ||
| 758 | "type": "varchar(64)", | ||
| 759 | "typeSchema": null, | ||
| 760 | "notNull": true, | ||
| 761 | "dimensions": 0, | ||
| 762 | "default": null, | ||
| 763 | "generated": null, | ||
| 764 | "identity": null, | ||
| 765 | "name": "updated_at", | ||
| 766 | "entityType": "columns", | ||
| 767 | "schema": "public", | ||
| 768 | "table": "relay_live_activities" | ||
| 769 | }, | ||
| 770 | { | ||
| 771 | "type": "varchar(191)", | ||
| 772 | "typeSchema": null, | ||
| 773 | "notNull": true, | ||
| 774 | "dimensions": 0, | ||
| 775 | "default": null, | ||
| 776 | "generated": null, | ||
| 777 | "identity": null, | ||
| 778 | "name": "user_id", | ||
| 779 | "entityType": "columns", | ||
| 780 | "schema": "public", | ||
| 781 | "table": "relay_managed_endpoint_allocations" | ||
| 782 | }, | ||
| 783 | { | ||
| 784 | "type": "varchar(191)", | ||
| 785 | "typeSchema": null, | ||
| 786 | "notNull": true, | ||
| 787 | "dimensions": 0, | ||
| 788 | "default": null, | ||
| 789 | "generated": null, | ||
| 790 | "identity": null, | ||
| 791 | "name": "environment_id", | ||
| 792 | "entityType": "columns", | ||
| 793 | "schema": "public", | ||
| 794 | "table": "relay_managed_endpoint_allocations" | ||
| 795 | }, | ||
| 796 | { | ||
| 797 | "type": "text", | ||
| 798 | "typeSchema": null, | ||
| 799 | "notNull": true, | ||
| 800 | "dimensions": 0, | ||
| 801 | "default": null, | ||
| 802 | "generated": null, | ||
| 803 | "identity": null, | ||
| 804 | "name": "hostname", | ||
| 805 | "entityType": "columns", | ||
| 806 | "schema": "public", | ||
| 807 | "table": "relay_managed_endpoint_allocations" | ||
| 808 | }, | ||
| 809 | { | ||
| 810 | "type": "varchar(191)", | ||
| 811 | "typeSchema": null, | ||
| 812 | "notNull": false, | ||
| 813 | "dimensions": 0, | ||
| 814 | "default": null, | ||
| 815 | "generated": null, | ||
| 816 | "identity": null, | ||
| 817 | "name": "tunnel_id", | ||
| 818 | "entityType": "columns", | ||
| 819 | "schema": "public", | ||
| 820 | "table": "relay_managed_endpoint_allocations" | ||
| 821 | }, | ||
| 822 | { | ||
| 823 | "type": "text", | ||
| 824 | "typeSchema": null, | ||
| 825 | "notNull": true, | ||
| 826 | "dimensions": 0, | ||
| 827 | "default": null, | ||
| 828 | "generated": null, | ||
| 829 | "identity": null, | ||
| 830 | "name": "tunnel_name", | ||
| 831 | "entityType": "columns", | ||
| 832 | "schema": "public", | ||
| 833 | "table": "relay_managed_endpoint_allocations" | ||
| 834 | }, | ||
| 835 | { | ||
| 836 | "type": "varchar(191)", | ||
| 837 | "typeSchema": null, | ||
| 838 | "notNull": false, | ||
| 839 | "dimensions": 0, | ||
| 840 | "default": null, | ||
| 841 | "generated": null, | ||
| 842 | "identity": null, | ||
| 843 | "name": "dns_record_id", | ||
| 844 | "entityType": "columns", | ||
| 845 | "schema": "public", | ||
| 846 | "table": "relay_managed_endpoint_allocations" | ||
| 847 | }, | ||
| 848 | { | ||
| 849 | "type": "varchar(64)", | ||
| 850 | "typeSchema": null, | ||
| 851 | "notNull": false, | ||
| 852 | "dimensions": 0, | ||
| 853 | "default": null, | ||
| 854 | "generated": null, | ||
| 855 | "identity": null, | ||
| 856 | "name": "ready_at", | ||
| 857 | "entityType": "columns", | ||
| 858 | "schema": "public", | ||
| 859 | "table": "relay_managed_endpoint_allocations" | ||
| 860 | }, | ||
| 861 | { | ||
| 862 | "type": "varchar(64)", | ||
| 863 | "typeSchema": null, | ||
| 864 | "notNull": true, | ||
| 865 | "dimensions": 0, | ||
| 866 | "default": null, | ||
| 867 | "generated": null, | ||
| 868 | "identity": null, | ||
| 869 | "name": "created_at", | ||
| 870 | "entityType": "columns", | ||
| 871 | "schema": "public", | ||
| 872 | "table": "relay_managed_endpoint_allocations" | ||
| 873 | }, | ||
| 874 | { | ||
| 875 | "type": "varchar(64)", | ||
| 876 | "typeSchema": null, | ||
| 877 | "notNull": true, | ||
| 878 | "dimensions": 0, | ||
| 879 | "default": null, | ||
| 880 | "generated": null, | ||
| 881 | "identity": null, | ||
| 882 | "name": "updated_at", | ||
| 883 | "entityType": "columns", | ||
| 884 | "schema": "public", | ||
| 885 | "table": "relay_managed_endpoint_allocations" | ||
| 886 | }, | ||
| 887 | { | ||
| 888 | "type": "varchar(255)", | ||
| 889 | "typeSchema": null, | ||
| 890 | "notNull": true, | ||
| 891 | "dimensions": 0, | ||
| 892 | "default": null, | ||
| 893 | "generated": null, | ||
| 894 | "identity": null, | ||
| 895 | "name": "user_id", | ||
| 896 | "entityType": "columns", | ||
| 897 | "schema": "public", | ||
| 898 | "table": "relay_mobile_devices" | ||
| 899 | }, | ||
| 900 | { | ||
| 901 | "type": "varchar(255)", | ||
| 902 | "typeSchema": null, | ||
| 903 | "notNull": true, | ||
| 904 | "dimensions": 0, | ||
| 905 | "default": null, | ||
| 906 | "generated": null, | ||
| 907 | "identity": null, | ||
| 908 | "name": "device_id", | ||
| 909 | "entityType": "columns", | ||
| 910 | "schema": "public", | ||
| 911 | "table": "relay_mobile_devices" | ||
| 912 | }, | ||
| 913 | { | ||
| 914 | "type": "text", | ||
| 915 | "typeSchema": null, | ||
| 916 | "notNull": true, | ||
| 917 | "dimensions": 0, | ||
| 918 | "default": "'iOS device'", | ||
| 919 | "generated": null, | ||
| 920 | "identity": null, | ||
| 921 | "name": "label", | ||
| 922 | "entityType": "columns", | ||
| 923 | "schema": "public", | ||
| 924 | "table": "relay_mobile_devices" | ||
| 925 | }, | ||
| 926 | { | ||
| 927 | "type": "varchar(16)", | ||
| 928 | "typeSchema": null, | ||
| 929 | "notNull": true, | ||
| 930 | "dimensions": 0, | ||
| 931 | "default": null, | ||
| 932 | "generated": null, | ||
| 933 | "identity": null, | ||
| 934 | "name": "platform", | ||
| 935 | "entityType": "columns", | ||
| 936 | "schema": "public", | ||
| 937 | "table": "relay_mobile_devices" | ||
| 938 | }, | ||
| 939 | { | ||
| 940 | "type": "integer", | ||
| 941 | "typeSchema": null, | ||
| 942 | "notNull": true, | ||
| 943 | "dimensions": 0, | ||
| 944 | "default": null, | ||
| 945 | "generated": null, | ||
| 946 | "identity": null, | ||
| 947 | "name": "ios_major_version", | ||
| 948 | "entityType": "columns", | ||
| 949 | "schema": "public", | ||
| 950 | "table": "relay_mobile_devices" | ||
| 951 | }, | ||
| 952 | { | ||
| 953 | "type": "varchar(64)", | ||
| 954 | "typeSchema": null, | ||
| 955 | "notNull": false, | ||
| 956 | "dimensions": 0, | ||
| 957 | "default": null, | ||
| 958 | "generated": null, | ||
| 959 | "identity": null, | ||
| 960 | "name": "app_version", | ||
| 961 | "entityType": "columns", | ||
| 962 | "schema": "public", | ||
| 963 | "table": "relay_mobile_devices" | ||
| 964 | }, | ||
| 965 | { | ||
| 966 | "type": "text", | ||
| 967 | "typeSchema": null, | ||
| 968 | "notNull": false, | ||
| 969 | "dimensions": 0, | ||
| 970 | "default": null, | ||
| 971 | "generated": null, | ||
| 972 | "identity": null, | ||
| 973 | "name": "push_token", | ||
| 974 | "entityType": "columns", | ||
| 975 | "schema": "public", | ||
| 976 | "table": "relay_mobile_devices" | ||
| 977 | }, | ||
| 978 | { | ||
| 979 | "type": "text", | ||
| 980 | "typeSchema": null, | ||
| 981 | "notNull": false, | ||
| 982 | "dimensions": 0, | ||
| 983 | "default": null, | ||
| 984 | "generated": null, | ||
| 985 | "identity": null, | ||
| 986 | "name": "push_to_start_token", | ||
| 987 | "entityType": "columns", | ||
| 988 | "schema": "public", | ||
| 989 | "table": "relay_mobile_devices" | ||
| 990 | }, | ||
| 991 | { | ||
| 992 | "type": "jsonb", | ||
| 993 | "typeSchema": null, | ||
| 994 | "notNull": true, | ||
| 995 | "dimensions": 0, | ||
| 996 | "default": null, | ||
| 997 | "generated": null, | ||
| 998 | "identity": null, | ||
| 999 | "name": "preferences_json", | ||
| 1000 | "entityType": "columns", | ||
| 1001 | "schema": "public", | ||
| 1002 | "table": "relay_mobile_devices" | ||
| 1003 | }, | ||
| 1004 | { | ||
| 1005 | "type": "varchar(64)", | ||
| 1006 | "typeSchema": null, | ||
| 1007 | "notNull": true, | ||
| 1008 | "dimensions": 0, | ||
| 1009 | "default": null, | ||
| 1010 | "generated": null, | ||
| 1011 | "identity": null, | ||
| 1012 | "name": "created_at", | ||
| 1013 | "entityType": "columns", | ||
| 1014 | "schema": "public", | ||
| 1015 | "table": "relay_mobile_devices" | ||
| 1016 | }, | ||
| 1017 | { | ||
| 1018 | "type": "varchar(64)", | ||
| 1019 | "typeSchema": null, | ||
| 1020 | "notNull": true, | ||
| 1021 | "dimensions": 0, | ||
| 1022 | "default": null, | ||
| 1023 | "generated": null, | ||
| 1024 | "identity": null, | ||
| 1025 | "name": "updated_at", | ||
| 1026 | "entityType": "columns", | ||
| 1027 | "schema": "public", | ||
| 1028 | "table": "relay_mobile_devices" | ||
| 1029 | }, | ||
| 1030 | { | ||
| 1031 | "nameExplicit": true, | ||
| 1032 | "columns": [ | ||
| 1033 | { | ||
| 1034 | "value": "updated_at", | ||
| 1035 | "isExpression": false, | ||
| 1036 | "asc": true, | ||
| 1037 | "nullsFirst": false, | ||
| 1038 | "opclass": null | ||
| 1039 | } | ||
| 1040 | ], | ||
| 1041 | "isUnique": false, | ||
| 1042 | "where": null, | ||
| 1043 | "with": "", | ||
| 1044 | "method": "btree", | ||
| 1045 | "concurrently": false, | ||
| 1046 | "name": "idx_relay_agent_activity_rows_updated", | ||
| 1047 | "entityType": "indexes", | ||
| 1048 | "schema": "public", | ||
| 1049 | "table": "relay_agent_activity_rows" | ||
| 1050 | }, | ||
| 1051 | { | ||
| 1052 | "nameExplicit": true, | ||
| 1053 | "columns": [ | ||
| 1054 | { | ||
| 1055 | "value": "environment_id", | ||
| 1056 | "isExpression": false, | ||
| 1057 | "asc": true, | ||
| 1058 | "nullsFirst": false, | ||
| 1059 | "opclass": null | ||
| 1060 | }, | ||
| 1061 | { | ||
| 1062 | "value": "thread_id", | ||
| 1063 | "isExpression": false, | ||
| 1064 | "asc": true, | ||
| 1065 | "nullsFirst": false, | ||
| 1066 | "opclass": null | ||
| 1067 | }, | ||
| 1068 | { | ||
| 1069 | "value": "created_at", | ||
| 1070 | "isExpression": false, | ||
| 1071 | "asc": true, | ||
| 1072 | "nullsFirst": false, | ||
| 1073 | "opclass": null | ||
| 1074 | } | ||
| 1075 | ], | ||
| 1076 | "isUnique": false, | ||
| 1077 | "where": null, | ||
| 1078 | "with": "", | ||
| 1079 | "method": "btree", | ||
| 1080 | "concurrently": false, | ||
| 1081 | "name": "idx_relay_delivery_attempts_environment", | ||
| 1082 | "entityType": "indexes", | ||
| 1083 | "schema": "public", | ||
| 1084 | "table": "relay_delivery_attempts" | ||
| 1085 | }, | ||
| 1086 | { | ||
| 1087 | "nameExplicit": true, | ||
| 1088 | "columns": [ | ||
| 1089 | { | ||
| 1090 | "value": "source_job_id", | ||
| 1091 | "isExpression": false, | ||
| 1092 | "asc": true, | ||
| 1093 | "nullsFirst": false, | ||
| 1094 | "opclass": null | ||
| 1095 | } | ||
| 1096 | ], | ||
| 1097 | "isUnique": true, | ||
| 1098 | "where": null, | ||
| 1099 | "with": "", | ||
| 1100 | "method": "btree", | ||
| 1101 | "concurrently": false, | ||
| 1102 | "name": "idx_relay_delivery_attempts_source_job", | ||
| 1103 | "entityType": "indexes", | ||
| 1104 | "schema": "public", | ||
| 1105 | "table": "relay_delivery_attempts" | ||
| 1106 | }, | ||
| 1107 | { | ||
| 1108 | "nameExplicit": true, | ||
| 1109 | "columns": [ | ||
| 1110 | { | ||
| 1111 | "value": "expires_at", | ||
| 1112 | "isExpression": false, | ||
| 1113 | "asc": true, | ||
| 1114 | "nullsFirst": false, | ||
| 1115 | "opclass": null | ||
| 1116 | } | ||
| 1117 | ], | ||
| 1118 | "isUnique": false, | ||
| 1119 | "where": null, | ||
| 1120 | "with": "", | ||
| 1121 | "method": "btree", | ||
| 1122 | "concurrently": false, | ||
| 1123 | "name": "idx_relay_dpop_proofs_expires_at", | ||
| 1124 | "entityType": "indexes", | ||
| 1125 | "schema": "public", | ||
| 1126 | "table": "relay_dpop_proofs" | ||
| 1127 | }, | ||
| 1128 | { | ||
| 1129 | "nameExplicit": true, | ||
| 1130 | "columns": [ | ||
| 1131 | { | ||
| 1132 | "value": "credential_hash", | ||
| 1133 | "isExpression": false, | ||
| 1134 | "asc": true, | ||
| 1135 | "nullsFirst": false, | ||
| 1136 | "opclass": null | ||
| 1137 | } | ||
| 1138 | ], | ||
| 1139 | "isUnique": true, | ||
| 1140 | "where": null, | ||
| 1141 | "with": "", | ||
| 1142 | "method": "btree", | ||
| 1143 | "concurrently": false, | ||
| 1144 | "name": "idx_relay_environment_credentials_hash", | ||
| 1145 | "entityType": "indexes", | ||
| 1146 | "schema": "public", | ||
| 1147 | "table": "relay_environment_credentials" | ||
| 1148 | }, | ||
| 1149 | { | ||
| 1150 | "nameExplicit": true, | ||
| 1151 | "columns": [ | ||
| 1152 | { | ||
| 1153 | "value": "environment_id", | ||
| 1154 | "isExpression": false, | ||
| 1155 | "asc": true, | ||
| 1156 | "nullsFirst": false, | ||
| 1157 | "opclass": null | ||
| 1158 | }, | ||
| 1159 | { | ||
| 1160 | "value": "revoked_at", | ||
| 1161 | "isExpression": false, | ||
| 1162 | "asc": true, | ||
| 1163 | "nullsFirst": false, | ||
| 1164 | "opclass": null | ||
| 1165 | } | ||
| 1166 | ], | ||
| 1167 | "isUnique": false, | ||
| 1168 | "where": null, | ||
| 1169 | "with": "", | ||
| 1170 | "method": "btree", | ||
| 1171 | "concurrently": false, | ||
| 1172 | "name": "idx_relay_environment_credentials_environment", | ||
| 1173 | "entityType": "indexes", | ||
| 1174 | "schema": "public", | ||
| 1175 | "table": "relay_environment_credentials" | ||
| 1176 | }, | ||
| 1177 | { | ||
| 1178 | "nameExplicit": true, | ||
| 1179 | "columns": [ | ||
| 1180 | { | ||
| 1181 | "value": "environment_id", | ||
| 1182 | "isExpression": false, | ||
| 1183 | "asc": true, | ||
| 1184 | "nullsFirst": false, | ||
| 1185 | "opclass": null | ||
| 1186 | }, | ||
| 1187 | { | ||
| 1188 | "value": "environment_public_key", | ||
| 1189 | "isExpression": false, | ||
| 1190 | "asc": true, | ||
| 1191 | "nullsFirst": false, | ||
| 1192 | "opclass": null | ||
| 1193 | }, | ||
| 1194 | { | ||
| 1195 | "value": "revoked_at", | ||
| 1196 | "isExpression": false, | ||
| 1197 | "asc": true, | ||
| 1198 | "nullsFirst": false, | ||
| 1199 | "opclass": null | ||
| 1200 | } | ||
| 1201 | ], | ||
| 1202 | "isUnique": false, | ||
| 1203 | "where": null, | ||
| 1204 | "with": "", | ||
| 1205 | "method": "btree", | ||
| 1206 | "concurrently": false, | ||
| 1207 | "name": "idx_relay_environment_credentials_environment_key", | ||
| 1208 | "entityType": "indexes", | ||
| 1209 | "schema": "public", | ||
| 1210 | "table": "relay_environment_credentials" | ||
| 1211 | }, | ||
| 1212 | { | ||
| 1213 | "nameExplicit": true, | ||
| 1214 | "columns": [ | ||
| 1215 | { | ||
| 1216 | "value": "environment_id", | ||
| 1217 | "isExpression": false, | ||
| 1218 | "asc": true, | ||
| 1219 | "nullsFirst": false, | ||
| 1220 | "opclass": null | ||
| 1221 | }, | ||
| 1222 | { | ||
| 1223 | "value": "revoked_at", | ||
| 1224 | "isExpression": false, | ||
| 1225 | "asc": true, | ||
| 1226 | "nullsFirst": false, | ||
| 1227 | "opclass": null | ||
| 1228 | } | ||
| 1229 | ], | ||
| 1230 | "isUnique": false, | ||
| 1231 | "where": null, | ||
| 1232 | "with": "", | ||
| 1233 | "method": "btree", | ||
| 1234 | "concurrently": false, | ||
| 1235 | "name": "idx_relay_environment_links_environment", | ||
| 1236 | "entityType": "indexes", | ||
| 1237 | "schema": "public", | ||
| 1238 | "table": "relay_environment_links" | ||
| 1239 | }, | ||
| 1240 | { | ||
| 1241 | "nameExplicit": true, | ||
| 1242 | "columns": [ | ||
| 1243 | { | ||
| 1244 | "value": "user_id", | ||
| 1245 | "isExpression": false, | ||
| 1246 | "asc": true, | ||
| 1247 | "nullsFirst": false, | ||
| 1248 | "opclass": null | ||
| 1249 | } | ||
| 1250 | ], | ||
| 1251 | "isUnique": false, | ||
| 1252 | "where": null, | ||
| 1253 | "with": "", | ||
| 1254 | "method": "btree", | ||
| 1255 | "concurrently": false, | ||
| 1256 | "name": "idx_relay_live_activities_user", | ||
| 1257 | "entityType": "indexes", | ||
| 1258 | "schema": "public", | ||
| 1259 | "table": "relay_live_activities" | ||
| 1260 | }, | ||
| 1261 | { | ||
| 1262 | "nameExplicit": true, | ||
| 1263 | "columns": [ | ||
| 1264 | { | ||
| 1265 | "value": "activity_push_token", | ||
| 1266 | "isExpression": false, | ||
| 1267 | "asc": true, | ||
| 1268 | "nullsFirst": false, | ||
| 1269 | "opclass": null | ||
| 1270 | } | ||
| 1271 | ], | ||
| 1272 | "isUnique": true, | ||
| 1273 | "where": null, | ||
| 1274 | "with": "", | ||
| 1275 | "method": "btree", | ||
| 1276 | "concurrently": false, | ||
| 1277 | "name": "idx_relay_live_activities_activity_push_token", | ||
| 1278 | "entityType": "indexes", | ||
| 1279 | "schema": "public", | ||
| 1280 | "table": "relay_live_activities" | ||
| 1281 | }, | ||
| 1282 | { | ||
| 1283 | "nameExplicit": true, | ||
| 1284 | "columns": [ | ||
| 1285 | { | ||
| 1286 | "value": "hostname", | ||
| 1287 | "isExpression": false, | ||
| 1288 | "asc": true, | ||
| 1289 | "nullsFirst": false, | ||
| 1290 | "opclass": null | ||
| 1291 | } | ||
| 1292 | ], | ||
| 1293 | "isUnique": true, | ||
| 1294 | "where": null, | ||
| 1295 | "with": "", | ||
| 1296 | "method": "btree", | ||
| 1297 | "concurrently": false, | ||
| 1298 | "name": "idx_relay_managed_endpoint_allocations_hostname", | ||
| 1299 | "entityType": "indexes", | ||
| 1300 | "schema": "public", | ||
| 1301 | "table": "relay_managed_endpoint_allocations" | ||
| 1302 | }, | ||
| 1303 | { | ||
| 1304 | "nameExplicit": true, | ||
| 1305 | "columns": [ | ||
| 1306 | { | ||
| 1307 | "value": "tunnel_name", | ||
| 1308 | "isExpression": false, | ||
| 1309 | "asc": true, | ||
| 1310 | "nullsFirst": false, | ||
| 1311 | "opclass": null | ||
| 1312 | } | ||
| 1313 | ], | ||
| 1314 | "isUnique": true, | ||
| 1315 | "where": null, | ||
| 1316 | "with": "", | ||
| 1317 | "method": "btree", | ||
| 1318 | "concurrently": false, | ||
| 1319 | "name": "idx_relay_managed_endpoint_allocations_tunnel_name", | ||
| 1320 | "entityType": "indexes", | ||
| 1321 | "schema": "public", | ||
| 1322 | "table": "relay_managed_endpoint_allocations" | ||
| 1323 | }, | ||
| 1324 | { | ||
| 1325 | "nameExplicit": true, | ||
| 1326 | "columns": [ | ||
| 1327 | { | ||
| 1328 | "value": "user_id", | ||
| 1329 | "isExpression": false, | ||
| 1330 | "asc": true, | ||
| 1331 | "nullsFirst": false, | ||
| 1332 | "opclass": null | ||
| 1333 | } | ||
| 1334 | ], | ||
| 1335 | "isUnique": false, | ||
| 1336 | "where": null, | ||
| 1337 | "with": "", | ||
| 1338 | "method": "btree", | ||
| 1339 | "concurrently": false, | ||
| 1340 | "name": "idx_relay_mobile_devices_user", | ||
| 1341 | "entityType": "indexes", | ||
| 1342 | "schema": "public", | ||
| 1343 | "table": "relay_mobile_devices" | ||
| 1344 | }, | ||
| 1345 | { | ||
| 1346 | "nameExplicit": true, | ||
| 1347 | "columns": [ | ||
| 1348 | { | ||
| 1349 | "value": "push_token", | ||
| 1350 | "isExpression": false, | ||
| 1351 | "asc": true, | ||
| 1352 | "nullsFirst": false, | ||
| 1353 | "opclass": null | ||
| 1354 | } | ||
| 1355 | ], | ||
| 1356 | "isUnique": true, | ||
| 1357 | "where": null, | ||
| 1358 | "with": "", | ||
| 1359 | "method": "btree", | ||
| 1360 | "concurrently": false, | ||
| 1361 | "name": "idx_relay_mobile_devices_push_token", | ||
| 1362 | "entityType": "indexes", | ||
| 1363 | "schema": "public", | ||
| 1364 | "table": "relay_mobile_devices" | ||
| 1365 | }, | ||
| 1366 | { | ||
| 1367 | "nameExplicit": true, | ||
| 1368 | "columns": [ | ||
| 1369 | { | ||
| 1370 | "value": "push_to_start_token", | ||
| 1371 | "isExpression": false, | ||
| 1372 | "asc": true, | ||
| 1373 | "nullsFirst": false, | ||
| 1374 | "opclass": null | ||
| 1375 | } | ||
| 1376 | ], | ||
| 1377 | "isUnique": true, | ||
| 1378 | "where": null, | ||
| 1379 | "with": "", | ||
| 1380 | "method": "btree", | ||
| 1381 | "concurrently": false, | ||
| 1382 | "name": "idx_relay_mobile_devices_push_to_start_token", | ||
| 1383 | "entityType": "indexes", | ||
| 1384 | "schema": "public", | ||
| 1385 | "table": "relay_mobile_devices" | ||
| 1386 | }, | ||
| 1387 | { | ||
| 1388 | "columns": ["environment_id", "environment_public_key", "thread_id"], | ||
| 1389 | "nameExplicit": false, | ||
| 1390 | "name": "relay_agent_activity_rows_pkey", | ||
| 1391 | "entityType": "pks", | ||
| 1392 | "schema": "public", | ||
| 1393 | "table": "relay_agent_activity_rows" | ||
| 1394 | }, | ||
| 1395 | { | ||
| 1396 | "columns": ["thumbprint", "jti"], | ||
| 1397 | "nameExplicit": false, | ||
| 1398 | "name": "relay_dpop_proofs_pkey", | ||
| 1399 | "entityType": "pks", | ||
| 1400 | "schema": "public", | ||
| 1401 | "table": "relay_dpop_proofs" | ||
| 1402 | }, | ||
| 1403 | { | ||
| 1404 | "columns": ["user_id", "environment_id"], | ||
| 1405 | "nameExplicit": false, | ||
| 1406 | "name": "relay_environment_links_pkey", | ||
| 1407 | "entityType": "pks", | ||
| 1408 | "schema": "public", | ||
| 1409 | "table": "relay_environment_links" | ||
| 1410 | }, | ||
| 1411 | { | ||
| 1412 | "columns": ["user_id", "device_id"], | ||
| 1413 | "nameExplicit": false, | ||
| 1414 | "name": "relay_live_activities_pkey", | ||
| 1415 | "entityType": "pks", | ||
| 1416 | "schema": "public", | ||
| 1417 | "table": "relay_live_activities" | ||
| 1418 | }, | ||
| 1419 | { | ||
| 1420 | "columns": ["user_id", "environment_id"], | ||
| 1421 | "nameExplicit": false, | ||
| 1422 | "name": "relay_managed_endpoint_allocations_pkey", | ||
| 1423 | "entityType": "pks", | ||
| 1424 | "schema": "public", | ||
| 1425 | "table": "relay_managed_endpoint_allocations" | ||
| 1426 | }, | ||
| 1427 | { | ||
| 1428 | "columns": ["user_id", "device_id"], | ||
| 1429 | "nameExplicit": false, | ||
| 1430 | "name": "relay_mobile_devices_pkey", | ||
| 1431 | "entityType": "pks", | ||
| 1432 | "schema": "public", | ||
| 1433 | "table": "relay_mobile_devices" | ||
| 1434 | }, | ||
| 1435 | { | ||
| 1436 | "columns": ["id"], | ||
| 1437 | "nameExplicit": false, | ||
| 1438 | "name": "relay_delivery_attempts_pkey", | ||
| 1439 | "schema": "public", | ||
| 1440 | "table": "relay_delivery_attempts", | ||
| 1441 | "entityType": "pks" | ||
| 1442 | }, | ||
| 1443 | { | ||
| 1444 | "columns": ["credential_id"], | ||
| 1445 | "nameExplicit": false, | ||
| 1446 | "name": "relay_environment_credentials_pkey", | ||
| 1447 | "schema": "public", | ||
| 1448 | "table": "relay_environment_credentials", | ||
| 1449 | "entityType": "pks" | ||
| 1450 | } | ||
| 1451 | ], | ||
| 1452 | "renames": [] | ||
| 1453 | } | ||
infra/relay/package.json created+29| ... | @@ -0,0 +1,29 @@ | ||
| 1 | { | ||
| 2 | "name": "t3code-relay", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "scripts": { | ||
| 6 | "deploy": "node -- scripts/deploy.ts", | ||
| 7 | "destroy": "alchemy destroy", | ||
| 8 | "test": "vp test run", | ||
| 9 | "typecheck": "tsgo --noEmit" | ||
| 10 | }, | ||
| 11 | "dependencies": { | ||
| 12 | "@clerk/backend": "3.4.14", | ||
| 13 | "@effect/sql-pg": "catalog:", | ||
| 14 | "@t3tools/client-runtime": "workspace:*", | ||
| 15 | "@t3tools/contracts": "workspace:*", | ||
| 16 | "@t3tools/shared": "workspace:*", | ||
| 17 | "alchemy": "https://pkg.ing/alchemy/8b9c241", | ||
| 18 | "drizzle-orm": "1.0.0-rc.3", | ||
| 19 | "effect": "catalog:" | ||
| 20 | }, | ||
| 21 | "devDependencies": { | ||
| 22 | "@cloudflare/workers-types": "^4.20260601.1", | ||
| 23 | "@effect/platform-node": "catalog:", | ||
| 24 | "@effect/vitest": "catalog:", | ||
| 25 | "@types/node": "catalog:", | ||
| 26 | "drizzle-kit": "1.0.0-rc.3", | ||
| 27 | "vitest": "catalog:" | ||
| 28 | } | ||
| 29 | } | ||
infra/relay/scripts/deploy.test.ts created+52| ... | @@ -0,0 +1,52 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { hasDeployChanges, reconcileRootEnvRelayUrl } from "./deploy.ts"; | ||
| 4 | |||
| 5 | describe("hasDeployChanges", () => { | ||
| 6 | it("detects resource, binding, and deletion changes", () => { | ||
| 7 | expect(hasDeployChanges({ resources: {}, deletions: {} } as never)).toBe(false); | ||
| 8 | expect( | ||
| 9 | hasDeployChanges({ | ||
| 10 | resources: { | ||
| 11 | api: { action: "create", bindings: [] }, | ||
| 12 | }, | ||
| 13 | deletions: {}, | ||
| 14 | } as never), | ||
| 15 | ).toBe(true); | ||
| 16 | expect( | ||
| 17 | hasDeployChanges({ | ||
| 18 | resources: { | ||
| 19 | api: { action: "noop", bindings: [{ action: "update" }] }, | ||
| 20 | }, | ||
| 21 | deletions: {}, | ||
| 22 | } as never), | ||
| 23 | ).toBe(true); | ||
| 24 | expect( | ||
| 25 | hasDeployChanges({ | ||
| 26 | resources: {}, | ||
| 27 | deletions: { | ||
| 28 | api: { action: "delete", bindings: [] }, | ||
| 29 | }, | ||
| 30 | } as never), | ||
| 31 | ).toBe(true); | ||
| 32 | }); | ||
| 33 | }); | ||
| 34 | |||
| 35 | describe("reconcileRootEnvRelayUrl", () => { | ||
| 36 | it("adds the relay URL to an empty root env file", () => { | ||
| 37 | expect(reconcileRootEnvRelayUrl("", "https://relay.example.test")).toBe( | ||
| 38 | "T3CODE_RELAY_URL=https://relay.example.test\n", | ||
| 39 | ); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("preserves unrelated root env entries while replacing a previous relay URL", () => { | ||
| 43 | expect( | ||
| 44 | reconcileRootEnvRelayUrl( | ||
| 45 | "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example\nT3CODE_RELAY_URL=https://old.example.test\n", | ||
| 46 | "https://relay.example.test", | ||
| 47 | ), | ||
| 48 | ).toBe( | ||
| 49 | "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example\nT3CODE_RELAY_URL=https://relay.example.test\n", | ||
| 50 | ); | ||
| 51 | }); | ||
| 52 | }); | ||
infra/relay/scripts/deploy.ts created+212| ... | @@ -0,0 +1,212 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | |||
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 4 | import { AdoptPolicy } from "alchemy/AdoptPolicy"; | ||
| 5 | import { AlchemyContext, AlchemyContextLive } from "alchemy/AlchemyContext"; | ||
| 6 | import * as Apply from "alchemy/Apply"; | ||
| 7 | import { provideFreshArtifactStore } from "alchemy/Artifacts"; | ||
| 8 | import { AuthProviders } from "alchemy/Auth/AuthProvider"; | ||
| 9 | import { CredentialsStoreLive } from "alchemy/Auth/Credentials"; | ||
| 10 | import { ProfileLive } from "alchemy/Auth/Profile"; | ||
| 11 | import { Cli } from "alchemy/Cli/Cli"; | ||
| 12 | import { LoggingCli } from "alchemy/Cli/LoggingCli"; | ||
| 13 | import * as Plan from "alchemy/Plan"; | ||
| 14 | import * as Stage from "alchemy/Stage"; | ||
| 15 | import { TelemetryLive } from "alchemy/Telemetry/Layer"; | ||
| 16 | import { PlatformServices } from "alchemy/Util/PlatformServices"; | ||
| 17 | import * as Config from "effect/Config"; | ||
| 18 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 19 | import * as Console from "effect/Console"; | ||
| 20 | import * as Data from "effect/Data"; | ||
| 21 | import * as Effect from "effect/Effect"; | ||
| 22 | import * as FileSystem from "effect/FileSystem"; | ||
| 23 | import * as Layer from "effect/Layer"; | ||
| 24 | import * as Option from "effect/Option"; | ||
| 25 | import * as Path from "effect/Path"; | ||
| 26 | import { Command, Flag, Prompt } from "effect/unstable/cli"; | ||
| 27 | import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; | ||
| 28 | |||
| 29 | import RelayStack from "../alchemy.run.ts"; | ||
| 30 | |||
| 31 | export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{ | ||
| 32 | readonly message: string; | ||
| 33 | }> {} | ||
| 34 | |||
| 35 | export interface RelayDeployOptions { | ||
| 36 | readonly dryRun: boolean; | ||
| 37 | readonly force: boolean; | ||
| 38 | readonly envFile: Option.Option<string>; | ||
| 39 | readonly stage: Option.Option<string>; | ||
| 40 | readonly yes: boolean; | ||
| 41 | readonly adopt: boolean; | ||
| 42 | } | ||
| 43 | |||
| 44 | export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string { | ||
| 45 | const entry = `T3CODE_RELAY_URL=${relayUrl}`; | ||
| 46 | if (/^T3CODE_RELAY_URL=.*$/mu.test(contents)) { | ||
| 47 | return contents.replace(/^T3CODE_RELAY_URL=.*$/mu, entry); | ||
| 48 | } | ||
| 49 | if (!contents) { | ||
| 50 | return `${entry}\n`; | ||
| 51 | } | ||
| 52 | return `${contents}${contents.endsWith("\n") ? "" : "\n"}${entry}\n`; | ||
| 53 | } | ||
| 54 | |||
| 55 | export function hasDeployChanges(plan: Plan.Plan): boolean { | ||
| 56 | return ( | ||
| 57 | Object.keys(plan.deletions).length > 0 || | ||
| 58 | Object.values(plan.resources).some( | ||
| 59 | (node) => | ||
| 60 | node.action !== "noop" || node.bindings.some((binding) => binding.action !== "noop"), | ||
| 61 | ) | ||
| 62 | ); | ||
| 63 | } | ||
| 64 | |||
| 65 | const relayRoot = Effect.service(Path.Path).pipe( | ||
| 66 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), | ||
| 67 | ); | ||
| 68 | const repoRoot = Effect.service(Path.Path).pipe( | ||
| 69 | Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), | ||
| 70 | ); | ||
| 71 | |||
| 72 | const loadDeployConfigProvider = Effect.fn("relay.deploy.loadConfigProvider")(function* ( | ||
| 73 | envFileOverride: Option.Option<string>, | ||
| 74 | ) { | ||
| 75 | const path = yield* Path.Path; | ||
| 76 | const root = yield* relayRoot; | ||
| 77 | const selectedEnvFile = Option.getOrUndefined(envFileOverride); | ||
| 78 | const envFile = selectedEnvFile ? path.resolve(root, selectedEnvFile) : path.join(root, ".env"); | ||
| 79 | return yield* ConfigProvider.fromDotEnv({ path: envFile }); | ||
| 80 | }); | ||
| 81 | |||
| 82 | const relayDeployStage = Config.nonEmptyString("stage").pipe( | ||
| 83 | Config.option, | ||
| 84 | Config.map( | ||
| 85 | Option.getOrElse(() => `dev_${process.env.USER ?? process.env.USERNAME ?? "unknown"}`), | ||
| 86 | ), | ||
| 87 | ); | ||
| 88 | |||
| 89 | const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (relayUrl: string) { | ||
| 90 | const fs = yield* FileSystem.FileSystem; | ||
| 91 | const path = yield* Path.Path; | ||
| 92 | const root = yield* repoRoot; | ||
| 93 | const rootEnvPath = path.join(root, ".env"); | ||
| 94 | const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : ""; | ||
| 95 | |||
| 96 | yield* fs.writeFileString(rootEnvPath, reconcileRootEnvRelayUrl(contents, relayUrl)); | ||
| 97 | yield* Console.log(`Updated ${rootEnvPath} with T3CODE_RELAY_URL=${relayUrl}`); | ||
| 98 | }); | ||
| 99 | |||
| 100 | const deployServices = Layer.mergeAll( | ||
| 101 | Layer.provideMerge(AlchemyContextLive, PlatformServices), | ||
| 102 | Layer.provide(ProfileLive, PlatformServices), | ||
| 103 | Layer.provide(CredentialsStoreLive, PlatformServices), | ||
| 104 | FetchHttpClient.layer, | ||
| 105 | TelemetryLive, | ||
| 106 | LoggingCli, | ||
| 107 | ); | ||
| 108 | |||
| 109 | const runRelayDeploy = Effect.fn("relay.deploy.run")( | ||
| 110 | function* ( | ||
| 111 | options: RelayDeployOptions, | ||
| 112 | _configProvider: ConfigProvider.ConfigProvider, | ||
| 113 | _stage: string, | ||
| 114 | ) { | ||
| 115 | const stack = yield* RelayStack; | ||
| 116 | |||
| 117 | const cli = yield* Cli; | ||
| 118 | const plan = yield* Plan.make(stack, { force: options.force }).pipe( | ||
| 119 | Effect.provide(stack.services), | ||
| 120 | ); | ||
| 121 | if (options.dryRun) { | ||
| 122 | yield* cli.displayPlan(plan); | ||
| 123 | return Option.none<string>(); | ||
| 124 | } | ||
| 125 | if (!options.yes && hasDeployChanges(plan)) { | ||
| 126 | yield* cli.displayPlan(plan); | ||
| 127 | const approved = yield* Prompt.run( | ||
| 128 | Prompt.confirm({ | ||
| 129 | message: "Apply this relay deployment?", | ||
| 130 | }), | ||
| 131 | ); | ||
| 132 | if (!approved) { | ||
| 133 | yield* Console.log("Deployment cancelled."); | ||
| 134 | return Option.none<string>(); | ||
| 135 | } | ||
| 136 | } | ||
| 137 | const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); | ||
| 138 | if (output.url === undefined) { | ||
| 139 | return yield* new RelayDeployError({ | ||
| 140 | message: "Alchemy relay deploy output did not include a URL", | ||
| 141 | }); | ||
| 142 | } | ||
| 143 | return Option.some(output.url); | ||
| 144 | }, | ||
| 145 | (effect, options, configProvider, stage) => | ||
| 146 | effect.pipe( | ||
| 147 | Effect.provide( | ||
| 148 | Layer.mergeAll( | ||
| 149 | Layer.effect( | ||
| 150 | AlchemyContext, | ||
| 151 | AlchemyContext.pipe(Effect.map((context) => ({ ...context, adopt: options.adopt }))), | ||
| 152 | ), | ||
| 153 | Layer.succeed(AdoptPolicy, options.adopt), | ||
| 154 | Layer.succeed(AuthProviders, {}), | ||
| 155 | ConfigProvider.layer(configProvider), | ||
| 156 | Layer.succeed(Stage.Stage, stage), | ||
| 157 | ), | ||
| 158 | ), | ||
| 159 | provideFreshArtifactStore, | ||
| 160 | ), | ||
| 161 | ); | ||
| 162 | |||
| 163 | export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployOptions) { | ||
| 164 | const configProvider = yield* loadDeployConfigProvider(options.envFile); | ||
| 165 | const configuredStage = yield* relayDeployStage.pipe( | ||
| 166 | Effect.provide(ConfigProvider.layer(configProvider)), | ||
| 167 | ); | ||
| 168 | const stage = Option.getOrElse(options.stage, () => configuredStage); | ||
| 169 | const relayUrl = yield* runRelayDeploy(options, configProvider, stage); | ||
| 170 | if (Option.isSome(relayUrl)) { | ||
| 171 | yield* reconcileRootEnv(relayUrl.value); | ||
| 172 | } | ||
| 173 | }); | ||
| 174 | |||
| 175 | export const relayDeployCommand = Command.make( | ||
| 176 | "relay-deploy", | ||
| 177 | { | ||
| 178 | dryRun: Flag.boolean("dry-run").pipe( | ||
| 179 | Flag.withDescription("Dry run the deployment without applying changes."), | ||
| 180 | Flag.withDefault(false), | ||
| 181 | ), | ||
| 182 | force: Flag.boolean("force").pipe( | ||
| 183 | Flag.withDescription("Force updates for resources that would otherwise no-op."), | ||
| 184 | Flag.withDefault(false), | ||
| 185 | ), | ||
| 186 | envFile: Flag.string("env-file").pipe( | ||
| 187 | Flag.withDescription("Environment file to load. Defaults to infra/relay/.env."), | ||
| 188 | Flag.optional, | ||
| 189 | ), | ||
| 190 | stage: Flag.string("stage").pipe( | ||
| 191 | Flag.withDescription("Stage to deploy. Defaults to dev_${USER}."), | ||
| 192 | Flag.optional, | ||
| 193 | ), | ||
| 194 | yes: Flag.boolean("yes").pipe( | ||
| 195 | Flag.withDescription("Skip the deployment confirmation prompt."), | ||
| 196 | Flag.withDefault(false), | ||
| 197 | ), | ||
| 198 | adopt: Flag.boolean("adopt").pipe( | ||
| 199 | Flag.withDescription("Adopt pre-existing cloud resources that conflict with this stack."), | ||
| 200 | Flag.withDefault(false), | ||
| 201 | ), | ||
| 202 | }, | ||
| 203 | deploy, | ||
| 204 | ).pipe(Command.withDescription("Deploy the T3 Code relay through Alchemy.")); | ||
| 205 | |||
| 206 | if (import.meta.main) { | ||
| 207 | Command.run(relayDeployCommand, { version: "0.0.0" }).pipe( | ||
| 208 | Effect.provide(deployServices), | ||
| 209 | Effect.scoped, | ||
| 210 | NodeRuntime.runMain, | ||
| 211 | ); | ||
| 212 | } | ||
infra/relay/src/Config.ts created+32| ... | @@ -0,0 +1,32 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Redacted from "effect/Redacted"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | |||
| 5 | export const ApnsEnvironment = Schema.Literals(["sandbox", "production"]); | ||
| 6 | export type ApnsEnvironment = typeof ApnsEnvironment.Type; | ||
| 7 | |||
| 8 | export interface ApnsCredentials { | ||
| 9 | readonly teamId: string; | ||
| 10 | readonly keyId: string; | ||
| 11 | readonly privateKey: Redacted.Redacted<string>; | ||
| 12 | readonly bundleId: string; | ||
| 13 | readonly environment: ApnsEnvironment; | ||
| 14 | } | ||
| 15 | |||
| 16 | export interface RelayConfigurationShape { | ||
| 17 | readonly relayIssuer: string; | ||
| 18 | readonly apns: ApnsCredentials; | ||
| 19 | readonly clerkSecretKey: Redacted.Redacted<string>; | ||
| 20 | readonly clerkPublishableKey: string; | ||
| 21 | readonly clerkJwtAudience: string; | ||
| 22 | readonly apnsDeliveryJobSigningSecret: Redacted.Redacted<string>; | ||
| 23 | readonly cloudMintPrivateKey: Redacted.Redacted<string>; | ||
| 24 | readonly cloudMintPublicKey: string; | ||
| 25 | readonly managedEndpointBaseDomain: string | undefined; | ||
| 26 | readonly managedEndpointNamespace: string | undefined; | ||
| 27 | } | ||
| 28 | |||
| 29 | export class RelayConfiguration extends Context.Service< | ||
| 30 | RelayConfiguration, | ||
| 31 | RelayConfigurationShape | ||
| 32 | >()("t3code-relay/Config/RelayConfiguration") {} | ||
infra/relay/src/agentActivity/AgentActivityPublisher.test.ts created+627| ... | @@ -0,0 +1,627 @@ | ||
| 1 | import type { RelayAgentActivityState, RelayDeliveryResult } from "@t3tools/contracts/relay"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | |||
| 6 | import * as AgentActivityRows from "./AgentActivityRows.ts"; | ||
| 7 | import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; | ||
| 8 | import * as LiveActivities from "./LiveActivities.ts"; | ||
| 9 | import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; | ||
| 10 | import * as ApnsDeliveries from "./ApnsDeliveries.ts"; | ||
| 11 | |||
| 12 | const state: RelayAgentActivityState = { | ||
| 13 | environmentId: "env" as RelayAgentActivityState["environmentId"], | ||
| 14 | threadId: "thread" as RelayAgentActivityState["threadId"], | ||
| 15 | projectTitle: "Project", | ||
| 16 | threadTitle: "Thread", | ||
| 17 | modelTitle: "gpt-5.4", | ||
| 18 | phase: "running", | ||
| 19 | headline: "Running", | ||
| 20 | updatedAt: "1970-01-01T00:00:00.000Z", | ||
| 21 | deepLink: "/threads/env/thread", | ||
| 22 | }; | ||
| 23 | |||
| 24 | function target(deviceId: string): LiveActivities.TargetRow { | ||
| 25 | return { | ||
| 26 | user_id: "dev:julius", | ||
| 27 | device_id: deviceId, | ||
| 28 | platform: "ios", | ||
| 29 | ios_major_version: 18, | ||
| 30 | app_version: "1.0.0", | ||
| 31 | push_token: null, | ||
| 32 | push_to_start_token: "start-token", | ||
| 33 | preferences_json: "{}", | ||
| 34 | activity_push_token: null, | ||
| 35 | remote_start_queued_at: null, | ||
| 36 | remote_started_at: null, | ||
| 37 | ended_at: null, | ||
| 38 | last_aggregate_json: null, | ||
| 39 | last_live_activity_delivery_at: null, | ||
| 40 | }; | ||
| 41 | } | ||
| 42 | |||
| 43 | function makeLiveActivities( | ||
| 44 | overrides: Partial<LiveActivities.LiveActivitiesShape> = {}, | ||
| 45 | ): LiveActivities.LiveActivitiesShape { | ||
| 46 | return { | ||
| 47 | register: () => Effect.void, | ||
| 48 | listTargets: () => Effect.succeed([]), | ||
| 49 | markDelivery: () => Effect.void, | ||
| 50 | markStartQueued: () => Effect.void, | ||
| 51 | clearStartQueued: () => Effect.void, | ||
| 52 | invalidateDeliveryToken: () => Effect.void, | ||
| 53 | ...overrides, | ||
| 54 | }; | ||
| 55 | } | ||
| 56 | |||
| 57 | function makeAgentActivityRows( | ||
| 58 | overrides: Partial<AgentActivityRows.AgentActivityRowsShape> = {}, | ||
| 59 | ): AgentActivityRows.AgentActivityRowsShape { | ||
| 60 | return { | ||
| 61 | upsert: () => Effect.void, | ||
| 62 | remove: () => Effect.void, | ||
| 63 | listForUser: () => Effect.succeed([state]), | ||
| 64 | ...overrides, | ||
| 65 | }; | ||
| 66 | } | ||
| 67 | |||
| 68 | function makeEnvironmentLinks( | ||
| 69 | overrides: Partial<EnvironmentLinks.EnvironmentLinksShape> = {}, | ||
| 70 | ): EnvironmentLinks.EnvironmentLinksShape { | ||
| 71 | return { | ||
| 72 | upsert: () => Effect.void, | ||
| 73 | listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), | ||
| 74 | listDeliveryUsersForEnvironment: () => | ||
| 75 | Effect.succeed([ | ||
| 76 | { | ||
| 77 | userId: "dev:julius", | ||
| 78 | notificationsEnabled: true, | ||
| 79 | liveActivitiesEnabled: true, | ||
| 80 | }, | ||
| 81 | ]), | ||
| 82 | listPublicKeysForEnvironment: () => Effect.succeed([]), | ||
| 83 | listForUser: () => Effect.succeed([]), | ||
| 84 | getForUser: () => Effect.succeed(null), | ||
| 85 | revokeForUser: () => Effect.succeed(false), | ||
| 86 | ...overrides, | ||
| 87 | }; | ||
| 88 | } | ||
| 89 | |||
| 90 | function makeApnsDeliveries( | ||
| 91 | overrides: Partial<ApnsDeliveries.ApnsDeliveriesShape> = {}, | ||
| 92 | ): ApnsDeliveries.ApnsDeliveriesShape { | ||
| 93 | return { | ||
| 94 | sendForTarget: () => Effect.succeed(null), | ||
| 95 | sendPushNotificationForTarget: () => Effect.succeed(null), | ||
| 96 | sendLiveActivity: () => | ||
| 97 | Effect.succeed({ | ||
| 98 | deviceId: "device", | ||
| 99 | kind: "live_activity_start", | ||
| 100 | ok: true, | ||
| 101 | apnsStatus: 200, | ||
| 102 | apnsReason: null, | ||
| 103 | apnsId: "apns-id", | ||
| 104 | }), | ||
| 105 | sendPushNotification: () => | ||
| 106 | Effect.succeed({ | ||
| 107 | deviceId: "device", | ||
| 108 | kind: "push_notification", | ||
| 109 | ok: true, | ||
| 110 | apnsStatus: 200, | ||
| 111 | apnsReason: null, | ||
| 112 | apnsId: "apns-id", | ||
| 113 | }), | ||
| 114 | processSignedJob: () => | ||
| 115 | Effect.succeed({ | ||
| 116 | deviceId: "device", | ||
| 117 | kind: "live_activity_start", | ||
| 118 | ok: true, | ||
| 119 | apnsStatus: 200, | ||
| 120 | apnsReason: null, | ||
| 121 | apnsId: "apns-id", | ||
| 122 | }), | ||
| 123 | ...overrides, | ||
| 124 | }; | ||
| 125 | } | ||
| 126 | |||
| 127 | describe("AgentActivityPublisher", () => { | ||
| 128 | it.effect("replays the latest aggregate when a Live Activity token registers", () => { | ||
| 129 | const registeredTarget: LiveActivities.TargetRow = { | ||
| 130 | ...target("device-1"), | ||
| 131 | push_to_start_token: null, | ||
| 132 | activity_push_token: "activity-token", | ||
| 133 | remote_start_queued_at: null, | ||
| 134 | remote_started_at: "1970-01-01T00:00:01.000Z", | ||
| 135 | }; | ||
| 136 | const sent: Array<Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendForTarget"]>[0]> = []; | ||
| 137 | const deliveryResult: RelayDeliveryResult = { | ||
| 138 | deviceId: "device-1", | ||
| 139 | kind: "live_activity_update", | ||
| 140 | ok: true, | ||
| 141 | apnsStatus: null, | ||
| 142 | apnsReason: null, | ||
| 143 | apnsId: "queued", | ||
| 144 | }; | ||
| 145 | |||
| 146 | return Effect.gen(function* () { | ||
| 147 | const result = yield* Effect.gen(function* () { | ||
| 148 | const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; | ||
| 149 | return yield* publisher.replayForLiveActivityRegistration({ | ||
| 150 | userId: "dev:julius", | ||
| 151 | deviceId: "device-1", | ||
| 152 | }); | ||
| 153 | }).pipe( | ||
| 154 | Effect.provide( | ||
| 155 | AgentActivityPublisher.layer.pipe( | ||
| 156 | Layer.provide( | ||
| 157 | Layer.mergeAll( | ||
| 158 | Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), | ||
| 159 | Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), | ||
| 160 | Layer.succeed( | ||
| 161 | LiveActivities.LiveActivities, | ||
| 162 | makeLiveActivities({ | ||
| 163 | listTargets: () => Effect.succeed([registeredTarget, target("device-2")]), | ||
| 164 | }), | ||
| 165 | ), | ||
| 166 | Layer.succeed( | ||
| 167 | ApnsDeliveries.ApnsDeliveries, | ||
| 168 | makeApnsDeliveries({ | ||
| 169 | sendForTarget: (input) => | ||
| 170 | Effect.sync(() => { | ||
| 171 | sent.push(input); | ||
| 172 | return deliveryResult; | ||
| 173 | }), | ||
| 174 | }), | ||
| 175 | ), | ||
| 176 | ), | ||
| 177 | ), | ||
| 178 | ), | ||
| 179 | ), | ||
| 180 | ); | ||
| 181 | |||
| 182 | expect(result).toEqual(deliveryResult); | ||
| 183 | expect(sent).toHaveLength(1); | ||
| 184 | expect(sent[0]?.target.device_id).toBe("device-1"); | ||
| 185 | expect(sent[0]?.aggregate).toMatchObject({ | ||
| 186 | activeCount: 1, | ||
| 187 | activities: [ | ||
| 188 | { | ||
| 189 | environmentId: state.environmentId, | ||
| 190 | threadId: state.threadId, | ||
| 191 | status: "Working", | ||
| 192 | }, | ||
| 193 | ], | ||
| 194 | }); | ||
| 195 | }); | ||
| 196 | }); | ||
| 197 | |||
| 198 | it.effect("publishes listed targets through the APNs delivery service", () => { | ||
| 199 | const firstTarget = target("device-1"); | ||
| 200 | const secondTarget = target("device-2"); | ||
| 201 | const deliveryResult: RelayDeliveryResult = { | ||
| 202 | deviceId: "device-1", | ||
| 203 | kind: "live_activity_start", | ||
| 204 | ok: true, | ||
| 205 | apnsStatus: 200, | ||
| 206 | apnsReason: null, | ||
| 207 | apnsId: "apns-id", | ||
| 208 | }; | ||
| 209 | const sentTargets: Array<string> = []; | ||
| 210 | const deliveryLookups: Array<{ | ||
| 211 | readonly environmentId: string; | ||
| 212 | readonly environmentPublicKey: string; | ||
| 213 | }> = []; | ||
| 214 | const upserts: Array<Parameters<AgentActivityRows.AgentActivityRowsShape["upsert"]>[0]> = []; | ||
| 215 | |||
| 216 | return Effect.gen(function* () { | ||
| 217 | const result = yield* Effect.gen(function* () { | ||
| 218 | const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; | ||
| 219 | return yield* publisher.publish({ | ||
| 220 | environmentId: "env", | ||
| 221 | environmentPublicKey: "environment-public-key", | ||
| 222 | threadId: "thread", | ||
| 223 | state, | ||
| 224 | }); | ||
| 225 | }).pipe( | ||
| 226 | Effect.provide( | ||
| 227 | AgentActivityPublisher.layer.pipe( | ||
| 228 | Layer.provide( | ||
| 229 | Layer.mergeAll( | ||
| 230 | Layer.succeed( | ||
| 231 | AgentActivityRows.AgentActivityRows, | ||
| 232 | makeAgentActivityRows({ | ||
| 233 | upsert: (input) => | ||
| 234 | Effect.sync(() => { | ||
| 235 | upserts.push(input); | ||
| 236 | }), | ||
| 237 | }), | ||
| 238 | ), | ||
| 239 | Layer.succeed( | ||
| 240 | EnvironmentLinks.EnvironmentLinks, | ||
| 241 | makeEnvironmentLinks({ | ||
| 242 | listDeliveryUsersForEnvironment: (input) => | ||
| 243 | Effect.sync(() => { | ||
| 244 | deliveryLookups.push(input); | ||
| 245 | return [ | ||
| 246 | { | ||
| 247 | userId: "dev:julius", | ||
| 248 | notificationsEnabled: true, | ||
| 249 | liveActivitiesEnabled: true, | ||
| 250 | }, | ||
| 251 | ]; | ||
| 252 | }), | ||
| 253 | }), | ||
| 254 | ), | ||
| 255 | Layer.succeed( | ||
| 256 | LiveActivities.LiveActivities, | ||
| 257 | makeLiveActivities({ | ||
| 258 | listTargets: () => Effect.succeed([firstTarget, secondTarget]), | ||
| 259 | }), | ||
| 260 | ), | ||
| 261 | Layer.succeed( | ||
| 262 | ApnsDeliveries.ApnsDeliveries, | ||
| 263 | makeApnsDeliveries({ | ||
| 264 | sendForTarget: (input) => | ||
| 265 | Effect.sync(() => { | ||
| 266 | sentTargets.push(input.target.device_id); | ||
| 267 | return input.target.device_id === "device-1" ? deliveryResult : null; | ||
| 268 | }), | ||
| 269 | }), | ||
| 270 | ), | ||
| 271 | ), | ||
| 272 | ), | ||
| 273 | ), | ||
| 274 | ), | ||
| 275 | ); | ||
| 276 | |||
| 277 | expect(sentTargets).toEqual(["device-1", "device-2"]); | ||
| 278 | expect(deliveryLookups).toEqual([ | ||
| 279 | { | ||
| 280 | environmentId: "env", | ||
| 281 | environmentPublicKey: "environment-public-key", | ||
| 282 | }, | ||
| 283 | ]); | ||
| 284 | expect(upserts).toMatchObject([ | ||
| 285 | { | ||
| 286 | environmentPublicKey: "environment-public-key", | ||
| 287 | state: { | ||
| 288 | environmentId: "env", | ||
| 289 | threadId: "thread", | ||
| 290 | }, | ||
| 291 | }, | ||
| 292 | ]); | ||
| 293 | expect(result).toEqual({ ok: true, deliveries: [deliveryResult] }); | ||
| 294 | }); | ||
| 295 | }); | ||
| 296 | |||
| 297 | it.effect("ends the last remote Live Activity with a terminal content state", () => { | ||
| 298 | const completedState: RelayAgentActivityState = { | ||
| 299 | ...state, | ||
| 300 | phase: "completed", | ||
| 301 | headline: "Done", | ||
| 302 | updatedAt: "1970-01-01T00:00:10.000Z", | ||
| 303 | }; | ||
| 304 | const sentAggregates: Array< | ||
| 305 | Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendForTarget"]>[0] | ||
| 306 | > = []; | ||
| 307 | const removals: Array<Parameters<AgentActivityRows.AgentActivityRowsShape["remove"]>[0]> = []; | ||
| 308 | |||
| 309 | return Effect.gen(function* () { | ||
| 310 | const result = yield* Effect.gen(function* () { | ||
| 311 | const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; | ||
| 312 | return yield* publisher.publish({ | ||
| 313 | environmentId: "env", | ||
| 314 | environmentPublicKey: "environment-public-key", | ||
| 315 | threadId: "thread", | ||
| 316 | state: completedState, | ||
| 317 | }); | ||
| 318 | }).pipe( | ||
| 319 | Effect.provide( | ||
| 320 | AgentActivityPublisher.layer.pipe( | ||
| 321 | Layer.provide( | ||
| 322 | Layer.mergeAll( | ||
| 323 | Layer.succeed( | ||
| 324 | AgentActivityRows.AgentActivityRows, | ||
| 325 | makeAgentActivityRows({ | ||
| 326 | remove: (input) => | ||
| 327 | Effect.sync(() => { | ||
| 328 | removals.push(input); | ||
| 329 | }), | ||
| 330 | listForUser: () => Effect.succeed([]), | ||
| 331 | }), | ||
| 332 | ), | ||
| 333 | Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), | ||
| 334 | Layer.succeed( | ||
| 335 | LiveActivities.LiveActivities, | ||
| 336 | makeLiveActivities({ | ||
| 337 | listTargets: () => | ||
| 338 | Effect.succeed([ | ||
| 339 | { | ||
| 340 | ...target("device-1"), | ||
| 341 | push_to_start_token: null, | ||
| 342 | activity_push_token: "activity-token", | ||
| 343 | remote_started_at: "1970-01-01T00:00:00.000Z", | ||
| 344 | }, | ||
| 345 | ]), | ||
| 346 | }), | ||
| 347 | ), | ||
| 348 | Layer.succeed( | ||
| 349 | ApnsDeliveries.ApnsDeliveries, | ||
| 350 | makeApnsDeliveries({ | ||
| 351 | sendForTarget: (input) => | ||
| 352 | Effect.sync(() => { | ||
| 353 | sentAggregates.push(input); | ||
| 354 | return { | ||
| 355 | deviceId: input.target.device_id, | ||
| 356 | kind: "live_activity_end", | ||
| 357 | ok: true, | ||
| 358 | apnsStatus: null, | ||
| 359 | apnsReason: null, | ||
| 360 | apnsId: "queued", | ||
| 361 | }; | ||
| 362 | }), | ||
| 363 | }), | ||
| 364 | ), | ||
| 365 | ), | ||
| 366 | ), | ||
| 367 | ), | ||
| 368 | ), | ||
| 369 | ); | ||
| 370 | |||
| 371 | expect(result.deliveries).toMatchObject([ | ||
| 372 | { | ||
| 373 | deviceId: "device-1", | ||
| 374 | kind: "live_activity_end", | ||
| 375 | ok: true, | ||
| 376 | }, | ||
| 377 | ]); | ||
| 378 | expect(removals).toEqual([ | ||
| 379 | { | ||
| 380 | environmentId: "env", | ||
| 381 | environmentPublicKey: "environment-public-key", | ||
| 382 | threadId: "thread", | ||
| 383 | }, | ||
| 384 | ]); | ||
| 385 | expect(sentAggregates).toHaveLength(1); | ||
| 386 | expect(sentAggregates[0]?.aggregate).toMatchObject({ | ||
| 387 | activeCount: 0, | ||
| 388 | subtitle: "Agent work completed", | ||
| 389 | activities: [ | ||
| 390 | { | ||
| 391 | environmentId: completedState.environmentId, | ||
| 392 | threadId: completedState.threadId, | ||
| 393 | phase: "completed", | ||
| 394 | status: "Done", | ||
| 395 | }, | ||
| 396 | ], | ||
| 397 | }); | ||
| 398 | }); | ||
| 399 | }); | ||
| 400 | |||
| 401 | it.effect("queues push notifications for notification-only environment links", () => { | ||
| 402 | const notificationState: RelayAgentActivityState = { | ||
| 403 | ...state, | ||
| 404 | phase: "waiting_for_input", | ||
| 405 | headline: "Needs input", | ||
| 406 | }; | ||
| 407 | const liveAggregates: Array< | ||
| 408 | Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendForTarget"]>[0] | ||
| 409 | > = []; | ||
| 410 | const pushAggregates: Array< | ||
| 411 | Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendPushNotificationForTarget"]>[0] | ||
| 412 | > = []; | ||
| 413 | |||
| 414 | return Effect.gen(function* () { | ||
| 415 | const result = yield* Effect.gen(function* () { | ||
| 416 | const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; | ||
| 417 | return yield* publisher.publish({ | ||
| 418 | environmentId: "env", | ||
| 419 | environmentPublicKey: "environment-public-key", | ||
| 420 | threadId: "thread", | ||
| 421 | state: notificationState, | ||
| 422 | }); | ||
| 423 | }).pipe( | ||
| 424 | Effect.provide( | ||
| 425 | AgentActivityPublisher.layer.pipe( | ||
| 426 | Layer.provide( | ||
| 427 | Layer.mergeAll( | ||
| 428 | Layer.succeed( | ||
| 429 | AgentActivityRows.AgentActivityRows, | ||
| 430 | makeAgentActivityRows({ | ||
| 431 | listForUser: () => Effect.succeed([]), | ||
| 432 | }), | ||
| 433 | ), | ||
| 434 | Layer.succeed( | ||
| 435 | EnvironmentLinks.EnvironmentLinks, | ||
| 436 | makeEnvironmentLinks({ | ||
| 437 | listDeliveryUsersForEnvironment: () => | ||
| 438 | Effect.succeed([ | ||
| 439 | { | ||
| 440 | userId: "dev:julius", | ||
| 441 | notificationsEnabled: true, | ||
| 442 | liveActivitiesEnabled: false, | ||
| 443 | }, | ||
| 444 | ]), | ||
| 445 | }), | ||
| 446 | ), | ||
| 447 | Layer.succeed( | ||
| 448 | LiveActivities.LiveActivities, | ||
| 449 | makeLiveActivities({ | ||
| 450 | listTargets: () => | ||
| 451 | Effect.succeed([ | ||
| 452 | { | ||
| 453 | ...target("device-1"), | ||
| 454 | push_token: "apns-device-token", | ||
| 455 | push_to_start_token: null, | ||
| 456 | }, | ||
| 457 | ]), | ||
| 458 | }), | ||
| 459 | ), | ||
| 460 | Layer.succeed( | ||
| 461 | ApnsDeliveries.ApnsDeliveries, | ||
| 462 | makeApnsDeliveries({ | ||
| 463 | sendForTarget: (input) => | ||
| 464 | Effect.sync(() => { | ||
| 465 | liveAggregates.push(input); | ||
| 466 | return null; | ||
| 467 | }), | ||
| 468 | sendPushNotificationForTarget: (input) => | ||
| 469 | Effect.sync(() => { | ||
| 470 | pushAggregates.push(input); | ||
| 471 | return { | ||
| 472 | deviceId: input.target.device_id, | ||
| 473 | kind: "push_notification", | ||
| 474 | ok: true, | ||
| 475 | queued: true, | ||
| 476 | apnsStatus: null, | ||
| 477 | apnsReason: null, | ||
| 478 | apnsId: null, | ||
| 479 | }; | ||
| 480 | }), | ||
| 481 | }), | ||
| 482 | ), | ||
| 483 | ), | ||
| 484 | ), | ||
| 485 | ), | ||
| 486 | ), | ||
| 487 | ); | ||
| 488 | |||
| 489 | expect(liveAggregates).toMatchObject([{ aggregate: null }]); | ||
| 490 | expect(pushAggregates).toHaveLength(1); | ||
| 491 | expect(pushAggregates[0]?.aggregate).toMatchObject({ | ||
| 492 | activeCount: 1, | ||
| 493 | activities: [ | ||
| 494 | { | ||
| 495 | phase: "waiting_for_input", | ||
| 496 | status: "Input", | ||
| 497 | threadId: notificationState.threadId, | ||
| 498 | }, | ||
| 499 | ], | ||
| 500 | }); | ||
| 501 | expect(result.deliveries).toMatchObject([ | ||
| 502 | { | ||
| 503 | deviceId: "device-1", | ||
| 504 | kind: "push_notification", | ||
| 505 | queued: true, | ||
| 506 | }, | ||
| 507 | ]); | ||
| 508 | }); | ||
| 509 | }); | ||
| 510 | |||
| 511 | it.effect( | ||
| 512 | "does not build Live Activity aggregates for links with Live Activities disabled", | ||
| 513 | () => { | ||
| 514 | const notificationState: RelayAgentActivityState = { | ||
| 515 | ...state, | ||
| 516 | phase: "waiting_for_approval", | ||
| 517 | headline: "Needs approval", | ||
| 518 | }; | ||
| 519 | const liveAggregates: Array< | ||
| 520 | Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendForTarget"]>[0] | ||
| 521 | > = []; | ||
| 522 | const pushAggregates: Array< | ||
| 523 | Parameters<ApnsDeliveries.ApnsDeliveriesShape["sendPushNotificationForTarget"]>[0] | ||
| 524 | > = []; | ||
| 525 | |||
| 526 | return Effect.gen(function* () { | ||
| 527 | const result = yield* Effect.gen(function* () { | ||
| 528 | const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; | ||
| 529 | return yield* publisher.publish({ | ||
| 530 | environmentId: "env", | ||
| 531 | environmentPublicKey: "environment-public-key", | ||
| 532 | threadId: "thread", | ||
| 533 | state: notificationState, | ||
| 534 | }); | ||
| 535 | }).pipe( | ||
| 536 | Effect.provide( | ||
| 537 | AgentActivityPublisher.layer.pipe( | ||
| 538 | Layer.provide( | ||
| 539 | Layer.mergeAll( | ||
| 540 | Layer.succeed( | ||
| 541 | AgentActivityRows.AgentActivityRows, | ||
| 542 | makeAgentActivityRows({ | ||
| 543 | listForUser: () => | ||
| 544 | Effect.succeed([ | ||
| 545 | { | ||
| 546 | ...state, | ||
| 547 | environmentId: "other-env" as RelayAgentActivityState["environmentId"], | ||
| 548 | threadId: "other-thread" as RelayAgentActivityState["threadId"], | ||
| 549 | }, | ||
| 550 | ]), | ||
| 551 | }), | ||
| 552 | ), | ||
| 553 | Layer.succeed( | ||
| 554 | EnvironmentLinks.EnvironmentLinks, | ||
| 555 | makeEnvironmentLinks({ | ||
| 556 | listDeliveryUsersForEnvironment: () => | ||
| 557 | Effect.succeed([ | ||
| 558 | { | ||
| 559 | userId: "dev:julius", | ||
| 560 | notificationsEnabled: true, | ||
| 561 | liveActivitiesEnabled: false, | ||
| 562 | }, | ||
| 563 | ]), | ||
| 564 | }), | ||
| 565 | ), | ||
| 566 | Layer.succeed( | ||
| 567 | LiveActivities.LiveActivities, | ||
| 568 | makeLiveActivities({ | ||
| 569 | listTargets: () => | ||
| 570 | Effect.succeed([ | ||
| 571 | { | ||
| 572 | ...target("device-1"), | ||
| 573 | push_token: "apns-device-token", | ||
| 574 | push_to_start_token: "push-to-start-token", | ||
| 575 | }, | ||
| 576 | ]), | ||
| 577 | }), | ||
| 578 | ), | ||
| 579 | Layer.succeed( | ||
| 580 | ApnsDeliveries.ApnsDeliveries, | ||
| 581 | makeApnsDeliveries({ | ||
| 582 | sendForTarget: (input) => | ||
| 583 | Effect.sync(() => { | ||
| 584 | liveAggregates.push(input); | ||
| 585 | return null; | ||
| 586 | }), | ||
| 587 | sendPushNotificationForTarget: (input) => | ||
| 588 | Effect.sync(() => { | ||
| 589 | pushAggregates.push(input); | ||
| 590 | return { | ||
| 591 | deviceId: input.target.device_id, | ||
| 592 | kind: "push_notification", | ||
| 593 | ok: true, | ||
| 594 | queued: true, | ||
| 595 | apnsStatus: null, | ||
| 596 | apnsReason: null, | ||
| 597 | apnsId: null, | ||
| 598 | }; | ||
| 599 | }), | ||
| 600 | }), | ||
| 601 | ), | ||
| 602 | ), | ||
| 603 | ), | ||
| 604 | ), | ||
| 605 | ), | ||
| 606 | ); | ||
| 607 | |||
| 608 | expect(liveAggregates).toMatchObject([{ aggregate: null }]); | ||
| 609 | expect(pushAggregates).toHaveLength(1); | ||
| 610 | expect(pushAggregates[0]?.aggregate?.activities).toMatchObject([ | ||
| 611 | { | ||
| 612 | environmentId: notificationState.environmentId, | ||
| 613 | threadId: notificationState.threadId, | ||
| 614 | phase: "waiting_for_approval", | ||
| 615 | }, | ||
| 616 | ]); | ||
| 617 | expect(result.deliveries).toMatchObject([ | ||
| 618 | { | ||
| 619 | deviceId: "device-1", | ||
| 620 | kind: "push_notification", | ||
| 621 | queued: true, | ||
| 622 | }, | ||
| 623 | ]); | ||
| 624 | }); | ||
| 625 | }, | ||
| 626 | ); | ||
| 627 | }); | ||
infra/relay/src/agentActivity/AgentActivityPublisher.ts created+230| ... | @@ -0,0 +1,230 @@ | ||
| 1 | import type { | ||
| 2 | RelayAgentActivityAggregateState, | ||
| 3 | RelayAgentActivityState, | ||
| 4 | RelayDeliveryResult, | ||
| 5 | RelayPublishResponse, | ||
| 6 | } from "@t3tools/contracts/relay"; | ||
| 7 | import * as Context from "effect/Context"; | ||
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | |||
| 12 | import { sanitizeAgentActivityAggregateState } from "./agentActivityPayloads.ts"; | ||
| 13 | import * as AgentActivityRows from "./AgentActivityRows.ts"; | ||
| 14 | import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; | ||
| 15 | import * as LiveActivities from "./LiveActivities.ts"; | ||
| 16 | import * as ApnsDeliveries from "./ApnsDeliveries.ts"; | ||
| 17 | |||
| 18 | export type AgentActivityPublishError = | ||
| 19 | | AgentActivityRows.AgentActivityRowUpsertPersistenceError | ||
| 20 | | AgentActivityRows.AgentActivityRowDeletePersistenceError | ||
| 21 | | AgentActivityRows.AgentActivityRowListPersistenceError | ||
| 22 | | EnvironmentLinks.EnvironmentLinkUserListPersistenceError | ||
| 23 | | LiveActivities.LiveActivityTargetListPersistenceError | ||
| 24 | | ApnsDeliveries.ApnsDeliveryError; | ||
| 25 | |||
| 26 | export interface AgentActivityPublisherShape { | ||
| 27 | readonly publish: (input: { | ||
| 28 | readonly environmentId: string; | ||
| 29 | readonly environmentPublicKey: string; | ||
| 30 | readonly threadId: string; | ||
| 31 | readonly state: RelayAgentActivityState | null; | ||
| 32 | }) => Effect.Effect<RelayPublishResponse, AgentActivityPublishError>; | ||
| 33 | readonly replayForLiveActivityRegistration: (input: { | ||
| 34 | readonly userId: string; | ||
| 35 | readonly deviceId: string; | ||
| 36 | }) => Effect.Effect<RelayDeliveryResult | null, AgentActivityPublishError>; | ||
| 37 | } | ||
| 38 | |||
| 39 | export class AgentActivityPublisher extends Context.Service< | ||
| 40 | AgentActivityPublisher, | ||
| 41 | AgentActivityPublisherShape | ||
| 42 | >()("t3code-relay/agentActivity/AgentActivityPublisher") {} | ||
| 43 | |||
| 44 | const make = Effect.gen(function* () { | ||
| 45 | const rows = yield* AgentActivityRows.AgentActivityRows; | ||
| 46 | const links = yield* EnvironmentLinks.EnvironmentLinks; | ||
| 47 | const liveActivities = yield* LiveActivities.LiveActivities; | ||
| 48 | const apnsDeliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 49 | |||
| 50 | const publishForDeliveryUser = Effect.fnUntraced(function* (input: { | ||
| 51 | readonly deliveryUser: EnvironmentLinks.AgentAwarenessDeliveryUserRecord; | ||
| 52 | readonly state: RelayAgentActivityState | null; | ||
| 53 | readonly nowMs: number; | ||
| 54 | }) { | ||
| 55 | const activeStates = yield* rows.listForUser({ userId: input.deliveryUser.userId }); | ||
| 56 | const liveActivityAggregate = input.deliveryUser.liveActivitiesEnabled | ||
| 57 | ? makeAggregateState({ | ||
| 58 | activeStates, | ||
| 59 | terminalState: input.state && isTerminalPhase(input.state) ? input.state : null, | ||
| 60 | }) | ||
| 61 | : null; | ||
| 62 | const notificationOnlyAggregate = | ||
| 63 | input.deliveryUser.notificationsEnabled && | ||
| 64 | !input.deliveryUser.liveActivitiesEnabled && | ||
| 65 | input.state !== null | ||
| 66 | ? makeAggregateState({ | ||
| 67 | activeStates: isTerminalPhase(input.state) ? [] : [input.state], | ||
| 68 | terminalState: isTerminalPhase(input.state) ? input.state : null, | ||
| 69 | }) | ||
| 70 | : null; | ||
| 71 | const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId }); | ||
| 72 | const deliveriesByTarget = yield* Effect.forEach( | ||
| 73 | targets, | ||
| 74 | (target) => | ||
| 75 | Effect.all( | ||
| 76 | [ | ||
| 77 | apnsDeliveries.sendForTarget({ | ||
| 78 | target, | ||
| 79 | aggregate: liveActivityAggregate, | ||
| 80 | nowMs: input.nowMs, | ||
| 81 | }), | ||
| 82 | notificationOnlyAggregate === null | ||
| 83 | ? Effect.succeed(null) | ||
| 84 | : apnsDeliveries.sendPushNotificationForTarget({ | ||
| 85 | target, | ||
| 86 | aggregate: notificationOnlyAggregate, | ||
| 87 | }), | ||
| 88 | ], | ||
| 89 | { concurrency: 2 }, | ||
| 90 | ), | ||
| 91 | { concurrency: 4 }, | ||
| 92 | ); | ||
| 93 | return deliveriesByTarget.flat(); | ||
| 94 | }); | ||
| 95 | |||
| 96 | return AgentActivityPublisher.of({ | ||
| 97 | replayForLiveActivityRegistration: Effect.fn( | ||
| 98 | "relay.agent_activity_publisher.replay_for_live_activity_registration", | ||
| 99 | )(function* (input) { | ||
| 100 | yield* Effect.annotateCurrentSpan({ | ||
| 101 | "relay.mobile.device_id": input.deviceId, | ||
| 102 | "relay.operation": "replayForLiveActivityRegistration", | ||
| 103 | }); | ||
| 104 | const activeStates = yield* rows.listForUser({ userId: input.userId }); | ||
| 105 | const targets = yield* liveActivities.listTargets({ userId: input.userId }); | ||
| 106 | const target = targets.find((row) => row.device_id === input.deviceId) ?? null; | ||
| 107 | if (target === null) { | ||
| 108 | return null; | ||
| 109 | } | ||
| 110 | const aggregate = makeAggregateState({ activeStates, terminalState: null }); | ||
| 111 | const now = yield* DateTime.now; | ||
| 112 | return yield* apnsDeliveries.sendForTarget({ | ||
| 113 | target, | ||
| 114 | aggregate, | ||
| 115 | nowMs: now.epochMilliseconds, | ||
| 116 | }); | ||
| 117 | }), | ||
| 118 | publish: Effect.fn("relay.agent_activity_publisher.publish")(function* (input) { | ||
| 119 | yield* Effect.annotateCurrentSpan({ | ||
| 120 | "relay.environment_id": input.environmentId, | ||
| 121 | "relay.thread_id": input.threadId, | ||
| 122 | "relay.agent_activity.phase": input.state?.phase ?? "deleted", | ||
| 123 | }); | ||
| 124 | if (input.state && !isTerminalPhase(input.state)) { | ||
| 125 | yield* rows.upsert({ | ||
| 126 | environmentPublicKey: input.environmentPublicKey, | ||
| 127 | state: input.state, | ||
| 128 | }); | ||
| 129 | } else { | ||
| 130 | yield* rows.remove({ | ||
| 131 | environmentId: input.environmentId, | ||
| 132 | environmentPublicKey: input.environmentPublicKey, | ||
| 133 | threadId: input.threadId, | ||
| 134 | }); | ||
| 135 | } | ||
| 136 | |||
| 137 | const deliveryUsers = yield* links.listDeliveryUsersForEnvironment({ | ||
| 138 | environmentId: input.environmentId, | ||
| 139 | environmentPublicKey: input.environmentPublicKey, | ||
| 140 | }); | ||
| 141 | const now = yield* DateTime.now; | ||
| 142 | const deliveriesByUser = yield* Effect.forEach( | ||
| 143 | deliveryUsers, | ||
| 144 | (deliveryUser) => | ||
| 145 | publishForDeliveryUser({ | ||
| 146 | deliveryUser, | ||
| 147 | state: input.state, | ||
| 148 | nowMs: now.epochMilliseconds, | ||
| 149 | }), | ||
| 150 | { concurrency: 4 }, | ||
| 151 | ); | ||
| 152 | const deliveries = deliveriesByUser.flat(); | ||
| 153 | return { | ||
| 154 | ok: true, | ||
| 155 | deliveries: deliveries.filter( | ||
| 156 | (delivery): delivery is RelayDeliveryResult => delivery !== null, | ||
| 157 | ), | ||
| 158 | }; | ||
| 159 | }), | ||
| 160 | }); | ||
| 161 | }); | ||
| 162 | |||
| 163 | function statusForPhase(phase: RelayAgentActivityState["phase"]): string { | ||
| 164 | switch (phase) { | ||
| 165 | case "waiting_for_approval": | ||
| 166 | return "Approval"; | ||
| 167 | case "waiting_for_input": | ||
| 168 | return "Input"; | ||
| 169 | case "completed": | ||
| 170 | return "Done"; | ||
| 171 | case "failed": | ||
| 172 | return "Failed"; | ||
| 173 | case "starting": | ||
| 174 | return "Starting"; | ||
| 175 | case "running": | ||
| 176 | return "Working"; | ||
| 177 | case "stale": | ||
| 178 | return "Waiting"; | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | function isTerminalPhase(state: RelayAgentActivityState): boolean { | ||
| 183 | return state.phase === "completed" || state.phase === "failed"; | ||
| 184 | } | ||
| 185 | |||
| 186 | function aggregateRowForState(state: RelayAgentActivityState) { | ||
| 187 | return { | ||
| 188 | environmentId: state.environmentId, | ||
| 189 | threadId: state.threadId, | ||
| 190 | projectTitle: state.projectTitle, | ||
| 191 | threadTitle: state.threadTitle, | ||
| 192 | modelTitle: state.modelTitle, | ||
| 193 | phase: state.phase, | ||
| 194 | status: statusForPhase(state.phase), | ||
| 195 | updatedAt: state.updatedAt, | ||
| 196 | deepLink: state.deepLink, | ||
| 197 | }; | ||
| 198 | } | ||
| 199 | |||
| 200 | function terminalAggregateState(state: RelayAgentActivityState): RelayAgentActivityAggregateState { | ||
| 201 | return sanitizeAgentActivityAggregateState({ | ||
| 202 | title: "T3 Code", | ||
| 203 | subtitle: state.phase === "failed" ? "Agent work failed" : "Agent work completed", | ||
| 204 | activeCount: 0, | ||
| 205 | updatedAt: state.updatedAt, | ||
| 206 | activities: [aggregateRowForState(state)], | ||
| 207 | }); | ||
| 208 | } | ||
| 209 | |||
| 210 | function makeAggregateState(input: { | ||
| 211 | readonly activeStates: ReadonlyArray<RelayAgentActivityState>; | ||
| 212 | readonly terminalState: RelayAgentActivityState | null; | ||
| 213 | }): RelayAgentActivityAggregateState | null { | ||
| 214 | const activeStates = input.activeStates.filter((state) => !isTerminalPhase(state)); | ||
| 215 | if (activeStates.length === 0) { | ||
| 216 | return input.terminalState === null ? null : terminalAggregateState(input.terminalState); | ||
| 217 | } | ||
| 218 | const updatedAt = activeStates.reduce((latest, state) => | ||
| 219 | state.updatedAt.localeCompare(latest.updatedAt) > 0 ? state : latest, | ||
| 220 | ).updatedAt; | ||
| 221 | return sanitizeAgentActivityAggregateState({ | ||
| 222 | title: "T3 Code", | ||
| 223 | subtitle: "Agent work in progress", | ||
| 224 | activeCount: activeStates.length, | ||
| 225 | updatedAt, | ||
| 226 | activities: activeStates.slice(0, 3).map(aggregateRowForState), | ||
| 227 | }); | ||
| 228 | } | ||
| 229 | |||
| 230 | export const layer = Layer.effect(AgentActivityPublisher, make); | ||
infra/relay/src/agentActivity/AgentActivityRows.ts created+158| ... | @@ -0,0 +1,158 @@ | ||
| 1 | import type { RelayAgentActivityState } from "@t3tools/contracts/relay"; | ||
| 2 | import { RelayAgentActivityState as RelayAgentActivityStateSchema } from "@t3tools/contracts/relay"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Data from "effect/Data"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import { cast } from "effect/Function"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import { and, desc, eq, isNull } from "drizzle-orm"; | ||
| 12 | |||
| 13 | import { RelayDb } from "../db.ts"; | ||
| 14 | import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; | ||
| 15 | |||
| 16 | export class AgentActivityRowUpsertPersistenceError extends Data.TaggedError( | ||
| 17 | "AgentActivityRowUpsertPersistenceError", | ||
| 18 | )<{ | ||
| 19 | readonly cause: unknown; | ||
| 20 | }> {} | ||
| 21 | |||
| 22 | export class AgentActivityRowDeletePersistenceError extends Data.TaggedError( | ||
| 23 | "AgentActivityRowDeletePersistenceError", | ||
| 24 | )<{ | ||
| 25 | readonly cause: unknown; | ||
| 26 | }> {} | ||
| 27 | |||
| 28 | export class AgentActivityRowListPersistenceError extends Data.TaggedError( | ||
| 29 | "AgentActivityRowListPersistenceError", | ||
| 30 | )<{ | ||
| 31 | readonly cause: unknown; | ||
| 32 | }> {} | ||
| 33 | |||
| 34 | export interface AgentActivityRowsShape { | ||
| 35 | readonly upsert: (input: { | ||
| 36 | readonly environmentPublicKey: string; | ||
| 37 | readonly state: RelayAgentActivityState; | ||
| 38 | }) => Effect.Effect<void, AgentActivityRowUpsertPersistenceError>; | ||
| 39 | readonly remove: (input: { | ||
| 40 | readonly environmentId: string; | ||
| 41 | readonly environmentPublicKey: string; | ||
| 42 | readonly threadId: string; | ||
| 43 | }) => Effect.Effect<void, AgentActivityRowDeletePersistenceError>; | ||
| 44 | readonly listForUser: (input: { | ||
| 45 | readonly userId: string; | ||
| 46 | }) => Effect.Effect<ReadonlyArray<RelayAgentActivityState>, AgentActivityRowListPersistenceError>; | ||
| 47 | } | ||
| 48 | |||
| 49 | export class AgentActivityRows extends Context.Service<AgentActivityRows, AgentActivityRowsShape>()( | ||
| 50 | "t3code-relay/agentActivity/AgentActivityRows", | ||
| 51 | ) {} | ||
| 52 | |||
| 53 | const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); | ||
| 54 | const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); | ||
| 55 | |||
| 56 | const encodeRelayAgentActivityStateJson = Schema.encodeEffect( | ||
| 57 | Schema.fromJsonString(RelayAgentActivityStateSchema), | ||
| 58 | ); | ||
| 59 | |||
| 60 | const decodeRelayAgentActivityStateJson = Schema.decodeUnknownOption( | ||
| 61 | Schema.fromJsonString(RelayAgentActivityStateSchema), | ||
| 62 | ); | ||
| 63 | |||
| 64 | const make = Effect.gen(function* () { | ||
| 65 | const db = yield* RelayDb; | ||
| 66 | |||
| 67 | return AgentActivityRows.of({ | ||
| 68 | upsert: Effect.fn("relay.agent_activity_rows.upsert")( | ||
| 69 | function* (input) { | ||
| 70 | yield* Effect.annotateCurrentSpan({ | ||
| 71 | "relay.environment_id": input.state.environmentId, | ||
| 72 | "relay.thread_id": input.state.threadId, | ||
| 73 | }); | ||
| 74 | const now = yield* DateTime.now; | ||
| 75 | const stateJson = yield* encodeRelayAgentActivityStateJson(input.state).pipe( | ||
| 76 | Effect.flatMap(decodeJsonString), | ||
| 77 | Effect.map(cast<unknown, RelayAgentActivityState>), | ||
| 78 | ); | ||
| 79 | yield* db | ||
| 80 | .insert(relayAgentActivityRows) | ||
| 81 | .values({ | ||
| 82 | environmentId: input.state.environmentId, | ||
| 83 | environmentPublicKey: input.environmentPublicKey, | ||
| 84 | threadId: input.state.threadId, | ||
| 85 | stateJson, | ||
| 86 | updatedAt: input.state.updatedAt, | ||
| 87 | createdAt: DateTime.formatIso(now), | ||
| 88 | }) | ||
| 89 | .onConflictDoUpdate({ | ||
| 90 | target: [ | ||
| 91 | relayAgentActivityRows.environmentId, | ||
| 92 | relayAgentActivityRows.environmentPublicKey, | ||
| 93 | relayAgentActivityRows.threadId, | ||
| 94 | ], | ||
| 95 | set: { | ||
| 96 | stateJson, | ||
| 97 | updatedAt: input.state.updatedAt, | ||
| 98 | }, | ||
| 99 | }); | ||
| 100 | }, | ||
| 101 | Effect.mapError((cause) => new AgentActivityRowUpsertPersistenceError({ cause })), | ||
| 102 | ), | ||
| 103 | |||
| 104 | remove: Effect.fn("relay.agent_activity_rows.remove")(function* (input) { | ||
| 105 | yield* Effect.annotateCurrentSpan({ | ||
| 106 | "relay.environment_id": input.environmentId, | ||
| 107 | "relay.thread_id": input.threadId, | ||
| 108 | }); | ||
| 109 | yield* db | ||
| 110 | .delete(relayAgentActivityRows) | ||
| 111 | .where( | ||
| 112 | and( | ||
| 113 | eq(relayAgentActivityRows.environmentId, input.environmentId), | ||
| 114 | eq(relayAgentActivityRows.environmentPublicKey, input.environmentPublicKey), | ||
| 115 | eq(relayAgentActivityRows.threadId, input.threadId), | ||
| 116 | ), | ||
| 117 | ) | ||
| 118 | .pipe(Effect.mapError((cause) => new AgentActivityRowDeletePersistenceError({ cause }))); | ||
| 119 | }), | ||
| 120 | |||
| 121 | listForUser: Effect.fn("relay.agent_activity_rows.list_for_user")(function* (input) { | ||
| 122 | return yield* db | ||
| 123 | .select({ stateJson: relayAgentActivityRows.stateJson }) | ||
| 124 | .from(relayAgentActivityRows) | ||
| 125 | .innerJoin( | ||
| 126 | relayEnvironmentLinks, | ||
| 127 | and( | ||
| 128 | eq(relayEnvironmentLinks.environmentId, relayAgentActivityRows.environmentId), | ||
| 129 | eq( | ||
| 130 | relayEnvironmentLinks.environmentPublicKey, | ||
| 131 | relayAgentActivityRows.environmentPublicKey, | ||
| 132 | ), | ||
| 133 | ), | ||
| 134 | ) | ||
| 135 | .where( | ||
| 136 | and( | ||
| 137 | eq(relayEnvironmentLinks.userId, input.userId), | ||
| 138 | isNull(relayEnvironmentLinks.revokedAt), | ||
| 139 | eq(relayEnvironmentLinks.liveActivitiesEnabled, true), | ||
| 140 | ), | ||
| 141 | ) | ||
| 142 | .orderBy(desc(relayAgentActivityRows.updatedAt)) | ||
| 143 | .pipe( | ||
| 144 | Effect.flatMap((rows) => | ||
| 145 | Effect.forEach(rows, (row) => encodeJsonValue(row.stateJson), { | ||
| 146 | concurrency: "unbounded", | ||
| 147 | }), | ||
| 148 | ), | ||
| 149 | Effect.map((rows) => | ||
| 150 | rows.flatMap((row) => Option.toArray(decodeRelayAgentActivityStateJson(row))), | ||
| 151 | ), | ||
| 152 | Effect.mapError((cause) => new AgentActivityRowListPersistenceError({ cause })), | ||
| 153 | ); | ||
| 154 | }), | ||
| 155 | }); | ||
| 156 | }); | ||
| 157 | |||
| 158 | export const layer = Layer.effect(AgentActivityRows, make); | ||
infra/relay/src/agentActivity/ApnsClient.test.ts created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import { HttpClient } from "effect/unstable/http"; | ||
| 6 | |||
| 7 | import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; | ||
| 8 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 9 | import * as ApnsClient from "./ApnsClient.ts"; | ||
| 10 | |||
| 11 | const TestLayer = ApnsClient.layer.pipe( | ||
| 12 | Layer.provide( | ||
| 13 | Layer.succeed( | ||
| 14 | HttpClient.HttpClient, | ||
| 15 | HttpClient.make(() => Effect.die("unexpected APNs HTTP request")), | ||
| 16 | ), | ||
| 17 | ), | ||
| 18 | ); | ||
| 19 | |||
| 20 | describe("ApnsClient", () => { | ||
| 21 | const now = DateTime.makeUnsafe(0); | ||
| 22 | const state: RelayAgentActivityAggregateState = { | ||
| 23 | title: "T3 Code", | ||
| 24 | subtitle: "Agent work in progress", | ||
| 25 | activeCount: 1, | ||
| 26 | updatedAt: DateTime.formatIso(now), | ||
| 27 | activities: [ | ||
| 28 | { | ||
| 29 | environmentId: EnvironmentId.make("env"), | ||
| 30 | threadId: ThreadId.make("thread"), | ||
| 31 | projectTitle: "Project", | ||
| 32 | threadTitle: "Thread", | ||
| 33 | modelTitle: "gpt-5.4", | ||
| 34 | phase: "running" as const, | ||
| 35 | status: "Working", | ||
| 36 | updatedAt: DateTime.formatIso(now), | ||
| 37 | deepLink: "/", | ||
| 38 | }, | ||
| 39 | ], | ||
| 40 | }; | ||
| 41 | |||
| 42 | it.effect("requests an update push token when remotely starting a Live Activity", () => | ||
| 43 | Effect.gen(function* () { | ||
| 44 | const apns = yield* ApnsClient.ApnsClient; | ||
| 45 | const request = apns.makeLiveActivityRequest({ | ||
| 46 | event: "start", | ||
| 47 | token: "token", | ||
| 48 | state, | ||
| 49 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 50 | nowIso: DateTime.formatIso(now), | ||
| 51 | }); | ||
| 52 | |||
| 53 | expect(request.priority).toBe("10"); | ||
| 54 | expect(request.payload).toMatchObject({ | ||
| 55 | aps: { | ||
| 56 | event: "start", | ||
| 57 | "attributes-type": "LiveActivityAttributes", | ||
| 58 | "input-push-token": 1, | ||
| 59 | "content-state": { | ||
| 60 | name: "AgentActivity", | ||
| 61 | }, | ||
| 62 | }, | ||
| 63 | }); | ||
| 64 | }).pipe(Effect.provide(TestLayer)), | ||
| 65 | ); | ||
| 66 | |||
| 67 | it.effect("builds a low-priority update payload", () => | ||
| 68 | Effect.gen(function* () { | ||
| 69 | const apns = yield* ApnsClient.ApnsClient; | ||
| 70 | const request = apns.makeLiveActivityRequest({ | ||
| 71 | event: "update", | ||
| 72 | token: "token", | ||
| 73 | state, | ||
| 74 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 75 | nowIso: DateTime.formatIso(now), | ||
| 76 | }); | ||
| 77 | |||
| 78 | expect(request.priority).toBe("5"); | ||
| 79 | expect(request.payload).toMatchObject({ | ||
| 80 | aps: { | ||
| 81 | event: "update", | ||
| 82 | "content-state": { | ||
| 83 | name: "AgentActivity", | ||
| 84 | }, | ||
| 85 | }, | ||
| 86 | }); | ||
| 87 | }).pipe(Effect.provide(TestLayer)), | ||
| 88 | ); | ||
| 89 | |||
| 90 | it.effect("builds an end payload with a dismissal date", () => | ||
| 91 | Effect.gen(function* () { | ||
| 92 | const apns = yield* ApnsClient.ApnsClient; | ||
| 93 | const request = apns.makeLiveActivityRequest({ | ||
| 94 | event: "end", | ||
| 95 | token: "token", | ||
| 96 | state, | ||
| 97 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 98 | nowIso: DateTime.formatIso(now), | ||
| 99 | }); | ||
| 100 | |||
| 101 | expect(request.priority).toBe("10"); | ||
| 102 | expect(request.payload).toMatchObject({ | ||
| 103 | aps: { | ||
| 104 | event: "end", | ||
| 105 | "dismissal-date": 300, | ||
| 106 | }, | ||
| 107 | }); | ||
| 108 | }).pipe(Effect.provide(TestLayer)), | ||
| 109 | ); | ||
| 110 | |||
| 111 | it.effect("builds a standard APNs alert payload with routing metadata", () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const apns = yield* ApnsClient.ApnsClient; | ||
| 114 | const request = apns.makePushNotificationRequest({ | ||
| 115 | token: "push-token", | ||
| 116 | notification: { | ||
| 117 | title: "Thread", | ||
| 118 | body: "Input: Project", | ||
| 119 | environmentId: "env", | ||
| 120 | threadId: "thread", | ||
| 121 | deepLink: "/threads/env/thread", | ||
| 122 | }, | ||
| 123 | }); | ||
| 124 | |||
| 125 | expect(request.priority).toBe("10"); | ||
| 126 | expect(request.payload).toMatchObject({ | ||
| 127 | aps: { | ||
| 128 | alert: { | ||
| 129 | title: "Thread", | ||
| 130 | body: "Input: Project", | ||
| 131 | }, | ||
| 132 | sound: "default", | ||
| 133 | }, | ||
| 134 | environmentId: "env", | ||
| 135 | threadId: "thread", | ||
| 136 | deepLink: "/threads/env/thread", | ||
| 137 | }); | ||
| 138 | }).pipe(Effect.provide(TestLayer)), | ||
| 139 | ); | ||
| 140 | }); | ||
infra/relay/src/agentActivity/ApnsClient.ts created+320| ... | @@ -0,0 +1,320 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | |||
| 3 | import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; | ||
| 4 | import * as Context from "effect/Context"; | ||
| 5 | import * as Data from "effect/Data"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Encoding from "effect/Encoding"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Redacted from "effect/Redacted"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import { | ||
| 13 | Headers, | ||
| 14 | HttpClient, | ||
| 15 | HttpClientRequest, | ||
| 16 | type HttpBody, | ||
| 17 | type HttpClientError, | ||
| 18 | } from "effect/unstable/http"; | ||
| 19 | import type { ApnsCredentials } from "../Config.ts"; | ||
| 20 | import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; | ||
| 21 | |||
| 22 | const LIVE_ACTIVITY_NAME = "AgentActivity"; | ||
| 23 | const STALE_AFTER_SECONDS = 2 * 60; | ||
| 24 | const DISMISS_AFTER_SECONDS = 5 * 60; | ||
| 25 | |||
| 26 | export type ApnsLiveActivityEvent = "start" | "update" | "end"; | ||
| 27 | |||
| 28 | interface ApnsLiveActivityRequest { | ||
| 29 | readonly token: string; | ||
| 30 | readonly event: ApnsLiveActivityEvent; | ||
| 31 | readonly priority: "5" | "10"; | ||
| 32 | readonly payload: unknown; | ||
| 33 | } | ||
| 34 | |||
| 35 | interface ApnsPushNotificationRequest { | ||
| 36 | readonly token: string; | ||
| 37 | readonly priority: "10"; | ||
| 38 | readonly payload: unknown; | ||
| 39 | } | ||
| 40 | |||
| 41 | export interface ApnsDeliveryResult { | ||
| 42 | readonly ok: boolean; | ||
| 43 | readonly status: number; | ||
| 44 | readonly reason?: string; | ||
| 45 | readonly apnsId: string | null; | ||
| 46 | } | ||
| 47 | |||
| 48 | export class ApnsSigningError extends Data.TaggedError("ApnsSigningError")<{ | ||
| 49 | readonly phase: "encoding" | "signing"; | ||
| 50 | readonly cause: unknown; | ||
| 51 | }> {} | ||
| 52 | |||
| 53 | export class ApnsHttpRequestError extends Data.TaggedError("ApnsHttpRequestError")<{ | ||
| 54 | readonly cause: HttpClientError.HttpClientError | HttpBody.HttpBodyError; | ||
| 55 | }> {} | ||
| 56 | |||
| 57 | export class ApnsInvalidResponseError extends Data.TaggedError("ApnsInvalidResponseError")<{ | ||
| 58 | readonly cause: unknown; | ||
| 59 | }> {} | ||
| 60 | |||
| 61 | export type ApnsError = ApnsSigningError | ApnsHttpRequestError | ApnsInvalidResponseError; | ||
| 62 | |||
| 63 | const decodeApnsErrorResponseJson = Schema.decodeUnknownOption( | ||
| 64 | Schema.fromJsonString( | ||
| 65 | Schema.Struct({ | ||
| 66 | reason: Schema.optional(Schema.String), | ||
| 67 | }), | ||
| 68 | ), | ||
| 69 | ); | ||
| 70 | const encodeApnsJwtHeaderJson = Schema.encodeEffect( | ||
| 71 | Schema.fromJsonString( | ||
| 72 | Schema.Struct({ | ||
| 73 | alg: Schema.Literal("ES256"), | ||
| 74 | kid: Schema.String, | ||
| 75 | }), | ||
| 76 | ), | ||
| 77 | ); | ||
| 78 | const encodeApnsJwtPayloadJson = Schema.encodeEffect( | ||
| 79 | Schema.fromJsonString( | ||
| 80 | Schema.Struct({ | ||
| 81 | iss: Schema.String, | ||
| 82 | iat: Schema.Number, | ||
| 83 | }), | ||
| 84 | ), | ||
| 85 | ); | ||
| 86 | |||
| 87 | const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: { | ||
| 88 | readonly teamId: ApnsCredentials["teamId"]; | ||
| 89 | readonly keyId: ApnsCredentials["keyId"]; | ||
| 90 | readonly privateKey: ApnsCredentials["privateKey"]; | ||
| 91 | readonly issuedAtUnixSeconds: number; | ||
| 92 | }) { | ||
| 93 | const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe( | ||
| 94 | Effect.mapError((cause) => new ApnsSigningError({ cause, phase: "encoding" })), | ||
| 95 | ); | ||
| 96 | const payloadJson = yield* encodeApnsJwtPayloadJson({ | ||
| 97 | iss: input.teamId, | ||
| 98 | iat: input.issuedAtUnixSeconds, | ||
| 99 | }).pipe(Effect.mapError((cause) => new ApnsSigningError({ cause, phase: "encoding" }))); | ||
| 100 | |||
| 101 | const privateKey = Redacted.value(input.privateKey); | ||
| 102 | const header = Encoding.encodeBase64Url(headerJson); | ||
| 103 | const payload = Encoding.encodeBase64Url(payloadJson); | ||
| 104 | const signingInput = `${header}.${payload}`; | ||
| 105 | |||
| 106 | return yield* Effect.try({ | ||
| 107 | try: () => { | ||
| 108 | const signature = NodeCrypto.createSign("sha256") | ||
| 109 | .update(signingInput) | ||
| 110 | .sign({ | ||
| 111 | key: privateKey.replace(/\\n/g, "\n"), | ||
| 112 | dsaEncoding: "ieee-p1363", | ||
| 113 | }); | ||
| 114 | return `${signingInput}.${Encoding.encodeBase64Url(signature)}`; | ||
| 115 | }, | ||
| 116 | catch: (cause) => new ApnsSigningError({ cause, phase: "signing" }), | ||
| 117 | }); | ||
| 118 | }); | ||
| 119 | |||
| 120 | function contentState(state: RelayAgentActivityAggregateState) { | ||
| 121 | return { | ||
| 122 | name: LIVE_ACTIVITY_NAME, | ||
| 123 | props: JSON.stringify(state), | ||
| 124 | }; | ||
| 125 | } | ||
| 126 | |||
| 127 | interface LiveActivityRequestBase { | ||
| 128 | readonly token: string; | ||
| 129 | readonly nowEpochSeconds: number; | ||
| 130 | readonly nowIso: string; | ||
| 131 | } | ||
| 132 | |||
| 133 | type MakeLiveActivityRequestInput = | ||
| 134 | | (LiveActivityRequestBase & { | ||
| 135 | readonly event: "end"; | ||
| 136 | readonly state: RelayAgentActivityAggregateState | null; | ||
| 137 | }) | ||
| 138 | | (LiveActivityRequestBase & { | ||
| 139 | readonly event: "start" | "update"; | ||
| 140 | readonly state: RelayAgentActivityAggregateState; | ||
| 141 | }); | ||
| 142 | |||
| 143 | function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest { | ||
| 144 | const timestamp = input.nowEpochSeconds; | ||
| 145 | if (input.event === "end") { | ||
| 146 | return { | ||
| 147 | token: input.token, | ||
| 148 | event: input.event, | ||
| 149 | priority: "10", | ||
| 150 | payload: { | ||
| 151 | aps: { | ||
| 152 | timestamp, | ||
| 153 | event: "end", | ||
| 154 | ...(input.state ? { "content-state": contentState(input.state) } : {}), | ||
| 155 | "dismissal-date": timestamp + DISMISS_AFTER_SECONDS, | ||
| 156 | }, | ||
| 157 | }, | ||
| 158 | }; | ||
| 159 | } | ||
| 160 | |||
| 161 | const state = input.state; | ||
| 162 | return { | ||
| 163 | token: input.token, | ||
| 164 | event: input.event, | ||
| 165 | priority: input.event === "update" ? "5" : "10", | ||
| 166 | payload: { | ||
| 167 | aps: { | ||
| 168 | timestamp, | ||
| 169 | event: input.event, | ||
| 170 | ...(input.event === "start" | ||
| 171 | ? { | ||
| 172 | "attributes-type": "LiveActivityAttributes", | ||
| 173 | attributes: {}, | ||
| 174 | "input-push-token": 1, | ||
| 175 | alert: { | ||
| 176 | title: state.title, | ||
| 177 | body: state.subtitle, | ||
| 178 | }, | ||
| 179 | } | ||
| 180 | : {}), | ||
| 181 | "content-state": contentState(state), | ||
| 182 | "stale-date": timestamp + STALE_AFTER_SECONDS, | ||
| 183 | }, | ||
| 184 | }, | ||
| 185 | }; | ||
| 186 | } | ||
| 187 | |||
| 188 | function makePushNotificationRequest(input: { | ||
| 189 | readonly token: string; | ||
| 190 | readonly notification: ApnsNotificationPayload; | ||
| 191 | }): ApnsPushNotificationRequest { | ||
| 192 | return { | ||
| 193 | token: input.token, | ||
| 194 | priority: "10", | ||
| 195 | payload: { | ||
| 196 | aps: { | ||
| 197 | alert: { | ||
| 198 | title: input.notification.title, | ||
| 199 | body: input.notification.body, | ||
| 200 | }, | ||
| 201 | sound: "default", | ||
| 202 | }, | ||
| 203 | environmentId: input.notification.environmentId, | ||
| 204 | threadId: input.notification.threadId, | ||
| 205 | deepLink: input.notification.deepLink, | ||
| 206 | }, | ||
| 207 | }; | ||
| 208 | } | ||
| 209 | |||
| 210 | function apnsReasonFromBody(body: string): string | undefined { | ||
| 211 | if (body.trim().length === 0) { | ||
| 212 | return undefined; | ||
| 213 | } | ||
| 214 | return Option.match(decodeApnsErrorResponseJson(body), { | ||
| 215 | onNone: () => body, | ||
| 216 | onSome: (parsed) => parsed.reason ?? body, | ||
| 217 | }); | ||
| 218 | } | ||
| 219 | |||
| 220 | export interface ApnsClientShape { | ||
| 221 | readonly makeLiveActivityRequest: typeof makeLiveActivityRequest; | ||
| 222 | readonly makePushNotificationRequest: typeof makePushNotificationRequest; | ||
| 223 | readonly sendLiveActivityRequest: (input: { | ||
| 224 | readonly credentials: ApnsCredentials; | ||
| 225 | readonly request: ApnsLiveActivityRequest; | ||
| 226 | readonly issuedAtUnixSeconds: number; | ||
| 227 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; | ||
| 228 | readonly sendPushNotificationRequest: (input: { | ||
| 229 | readonly credentials: ApnsCredentials; | ||
| 230 | readonly request: ApnsPushNotificationRequest; | ||
| 231 | readonly issuedAtUnixSeconds: number; | ||
| 232 | }) => Effect.Effect<ApnsDeliveryResult, ApnsError>; | ||
| 233 | } | ||
| 234 | |||
| 235 | export class ApnsClient extends Context.Service<ApnsClient, ApnsClientShape>()( | ||
| 236 | "t3code-relay/agentActivity/ApnsClient", | ||
| 237 | ) {} | ||
| 238 | |||
| 239 | const make = Effect.gen(function* () { | ||
| 240 | const httpClient = yield* HttpClient.HttpClient; | ||
| 241 | |||
| 242 | const sendLiveActivityRequest: ApnsClientShape["sendLiveActivityRequest"] = Effect.fn( | ||
| 243 | "relay.apns.send_live_activity_request", | ||
| 244 | )(function* (input) { | ||
| 245 | yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event }); | ||
| 246 | const jwt = yield* makeApnsJwt({ | ||
| 247 | ...input.credentials, | ||
| 248 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | ||
| 249 | }); | ||
| 250 | const host = | ||
| 251 | input.credentials.environment === "production" | ||
| 252 | ? "https://api.push.apple.com" | ||
| 253 | : "https://api.sandbox.push.apple.com"; | ||
| 254 | const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe( | ||
| 255 | HttpClientRequest.setHeaders({ | ||
| 256 | authorization: `bearer ${jwt}`, | ||
| 257 | "apns-priority": input.request.priority, | ||
| 258 | "apns-push-type": "liveactivity", | ||
| 259 | "apns-topic": `${input.credentials.bundleId}.push-type.liveactivity`, | ||
| 260 | }), | ||
| 261 | HttpClientRequest.bodyJson(input.request.payload), | ||
| 262 | Effect.flatMap(httpClient.execute), | ||
| 263 | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), | ||
| 264 | ); | ||
| 265 | const responseText = yield* response.text.pipe( | ||
| 266 | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), | ||
| 267 | ); | ||
| 268 | const reason = apnsReasonFromBody(responseText); | ||
| 269 | return { | ||
| 270 | ok: response.status >= 200 && response.status < 300, | ||
| 271 | status: response.status, | ||
| 272 | ...(reason === undefined ? {} : { reason }), | ||
| 273 | apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), | ||
| 274 | }; | ||
| 275 | }); | ||
| 276 | |||
| 277 | const sendPushNotificationRequest: ApnsClientShape["sendPushNotificationRequest"] = Effect.fn( | ||
| 278 | "relay.apns.send_push_notification_request", | ||
| 279 | )(function* (input) { | ||
| 280 | yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" }); | ||
| 281 | const jwt = yield* makeApnsJwt({ | ||
| 282 | ...input.credentials, | ||
| 283 | issuedAtUnixSeconds: input.issuedAtUnixSeconds, | ||
| 284 | }); | ||
| 285 | const host = | ||
| 286 | input.credentials.environment === "production" | ||
| 287 | ? "https://api.push.apple.com" | ||
| 288 | : "https://api.sandbox.push.apple.com"; | ||
| 289 | const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe( | ||
| 290 | HttpClientRequest.setHeaders({ | ||
| 291 | authorization: `bearer ${jwt}`, | ||
| 292 | "apns-priority": input.request.priority, | ||
| 293 | "apns-push-type": "alert", | ||
| 294 | "apns-topic": input.credentials.bundleId, | ||
| 295 | }), | ||
| 296 | HttpClientRequest.bodyJson(input.request.payload), | ||
| 297 | Effect.flatMap(httpClient.execute), | ||
| 298 | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), | ||
| 299 | ); | ||
| 300 | const responseText = yield* response.text.pipe( | ||
| 301 | Effect.mapError((cause) => new ApnsHttpRequestError({ cause })), | ||
| 302 | ); | ||
| 303 | const reason = apnsReasonFromBody(responseText); | ||
| 304 | return { | ||
| 305 | ok: response.status >= 200 && response.status < 300, | ||
| 306 | status: response.status, | ||
| 307 | ...(reason === undefined ? {} : { reason }), | ||
| 308 | apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")), | ||
| 309 | }; | ||
| 310 | }); | ||
| 311 | |||
| 312 | return ApnsClient.of({ | ||
| 313 | makeLiveActivityRequest, | ||
| 314 | makePushNotificationRequest, | ||
| 315 | sendLiveActivityRequest, | ||
| 316 | sendPushNotificationRequest, | ||
| 317 | }); | ||
| 318 | }); | ||
| 319 | |||
| 320 | export const layer = Layer.effect(ApnsClient, make); | ||
infra/relay/src/agentActivity/ApnsDeliveries.test.ts created+1128| ... | @@ -0,0 +1,1128 @@ | ||
| 1 | import type { | ||
| 2 | RelayAgentActivityAggregateState, | ||
| 3 | RelayAgentActivityState, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as NodeCrypto from "node:crypto"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import * as Redacted from "effect/Redacted"; | ||
| 11 | import { | ||
| 12 | FetchHttpClient, | ||
| 13 | HttpClient, | ||
| 14 | HttpClientRequest, | ||
| 15 | HttpClientResponse, | ||
| 16 | } from "effect/unstable/http"; | ||
| 17 | |||
| 18 | import { | ||
| 19 | makeApnsDeliveryJobPayload, | ||
| 20 | signApnsDeliveryJob, | ||
| 21 | type SignedApnsDeliveryJob, | ||
| 22 | } from "./apnsDeliveryJobs.ts"; | ||
| 23 | import * as DeliveryAttempts from "./DeliveryAttempts.ts"; | ||
| 24 | import * as LiveActivities from "./LiveActivities.ts"; | ||
| 25 | import * as RelayConfiguration from "../Config.ts"; | ||
| 26 | import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; | ||
| 27 | import * as ApnsDeliveries from "./ApnsDeliveries.ts"; | ||
| 28 | import * as ApnsClient from "./ApnsClient.ts"; | ||
| 29 | |||
| 30 | const config = RelayConfiguration.RelayConfiguration.of({ | ||
| 31 | relayIssuer: "https://relay.example.test", | ||
| 32 | apns: { | ||
| 33 | environment: "sandbox", | ||
| 34 | teamId: "team-id", | ||
| 35 | keyId: "key-id", | ||
| 36 | privateKey: Redacted.make("not-a-private-key"), | ||
| 37 | bundleId: "com.t3tools.t3code.dev", | ||
| 38 | }, | ||
| 39 | apnsDeliveryJobSigningSecret: Redacted.make("job-signing-secret"), | ||
| 40 | clerkSecretKey: Redacted.make("clerk-secret"), | ||
| 41 | clerkPublishableKey: "pk_test_test", | ||
| 42 | clerkJwtAudience: "t3-code-relay", | ||
| 43 | cloudMintPrivateKey: Redacted.make("cloud-private-key"), | ||
| 44 | cloudMintPublicKey: "cloud-public-key", | ||
| 45 | managedEndpointBaseDomain: undefined, | ||
| 46 | managedEndpointNamespace: undefined, | ||
| 47 | }); | ||
| 48 | |||
| 49 | const apnsSigningKeyPair = NodeCrypto.generateKeyPairSync("ec", { | ||
| 50 | namedCurve: "P-256", | ||
| 51 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 52 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 53 | }); | ||
| 54 | |||
| 55 | const signingConfig = RelayConfiguration.RelayConfiguration.of({ | ||
| 56 | ...config, | ||
| 57 | apns: { | ||
| 58 | ...config.apns, | ||
| 59 | privateKey: Redacted.make(apnsSigningKeyPair.privateKey), | ||
| 60 | }, | ||
| 61 | }); | ||
| 62 | |||
| 63 | const state: RelayAgentActivityState = { | ||
| 64 | environmentId: "env" as RelayAgentActivityState["environmentId"], | ||
| 65 | threadId: "thread" as RelayAgentActivityState["threadId"], | ||
| 66 | projectTitle: "Project", | ||
| 67 | threadTitle: "Thread", | ||
| 68 | modelTitle: "gpt-5.4", | ||
| 69 | phase: "running", | ||
| 70 | headline: "Running", | ||
| 71 | updatedAt: "1970-01-01T00:00:00.000Z", | ||
| 72 | deepLink: "/", | ||
| 73 | }; | ||
| 74 | |||
| 75 | const aggregate: RelayAgentActivityAggregateState = { | ||
| 76 | title: "T3 Code", | ||
| 77 | subtitle: "Agent work in progress", | ||
| 78 | activeCount: 1, | ||
| 79 | updatedAt: state.updatedAt, | ||
| 80 | activities: [ | ||
| 81 | { | ||
| 82 | environmentId: state.environmentId, | ||
| 83 | threadId: state.threadId, | ||
| 84 | projectTitle: state.projectTitle, | ||
| 85 | threadTitle: state.threadTitle, | ||
| 86 | modelTitle: state.modelTitle, | ||
| 87 | phase: state.phase, | ||
| 88 | status: "Working", | ||
| 89 | updatedAt: state.updatedAt, | ||
| 90 | deepLink: state.deepLink, | ||
| 91 | }, | ||
| 92 | ], | ||
| 93 | }; | ||
| 94 | |||
| 95 | const enabledPreferences = JSON.stringify({ | ||
| 96 | liveActivitiesEnabled: true, | ||
| 97 | notificationsEnabled: true, | ||
| 98 | notifyOnApproval: true, | ||
| 99 | notifyOnInput: true, | ||
| 100 | notifyOnCompletion: true, | ||
| 101 | notifyOnFailure: true, | ||
| 102 | }); | ||
| 103 | |||
| 104 | const disabledPreferences = JSON.stringify({ | ||
| 105 | liveActivitiesEnabled: false, | ||
| 106 | notificationsEnabled: true, | ||
| 107 | notifyOnApproval: true, | ||
| 108 | notifyOnInput: true, | ||
| 109 | notifyOnCompletion: true, | ||
| 110 | notifyOnFailure: true, | ||
| 111 | }); | ||
| 112 | |||
| 113 | const notificationsDisabledPreferences = JSON.stringify({ | ||
| 114 | liveActivitiesEnabled: false, | ||
| 115 | notificationsEnabled: false, | ||
| 116 | notifyOnApproval: true, | ||
| 117 | notifyOnInput: true, | ||
| 118 | notifyOnCompletion: true, | ||
| 119 | notifyOnFailure: true, | ||
| 120 | }); | ||
| 121 | |||
| 122 | const target: LiveActivities.TargetRow = { | ||
| 123 | user_id: "dev:julius", | ||
| 124 | device_id: "device-1", | ||
| 125 | platform: "ios", | ||
| 126 | ios_major_version: 18, | ||
| 127 | app_version: "1.0.0", | ||
| 128 | push_token: null, | ||
| 129 | push_to_start_token: "start-token", | ||
| 130 | preferences_json: enabledPreferences, | ||
| 131 | activity_push_token: "activity-token", | ||
| 132 | remote_start_queued_at: null, | ||
| 133 | remote_started_at: "1970-01-01T00:00:00.000Z", | ||
| 134 | ended_at: null, | ||
| 135 | last_aggregate_json: null, | ||
| 136 | last_live_activity_delivery_at: null, | ||
| 137 | }; | ||
| 138 | |||
| 139 | function makeLayer(input: { | ||
| 140 | readonly attempts: Array<DeliveryAttempts.DeliveryAttemptInput>; | ||
| 141 | readonly sourceJobClaims?: ReadonlyMap<string, DeliveryAttempts.DeliverySourceJobClaimResult>; | ||
| 142 | readonly queuedJobs?: Array<SignedApnsDeliveryJob>; | ||
| 143 | readonly queuedStarts?: Array< | ||
| 144 | Parameters<LiveActivities.LiveActivitiesShape["markStartQueued"]>[0] | ||
| 145 | >; | ||
| 146 | readonly clearedStarts?: Array< | ||
| 147 | Parameters<LiveActivities.LiveActivitiesShape["clearStartQueued"]>[0] | ||
| 148 | >; | ||
| 149 | readonly markedDeliveries?: Array< | ||
| 150 | Parameters<LiveActivities.LiveActivitiesShape["markDelivery"]>[0] | ||
| 151 | >; | ||
| 152 | readonly invalidatedTokens?: Array< | ||
| 153 | Parameters<LiveActivities.LiveActivitiesShape["invalidateDeliveryToken"]>[0] | ||
| 154 | >; | ||
| 155 | readonly currentTargets?: ReadonlyArray<LiveActivities.TargetRow>; | ||
| 156 | readonly config?: RelayConfiguration.RelayConfigurationShape; | ||
| 157 | readonly execute?: ( | ||
| 158 | request: HttpClientRequest.HttpClientRequest, | ||
| 159 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse>; | ||
| 160 | }) { | ||
| 161 | return ApnsDeliveries.layer.pipe( | ||
| 162 | Layer.provide(ApnsClient.layer), | ||
| 163 | Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), | ||
| 164 | Layer.provide( | ||
| 165 | Layer.mergeAll( | ||
| 166 | Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { | ||
| 167 | send: (body) => | ||
| 168 | Effect.sync(() => { | ||
| 169 | input.queuedJobs?.push(body); | ||
| 170 | }), | ||
| 171 | }), | ||
| 172 | Layer.succeed(DeliveryAttempts.DeliveryAttempts, { | ||
| 173 | record: (attempt) => | ||
| 174 | Effect.sync(() => { | ||
| 175 | input.attempts.push(attempt); | ||
| 176 | }), | ||
| 177 | claimSourceJob: (attempt) => | ||
| 178 | Effect.sync(() => { | ||
| 179 | const claim = input.sourceJobClaims?.get(attempt.sourceJobId); | ||
| 180 | if (claim) { | ||
| 181 | return claim; | ||
| 182 | } | ||
| 183 | input.attempts.push(attempt); | ||
| 184 | return "claimed"; | ||
| 185 | }), | ||
| 186 | completeSourceJob: (completion) => | ||
| 187 | Effect.sync(() => { | ||
| 188 | const attempt = input.attempts.find( | ||
| 189 | (row) => row.sourceJobId === completion.sourceJobId, | ||
| 190 | ); | ||
| 191 | if (attempt) { | ||
| 192 | Object.assign(attempt, completion); | ||
| 193 | } | ||
| 194 | }), | ||
| 195 | }), | ||
| 196 | Layer.succeed(LiveActivities.LiveActivities, { | ||
| 197 | register: () => Effect.void, | ||
| 198 | listTargets: () => Effect.succeed(input.currentTargets ?? [target]), | ||
| 199 | markStartQueued: (queued) => | ||
| 200 | Effect.sync(() => { | ||
| 201 | input.queuedStarts?.push(queued); | ||
| 202 | }), | ||
| 203 | clearStartQueued: (cleared) => | ||
| 204 | Effect.sync(() => { | ||
| 205 | input.clearedStarts?.push(cleared); | ||
| 206 | }), | ||
| 207 | markDelivery: (delivery) => | ||
| 208 | Effect.sync(() => { | ||
| 209 | input.markedDeliveries?.push(delivery); | ||
| 210 | }), | ||
| 211 | invalidateDeliveryToken: (invalidated) => | ||
| 212 | Effect.sync(() => { | ||
| 213 | input.invalidatedTokens?.push(invalidated); | ||
| 214 | }), | ||
| 215 | }), | ||
| 216 | Layer.succeed(RelayConfiguration.RelayConfiguration, input.config ?? config), | ||
| 217 | input.execute | ||
| 218 | ? Layer.succeed(HttpClient.HttpClient, HttpClient.make(input.execute)) | ||
| 219 | : FetchHttpClient.layer, | ||
| 220 | ), | ||
| 221 | ), | ||
| 222 | ); | ||
| 223 | } | ||
| 224 | |||
| 225 | describe("ApnsDeliveries", () => { | ||
| 226 | it.effect("queues a restart using the push-to-start token", () => { | ||
| 227 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 228 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 229 | const queuedStarts: Array< | ||
| 230 | Parameters<LiveActivities.LiveActivitiesShape["markStartQueued"]>[0] | ||
| 231 | > = []; | ||
| 232 | const markedDeliveries: Array< | ||
| 233 | Parameters<LiveActivities.LiveActivitiesShape["markDelivery"]>[0] | ||
| 234 | > = []; | ||
| 235 | |||
| 236 | return Effect.gen(function* () { | ||
| 237 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 238 | const result = yield* deliveries.sendForTarget({ | ||
| 239 | target: { | ||
| 240 | ...target, | ||
| 241 | ended_at: "1970-01-01T00:00:05.000Z", | ||
| 242 | }, | ||
| 243 | aggregate, | ||
| 244 | nowMs: 10_000, | ||
| 245 | }); | ||
| 246 | |||
| 247 | expect(result?.kind).toBe("live_activity_start"); | ||
| 248 | expect(result?.ok).toBe(true); | ||
| 249 | expect(queuedJobs).toMatchObject([ | ||
| 250 | { | ||
| 251 | payload: { | ||
| 252 | kind: "live_activity_start", | ||
| 253 | target: { | ||
| 254 | token: "start-token", | ||
| 255 | }, | ||
| 256 | }, | ||
| 257 | }, | ||
| 258 | ]); | ||
| 259 | expect(attempts).toEqual([]); | ||
| 260 | expect(queuedStarts).toMatchObject([ | ||
| 261 | { | ||
| 262 | userId: target.user_id, | ||
| 263 | deviceId: target.device_id, | ||
| 264 | }, | ||
| 265 | ]); | ||
| 266 | expect(markedDeliveries).toEqual([]); | ||
| 267 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs, queuedStarts, markedDeliveries }))); | ||
| 268 | }); | ||
| 269 | |||
| 270 | it.effect("queues an end using the activity token", () => { | ||
| 271 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 272 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 273 | |||
| 274 | return Effect.gen(function* () { | ||
| 275 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 276 | const result = yield* deliveries.sendForTarget({ | ||
| 277 | target, | ||
| 278 | aggregate: null, | ||
| 279 | nowMs: 5_000, | ||
| 280 | }); | ||
| 281 | |||
| 282 | expect(result?.kind).toBe("live_activity_end"); | ||
| 283 | expect(result?.ok).toBe(true); | ||
| 284 | expect(queuedJobs).toMatchObject([ | ||
| 285 | { | ||
| 286 | payload: { | ||
| 287 | kind: "live_activity_end", | ||
| 288 | target: { | ||
| 289 | token: "activity-token", | ||
| 290 | }, | ||
| 291 | }, | ||
| 292 | }, | ||
| 293 | ]); | ||
| 294 | expect(attempts).toEqual([]); | ||
| 295 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 296 | }); | ||
| 297 | |||
| 298 | it.effect("does not queue a remote start when Live Activities are disabled", () => { | ||
| 299 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 300 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 301 | |||
| 302 | return Effect.gen(function* () { | ||
| 303 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 304 | const result = yield* deliveries.sendForTarget({ | ||
| 305 | target: { | ||
| 306 | ...target, | ||
| 307 | activity_push_token: null, | ||
| 308 | remote_started_at: null, | ||
| 309 | ended_at: null, | ||
| 310 | preferences_json: disabledPreferences, | ||
| 311 | }, | ||
| 312 | aggregate, | ||
| 313 | nowMs: 5_000, | ||
| 314 | }); | ||
| 315 | |||
| 316 | expect(result).toBeNull(); | ||
| 317 | expect(queuedJobs).toEqual([]); | ||
| 318 | expect(attempts).toEqual([]); | ||
| 319 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 320 | }); | ||
| 321 | |||
| 322 | it.effect("does not queue a duplicate remote start while a start is already queued", () => { | ||
| 323 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 324 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 325 | |||
| 326 | return Effect.gen(function* () { | ||
| 327 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 328 | const result = yield* deliveries.sendForTarget({ | ||
| 329 | target: { | ||
| 330 | ...target, | ||
| 331 | activity_push_token: null, | ||
| 332 | remote_start_queued_at: "1970-01-01T00:00:03.000Z", | ||
| 333 | remote_started_at: null, | ||
| 334 | ended_at: null, | ||
| 335 | }, | ||
| 336 | aggregate, | ||
| 337 | nowMs: 5_000, | ||
| 338 | }); | ||
| 339 | |||
| 340 | expect(result).toBeNull(); | ||
| 341 | expect(queuedJobs).toEqual([]); | ||
| 342 | expect(attempts).toEqual([]); | ||
| 343 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 344 | }); | ||
| 345 | |||
| 346 | it.effect("queues bounded Live Activity aggregate payloads", () => { | ||
| 347 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 348 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 349 | const longTitle = "x".repeat(300); | ||
| 350 | const inputAggregate: RelayAgentActivityAggregateState = { | ||
| 351 | ...aggregate, | ||
| 352 | title: longTitle, | ||
| 353 | subtitle: longTitle, | ||
| 354 | activities: [0, 1, 2, 3].map((index) => | ||
| 355 | Object.assign({}, aggregate.activities[0]!, { | ||
| 356 | projectTitle: longTitle, | ||
| 357 | threadTitle: longTitle, | ||
| 358 | modelTitle: longTitle, | ||
| 359 | status: longTitle, | ||
| 360 | threadId: `thread-${index}` as RelayAgentActivityState["threadId"], | ||
| 361 | deepLink: "https://example.test/not-an-app-link", | ||
| 362 | }), | ||
| 363 | ), | ||
| 364 | }; | ||
| 365 | |||
| 366 | return Effect.gen(function* () { | ||
| 367 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 368 | yield* deliveries.sendForTarget({ | ||
| 369 | target: { | ||
| 370 | ...target, | ||
| 371 | activity_push_token: null, | ||
| 372 | remote_started_at: null, | ||
| 373 | ended_at: "1970-01-01T00:00:05.000Z", | ||
| 374 | }, | ||
| 375 | aggregate: inputAggregate, | ||
| 376 | nowMs: 10_000, | ||
| 377 | }); | ||
| 378 | |||
| 379 | const payloadAggregate = queuedJobs[0]?.payload.aggregate; | ||
| 380 | expect(payloadAggregate?.title.length).toBeLessThanOrEqual(120); | ||
| 381 | expect(payloadAggregate?.subtitle.length).toBeLessThanOrEqual(120); | ||
| 382 | expect(payloadAggregate?.activities).toHaveLength(3); | ||
| 383 | expect(payloadAggregate?.activities[0]?.projectTitle.length).toBeLessThanOrEqual(120); | ||
| 384 | expect(payloadAggregate?.activities[0]?.status.length).toBeLessThanOrEqual(40); | ||
| 385 | expect(payloadAggregate?.activities[0]?.deepLink).toBe("/"); | ||
| 386 | expect(attempts).toEqual([]); | ||
| 387 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 388 | }); | ||
| 389 | |||
| 390 | it.effect("queues an end for an active Live Activity when Live Activities are disabled", () => { | ||
| 391 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 392 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 393 | |||
| 394 | return Effect.gen(function* () { | ||
| 395 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 396 | const result = yield* deliveries.sendForTarget({ | ||
| 397 | target: { | ||
| 398 | ...target, | ||
| 399 | preferences_json: disabledPreferences, | ||
| 400 | }, | ||
| 401 | aggregate, | ||
| 402 | nowMs: 5_000, | ||
| 403 | }); | ||
| 404 | |||
| 405 | expect(result?.kind).toBe("live_activity_end"); | ||
| 406 | expect(queuedJobs).toMatchObject([ | ||
| 407 | { | ||
| 408 | payload: { | ||
| 409 | kind: "live_activity_end", | ||
| 410 | target: { | ||
| 411 | token: "activity-token", | ||
| 412 | }, | ||
| 413 | }, | ||
| 414 | }, | ||
| 415 | ]); | ||
| 416 | expect(attempts).toEqual([]); | ||
| 417 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 418 | }); | ||
| 419 | |||
| 420 | it.effect( | ||
| 421 | "queues an alert while ending an active Live Activity when only Live Activities are disabled", | ||
| 422 | () => { | ||
| 423 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 424 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 425 | const inputAggregate: RelayAgentActivityAggregateState = { | ||
| 426 | ...aggregate, | ||
| 427 | activities: [ | ||
| 428 | { | ||
| 429 | ...aggregate.activities[0]!, | ||
| 430 | phase: "waiting_for_input", | ||
| 431 | status: "Input", | ||
| 432 | }, | ||
| 433 | ], | ||
| 434 | }; | ||
| 435 | |||
| 436 | return Effect.gen(function* () { | ||
| 437 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 438 | const result = yield* deliveries.sendForTarget({ | ||
| 439 | target: { | ||
| 440 | ...target, | ||
| 441 | push_token: "apns-device-token", | ||
| 442 | preferences_json: disabledPreferences, | ||
| 443 | }, | ||
| 444 | aggregate: inputAggregate, | ||
| 445 | nowMs: 5_000, | ||
| 446 | }); | ||
| 447 | |||
| 448 | expect(result?.kind).toBe("live_activity_end"); | ||
| 449 | expect(queuedJobs).toMatchObject([ | ||
| 450 | { | ||
| 451 | payload: { | ||
| 452 | kind: "live_activity_end", | ||
| 453 | target: { | ||
| 454 | token: "activity-token", | ||
| 455 | }, | ||
| 456 | }, | ||
| 457 | }, | ||
| 458 | { | ||
| 459 | payload: { | ||
| 460 | kind: "push_notification", | ||
| 461 | target: { | ||
| 462 | token: "apns-device-token", | ||
| 463 | }, | ||
| 464 | notification: { | ||
| 465 | title: "Thread", | ||
| 466 | body: "Input: Project", | ||
| 467 | environmentId: "env", | ||
| 468 | threadId: "thread", | ||
| 469 | deepLink: "/", | ||
| 470 | }, | ||
| 471 | }, | ||
| 472 | }, | ||
| 473 | ]); | ||
| 474 | expect(attempts).toEqual([]); | ||
| 475 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 476 | }, | ||
| 477 | ); | ||
| 478 | |||
| 479 | it.effect("does not queue alert pushes when notification permission is disabled", () => { | ||
| 480 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 481 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 482 | const inputAggregate: RelayAgentActivityAggregateState = { | ||
| 483 | ...aggregate, | ||
| 484 | activities: [ | ||
| 485 | { | ||
| 486 | ...aggregate.activities[0]!, | ||
| 487 | phase: "waiting_for_input", | ||
| 488 | status: "Input", | ||
| 489 | }, | ||
| 490 | ], | ||
| 491 | }; | ||
| 492 | |||
| 493 | return Effect.gen(function* () { | ||
| 494 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 495 | const result = yield* deliveries.sendForTarget({ | ||
| 496 | target: { | ||
| 497 | ...target, | ||
| 498 | push_token: "apns-device-token", | ||
| 499 | preferences_json: notificationsDisabledPreferences, | ||
| 500 | }, | ||
| 501 | aggregate: inputAggregate, | ||
| 502 | nowMs: 5_000, | ||
| 503 | }); | ||
| 504 | |||
| 505 | expect(result?.kind).toBe("live_activity_end"); | ||
| 506 | expect(queuedJobs).toMatchObject([ | ||
| 507 | { | ||
| 508 | payload: { | ||
| 509 | kind: "live_activity_end", | ||
| 510 | target: { | ||
| 511 | token: "activity-token", | ||
| 512 | }, | ||
| 513 | }, | ||
| 514 | }, | ||
| 515 | ]); | ||
| 516 | expect(attempts).toEqual([]); | ||
| 517 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 518 | }); | ||
| 519 | |||
| 520 | it.effect( | ||
| 521 | "queues a push notification for approval and input states when no Live Activity delivery is available", | ||
| 522 | () => { | ||
| 523 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 524 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 525 | const inputAggregate: RelayAgentActivityAggregateState = { | ||
| 526 | ...aggregate, | ||
| 527 | activities: [ | ||
| 528 | { | ||
| 529 | ...aggregate.activities[0]!, | ||
| 530 | phase: "waiting_for_input", | ||
| 531 | status: "Input", | ||
| 532 | }, | ||
| 533 | ], | ||
| 534 | }; | ||
| 535 | |||
| 536 | return Effect.gen(function* () { | ||
| 537 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 538 | const result = yield* deliveries.sendForTarget({ | ||
| 539 | target: { | ||
| 540 | ...target, | ||
| 541 | push_token: "apns-device-token", | ||
| 542 | push_to_start_token: null, | ||
| 543 | activity_push_token: null, | ||
| 544 | remote_started_at: null, | ||
| 545 | }, | ||
| 546 | aggregate: inputAggregate, | ||
| 547 | nowMs: 5_000, | ||
| 548 | }); | ||
| 549 | |||
| 550 | expect(result?.kind).toBe("push_notification"); | ||
| 551 | expect(result?.ok).toBe(true); | ||
| 552 | expect(queuedJobs).toMatchObject([ | ||
| 553 | { | ||
| 554 | payload: { | ||
| 555 | kind: "push_notification", | ||
| 556 | target: { | ||
| 557 | token: "apns-device-token", | ||
| 558 | }, | ||
| 559 | notification: { | ||
| 560 | title: "Thread", | ||
| 561 | body: "Input: Project", | ||
| 562 | environmentId: "env", | ||
| 563 | threadId: "thread", | ||
| 564 | deepLink: "/", | ||
| 565 | }, | ||
| 566 | }, | ||
| 567 | }, | ||
| 568 | ]); | ||
| 569 | expect(attempts).toEqual([]); | ||
| 570 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 571 | }, | ||
| 572 | ); | ||
| 573 | |||
| 574 | it.effect("queues bounded alert notification payloads", () => { | ||
| 575 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 576 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 577 | const longTitle = "x".repeat(300); | ||
| 578 | const inputAggregate: RelayAgentActivityAggregateState = { | ||
| 579 | ...aggregate, | ||
| 580 | activities: [ | ||
| 581 | { | ||
| 582 | ...aggregate.activities[0]!, | ||
| 583 | projectTitle: longTitle, | ||
| 584 | threadTitle: longTitle, | ||
| 585 | phase: "waiting_for_input", | ||
| 586 | status: "Input", | ||
| 587 | deepLink: "https://example.test/not-an-app-link", | ||
| 588 | }, | ||
| 589 | ], | ||
| 590 | }; | ||
| 591 | |||
| 592 | return Effect.gen(function* () { | ||
| 593 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 594 | yield* deliveries.sendForTarget({ | ||
| 595 | target: { | ||
| 596 | ...target, | ||
| 597 | push_token: "apns-device-token", | ||
| 598 | push_to_start_token: null, | ||
| 599 | activity_push_token: null, | ||
| 600 | remote_started_at: null, | ||
| 601 | }, | ||
| 602 | aggregate: inputAggregate, | ||
| 603 | nowMs: 5_000, | ||
| 604 | }); | ||
| 605 | |||
| 606 | const notification = queuedJobs[0]?.payload.notification; | ||
| 607 | expect(notification?.title.length).toBeLessThanOrEqual(120); | ||
| 608 | expect(notification?.body.length).toBeLessThanOrEqual(120); | ||
| 609 | expect(notification?.deepLink).toBe("/"); | ||
| 610 | expect(attempts).toEqual([]); | ||
| 611 | }).pipe(Effect.provide(makeLayer({ attempts, queuedJobs }))); | ||
| 612 | }); | ||
| 613 | |||
| 614 | it.effect("processes signed jobs through APNs and records attempts", () => { | ||
| 615 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 616 | const payload = makeApnsDeliveryJobPayload({ | ||
| 617 | kind: "live_activity_update", | ||
| 618 | userId: target.user_id, | ||
| 619 | deviceId: target.device_id, | ||
| 620 | token: target.activity_push_token ?? "activity-token", | ||
| 621 | aggregate, | ||
| 622 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 623 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 624 | jobId: "job-1", | ||
| 625 | }); | ||
| 626 | const signed = signApnsDeliveryJob({ | ||
| 627 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 628 | payload, | ||
| 629 | }); | ||
| 630 | |||
| 631 | return Effect.gen(function* () { | ||
| 632 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 633 | const result = yield* deliveries.processSignedJob(signed); | ||
| 634 | |||
| 635 | expect(result.kind).toBe("live_activity_update"); | ||
| 636 | expect(result.ok).toBe(false); | ||
| 637 | expect(attempts).toMatchObject([ | ||
| 638 | { | ||
| 639 | kind: "live_activity_update", | ||
| 640 | sourceJobId: "job-1", | ||
| 641 | token: "activity-token", | ||
| 642 | }, | ||
| 643 | ]); | ||
| 644 | }).pipe(Effect.provide(makeLayer({ attempts }))); | ||
| 645 | }); | ||
| 646 | |||
| 647 | it.effect("processes signed push notification jobs through APNs and records attempts", () => { | ||
| 648 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 649 | const payload = makeApnsDeliveryJobPayload({ | ||
| 650 | kind: "push_notification", | ||
| 651 | userId: target.user_id, | ||
| 652 | deviceId: target.device_id, | ||
| 653 | token: "apns-device-token", | ||
| 654 | aggregate: null, | ||
| 655 | notification: { | ||
| 656 | title: "Thread", | ||
| 657 | body: "Input: Project", | ||
| 658 | environmentId: "env", | ||
| 659 | threadId: "thread", | ||
| 660 | deepLink: "/", | ||
| 661 | }, | ||
| 662 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 663 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 664 | jobId: "job-push-1", | ||
| 665 | }); | ||
| 666 | const signed = signApnsDeliveryJob({ | ||
| 667 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 668 | payload, | ||
| 669 | }); | ||
| 670 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 671 | Effect.succeed(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))); | ||
| 672 | |||
| 673 | return Effect.gen(function* () { | ||
| 674 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 675 | const result = yield* deliveries.processSignedJob(signed); | ||
| 676 | |||
| 677 | expect(result.kind).toBe("push_notification"); | ||
| 678 | expect(result.ok).toBe(true); | ||
| 679 | expect(result.apnsStatus).toBe(200); | ||
| 680 | expect(attempts).toMatchObject([ | ||
| 681 | { | ||
| 682 | kind: "push_notification", | ||
| 683 | sourceJobId: "job-push-1", | ||
| 684 | token: "apns-device-token", | ||
| 685 | environmentId: "env", | ||
| 686 | threadId: "thread", | ||
| 687 | deviceId: target.device_id, | ||
| 688 | apnsStatus: 200, | ||
| 689 | }, | ||
| 690 | ]); | ||
| 691 | }).pipe( | ||
| 692 | Effect.provide( | ||
| 693 | makeLayer({ | ||
| 694 | attempts, | ||
| 695 | currentTargets: [ | ||
| 696 | { | ||
| 697 | ...target, | ||
| 698 | push_token: "apns-device-token", | ||
| 699 | }, | ||
| 700 | ], | ||
| 701 | config: signingConfig, | ||
| 702 | execute, | ||
| 703 | }), | ||
| 704 | ), | ||
| 705 | ); | ||
| 706 | }); | ||
| 707 | |||
| 708 | it.effect("skips duplicate signed queue jobs before calling APNs", () => { | ||
| 709 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 710 | let executeCount = 0; | ||
| 711 | const payload = makeApnsDeliveryJobPayload({ | ||
| 712 | kind: "push_notification", | ||
| 713 | userId: target.user_id, | ||
| 714 | deviceId: target.device_id, | ||
| 715 | token: "apns-device-token", | ||
| 716 | aggregate: null, | ||
| 717 | notification: { | ||
| 718 | title: "Thread", | ||
| 719 | body: "Input: Project", | ||
| 720 | environmentId: "env", | ||
| 721 | threadId: "thread", | ||
| 722 | deepLink: "/", | ||
| 723 | }, | ||
| 724 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 725 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 726 | jobId: "job-push-duplicate", | ||
| 727 | }); | ||
| 728 | const signed = signApnsDeliveryJob({ | ||
| 729 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 730 | payload, | ||
| 731 | }); | ||
| 732 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 733 | Effect.sync(() => { | ||
| 734 | executeCount += 1; | ||
| 735 | return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | ||
| 736 | }); | ||
| 737 | |||
| 738 | return Effect.gen(function* () { | ||
| 739 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 740 | const result = yield* deliveries.processSignedJob(signed); | ||
| 741 | |||
| 742 | expect(result).toMatchObject({ | ||
| 743 | kind: "push_notification", | ||
| 744 | ok: true, | ||
| 745 | apnsStatus: null, | ||
| 746 | apnsReason: "Duplicate APNs delivery job skipped.", | ||
| 747 | }); | ||
| 748 | expect(executeCount).toBe(0); | ||
| 749 | expect(attempts).toEqual([]); | ||
| 750 | }).pipe( | ||
| 751 | Effect.provide( | ||
| 752 | makeLayer({ | ||
| 753 | attempts, | ||
| 754 | sourceJobClaims: new Map([["job-push-duplicate", "completed"]]), | ||
| 755 | config: signingConfig, | ||
| 756 | execute, | ||
| 757 | }), | ||
| 758 | ), | ||
| 759 | ); | ||
| 760 | }); | ||
| 761 | |||
| 762 | it.effect("skips stale signed Live Activity jobs when the registered token changed", () => { | ||
| 763 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 764 | let executeCount = 0; | ||
| 765 | const payload = makeApnsDeliveryJobPayload({ | ||
| 766 | kind: "live_activity_update", | ||
| 767 | userId: target.user_id, | ||
| 768 | deviceId: target.device_id, | ||
| 769 | token: "stale-activity-token", | ||
| 770 | aggregate, | ||
| 771 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 772 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 773 | jobId: "job-update-stale-token", | ||
| 774 | }); | ||
| 775 | const signed = signApnsDeliveryJob({ | ||
| 776 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 777 | payload, | ||
| 778 | }); | ||
| 779 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 780 | Effect.sync(() => { | ||
| 781 | executeCount += 1; | ||
| 782 | return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | ||
| 783 | }); | ||
| 784 | |||
| 785 | return Effect.gen(function* () { | ||
| 786 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 787 | const result = yield* deliveries.processSignedJob(signed); | ||
| 788 | |||
| 789 | expect(result).toMatchObject({ | ||
| 790 | kind: "live_activity_update", | ||
| 791 | ok: true, | ||
| 792 | apnsStatus: null, | ||
| 793 | apnsReason: "Stale APNs delivery job skipped.", | ||
| 794 | }); | ||
| 795 | expect(executeCount).toBe(0); | ||
| 796 | expect(attempts).toMatchObject([ | ||
| 797 | { | ||
| 798 | kind: "live_activity_update", | ||
| 799 | sourceJobId: "job-update-stale-token", | ||
| 800 | token: "stale-activity-token", | ||
| 801 | apnsReason: "Stale APNs delivery job skipped.", | ||
| 802 | }, | ||
| 803 | ]); | ||
| 804 | }).pipe( | ||
| 805 | Effect.provide( | ||
| 806 | makeLayer({ | ||
| 807 | attempts, | ||
| 808 | config: signingConfig, | ||
| 809 | execute, | ||
| 810 | }), | ||
| 811 | ), | ||
| 812 | ); | ||
| 813 | }); | ||
| 814 | |||
| 815 | it.effect("skips stale signed push notification jobs when the device token changed", () => { | ||
| 816 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 817 | let executeCount = 0; | ||
| 818 | const payload = makeApnsDeliveryJobPayload({ | ||
| 819 | kind: "push_notification", | ||
| 820 | userId: target.user_id, | ||
| 821 | deviceId: target.device_id, | ||
| 822 | token: "stale-device-token", | ||
| 823 | aggregate: null, | ||
| 824 | notification: { | ||
| 825 | title: "Thread", | ||
| 826 | body: "Input: Project", | ||
| 827 | environmentId: "env", | ||
| 828 | threadId: "thread", | ||
| 829 | deepLink: "/", | ||
| 830 | }, | ||
| 831 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 832 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 833 | jobId: "job-push-stale-token", | ||
| 834 | }); | ||
| 835 | const signed = signApnsDeliveryJob({ | ||
| 836 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 837 | payload, | ||
| 838 | }); | ||
| 839 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 840 | Effect.sync(() => { | ||
| 841 | executeCount += 1; | ||
| 842 | return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | ||
| 843 | }); | ||
| 844 | |||
| 845 | return Effect.gen(function* () { | ||
| 846 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 847 | const result = yield* deliveries.processSignedJob(signed); | ||
| 848 | |||
| 849 | expect(result).toMatchObject({ | ||
| 850 | kind: "push_notification", | ||
| 851 | ok: true, | ||
| 852 | apnsStatus: null, | ||
| 853 | apnsReason: "Stale APNs delivery job skipped.", | ||
| 854 | }); | ||
| 855 | expect(executeCount).toBe(0); | ||
| 856 | expect(attempts).toMatchObject([ | ||
| 857 | { | ||
| 858 | kind: "push_notification", | ||
| 859 | sourceJobId: "job-push-stale-token", | ||
| 860 | token: "stale-device-token", | ||
| 861 | apnsReason: "Stale APNs delivery job skipped.", | ||
| 862 | }, | ||
| 863 | ]); | ||
| 864 | }).pipe( | ||
| 865 | Effect.provide( | ||
| 866 | makeLayer({ | ||
| 867 | attempts, | ||
| 868 | currentTargets: [ | ||
| 869 | { | ||
| 870 | ...target, | ||
| 871 | push_token: "current-device-token", | ||
| 872 | }, | ||
| 873 | ], | ||
| 874 | config: signingConfig, | ||
| 875 | execute, | ||
| 876 | }), | ||
| 877 | ), | ||
| 878 | ); | ||
| 879 | }); | ||
| 880 | |||
| 881 | it.effect("retries signed queue jobs that are already claimed but not completed", () => { | ||
| 882 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 883 | let executeCount = 0; | ||
| 884 | const payload = makeApnsDeliveryJobPayload({ | ||
| 885 | kind: "push_notification", | ||
| 886 | userId: target.user_id, | ||
| 887 | deviceId: target.device_id, | ||
| 888 | token: "apns-device-token", | ||
| 889 | aggregate: null, | ||
| 890 | notification: { | ||
| 891 | title: "Thread", | ||
| 892 | body: "Input: Project", | ||
| 893 | environmentId: "env", | ||
| 894 | threadId: "thread", | ||
| 895 | deepLink: "/", | ||
| 896 | }, | ||
| 897 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 898 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 899 | jobId: "job-push-in-flight", | ||
| 900 | }); | ||
| 901 | const signed = signApnsDeliveryJob({ | ||
| 902 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 903 | payload, | ||
| 904 | }); | ||
| 905 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 906 | Effect.sync(() => { | ||
| 907 | executeCount += 1; | ||
| 908 | return HttpClientResponse.fromWeb(request, new Response("", { status: 200 })); | ||
| 909 | }); | ||
| 910 | |||
| 911 | return Effect.gen(function* () { | ||
| 912 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 913 | const result = yield* Effect.exit(deliveries.processSignedJob(signed)); | ||
| 914 | |||
| 915 | expect(result._tag).toBe("Failure"); | ||
| 916 | if (result._tag === "Failure") { | ||
| 917 | expect(result.cause.toString()).toContain("ApnsDeliveryJobClaimInFlight"); | ||
| 918 | } | ||
| 919 | expect(executeCount).toBe(0); | ||
| 920 | expect(attempts).toEqual([]); | ||
| 921 | }).pipe( | ||
| 922 | Effect.provide( | ||
| 923 | makeLayer({ | ||
| 924 | attempts, | ||
| 925 | sourceJobClaims: new Map([["job-push-in-flight", "in_flight"]]), | ||
| 926 | config: signingConfig, | ||
| 927 | execute, | ||
| 928 | }), | ||
| 929 | ), | ||
| 930 | ); | ||
| 931 | }); | ||
| 932 | |||
| 933 | it.effect("invalidates dead device push tokens after permanent APNs alert failures", () => { | ||
| 934 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 935 | const invalidatedTokens: Array< | ||
| 936 | Parameters<LiveActivities.LiveActivitiesShape["invalidateDeliveryToken"]>[0] | ||
| 937 | > = []; | ||
| 938 | const payload = makeApnsDeliveryJobPayload({ | ||
| 939 | kind: "push_notification", | ||
| 940 | userId: target.user_id, | ||
| 941 | deviceId: target.device_id, | ||
| 942 | token: "apns-device-token", | ||
| 943 | aggregate: null, | ||
| 944 | notification: { | ||
| 945 | title: "Thread", | ||
| 946 | body: "Failed: Project", | ||
| 947 | environmentId: "env", | ||
| 948 | threadId: "thread", | ||
| 949 | deepLink: "/", | ||
| 950 | }, | ||
| 951 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 952 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 953 | jobId: "job-push-bad-token", | ||
| 954 | }); | ||
| 955 | const signed = signApnsDeliveryJob({ | ||
| 956 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 957 | payload, | ||
| 958 | }); | ||
| 959 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 960 | Effect.succeed( | ||
| 961 | HttpClientResponse.fromWeb( | ||
| 962 | request, | ||
| 963 | Response.json({ reason: "BadDeviceToken" }, { status: 400 }), | ||
| 964 | ), | ||
| 965 | ); | ||
| 966 | |||
| 967 | return Effect.gen(function* () { | ||
| 968 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 969 | const result = yield* deliveries.processSignedJob(signed); | ||
| 970 | |||
| 971 | expect(result.kind).toBe("push_notification"); | ||
| 972 | expect(result.ok).toBe(false); | ||
| 973 | expect(result.apnsStatus).toBe(400); | ||
| 974 | expect(result.apnsReason).toBe("BadDeviceToken"); | ||
| 975 | expect(invalidatedTokens).toMatchObject([ | ||
| 976 | { | ||
| 977 | userId: target.user_id, | ||
| 978 | deviceId: target.device_id, | ||
| 979 | kind: "push_notification", | ||
| 980 | }, | ||
| 981 | ]); | ||
| 982 | }).pipe( | ||
| 983 | Effect.provide( | ||
| 984 | makeLayer({ | ||
| 985 | attempts, | ||
| 986 | invalidatedTokens, | ||
| 987 | currentTargets: [ | ||
| 988 | { | ||
| 989 | ...target, | ||
| 990 | push_token: "apns-device-token", | ||
| 991 | }, | ||
| 992 | ], | ||
| 993 | config: signingConfig, | ||
| 994 | execute, | ||
| 995 | }), | ||
| 996 | ), | ||
| 997 | ); | ||
| 998 | }); | ||
| 999 | |||
| 1000 | it.effect("clears queued start state when a start job fails in APNs", () => { | ||
| 1001 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 1002 | const clearedStarts: Array< | ||
| 1003 | Parameters<LiveActivities.LiveActivitiesShape["clearStartQueued"]>[0] | ||
| 1004 | > = []; | ||
| 1005 | const payload = makeApnsDeliveryJobPayload({ | ||
| 1006 | kind: "live_activity_start", | ||
| 1007 | userId: target.user_id, | ||
| 1008 | deviceId: target.device_id, | ||
| 1009 | token: target.push_to_start_token ?? "start-token", | ||
| 1010 | aggregate, | ||
| 1011 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 1012 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 1013 | jobId: "job-start-1", | ||
| 1014 | }); | ||
| 1015 | const signed = signApnsDeliveryJob({ | ||
| 1016 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 1017 | payload, | ||
| 1018 | }); | ||
| 1019 | |||
| 1020 | return Effect.gen(function* () { | ||
| 1021 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 1022 | const result = yield* deliveries.processSignedJob(signed); | ||
| 1023 | |||
| 1024 | expect(result.kind).toBe("live_activity_start"); | ||
| 1025 | expect(result.ok).toBe(false); | ||
| 1026 | expect(clearedStarts).toEqual([ | ||
| 1027 | { | ||
| 1028 | userId: target.user_id, | ||
| 1029 | deviceId: target.device_id, | ||
| 1030 | }, | ||
| 1031 | ]); | ||
| 1032 | }).pipe(Effect.provide(makeLayer({ attempts, clearedStarts }))); | ||
| 1033 | }); | ||
| 1034 | |||
| 1035 | it.effect("invalidates dead push-to-start tokens after permanent APNs start failures", () => { | ||
| 1036 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 1037 | const invalidatedTokens: Array< | ||
| 1038 | Parameters<LiveActivities.LiveActivitiesShape["invalidateDeliveryToken"]>[0] | ||
| 1039 | > = []; | ||
| 1040 | const payload = makeApnsDeliveryJobPayload({ | ||
| 1041 | kind: "live_activity_start", | ||
| 1042 | userId: target.user_id, | ||
| 1043 | deviceId: target.device_id, | ||
| 1044 | token: target.push_to_start_token ?? "start-token", | ||
| 1045 | aggregate, | ||
| 1046 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 1047 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 1048 | jobId: "job-start-bad-token", | ||
| 1049 | }); | ||
| 1050 | const signed = signApnsDeliveryJob({ | ||
| 1051 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 1052 | payload, | ||
| 1053 | }); | ||
| 1054 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 1055 | Effect.succeed( | ||
| 1056 | HttpClientResponse.fromWeb( | ||
| 1057 | request, | ||
| 1058 | Response.json({ reason: "BadDeviceToken" }, { status: 400 }), | ||
| 1059 | ), | ||
| 1060 | ); | ||
| 1061 | |||
| 1062 | return Effect.gen(function* () { | ||
| 1063 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 1064 | const result = yield* deliveries.processSignedJob(signed); | ||
| 1065 | |||
| 1066 | expect(result.kind).toBe("live_activity_start"); | ||
| 1067 | expect(result.ok).toBe(false); | ||
| 1068 | expect(result.apnsStatus).toBe(400); | ||
| 1069 | expect(result.apnsReason).toBe("BadDeviceToken"); | ||
| 1070 | expect(invalidatedTokens).toMatchObject([ | ||
| 1071 | { | ||
| 1072 | userId: target.user_id, | ||
| 1073 | deviceId: target.device_id, | ||
| 1074 | kind: "live_activity_start", | ||
| 1075 | }, | ||
| 1076 | ]); | ||
| 1077 | }).pipe( | ||
| 1078 | Effect.provide(makeLayer({ attempts, invalidatedTokens, config: signingConfig, execute })), | ||
| 1079 | ); | ||
| 1080 | }); | ||
| 1081 | |||
| 1082 | it.effect("invalidates dead Live Activity tokens after APNs unregisters them", () => { | ||
| 1083 | const attempts: Array<DeliveryAttempts.DeliveryAttemptInput> = []; | ||
| 1084 | const invalidatedTokens: Array< | ||
| 1085 | Parameters<LiveActivities.LiveActivitiesShape["invalidateDeliveryToken"]>[0] | ||
| 1086 | > = []; | ||
| 1087 | const payload = makeApnsDeliveryJobPayload({ | ||
| 1088 | kind: "live_activity_update", | ||
| 1089 | userId: target.user_id, | ||
| 1090 | deviceId: target.device_id, | ||
| 1091 | token: target.activity_push_token ?? "activity-token", | ||
| 1092 | aggregate, | ||
| 1093 | createdAt: "1970-01-01T00:00:00.000Z", | ||
| 1094 | expiresAt: "1970-01-01T00:10:00.000Z", | ||
| 1095 | jobId: "job-update-unregistered", | ||
| 1096 | }); | ||
| 1097 | const signed = signApnsDeliveryJob({ | ||
| 1098 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 1099 | payload, | ||
| 1100 | }); | ||
| 1101 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 1102 | Effect.succeed( | ||
| 1103 | HttpClientResponse.fromWeb( | ||
| 1104 | request, | ||
| 1105 | Response.json({ reason: "Unregistered" }, { status: 410 }), | ||
| 1106 | ), | ||
| 1107 | ); | ||
| 1108 | |||
| 1109 | return Effect.gen(function* () { | ||
| 1110 | const deliveries = yield* ApnsDeliveries.ApnsDeliveries; | ||
| 1111 | const result = yield* deliveries.processSignedJob(signed); | ||
| 1112 | |||
| 1113 | expect(result.kind).toBe("live_activity_update"); | ||
| 1114 | expect(result.ok).toBe(false); | ||
| 1115 | expect(result.apnsStatus).toBe(410); | ||
| 1116 | expect(result.apnsReason).toBe("Unregistered"); | ||
| 1117 | expect(invalidatedTokens).toMatchObject([ | ||
| 1118 | { | ||
| 1119 | userId: target.user_id, | ||
| 1120 | deviceId: target.device_id, | ||
| 1121 | kind: "live_activity_update", | ||
| 1122 | }, | ||
| 1123 | ]); | ||
| 1124 | }).pipe( | ||
| 1125 | Effect.provide(makeLayer({ attempts, invalidatedTokens, config: signingConfig, execute })), | ||
| 1126 | ); | ||
| 1127 | }); | ||
| 1128 | }); | ||
infra/relay/src/agentActivity/ApnsDeliveries.ts created+795| ... | @@ -0,0 +1,795 @@ | ||
| 1 | import type { | ||
| 2 | RelayAgentActivityAggregateState, | ||
| 3 | RelayAgentAwarenessPreferences, | ||
| 4 | RelayDeliveryKind, | ||
| 5 | RelayDeliveryResult, | ||
| 6 | } from "@t3tools/contracts/relay"; | ||
| 7 | import { | ||
| 8 | RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema, | ||
| 9 | RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema, | ||
| 10 | } from "@t3tools/contracts/relay"; | ||
| 11 | import * as Context from "effect/Context"; | ||
| 12 | import * as Data from "effect/Data"; | ||
| 13 | import * as DateTime from "effect/DateTime"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import * as Option from "effect/Option"; | ||
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | sanitizeAgentActivityAggregateState, | ||
| 21 | sanitizeApnsNotificationPayload, | ||
| 22 | } from "./agentActivityPayloads.ts"; | ||
| 23 | import * as Apns from "./ApnsClient.ts"; | ||
| 24 | import { | ||
| 25 | ApnsDeliveryJobInvalid, | ||
| 26 | type ApnsNotificationPayload, | ||
| 27 | SignedApnsDeliveryJob, | ||
| 28 | verifySignedApnsDeliveryJob, | ||
| 29 | type ApnsDeliveryJobVerificationError, | ||
| 30 | } from "./apnsDeliveryJobs.ts"; | ||
| 31 | import * as DeliveryAttempts from "./DeliveryAttempts.ts"; | ||
| 32 | import * as LiveActivities from "./LiveActivities.ts"; | ||
| 33 | import * as RelayConfiguration from "../Config.ts"; | ||
| 34 | import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; | ||
| 35 | import { withSpanAttributes } from "../observability.ts"; | ||
| 36 | |||
| 37 | const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000; | ||
| 38 | const PERMANENT_APNS_TOKEN_REASONS = new Set([ | ||
| 39 | "BadDeviceToken", | ||
| 40 | "DeviceTokenNotForTopic", | ||
| 41 | "Unregistered", | ||
| 42 | ]); | ||
| 43 | |||
| 44 | type LiveActivityDeliveryKind = Extract< | ||
| 45 | RelayDeliveryKind, | ||
| 46 | "live_activity_start" | "live_activity_update" | "live_activity_end" | ||
| 47 | >; | ||
| 48 | |||
| 49 | type ChosenLiveActivityDelivery = | ||
| 50 | | { | ||
| 51 | readonly kind: "live_activity_start" | "live_activity_update"; | ||
| 52 | readonly token: string; | ||
| 53 | readonly aggregate: RelayAgentActivityAggregateState; | ||
| 54 | } | ||
| 55 | | { | ||
| 56 | readonly kind: "live_activity_end"; | ||
| 57 | readonly token: string; | ||
| 58 | readonly aggregate: RelayAgentActivityAggregateState | null; | ||
| 59 | }; | ||
| 60 | |||
| 61 | type ChosenPushNotificationDelivery = { | ||
| 62 | readonly kind: "push_notification"; | ||
| 63 | readonly token: string; | ||
| 64 | readonly notification: ApnsNotificationPayload; | ||
| 65 | }; | ||
| 66 | |||
| 67 | type ChosenDelivery = ChosenLiveActivityDelivery | ChosenPushNotificationDelivery; | ||
| 68 | |||
| 69 | export type ApnsDeliveryError = | ||
| 70 | | ApnsDeliveryQueue.ApnsDeliveryQueueError | ||
| 71 | | ApnsDeliveryJobVerificationError | ||
| 72 | | ApnsDeliveryJobClaimInFlight | ||
| 73 | | DeliveryAttempts.DeliveryAttemptRecordPersistenceError | ||
| 74 | | LiveActivities.LiveActivityTargetListPersistenceError | ||
| 75 | | LiveActivities.LiveActivityDeliveryMarkPersistenceError; | ||
| 76 | |||
| 77 | export class ApnsDeliveryJobClaimInFlight extends Data.TaggedError("ApnsDeliveryJobClaimInFlight")<{ | ||
| 78 | readonly sourceJobId: string; | ||
| 79 | }> {} | ||
| 80 | |||
| 81 | const decodeRelayAgentActivityAggregateStateJson = Schema.decodeUnknownOption( | ||
| 82 | Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), | ||
| 83 | ); | ||
| 84 | const decodeRelayAgentAwarenessPreferencesJson = Schema.decodeUnknownOption( | ||
| 85 | Schema.fromJsonString(RelayAgentAwarenessPreferencesSchema), | ||
| 86 | ); | ||
| 87 | const decodeSignedApnsDeliveryJob = Schema.decodeUnknownEffect(SignedApnsDeliveryJob); | ||
| 88 | |||
| 89 | function apnsErrorMessage(error: Apns.ApnsError): string { | ||
| 90 | switch (error._tag) { | ||
| 91 | case "ApnsSigningError": | ||
| 92 | return "Failed to sign APNs request."; | ||
| 93 | case "ApnsHttpRequestError": | ||
| 94 | return "Failed to send APNs request."; | ||
| 95 | case "ApnsInvalidResponseError": | ||
| 96 | return "APNs returned an invalid response."; | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | function parseAggregate(value: string | null): RelayAgentActivityAggregateState | null { | ||
| 101 | if (!value) { | ||
| 102 | return null; | ||
| 103 | } | ||
| 104 | return Option.getOrNull(decodeRelayAgentActivityAggregateStateJson(value)); | ||
| 105 | } | ||
| 106 | |||
| 107 | function parsePreferences(value: string): RelayAgentAwarenessPreferences | null { | ||
| 108 | return Option.getOrNull(decodeRelayAgentAwarenessPreferencesJson(value)); | ||
| 109 | } | ||
| 110 | |||
| 111 | function shouldUpdateLiveActivity(input: { | ||
| 112 | readonly previousAggregate: RelayAgentActivityAggregateState | null; | ||
| 113 | readonly nextAggregate: RelayAgentActivityAggregateState; | ||
| 114 | readonly lastDeliveryAt: string | null; | ||
| 115 | readonly nowMs: number; | ||
| 116 | }): boolean { | ||
| 117 | if (!input.previousAggregate) { | ||
| 118 | return true; | ||
| 119 | } | ||
| 120 | if (input.previousAggregate.activeCount !== input.nextAggregate.activeCount) { | ||
| 121 | return true; | ||
| 122 | } | ||
| 123 | if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) { | ||
| 124 | return false; | ||
| 125 | } | ||
| 126 | const lastDeliveryAtMs = | ||
| 127 | input.lastDeliveryAt === null | ||
| 128 | ? null | ||
| 129 | : Option.match(DateTime.make(input.lastDeliveryAt), { | ||
| 130 | onNone: () => Number.NaN, | ||
| 131 | onSome: (dt) => dt.epochMilliseconds, | ||
| 132 | }); | ||
| 133 | return ( | ||
| 134 | lastDeliveryAtMs === null || | ||
| 135 | Number.isNaN(lastDeliveryAtMs) || | ||
| 136 | input.nowMs - lastDeliveryAtMs >= MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | |||
| 140 | function notificationForAggregate(input: { | ||
| 141 | readonly target: LiveActivities.TargetRow; | ||
| 142 | readonly aggregate: RelayAgentActivityAggregateState | null; | ||
| 143 | }): ApnsNotificationPayload | null { | ||
| 144 | if (!input.target.push_token || input.aggregate === null) { | ||
| 145 | return null; | ||
| 146 | } | ||
| 147 | const preferences = parsePreferences(input.target.preferences_json); | ||
| 148 | if (!preferences?.notificationsEnabled) { | ||
| 149 | return null; | ||
| 150 | } | ||
| 151 | const activity = input.aggregate.activities[0]; | ||
| 152 | if (!activity) { | ||
| 153 | return null; | ||
| 154 | } | ||
| 155 | const enabled = | ||
| 156 | (activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) || | ||
| 157 | (activity.phase === "waiting_for_input" && preferences.notifyOnInput) || | ||
| 158 | (activity.phase === "completed" && preferences.notifyOnCompletion) || | ||
| 159 | (activity.phase === "failed" && preferences.notifyOnFailure); | ||
| 160 | if (!enabled) { | ||
| 161 | return null; | ||
| 162 | } | ||
| 163 | return { | ||
| 164 | title: activity.threadTitle, | ||
| 165 | body: `${activity.status}: ${activity.projectTitle}`, | ||
| 166 | environmentId: activity.environmentId, | ||
| 167 | threadId: activity.threadId, | ||
| 168 | deepLink: activity.deepLink, | ||
| 169 | }; | ||
| 170 | } | ||
| 171 | |||
| 172 | function chooseLiveActivityDelivery(input: { | ||
| 173 | readonly target: LiveActivities.TargetRow; | ||
| 174 | readonly aggregate: RelayAgentActivityAggregateState | null; | ||
| 175 | readonly nowMs: number; | ||
| 176 | }): ChosenLiveActivityDelivery | null { | ||
| 177 | const hasActiveActivity = | ||
| 178 | input.target.ended_at === null && | ||
| 179 | (input.target.remote_start_queued_at !== null || | ||
| 180 | input.target.remote_started_at !== null || | ||
| 181 | input.target.activity_push_token !== null); | ||
| 182 | const preferences = parsePreferences(input.target.preferences_json); | ||
| 183 | if (preferences?.liveActivitiesEnabled === false) { | ||
| 184 | return hasActiveActivity && input.target.activity_push_token | ||
| 185 | ? { | ||
| 186 | kind: "live_activity_end", | ||
| 187 | token: input.target.activity_push_token, | ||
| 188 | aggregate: null, | ||
| 189 | } | ||
| 190 | : null; | ||
| 191 | } | ||
| 192 | if (input.aggregate === null || input.aggregate.activeCount === 0) { | ||
| 193 | return hasActiveActivity && input.target.activity_push_token | ||
| 194 | ? { | ||
| 195 | kind: "live_activity_end", | ||
| 196 | token: input.target.activity_push_token, | ||
| 197 | aggregate: input.aggregate, | ||
| 198 | } | ||
| 199 | : null; | ||
| 200 | } | ||
| 201 | if (!hasActiveActivity) { | ||
| 202 | return input.target.push_to_start_token | ||
| 203 | ? { | ||
| 204 | kind: "live_activity_start", | ||
| 205 | token: input.target.push_to_start_token, | ||
| 206 | aggregate: input.aggregate, | ||
| 207 | } | ||
| 208 | : null; | ||
| 209 | } | ||
| 210 | if (!input.target.activity_push_token) { | ||
| 211 | return null; | ||
| 212 | } | ||
| 213 | return shouldUpdateLiveActivity({ | ||
| 214 | previousAggregate: parseAggregate(input.target.last_aggregate_json), | ||
| 215 | nextAggregate: input.aggregate, | ||
| 216 | lastDeliveryAt: input.target.last_live_activity_delivery_at, | ||
| 217 | nowMs: input.nowMs, | ||
| 218 | }) || | ||
| 219 | input.aggregate.activities.some( | ||
| 220 | (row) => row.phase === "waiting_for_approval" || row.phase === "waiting_for_input", | ||
| 221 | ) | ||
| 222 | ? { | ||
| 223 | kind: "live_activity_update", | ||
| 224 | token: input.target.activity_push_token, | ||
| 225 | aggregate: input.aggregate, | ||
| 226 | } | ||
| 227 | : null; | ||
| 228 | } | ||
| 229 | |||
| 230 | function chooseDelivery(input: { | ||
| 231 | readonly target: LiveActivities.TargetRow; | ||
| 232 | readonly aggregate: RelayAgentActivityAggregateState | null; | ||
| 233 | readonly nowMs: number; | ||
| 234 | }): ChosenDelivery | null { | ||
| 235 | const liveActivityDelivery = chooseLiveActivityDelivery(input); | ||
| 236 | if (liveActivityDelivery) { | ||
| 237 | return liveActivityDelivery; | ||
| 238 | } | ||
| 239 | const notification = notificationForAggregate(input); | ||
| 240 | return notification && input.target.push_token | ||
| 241 | ? { | ||
| 242 | kind: "push_notification", | ||
| 243 | token: input.target.push_token, | ||
| 244 | notification, | ||
| 245 | } | ||
| 246 | : null; | ||
| 247 | } | ||
| 248 | |||
| 249 | function deliveryEvent(kind: LiveActivityDeliveryKind): Apns.ApnsLiveActivityEvent { | ||
| 250 | switch (kind) { | ||
| 251 | case "live_activity_start": | ||
| 252 | return "start"; | ||
| 253 | case "live_activity_update": | ||
| 254 | return "update"; | ||
| 255 | case "live_activity_end": | ||
| 256 | return "end"; | ||
| 257 | } | ||
| 258 | } | ||
| 259 | |||
| 260 | function isPermanentApnsTokenFailure(result: Apns.ApnsDeliveryResult): boolean { | ||
| 261 | return ( | ||
| 262 | !result.ok && | ||
| 263 | (result.status === 410 || | ||
| 264 | (result.status === 400 && | ||
| 265 | result.reason !== undefined && | ||
| 266 | PERMANENT_APNS_TOKEN_REASONS.has(result.reason))) | ||
| 267 | ); | ||
| 268 | } | ||
| 269 | |||
| 270 | function isDeliveryJobVerificationError(value: unknown): value is ApnsDeliveryJobVerificationError { | ||
| 271 | return ( | ||
| 272 | typeof value === "object" && | ||
| 273 | value !== null && | ||
| 274 | "_tag" in value && | ||
| 275 | (value._tag === "ApnsDeliveryJobInvalid" || value._tag === "ApnsDeliveryJobExpired") | ||
| 276 | ); | ||
| 277 | } | ||
| 278 | |||
| 279 | function duplicateJobResult(input: { | ||
| 280 | readonly deviceId: string; | ||
| 281 | readonly kind: RelayDeliveryKind; | ||
| 282 | }): RelayDeliveryResult { | ||
| 283 | return { | ||
| 284 | deviceId: input.deviceId, | ||
| 285 | kind: input.kind, | ||
| 286 | ok: true, | ||
| 287 | apnsStatus: null, | ||
| 288 | apnsReason: "Duplicate APNs delivery job skipped.", | ||
| 289 | apnsId: null, | ||
| 290 | }; | ||
| 291 | } | ||
| 292 | |||
| 293 | function staleJobResult(input: { | ||
| 294 | readonly deviceId: string; | ||
| 295 | readonly kind: RelayDeliveryKind; | ||
| 296 | }): RelayDeliveryResult { | ||
| 297 | return { | ||
| 298 | deviceId: input.deviceId, | ||
| 299 | kind: input.kind, | ||
| 300 | ok: true, | ||
| 301 | apnsStatus: null, | ||
| 302 | apnsReason: "Stale APNs delivery job skipped.", | ||
| 303 | apnsId: null, | ||
| 304 | }; | ||
| 305 | } | ||
| 306 | |||
| 307 | function deliveryAttemptOutcome(result: Apns.ApnsDeliveryResult) { | ||
| 308 | return { | ||
| 309 | ...(result.status === 0 ? {} : { apnsStatus: result.status }), | ||
| 310 | ...(result.reason === undefined ? {} : { apnsReason: result.reason }), | ||
| 311 | apnsId: result.apnsId, | ||
| 312 | ...(result.status === 0 ? { transportError: result.reason ?? "APNs request failed." } : {}), | ||
| 313 | }; | ||
| 314 | } | ||
| 315 | |||
| 316 | interface LiveActivityDeliveryTarget { | ||
| 317 | readonly user_id: string; | ||
| 318 | readonly device_id: string; | ||
| 319 | } | ||
| 320 | |||
| 321 | function expectedCurrentToken(input: { | ||
| 322 | readonly target: LiveActivities.TargetRow; | ||
| 323 | readonly kind: RelayDeliveryKind; | ||
| 324 | }): string | null { | ||
| 325 | switch (input.kind) { | ||
| 326 | case "live_activity_start": | ||
| 327 | return input.target.push_to_start_token; | ||
| 328 | case "live_activity_update": | ||
| 329 | case "live_activity_end": | ||
| 330 | return input.target.activity_push_token; | ||
| 331 | case "push_notification": | ||
| 332 | return input.target.push_token; | ||
| 333 | } | ||
| 334 | } | ||
| 335 | |||
| 336 | interface SendLiveActivityDeliveryInputBase { | ||
| 337 | readonly target: LiveActivityDeliveryTarget; | ||
| 338 | readonly token: string; | ||
| 339 | readonly sourceJobId?: string | null; | ||
| 340 | } | ||
| 341 | |||
| 342 | export type SendLiveActivityDeliveryInput = | ||
| 343 | | (SendLiveActivityDeliveryInputBase & { | ||
| 344 | readonly kind: "live_activity_start" | "live_activity_update"; | ||
| 345 | readonly aggregate: RelayAgentActivityAggregateState; | ||
| 346 | }) | ||
| 347 | | (SendLiveActivityDeliveryInputBase & { | ||
| 348 | readonly kind: "live_activity_end"; | ||
| 349 | readonly aggregate: RelayAgentActivityAggregateState | null; | ||
| 350 | }); | ||
| 351 | |||
| 352 | function makeLiveActivityDeliveryRequest( | ||
| 353 | apns: Apns.ApnsClientShape, | ||
| 354 | input: SendLiveActivityDeliveryInput, | ||
| 355 | now: DateTime.DateTime, | ||
| 356 | ) { | ||
| 357 | const epochSeconds = Math.floor(now.epochMilliseconds / 1_000); | ||
| 358 | const base = { | ||
| 359 | token: input.token, | ||
| 360 | nowEpochSeconds: epochSeconds, | ||
| 361 | nowIso: DateTime.formatIso(now), | ||
| 362 | }; | ||
| 363 | switch (input.kind) { | ||
| 364 | case "live_activity_start": | ||
| 365 | case "live_activity_update": | ||
| 366 | return { | ||
| 367 | epochSeconds, | ||
| 368 | iso: base.nowIso, | ||
| 369 | request: apns.makeLiveActivityRequest({ | ||
| 370 | ...base, | ||
| 371 | event: deliveryEvent(input.kind), | ||
| 372 | state: input.aggregate, | ||
| 373 | }), | ||
| 374 | }; | ||
| 375 | case "live_activity_end": | ||
| 376 | return { | ||
| 377 | epochSeconds, | ||
| 378 | iso: base.nowIso, | ||
| 379 | request: apns.makeLiveActivityRequest({ | ||
| 380 | ...base, | ||
| 381 | event: "end", | ||
| 382 | state: input.aggregate, | ||
| 383 | }), | ||
| 384 | }; | ||
| 385 | } | ||
| 386 | } | ||
| 387 | |||
| 388 | export interface ApnsDeliveriesShape { | ||
| 389 | readonly sendForTarget: (input: { | ||
| 390 | readonly target: LiveActivities.TargetRow; | ||
| 391 | readonly aggregate: RelayAgentActivityAggregateState | null; | ||
| 392 | readonly nowMs: number; | ||
| 393 | }) => Effect.Effect<RelayDeliveryResult | null, ApnsDeliveryError>; | ||
| 394 | readonly sendPushNotificationForTarget: (input: { | ||
| 395 | readonly target: LiveActivities.TargetRow; | ||
| 396 | readonly aggregate: RelayAgentActivityAggregateState | null; | ||
| 397 | }) => Effect.Effect<RelayDeliveryResult | null, ApnsDeliveryError>; | ||
| 398 | readonly sendLiveActivity: ( | ||
| 399 | input: SendLiveActivityDeliveryInput, | ||
| 400 | ) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryError>; | ||
| 401 | readonly processSignedJob: ( | ||
| 402 | body: unknown, | ||
| 403 | ) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryError>; | ||
| 404 | readonly sendPushNotification: (input: { | ||
| 405 | readonly target: LiveActivityDeliveryTarget; | ||
| 406 | readonly token: string; | ||
| 407 | readonly sourceJobId?: string | null; | ||
| 408 | readonly notification: ApnsNotificationPayload; | ||
| 409 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryError>; | ||
| 410 | } | ||
| 411 | |||
| 412 | export class ApnsDeliveries extends Context.Service<ApnsDeliveries, ApnsDeliveriesShape>()( | ||
| 413 | "t3code-relay/agentActivity/ApnsDeliveries", | ||
| 414 | ) {} | ||
| 415 | |||
| 416 | const make = Effect.gen(function* () { | ||
| 417 | const attempts = yield* DeliveryAttempts.DeliveryAttempts; | ||
| 418 | const liveActivities = yield* LiveActivities.LiveActivities; | ||
| 419 | const deliveryQueue = yield* ApnsDeliveryQueue.ApnsDeliveryQueue; | ||
| 420 | const config = yield* RelayConfiguration.RelayConfiguration; | ||
| 421 | const apns = yield* Apns.ApnsClient; | ||
| 422 | |||
| 423 | const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { | ||
| 424 | readonly target: LiveActivityDeliveryTarget; | ||
| 425 | readonly kind: RelayDeliveryKind; | ||
| 426 | readonly token: string; | ||
| 427 | }) { | ||
| 428 | return yield* liveActivities.listTargets({ userId: input.target.user_id }).pipe( | ||
| 429 | Effect.map((targets) => { | ||
| 430 | const currentTarget = targets.find((row) => row.device_id === input.target.device_id); | ||
| 431 | return ( | ||
| 432 | currentTarget !== undefined && | ||
| 433 | expectedCurrentToken({ target: currentTarget, kind: input.kind }) === input.token | ||
| 434 | ); | ||
| 435 | }), | ||
| 436 | ); | ||
| 437 | }); | ||
| 438 | |||
| 439 | const sendLiveActivity: ApnsDeliveriesShape["sendLiveActivity"] = Effect.fn( | ||
| 440 | "relay.apns_deliveries.send_live_activity", | ||
| 441 | )(function* (input) { | ||
| 442 | yield* Effect.annotateCurrentSpan({ | ||
| 443 | "relay.mobile.device_id": input.target.device_id, | ||
| 444 | "relay.delivery.kind": input.kind, | ||
| 445 | ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), | ||
| 446 | }); | ||
| 447 | const now = yield* DateTime.now; | ||
| 448 | const aggregate = | ||
| 449 | input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate); | ||
| 450 | const { epochSeconds, iso, request } = makeLiveActivityDeliveryRequest( | ||
| 451 | apns, | ||
| 452 | { ...input, aggregate } as SendLiveActivityDeliveryInput, | ||
| 453 | now, | ||
| 454 | ); | ||
| 455 | if (input.sourceJobId) { | ||
| 456 | const claim = yield* attempts.claimSourceJob({ | ||
| 457 | userId: input.target.user_id, | ||
| 458 | environmentId: null, | ||
| 459 | threadId: null, | ||
| 460 | deviceId: input.target.device_id, | ||
| 461 | kind: input.kind, | ||
| 462 | sourceJobId: input.sourceJobId, | ||
| 463 | token: input.token, | ||
| 464 | }); | ||
| 465 | if (claim === "completed") { | ||
| 466 | return duplicateJobResult({ deviceId: input.target.device_id, kind: input.kind }); | ||
| 467 | } | ||
| 468 | if (claim === "in_flight") { | ||
| 469 | return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId }); | ||
| 470 | } | ||
| 471 | const tokenIsCurrent = yield* isCurrentSignedJobToken({ | ||
| 472 | target: input.target, | ||
| 473 | kind: input.kind, | ||
| 474 | token: input.token, | ||
| 475 | }); | ||
| 476 | if (!tokenIsCurrent) { | ||
| 477 | yield* attempts.completeSourceJob({ | ||
| 478 | sourceJobId: input.sourceJobId, | ||
| 479 | apnsReason: "Stale APNs delivery job skipped.", | ||
| 480 | }); | ||
| 481 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); | ||
| 482 | } | ||
| 483 | } | ||
| 484 | const result = yield* apns | ||
| 485 | .sendLiveActivityRequest({ | ||
| 486 | credentials: config.apns, | ||
| 487 | request, | ||
| 488 | issuedAtUnixSeconds: epochSeconds, | ||
| 489 | }) | ||
| 490 | .pipe( | ||
| 491 | Effect.catch((error) => | ||
| 492 | Effect.succeed({ | ||
| 493 | ok: false, | ||
| 494 | status: 0, | ||
| 495 | reason: apnsErrorMessage(error), | ||
| 496 | apnsId: null, | ||
| 497 | }), | ||
| 498 | ), | ||
| 499 | ); | ||
| 500 | if (result.ok) { | ||
| 501 | yield* liveActivities.markDelivery({ | ||
| 502 | userId: input.target.user_id, | ||
| 503 | deviceId: input.target.device_id, | ||
| 504 | kind: input.kind, | ||
| 505 | aggregate, | ||
| 506 | deliveredAt: iso, | ||
| 507 | }); | ||
| 508 | } else if (isPermanentApnsTokenFailure(result)) { | ||
| 509 | yield* liveActivities.invalidateDeliveryToken({ | ||
| 510 | userId: input.target.user_id, | ||
| 511 | deviceId: input.target.device_id, | ||
| 512 | kind: input.kind, | ||
| 513 | invalidatedAt: iso, | ||
| 514 | }); | ||
| 515 | } else if (input.kind === "live_activity_start") { | ||
| 516 | yield* liveActivities.clearStartQueued({ | ||
| 517 | userId: input.target.user_id, | ||
| 518 | deviceId: input.target.device_id, | ||
| 519 | }); | ||
| 520 | } | ||
| 521 | if (input.sourceJobId) { | ||
| 522 | yield* attempts.completeSourceJob({ | ||
| 523 | sourceJobId: input.sourceJobId, | ||
| 524 | ...deliveryAttemptOutcome(result), | ||
| 525 | }); | ||
| 526 | } else { | ||
| 527 | yield* attempts.record({ | ||
| 528 | userId: input.target.user_id, | ||
| 529 | environmentId: null, | ||
| 530 | threadId: null, | ||
| 531 | deviceId: input.target.device_id, | ||
| 532 | kind: input.kind, | ||
| 533 | token: input.token, | ||
| 534 | ...deliveryAttemptOutcome(result), | ||
| 535 | }); | ||
| 536 | } | ||
| 537 | return { | ||
| 538 | deviceId: input.target.device_id, | ||
| 539 | kind: input.kind, | ||
| 540 | ok: result.ok, | ||
| 541 | apnsStatus: result.status === 0 ? null : result.status, | ||
| 542 | apnsReason: result.reason ?? null, | ||
| 543 | apnsId: result.apnsId, | ||
| 544 | }; | ||
| 545 | }); | ||
| 546 | |||
| 547 | const sendPushNotification: ApnsDeliveriesShape["sendPushNotification"] = Effect.fn( | ||
| 548 | "relay.apns_deliveries.send_push_notification", | ||
| 549 | )(function* (input) { | ||
| 550 | yield* Effect.annotateCurrentSpan({ | ||
| 551 | "relay.mobile.device_id": input.target.device_id, | ||
| 552 | "relay.delivery.kind": "push_notification", | ||
| 553 | ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), | ||
| 554 | }); | ||
| 555 | const now = yield* DateTime.now; | ||
| 556 | const epochSeconds = Math.floor(now.epochMilliseconds / 1_000); | ||
| 557 | const notification = sanitizeApnsNotificationPayload(input.notification); | ||
| 558 | yield* Effect.annotateCurrentSpan({ | ||
| 559 | "relay.environment_id": notification.environmentId, | ||
| 560 | "relay.thread_id": notification.threadId, | ||
| 561 | }); | ||
| 562 | const request = apns.makePushNotificationRequest({ | ||
| 563 | token: input.token, | ||
| 564 | notification, | ||
| 565 | }); | ||
| 566 | if (input.sourceJobId) { | ||
| 567 | const claim = yield* attempts.claimSourceJob({ | ||
| 568 | userId: input.target.user_id, | ||
| 569 | environmentId: notification.environmentId, | ||
| 570 | threadId: notification.threadId, | ||
| 571 | deviceId: input.target.device_id, | ||
| 572 | kind: "push_notification", | ||
| 573 | sourceJobId: input.sourceJobId, | ||
| 574 | token: input.token, | ||
| 575 | }); | ||
| 576 | if (claim === "completed") { | ||
| 577 | return duplicateJobResult({ | ||
| 578 | deviceId: input.target.device_id, | ||
| 579 | kind: "push_notification", | ||
| 580 | }); | ||
| 581 | } | ||
| 582 | if (claim === "in_flight") { | ||
| 583 | return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId }); | ||
| 584 | } | ||
| 585 | const tokenIsCurrent = yield* isCurrentSignedJobToken({ | ||
| 586 | target: input.target, | ||
| 587 | kind: "push_notification", | ||
| 588 | token: input.token, | ||
| 589 | }); | ||
| 590 | if (!tokenIsCurrent) { | ||
| 591 | yield* attempts.completeSourceJob({ | ||
| 592 | sourceJobId: input.sourceJobId, | ||
| 593 | apnsReason: "Stale APNs delivery job skipped.", | ||
| 594 | }); | ||
| 595 | return staleJobResult({ | ||
| 596 | deviceId: input.target.device_id, | ||
| 597 | kind: "push_notification", | ||
| 598 | }); | ||
| 599 | } | ||
| 600 | } | ||
| 601 | const result = yield* apns | ||
| 602 | .sendPushNotificationRequest({ | ||
| 603 | credentials: config.apns, | ||
| 604 | request, | ||
| 605 | issuedAtUnixSeconds: epochSeconds, | ||
| 606 | }) | ||
| 607 | .pipe( | ||
| 608 | Effect.catch((error) => | ||
| 609 | Effect.succeed({ | ||
| 610 | ok: false, | ||
| 611 | status: 0, | ||
| 612 | reason: apnsErrorMessage(error), | ||
| 613 | apnsId: null, | ||
| 614 | }), | ||
| 615 | ), | ||
| 616 | ); | ||
| 617 | if (isPermanentApnsTokenFailure(result)) { | ||
| 618 | yield* liveActivities.invalidateDeliveryToken({ | ||
| 619 | userId: input.target.user_id, | ||
| 620 | deviceId: input.target.device_id, | ||
| 621 | kind: "push_notification", | ||
| 622 | invalidatedAt: DateTime.formatIso(now), | ||
| 623 | }); | ||
| 624 | } | ||
| 625 | if (input.sourceJobId) { | ||
| 626 | yield* attempts.completeSourceJob({ | ||
| 627 | sourceJobId: input.sourceJobId, | ||
| 628 | ...deliveryAttemptOutcome(result), | ||
| 629 | }); | ||
| 630 | } else { | ||
| 631 | yield* attempts.record({ | ||
| 632 | userId: input.target.user_id, | ||
| 633 | environmentId: notification.environmentId, | ||
| 634 | threadId: notification.threadId, | ||
| 635 | deviceId: input.target.device_id, | ||
| 636 | kind: "push_notification", | ||
| 637 | token: input.token, | ||
| 638 | ...deliveryAttemptOutcome(result), | ||
| 639 | }); | ||
| 640 | } | ||
| 641 | return { | ||
| 642 | deviceId: input.target.device_id, | ||
| 643 | kind: "push_notification" as const, | ||
| 644 | ok: result.ok, | ||
| 645 | apnsStatus: result.status === 0 ? null : result.status, | ||
| 646 | apnsReason: result.reason ?? null, | ||
| 647 | apnsId: result.apnsId, | ||
| 648 | }; | ||
| 649 | }); | ||
| 650 | |||
| 651 | const processSignedJob: ApnsDeliveriesShape["processSignedJob"] = Effect.fn( | ||
| 652 | "relay.apns_deliveries.process_signed_job", | ||
| 653 | )(function* (body) { | ||
| 654 | const signedJob = yield* decodeSignedApnsDeliveryJob(body).pipe( | ||
| 655 | Effect.mapError( | ||
| 656 | () => | ||
| 657 | new ApnsDeliveryJobInvalid({ | ||
| 658 | message: "Invalid APNs delivery queue job.", | ||
| 659 | }), | ||
| 660 | ), | ||
| 661 | ); | ||
| 662 | const now = yield* DateTime.now; | ||
| 663 | const payload = verifySignedApnsDeliveryJob({ | ||
| 664 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 665 | job: signedJob, | ||
| 666 | nowMs: now.epochMilliseconds, | ||
| 667 | }); | ||
| 668 | if (isDeliveryJobVerificationError(payload)) { | ||
| 669 | return yield* payload; | ||
| 670 | } | ||
| 671 | yield* Effect.annotateCurrentSpan({ | ||
| 672 | "relay.mobile.device_id": payload.target.deviceId, | ||
| 673 | "relay.delivery.kind": payload.kind, | ||
| 674 | "relay.delivery.job_id": payload.jobId, | ||
| 675 | }); | ||
| 676 | return yield* Effect.suspend(() => { | ||
| 677 | switch (payload.kind) { | ||
| 678 | case "live_activity_start": | ||
| 679 | case "live_activity_update": | ||
| 680 | if (payload.aggregate === null) { | ||
| 681 | return Effect.fail( | ||
| 682 | new ApnsDeliveryJobInvalid({ | ||
| 683 | message: "Live Activity start/update jobs require an aggregate.", | ||
| 684 | }), | ||
| 685 | ); | ||
| 686 | } | ||
| 687 | return sendLiveActivity({ | ||
| 688 | target: { | ||
| 689 | user_id: payload.target.userId, | ||
| 690 | device_id: payload.target.deviceId, | ||
| 691 | }, | ||
| 692 | token: payload.target.token, | ||
| 693 | sourceJobId: payload.jobId, | ||
| 694 | kind: payload.kind, | ||
| 695 | aggregate: payload.aggregate, | ||
| 696 | }); | ||
| 697 | case "live_activity_end": | ||
| 698 | return sendLiveActivity({ | ||
| 699 | target: { | ||
| 700 | user_id: payload.target.userId, | ||
| 701 | device_id: payload.target.deviceId, | ||
| 702 | }, | ||
| 703 | token: payload.target.token, | ||
| 704 | sourceJobId: payload.jobId, | ||
| 705 | kind: payload.kind, | ||
| 706 | aggregate: payload.aggregate, | ||
| 707 | }); | ||
| 708 | case "push_notification": | ||
| 709 | if (payload.notification === null) { | ||
| 710 | return Effect.fail( | ||
| 711 | new ApnsDeliveryJobInvalid({ | ||
| 712 | message: "Push notification jobs require a notification payload.", | ||
| 713 | }), | ||
| 714 | ); | ||
| 715 | } | ||
| 716 | return sendPushNotification({ | ||
| 717 | target: { | ||
| 718 | user_id: payload.target.userId, | ||
| 719 | device_id: payload.target.deviceId, | ||
| 720 | }, | ||
| 721 | token: payload.target.token, | ||
| 722 | sourceJobId: payload.jobId, | ||
| 723 | notification: payload.notification, | ||
| 724 | }); | ||
| 725 | } | ||
| 726 | }).pipe(withSpanAttributes({ "user.id": payload.target.userId })); | ||
| 727 | }); | ||
| 728 | |||
| 729 | return ApnsDeliveries.of({ | ||
| 730 | sendLiveActivity, | ||
| 731 | sendPushNotification, | ||
| 732 | processSignedJob, | ||
| 733 | sendPushNotificationForTarget: Effect.fnUntraced(function* (input) { | ||
| 734 | const notification = notificationForAggregate(input); | ||
| 735 | const token = input.target.push_token; | ||
| 736 | return yield* notification && token | ||
| 737 | ? deliveryQueue.enqueuePushNotification({ | ||
| 738 | userId: input.target.user_id, | ||
| 739 | deviceId: input.target.device_id, | ||
| 740 | token, | ||
| 741 | notification, | ||
| 742 | }) | ||
| 743 | : Effect.succeed(null); | ||
| 744 | }), | ||
| 745 | sendForTarget: Effect.fnUntraced(function* (input) { | ||
| 746 | const delivery = chooseDelivery({ | ||
| 747 | target: input.target, | ||
| 748 | aggregate: input.aggregate, | ||
| 749 | nowMs: input.nowMs, | ||
| 750 | }); | ||
| 751 | if (!delivery) { | ||
| 752 | return null; | ||
| 753 | } | ||
| 754 | if (delivery.kind === "push_notification") { | ||
| 755 | const result = yield* deliveryQueue.enqueuePushNotification({ | ||
| 756 | userId: input.target.user_id, | ||
| 757 | deviceId: input.target.device_id, | ||
| 758 | token: delivery.token, | ||
| 759 | notification: delivery.notification, | ||
| 760 | }); | ||
| 761 | return result; | ||
| 762 | } | ||
| 763 | const result = yield* deliveryQueue.enqueueLiveActivity({ | ||
| 764 | userId: input.target.user_id, | ||
| 765 | deviceId: input.target.device_id, | ||
| 766 | kind: delivery.kind, | ||
| 767 | token: delivery.token, | ||
| 768 | aggregate: delivery.aggregate, | ||
| 769 | }); | ||
| 770 | const notification = notificationForAggregate({ | ||
| 771 | target: input.target, | ||
| 772 | aggregate: input.aggregate, | ||
| 773 | }); | ||
| 774 | if (delivery.kind === "live_activity_end" && notification && input.target.push_token) { | ||
| 775 | yield* deliveryQueue.enqueuePushNotification({ | ||
| 776 | userId: input.target.user_id, | ||
| 777 | deviceId: input.target.device_id, | ||
| 778 | token: input.target.push_token, | ||
| 779 | notification, | ||
| 780 | }); | ||
| 781 | } | ||
| 782 | if (delivery.kind === "live_activity_start") { | ||
| 783 | const now = yield* DateTime.now; | ||
| 784 | yield* liveActivities.markStartQueued({ | ||
| 785 | userId: input.target.user_id, | ||
| 786 | deviceId: input.target.device_id, | ||
| 787 | queuedAt: DateTime.formatIso(now), | ||
| 788 | }); | ||
| 789 | } | ||
| 790 | return result; | ||
| 791 | }), | ||
| 792 | }); | ||
| 793 | }); | ||
| 794 | |||
| 795 | export const layer = Layer.effect(ApnsDeliveries, make); | ||
infra/relay/src/agentActivity/ApnsDeliveryQueue.ts created+163| ... | @@ -0,0 +1,163 @@ | ||
| 1 | import * as Alchemy from "alchemy"; | ||
| 2 | import * as Cloudflare from "alchemy/Cloudflare"; | ||
| 3 | import * as Crypto from "effect/Crypto"; | ||
| 4 | import * as Context from "effect/Context"; | ||
| 5 | import * as Data from "effect/Data"; | ||
| 6 | import * as DateTime from "effect/DateTime"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | |||
| 10 | import type { RelayDeliveryResult } from "@t3tools/contracts/relay"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | sanitizeAgentActivityAggregateState, | ||
| 14 | sanitizeApnsNotificationPayload, | ||
| 15 | } from "./agentActivityPayloads.ts"; | ||
| 16 | import { | ||
| 17 | expiresAtForJob, | ||
| 18 | makeApnsDeliveryJobPayload, | ||
| 19 | signApnsDeliveryJob, | ||
| 20 | type ApnsDeliveryJobPayload, | ||
| 21 | type SignedApnsDeliveryJob, | ||
| 22 | } from "./apnsDeliveryJobs.ts"; | ||
| 23 | import * as RelayConfiguration from "../Config.ts"; | ||
| 24 | |||
| 25 | export class ApnsDeliveryQueueSendError extends Data.TaggedError("ApnsDeliveryQueueSendError")<{ | ||
| 26 | readonly cause: unknown; | ||
| 27 | }> {} | ||
| 28 | |||
| 29 | export type ApnsDeliveryQueueError = ApnsDeliveryQueueSendError; | ||
| 30 | |||
| 31 | export interface ApnsDeliveryQueueSenderShape { | ||
| 32 | readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect<void, ApnsDeliveryQueueSendError>; | ||
| 33 | } | ||
| 34 | |||
| 35 | export class ApnsDeliveryQueueSender extends Context.Service< | ||
| 36 | ApnsDeliveryQueueSender, | ||
| 37 | ApnsDeliveryQueueSenderShape | ||
| 38 | >()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {} | ||
| 39 | |||
| 40 | export interface ApnsDeliveryQueueShape { | ||
| 41 | readonly enqueueLiveActivity: (input: { | ||
| 42 | readonly kind: ApnsDeliveryJobPayload["kind"]; | ||
| 43 | readonly userId: string; | ||
| 44 | readonly deviceId: string; | ||
| 45 | readonly token: string; | ||
| 46 | readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; | ||
| 47 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; | ||
| 48 | readonly enqueuePushNotification: (input: { | ||
| 49 | readonly userId: string; | ||
| 50 | readonly deviceId: string; | ||
| 51 | readonly token: string; | ||
| 52 | readonly notification: NonNullable<ApnsDeliveryJobPayload["notification"]>; | ||
| 53 | }) => Effect.Effect<RelayDeliveryResult, ApnsDeliveryQueueError>; | ||
| 54 | } | ||
| 55 | |||
| 56 | export class ApnsDeliveryQueue extends Context.Service<ApnsDeliveryQueue, ApnsDeliveryQueueShape>()( | ||
| 57 | "t3code-relay/agentActivity/ApnsDeliveryQueue", | ||
| 58 | ) {} | ||
| 59 | |||
| 60 | const make = Effect.gen(function* () { | ||
| 61 | const sender = yield* ApnsDeliveryQueueSender; | ||
| 62 | const crypto = yield* Crypto.Crypto; | ||
| 63 | const config = yield* RelayConfiguration.RelayConfiguration; | ||
| 64 | |||
| 65 | return ApnsDeliveryQueue.of({ | ||
| 66 | enqueueLiveActivity: Effect.fn("relay.apns_delivery_queue.enqueue_live_activity")( | ||
| 67 | function* (input) { | ||
| 68 | yield* Effect.annotateCurrentSpan({ | ||
| 69 | "relay.mobile.device_id": input.deviceId, | ||
| 70 | "relay.delivery.kind": input.kind, | ||
| 71 | }); | ||
| 72 | const now = yield* DateTime.now; | ||
| 73 | const jobId = yield* crypto.randomUUIDv4.pipe( | ||
| 74 | Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), | ||
| 75 | ); | ||
| 76 | yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); | ||
| 77 | const payload = makeApnsDeliveryJobPayload({ | ||
| 78 | ...input, | ||
| 79 | aggregate: | ||
| 80 | input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate), | ||
| 81 | jobId, | ||
| 82 | createdAt: DateTime.formatIso(now), | ||
| 83 | expiresAt: expiresAtForJob(now.epochMilliseconds), | ||
| 84 | }); | ||
| 85 | const signed = signApnsDeliveryJob({ | ||
| 86 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 87 | payload, | ||
| 88 | }); | ||
| 89 | yield* sender.send(signed); | ||
| 90 | return { | ||
| 91 | deviceId: input.deviceId, | ||
| 92 | kind: input.kind, | ||
| 93 | ok: true, | ||
| 94 | queued: true, | ||
| 95 | apnsStatus: null, | ||
| 96 | apnsReason: null, | ||
| 97 | apnsId: null, | ||
| 98 | }; | ||
| 99 | }, | ||
| 100 | ), | ||
| 101 | enqueuePushNotification: Effect.fn("relay.apns_delivery_queue.enqueue_push_notification")( | ||
| 102 | function* (input) { | ||
| 103 | yield* Effect.annotateCurrentSpan({ | ||
| 104 | "relay.mobile.device_id": input.deviceId, | ||
| 105 | "relay.delivery.kind": "push_notification", | ||
| 106 | "relay.environment_id": input.notification.environmentId, | ||
| 107 | "relay.thread_id": input.notification.threadId, | ||
| 108 | }); | ||
| 109 | const now = yield* DateTime.now; | ||
| 110 | const jobId = yield* crypto.randomUUIDv4.pipe( | ||
| 111 | Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), | ||
| 112 | ); | ||
| 113 | yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": jobId }); | ||
| 114 | const payload = makeApnsDeliveryJobPayload({ | ||
| 115 | kind: "push_notification", | ||
| 116 | userId: input.userId, | ||
| 117 | deviceId: input.deviceId, | ||
| 118 | token: input.token, | ||
| 119 | aggregate: null, | ||
| 120 | notification: sanitizeApnsNotificationPayload(input.notification), | ||
| 121 | jobId, | ||
| 122 | createdAt: DateTime.formatIso(now), | ||
| 123 | expiresAt: expiresAtForJob(now.epochMilliseconds), | ||
| 124 | }); | ||
| 125 | const signed = signApnsDeliveryJob({ | ||
| 126 | secret: config.apnsDeliveryJobSigningSecret, | ||
| 127 | payload, | ||
| 128 | }); | ||
| 129 | yield* sender.send(signed); | ||
| 130 | return { | ||
| 131 | deviceId: input.deviceId, | ||
| 132 | kind: "push_notification" as const, | ||
| 133 | ok: true, | ||
| 134 | queued: true, | ||
| 135 | apnsStatus: null, | ||
| 136 | apnsReason: null, | ||
| 137 | apnsId: null, | ||
| 138 | }; | ||
| 139 | }, | ||
| 140 | ), | ||
| 141 | }); | ||
| 142 | }); | ||
| 143 | |||
| 144 | export const layer = Layer.effect(ApnsDeliveryQueue, make); | ||
| 145 | |||
| 146 | export const layerCloudflareQueues = ( | ||
| 147 | sender: Cloudflare.QueueSender, | ||
| 148 | alchemyRuntimeContext: Alchemy.BaseRuntimeContext, | ||
| 149 | ) => | ||
| 150 | layer.pipe( | ||
| 151 | Layer.provide( | ||
| 152 | Layer.succeed( | ||
| 153 | ApnsDeliveryQueueSender, | ||
| 154 | ApnsDeliveryQueueSender.of({ | ||
| 155 | send: (body) => | ||
| 156 | sender.send(body).pipe( | ||
| 157 | Effect.mapError((cause) => new ApnsDeliveryQueueSendError({ cause })), | ||
| 158 | Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), | ||
| 159 | ), | ||
| 160 | }), | ||
| 161 | ), | ||
| 162 | ), | ||
| 163 | ); | ||
infra/relay/src/agentActivity/DeliveryAttempts.test.ts created+323| ... | @@ -0,0 +1,323 @@ | ||
| 1 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | |||
| 6 | import { RelayDb, type RelayDatabase } from "../db.ts"; | ||
| 7 | import { relayDeliveryAttempts } from "../persistence/schema.ts"; | ||
| 8 | import * as DeliveryAttempts from "./DeliveryAttempts.ts"; | ||
| 9 | |||
| 10 | describe("DeliveryAttempts", () => { | ||
| 11 | it.effect("records the signed queue source job id for APNs delivery auditability", () => { | ||
| 12 | const insertedValues: Array<Record<string, unknown>> = []; | ||
| 13 | const fakeDb = { | ||
| 14 | insert: (table: unknown) => { | ||
| 15 | expect(table).toBe(relayDeliveryAttempts); | ||
| 16 | return { | ||
| 17 | values: (values: Record<string, unknown>) => { | ||
| 18 | insertedValues.push(values); | ||
| 19 | return Effect.void; | ||
| 20 | }, | ||
| 21 | }; | ||
| 22 | }, | ||
| 23 | } as unknown as RelayDatabase; | ||
| 24 | |||
| 25 | return Effect.gen(function* () { | ||
| 26 | const attempts = yield* DeliveryAttempts.DeliveryAttempts; | ||
| 27 | yield* attempts.record({ | ||
| 28 | userId: "user-1", | ||
| 29 | environmentId: "env-1", | ||
| 30 | threadId: "thread-1", | ||
| 31 | deviceId: "device-1", | ||
| 32 | kind: "live_activity_update", | ||
| 33 | sourceJobId: "job-1", | ||
| 34 | token: "apns-token", | ||
| 35 | apnsStatus: 200, | ||
| 36 | apnsId: "apns-id", | ||
| 37 | }); | ||
| 38 | |||
| 39 | expect(insertedValues).toHaveLength(1); | ||
| 40 | expect(insertedValues[0]).toMatchObject({ | ||
| 41 | userId: "user-1", | ||
| 42 | environmentId: "env-1", | ||
| 43 | threadId: "thread-1", | ||
| 44 | deviceId: "device-1", | ||
| 45 | kind: "live_activity_update", | ||
| 46 | sourceJobId: "job-1", | ||
| 47 | tokenSuffix: "ns-token", | ||
| 48 | apnsStatus: 200, | ||
| 49 | apnsId: "apns-id", | ||
| 50 | }); | ||
| 51 | }).pipe( | ||
| 52 | Effect.provide( | ||
| 53 | DeliveryAttempts.layer.pipe( | ||
| 54 | Layer.provide(NodeCryptoLayer.layer), | ||
| 55 | Layer.provide(Layer.succeed(RelayDb, fakeDb)), | ||
| 56 | ), | ||
| 57 | ), | ||
| 58 | ); | ||
| 59 | }); | ||
| 60 | |||
| 61 | it.effect("claims signed queue source jobs before APNs delivery", () => { | ||
| 62 | const insertedValues: Array<Record<string, unknown>> = []; | ||
| 63 | const conflictTargets: Array<unknown> = []; | ||
| 64 | const fakeDb = { | ||
| 65 | insert: (table: unknown) => { | ||
| 66 | expect(table).toBe(relayDeliveryAttempts); | ||
| 67 | return { | ||
| 68 | values: (values: Record<string, unknown>) => { | ||
| 69 | insertedValues.push(values); | ||
| 70 | return { | ||
| 71 | onConflictDoNothing: (config: { readonly target: unknown }) => { | ||
| 72 | conflictTargets.push(config.target); | ||
| 73 | return { | ||
| 74 | returning: () => Effect.succeed([{ id: values.id }]), | ||
| 75 | }; | ||
| 76 | }, | ||
| 77 | }; | ||
| 78 | }, | ||
| 79 | }; | ||
| 80 | }, | ||
| 81 | } as unknown as RelayDatabase; | ||
| 82 | |||
| 83 | return Effect.gen(function* () { | ||
| 84 | const attempts = yield* DeliveryAttempts.DeliveryAttempts; | ||
| 85 | const claimed = yield* attempts.claimSourceJob({ | ||
| 86 | userId: "user-1", | ||
| 87 | environmentId: "env-1", | ||
| 88 | threadId: "thread-1", | ||
| 89 | deviceId: "device-1", | ||
| 90 | kind: "push_notification", | ||
| 91 | sourceJobId: "job-1", | ||
| 92 | token: "apns-token", | ||
| 93 | }); | ||
| 94 | |||
| 95 | expect(claimed).toBe("claimed"); | ||
| 96 | expect(conflictTargets).toEqual([relayDeliveryAttempts.sourceJobId]); | ||
| 97 | expect(insertedValues[0]).toMatchObject({ | ||
| 98 | kind: "push_notification", | ||
| 99 | sourceJobId: "job-1", | ||
| 100 | tokenSuffix: "ns-token", | ||
| 101 | apnsStatus: null, | ||
| 102 | }); | ||
| 103 | }).pipe( | ||
| 104 | Effect.provide( | ||
| 105 | DeliveryAttempts.layer.pipe( | ||
| 106 | Layer.provide(NodeCryptoLayer.layer), | ||
| 107 | Layer.provide(Layer.succeed(RelayDb, fakeDb)), | ||
| 108 | ), | ||
| 109 | ), | ||
| 110 | ); | ||
| 111 | }); | ||
| 112 | |||
| 113 | it.effect("reports completed source jobs when the durable claim already exists", () => { | ||
| 114 | const fakeDb = { | ||
| 115 | insert: () => ({ | ||
| 116 | values: () => ({ | ||
| 117 | onConflictDoNothing: () => ({ | ||
| 118 | returning: () => Effect.succeed([]), | ||
| 119 | }), | ||
| 120 | }), | ||
| 121 | }), | ||
| 122 | select: () => ({ | ||
| 123 | from: () => ({ | ||
| 124 | where: () => ({ | ||
| 125 | limit: () => | ||
| 126 | Effect.succeed([ | ||
| 127 | { | ||
| 128 | createdAt: "2026-05-26T00:00:00.000Z", | ||
| 129 | apnsStatus: 200, | ||
| 130 | apnsReason: null, | ||
| 131 | apnsId: null, | ||
| 132 | transportError: null, | ||
| 133 | }, | ||
| 134 | ]), | ||
| 135 | }), | ||
| 136 | }), | ||
| 137 | }), | ||
| 138 | } as unknown as RelayDatabase; | ||
| 139 | |||
| 140 | return Effect.gen(function* () { | ||
| 141 | const attempts = yield* DeliveryAttempts.DeliveryAttempts; | ||
| 142 | const claimed = yield* attempts.claimSourceJob({ | ||
| 143 | userId: "user-1", | ||
| 144 | environmentId: null, | ||
| 145 | threadId: null, | ||
| 146 | deviceId: "device-1", | ||
| 147 | kind: "live_activity_update", | ||
| 148 | sourceJobId: "job-1", | ||
| 149 | token: "apns-token", | ||
| 150 | }); | ||
| 151 | |||
| 152 | expect(claimed).toBe("completed"); | ||
| 153 | }).pipe( | ||
| 154 | Effect.provide( | ||
| 155 | DeliveryAttempts.layer.pipe( | ||
| 156 | Layer.provide(NodeCryptoLayer.layer), | ||
| 157 | Layer.provide(Layer.succeed(RelayDb, fakeDb)), | ||
| 158 | ), | ||
| 159 | ), | ||
| 160 | ); | ||
| 161 | }); | ||
| 162 | |||
| 163 | it.effect("reports in-flight source jobs while an active claim lease exists", () => { | ||
| 164 | const fakeDb = { | ||
| 165 | insert: () => ({ | ||
| 166 | values: () => ({ | ||
| 167 | onConflictDoNothing: () => ({ | ||
| 168 | returning: () => Effect.succeed([]), | ||
| 169 | }), | ||
| 170 | }), | ||
| 171 | }), | ||
| 172 | select: () => ({ | ||
| 173 | from: () => ({ | ||
| 174 | where: () => ({ | ||
| 175 | limit: () => | ||
| 176 | Effect.succeed([ | ||
| 177 | { | ||
| 178 | createdAt: "2999-01-01T00:00:00.000Z", | ||
| 179 | apnsStatus: null, | ||
| 180 | apnsReason: null, | ||
| 181 | apnsId: null, | ||
| 182 | transportError: null, | ||
| 183 | }, | ||
| 184 | ]), | ||
| 185 | }), | ||
| 186 | }), | ||
| 187 | }), | ||
| 188 | } as unknown as RelayDatabase; | ||
| 189 | |||
| 190 | return Effect.gen(function* () { | ||
| 191 | const attempts = yield* DeliveryAttempts.DeliveryAttempts; | ||
| 192 | const claimed = yield* attempts.claimSourceJob({ | ||
| 193 | userId: "user-1", | ||
| 194 | environmentId: null, | ||
| 195 | threadId: null, | ||
| 196 | deviceId: "device-1", | ||
| 197 | kind: "live_activity_update", | ||
| 198 | sourceJobId: "job-1", | ||
| 199 | token: "apns-token", | ||
| 200 | }); | ||
| 201 | |||
| 202 | expect(claimed).toBe("in_flight"); | ||
| 203 | }).pipe( | ||
| 204 | Effect.provide( | ||
| 205 | DeliveryAttempts.layer.pipe( | ||
| 206 | Layer.provide(NodeCryptoLayer.layer), | ||
| 207 | Layer.provide(Layer.succeed(RelayDb, fakeDb)), | ||
| 208 | ), | ||
| 209 | ), | ||
| 210 | ); | ||
| 211 | }); | ||
| 212 | |||
| 213 | it.effect("reclaims source jobs after the claim lease expires", () => { | ||
| 214 | const updatedValues: Array<Record<string, unknown>> = []; | ||
| 215 | const fakeDb = { | ||
| 216 | insert: () => ({ | ||
| 217 | values: () => ({ | ||
| 218 | onConflictDoNothing: () => ({ | ||
| 219 | returning: () => Effect.succeed([]), | ||
| 220 | }), | ||
| 221 | }), | ||
| 222 | }), | ||
| 223 | select: () => ({ | ||
| 224 | from: () => ({ | ||
| 225 | where: () => ({ | ||
| 226 | limit: () => | ||
| 227 | Effect.succeed([ | ||
| 228 | { | ||
| 229 | createdAt: "1969-12-31T23:00:00.000Z", | ||
| 230 | apnsStatus: null, | ||
| 231 | apnsReason: null, | ||
| 232 | apnsId: null, | ||
| 233 | transportError: null, | ||
| 234 | }, | ||
| 235 | ]), | ||
| 236 | }), | ||
| 237 | }), | ||
| 238 | }), | ||
| 239 | update: () => ({ | ||
| 240 | set: (values: Record<string, unknown>) => { | ||
| 241 | updatedValues.push(values); | ||
| 242 | return { | ||
| 243 | where: () => ({ | ||
| 244 | returning: () => Effect.succeed([{ id: "attempt-1" }]), | ||
| 245 | }), | ||
| 246 | }; | ||
| 247 | }, | ||
| 248 | }), | ||
| 249 | } as unknown as RelayDatabase; | ||
| 250 | |||
| 251 | return Effect.gen(function* () { | ||
| 252 | const attempts = yield* DeliveryAttempts.DeliveryAttempts; | ||
| 253 | const claimed = yield* attempts.claimSourceJob({ | ||
| 254 | userId: "user-1", | ||
| 255 | environmentId: null, | ||
| 256 | threadId: null, | ||
| 257 | deviceId: "device-1", | ||
| 258 | kind: "live_activity_update", | ||
| 259 | sourceJobId: "job-1", | ||
| 260 | token: "apns-token", | ||
| 261 | }); | ||
| 262 | |||
| 263 | expect(claimed).toBe("claimed"); | ||
| 264 | expect(updatedValues[0]?.createdAt).toEqual(expect.any(String)); | ||
| 265 | }).pipe( | ||
| 266 | Effect.provide( | ||
| 267 | DeliveryAttempts.layer.pipe( | ||
| 268 | Layer.provide(NodeCryptoLayer.layer), | ||
| 269 | Layer.provide(Layer.succeed(RelayDb, fakeDb)), | ||
| 270 | ), | ||
| 271 | ), | ||
| 272 | ); | ||
| 273 | }); | ||
| 274 | |||
| 275 | it.effect("completes a claimed source job with the APNs outcome", () => { | ||
| 276 | const updatedValues: Array<Record<string, unknown>> = []; | ||
| 277 | const whereClauses: Array<unknown> = []; | ||
| 278 | const fakeDb = { | ||
| 279 | update: (table: unknown) => { | ||
| 280 | expect(table).toBe(relayDeliveryAttempts); | ||
| 281 | return { | ||
| 282 | set: (values: Record<string, unknown>) => { | ||
| 283 | updatedValues.push(values); | ||
| 284 | return { | ||
| 285 | where: (clause: unknown) => { | ||
| 286 | whereClauses.push(clause); | ||
| 287 | return Effect.void; | ||
| 288 | }, | ||
| 289 | }; | ||
| 290 | }, | ||
| 291 | }; | ||
| 292 | }, | ||
| 293 | } as unknown as RelayDatabase; | ||
| 294 | |||
| 295 | return Effect.gen(function* () { | ||
| 296 | const attempts = yield* DeliveryAttempts.DeliveryAttempts; | ||
| 297 | yield* attempts.completeSourceJob({ | ||
| 298 | sourceJobId: "job-1", | ||
| 299 | apnsStatus: 410, | ||
| 300 | apnsReason: "Unregistered", | ||
| 301 | apnsId: "apns-id", | ||
| 302 | }); | ||
| 303 | |||
| 304 | expect(whereClauses).toHaveLength(1); | ||
| 305 | expect(updatedValues).toEqual([ | ||
| 306 | { | ||
| 307 | createdAt: expect.any(String), | ||
| 308 | apnsStatus: 410, | ||
| 309 | apnsReason: "Unregistered", | ||
| 310 | apnsId: "apns-id", | ||
| 311 | transportError: null, | ||
| 312 | }, | ||
| 313 | ]); | ||
| 314 | }).pipe( | ||
| 315 | Effect.provide( | ||
| 316 | DeliveryAttempts.layer.pipe( | ||
| 317 | Layer.provide(NodeCryptoLayer.layer), | ||
| 318 | Layer.provide(Layer.succeed(RelayDb, fakeDb)), | ||
| 319 | ), | ||
| 320 | ), | ||
| 321 | ); | ||
| 322 | }); | ||
| 323 | }); | ||
infra/relay/src/agentActivity/DeliveryAttempts.ts created+203| ... | @@ -0,0 +1,203 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Data from "effect/Data"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import { and, eq, isNull } from "drizzle-orm"; | ||
| 8 | import * as Crypto from "effect/Crypto"; | ||
| 9 | |||
| 10 | import { RelayDb } from "../db.ts"; | ||
| 11 | import { relayDeliveryAttempts } from "../persistence/schema.ts"; | ||
| 12 | |||
| 13 | export class DeliveryAttemptRecordPersistenceError extends Data.TaggedError( | ||
| 14 | "DeliveryAttemptRecordPersistenceError", | ||
| 15 | )<{ | ||
| 16 | readonly cause: unknown; | ||
| 17 | }> {} | ||
| 18 | |||
| 19 | export interface DeliveryAttemptInput { | ||
| 20 | readonly userId: string | null; | ||
| 21 | readonly environmentId: string | null; | ||
| 22 | readonly threadId: string | null; | ||
| 23 | readonly deviceId: string | null; | ||
| 24 | readonly kind: string; | ||
| 25 | readonly sourceJobId?: string | null; | ||
| 26 | readonly token: string | null; | ||
| 27 | readonly apnsStatus?: number; | ||
| 28 | readonly apnsReason?: string; | ||
| 29 | readonly apnsId?: string | null; | ||
| 30 | readonly transportError?: string; | ||
| 31 | } | ||
| 32 | |||
| 33 | export interface DeliveryAttemptCompletionInput { | ||
| 34 | readonly sourceJobId: string; | ||
| 35 | readonly apnsStatus?: number; | ||
| 36 | readonly apnsReason?: string; | ||
| 37 | readonly apnsId?: string | null; | ||
| 38 | readonly transportError?: string; | ||
| 39 | } | ||
| 40 | |||
| 41 | export type DeliverySourceJobClaimResult = "claimed" | "completed" | "in_flight"; | ||
| 42 | |||
| 43 | export interface DeliveryAttemptsShape { | ||
| 44 | readonly record: ( | ||
| 45 | input: DeliveryAttemptInput, | ||
| 46 | ) => Effect.Effect<void, DeliveryAttemptRecordPersistenceError>; | ||
| 47 | readonly claimSourceJob: ( | ||
| 48 | input: DeliveryAttemptInput & { readonly sourceJobId: string }, | ||
| 49 | ) => Effect.Effect<DeliverySourceJobClaimResult, DeliveryAttemptRecordPersistenceError>; | ||
| 50 | readonly completeSourceJob: ( | ||
| 51 | input: DeliveryAttemptCompletionInput, | ||
| 52 | ) => Effect.Effect<void, DeliveryAttemptRecordPersistenceError>; | ||
| 53 | } | ||
| 54 | |||
| 55 | export class DeliveryAttempts extends Context.Service<DeliveryAttempts, DeliveryAttemptsShape>()( | ||
| 56 | "t3code-relay/agentActivity/DeliveryAttempts", | ||
| 57 | ) {} | ||
| 58 | |||
| 59 | const SOURCE_JOB_CLAIM_LEASE_MINUTES = 10; | ||
| 60 | |||
| 61 | function insertValues( | ||
| 62 | input: DeliveryAttemptInput, | ||
| 63 | id: string, | ||
| 64 | createdAt: string, | ||
| 65 | ): typeof relayDeliveryAttempts.$inferInsert { | ||
| 66 | return { | ||
| 67 | id, | ||
| 68 | createdAt, | ||
| 69 | userId: input.userId, | ||
| 70 | environmentId: input.environmentId, | ||
| 71 | threadId: input.threadId, | ||
| 72 | deviceId: input.deviceId, | ||
| 73 | kind: input.kind, | ||
| 74 | sourceJobId: input.sourceJobId ?? null, | ||
| 75 | tokenSuffix: input.token?.slice(-8) ?? null, | ||
| 76 | apnsStatus: input.apnsStatus ?? null, | ||
| 77 | apnsReason: input.apnsReason ?? null, | ||
| 78 | apnsId: input.apnsId ?? null, | ||
| 79 | transportError: input.transportError ?? null, | ||
| 80 | }; | ||
| 81 | } | ||
| 82 | |||
| 83 | const make = Effect.gen(function* () { | ||
| 84 | const db = yield* RelayDb; | ||
| 85 | const crypto = yield* Crypto.Crypto; | ||
| 86 | |||
| 87 | const isExpiredClaim = (claimedAt: string | null, now: DateTime.DateTime) => { | ||
| 88 | if (claimedAt === null) { | ||
| 89 | return true; | ||
| 90 | } | ||
| 91 | return Option.match(DateTime.make(claimedAt), { | ||
| 92 | onNone: () => true, | ||
| 93 | onSome: (dateTime) => | ||
| 94 | now.epochMilliseconds - dateTime.epochMilliseconds >= | ||
| 95 | SOURCE_JOB_CLAIM_LEASE_MINUTES * 60 * 1_000, | ||
| 96 | }); | ||
| 97 | }; | ||
| 98 | |||
| 99 | return DeliveryAttempts.of({ | ||
| 100 | record: Effect.fn("relay.delivery_attempts.record")( | ||
| 101 | function* (input) { | ||
| 102 | yield* Effect.annotateCurrentSpan({ | ||
| 103 | "relay.delivery.kind": input.kind, | ||
| 104 | ...(input.sourceJobId ? { "relay.delivery.job_id": input.sourceJobId } : {}), | ||
| 105 | ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), | ||
| 106 | ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), | ||
| 107 | ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), | ||
| 108 | }); | ||
| 109 | const id = yield* crypto.randomUUIDv4; | ||
| 110 | const createdAt = DateTime.formatIso(yield* DateTime.now); | ||
| 111 | yield* db.insert(relayDeliveryAttempts).values(insertValues(input, id, createdAt)); | ||
| 112 | }, | ||
| 113 | Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), | ||
| 114 | ), | ||
| 115 | claimSourceJob: Effect.fn("relay.delivery_attempts.claim_source_job")( | ||
| 116 | function* (input) { | ||
| 117 | yield* Effect.annotateCurrentSpan({ | ||
| 118 | "relay.delivery.kind": input.kind, | ||
| 119 | "relay.delivery.job_id": input.sourceJobId, | ||
| 120 | ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), | ||
| 121 | ...(input.environmentId ? { "relay.environment_id": input.environmentId } : {}), | ||
| 122 | ...(input.threadId ? { "relay.thread_id": input.threadId } : {}), | ||
| 123 | }); | ||
| 124 | const id = yield* crypto.randomUUIDv4; | ||
| 125 | const now = yield* DateTime.now; | ||
| 126 | const createdAt = DateTime.formatIso(now); | ||
| 127 | const inserted = yield* db | ||
| 128 | .insert(relayDeliveryAttempts) | ||
| 129 | .values(insertValues(input, id, createdAt)) | ||
| 130 | .onConflictDoNothing({ target: relayDeliveryAttempts.sourceJobId }) | ||
| 131 | .returning({ id: relayDeliveryAttempts.id }); | ||
| 132 | if (inserted.length > 0) { | ||
| 133 | return "claimed"; | ||
| 134 | } | ||
| 135 | |||
| 136 | const existing = yield* db | ||
| 137 | .select({ | ||
| 138 | createdAt: relayDeliveryAttempts.createdAt, | ||
| 139 | apnsStatus: relayDeliveryAttempts.apnsStatus, | ||
| 140 | apnsReason: relayDeliveryAttempts.apnsReason, | ||
| 141 | apnsId: relayDeliveryAttempts.apnsId, | ||
| 142 | transportError: relayDeliveryAttempts.transportError, | ||
| 143 | }) | ||
| 144 | .from(relayDeliveryAttempts) | ||
| 145 | .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId)) | ||
| 146 | .limit(1); | ||
| 147 | const row = existing[0]; | ||
| 148 | if (!row) { | ||
| 149 | return "in_flight"; | ||
| 150 | } | ||
| 151 | if ( | ||
| 152 | row.apnsStatus !== null || | ||
| 153 | row.apnsReason !== null || | ||
| 154 | row.apnsId !== null || | ||
| 155 | row.transportError !== null | ||
| 156 | ) { | ||
| 157 | return "completed"; | ||
| 158 | } | ||
| 159 | if (!isExpiredClaim(row.createdAt, now)) { | ||
| 160 | return "in_flight"; | ||
| 161 | } | ||
| 162 | |||
| 163 | const reclaimed = yield* db | ||
| 164 | .update(relayDeliveryAttempts) | ||
| 165 | .set({ | ||
| 166 | createdAt, | ||
| 167 | }) | ||
| 168 | .where( | ||
| 169 | and( | ||
| 170 | eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId), | ||
| 171 | eq(relayDeliveryAttempts.createdAt, row.createdAt), | ||
| 172 | isNull(relayDeliveryAttempts.apnsStatus), | ||
| 173 | isNull(relayDeliveryAttempts.apnsReason), | ||
| 174 | isNull(relayDeliveryAttempts.apnsId), | ||
| 175 | isNull(relayDeliveryAttempts.transportError), | ||
| 176 | ), | ||
| 177 | ) | ||
| 178 | .returning({ id: relayDeliveryAttempts.id }); | ||
| 179 | return reclaimed.length > 0 ? "claimed" : "in_flight"; | ||
| 180 | }, | ||
| 181 | Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), | ||
| 182 | ), | ||
| 183 | completeSourceJob: Effect.fn("relay.delivery_attempts.complete_source_job")( | ||
| 184 | function* (input) { | ||
| 185 | yield* Effect.annotateCurrentSpan({ "relay.delivery.job_id": input.sourceJobId }); | ||
| 186 | const completedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 187 | yield* db | ||
| 188 | .update(relayDeliveryAttempts) | ||
| 189 | .set({ | ||
| 190 | createdAt: completedAt, | ||
| 191 | apnsStatus: input.apnsStatus ?? null, | ||
| 192 | apnsReason: input.apnsReason ?? null, | ||
| 193 | apnsId: input.apnsId ?? null, | ||
| 194 | transportError: input.transportError ?? null, | ||
| 195 | }) | ||
| 196 | .where(eq(relayDeliveryAttempts.sourceJobId, input.sourceJobId)); | ||
| 197 | }, | ||
| 198 | Effect.mapError((cause) => new DeliveryAttemptRecordPersistenceError({ cause })), | ||
| 199 | ), | ||
| 200 | }); | ||
| 201 | }); | ||
| 202 | |||
| 203 | export const layer = Layer.effect(DeliveryAttempts, make); | ||
infra/relay/src/agentActivity/Devices.test.ts created+220| ... | @@ -0,0 +1,220 @@ | ||
| 1 | import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import type { SQL } from "drizzle-orm"; | ||
| 4 | import { PgDialect } from "drizzle-orm/pg-core"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | |||
| 8 | import { RelayDb, type RelayDatabase } from "../db.ts"; | ||
| 9 | import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; | ||
| 10 | import * as Devices from "./Devices.ts"; | ||
| 11 | |||
| 12 | const registration: RelayDeviceRegistrationRequest = { | ||
| 13 | deviceId: "device-1" as RelayDeviceRegistrationRequest["deviceId"], | ||
| 14 | label: "Julius's iPhone", | ||
| 15 | platform: "ios", | ||
| 16 | iosMajorVersion: 18, | ||
| 17 | appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"], | ||
| 18 | pushToken: "apns-device-token" as RelayDeviceRegistrationRequest["pushToken"], | ||
| 19 | pushToStartToken: "push-to-start-token" as RelayDeviceRegistrationRequest["pushToStartToken"], | ||
| 20 | preferences: { | ||
| 21 | notificationsEnabled: true, | ||
| 22 | liveActivitiesEnabled: true, | ||
| 23 | notifyOnApproval: true, | ||
| 24 | notifyOnInput: true, | ||
| 25 | notifyOnCompletion: true, | ||
| 26 | notifyOnFailure: true, | ||
| 27 | }, | ||
| 28 | }; | ||
| 29 | |||
| 30 | describe("Devices", () => { | ||
| 31 | it.effect("claims APNs tokens globally before upserting the current user device", () => { | ||
| 32 | const calls: Array<string> = []; | ||
| 33 | const updateSets: Array<Record<string, unknown>> = []; | ||
| 34 | const updateConditions: Array<SQL> = []; | ||
| 35 | const insertedValues: Array<Record<string, unknown>> = []; | ||
| 36 | const dialect = new PgDialect(); | ||
| 37 | |||
| 38 | const fakeDb = { | ||
| 39 | update: (table: unknown) => { | ||
| 40 | expect(table).toBe(relayMobileDevices); | ||
| 41 | calls.push("update"); | ||
| 42 | return { | ||
| 43 | set: (values: Record<string, unknown>) => { | ||
| 44 | updateSets.push(values); | ||
| 45 | calls.push("update.set"); | ||
| 46 | return { | ||
| 47 | where: (condition: SQL) => { | ||
| 48 | expect(condition).toBeDefined(); | ||
| 49 | updateConditions.push(condition); | ||
| 50 | calls.push("update.where"); | ||
| 51 | return Effect.void; | ||
| 52 | }, | ||
| 53 | }; | ||
| 54 | }, | ||
| 55 | }; | ||
| 56 | }, | ||
| 57 | insert: (table: unknown) => { | ||
| 58 | expect(table).toBe(relayMobileDevices); | ||
| 59 | calls.push("insert"); | ||
| 60 | return { | ||
| 61 | values: (values: Record<string, unknown>) => { | ||
| 62 | insertedValues.push(values); | ||
| 63 | calls.push("insert.values"); | ||
| 64 | return { | ||
| 65 | onConflictDoUpdate: (config: unknown) => { | ||
| 66 | expect(config).toBeDefined(); | ||
| 67 | calls.push("insert.onConflictDoUpdate"); | ||
| 68 | return Effect.void; | ||
| 69 | }, | ||
| 70 | }; | ||
| 71 | }, | ||
| 72 | }; | ||
| 73 | }, | ||
| 74 | } as unknown as RelayDatabase; | ||
| 75 | |||
| 76 | return Effect.gen(function* () { | ||
| 77 | const devices = yield* Devices.Devices; | ||
| 78 | yield* devices.register({ userId: "user-2", registration }); | ||
| 79 | |||
| 80 | expect(calls).toEqual([ | ||
| 81 | "update", | ||
| 82 | "update.set", | ||
| 83 | "update.where", | ||
| 84 | "update", | ||
| 85 | "update.set", | ||
| 86 | "update.where", | ||
| 87 | "insert", | ||
| 88 | "insert.values", | ||
| 89 | "insert.onConflictDoUpdate", | ||
| 90 | ]); | ||
| 91 | expect(updateSets).toEqual([ | ||
| 92 | expect.objectContaining({ pushToken: null }), | ||
| 93 | expect.objectContaining({ pushToStartToken: null }), | ||
| 94 | ]); | ||
| 95 | expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([ | ||
| 96 | { | ||
| 97 | sql: '"relay_mobile_devices"."push_token" = $1', | ||
| 98 | params: ["apns-device-token"], | ||
| 99 | }, | ||
| 100 | { | ||
| 101 | sql: '"relay_mobile_devices"."push_to_start_token" = $1', | ||
| 102 | params: ["push-to-start-token"], | ||
| 103 | }, | ||
| 104 | ]); | ||
| 105 | expect(insertedValues).toEqual([ | ||
| 106 | expect.objectContaining({ | ||
| 107 | userId: "user-2", | ||
| 108 | deviceId: "device-1", | ||
| 109 | pushToken: "apns-device-token", | ||
| 110 | pushToStartToken: "push-to-start-token", | ||
| 111 | }), | ||
| 112 | ]); | ||
| 113 | }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); | ||
| 114 | }); | ||
| 115 | |||
| 116 | it.effect("unregisters APNs state only for the current user device", () => { | ||
| 117 | const calls: Array<string> = []; | ||
| 118 | const deleteConditions: Array<SQL> = []; | ||
| 119 | const dialect = new PgDialect(); | ||
| 120 | |||
| 121 | const fakeDb = { | ||
| 122 | delete: (table: unknown) => { | ||
| 123 | calls.push(table === relayLiveActivities ? "delete.liveActivities" : "delete.devices"); | ||
| 124 | return { | ||
| 125 | where: (condition: SQL) => { | ||
| 126 | expect(condition).toBeDefined(); | ||
| 127 | deleteConditions.push(condition); | ||
| 128 | calls.push("delete.where"); | ||
| 129 | return Effect.void; | ||
| 130 | }, | ||
| 131 | }; | ||
| 132 | }, | ||
| 133 | } as unknown as RelayDatabase; | ||
| 134 | |||
| 135 | return Effect.gen(function* () { | ||
| 136 | const devices = yield* Devices.Devices; | ||
| 137 | yield* devices.unregister({ userId: "user-2", deviceId: "device-1" }); | ||
| 138 | |||
| 139 | expect(calls).toEqual([ | ||
| 140 | "delete.liveActivities", | ||
| 141 | "delete.where", | ||
| 142 | "delete.devices", | ||
| 143 | "delete.where", | ||
| 144 | ]); | ||
| 145 | expect(deleteConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([ | ||
| 146 | { | ||
| 147 | sql: | ||
| 148 | '(("relay_live_activities"."user_id" = $1) and ' + | ||
| 149 | '("relay_live_activities"."device_id" = $2))', | ||
| 150 | params: ["user-2", "device-1"], | ||
| 151 | }, | ||
| 152 | { | ||
| 153 | sql: | ||
| 154 | '(("relay_mobile_devices"."user_id" = $1) and ' + | ||
| 155 | '("relay_mobile_devices"."device_id" = $2))', | ||
| 156 | params: ["user-2", "device-1"], | ||
| 157 | }, | ||
| 158 | ]); | ||
| 159 | }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); | ||
| 160 | }); | ||
| 161 | |||
| 162 | it.effect("lists safe notification state without exposing APNs tokens", () => { | ||
| 163 | const dialect = new PgDialect(); | ||
| 164 | let condition: SQL | null = null; | ||
| 165 | const fakeDb = { | ||
| 166 | select: () => ({ | ||
| 167 | from: (table: unknown) => { | ||
| 168 | expect(table).toBe(relayMobileDevices); | ||
| 169 | return { | ||
| 170 | where: (nextCondition: SQL) => { | ||
| 171 | condition = nextCondition; | ||
| 172 | return Effect.succeed([ | ||
| 173 | { | ||
| 174 | deviceId: "device-1", | ||
| 175 | label: "Julius's iPhone", | ||
| 176 | platform: "ios" as const, | ||
| 177 | iosMajorVersion: 18, | ||
| 178 | appVersion: "1.0.0", | ||
| 179 | preferences: registration.preferences, | ||
| 180 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 181 | }, | ||
| 182 | ]); | ||
| 183 | }, | ||
| 184 | }; | ||
| 185 | }, | ||
| 186 | }), | ||
| 187 | } as unknown as RelayDatabase; | ||
| 188 | |||
| 189 | return Effect.gen(function* () { | ||
| 190 | const devices = yield* Devices.Devices; | ||
| 191 | const listed = yield* devices.listForUser({ userId: "user-2" }); | ||
| 192 | |||
| 193 | expect(condition).not.toBeNull(); | ||
| 194 | expect(dialect.sqlToQuery(condition!)).toEqual({ | ||
| 195 | sql: '"relay_mobile_devices"."user_id" = $1', | ||
| 196 | params: ["user-2"], | ||
| 197 | }); | ||
| 198 | expect(listed).toEqual([ | ||
| 199 | { | ||
| 200 | deviceId: "device-1", | ||
| 201 | label: "Julius's iPhone", | ||
| 202 | platform: "ios", | ||
| 203 | iosMajorVersion: 18, | ||
| 204 | appVersion: "1.0.0", | ||
| 205 | notifications: { | ||
| 206 | enabled: true, | ||
| 207 | notifyOnApproval: true, | ||
| 208 | notifyOnInput: true, | ||
| 209 | notifyOnCompletion: true, | ||
| 210 | notifyOnFailure: true, | ||
| 211 | }, | ||
| 212 | liveActivities: { | ||
| 213 | enabled: true, | ||
| 214 | }, | ||
| 215 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 216 | }, | ||
| 217 | ]); | ||
| 218 | }).pipe(Effect.provide(Devices.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); | ||
| 219 | }); | ||
| 220 | }); | ||
infra/relay/src/agentActivity/Devices.ts created+181| ... | @@ -0,0 +1,181 @@ | ||
| 1 | import type { | ||
| 2 | RelayClientDeviceRecord, | ||
| 3 | RelayDeviceRegistrationRequest, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import * as Context from "effect/Context"; | ||
| 6 | import * as Data from "effect/Data"; | ||
| 7 | import * as DateTime from "effect/DateTime"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import { and, eq } from "drizzle-orm"; | ||
| 11 | import { sql } from "drizzle-orm"; | ||
| 12 | |||
| 13 | import { RelayDb } from "../db.ts"; | ||
| 14 | import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; | ||
| 15 | |||
| 16 | export class DeviceRegistrationPersistenceError extends Data.TaggedError( | ||
| 17 | "DeviceRegistrationPersistenceError", | ||
| 18 | )<{ | ||
| 19 | readonly cause: unknown; | ||
| 20 | }> {} | ||
| 21 | |||
| 22 | export class DeviceUnregistrationPersistenceError extends Data.TaggedError( | ||
| 23 | "DeviceUnregistrationPersistenceError", | ||
| 24 | )<{ | ||
| 25 | readonly cause: unknown; | ||
| 26 | }> {} | ||
| 27 | |||
| 28 | export class DeviceListPersistenceError extends Data.TaggedError("DeviceListPersistenceError")<{ | ||
| 29 | readonly cause: unknown; | ||
| 30 | }> {} | ||
| 31 | |||
| 32 | export interface DevicesShape { | ||
| 33 | readonly register: (input: { | ||
| 34 | readonly userId: string; | ||
| 35 | readonly registration: RelayDeviceRegistrationRequest; | ||
| 36 | }) => Effect.Effect<void, DeviceRegistrationPersistenceError>; | ||
| 37 | readonly unregister: (input: { | ||
| 38 | readonly userId: string; | ||
| 39 | readonly deviceId: string; | ||
| 40 | }) => Effect.Effect<void, DeviceUnregistrationPersistenceError>; | ||
| 41 | readonly listForUser: (input: { | ||
| 42 | readonly userId: string; | ||
| 43 | }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, DeviceListPersistenceError>; | ||
| 44 | } | ||
| 45 | |||
| 46 | export class Devices extends Context.Service<Devices, DevicesShape>()( | ||
| 47 | "t3code-relay/agentActivity/Devices", | ||
| 48 | ) {} | ||
| 49 | |||
| 50 | const make = Effect.gen(function* () { | ||
| 51 | const db = yield* RelayDb; | ||
| 52 | |||
| 53 | return Devices.of({ | ||
| 54 | register: Effect.fn("relay.devices.register")( | ||
| 55 | function* (input) { | ||
| 56 | yield* Effect.annotateCurrentSpan({ | ||
| 57 | "relay.mobile.device_id": input.registration.deviceId, | ||
| 58 | }); | ||
| 59 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 60 | const registration = input.registration; | ||
| 61 | |||
| 62 | yield* Effect.all( | ||
| 63 | [ | ||
| 64 | registration.pushToken | ||
| 65 | ? db | ||
| 66 | .update(relayMobileDevices) | ||
| 67 | .set({ pushToken: null, updatedAt }) | ||
| 68 | .where(eq(relayMobileDevices.pushToken, registration.pushToken)) | ||
| 69 | : Effect.void, | ||
| 70 | registration.pushToStartToken | ||
| 71 | ? db | ||
| 72 | .update(relayMobileDevices) | ||
| 73 | .set({ pushToStartToken: null, updatedAt }) | ||
| 74 | .where(eq(relayMobileDevices.pushToStartToken, registration.pushToStartToken)) | ||
| 75 | : Effect.void, | ||
| 76 | ], | ||
| 77 | { concurrency: 2, discard: true }, | ||
| 78 | ); | ||
| 79 | |||
| 80 | yield* db | ||
| 81 | .insert(relayMobileDevices) | ||
| 82 | .values({ | ||
| 83 | userId: input.userId, | ||
| 84 | deviceId: registration.deviceId, | ||
| 85 | label: registration.label, | ||
| 86 | platform: registration.platform, | ||
| 87 | iosMajorVersion: registration.iosMajorVersion, | ||
| 88 | appVersion: registration.appVersion ?? null, | ||
| 89 | pushToken: registration.pushToken ?? null, | ||
| 90 | pushToStartToken: registration.pushToStartToken ?? null, | ||
| 91 | preferencesJson: registration.preferences, | ||
| 92 | createdAt: updatedAt, | ||
| 93 | updatedAt, | ||
| 94 | }) | ||
| 95 | .onConflictDoUpdate({ | ||
| 96 | target: [relayMobileDevices.userId, relayMobileDevices.deviceId], | ||
| 97 | set: { | ||
| 98 | platform: registration.platform, | ||
| 99 | label: registration.label, | ||
| 100 | iosMajorVersion: registration.iosMajorVersion, | ||
| 101 | appVersion: registration.appVersion ?? null, | ||
| 102 | pushToken: sql`coalesce(excluded.push_token, ${relayMobileDevices.pushToken})`, | ||
| 103 | pushToStartToken: sql`coalesce( | ||
| 104 | excluded.push_to_start_token, | ||
| 105 | ${relayMobileDevices.pushToStartToken} | ||
| 106 | )`, | ||
| 107 | preferencesJson: registration.preferences, | ||
| 108 | updatedAt, | ||
| 109 | }, | ||
| 110 | }); | ||
| 111 | }, | ||
| 112 | Effect.mapError((cause) => new DeviceRegistrationPersistenceError({ cause })), | ||
| 113 | ), | ||
| 114 | unregister: Effect.fn("relay.devices.unregister")( | ||
| 115 | function* (input) { | ||
| 116 | yield* Effect.annotateCurrentSpan({ | ||
| 117 | "relay.mobile.device_id": input.deviceId, | ||
| 118 | }); | ||
| 119 | yield* Effect.all( | ||
| 120 | [ | ||
| 121 | db | ||
| 122 | .delete(relayLiveActivities) | ||
| 123 | .where( | ||
| 124 | and( | ||
| 125 | eq(relayLiveActivities.userId, input.userId), | ||
| 126 | eq(relayLiveActivities.deviceId, input.deviceId), | ||
| 127 | ), | ||
| 128 | ), | ||
| 129 | db | ||
| 130 | .delete(relayMobileDevices) | ||
| 131 | .where( | ||
| 132 | and( | ||
| 133 | eq(relayMobileDevices.userId, input.userId), | ||
| 134 | eq(relayMobileDevices.deviceId, input.deviceId), | ||
| 135 | ), | ||
| 136 | ), | ||
| 137 | ], | ||
| 138 | { concurrency: 2, discard: true }, | ||
| 139 | ); | ||
| 140 | }, | ||
| 141 | Effect.mapError((cause) => new DeviceUnregistrationPersistenceError({ cause })), | ||
| 142 | ), | ||
| 143 | listForUser: Effect.fn("relay.devices.listForUser")( | ||
| 144 | function* (input) { | ||
| 145 | const rows = yield* db | ||
| 146 | .select({ | ||
| 147 | deviceId: relayMobileDevices.deviceId, | ||
| 148 | label: relayMobileDevices.label, | ||
| 149 | platform: relayMobileDevices.platform, | ||
| 150 | iosMajorVersion: relayMobileDevices.iosMajorVersion, | ||
| 151 | appVersion: relayMobileDevices.appVersion, | ||
| 152 | preferences: relayMobileDevices.preferencesJson, | ||
| 153 | updatedAt: relayMobileDevices.updatedAt, | ||
| 154 | }) | ||
| 155 | .from(relayMobileDevices) | ||
| 156 | .where(eq(relayMobileDevices.userId, input.userId)); | ||
| 157 | return rows.map((row) => ({ | ||
| 158 | deviceId: row.deviceId, | ||
| 159 | label: row.label, | ||
| 160 | platform: row.platform, | ||
| 161 | iosMajorVersion: row.iosMajorVersion, | ||
| 162 | appVersion: row.appVersion, | ||
| 163 | notifications: { | ||
| 164 | enabled: row.preferences.notificationsEnabled, | ||
| 165 | notifyOnApproval: row.preferences.notifyOnApproval, | ||
| 166 | notifyOnInput: row.preferences.notifyOnInput, | ||
| 167 | notifyOnCompletion: row.preferences.notifyOnCompletion, | ||
| 168 | notifyOnFailure: row.preferences.notifyOnFailure, | ||
| 169 | }, | ||
| 170 | liveActivities: { | ||
| 171 | enabled: row.preferences.liveActivitiesEnabled, | ||
| 172 | }, | ||
| 173 | updatedAt: row.updatedAt, | ||
| 174 | })); | ||
| 175 | }, | ||
| 176 | Effect.mapError((cause) => new DeviceListPersistenceError({ cause })), | ||
| 177 | ), | ||
| 178 | }); | ||
| 179 | }); | ||
| 180 | |||
| 181 | export const layer = Layer.effect(Devices, make); | ||
infra/relay/src/agentActivity/LiveActivities.test.ts created+196| ... | @@ -0,0 +1,196 @@ | ||
| 1 | import type { | ||
| 2 | RelayAgentActivityAggregateState, | ||
| 3 | RelayLiveActivityRegistrationRequest, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import { describe, expect, it } from "@effect/vitest"; | ||
| 6 | import type { SQL } from "drizzle-orm"; | ||
| 7 | import { PgDialect } from "drizzle-orm/pg-core"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | |||
| 11 | import { RelayDb, type RelayDatabase } from "../db.ts"; | ||
| 12 | import { relayLiveActivities } from "../persistence/schema.ts"; | ||
| 13 | import * as LiveActivities from "./LiveActivities.ts"; | ||
| 14 | |||
| 15 | const aggregate: RelayAgentActivityAggregateState = { | ||
| 16 | title: "T3 Code", | ||
| 17 | subtitle: "Agent work in progress", | ||
| 18 | activeCount: 1, | ||
| 19 | updatedAt: "2026-05-25T00:00:00.000Z", | ||
| 20 | activities: [ | ||
| 21 | { | ||
| 22 | environmentId: | ||
| 23 | "env" as RelayAgentActivityAggregateState["activities"][number]["environmentId"], | ||
| 24 | threadId: "thread" as RelayAgentActivityAggregateState["activities"][number]["threadId"], | ||
| 25 | projectTitle: "Project", | ||
| 26 | threadTitle: "Thread", | ||
| 27 | modelTitle: "gpt-5.4", | ||
| 28 | phase: "running", | ||
| 29 | status: "Working", | ||
| 30 | updatedAt: "2026-05-25T00:00:00.000Z", | ||
| 31 | deepLink: "/threads/env/thread", | ||
| 32 | }, | ||
| 33 | ], | ||
| 34 | }; | ||
| 35 | |||
| 36 | describe("LiveActivities", () => { | ||
| 37 | it.effect( | ||
| 38 | "claims Live Activity push tokens globally before upserting the current user device", | ||
| 39 | () => { | ||
| 40 | const registration: RelayLiveActivityRegistrationRequest = { | ||
| 41 | deviceId: "device-1" as RelayLiveActivityRegistrationRequest["deviceId"], | ||
| 42 | activityPushToken: | ||
| 43 | "activity-push-token" as RelayLiveActivityRegistrationRequest["activityPushToken"], | ||
| 44 | }; | ||
| 45 | const calls: Array<string> = []; | ||
| 46 | const updateSets: Array<Record<string, unknown>> = []; | ||
| 47 | const updateConditions: Array<SQL> = []; | ||
| 48 | const insertedValues: Array<Record<string, unknown>> = []; | ||
| 49 | const conflictConfigs: Array<{ | ||
| 50 | readonly set?: Record<string, unknown>; | ||
| 51 | }> = []; | ||
| 52 | const dialect = new PgDialect(); | ||
| 53 | |||
| 54 | const fakeDb = { | ||
| 55 | update: (table: unknown) => { | ||
| 56 | expect(table).toBe(relayLiveActivities); | ||
| 57 | calls.push("update"); | ||
| 58 | return { | ||
| 59 | set: (values: Record<string, unknown>) => { | ||
| 60 | updateSets.push(values); | ||
| 61 | calls.push("update.set"); | ||
| 62 | return { | ||
| 63 | where: (condition: SQL) => { | ||
| 64 | expect(condition).toBeDefined(); | ||
| 65 | updateConditions.push(condition); | ||
| 66 | calls.push("update.where"); | ||
| 67 | return Effect.void; | ||
| 68 | }, | ||
| 69 | }; | ||
| 70 | }, | ||
| 71 | }; | ||
| 72 | }, | ||
| 73 | insert: (table: unknown) => { | ||
| 74 | expect(table).toBe(relayLiveActivities); | ||
| 75 | calls.push("insert"); | ||
| 76 | return { | ||
| 77 | values: (values: Record<string, unknown>) => { | ||
| 78 | insertedValues.push(values); | ||
| 79 | calls.push("insert.values"); | ||
| 80 | return { | ||
| 81 | onConflictDoUpdate: (config: { readonly set?: Record<string, unknown> }) => { | ||
| 82 | expect(config).toBeDefined(); | ||
| 83 | conflictConfigs.push(config); | ||
| 84 | calls.push("insert.onConflictDoUpdate"); | ||
| 85 | return Effect.void; | ||
| 86 | }, | ||
| 87 | }; | ||
| 88 | }, | ||
| 89 | }; | ||
| 90 | }, | ||
| 91 | } as unknown as RelayDatabase; | ||
| 92 | |||
| 93 | return Effect.gen(function* () { | ||
| 94 | const liveActivities = yield* LiveActivities.LiveActivities; | ||
| 95 | yield* liveActivities.register({ userId: "user-2", registration }); | ||
| 96 | |||
| 97 | expect(calls).toEqual([ | ||
| 98 | "update", | ||
| 99 | "update.set", | ||
| 100 | "update.where", | ||
| 101 | "insert", | ||
| 102 | "insert.values", | ||
| 103 | "insert.onConflictDoUpdate", | ||
| 104 | ]); | ||
| 105 | expect(updateSets).toEqual([ | ||
| 106 | expect.objectContaining({ | ||
| 107 | activityPushToken: null, | ||
| 108 | remoteStartQueuedAt: null, | ||
| 109 | remoteStartedAt: null, | ||
| 110 | }), | ||
| 111 | ]); | ||
| 112 | expect(updateConditions.map((condition) => dialect.sqlToQuery(condition))).toEqual([ | ||
| 113 | { | ||
| 114 | sql: '"relay_live_activities"."activity_push_token" = $1', | ||
| 115 | params: ["activity-push-token"], | ||
| 116 | }, | ||
| 117 | ]); | ||
| 118 | expect(insertedValues).toEqual([ | ||
| 119 | expect.objectContaining({ | ||
| 120 | userId: "user-2", | ||
| 121 | deviceId: "device-1", | ||
| 122 | activityPushToken: "activity-push-token", | ||
| 123 | remoteStartQueuedAt: null, | ||
| 124 | remoteStartedAt: expect.any(String), | ||
| 125 | endedAt: null, | ||
| 126 | lastAggregateJson: null, | ||
| 127 | lastLiveActivityDeliveryAt: null, | ||
| 128 | }), | ||
| 129 | ]); | ||
| 130 | expect(conflictConfigs[0]?.set).toEqual( | ||
| 131 | expect.objectContaining({ | ||
| 132 | activityPushToken: "activity-push-token", | ||
| 133 | remoteStartQueuedAt: null, | ||
| 134 | remoteStartedAt: expect.any(String), | ||
| 135 | endedAt: null, | ||
| 136 | lastAggregateJson: null, | ||
| 137 | lastLiveActivityDeliveryAt: null, | ||
| 138 | }), | ||
| 139 | ); | ||
| 140 | }).pipe( | ||
| 141 | Effect.provide(LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb)))), | ||
| 142 | ); | ||
| 143 | }, | ||
| 144 | ); | ||
| 145 | |||
| 146 | it.effect("preserves ended state when a delayed update delivery is marked", () => { | ||
| 147 | const insertedValues: Array<Record<string, unknown>> = []; | ||
| 148 | const conflictConfigs: Array<{ | ||
| 149 | readonly set?: Record<string, unknown>; | ||
| 150 | }> = []; | ||
| 151 | |||
| 152 | const fakeDb = { | ||
| 153 | insert: (table: unknown) => { | ||
| 154 | expect(table).toBe(relayLiveActivities); | ||
| 155 | return { | ||
| 156 | values: (values: Record<string, unknown>) => { | ||
| 157 | insertedValues.push(values); | ||
| 158 | return { | ||
| 159 | onConflictDoUpdate: (config: { readonly set?: Record<string, unknown> }) => { | ||
| 160 | conflictConfigs.push(config); | ||
| 161 | return Effect.void; | ||
| 162 | }, | ||
| 163 | }; | ||
| 164 | }, | ||
| 165 | }; | ||
| 166 | }, | ||
| 167 | } as unknown as RelayDatabase; | ||
| 168 | |||
| 169 | return Effect.gen(function* () { | ||
| 170 | const liveActivities = yield* LiveActivities.LiveActivities; | ||
| 171 | yield* liveActivities.markDelivery({ | ||
| 172 | userId: "user-2", | ||
| 173 | deviceId: "device-1", | ||
| 174 | kind: "live_activity_update", | ||
| 175 | aggregate, | ||
| 176 | deliveredAt: "2026-05-25T00:00:10.000Z", | ||
| 177 | }); | ||
| 178 | |||
| 179 | expect(insertedValues).toEqual([ | ||
| 180 | expect.objectContaining({ | ||
| 181 | userId: "user-2", | ||
| 182 | deviceId: "device-1", | ||
| 183 | endedAt: null, | ||
| 184 | }), | ||
| 185 | ]); | ||
| 186 | expect(conflictConfigs[0]?.set).toEqual( | ||
| 187 | expect.objectContaining({ | ||
| 188 | endedAt: relayLiveActivities.endedAt, | ||
| 189 | lastLiveActivityDeliveryAt: "2026-05-25T00:00:10.000Z", | ||
| 190 | }), | ||
| 191 | ); | ||
| 192 | }).pipe( | ||
| 193 | Effect.provide(LiveActivities.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb)))), | ||
| 194 | ); | ||
| 195 | }); | ||
| 196 | }); | ||
infra/relay/src/agentActivity/LiveActivities.ts created+373| ... | @@ -0,0 +1,373 @@ | ||
| 1 | import type { | ||
| 2 | RelayAgentActivityAggregateState, | ||
| 3 | RelayDeliveryKind, | ||
| 4 | RelayLiveActivityRegistrationRequest, | ||
| 5 | } from "@t3tools/contracts/relay"; | ||
| 6 | import { RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema } from "@t3tools/contracts/relay"; | ||
| 7 | import * as Context from "effect/Context"; | ||
| 8 | import * as Data from "effect/Data"; | ||
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import { cast } from "effect/Function"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Schema from "effect/Schema"; | ||
| 14 | import { and, eq, sql } from "drizzle-orm"; | ||
| 15 | |||
| 16 | import { RelayDb } from "../db.ts"; | ||
| 17 | import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts"; | ||
| 18 | |||
| 19 | export class LiveActivityRegistrationPersistenceError extends Data.TaggedError( | ||
| 20 | "LiveActivityRegistrationPersistenceError", | ||
| 21 | )<{ | ||
| 22 | readonly cause: unknown; | ||
| 23 | }> {} | ||
| 24 | |||
| 25 | export class LiveActivityTargetListPersistenceError extends Data.TaggedError( | ||
| 26 | "LiveActivityTargetListPersistenceError", | ||
| 27 | )<{ | ||
| 28 | readonly cause: unknown; | ||
| 29 | }> {} | ||
| 30 | |||
| 31 | export class LiveActivityDeliveryMarkPersistenceError extends Data.TaggedError( | ||
| 32 | "LiveActivityDeliveryMarkPersistenceError", | ||
| 33 | )<{ | ||
| 34 | readonly cause: unknown; | ||
| 35 | }> {} | ||
| 36 | |||
| 37 | export interface DeviceRow { | ||
| 38 | readonly user_id: string; | ||
| 39 | readonly device_id: string; | ||
| 40 | readonly platform: "ios"; | ||
| 41 | readonly ios_major_version: number; | ||
| 42 | readonly app_version: string | null; | ||
| 43 | readonly push_token: string | null; | ||
| 44 | readonly push_to_start_token: string | null; | ||
| 45 | readonly preferences_json: string; | ||
| 46 | } | ||
| 47 | |||
| 48 | export interface LiveActivityRow { | ||
| 49 | readonly activity_push_token: string | null; | ||
| 50 | readonly remote_start_queued_at: string | null; | ||
| 51 | readonly remote_started_at: string | null; | ||
| 52 | readonly ended_at: string | null; | ||
| 53 | readonly last_aggregate_json: string | null; | ||
| 54 | readonly last_live_activity_delivery_at: string | null; | ||
| 55 | } | ||
| 56 | |||
| 57 | export type TargetRow = DeviceRow & LiveActivityRow; | ||
| 58 | |||
| 59 | export interface LiveActivitiesShape { | ||
| 60 | readonly register: (input: { | ||
| 61 | readonly userId: string; | ||
| 62 | readonly registration: RelayLiveActivityRegistrationRequest; | ||
| 63 | }) => Effect.Effect<void, LiveActivityRegistrationPersistenceError>; | ||
| 64 | readonly listTargets: (input: { | ||
| 65 | readonly userId: string; | ||
| 66 | }) => Effect.Effect<ReadonlyArray<TargetRow>, LiveActivityTargetListPersistenceError>; | ||
| 67 | readonly markDelivery: (input: { | ||
| 68 | readonly userId: string; | ||
| 69 | readonly deviceId: string; | ||
| 70 | readonly kind: RelayDeliveryKind; | ||
| 71 | readonly aggregate: RelayAgentActivityAggregateState | null; | ||
| 72 | readonly deliveredAt: string; | ||
| 73 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | ||
| 74 | readonly markStartQueued: (input: { | ||
| 75 | readonly userId: string; | ||
| 76 | readonly deviceId: string; | ||
| 77 | readonly queuedAt: string; | ||
| 78 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | ||
| 79 | readonly clearStartQueued: (input: { | ||
| 80 | readonly userId: string; | ||
| 81 | readonly deviceId: string; | ||
| 82 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | ||
| 83 | readonly invalidateDeliveryToken: (input: { | ||
| 84 | readonly userId: string; | ||
| 85 | readonly deviceId: string; | ||
| 86 | readonly kind: RelayDeliveryKind; | ||
| 87 | readonly invalidatedAt: string; | ||
| 88 | }) => Effect.Effect<void, LiveActivityDeliveryMarkPersistenceError>; | ||
| 89 | } | ||
| 90 | |||
| 91 | export class LiveActivities extends Context.Service<LiveActivities, LiveActivitiesShape>()( | ||
| 92 | "t3code-relay/agentActivity/LiveActivities", | ||
| 93 | ) {} | ||
| 94 | |||
| 95 | const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString); | ||
| 96 | const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString); | ||
| 97 | |||
| 98 | const encodeRelayAgentActivityAggregateStateJson = Schema.encodeEffect( | ||
| 99 | Schema.fromJsonString(RelayAgentActivityAggregateStateSchema), | ||
| 100 | ); | ||
| 101 | |||
| 102 | const make = Effect.gen(function* () { | ||
| 103 | const db = yield* RelayDb; | ||
| 104 | |||
| 105 | return LiveActivities.of({ | ||
| 106 | register: Effect.fn("relay.live_activities.register")( | ||
| 107 | function* (input) { | ||
| 108 | yield* Effect.annotateCurrentSpan({ | ||
| 109 | "relay.mobile.device_id": input.registration.deviceId, | ||
| 110 | }); | ||
| 111 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 112 | const registration = input.registration; | ||
| 113 | |||
| 114 | yield* db | ||
| 115 | .update(relayLiveActivities) | ||
| 116 | .set({ | ||
| 117 | activityPushToken: null, | ||
| 118 | remoteStartQueuedAt: null, | ||
| 119 | remoteStartedAt: null, | ||
| 120 | endedAt: updatedAt, | ||
| 121 | updatedAt, | ||
| 122 | }) | ||
| 123 | .where(eq(relayLiveActivities.activityPushToken, registration.activityPushToken)); | ||
| 124 | |||
| 125 | yield* db | ||
| 126 | .insert(relayLiveActivities) | ||
| 127 | .values({ | ||
| 128 | userId: input.userId, | ||
| 129 | deviceId: registration.deviceId, | ||
| 130 | activityPushToken: registration.activityPushToken, | ||
| 131 | remoteStartQueuedAt: null, | ||
| 132 | remoteStartedAt: updatedAt, | ||
| 133 | endedAt: null, | ||
| 134 | lastAggregateJson: null, | ||
| 135 | lastLiveActivityDeliveryAt: null, | ||
| 136 | createdAt: updatedAt, | ||
| 137 | updatedAt, | ||
| 138 | }) | ||
| 139 | .onConflictDoUpdate({ | ||
| 140 | target: [relayLiveActivities.userId, relayLiveActivities.deviceId], | ||
| 141 | set: { | ||
| 142 | activityPushToken: registration.activityPushToken, | ||
| 143 | remoteStartQueuedAt: null, | ||
| 144 | remoteStartedAt: updatedAt, | ||
| 145 | endedAt: null, | ||
| 146 | lastAggregateJson: null, | ||
| 147 | lastLiveActivityDeliveryAt: null, | ||
| 148 | updatedAt, | ||
| 149 | }, | ||
| 150 | }); | ||
| 151 | }, | ||
| 152 | Effect.mapError((cause) => new LiveActivityRegistrationPersistenceError({ cause })), | ||
| 153 | ), | ||
| 154 | |||
| 155 | listTargets: Effect.fn("relay.live_activities.list_targets")(function* (input) { | ||
| 156 | return yield* db | ||
| 157 | .select({ | ||
| 158 | device_id: relayMobileDevices.deviceId, | ||
| 159 | user_id: relayMobileDevices.userId, | ||
| 160 | platform: relayMobileDevices.platform, | ||
| 161 | ios_major_version: relayMobileDevices.iosMajorVersion, | ||
| 162 | app_version: relayMobileDevices.appVersion, | ||
| 163 | push_token: relayMobileDevices.pushToken, | ||
| 164 | push_to_start_token: relayMobileDevices.pushToStartToken, | ||
| 165 | preferences_json: relayMobileDevices.preferencesJson, | ||
| 166 | activity_push_token: relayLiveActivities.activityPushToken, | ||
| 167 | remote_start_queued_at: relayLiveActivities.remoteStartQueuedAt, | ||
| 168 | remote_started_at: relayLiveActivities.remoteStartedAt, | ||
| 169 | ended_at: relayLiveActivities.endedAt, | ||
| 170 | last_aggregate_json: relayLiveActivities.lastAggregateJson, | ||
| 171 | last_live_activity_delivery_at: relayLiveActivities.lastLiveActivityDeliveryAt, | ||
| 172 | }) | ||
| 173 | .from(relayMobileDevices) | ||
| 174 | .leftJoin( | ||
| 175 | relayLiveActivities, | ||
| 176 | and( | ||
| 177 | eq(relayLiveActivities.userId, relayMobileDevices.userId), | ||
| 178 | eq(relayLiveActivities.deviceId, relayMobileDevices.deviceId), | ||
| 179 | ), | ||
| 180 | ) | ||
| 181 | .where(eq(relayMobileDevices.userId, input.userId)) | ||
| 182 | .pipe( | ||
| 183 | Effect.flatMap((rows) => | ||
| 184 | Effect.forEach( | ||
| 185 | rows, | ||
| 186 | (row) => | ||
| 187 | Effect.all({ | ||
| 188 | preferences_json: encodeJsonValue(row.preferences_json), | ||
| 189 | last_aggregate_json: | ||
| 190 | row.last_aggregate_json === null | ||
| 191 | ? Effect.succeed(null) | ||
| 192 | : encodeJsonValue(row.last_aggregate_json), | ||
| 193 | }).pipe( | ||
| 194 | Effect.map((json) => ({ | ||
| 195 | ...row, | ||
| 196 | ...json, | ||
| 197 | })), | ||
| 198 | ), | ||
| 199 | { concurrency: "unbounded" }, | ||
| 200 | ), | ||
| 201 | ), | ||
| 202 | Effect.map((rows): ReadonlyArray<TargetRow> => rows), | ||
| 203 | Effect.mapError((cause) => new LiveActivityTargetListPersistenceError({ cause })), | ||
| 204 | ); | ||
| 205 | }), | ||
| 206 | |||
| 207 | markDelivery: Effect.fn("relay.live_activities.mark_delivery")( | ||
| 208 | function* (input) { | ||
| 209 | yield* Effect.annotateCurrentSpan({ | ||
| 210 | "relay.mobile.device_id": input.deviceId, | ||
| 211 | "relay.delivery.kind": input.kind, | ||
| 212 | }); | ||
| 213 | const aggregateJson = | ||
| 214 | input.aggregate === null | ||
| 215 | ? null | ||
| 216 | : yield* encodeRelayAgentActivityAggregateStateJson(input.aggregate).pipe( | ||
| 217 | Effect.flatMap(decodeJsonString), | ||
| 218 | Effect.map(cast<unknown, RelayAgentActivityAggregateState>), | ||
| 219 | ); | ||
| 220 | |||
| 221 | yield* db | ||
| 222 | .insert(relayLiveActivities) | ||
| 223 | .values({ | ||
| 224 | userId: input.userId, | ||
| 225 | deviceId: input.deviceId, | ||
| 226 | remoteStartedAt: input.kind === "live_activity_start" ? input.deliveredAt : null, | ||
| 227 | remoteStartQueuedAt: null, | ||
| 228 | endedAt: input.kind === "live_activity_end" ? input.deliveredAt : null, | ||
| 229 | lastAggregateJson: aggregateJson, | ||
| 230 | lastLiveActivityDeliveryAt: input.deliveredAt, | ||
| 231 | createdAt: input.deliveredAt, | ||
| 232 | updatedAt: input.deliveredAt, | ||
| 233 | }) | ||
| 234 | .onConflictDoUpdate({ | ||
| 235 | target: [relayLiveActivities.userId, relayLiveActivities.deviceId], | ||
| 236 | set: { | ||
| 237 | remoteStartedAt: sql`coalesce( | ||
| 238 | ${relayLiveActivities.remoteStartedAt}, | ||
| 239 | excluded.remote_started_at | ||
| 240 | )`, | ||
| 241 | remoteStartQueuedAt: null, | ||
| 242 | endedAt: | ||
| 243 | input.kind === "live_activity_start" | ||
| 244 | ? null | ||
| 245 | : input.kind === "live_activity_end" | ||
| 246 | ? input.deliveredAt | ||
| 247 | : relayLiveActivities.endedAt, | ||
| 248 | lastAggregateJson: aggregateJson, | ||
| 249 | lastLiveActivityDeliveryAt: input.deliveredAt, | ||
| 250 | updatedAt: input.deliveredAt, | ||
| 251 | }, | ||
| 252 | }); | ||
| 253 | }, | ||
| 254 | Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause })), | ||
| 255 | ), | ||
| 256 | |||
| 257 | markStartQueued: Effect.fn("relay.live_activities.mark_start_queued")(function* (input) { | ||
| 258 | yield* Effect.annotateCurrentSpan({ | ||
| 259 | "relay.mobile.device_id": input.deviceId, | ||
| 260 | }); | ||
| 261 | yield* db | ||
| 262 | .insert(relayLiveActivities) | ||
| 263 | .values({ | ||
| 264 | userId: input.userId, | ||
| 265 | deviceId: input.deviceId, | ||
| 266 | remoteStartQueuedAt: input.queuedAt, | ||
| 267 | remoteStartedAt: null, | ||
| 268 | endedAt: null, | ||
| 269 | createdAt: input.queuedAt, | ||
| 270 | updatedAt: input.queuedAt, | ||
| 271 | }) | ||
| 272 | .onConflictDoUpdate({ | ||
| 273 | target: [relayLiveActivities.userId, relayLiveActivities.deviceId], | ||
| 274 | set: { | ||
| 275 | remoteStartQueuedAt: sql`coalesce( | ||
| 276 | ${relayLiveActivities.remoteStartQueuedAt}, | ||
| 277 | excluded.remote_start_queued_at | ||
| 278 | )`, | ||
| 279 | endedAt: null, | ||
| 280 | updatedAt: input.queuedAt, | ||
| 281 | }, | ||
| 282 | }) | ||
| 283 | .pipe(Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause }))); | ||
| 284 | }), | ||
| 285 | |||
| 286 | clearStartQueued: Effect.fn("relay.live_activities.clear_start_queued")(function* (input) { | ||
| 287 | yield* Effect.annotateCurrentSpan({ | ||
| 288 | "relay.mobile.device_id": input.deviceId, | ||
| 289 | }); | ||
| 290 | yield* db | ||
| 291 | .update(relayLiveActivities) | ||
| 292 | .set({ remoteStartQueuedAt: null }) | ||
| 293 | .where( | ||
| 294 | and( | ||
| 295 | eq(relayLiveActivities.userId, input.userId), | ||
| 296 | eq(relayLiveActivities.deviceId, input.deviceId), | ||
| 297 | ), | ||
| 298 | ) | ||
| 299 | .pipe(Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause }))); | ||
| 300 | }), | ||
| 301 | |||
| 302 | invalidateDeliveryToken: Effect.fn("relay.live_activities.invalidate_delivery_token")( | ||
| 303 | function* (input) { | ||
| 304 | yield* Effect.annotateCurrentSpan({ | ||
| 305 | "relay.mobile.device_id": input.deviceId, | ||
| 306 | "relay.delivery.kind": input.kind, | ||
| 307 | }); | ||
| 308 | if (input.kind === "push_notification") { | ||
| 309 | yield* db | ||
| 310 | .update(relayMobileDevices) | ||
| 311 | .set({ | ||
| 312 | pushToken: null, | ||
| 313 | updatedAt: input.invalidatedAt, | ||
| 314 | }) | ||
| 315 | .where( | ||
| 316 | and( | ||
| 317 | eq(relayMobileDevices.userId, input.userId), | ||
| 318 | eq(relayMobileDevices.deviceId, input.deviceId), | ||
| 319 | ), | ||
| 320 | ); | ||
| 321 | return; | ||
| 322 | } | ||
| 323 | |||
| 324 | if (input.kind === "live_activity_start") { | ||
| 325 | yield* db | ||
| 326 | .update(relayMobileDevices) | ||
| 327 | .set({ | ||
| 328 | pushToStartToken: null, | ||
| 329 | updatedAt: input.invalidatedAt, | ||
| 330 | }) | ||
| 331 | .where( | ||
| 332 | and( | ||
| 333 | eq(relayMobileDevices.userId, input.userId), | ||
| 334 | eq(relayMobileDevices.deviceId, input.deviceId), | ||
| 335 | ), | ||
| 336 | ); | ||
| 337 | yield* db | ||
| 338 | .update(relayLiveActivities) | ||
| 339 | .set({ | ||
| 340 | remoteStartQueuedAt: null, | ||
| 341 | updatedAt: input.invalidatedAt, | ||
| 342 | }) | ||
| 343 | .where( | ||
| 344 | and( | ||
| 345 | eq(relayLiveActivities.userId, input.userId), | ||
| 346 | eq(relayLiveActivities.deviceId, input.deviceId), | ||
| 347 | ), | ||
| 348 | ); | ||
| 349 | return; | ||
| 350 | } | ||
| 351 | |||
| 352 | yield* db | ||
| 353 | .update(relayLiveActivities) | ||
| 354 | .set({ | ||
| 355 | activityPushToken: null, | ||
| 356 | remoteStartQueuedAt: null, | ||
| 357 | remoteStartedAt: null, | ||
| 358 | endedAt: input.invalidatedAt, | ||
| 359 | updatedAt: input.invalidatedAt, | ||
| 360 | }) | ||
| 361 | .where( | ||
| 362 | and( | ||
| 363 | eq(relayLiveActivities.userId, input.userId), | ||
| 364 | eq(relayLiveActivities.deviceId, input.deviceId), | ||
| 365 | ), | ||
| 366 | ); | ||
| 367 | }, | ||
| 368 | Effect.mapError((cause) => new LiveActivityDeliveryMarkPersistenceError({ cause })), | ||
| 369 | ), | ||
| 370 | }); | ||
| 371 | }); | ||
| 372 | |||
| 373 | export const layer = Layer.effect(LiveActivities, make); | ||
infra/relay/src/agentActivity/MobileRegistrations.test.ts created+464| ... | @@ -0,0 +1,464 @@ | ||
| 1 | import type { | ||
| 2 | RelayAgentActivityState, | ||
| 3 | RelayDeviceRegistrationRequest, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import type { SignedApnsDeliveryJob } from "./apnsDeliveryJobs.ts"; | ||
| 6 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import * as Redacted from "effect/Redacted"; | ||
| 11 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 12 | |||
| 13 | import * as Devices from "./Devices.ts"; | ||
| 14 | import * as AgentActivityRows from "./AgentActivityRows.ts"; | ||
| 15 | import * as DeliveryAttempts from "./DeliveryAttempts.ts"; | ||
| 16 | import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; | ||
| 17 | import * as LiveActivities from "./LiveActivities.ts"; | ||
| 18 | import * as RelayConfiguration from "../Config.ts"; | ||
| 19 | import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; | ||
| 20 | import * as ApnsDeliveries from "./ApnsDeliveries.ts"; | ||
| 21 | import * as ApnsClient from "./ApnsClient.ts"; | ||
| 22 | import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts"; | ||
| 23 | import * as MobileRegistrations from "./MobileRegistrations.ts"; | ||
| 24 | |||
| 25 | const device: RelayDeviceRegistrationRequest = { | ||
| 26 | deviceId: "device-1" as RelayDeviceRegistrationRequest["deviceId"], | ||
| 27 | label: "Julius's iPhone", | ||
| 28 | platform: "ios", | ||
| 29 | iosMajorVersion: 18, | ||
| 30 | appVersion: "1.0.0" as RelayDeviceRegistrationRequest["appVersion"], | ||
| 31 | preferences: { | ||
| 32 | liveActivitiesEnabled: true, | ||
| 33 | notificationsEnabled: true, | ||
| 34 | notifyOnApproval: true, | ||
| 35 | notifyOnInput: true, | ||
| 36 | notifyOnCompletion: true, | ||
| 37 | notifyOnFailure: true, | ||
| 38 | }, | ||
| 39 | }; | ||
| 40 | |||
| 41 | function makeDevices(overrides: Partial<Devices.DevicesShape> = {}): Devices.DevicesShape { | ||
| 42 | return { | ||
| 43 | register: () => Effect.void, | ||
| 44 | unregister: () => Effect.void, | ||
| 45 | listForUser: () => Effect.succeed([]), | ||
| 46 | ...overrides, | ||
| 47 | }; | ||
| 48 | } | ||
| 49 | |||
| 50 | function makeLiveActivities( | ||
| 51 | overrides: Partial<LiveActivities.LiveActivitiesShape> = {}, | ||
| 52 | ): LiveActivities.LiveActivitiesShape { | ||
| 53 | return { | ||
| 54 | register: () => Effect.void, | ||
| 55 | listTargets: () => Effect.succeed([]), | ||
| 56 | markDelivery: () => Effect.void, | ||
| 57 | markStartQueued: () => Effect.void, | ||
| 58 | clearStartQueued: () => Effect.void, | ||
| 59 | invalidateDeliveryToken: () => Effect.void, | ||
| 60 | ...overrides, | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | function makeAgentActivityRows( | ||
| 65 | overrides: Partial<AgentActivityRows.AgentActivityRowsShape> = {}, | ||
| 66 | ): AgentActivityRows.AgentActivityRowsShape { | ||
| 67 | return { | ||
| 68 | upsert: () => Effect.void, | ||
| 69 | remove: () => Effect.void, | ||
| 70 | listForUser: () => { | ||
| 71 | const activeState: RelayAgentActivityState = { | ||
| 72 | environmentId: "env-1" as RelayAgentActivityState["environmentId"], | ||
| 73 | threadId: "thread-1" as RelayAgentActivityState["threadId"], | ||
| 74 | projectTitle: "Project", | ||
| 75 | threadTitle: "Implement APNs", | ||
| 76 | modelTitle: "gpt-5.4", | ||
| 77 | phase: "running", | ||
| 78 | headline: "Working", | ||
| 79 | updatedAt: "1970-01-01T00:00:10.000Z", | ||
| 80 | deepLink: "/env-1/thread-1", | ||
| 81 | }; | ||
| 82 | return Effect.succeed([activeState]); | ||
| 83 | }, | ||
| 84 | ...overrides, | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | |||
| 88 | function makeEnvironmentLinks( | ||
| 89 | overrides: Partial<EnvironmentLinks.EnvironmentLinksShape> = {}, | ||
| 90 | ): EnvironmentLinks.EnvironmentLinksShape { | ||
| 91 | return { | ||
| 92 | upsert: () => Effect.void, | ||
| 93 | listUsersForEnvironment: () => Effect.succeed(["dev:julius"]), | ||
| 94 | listDeliveryUsersForEnvironment: () => | ||
| 95 | Effect.succeed([ | ||
| 96 | { | ||
| 97 | userId: "dev:julius", | ||
| 98 | notificationsEnabled: true, | ||
| 99 | liveActivitiesEnabled: true, | ||
| 100 | }, | ||
| 101 | ]), | ||
| 102 | listPublicKeysForEnvironment: () => Effect.succeed([]), | ||
| 103 | listForUser: () => Effect.succeed([]), | ||
| 104 | getForUser: () => Effect.succeed(null), | ||
| 105 | revokeForUser: () => Effect.succeed(false), | ||
| 106 | ...overrides, | ||
| 107 | }; | ||
| 108 | } | ||
| 109 | |||
| 110 | function makeDeliveryAttempts( | ||
| 111 | overrides: Partial<DeliveryAttempts.DeliveryAttemptsShape> = {}, | ||
| 112 | ): DeliveryAttempts.DeliveryAttemptsShape { | ||
| 113 | return { | ||
| 114 | record: () => Effect.void, | ||
| 115 | claimSourceJob: () => Effect.succeed("claimed"), | ||
| 116 | completeSourceJob: () => Effect.void, | ||
| 117 | ...overrides, | ||
| 118 | }; | ||
| 119 | } | ||
| 120 | |||
| 121 | const config = RelayConfiguration.RelayConfiguration.of({ | ||
| 122 | relayIssuer: "https://relay.example.test", | ||
| 123 | apns: { | ||
| 124 | environment: "sandbox", | ||
| 125 | teamId: "team-id", | ||
| 126 | keyId: "key-id", | ||
| 127 | bundleId: "codes.t3.mobile", | ||
| 128 | privateKey: Redacted.make("apns-private-key"), | ||
| 129 | }, | ||
| 130 | clerkSecretKey: Redacted.make("clerk-secret"), | ||
| 131 | clerkPublishableKey: "pk_test_test", | ||
| 132 | clerkJwtAudience: "t3-code-relay", | ||
| 133 | apnsDeliveryJobSigningSecret: Redacted.make("apns-job-secret"), | ||
| 134 | cloudMintPrivateKey: Redacted.make("cloud-private-key"), | ||
| 135 | cloudMintPublicKey: "cloud-public-key", | ||
| 136 | managedEndpointBaseDomain: undefined, | ||
| 137 | managedEndpointNamespace: undefined, | ||
| 138 | }); | ||
| 139 | |||
| 140 | function makeRegistrationReplayLayer(input: { | ||
| 141 | readonly devices: Devices.DevicesShape; | ||
| 142 | readonly liveActivities: LiveActivities.LiveActivitiesShape; | ||
| 143 | readonly queuedJobs: Array<SignedApnsDeliveryJob>; | ||
| 144 | }) { | ||
| 145 | return MobileRegistrations.layer.pipe( | ||
| 146 | Layer.provide(AgentActivityPublisher.layer), | ||
| 147 | Layer.provide(ApnsDeliveries.layer.pipe(Layer.provide(ApnsClient.layer))), | ||
| 148 | Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))), | ||
| 149 | Layer.provide( | ||
| 150 | Layer.mergeAll( | ||
| 151 | Layer.succeed(Devices.Devices, input.devices), | ||
| 152 | Layer.succeed(AgentActivityRows.AgentActivityRows, makeAgentActivityRows()), | ||
| 153 | Layer.succeed(EnvironmentLinks.EnvironmentLinks, makeEnvironmentLinks()), | ||
| 154 | Layer.succeed(LiveActivities.LiveActivities, input.liveActivities), | ||
| 155 | Layer.succeed(DeliveryAttempts.DeliveryAttempts, makeDeliveryAttempts()), | ||
| 156 | Layer.succeed(RelayConfiguration.RelayConfiguration, config), | ||
| 157 | Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, { | ||
| 158 | send: (body) => | ||
| 159 | Effect.sync(() => { | ||
| 160 | input.queuedJobs.push(body); | ||
| 161 | }), | ||
| 162 | }), | ||
| 163 | ), | ||
| 164 | ), | ||
| 165 | Layer.provide(FetchHttpClient.layer), | ||
| 166 | ); | ||
| 167 | } | ||
| 168 | |||
| 169 | function makeAgentActivityPublisher( | ||
| 170 | overrides: Partial<AgentActivityPublisher.AgentActivityPublisherShape> = {}, | ||
| 171 | ): AgentActivityPublisher.AgentActivityPublisherShape { | ||
| 172 | return { | ||
| 173 | publish: () => Effect.succeed({ ok: true, deliveries: [] }), | ||
| 174 | replayForLiveActivityRegistration: () => Effect.succeed(null), | ||
| 175 | ...overrides, | ||
| 176 | }; | ||
| 177 | } | ||
| 178 | |||
| 179 | describe("MobileRegistrations", () => { | ||
| 180 | it.effect("registers devices through the device persistence service", () => { | ||
| 181 | let registered: Parameters<Devices.DevicesShape["register"]>[0] | null = null; | ||
| 182 | let replayed: | ||
| 183 | | Parameters< | ||
| 184 | AgentActivityPublisher.AgentActivityPublisherShape["replayForLiveActivityRegistration"] | ||
| 185 | >[0] | ||
| 186 | | null = null; | ||
| 187 | |||
| 188 | return Effect.gen(function* () { | ||
| 189 | const result = yield* Effect.gen(function* () { | ||
| 190 | const registrations = yield* MobileRegistrations.MobileRegistrations; | ||
| 191 | return yield* registrations.registerDevice({ userId: "dev:julius", payload: device }); | ||
| 192 | }).pipe( | ||
| 193 | Effect.provide( | ||
| 194 | MobileRegistrations.layer.pipe( | ||
| 195 | Layer.provide( | ||
| 196 | Layer.mergeAll( | ||
| 197 | Layer.succeed( | ||
| 198 | Devices.Devices, | ||
| 199 | makeDevices({ | ||
| 200 | register: (input) => | ||
| 201 | Effect.sync(() => { | ||
| 202 | registered = input; | ||
| 203 | }), | ||
| 204 | }), | ||
| 205 | ), | ||
| 206 | Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), | ||
| 207 | Layer.succeed( | ||
| 208 | AgentActivityPublisher.AgentActivityPublisher, | ||
| 209 | makeAgentActivityPublisher({ | ||
| 210 | replayForLiveActivityRegistration: (input) => | ||
| 211 | Effect.sync(() => { | ||
| 212 | replayed = input; | ||
| 213 | return null; | ||
| 214 | }), | ||
| 215 | }), | ||
| 216 | ), | ||
| 217 | ), | ||
| 218 | ), | ||
| 219 | ), | ||
| 220 | ), | ||
| 221 | ); | ||
| 222 | |||
| 223 | expect(result).toEqual({ ok: true }); | ||
| 224 | expect(registered).toEqual({ userId: "dev:julius", registration: device }); | ||
| 225 | expect(replayed).toEqual({ | ||
| 226 | userId: "dev:julius", | ||
| 227 | deviceId: "device-1", | ||
| 228 | }); | ||
| 229 | }); | ||
| 230 | }); | ||
| 231 | |||
| 232 | it.effect("keeps device registration successful when activity replay fails", () => { | ||
| 233 | return Effect.gen(function* () { | ||
| 234 | const result = yield* Effect.gen(function* () { | ||
| 235 | const registrations = yield* MobileRegistrations.MobileRegistrations; | ||
| 236 | return yield* registrations.registerDevice({ userId: "dev:julius", payload: device }); | ||
| 237 | }).pipe( | ||
| 238 | Effect.provide( | ||
| 239 | MobileRegistrations.layer.pipe( | ||
| 240 | Layer.provide( | ||
| 241 | Layer.mergeAll( | ||
| 242 | Layer.succeed(Devices.Devices, makeDevices()), | ||
| 243 | Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), | ||
| 244 | Layer.succeed( | ||
| 245 | AgentActivityPublisher.AgentActivityPublisher, | ||
| 246 | makeAgentActivityPublisher({ | ||
| 247 | replayForLiveActivityRegistration: () => | ||
| 248 | Effect.fail( | ||
| 249 | new AgentActivityRows.AgentActivityRowListPersistenceError({ | ||
| 250 | cause: "replay failed", | ||
| 251 | }), | ||
| 252 | ), | ||
| 253 | }), | ||
| 254 | ), | ||
| 255 | ), | ||
| 256 | ), | ||
| 257 | ), | ||
| 258 | ), | ||
| 259 | ); | ||
| 260 | |||
| 261 | expect(result).toEqual({ ok: true }); | ||
| 262 | }); | ||
| 263 | }); | ||
| 264 | |||
| 265 | it.effect("unregisters the current user's device", () => { | ||
| 266 | let unregistered: Parameters<Devices.DevicesShape["unregister"]>[0] | null = null; | ||
| 267 | |||
| 268 | return Effect.gen(function* () { | ||
| 269 | const result = yield* Effect.gen(function* () { | ||
| 270 | const registrations = yield* MobileRegistrations.MobileRegistrations; | ||
| 271 | return yield* registrations.unregisterDevice({ | ||
| 272 | userId: "dev:julius", | ||
| 273 | deviceId: "device-1", | ||
| 274 | }); | ||
| 275 | }).pipe( | ||
| 276 | Effect.provide( | ||
| 277 | MobileRegistrations.layer.pipe( | ||
| 278 | Layer.provide( | ||
| 279 | Layer.mergeAll( | ||
| 280 | Layer.succeed( | ||
| 281 | Devices.Devices, | ||
| 282 | makeDevices({ | ||
| 283 | unregister: (input) => | ||
| 284 | Effect.sync(() => { | ||
| 285 | unregistered = input; | ||
| 286 | }), | ||
| 287 | }), | ||
| 288 | ), | ||
| 289 | Layer.succeed(LiveActivities.LiveActivities, makeLiveActivities()), | ||
| 290 | Layer.succeed( | ||
| 291 | AgentActivityPublisher.AgentActivityPublisher, | ||
| 292 | makeAgentActivityPublisher(), | ||
| 293 | ), | ||
| 294 | ), | ||
| 295 | ), | ||
| 296 | ), | ||
| 297 | ), | ||
| 298 | ); | ||
| 299 | |||
| 300 | expect(result).toEqual({ ok: true }); | ||
| 301 | expect(unregistered).toEqual({ | ||
| 302 | userId: "dev:julius", | ||
| 303 | deviceId: "device-1", | ||
| 304 | }); | ||
| 305 | }); | ||
| 306 | }); | ||
| 307 | |||
| 308 | it.effect("replays the latest activity state after registering a Live Activity token", () => { | ||
| 309 | const liveActivity = { | ||
| 310 | deviceId: "device-1" as const, | ||
| 311 | activityPushToken: "activity-token" as const, | ||
| 312 | }; | ||
| 313 | let registered: Parameters<LiveActivities.LiveActivitiesShape["register"]>[0] | null = null; | ||
| 314 | let replayed: | ||
| 315 | | Parameters< | ||
| 316 | AgentActivityPublisher.AgentActivityPublisherShape["replayForLiveActivityRegistration"] | ||
| 317 | >[0] | ||
| 318 | | null = null; | ||
| 319 | |||
| 320 | return Effect.gen(function* () { | ||
| 321 | const result = yield* Effect.gen(function* () { | ||
| 322 | const registrations = yield* MobileRegistrations.MobileRegistrations; | ||
| 323 | return yield* registrations.registerLiveActivity({ | ||
| 324 | userId: "dev:julius", | ||
| 325 | payload: liveActivity, | ||
| 326 | }); | ||
| 327 | }).pipe( | ||
| 328 | Effect.provide( | ||
| 329 | MobileRegistrations.layer.pipe( | ||
| 330 | Layer.provide( | ||
| 331 | Layer.mergeAll( | ||
| 332 | Layer.succeed(Devices.Devices, makeDevices()), | ||
| 333 | Layer.succeed( | ||
| 334 | LiveActivities.LiveActivities, | ||
| 335 | makeLiveActivities({ | ||
| 336 | register: (input) => | ||
| 337 | Effect.sync(() => { | ||
| 338 | registered = input; | ||
| 339 | }), | ||
| 340 | }), | ||
| 341 | ), | ||
| 342 | Layer.succeed( | ||
| 343 | AgentActivityPublisher.AgentActivityPublisher, | ||
| 344 | makeAgentActivityPublisher({ | ||
| 345 | replayForLiveActivityRegistration: (input) => | ||
| 346 | Effect.sync(() => { | ||
| 347 | replayed = input; | ||
| 348 | return null; | ||
| 349 | }), | ||
| 350 | }), | ||
| 351 | ), | ||
| 352 | ), | ||
| 353 | ), | ||
| 354 | ), | ||
| 355 | ), | ||
| 356 | ); | ||
| 357 | |||
| 358 | expect(result).toEqual({ ok: true }); | ||
| 359 | expect(registered).toEqual({ | ||
| 360 | userId: "dev:julius", | ||
| 361 | registration: liveActivity, | ||
| 362 | }); | ||
| 363 | expect(replayed).toEqual({ | ||
| 364 | userId: "dev:julius", | ||
| 365 | deviceId: "device-1", | ||
| 366 | }); | ||
| 367 | }); | ||
| 368 | }); | ||
| 369 | |||
| 370 | it.effect( | ||
| 371 | "starts a remote Live Activity through the real publisher and APNs queue when a device registers after work is already active", | ||
| 372 | () => { | ||
| 373 | const queuedJobs: Array<SignedApnsDeliveryJob> = []; | ||
| 374 | const queuedStarts: Array< | ||
| 375 | Parameters<LiveActivities.LiveActivitiesShape["markStartQueued"]>[0] | ||
| 376 | > = []; | ||
| 377 | const registeredDevices: Array<Parameters<Devices.DevicesShape["register"]>[0]> = []; | ||
| 378 | const devices = makeDevices({ | ||
| 379 | register: (input) => | ||
| 380 | Effect.sync(() => { | ||
| 381 | registeredDevices.push(input); | ||
| 382 | }), | ||
| 383 | }); | ||
| 384 | const liveActivities = makeLiveActivities({ | ||
| 385 | listTargets: () => | ||
| 386 | Effect.succeed([ | ||
| 387 | { | ||
| 388 | user_id: "dev:julius", | ||
| 389 | device_id: "device-1", | ||
| 390 | platform: "ios", | ||
| 391 | ios_major_version: 18, | ||
| 392 | app_version: "1.0.0", | ||
| 393 | push_token: "apns-device-token", | ||
| 394 | push_to_start_token: "push-to-start-token", | ||
| 395 | preferences_json: JSON.stringify(device.preferences), | ||
| 396 | activity_push_token: null, | ||
| 397 | remote_start_queued_at: null, | ||
| 398 | remote_started_at: null, | ||
| 399 | ended_at: null, | ||
| 400 | last_aggregate_json: null, | ||
| 401 | last_live_activity_delivery_at: null, | ||
| 402 | }, | ||
| 403 | ]), | ||
| 404 | markStartQueued: (input) => | ||
| 405 | Effect.sync(() => { | ||
| 406 | queuedStarts.push(input); | ||
| 407 | }), | ||
| 408 | }); | ||
| 409 | |||
| 410 | return Effect.gen(function* () { | ||
| 411 | const registrations = yield* MobileRegistrations.MobileRegistrations; | ||
| 412 | const result = yield* registrations.registerDevice({ | ||
| 413 | userId: "dev:julius", | ||
| 414 | payload: { | ||
| 415 | ...device, | ||
| 416 | pushToken: "apns-device-token", | ||
| 417 | pushToStartToken: "push-to-start-token", | ||
| 418 | }, | ||
| 419 | }); | ||
| 420 | |||
| 421 | expect(result).toEqual({ ok: true }); | ||
| 422 | expect(registeredDevices).toEqual([ | ||
| 423 | { | ||
| 424 | userId: "dev:julius", | ||
| 425 | registration: { | ||
| 426 | ...device, | ||
| 427 | pushToken: "apns-device-token", | ||
| 428 | pushToStartToken: "push-to-start-token", | ||
| 429 | }, | ||
| 430 | }, | ||
| 431 | ]); | ||
| 432 | expect(queuedStarts).toMatchObject([ | ||
| 433 | { | ||
| 434 | userId: "dev:julius", | ||
| 435 | deviceId: "device-1", | ||
| 436 | }, | ||
| 437 | ]); | ||
| 438 | expect(queuedJobs).toHaveLength(1); | ||
| 439 | expect(queuedJobs[0]?.payload).toMatchObject({ | ||
| 440 | kind: "live_activity_start", | ||
| 441 | target: { | ||
| 442 | userId: "dev:julius", | ||
| 443 | deviceId: "device-1", | ||
| 444 | token: "push-to-start-token", | ||
| 445 | }, | ||
| 446 | aggregate: { | ||
| 447 | title: "T3 Code", | ||
| 448 | subtitle: "Agent work in progress", | ||
| 449 | activeCount: 1, | ||
| 450 | activities: [ | ||
| 451 | { | ||
| 452 | environmentId: "env-1", | ||
| 453 | threadId: "thread-1", | ||
| 454 | threadTitle: "Implement APNs", | ||
| 455 | status: "Working", | ||
| 456 | }, | ||
| 457 | ], | ||
| 458 | }, | ||
| 459 | notification: null, | ||
| 460 | }); | ||
| 461 | }).pipe(Effect.provide(makeRegistrationReplayLayer({ devices, liveActivities, queuedJobs }))); | ||
| 462 | }, | ||
| 463 | ); | ||
| 464 | }); | ||
infra/relay/src/agentActivity/MobileRegistrations.ts created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | import type { | ||
| 2 | RelayDeviceRegistrationRequest, | ||
| 3 | RelayLiveActivityRegistrationRequest, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import * as Context from "effect/Context"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | |||
| 9 | import * as Devices from "./Devices.ts"; | ||
| 10 | import * as LiveActivities from "./LiveActivities.ts"; | ||
| 11 | import * as AgentActivityPublisher from "./AgentActivityPublisher.ts"; | ||
| 12 | |||
| 13 | export type MobileRegistrationError = | ||
| 14 | | Devices.DeviceRegistrationPersistenceError | ||
| 15 | | Devices.DeviceUnregistrationPersistenceError | ||
| 16 | | LiveActivities.LiveActivityRegistrationPersistenceError; | ||
| 17 | |||
| 18 | export interface MobileRegistrationsShape { | ||
| 19 | readonly registerDevice: (input: { | ||
| 20 | readonly userId: string; | ||
| 21 | readonly payload: RelayDeviceRegistrationRequest; | ||
| 22 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; | ||
| 23 | readonly registerLiveActivity: (input: { | ||
| 24 | readonly userId: string; | ||
| 25 | readonly payload: RelayLiveActivityRegistrationRequest; | ||
| 26 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; | ||
| 27 | readonly unregisterDevice: (input: { | ||
| 28 | readonly userId: string; | ||
| 29 | readonly deviceId: string; | ||
| 30 | }) => Effect.Effect<{ readonly ok: true }, MobileRegistrationError>; | ||
| 31 | } | ||
| 32 | |||
| 33 | export class MobileRegistrations extends Context.Service< | ||
| 34 | MobileRegistrations, | ||
| 35 | MobileRegistrationsShape | ||
| 36 | >()("t3code-relay/agentActivity/MobileRegistrations") {} | ||
| 37 | |||
| 38 | const make = Effect.gen(function* () { | ||
| 39 | const devices = yield* Devices.Devices; | ||
| 40 | const liveActivities = yield* LiveActivities.LiveActivities; | ||
| 41 | const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; | ||
| 42 | |||
| 43 | return MobileRegistrations.of({ | ||
| 44 | registerDevice: Effect.fn("relay.mobile_registrations.register_device")(function* (input) { | ||
| 45 | yield* Effect.annotateCurrentSpan({ | ||
| 46 | "relay.mobile.device_id": input.payload.deviceId, | ||
| 47 | "relay.mobile.platform": input.payload.platform, | ||
| 48 | }); | ||
| 49 | yield* devices.register({ userId: input.userId, registration: input.payload }); | ||
| 50 | yield* publisher | ||
| 51 | .replayForLiveActivityRegistration({ | ||
| 52 | userId: input.userId, | ||
| 53 | deviceId: input.payload.deviceId, | ||
| 54 | }) | ||
| 55 | .pipe( | ||
| 56 | Effect.tapError((cause) => | ||
| 57 | Effect.logWarning("device registration activity replay failed", { cause }), | ||
| 58 | ), | ||
| 59 | Effect.ignore, | ||
| 60 | ); | ||
| 61 | return { ok: true as const }; | ||
| 62 | }), | ||
| 63 | registerLiveActivity: Effect.fn("relay.mobile_registrations.register_live_activity")( | ||
| 64 | function* (input) { | ||
| 65 | yield* Effect.annotateCurrentSpan({ | ||
| 66 | "relay.mobile.device_id": input.payload.deviceId, | ||
| 67 | }); | ||
| 68 | yield* liveActivities.register({ userId: input.userId, registration: input.payload }); | ||
| 69 | yield* publisher | ||
| 70 | .replayForLiveActivityRegistration({ | ||
| 71 | userId: input.userId, | ||
| 72 | deviceId: input.payload.deviceId, | ||
| 73 | }) | ||
| 74 | .pipe( | ||
| 75 | Effect.tapError((cause) => | ||
| 76 | Effect.logWarning("live activity registration replay failed", { cause }), | ||
| 77 | ), | ||
| 78 | Effect.ignore, | ||
| 79 | ); | ||
| 80 | return { ok: true as const }; | ||
| 81 | }, | ||
| 82 | ), | ||
| 83 | unregisterDevice: Effect.fn("relay.mobile_registrations.unregister_device")(function* (input) { | ||
| 84 | yield* Effect.annotateCurrentSpan({ | ||
| 85 | "relay.mobile.device_id": input.deviceId, | ||
| 86 | }); | ||
| 87 | yield* devices.unregister(input); | ||
| 88 | return { ok: true as const }; | ||
| 89 | }), | ||
| 90 | }); | ||
| 91 | }); | ||
| 92 | |||
| 93 | export const layer = Layer.effect(MobileRegistrations, make); | ||
infra/relay/src/agentActivity/agentActivityPayloads.ts created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | import type { | ||
| 2 | RelayAgentActivityAggregateRow, | ||
| 3 | RelayAgentActivityAggregateState, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; | ||
| 6 | |||
| 7 | const MAX_SUMMARY_TEXT_LENGTH = 120; | ||
| 8 | const MAX_STATUS_TEXT_LENGTH = 40; | ||
| 9 | const MAX_DEEP_LINK_LENGTH = 512; | ||
| 10 | const MAX_ACTIVITY_ROWS = 3; | ||
| 11 | |||
| 12 | function truncateText(value: string, maxLength: number): string { | ||
| 13 | const trimmed = value.trim(); | ||
| 14 | if (trimmed.length <= maxLength) { | ||
| 15 | return trimmed; | ||
| 16 | } | ||
| 17 | return trimmed.slice(0, maxLength - 3).trimEnd() + "..."; | ||
| 18 | } | ||
| 19 | |||
| 20 | function sanitizeDeepLink(value: string): string { | ||
| 21 | const trimmed = value.trim(); | ||
| 22 | if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { | ||
| 23 | return "/"; | ||
| 24 | } | ||
| 25 | return truncateText(trimmed, MAX_DEEP_LINK_LENGTH); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function sanitizeAgentActivityAggregateRow( | ||
| 29 | row: RelayAgentActivityAggregateRow, | ||
| 30 | ): RelayAgentActivityAggregateRow { | ||
| 31 | return { | ||
| 32 | ...row, | ||
| 33 | projectTitle: truncateText(row.projectTitle, MAX_SUMMARY_TEXT_LENGTH), | ||
| 34 | threadTitle: truncateText(row.threadTitle, MAX_SUMMARY_TEXT_LENGTH), | ||
| 35 | modelTitle: truncateText(row.modelTitle, MAX_SUMMARY_TEXT_LENGTH), | ||
| 36 | status: truncateText(row.status, MAX_STATUS_TEXT_LENGTH), | ||
| 37 | deepLink: sanitizeDeepLink(row.deepLink), | ||
| 38 | }; | ||
| 39 | } | ||
| 40 | |||
| 41 | export function sanitizeAgentActivityAggregateState( | ||
| 42 | aggregate: RelayAgentActivityAggregateState, | ||
| 43 | ): RelayAgentActivityAggregateState { | ||
| 44 | return { | ||
| 45 | ...aggregate, | ||
| 46 | title: truncateText(aggregate.title, MAX_SUMMARY_TEXT_LENGTH), | ||
| 47 | subtitle: truncateText(aggregate.subtitle, MAX_SUMMARY_TEXT_LENGTH), | ||
| 48 | activities: aggregate.activities | ||
| 49 | .slice(0, MAX_ACTIVITY_ROWS) | ||
| 50 | .map(sanitizeAgentActivityAggregateRow), | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | |||
| 54 | export function sanitizeApnsNotificationPayload( | ||
| 55 | notification: ApnsNotificationPayload, | ||
| 56 | ): ApnsNotificationPayload { | ||
| 57 | return { | ||
| 58 | ...notification, | ||
| 59 | title: truncateText(notification.title, MAX_SUMMARY_TEXT_LENGTH), | ||
| 60 | body: truncateText(notification.body, MAX_SUMMARY_TEXT_LENGTH), | ||
| 61 | deepLink: sanitizeDeepLink(notification.deepLink), | ||
| 62 | }; | ||
| 63 | } | ||
infra/relay/src/agentActivity/apnsDeliveryJobs.test.ts created+221| ... | @@ -0,0 +1,221 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; | ||
| 4 | import * as Redacted from "effect/Redacted"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | makeApnsDeliveryJobPayload, | ||
| 8 | signApnsDeliveryJob, | ||
| 9 | verifySignedApnsDeliveryJob, | ||
| 10 | } from "./apnsDeliveryJobs.ts"; | ||
| 11 | |||
| 12 | const secret = Redacted.make("queue-signing-secret"); | ||
| 13 | const aggregate: RelayAgentActivityAggregateState = { | ||
| 14 | title: "T3 Code", | ||
| 15 | subtitle: "Agent work in progress", | ||
| 16 | activeCount: 1, | ||
| 17 | updatedAt: "2026-05-25T00:00:00.000Z", | ||
| 18 | activities: [ | ||
| 19 | { | ||
| 20 | environmentId: EnvironmentId.make("env"), | ||
| 21 | threadId: ThreadId.make("thread"), | ||
| 22 | projectTitle: "Project", | ||
| 23 | threadTitle: "Thread", | ||
| 24 | modelTitle: "gpt-5.4", | ||
| 25 | phase: "running", | ||
| 26 | status: "Working", | ||
| 27 | updatedAt: "2026-05-25T00:00:00.000Z", | ||
| 28 | deepLink: "/threads/env/thread", | ||
| 29 | }, | ||
| 30 | ], | ||
| 31 | }; | ||
| 32 | |||
| 33 | const notification = { | ||
| 34 | title: "Thread", | ||
| 35 | body: "Input: Project", | ||
| 36 | environmentId: "env", | ||
| 37 | threadId: "thread", | ||
| 38 | deepLink: "/threads/env/thread", | ||
| 39 | }; | ||
| 40 | |||
| 41 | describe("apnsDeliveryJobs", () => { | ||
| 42 | it("rejects tampered signed queue jobs", () => { | ||
| 43 | const payload = makeApnsDeliveryJobPayload({ | ||
| 44 | kind: "live_activity_end", | ||
| 45 | userId: "user-1", | ||
| 46 | deviceId: "device-1", | ||
| 47 | token: "token-1", | ||
| 48 | aggregate: null, | ||
| 49 | createdAt: "2026-05-25T00:00:00.000Z", | ||
| 50 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 51 | jobId: "job-1", | ||
| 52 | }); | ||
| 53 | const signed = signApnsDeliveryJob({ secret, payload }); | ||
| 54 | const tampered = { | ||
| 55 | ...signed, | ||
| 56 | payload: { | ||
| 57 | ...signed.payload, | ||
| 58 | target: { | ||
| 59 | ...signed.payload.target, | ||
| 60 | token: "attacker-token", | ||
| 61 | }, | ||
| 62 | }, | ||
| 63 | }; | ||
| 64 | |||
| 65 | const result = verifySignedApnsDeliveryJob({ | ||
| 66 | secret, | ||
| 67 | job: tampered, | ||
| 68 | nowMs: 0, | ||
| 69 | }); | ||
| 70 | |||
| 71 | expect(result).toMatchObject({ | ||
| 72 | _tag: "ApnsDeliveryJobInvalid", | ||
| 73 | }); | ||
| 74 | }); | ||
| 75 | |||
| 76 | it("rejects Live Activity start jobs without aggregate state", () => { | ||
| 77 | const payload = makeApnsDeliveryJobPayload({ | ||
| 78 | kind: "live_activity_start", | ||
| 79 | userId: "user-1", | ||
| 80 | deviceId: "device-1", | ||
| 81 | token: "token-1", | ||
| 82 | aggregate: null, | ||
| 83 | createdAt: "2026-05-25T00:00:00.000Z", | ||
| 84 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 85 | jobId: "job-start-invalid", | ||
| 86 | }); | ||
| 87 | const signed = signApnsDeliveryJob({ secret, payload }); | ||
| 88 | |||
| 89 | const result = verifySignedApnsDeliveryJob({ | ||
| 90 | secret, | ||
| 91 | job: signed, | ||
| 92 | nowMs: 0, | ||
| 93 | }); | ||
| 94 | |||
| 95 | expect(result).toMatchObject({ | ||
| 96 | _tag: "ApnsDeliveryJobInvalid", | ||
| 97 | message: "Live Activity start/update jobs require an aggregate.", | ||
| 98 | }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | it("rejects push notification jobs carrying aggregate state", () => { | ||
| 102 | const payload = makeApnsDeliveryJobPayload({ | ||
| 103 | kind: "push_notification", | ||
| 104 | userId: "user-1", | ||
| 105 | deviceId: "device-1", | ||
| 106 | token: "token-1", | ||
| 107 | aggregate, | ||
| 108 | notification, | ||
| 109 | createdAt: "2026-05-25T00:00:00.000Z", | ||
| 110 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 111 | jobId: "job-push-invalid", | ||
| 112 | }); | ||
| 113 | const signed = signApnsDeliveryJob({ secret, payload }); | ||
| 114 | |||
| 115 | const result = verifySignedApnsDeliveryJob({ | ||
| 116 | secret, | ||
| 117 | job: signed, | ||
| 118 | nowMs: 0, | ||
| 119 | }); | ||
| 120 | |||
| 121 | expect(result).toMatchObject({ | ||
| 122 | _tag: "ApnsDeliveryJobInvalid", | ||
| 123 | message: "Push notification jobs must not carry aggregate state.", | ||
| 124 | }); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("accepts minimal kind-specific signed queue jobs", () => { | ||
| 128 | const pushPayload = makeApnsDeliveryJobPayload({ | ||
| 129 | kind: "push_notification", | ||
| 130 | userId: "user-1", | ||
| 131 | deviceId: "device-1", | ||
| 132 | token: "token-1", | ||
| 133 | aggregate: null, | ||
| 134 | notification, | ||
| 135 | createdAt: "2026-05-25T00:00:00.000Z", | ||
| 136 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 137 | jobId: "job-push-valid", | ||
| 138 | }); | ||
| 139 | const liveActivityPayload = makeApnsDeliveryJobPayload({ | ||
| 140 | kind: "live_activity_update", | ||
| 141 | userId: "user-1", | ||
| 142 | deviceId: "device-1", | ||
| 143 | token: "token-1", | ||
| 144 | aggregate, | ||
| 145 | createdAt: "2026-05-25T00:00:00.000Z", | ||
| 146 | expiresAt: "2026-05-25T00:05:00.000Z", | ||
| 147 | jobId: "job-live-valid", | ||
| 148 | }); | ||
| 149 | |||
| 150 | expect( | ||
| 151 | verifySignedApnsDeliveryJob({ | ||
| 152 | secret, | ||
| 153 | job: signApnsDeliveryJob({ secret, payload: pushPayload }), | ||
| 154 | nowMs: 0, | ||
| 155 | }), | ||
| 156 | ).toEqual(pushPayload); | ||
| 157 | expect( | ||
| 158 | verifySignedApnsDeliveryJob({ | ||
| 159 | secret, | ||
| 160 | job: signApnsDeliveryJob({ secret, payload: liveActivityPayload }), | ||
| 161 | nowMs: 0, | ||
| 162 | }), | ||
| 163 | ).toEqual(liveActivityPayload); | ||
| 164 | }); | ||
| 165 | |||
| 166 | it("rejects jobs with invalid or overlong time windows", () => { | ||
| 167 | const basePayload = makeApnsDeliveryJobPayload({ | ||
| 168 | kind: "live_activity_end", | ||
| 169 | userId: "user-1", | ||
| 170 | deviceId: "device-1", | ||
| 171 | token: "token-1", | ||
| 172 | aggregate: null, | ||
| 173 | createdAt: "2026-05-25T00:00:00.000Z", | ||
| 174 | expiresAt: "2026-05-25T00:10:00.000Z", | ||
| 175 | jobId: "job-window", | ||
| 176 | }); | ||
| 177 | const invalidCreatedAt = { | ||
| 178 | ...basePayload, | ||
| 179 | createdAt: "not-a-date", | ||
| 180 | }; | ||
| 181 | const invertedWindow = { | ||
| 182 | ...basePayload, | ||
| 183 | expiresAt: "2026-05-24T23:59:59.000Z", | ||
| 184 | }; | ||
| 185 | const overlongWindow = { | ||
| 186 | ...basePayload, | ||
| 187 | expiresAt: "2026-05-25T00:10:01.000Z", | ||
| 188 | }; | ||
| 189 | |||
| 190 | expect( | ||
| 191 | verifySignedApnsDeliveryJob({ | ||
| 192 | secret, | ||
| 193 | job: signApnsDeliveryJob({ secret, payload: invalidCreatedAt }), | ||
| 194 | nowMs: 0, | ||
| 195 | }), | ||
| 196 | ).toMatchObject({ | ||
| 197 | _tag: "ApnsDeliveryJobInvalid", | ||
| 198 | message: "Invalid APNs delivery job creation time.", | ||
| 199 | }); | ||
| 200 | expect( | ||
| 201 | verifySignedApnsDeliveryJob({ | ||
| 202 | secret, | ||
| 203 | job: signApnsDeliveryJob({ secret, payload: invertedWindow }), | ||
| 204 | nowMs: 0, | ||
| 205 | }), | ||
| 206 | ).toMatchObject({ | ||
| 207 | _tag: "ApnsDeliveryJobInvalid", | ||
| 208 | message: "Invalid APNs delivery job time window.", | ||
| 209 | }); | ||
| 210 | expect( | ||
| 211 | verifySignedApnsDeliveryJob({ | ||
| 212 | secret, | ||
| 213 | job: signApnsDeliveryJob({ secret, payload: overlongWindow }), | ||
| 214 | nowMs: 0, | ||
| 215 | }), | ||
| 216 | ).toMatchObject({ | ||
| 217 | _tag: "ApnsDeliveryJobInvalid", | ||
| 218 | message: "APNs delivery job time window is too long.", | ||
| 219 | }); | ||
| 220 | }); | ||
| 221 | }); | ||
infra/relay/src/agentActivity/apnsDeliveryJobs.ts created+196| ... | @@ -0,0 +1,196 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | |||
| 3 | import { RelayAgentActivityAggregateState, type RelayDeliveryKind } from "@t3tools/contracts/relay"; | ||
| 4 | import { stableStringify } from "@t3tools/shared/relaySigning"; | ||
| 5 | import * as Data from "effect/Data"; | ||
| 6 | import * as DateTime from "effect/DateTime"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Redacted from "effect/Redacted"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | |||
| 11 | const MAX_JOB_AGE_MS = 10 * 60 * 1_000; | ||
| 12 | export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256"; | ||
| 13 | |||
| 14 | const ApnsDeliveryKind = Schema.Literals([ | ||
| 15 | "live_activity_start", | ||
| 16 | "live_activity_update", | ||
| 17 | "live_activity_end", | ||
| 18 | "push_notification", | ||
| 19 | ]); | ||
| 20 | |||
| 21 | export const ApnsNotificationPayload = Schema.Struct({ | ||
| 22 | title: Schema.String, | ||
| 23 | body: Schema.String, | ||
| 24 | environmentId: Schema.String, | ||
| 25 | threadId: Schema.String, | ||
| 26 | deepLink: Schema.String, | ||
| 27 | }); | ||
| 28 | export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type; | ||
| 29 | |||
| 30 | export const ApnsDeliveryJobPayload = Schema.Struct({ | ||
| 31 | version: Schema.Literal(1), | ||
| 32 | jobId: Schema.String, | ||
| 33 | kind: ApnsDeliveryKind, | ||
| 34 | target: Schema.Struct({ | ||
| 35 | userId: Schema.String, | ||
| 36 | deviceId: Schema.String, | ||
| 37 | token: Schema.String, | ||
| 38 | }), | ||
| 39 | aggregate: Schema.NullOr(RelayAgentActivityAggregateState), | ||
| 40 | notification: Schema.NullOr(ApnsNotificationPayload), | ||
| 41 | createdAt: Schema.String, | ||
| 42 | expiresAt: Schema.String, | ||
| 43 | }); | ||
| 44 | export type ApnsDeliveryJobPayload = typeof ApnsDeliveryJobPayload.Type; | ||
| 45 | |||
| 46 | export const SignedApnsDeliveryJob = Schema.Struct({ | ||
| 47 | algorithm: Schema.Literal(APNS_DELIVERY_JOB_SIGNING_ALGORITHM), | ||
| 48 | payload: ApnsDeliveryJobPayload, | ||
| 49 | signature: Schema.String, | ||
| 50 | }); | ||
| 51 | export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type; | ||
| 52 | |||
| 53 | export class ApnsDeliveryJobInvalid extends Data.TaggedError("ApnsDeliveryJobInvalid")<{ | ||
| 54 | readonly message: string; | ||
| 55 | }> {} | ||
| 56 | |||
| 57 | export class ApnsDeliveryJobExpired extends Data.TaggedError("ApnsDeliveryJobExpired")<{ | ||
| 58 | readonly expiresAt: string; | ||
| 59 | }> {} | ||
| 60 | |||
| 61 | export type ApnsDeliveryJobVerificationError = ApnsDeliveryJobInvalid | ApnsDeliveryJobExpired; | ||
| 62 | |||
| 63 | export function makeApnsDeliveryJobPayload(input: { | ||
| 64 | readonly kind: RelayDeliveryKind; | ||
| 65 | readonly userId: string; | ||
| 66 | readonly deviceId: string; | ||
| 67 | readonly token: string; | ||
| 68 | readonly aggregate: ApnsDeliveryJobPayload["aggregate"]; | ||
| 69 | readonly notification?: ApnsNotificationPayload | null; | ||
| 70 | readonly createdAt: string; | ||
| 71 | readonly expiresAt: string; | ||
| 72 | readonly jobId: string; | ||
| 73 | }): ApnsDeliveryJobPayload { | ||
| 74 | return { | ||
| 75 | version: 1, | ||
| 76 | jobId: input.jobId, | ||
| 77 | kind: input.kind, | ||
| 78 | target: { | ||
| 79 | userId: input.userId, | ||
| 80 | deviceId: input.deviceId, | ||
| 81 | token: input.token, | ||
| 82 | }, | ||
| 83 | aggregate: input.aggregate, | ||
| 84 | notification: input.notification ?? null, | ||
| 85 | createdAt: input.createdAt, | ||
| 86 | expiresAt: input.expiresAt, | ||
| 87 | }; | ||
| 88 | } | ||
| 89 | |||
| 90 | export function expiresAtForJob(createdAtMs: number): string { | ||
| 91 | return DateTime.formatIso(Option.getOrThrow(DateTime.make(createdAtMs + MAX_JOB_AGE_MS))); | ||
| 92 | } | ||
| 93 | |||
| 94 | function validatePayloadShape(payload: ApnsDeliveryJobPayload): ApnsDeliveryJobInvalid | null { | ||
| 95 | switch (payload.kind) { | ||
| 96 | case "live_activity_start": | ||
| 97 | case "live_activity_update": | ||
| 98 | if (payload.aggregate === null) { | ||
| 99 | return new ApnsDeliveryJobInvalid({ | ||
| 100 | message: "Live Activity start/update jobs require an aggregate.", | ||
| 101 | }); | ||
| 102 | } | ||
| 103 | if (payload.notification !== null) { | ||
| 104 | return new ApnsDeliveryJobInvalid({ | ||
| 105 | message: "Live Activity jobs must not carry push notification payloads.", | ||
| 106 | }); | ||
| 107 | } | ||
| 108 | return null; | ||
| 109 | case "live_activity_end": | ||
| 110 | if (payload.notification !== null) { | ||
| 111 | return new ApnsDeliveryJobInvalid({ | ||
| 112 | message: "Live Activity jobs must not carry push notification payloads.", | ||
| 113 | }); | ||
| 114 | } | ||
| 115 | return null; | ||
| 116 | case "push_notification": | ||
| 117 | if (payload.notification === null) { | ||
| 118 | return new ApnsDeliveryJobInvalid({ | ||
| 119 | message: "Push notification jobs require a notification payload.", | ||
| 120 | }); | ||
| 121 | } | ||
| 122 | if (payload.aggregate !== null) { | ||
| 123 | return new ApnsDeliveryJobInvalid({ | ||
| 124 | message: "Push notification jobs must not carry aggregate state.", | ||
| 125 | }); | ||
| 126 | } | ||
| 127 | return null; | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | function signatureForPayload(input: { | ||
| 132 | readonly secret: Redacted.Redacted<string>; | ||
| 133 | readonly payload: ApnsDeliveryJobPayload; | ||
| 134 | }): string { | ||
| 135 | return NodeCrypto.createHmac("sha256", Redacted.value(input.secret)) | ||
| 136 | .update(stableStringify(input.payload)) | ||
| 137 | .digest("base64url"); | ||
| 138 | } | ||
| 139 | |||
| 140 | function timingSafeEqualBase64Url(left: string, right: string): boolean { | ||
| 141 | const leftBuffer = Buffer.from(left, "base64url"); | ||
| 142 | const rightBuffer = Buffer.from(right, "base64url"); | ||
| 143 | if (leftBuffer.length !== rightBuffer.length) { | ||
| 144 | return false; | ||
| 145 | } | ||
| 146 | return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer); | ||
| 147 | } | ||
| 148 | |||
| 149 | export function signApnsDeliveryJob(input: { | ||
| 150 | readonly secret: Redacted.Redacted<string>; | ||
| 151 | readonly payload: ApnsDeliveryJobPayload; | ||
| 152 | }): SignedApnsDeliveryJob { | ||
| 153 | return { | ||
| 154 | algorithm: APNS_DELIVERY_JOB_SIGNING_ALGORITHM, | ||
| 155 | payload: input.payload, | ||
| 156 | signature: signatureForPayload(input), | ||
| 157 | }; | ||
| 158 | } | ||
| 159 | |||
| 160 | export function verifySignedApnsDeliveryJob(input: { | ||
| 161 | readonly secret: Redacted.Redacted<string>; | ||
| 162 | readonly job: SignedApnsDeliveryJob; | ||
| 163 | readonly nowMs: number; | ||
| 164 | }): ApnsDeliveryJobPayload | ApnsDeliveryJobVerificationError { | ||
| 165 | const invalidPayload = validatePayloadShape(input.job.payload); | ||
| 166 | if (invalidPayload !== null) { | ||
| 167 | return invalidPayload; | ||
| 168 | } | ||
| 169 | const createdAt = DateTime.make(input.job.payload.createdAt); | ||
| 170 | if (Option.isNone(createdAt)) { | ||
| 171 | return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job creation time." }); | ||
| 172 | } | ||
| 173 | const expiresAt = DateTime.make(input.job.payload.expiresAt); | ||
| 174 | if (Option.isNone(expiresAt)) { | ||
| 175 | return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job expiry." }); | ||
| 176 | } | ||
| 177 | const createdAtMs = createdAt.value.epochMilliseconds; | ||
| 178 | const expiresAtMs = expiresAt.value.epochMilliseconds; | ||
| 179 | if (expiresAtMs <= createdAtMs) { | ||
| 180 | return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job time window." }); | ||
| 181 | } | ||
| 182 | if (expiresAtMs - createdAtMs > MAX_JOB_AGE_MS) { | ||
| 183 | return new ApnsDeliveryJobInvalid({ message: "APNs delivery job time window is too long." }); | ||
| 184 | } | ||
| 185 | if (expiresAtMs <= input.nowMs) { | ||
| 186 | return new ApnsDeliveryJobExpired({ expiresAt: input.job.payload.expiresAt }); | ||
| 187 | } | ||
| 188 | const expected = signatureForPayload({ | ||
| 189 | secret: input.secret, | ||
| 190 | payload: input.job.payload, | ||
| 191 | }); | ||
| 192 | if (!timingSafeEqualBase64Url(input.job.signature, expected)) { | ||
| 193 | return new ApnsDeliveryJobInvalid({ message: "Invalid APNs delivery job signature." }); | ||
| 194 | } | ||
| 195 | return input.job.payload; | ||
| 196 | } | ||
infra/relay/src/auth/DpopProofs.test.ts created+95| ... | @@ -0,0 +1,95 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | |||
| 7 | import { RelayDb, type RelayDatabase } from "../db.ts"; | ||
| 8 | import { relayDpopProofs } from "../persistence/schema.ts"; | ||
| 9 | import * as DpopProofs from "./DpopProofs.ts"; | ||
| 10 | |||
| 11 | describe("DpopProofReplay", () => { | ||
| 12 | it.effect("consumes proof ids without pruning expired rows on the request path", () => { | ||
| 13 | const calls: Array<string> = []; | ||
| 14 | const insertedValues: Array<{ | ||
| 15 | readonly thumbprint: string; | ||
| 16 | readonly jti: string; | ||
| 17 | readonly iat: number; | ||
| 18 | readonly expiresAt: string; | ||
| 19 | readonly createdAt: string; | ||
| 20 | }> = []; | ||
| 21 | const fakeDb = { | ||
| 22 | insert: (table: unknown) => { | ||
| 23 | expect(table).toBe(relayDpopProofs); | ||
| 24 | calls.push("insert"); | ||
| 25 | return { | ||
| 26 | values: (values: (typeof insertedValues)[number]) => { | ||
| 27 | insertedValues.push(values); | ||
| 28 | calls.push("insert.values"); | ||
| 29 | return { | ||
| 30 | onConflictDoNothing: () => { | ||
| 31 | calls.push("insert.onConflictDoNothing"); | ||
| 32 | return { | ||
| 33 | returning: (selection: unknown) => { | ||
| 34 | expect(selection).toBeDefined(); | ||
| 35 | calls.push("insert.returning"); | ||
| 36 | return Effect.succeed([{ jti: values.jti }]); | ||
| 37 | }, | ||
| 38 | }; | ||
| 39 | }, | ||
| 40 | }; | ||
| 41 | }, | ||
| 42 | }; | ||
| 43 | }, | ||
| 44 | } as unknown as RelayDatabase; | ||
| 45 | |||
| 46 | return Effect.gen(function* () { | ||
| 47 | const replay = yield* DpopProofs.DpopProofReplay; | ||
| 48 | const consumed = yield* replay.consume({ | ||
| 49 | thumbprint: "thumbprint", | ||
| 50 | jti: "jti", | ||
| 51 | iat: 1_771_000_000, | ||
| 52 | expiresAt: Option.getOrThrow(DateTime.make("2026-05-25T12:00:00.000Z")), | ||
| 53 | }); | ||
| 54 | |||
| 55 | expect(consumed).toBe(true); | ||
| 56 | expect(calls).toEqual([ | ||
| 57 | "insert", | ||
| 58 | "insert.values", | ||
| 59 | "insert.onConflictDoNothing", | ||
| 60 | "insert.returning", | ||
| 61 | ]); | ||
| 62 | expect(insertedValues).toMatchObject([ | ||
| 63 | { | ||
| 64 | thumbprint: "thumbprint", | ||
| 65 | jti: "jti", | ||
| 66 | iat: 1_771_000_000, | ||
| 67 | expiresAt: "2026-05-25T12:00:00.000Z", | ||
| 68 | }, | ||
| 69 | ]); | ||
| 70 | }).pipe(Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it.effect("prunes expired proof rows from the maintenance path", () => { | ||
| 74 | const calls: Array<string> = []; | ||
| 75 | const fakeDb = { | ||
| 76 | delete: (table: unknown) => { | ||
| 77 | expect(table).toBe(relayDpopProofs); | ||
| 78 | calls.push("delete"); | ||
| 79 | return { | ||
| 80 | where: (condition: unknown) => { | ||
| 81 | expect(condition).toBeDefined(); | ||
| 82 | calls.push("delete.where"); | ||
| 83 | return Effect.void; | ||
| 84 | }, | ||
| 85 | }; | ||
| 86 | }, | ||
| 87 | } as unknown as RelayDatabase; | ||
| 88 | |||
| 89 | return Effect.gen(function* () { | ||
| 90 | const replay = yield* DpopProofs.DpopProofReplay; | ||
| 91 | yield* replay.pruneExpired; | ||
| 92 | expect(calls).toEqual(["delete", "delete.where"]); | ||
| 93 | }).pipe(Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); | ||
| 94 | }); | ||
| 95 | }); | ||
infra/relay/src/auth/DpopProofs.ts created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Data from "effect/Data"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; | ||
| 7 | import { lt } from "drizzle-orm"; | ||
| 8 | |||
| 9 | import { verifyDpopProof } from "@t3tools/shared/dpop"; | ||
| 10 | import { RelayDb } from "../db.ts"; | ||
| 11 | import { relayDpopProofs } from "../persistence/schema.ts"; | ||
| 12 | |||
| 13 | export class DpopProofReplayPersistenceError extends Data.TaggedError( | ||
| 14 | "DpopProofReplayPersistenceError", | ||
| 15 | )<{ | ||
| 16 | readonly cause: unknown; | ||
| 17 | }> {} | ||
| 18 | |||
| 19 | export interface DpopProofReplayShape { | ||
| 20 | readonly verifyAndConsume: (input: { | ||
| 21 | readonly proof: string | undefined; | ||
| 22 | readonly method: string; | ||
| 23 | readonly url: string; | ||
| 24 | readonly expectedThumbprint?: string; | ||
| 25 | readonly expectedAccessToken?: string; | ||
| 26 | readonly now: DateTime.DateTime; | ||
| 27 | }) => Effect.Effect<string, HttpApiError.Unauthorized | DpopProofReplayPersistenceError>; | ||
| 28 | |||
| 29 | readonly consume: (input: { | ||
| 30 | readonly thumbprint: string; | ||
| 31 | readonly jti: string; | ||
| 32 | readonly iat: number; | ||
| 33 | readonly expiresAt: DateTime.DateTime; | ||
| 34 | }) => Effect.Effect<boolean, DpopProofReplayPersistenceError>; | ||
| 35 | |||
| 36 | readonly pruneExpired: Effect.Effect<void, DpopProofReplayPersistenceError>; | ||
| 37 | } | ||
| 38 | |||
| 39 | export class DpopProofReplay extends Context.Service<DpopProofReplay, DpopProofReplayShape>()( | ||
| 40 | "t3code-relay/auth/DpopProofs/DpopProofReplay", | ||
| 41 | ) {} | ||
| 42 | |||
| 43 | const make = Effect.gen(function* () { | ||
| 44 | const db = yield* RelayDb; | ||
| 45 | |||
| 46 | const consume: DpopProofReplayShape["consume"] = Effect.fn("relay.dpop_proofs.consume")( | ||
| 47 | function* (input) { | ||
| 48 | const createdAt = DateTime.formatIso(yield* DateTime.now); | ||
| 49 | const inserted = yield* db | ||
| 50 | .insert(relayDpopProofs) | ||
| 51 | .values({ | ||
| 52 | thumbprint: input.thumbprint, | ||
| 53 | jti: input.jti, | ||
| 54 | iat: input.iat, | ||
| 55 | expiresAt: DateTime.formatIso(input.expiresAt), | ||
| 56 | createdAt, | ||
| 57 | }) | ||
| 58 | .onConflictDoNothing() | ||
| 59 | .returning({ jti: relayDpopProofs.jti }); | ||
| 60 | return inserted.length > 0; | ||
| 61 | }, | ||
| 62 | Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), | ||
| 63 | ); | ||
| 64 | |||
| 65 | const verifyAndConsume: DpopProofReplayShape["verifyAndConsume"] = Effect.fn( | ||
| 66 | "relay.dpop_proofs.verify_and_consume", | ||
| 67 | )(function* (input) { | ||
| 68 | yield* Effect.annotateCurrentSpan({ | ||
| 69 | "relay.dpop.method": input.method, | ||
| 70 | "relay.dpop.expected_thumbprint_present": input.expectedThumbprint !== undefined, | ||
| 71 | "relay.dpop.expected_access_token_present": input.expectedAccessToken !== undefined, | ||
| 72 | }); | ||
| 73 | const result = verifyDpopProof({ | ||
| 74 | proof: input.proof, | ||
| 75 | method: input.method, | ||
| 76 | url: input.url, | ||
| 77 | nowEpochSeconds: Math.floor(input.now.epochMilliseconds / 1_000), | ||
| 78 | ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), | ||
| 79 | ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), | ||
| 80 | }); | ||
| 81 | if (!result.ok) { | ||
| 82 | yield* Effect.logWarning("relay dpop proof rejected", { | ||
| 83 | reason: result.reason, | ||
| 84 | method: input.method, | ||
| 85 | url: input.url, | ||
| 86 | expectedThumbprintPresent: input.expectedThumbprint !== undefined, | ||
| 87 | expectedAccessTokenPresent: input.expectedAccessToken !== undefined, | ||
| 88 | }); | ||
| 89 | return yield* new HttpApiError.Unauthorized({}); | ||
| 90 | } | ||
| 91 | const consumed = yield* consume({ | ||
| 92 | thumbprint: result.thumbprint, | ||
| 93 | jti: result.jti, | ||
| 94 | iat: result.iat, | ||
| 95 | expiresAt: DateTime.add(input.now, { minutes: 5 }), | ||
| 96 | }); | ||
| 97 | if (!consumed) { | ||
| 98 | yield* Effect.logWarning("relay dpop proof replay rejected", { | ||
| 99 | thumbprint: result.thumbprint, | ||
| 100 | jti: result.jti, | ||
| 101 | iat: result.iat, | ||
| 102 | }); | ||
| 103 | return yield* new HttpApiError.Unauthorized({}); | ||
| 104 | } | ||
| 105 | yield* Effect.annotateCurrentSpan({ | ||
| 106 | "relay.dpop.thumbprint": result.thumbprint, | ||
| 107 | "relay.dpop.iat": result.iat, | ||
| 108 | }); | ||
| 109 | return result.thumbprint; | ||
| 110 | }); | ||
| 111 | |||
| 112 | const pruneExpired: DpopProofReplayShape["pruneExpired"] = Effect.gen(function* () { | ||
| 113 | const now = DateTime.formatIso(yield* DateTime.now); | ||
| 114 | yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now }); | ||
| 115 | yield* db.delete(relayDpopProofs).where(lt(relayDpopProofs.expiresAt, now)); | ||
| 116 | }).pipe( | ||
| 117 | Effect.withSpan("relay.dpop_proofs.prune_expired"), | ||
| 118 | Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })), | ||
| 119 | ); | ||
| 120 | |||
| 121 | return DpopProofReplay.of({ | ||
| 122 | verifyAndConsume, | ||
| 123 | consume, | ||
| 124 | pruneExpired, | ||
| 125 | }); | ||
| 126 | }); | ||
| 127 | |||
| 128 | export const layer = Layer.effect(DpopProofReplay, make); | ||
infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts created+217| ... | @@ -0,0 +1,217 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | |||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import { | ||
| 5 | computeDpopAccessTokenHash, | ||
| 6 | computeDpopJwkThumbprint, | ||
| 7 | type DpopPublicJwk, | ||
| 8 | } from "@t3tools/shared/dpop"; | ||
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | |||
| 13 | import { RelayDb, type RelayDatabase } from "../db.ts"; | ||
| 14 | import { relayDpopProofs } from "../persistence/schema.ts"; | ||
| 15 | import * as DpopProofs from "./DpopProofs.ts"; | ||
| 16 | |||
| 17 | interface DpopProofInsertValues { | ||
| 18 | readonly thumbprint: string; | ||
| 19 | readonly jti: string; | ||
| 20 | readonly iat: number; | ||
| 21 | readonly expiresAt: string; | ||
| 22 | readonly createdAt: string; | ||
| 23 | } | ||
| 24 | |||
| 25 | function makeDpopProof(input: { | ||
| 26 | readonly method: string; | ||
| 27 | readonly url: string; | ||
| 28 | readonly iat: number; | ||
| 29 | readonly jti: string; | ||
| 30 | readonly accessToken?: string; | ||
| 31 | }) { | ||
| 32 | const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { | ||
| 33 | namedCurve: "P-256", | ||
| 34 | }); | ||
| 35 | const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk; | ||
| 36 | const header = Buffer.from( | ||
| 37 | JSON.stringify({ | ||
| 38 | typ: "dpop+jwt", | ||
| 39 | alg: "ES256", | ||
| 40 | jwk: publicJwk, | ||
| 41 | }), | ||
| 42 | ).toString("base64url"); | ||
| 43 | const payload = Buffer.from( | ||
| 44 | JSON.stringify({ | ||
| 45 | htm: input.method, | ||
| 46 | htu: input.url, | ||
| 47 | jti: input.jti, | ||
| 48 | iat: input.iat, | ||
| 49 | ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), | ||
| 50 | }), | ||
| 51 | ).toString("base64url"); | ||
| 52 | const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { | ||
| 53 | key: privateKey, | ||
| 54 | dsaEncoding: "ieee-p1363", | ||
| 55 | }).toString("base64url"); | ||
| 56 | return { | ||
| 57 | proof: `${header}.${payload}.${signature}`, | ||
| 58 | thumbprint: computeDpopJwkThumbprint(publicJwk), | ||
| 59 | }; | ||
| 60 | } | ||
| 61 | |||
| 62 | function layer( | ||
| 63 | insert: ( | ||
| 64 | values: DpopProofInsertValues, | ||
| 65 | ) => Effect.Effect<ReadonlyArray<{ readonly jti: string }>, { _tag: string }>, | ||
| 66 | ) { | ||
| 67 | const fakeDb = { | ||
| 68 | insert: (table: unknown) => { | ||
| 69 | expect(table).toBe(relayDpopProofs); | ||
| 70 | return { | ||
| 71 | values: (values: DpopProofInsertValues) => ({ | ||
| 72 | onConflictDoNothing: () => ({ | ||
| 73 | returning: (selection: unknown) => { | ||
| 74 | expect(selection).toBeDefined(); | ||
| 75 | return insert(values); | ||
| 76 | }, | ||
| 77 | }), | ||
| 78 | }), | ||
| 79 | }; | ||
| 80 | }, | ||
| 81 | } as unknown as RelayDatabase; | ||
| 82 | return DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))); | ||
| 83 | } | ||
| 84 | |||
| 85 | function consumeEachProofOnce() { | ||
| 86 | const consumed = new Set<string>(); | ||
| 87 | return (values: DpopProofInsertValues) => | ||
| 88 | Effect.sync(() => { | ||
| 89 | const key = `${values.thumbprint}:${values.jti}`; | ||
| 90 | if (consumed.has(key)) { | ||
| 91 | return []; | ||
| 92 | } | ||
| 93 | consumed.add(key); | ||
| 94 | return [{ jti: values.jti }]; | ||
| 95 | }); | ||
| 96 | } | ||
| 97 | |||
| 98 | describe("DpopProofReplay.verifyAndConsume", () => { | ||
| 99 | it.effect("rejects replayed proofs after persistence consumes the jti once", () => { | ||
| 100 | const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z"); | ||
| 101 | const proof = makeDpopProof({ | ||
| 102 | method: "POST", | ||
| 103 | url: "https://relay.example.com/v1/environments/env/connect", | ||
| 104 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 105 | jti: "proof-1", | ||
| 106 | }); | ||
| 107 | |||
| 108 | return Effect.gen(function* () { | ||
| 109 | const replay = yield* DpopProofs.DpopProofReplay; | ||
| 110 | const first = yield* replay.verifyAndConsume({ | ||
| 111 | proof: proof.proof, | ||
| 112 | method: "POST", | ||
| 113 | url: "https://relay.example.com/v1/environments/env/connect", | ||
| 114 | expectedThumbprint: proof.thumbprint, | ||
| 115 | now, | ||
| 116 | }); | ||
| 117 | const second = yield* Effect.exit( | ||
| 118 | replay.verifyAndConsume({ | ||
| 119 | proof: proof.proof, | ||
| 120 | method: "POST", | ||
| 121 | url: "https://relay.example.com/v1/environments/env/connect", | ||
| 122 | expectedThumbprint: proof.thumbprint, | ||
| 123 | now, | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | |||
| 127 | expect(first).toBe(proof.thumbprint); | ||
| 128 | expect(second._tag).toBe("Failure"); | ||
| 129 | }).pipe(Effect.provide(layer(consumeEachProofOnce()))); | ||
| 130 | }); | ||
| 131 | |||
| 132 | it.effect("rejects proofs missing the expected access token hash", () => { | ||
| 133 | const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z"); | ||
| 134 | const proof = makeDpopProof({ | ||
| 135 | method: "POST", | ||
| 136 | url: "https://relay.example.com/v1/environments/env/connect", | ||
| 137 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 138 | jti: "proof-1", | ||
| 139 | }); | ||
| 140 | |||
| 141 | return Effect.gen(function* () { | ||
| 142 | const replay = yield* DpopProofs.DpopProofReplay; | ||
| 143 | const result = yield* Effect.exit( | ||
| 144 | replay.verifyAndConsume({ | ||
| 145 | proof: proof.proof, | ||
| 146 | method: "POST", | ||
| 147 | url: "https://relay.example.com/v1/environments/env/connect", | ||
| 148 | expectedThumbprint: proof.thumbprint, | ||
| 149 | expectedAccessToken: "clerk-access-token", | ||
| 150 | now, | ||
| 151 | }), | ||
| 152 | ); | ||
| 153 | |||
| 154 | expect(result._tag).toBe("Failure"); | ||
| 155 | }).pipe(Effect.provide(layer(() => Effect.die("unexpected DPoP replay persistence")))); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it.effect("preserves replay persistence failures", () => { | ||
| 159 | const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z"); | ||
| 160 | const proof = makeDpopProof({ | ||
| 161 | method: "POST", | ||
| 162 | url: "https://relay.example.com/v1/environments/env/connect", | ||
| 163 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 164 | jti: "proof-persistence-failure", | ||
| 165 | }); | ||
| 166 | const cause = "database unavailable"; | ||
| 167 | |||
| 168 | return Effect.gen(function* () { | ||
| 169 | const replay = yield* DpopProofs.DpopProofReplay; | ||
| 170 | const error = yield* Effect.flip( | ||
| 171 | replay.verifyAndConsume({ | ||
| 172 | proof: proof.proof, | ||
| 173 | method: "POST", | ||
| 174 | url: "https://relay.example.com/v1/environments/env/connect", | ||
| 175 | expectedThumbprint: proof.thumbprint, | ||
| 176 | now, | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | |||
| 180 | expect(error).toEqual(new DpopProofs.DpopProofReplayPersistenceError({ cause })); | ||
| 181 | }).pipe(Effect.provide(layer(() => Effect.fail({ _tag: cause })))); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it.effect("accepts proofs bound to the access token hash", () => { | ||
| 185 | const now = DateTime.makeUnsafe("2026-05-25T12:00:00.000Z"); | ||
| 186 | const proof = makeDpopProof({ | ||
| 187 | method: "POST", | ||
| 188 | url: "https://relay.example.com/v1/environments/env/status", | ||
| 189 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 190 | jti: "proof-status-1", | ||
| 191 | accessToken: "clerk-access-token", | ||
| 192 | }); | ||
| 193 | |||
| 194 | return Effect.gen(function* () { | ||
| 195 | const replay = yield* DpopProofs.DpopProofReplay; | ||
| 196 | const thumbprint = yield* replay.verifyAndConsume({ | ||
| 197 | proof: proof.proof, | ||
| 198 | method: "POST", | ||
| 199 | url: "https://relay.example.com/v1/environments/env/status", | ||
| 200 | expectedAccessToken: "clerk-access-token", | ||
| 201 | now, | ||
| 202 | }); | ||
| 203 | const second = yield* Effect.exit( | ||
| 204 | replay.verifyAndConsume({ | ||
| 205 | proof: proof.proof, | ||
| 206 | method: "POST", | ||
| 207 | url: "https://relay.example.com/v1/environments/env/status", | ||
| 208 | expectedAccessToken: "clerk-access-token", | ||
| 209 | now, | ||
| 210 | }), | ||
| 211 | ); | ||
| 212 | |||
| 213 | expect(thumbprint).toBe(proof.thumbprint); | ||
| 214 | expect(second._tag).toBe("Failure"); | ||
| 215 | }).pipe(Effect.provide(layer(consumeEachProofOnce()))); | ||
| 216 | }); | ||
| 217 | }); | ||
infra/relay/src/auth/RelayTokens.test.ts created+188| ... | @@ -0,0 +1,188 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | |||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import { signRelayJwt } from "@t3tools/shared/relayJwt"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Redacted from "effect/Redacted"; | ||
| 8 | |||
| 9 | import * as RelayConfiguration from "../Config.ts"; | ||
| 10 | import * as RelayTokens from "./RelayTokens.ts"; | ||
| 11 | |||
| 12 | const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 13 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 14 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 15 | }); | ||
| 16 | |||
| 17 | const config = RelayConfiguration.RelayConfiguration.of({ | ||
| 18 | relayIssuer: "https://relay.example.test/", | ||
| 19 | apns: { | ||
| 20 | environment: "sandbox", | ||
| 21 | teamId: "team-id", | ||
| 22 | keyId: "key-id", | ||
| 23 | privateKey: Redacted.make("private-key"), | ||
| 24 | bundleId: "com.t3tools.t3code.dev", | ||
| 25 | }, | ||
| 26 | apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), | ||
| 27 | clerkSecretKey: Redacted.make("clerk-secret"), | ||
| 28 | clerkPublishableKey: "pk_test_test", | ||
| 29 | clerkJwtAudience: "t3-code-relay", | ||
| 30 | cloudMintPrivateKey: Redacted.make(keyPair.privateKey), | ||
| 31 | cloudMintPublicKey: keyPair.publicKey, | ||
| 32 | managedEndpointBaseDomain: undefined, | ||
| 33 | managedEndpointNamespace: undefined, | ||
| 34 | }); | ||
| 35 | |||
| 36 | const layer = RelayTokens.layer.pipe( | ||
| 37 | Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), | ||
| 38 | ); | ||
| 39 | |||
| 40 | describe("RelayTokens", () => { | ||
| 41 | it.effect("issues a user-bound environment link challenge", () => | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 44 | const token = yield* relayTokens.issueLinkChallenge({ | ||
| 45 | userId: "user_123", | ||
| 46 | request: { | ||
| 47 | notificationsEnabled: true, | ||
| 48 | liveActivitiesEnabled: true, | ||
| 49 | managedTunnelsEnabled: true, | ||
| 50 | }, | ||
| 51 | jti: "challenge-1", | ||
| 52 | issuedAtEpochSeconds: 100, | ||
| 53 | expiresAtEpochSeconds: 200, | ||
| 54 | }); | ||
| 55 | |||
| 56 | expect( | ||
| 57 | yield* relayTokens.verifyLinkChallenge({ | ||
| 58 | token, | ||
| 59 | userId: "user_123", | ||
| 60 | request: { | ||
| 61 | notificationsEnabled: true, | ||
| 62 | liveActivitiesEnabled: true, | ||
| 63 | managedTunnelsEnabled: true, | ||
| 64 | }, | ||
| 65 | nowEpochSeconds: 150, | ||
| 66 | }), | ||
| 67 | ).toMatchObject({ sub: "user_123", jti: "challenge-1" }); | ||
| 68 | expect( | ||
| 69 | yield* relayTokens.verifyLinkChallenge({ | ||
| 70 | token, | ||
| 71 | userId: "attacker", | ||
| 72 | request: { | ||
| 73 | notificationsEnabled: true, | ||
| 74 | liveActivitiesEnabled: true, | ||
| 75 | managedTunnelsEnabled: true, | ||
| 76 | }, | ||
| 77 | nowEpochSeconds: 150, | ||
| 78 | }), | ||
| 79 | ).toBeNull(); | ||
| 80 | }).pipe(Effect.provide(layer)), | ||
| 81 | ); | ||
| 82 | |||
| 83 | it.effect("issues and verifies DPoP access tokens bound to one proof-key thumbprint", () => | ||
| 84 | Effect.gen(function* () { | ||
| 85 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 86 | const token = yield* relayTokens.issueDpopAccessToken({ | ||
| 87 | userId: "user_123", | ||
| 88 | proofKeyThumbprint: "proof-key-thumbprint", | ||
| 89 | jti: "access-token-1", | ||
| 90 | issuedAtEpochSeconds: 100, | ||
| 91 | expiresAtEpochSeconds: 200, | ||
| 92 | clientId: "t3-mobile", | ||
| 93 | scopes: ["environment:connect", "environment:status", "mobile:registration"], | ||
| 94 | }); | ||
| 95 | |||
| 96 | expect( | ||
| 97 | yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 }), | ||
| 98 | ).toMatchObject({ | ||
| 99 | sub: "user_123", | ||
| 100 | cnf: { jkt: "proof-key-thumbprint" }, | ||
| 101 | client_id: "t3-mobile", | ||
| 102 | scope: ["environment:connect", "environment:status", "mobile:registration"], | ||
| 103 | }); | ||
| 104 | expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 261 })).toBeNull(); | ||
| 105 | }).pipe(Effect.provide(layer)), | ||
| 106 | ); | ||
| 107 | |||
| 108 | it.effect("issues tunnel-only DPoP access tokens to web public clients", () => | ||
| 109 | Effect.gen(function* () { | ||
| 110 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 111 | const token = yield* relayTokens.issueDpopAccessToken({ | ||
| 112 | userId: "user_123", | ||
| 113 | proofKeyThumbprint: "web-proof-key-thumbprint", | ||
| 114 | jti: "web-access-token-1", | ||
| 115 | issuedAtEpochSeconds: 100, | ||
| 116 | expiresAtEpochSeconds: 200, | ||
| 117 | clientId: "t3-web", | ||
| 118 | scopes: ["environment:connect", "environment:status"], | ||
| 119 | }); | ||
| 120 | |||
| 121 | expect( | ||
| 122 | yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 }), | ||
| 123 | ).toMatchObject({ | ||
| 124 | client_id: "t3-web", | ||
| 125 | scope: ["environment:connect", "environment:status"], | ||
| 126 | cnf: { jkt: "web-proof-key-thumbprint" }, | ||
| 127 | }); | ||
| 128 | }).pipe(Effect.provide(layer)), | ||
| 129 | ); | ||
| 130 | |||
| 131 | it.effect("treats requested scope as an order-independent set", () => | ||
| 132 | Effect.gen(function* () { | ||
| 133 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 134 | expect( | ||
| 135 | relayTokens.resolveDpopAccessTokenScopes({ | ||
| 136 | clientId: "t3-mobile", | ||
| 137 | scope: "environment:status environment:connect environment:status", | ||
| 138 | }), | ||
| 139 | ).toEqual(["environment:status", "environment:connect"]); | ||
| 140 | }).pipe(Effect.provide(layer)), | ||
| 141 | ); | ||
| 142 | |||
| 143 | it.effect("rejects signed DPoP tokens whose scope is outside the relay policy", () => | ||
| 144 | Effect.gen(function* () { | ||
| 145 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 146 | const token = yield* signRelayJwt({ | ||
| 147 | privateKey: keyPair.privateKey, | ||
| 148 | typ: "t3-relay-dpop-access+jwt", | ||
| 149 | payload: { | ||
| 150 | iss: "https://relay.example.test", | ||
| 151 | aud: "https://relay.example.test", | ||
| 152 | sub: "user_123", | ||
| 153 | jti: "access-token-invalid-scope", | ||
| 154 | iat: 100, | ||
| 155 | exp: 200, | ||
| 156 | client_id: "t3-mobile", | ||
| 157 | scope: "environment:admin", | ||
| 158 | cnf: { jkt: "proof-key-thumbprint" }, | ||
| 159 | }, | ||
| 160 | }); | ||
| 161 | |||
| 162 | expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 })).toBeNull(); | ||
| 163 | }).pipe(Effect.provide(layer)), | ||
| 164 | ); | ||
| 165 | |||
| 166 | it.effect("rejects mobile registration scope on a web public client token", () => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 169 | const token = yield* signRelayJwt({ | ||
| 170 | privateKey: keyPair.privateKey, | ||
| 171 | typ: "t3-relay-dpop-access+jwt", | ||
| 172 | payload: { | ||
| 173 | iss: "https://relay.example.test", | ||
| 174 | aud: "https://relay.example.test", | ||
| 175 | sub: "user_123", | ||
| 176 | jti: "web-token-invalid-mobile-scope", | ||
| 177 | iat: 100, | ||
| 178 | exp: 200, | ||
| 179 | client_id: "t3-web", | ||
| 180 | scope: "environment:connect mobile:registration", | ||
| 181 | cnf: { jkt: "proof-key-thumbprint" }, | ||
| 182 | }, | ||
| 183 | }); | ||
| 184 | |||
| 185 | expect(yield* relayTokens.verifyDpopAccessToken({ token, nowEpochSeconds: 150 })).toBeNull(); | ||
| 186 | }).pipe(Effect.provide(layer)), | ||
| 187 | ); | ||
| 188 | }); | ||
infra/relay/src/auth/RelayTokens.ts created+220| ... | @@ -0,0 +1,220 @@ | ||
| 1 | import { | ||
| 2 | RelayDpopAccessTokenScope, | ||
| 3 | RelayEnvironmentConnectScope, | ||
| 4 | RelayEnvironmentStatusScope, | ||
| 5 | RelayMobileClientId, | ||
| 6 | RelayMobileRegistrationScope, | ||
| 7 | RelayWebClientId, | ||
| 8 | type RelayPublicClientId, | ||
| 9 | type RelayEnvironmentLinkChallengeRequest, | ||
| 10 | } from "@t3tools/contracts/relay"; | ||
| 11 | import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; | ||
| 12 | import { | ||
| 13 | normalizeRelayIssuer, | ||
| 14 | signRelayJwt, | ||
| 15 | verifyRelayJwt, | ||
| 16 | type RelayJwtError, | ||
| 17 | } from "@t3tools/shared/relayJwt"; | ||
| 18 | import * as Context from "effect/Context"; | ||
| 19 | import * as Effect from "effect/Effect"; | ||
| 20 | import * as Layer from "effect/Layer"; | ||
| 21 | import * as Redacted from "effect/Redacted"; | ||
| 22 | import * as Schema from "effect/Schema"; | ||
| 23 | |||
| 24 | import * as RelayConfiguration from "../Config.ts"; | ||
| 25 | |||
| 26 | const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt"; | ||
| 27 | const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt"; | ||
| 28 | const LINK_CHALLENGE_KIND = "environment_link_challenge"; | ||
| 29 | |||
| 30 | const LinkChallengeClaims = Schema.Struct({ | ||
| 31 | kind: Schema.Literal(LINK_CHALLENGE_KIND), | ||
| 32 | iss: Schema.String, | ||
| 33 | aud: Schema.String, | ||
| 34 | sub: Schema.String, | ||
| 35 | jti: Schema.String, | ||
| 36 | iat: Schema.Int, | ||
| 37 | exp: Schema.Int, | ||
| 38 | notificationsEnabled: Schema.Boolean, | ||
| 39 | liveActivitiesEnabled: Schema.Boolean, | ||
| 40 | managedTunnelsEnabled: Schema.Boolean, | ||
| 41 | }); | ||
| 42 | export type LinkChallengeClaims = typeof LinkChallengeClaims.Type; | ||
| 43 | |||
| 44 | const RelayDpopAccessTokenClaims = Schema.Struct({ | ||
| 45 | iss: Schema.String, | ||
| 46 | aud: Schema.String, | ||
| 47 | sub: Schema.String, | ||
| 48 | jti: Schema.String, | ||
| 49 | iat: Schema.Int, | ||
| 50 | exp: Schema.Int, | ||
| 51 | client_id: Schema.Literals([RelayMobileClientId, RelayWebClientId]), | ||
| 52 | scope: Schema.String, | ||
| 53 | cnf: Schema.Struct({ jkt: Schema.String }), | ||
| 54 | }); | ||
| 55 | export type RelayDpopAccessTokenClaims = Omit<typeof RelayDpopAccessTokenClaims.Type, "scope"> & { | ||
| 56 | readonly scope: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 57 | }; | ||
| 58 | |||
| 59 | const decodeLinkChallengeClaims = Schema.decodeUnknownEffect(LinkChallengeClaims); | ||
| 60 | const decodeDpopAccessTokenClaims = Schema.decodeUnknownEffect(RelayDpopAccessTokenClaims); | ||
| 61 | |||
| 62 | const allowedScopesByClientId: Record< | ||
| 63 | RelayPublicClientId, | ||
| 64 | ReadonlySet<RelayDpopAccessTokenScope> | ||
| 65 | > = { | ||
| 66 | [RelayMobileClientId]: new Set([ | ||
| 67 | RelayEnvironmentConnectScope, | ||
| 68 | RelayEnvironmentStatusScope, | ||
| 69 | RelayMobileRegistrationScope, | ||
| 70 | ]), | ||
| 71 | [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]), | ||
| 72 | }; | ||
| 73 | |||
| 74 | function resolveDpopAccessTokenScopes(input: { | ||
| 75 | readonly clientId: RelayPublicClientId; | ||
| 76 | readonly scope: string; | ||
| 77 | }): ReadonlyArray<RelayDpopAccessTokenScope> | null { | ||
| 78 | return parseAllowedOAuthScope({ | ||
| 79 | value: input.scope, | ||
| 80 | allowedScopes: allowedScopesByClientId[input.clientId], | ||
| 81 | }); | ||
| 82 | } | ||
| 83 | |||
| 84 | export interface RelayTokensShape { | ||
| 85 | readonly resolveDpopAccessTokenScopes: typeof resolveDpopAccessTokenScopes; | ||
| 86 | readonly issueLinkChallenge: (input: { | ||
| 87 | readonly userId: string; | ||
| 88 | readonly request: RelayEnvironmentLinkChallengeRequest; | ||
| 89 | readonly jti: string; | ||
| 90 | readonly issuedAtEpochSeconds: number; | ||
| 91 | readonly expiresAtEpochSeconds: number; | ||
| 92 | }) => Effect.Effect<string, RelayJwtError>; | ||
| 93 | readonly verifyLinkChallenge: (input: { | ||
| 94 | readonly token: string; | ||
| 95 | readonly userId: string; | ||
| 96 | readonly request: RelayEnvironmentLinkChallengeRequest; | ||
| 97 | readonly nowEpochSeconds: number; | ||
| 98 | }) => Effect.Effect<LinkChallengeClaims | null>; | ||
| 99 | readonly issueDpopAccessToken: (input: { | ||
| 100 | readonly userId: string; | ||
| 101 | readonly proofKeyThumbprint: string; | ||
| 102 | readonly jti: string; | ||
| 103 | readonly issuedAtEpochSeconds: number; | ||
| 104 | readonly expiresAtEpochSeconds: number; | ||
| 105 | readonly clientId: RelayPublicClientId; | ||
| 106 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 107 | }) => Effect.Effect<string, RelayJwtError>; | ||
| 108 | readonly verifyDpopAccessToken: (input: { | ||
| 109 | readonly token: string; | ||
| 110 | readonly nowEpochSeconds: number; | ||
| 111 | }) => Effect.Effect<RelayDpopAccessTokenClaims | null>; | ||
| 112 | } | ||
| 113 | |||
| 114 | export class RelayTokens extends Context.Service<RelayTokens, RelayTokensShape>()( | ||
| 115 | "t3code-relay/auth/RelayTokens", | ||
| 116 | ) {} | ||
| 117 | |||
| 118 | const make = Effect.gen(function* () { | ||
| 119 | const config = yield* RelayConfiguration.RelayConfiguration; | ||
| 120 | const issuer = normalizeRelayIssuer(config.relayIssuer); | ||
| 121 | |||
| 122 | const issueLinkChallenge: RelayTokensShape["issueLinkChallenge"] = Effect.fn( | ||
| 123 | "relay.tokens.issue_link_challenge", | ||
| 124 | )(function* (input) { | ||
| 125 | return yield* signRelayJwt({ | ||
| 126 | privateKey: Redacted.value(config.cloudMintPrivateKey), | ||
| 127 | typ: LINK_CHALLENGE_TYP, | ||
| 128 | payload: { | ||
| 129 | kind: LINK_CHALLENGE_KIND, | ||
| 130 | iss: issuer, | ||
| 131 | aud: issuer, | ||
| 132 | sub: input.userId, | ||
| 133 | jti: input.jti, | ||
| 134 | iat: input.issuedAtEpochSeconds, | ||
| 135 | exp: input.expiresAtEpochSeconds, | ||
| 136 | ...input.request, | ||
| 137 | }, | ||
| 138 | }); | ||
| 139 | }); | ||
| 140 | |||
| 141 | const verifyLinkChallenge: RelayTokensShape["verifyLinkChallenge"] = Effect.fn( | ||
| 142 | "relay.tokens.verify_link_challenge", | ||
| 143 | )((input) => | ||
| 144 | verifyRelayJwt({ | ||
| 145 | publicKey: config.cloudMintPublicKey, | ||
| 146 | token: input.token, | ||
| 147 | typ: LINK_CHALLENGE_TYP, | ||
| 148 | issuer, | ||
| 149 | audience: issuer, | ||
| 150 | nowEpochSeconds: input.nowEpochSeconds, | ||
| 151 | }).pipe( | ||
| 152 | Effect.flatMap(decodeLinkChallengeClaims), | ||
| 153 | Effect.map((claims) => { | ||
| 154 | if ( | ||
| 155 | claims.sub !== input.userId || | ||
| 156 | (input.request.notificationsEnabled && claims.notificationsEnabled !== true) || | ||
| 157 | (input.request.liveActivitiesEnabled && claims.liveActivitiesEnabled !== true) || | ||
| 158 | (input.request.managedTunnelsEnabled && claims.managedTunnelsEnabled !== true) | ||
| 159 | ) { | ||
| 160 | return null; | ||
| 161 | } | ||
| 162 | return claims; | ||
| 163 | }), | ||
| 164 | Effect.catch(() => Effect.succeed(null)), | ||
| 165 | ), | ||
| 166 | ); | ||
| 167 | |||
| 168 | const issueDpopAccessToken: RelayTokensShape["issueDpopAccessToken"] = Effect.fn( | ||
| 169 | "relay.tokens.issue_dpop_access_token", | ||
| 170 | )(function* (input) { | ||
| 171 | return yield* signRelayJwt({ | ||
| 172 | privateKey: Redacted.value(config.cloudMintPrivateKey), | ||
| 173 | typ: ACCESS_TOKEN_TYP, | ||
| 174 | payload: { | ||
| 175 | iss: issuer, | ||
| 176 | aud: issuer, | ||
| 177 | sub: input.userId, | ||
| 178 | jti: input.jti, | ||
| 179 | iat: input.issuedAtEpochSeconds, | ||
| 180 | exp: input.expiresAtEpochSeconds, | ||
| 181 | client_id: input.clientId, | ||
| 182 | scope: encodeOAuthScope(input.scopes), | ||
| 183 | cnf: { jkt: input.proofKeyThumbprint }, | ||
| 184 | }, | ||
| 185 | }); | ||
| 186 | }); | ||
| 187 | |||
| 188 | const verifyDpopAccessToken: RelayTokensShape["verifyDpopAccessToken"] = Effect.fn( | ||
| 189 | "relay.tokens.verify_dpop_access_token", | ||
| 190 | )((input) => | ||
| 191 | verifyRelayJwt({ | ||
| 192 | publicKey: config.cloudMintPublicKey, | ||
| 193 | token: input.token, | ||
| 194 | typ: ACCESS_TOKEN_TYP, | ||
| 195 | issuer, | ||
| 196 | audience: issuer, | ||
| 197 | nowEpochSeconds: input.nowEpochSeconds, | ||
| 198 | }).pipe( | ||
| 199 | Effect.flatMap(decodeDpopAccessTokenClaims), | ||
| 200 | Effect.map((claims): RelayDpopAccessTokenClaims | null => { | ||
| 201 | const scopes = resolveDpopAccessTokenScopes({ | ||
| 202 | clientId: claims.client_id, | ||
| 203 | scope: claims.scope, | ||
| 204 | }); | ||
| 205 | return scopes === null ? null : { ...claims, scope: scopes }; | ||
| 206 | }), | ||
| 207 | Effect.orElseSucceed(() => null), | ||
| 208 | ), | ||
| 209 | ); | ||
| 210 | |||
| 211 | return RelayTokens.of({ | ||
| 212 | resolveDpopAccessTokenScopes, | ||
| 213 | issueLinkChallenge, | ||
| 214 | verifyLinkChallenge, | ||
| 215 | issueDpopAccessToken, | ||
| 216 | verifyDpopAccessToken, | ||
| 217 | }); | ||
| 218 | }); | ||
| 219 | |||
| 220 | export const layer = Layer.effect(RelayTokens, make); | ||
infra/relay/src/db.ts created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | import type { PgClient } from "@effect/sql-pg/PgClient"; | ||
| 2 | import * as Cloudflare from "alchemy/Cloudflare"; | ||
| 3 | import * as Drizzle from "alchemy/Drizzle"; | ||
| 4 | import * as Planetscale from "alchemy/Planetscale"; | ||
| 5 | import * as Alchemy from "alchemy"; | ||
| 6 | import * as RemovalPolicy from "alchemy/RemovalPolicy"; | ||
| 7 | import type { EffectPgDatabase } from "drizzle-orm/effect-postgres"; | ||
| 8 | import * as Context from "effect/Context"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | |||
| 11 | import { relayDatabaseMode } from "./dbConfig.ts"; | ||
| 12 | |||
| 13 | export interface RelayDatabase extends EffectPgDatabase { | ||
| 14 | readonly $client: PgClient; | ||
| 15 | } | ||
| 16 | |||
| 17 | export class RelayDb extends Context.Service<RelayDb, RelayDatabase>()("t3code-relay/db/RelayDb") {} | ||
| 18 | |||
| 19 | export const PlanetscaleDatabase = Effect.gen(function* () { | ||
| 20 | const { stage } = yield* Alchemy.Stack; | ||
| 21 | const schema = yield* Drizzle.Schema("RelaySchema", { | ||
| 22 | schema: "./src/persistence/schema.ts", | ||
| 23 | out: "./migrations/postgres", | ||
| 24 | dialect: "postgres", | ||
| 25 | }); | ||
| 26 | |||
| 27 | const mode = relayDatabaseMode(stage); | ||
| 28 | const database = | ||
| 29 | mode === "shared-database" | ||
| 30 | ? yield* Planetscale.PostgresDatabase("RelayPostgresDatabase", { | ||
| 31 | name: "t3coderelay", | ||
| 32 | region: { slug: "us-west" }, | ||
| 33 | clusterSize: "PS_5", | ||
| 34 | migrationsDir: schema.out, | ||
| 35 | migrationsTable: "relay_migrations", | ||
| 36 | replicas: 0, // BUMP BEFORE GOING TO PROD | ||
| 37 | }).pipe(RemovalPolicy.retain()) | ||
| 38 | : yield* Planetscale.PostgresDatabase.ref("RelayPostgresDatabase", { | ||
| 39 | stage: "prod", | ||
| 40 | }); | ||
| 41 | const branch = | ||
| 42 | mode === "stage-branch" | ||
| 43 | ? yield* Planetscale.PostgresBranch("RelayPostgresBranch", { | ||
| 44 | database, | ||
| 45 | migrationsDir: schema.out, | ||
| 46 | migrationsTable: "relay_migrations", | ||
| 47 | }) | ||
| 48 | : undefined; | ||
| 49 | |||
| 50 | const runtimeRole = yield* Planetscale.PostgresRole("RelayPostgresRuntimeRole", { | ||
| 51 | database, | ||
| 52 | ...(branch ? { branch } : {}), | ||
| 53 | inheritedRoles: ["pg_read_all_data", "pg_write_all_data"], | ||
| 54 | }); | ||
| 55 | |||
| 56 | return { branch, database, runtimeRole }; | ||
| 57 | }); | ||
| 58 | |||
| 59 | export const RelayHyperdrive = Effect.gen(function* () { | ||
| 60 | const { runtimeRole } = yield* PlanetscaleDatabase; | ||
| 61 | return yield* Cloudflare.Hyperdrive("RelayHyperdrive", { | ||
| 62 | origin: runtimeRole.origin, | ||
| 63 | caching: { | ||
| 64 | disabled: true, | ||
| 65 | }, | ||
| 66 | originConnectionLimit: 5, | ||
| 67 | }); | ||
| 68 | }); | ||
infra/relay/src/dbConfig.test.ts created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { relayDatabaseMode } from "./dbConfig.ts"; | ||
| 4 | |||
| 5 | describe("relayDatabaseMode", () => { | ||
| 6 | it("uses the shared database only for production", () => { | ||
| 7 | expect(relayDatabaseMode("prod")).toBe("shared-database"); | ||
| 8 | expect(relayDatabaseMode("dev_julius")).toBe("stage-branch"); | ||
| 9 | expect(relayDatabaseMode("preview")).toBe("stage-branch"); | ||
| 10 | }); | ||
| 11 | }); | ||
infra/relay/src/dbConfig.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | export type RelayDatabaseMode = "shared-database" | "stage-branch"; | ||
| 2 | |||
| 3 | export function relayDatabaseMode(stage: string): RelayDatabaseMode { | ||
| 4 | return stage === "prod" ? "shared-database" : "stage-branch"; | ||
| 5 | } | ||
infra/relay/src/deploymentConfig.test.ts created+96| ... | @@ -0,0 +1,96 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | managedEndpointDigestInput, | ||
| 5 | managedEndpointForHostname, | ||
| 6 | managedEndpointHostname, | ||
| 7 | isManagedEndpointHostname, | ||
| 8 | managedEndpointTunnelName, | ||
| 9 | relayOwnsManagedEndpointZone, | ||
| 10 | relayPublicDomainForStage, | ||
| 11 | relayResourceNameForStage, | ||
| 12 | relayStageSlug, | ||
| 13 | } from "./deploymentConfig.ts"; | ||
| 14 | |||
| 15 | describe("relayStageSlug", () => { | ||
| 16 | it("matches Alchemy physical-name sanitization for default developer stages", () => { | ||
| 17 | expect(relayStageSlug("dev_julius")).toBe("dev-julius"); | ||
| 18 | }); | ||
| 19 | }); | ||
| 20 | |||
| 21 | describe("relayPublicDomainForStage", () => { | ||
| 22 | it("uses the canonical relay hostname for production", () => { | ||
| 23 | expect(relayPublicDomainForStage("prod", ".example.com.")).toBe("relay.example.com"); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("isolates personal stages below the imported zone", () => { | ||
| 27 | expect(relayPublicDomainForStage("dev_julius", "example.com")).toBe( | ||
| 28 | "relay-dev-julius.example.com", | ||
| 29 | ); | ||
| 30 | }); | ||
| 31 | }); | ||
| 32 | |||
| 33 | describe("relayOwnsManagedEndpointZone", () => { | ||
| 34 | it("keeps the shared Cloudflare zone owned by production", () => { | ||
| 35 | expect(relayOwnsManagedEndpointZone("prod")).toBe(true); | ||
| 36 | expect(relayOwnsManagedEndpointZone("dev_julius")).toBe(false); | ||
| 37 | }); | ||
| 38 | }); | ||
| 39 | |||
| 40 | describe("relayResourceNameForStage", () => { | ||
| 41 | it("isolates production and personal stages", () => { | ||
| 42 | expect(relayResourceNameForStage("t3-code-relay-traces", "prod")).toBe( | ||
| 43 | "t3-code-relay-traces-prod", | ||
| 44 | ); | ||
| 45 | expect(relayResourceNameForStage("t3-code-relay-traces", "dev_julius")).toBe( | ||
| 46 | "t3-code-relay-traces-dev-julius", | ||
| 47 | ); | ||
| 48 | }); | ||
| 49 | }); | ||
| 50 | |||
| 51 | describe("managed endpoint names", () => { | ||
| 52 | it("uses the stage slug and a stable stage-scoped digest suffix", () => { | ||
| 53 | const hash = "ABCDEF0123456789ABCDEF0123456789"; | ||
| 54 | |||
| 55 | expect(managedEndpointDigestInput("dev_julius", "user_123", "env_123")).toBe( | ||
| 56 | "dev_julius:user_123:env_123", | ||
| 57 | ); | ||
| 58 | expect(managedEndpointHostname("dev_julius", ".example.com.", hash)).toBe( | ||
| 59 | "dev-julius-abcdef0123456789.example.com", | ||
| 60 | ); | ||
| 61 | expect(managedEndpointHostname("prod", "t3coderelay.com", hash)).toBe( | ||
| 62 | "prod-abcdef0123456789.t3coderelay.com", | ||
| 63 | ); | ||
| 64 | expect(managedEndpointTunnelName("dev_julius", hash)).toBe( | ||
| 65 | "t3coderelay-managedendpoint-dev-julius-abcdef0123456789", | ||
| 66 | ); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("keeps the DNS label within the provider limit for long stage names", () => { | ||
| 70 | const hostname = managedEndpointHostname( | ||
| 71 | "dev_" + "x".repeat(100), | ||
| 72 | "example.com", | ||
| 73 | "a".repeat(64), | ||
| 74 | ); | ||
| 75 | |||
| 76 | expect(hostname.split(".")[0]?.length).toBeLessThanOrEqual(63); | ||
| 77 | expect(hostname).toMatch(/-a{16}\.example\.com$/); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("accepts allocated hostnames within the relay zone", () => { | ||
| 81 | expect( | ||
| 82 | isManagedEndpointHostname("dev-julius-abcdef0123456789.example.com", "example.com"), | ||
| 83 | ).toBe(true); | ||
| 84 | expect(managedEndpointForHostname("dev-julius-abcdef0123456789.example.com")).toEqual({ | ||
| 85 | httpBaseUrl: "https://dev-julius-abcdef0123456789.example.com/", | ||
| 86 | wsBaseUrl: "wss://dev-julius-abcdef0123456789.example.com/ws", | ||
| 87 | providerKind: "cloudflare_tunnel", | ||
| 88 | }); | ||
| 89 | }); | ||
| 90 | |||
| 91 | it("rejects hostnames outside the relay zone", () => { | ||
| 92 | expect(isManagedEndpointHostname("internal.example.net", "example.com")).toBe(false); | ||
| 93 | expect(isManagedEndpointHostname("example.com.attacker.test", "example.com")).toBe(false); | ||
| 94 | expect(isManagedEndpointHostname("dev-julius.example.com.", "example.com")).toBe(false); | ||
| 95 | }); | ||
| 96 | }); | ||
infra/relay/src/deploymentConfig.ts created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; | ||
| 2 | |||
| 3 | const DNS_LABEL_MAX_LENGTH = 63; | ||
| 4 | const MANAGED_ENDPOINT_HASH_LENGTH = 16; | ||
| 5 | const MANAGED_ENDPOINT_TUNNEL_PREFIX = "t3coderelay-managedendpoint"; | ||
| 6 | export const MANAGED_ENDPOINT_ZONE_OWNER_STAGE = "prod"; | ||
| 7 | |||
| 8 | function normalizeZoneName(zoneName: string): string { | ||
| 9 | return zoneName | ||
| 10 | .trim() | ||
| 11 | .toLowerCase() | ||
| 12 | .replace(/^\.+|\.+$/g, ""); | ||
| 13 | } | ||
| 14 | |||
| 15 | function isDnsName(name: string): boolean { | ||
| 16 | return ( | ||
| 17 | name.length > 0 && | ||
| 18 | name.length <= 253 && | ||
| 19 | name | ||
| 20 | .split(".") | ||
| 21 | .every( | ||
| 22 | (label) => | ||
| 23 | label.length > 0 && | ||
| 24 | label.length <= DNS_LABEL_MAX_LENGTH && | ||
| 25 | /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label), | ||
| 26 | ) | ||
| 27 | ); | ||
| 28 | } | ||
| 29 | |||
| 30 | function stableSuffix(hash: string): string { | ||
| 31 | return hash.toLowerCase().slice(0, MANAGED_ENDPOINT_HASH_LENGTH); | ||
| 32 | } | ||
| 33 | |||
| 34 | function appendDnsSafeSuffix(prefix: string, suffix: string): string { | ||
| 35 | const truncatedPrefix = prefix | ||
| 36 | .slice(0, DNS_LABEL_MAX_LENGTH - suffix.length - 1) | ||
| 37 | .replace(/-+$/g, ""); | ||
| 38 | return `${truncatedPrefix}-${suffix}`; | ||
| 39 | } | ||
| 40 | |||
| 41 | /** | ||
| 42 | * Alchemy's physical-name helper sanitizes resource names after adding the | ||
| 43 | * stage. Keep custom domains and runtime-created resources aligned with it. | ||
| 44 | */ | ||
| 45 | export function relayStageSlug(stage: string): string { | ||
| 46 | return stage | ||
| 47 | .toLowerCase() | ||
| 48 | .replaceAll(/[^a-z0-9-]/g, "-") | ||
| 49 | .replaceAll(/-+/g, "-") | ||
| 50 | .replace(/^-+|-+$/g, ""); | ||
| 51 | } | ||
| 52 | |||
| 53 | export function relayResourceNameForStage(name: string, stage: string): string { | ||
| 54 | return `${name}-${relayStageSlug(stage)}`; | ||
| 55 | } | ||
| 56 | |||
| 57 | export function relayOwnsManagedEndpointZone(stage: string): boolean { | ||
| 58 | return stage === MANAGED_ENDPOINT_ZONE_OWNER_STAGE; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function relayPublicDomainForStage(stage: string, zoneName: string): string { | ||
| 62 | const stageSlug = relayStageSlug(stage); | ||
| 63 | const relayLabel = stage === "prod" ? "relay" : `relay-${stageSlug}`; | ||
| 64 | if (relayLabel.length > DNS_LABEL_MAX_LENGTH) { | ||
| 65 | throw new Error(`Relay stage is too long for a custom domain: ${stage}`); | ||
| 66 | } | ||
| 67 | return `${relayLabel}.${normalizeZoneName(zoneName)}`; | ||
| 68 | } | ||
| 69 | |||
| 70 | export function managedEndpointDigestInput( | ||
| 71 | stage: string, | ||
| 72 | userId: string, | ||
| 73 | environmentId: string, | ||
| 74 | ): string { | ||
| 75 | return `${stage}:${userId}:${environmentId}`; | ||
| 76 | } | ||
| 77 | |||
| 78 | export function managedEndpointHostname(stage: string, baseDomain: string, hash: string): string { | ||
| 79 | const label = appendDnsSafeSuffix(relayStageSlug(stage), stableSuffix(hash)); | ||
| 80 | return `${label}.${normalizeZoneName(baseDomain)}`; | ||
| 81 | } | ||
| 82 | |||
| 83 | export function isManagedEndpointHostname(hostname: string, baseDomain: string): boolean { | ||
| 84 | const normalizedHostname = normalizeZoneName(hostname); | ||
| 85 | const normalizedBaseDomain = normalizeZoneName(baseDomain); | ||
| 86 | return ( | ||
| 87 | hostname === normalizedHostname && | ||
| 88 | isDnsName(normalizedHostname) && | ||
| 89 | isDnsName(normalizedBaseDomain) && | ||
| 90 | normalizedHostname.endsWith(`.${normalizedBaseDomain}`) | ||
| 91 | ); | ||
| 92 | } | ||
| 93 | |||
| 94 | export function managedEndpointForHostname(hostname: string): RelayManagedEndpoint { | ||
| 95 | return { | ||
| 96 | httpBaseUrl: `https://${hostname}/`, | ||
| 97 | wsBaseUrl: `wss://${hostname}/ws`, | ||
| 98 | providerKind: "cloudflare_tunnel", | ||
| 99 | }; | ||
| 100 | } | ||
| 101 | |||
| 102 | export function managedEndpointTunnelName(stage: string, hash: string): string { | ||
| 103 | return `${MANAGED_ENDPOINT_TUNNEL_PREFIX}-${relayStageSlug(stage)}-${stableSuffix(hash)}`; | ||
| 104 | } | ||
infra/relay/src/environments/EnvironmentConnector.test.ts created+720| ... | @@ -0,0 +1,720 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | RelayCloudEnvironmentHealthRequest, | ||
| 6 | RelayCloudMintCredentialRequest, | ||
| 7 | RelayCloudEnvironmentHealthProofPayload, | ||
| 8 | RelayCloudMintCredentialProofPayload, | ||
| 9 | RelayEnvironmentHealthResponse, | ||
| 10 | RelayEnvironmentHealthResponseProofPayload, | ||
| 11 | RelayEnvironmentMintResponse, | ||
| 12 | RelayEnvironmentMintResponseProofPayload, | ||
| 13 | } from "@t3tools/contracts/relay"; | ||
| 14 | import { describe, expect, it } from "@effect/vitest"; | ||
| 15 | import * as DateTime from "effect/DateTime"; | ||
| 16 | import { RELAY_HEALTH_RESPONSE_TYP, RELAY_MINT_RESPONSE_TYP } from "@t3tools/shared/relayJwt"; | ||
| 17 | import * as Duration from "effect/Duration"; | ||
| 18 | import * as Effect from "effect/Effect"; | ||
| 19 | import * as Fiber from "effect/Fiber"; | ||
| 20 | import * as Layer from "effect/Layer"; | ||
| 21 | import * as Redacted from "effect/Redacted"; | ||
| 22 | import * as Result from "effect/Result"; | ||
| 23 | import * as Schema from "effect/Schema"; | ||
| 24 | import * as TestClock from "effect/testing/TestClock"; | ||
| 25 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 26 | |||
| 27 | import * as EnvironmentLinks from "./EnvironmentLinks.ts"; | ||
| 28 | import * as RelayConfiguration from "../Config.ts"; | ||
| 29 | import * as EnvironmentConnector from "./EnvironmentConnector.ts"; | ||
| 30 | import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; | ||
| 31 | |||
| 32 | const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 33 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 34 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 35 | }); | ||
| 36 | |||
| 37 | const environmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 38 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 39 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 40 | }); | ||
| 41 | |||
| 42 | const otherEnvironmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 43 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 44 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 45 | }); | ||
| 46 | |||
| 47 | const decodeHealthRequestBody = Schema.decodeUnknownSync( | ||
| 48 | Schema.fromJsonString(RelayCloudEnvironmentHealthRequest), | ||
| 49 | ); | ||
| 50 | const decodeMintRequestBody = Schema.decodeUnknownSync( | ||
| 51 | Schema.fromJsonString(RelayCloudMintCredentialRequest), | ||
| 52 | ); | ||
| 53 | |||
| 54 | function requestBodyText(request: HttpClientRequest.HttpClientRequest): string { | ||
| 55 | return request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}"; | ||
| 56 | } | ||
| 57 | |||
| 58 | const settings = RelayConfiguration.RelayConfiguration.of({ | ||
| 59 | relayIssuer: "https://relay.example.test", | ||
| 60 | apns: { | ||
| 61 | environment: "sandbox", | ||
| 62 | teamId: "team-id", | ||
| 63 | keyId: "key-id", | ||
| 64 | privateKey: Redacted.make("private-key"), | ||
| 65 | bundleId: "com.t3tools.t3code.dev", | ||
| 66 | }, | ||
| 67 | apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), | ||
| 68 | clerkSecretKey: Redacted.make("clerk-secret"), | ||
| 69 | clerkPublishableKey: "pk_test_test", | ||
| 70 | clerkJwtAudience: "t3-code-relay", | ||
| 71 | cloudMintPrivateKey: Redacted.make(cloudKeyPair.privateKey), | ||
| 72 | cloudMintPublicKey: cloudKeyPair.publicKey, | ||
| 73 | managedEndpointBaseDomain: "example.test", | ||
| 74 | managedEndpointNamespace: undefined, | ||
| 75 | }); | ||
| 76 | |||
| 77 | function signTestJwt(payload: object, typ: string, privateKey: string): string { | ||
| 78 | const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url"); | ||
| 79 | const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); | ||
| 80 | const input = `${header}.${encodedPayload}`; | ||
| 81 | return `${input}.${NodeCrypto.sign(null, Buffer.from(input), privateKey).toString("base64url")}`; | ||
| 82 | } | ||
| 83 | |||
| 84 | function decodeRequestProof<T>(proof: string): T { | ||
| 85 | const payload = proof.split(".")[1]; | ||
| 86 | if (!payload) throw new Error("Missing JWT payload."); | ||
| 87 | return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as T; | ||
| 88 | } | ||
| 89 | |||
| 90 | function signMintResponse( | ||
| 91 | request: RelayCloudMintCredentialRequest, | ||
| 92 | overrides: Partial<RelayEnvironmentMintResponseProofPayload> = {}, | ||
| 93 | privateKey = environmentKeyPair.privateKey, | ||
| 94 | ): RelayEnvironmentMintResponse { | ||
| 95 | const requestProof = decodeRequestProof<RelayCloudMintCredentialProofPayload>(request.proof); | ||
| 96 | const payload = { | ||
| 97 | iss: `t3-env:${requestProof.environmentId}`, | ||
| 98 | aud: "https://relay.example.test", | ||
| 99 | sub: requestProof.environmentId, | ||
| 100 | jti: "mint-response-jti", | ||
| 101 | iat: requestProof.iat, | ||
| 102 | exp: requestProof.exp, | ||
| 103 | environmentId: requestProof.environmentId, | ||
| 104 | clientProofKeyThumbprint: requestProof.clientProofKeyThumbprint, | ||
| 105 | requestNonce: requestProof.nonce, | ||
| 106 | credential: "pairing_credential", | ||
| 107 | ...overrides, | ||
| 108 | } satisfies RelayEnvironmentMintResponseProofPayload; | ||
| 109 | return { | ||
| 110 | credential: payload.credential, | ||
| 111 | expiresAt: DateTime.formatIso(DateTime.makeUnsafe(payload.exp * 1_000)), | ||
| 112 | proof: signTestJwt(payload, RELAY_MINT_RESPONSE_TYP, privateKey), | ||
| 113 | }; | ||
| 114 | } | ||
| 115 | |||
| 116 | function signHealthResponse( | ||
| 117 | request: RelayCloudEnvironmentHealthRequest, | ||
| 118 | privateKey = environmentKeyPair.privateKey, | ||
| 119 | overrides: Partial<RelayEnvironmentHealthResponse> = {}, | ||
| 120 | payloadOverrides: Partial<RelayEnvironmentHealthResponseProofPayload> = {}, | ||
| 121 | ): RelayEnvironmentHealthResponse { | ||
| 122 | const requestProof = decodeRequestProof<RelayCloudEnvironmentHealthProofPayload>(request.proof); | ||
| 123 | const payload = { | ||
| 124 | iss: `t3-env:${requestProof.environmentId}`, | ||
| 125 | aud: "https://relay.example.test", | ||
| 126 | sub: requestProof.environmentId, | ||
| 127 | jti: "health-response-jti", | ||
| 128 | iat: requestProof.iat, | ||
| 129 | exp: requestProof.exp, | ||
| 130 | environmentId: requestProof.environmentId, | ||
| 131 | requestNonce: requestProof.nonce, | ||
| 132 | status: "online", | ||
| 133 | descriptor: { | ||
| 134 | environmentId: requestProof.environmentId, | ||
| 135 | label: "Connector Test Environment", | ||
| 136 | platform: { os: "darwin", arch: "arm64" }, | ||
| 137 | serverVersion: "0.0.0-test", | ||
| 138 | capabilities: { repositoryIdentity: true }, | ||
| 139 | }, | ||
| 140 | checkedAt: DateTime.formatIso(DateTime.makeUnsafe(requestProof.iat * 1_000)), | ||
| 141 | ...payloadOverrides, | ||
| 142 | } satisfies RelayEnvironmentHealthResponseProofPayload; | ||
| 143 | return { | ||
| 144 | environmentId: payload.environmentId, | ||
| 145 | status: "online", | ||
| 146 | descriptor: payload.descriptor, | ||
| 147 | checkedAt: payload.checkedAt, | ||
| 148 | proof: signTestJwt(payload, RELAY_HEALTH_RESPONSE_TYP, privateKey), | ||
| 149 | ...overrides, | ||
| 150 | }; | ||
| 151 | } | ||
| 152 | |||
| 153 | function connectorTestLayer( | ||
| 154 | execute: ( | ||
| 155 | request: HttpClientRequest.HttpClientRequest, | ||
| 156 | ) => Effect.Effect<HttpClientResponse.HttpClientResponse>, | ||
| 157 | options?: { | ||
| 158 | readonly links?: EnvironmentLinks.EnvironmentLinksShape; | ||
| 159 | readonly allocations?: ManagedEndpointAllocations.ManagedEndpointAllocationsShape; | ||
| 160 | }, | ||
| 161 | ) { | ||
| 162 | return EnvironmentConnector.layer.pipe( | ||
| 163 | Layer.provide(NodeCryptoLayer.layer), | ||
| 164 | Layer.provide(Layer.succeed(EnvironmentLinks.EnvironmentLinks, options?.links ?? makeLinks())), | ||
| 165 | Layer.provide( | ||
| 166 | Layer.succeed( | ||
| 167 | ManagedEndpointAllocations.ManagedEndpointAllocations, | ||
| 168 | options?.allocations ?? makeAllocations(), | ||
| 169 | ), | ||
| 170 | ), | ||
| 171 | Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, settings)), | ||
| 172 | Layer.provide(Layer.succeed(HttpClient.HttpClient, HttpClient.make(execute))), | ||
| 173 | ); | ||
| 174 | } | ||
| 175 | |||
| 176 | function makeAllocations( | ||
| 177 | allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null = { | ||
| 178 | userId: "user_123", | ||
| 179 | environmentId: "env-connector-test", | ||
| 180 | hostname: "env.example.test", | ||
| 181 | tunnelId: "tunnel-id", | ||
| 182 | tunnelName: "tunnel-name", | ||
| 183 | dnsRecordId: "dns-record-id", | ||
| 184 | readyAt: "2026-05-25T00:00:00.000Z", | ||
| 185 | }, | ||
| 186 | ): ManagedEndpointAllocations.ManagedEndpointAllocationsShape { | ||
| 187 | return { | ||
| 188 | get: () => Effect.succeed(allocation), | ||
| 189 | reserve: () => Effect.die("unused"), | ||
| 190 | recordTunnel: () => Effect.die("unused"), | ||
| 191 | recordDns: () => Effect.die("unused"), | ||
| 192 | markReady: () => Effect.die("unused"), | ||
| 193 | remove: () => Effect.die("unused"), | ||
| 194 | }; | ||
| 195 | } | ||
| 196 | |||
| 197 | function makeLinks( | ||
| 198 | overrides: Partial<EnvironmentLinks.RelayLinkedEnvironmentRecord> = {}, | ||
| 199 | ): EnvironmentLinks.EnvironmentLinksShape { | ||
| 200 | return { | ||
| 201 | upsert: () => Effect.void, | ||
| 202 | listUsersForEnvironment: () => Effect.succeed([]), | ||
| 203 | listDeliveryUsersForEnvironment: () => Effect.succeed([]), | ||
| 204 | listPublicKeysForEnvironment: () => Effect.succeed([environmentKeyPair.publicKey]), | ||
| 205 | listForUser: () => Effect.succeed([]), | ||
| 206 | getForUser: () => | ||
| 207 | Effect.succeed({ | ||
| 208 | environmentId: "env-connector-test" as never, | ||
| 209 | label: "Connector Test Environment", | ||
| 210 | endpoint: { | ||
| 211 | httpBaseUrl: "https://env.example.test/", | ||
| 212 | wsBaseUrl: "wss://env.example.test/ws", | ||
| 213 | providerKind: "cloudflare_tunnel", | ||
| 214 | }, | ||
| 215 | linkedAt: "2026-05-25T00:00:00.000Z", | ||
| 216 | environmentPublicKey: environmentKeyPair.publicKey, | ||
| 217 | ...overrides, | ||
| 218 | }), | ||
| 219 | revokeForUser: () => Effect.succeed(false), | ||
| 220 | }; | ||
| 221 | } | ||
| 222 | |||
| 223 | describe("EnvironmentConnector", () => { | ||
| 224 | it.effect("checks linked environment health through the managed endpoint", () => { | ||
| 225 | const seenUrls: Array<string> = []; | ||
| 226 | const seenProofs: Array<RelayCloudEnvironmentHealthProofPayload> = []; | ||
| 227 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 228 | Effect.sync(() => { | ||
| 229 | const healthRequest = decodeHealthRequestBody(requestBodyText(request)); | ||
| 230 | seenUrls.push(request.url); | ||
| 231 | seenProofs.push(decodeRequestProof(healthRequest.proof)); | ||
| 232 | return HttpClientResponse.fromWeb( | ||
| 233 | request, | ||
| 234 | Response.json(signHealthResponse(healthRequest), { status: 200 }), | ||
| 235 | ); | ||
| 236 | }); | ||
| 237 | |||
| 238 | return Effect.gen(function* () { | ||
| 239 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 240 | const result = yield* connector.status({ | ||
| 241 | userId: "user_123", | ||
| 242 | environmentId: "env-connector-test", | ||
| 243 | }); | ||
| 244 | |||
| 245 | expect(seenUrls).toEqual(["https://env.example.test/api/t3-cloud/health"]); | ||
| 246 | expect(seenProofs[0]).toMatchObject({ | ||
| 247 | iss: "https://relay.example.test", | ||
| 248 | aud: "t3-env:env-connector-test", | ||
| 249 | sub: "user_123", | ||
| 250 | environmentId: "env-connector-test", | ||
| 251 | scope: ["environment:status"], | ||
| 252 | }); | ||
| 253 | expect(result).toMatchObject({ | ||
| 254 | environmentId: "env-connector-test", | ||
| 255 | status: "online", | ||
| 256 | descriptor: { | ||
| 257 | environmentId: "env-connector-test", | ||
| 258 | label: "Connector Test Environment", | ||
| 259 | }, | ||
| 260 | }); | ||
| 261 | }).pipe(Effect.provide(connectorTestLayer(execute))); | ||
| 262 | }); | ||
| 263 | |||
| 264 | it.effect("rejects manual endpoints before sending a health request", () => { | ||
| 265 | let requestCount = 0; | ||
| 266 | const execute = () => | ||
| 267 | Effect.sync(() => { | ||
| 268 | requestCount += 1; | ||
| 269 | throw new Error("unexpected request"); | ||
| 270 | }); | ||
| 271 | |||
| 272 | return Effect.gen(function* () { | ||
| 273 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 274 | const result = yield* Effect.result( | ||
| 275 | connector.status({ | ||
| 276 | userId: "user_123", | ||
| 277 | environmentId: "env-connector-test", | ||
| 278 | }), | ||
| 279 | ); | ||
| 280 | |||
| 281 | expect(Result.isFailure(result)).toBe(true); | ||
| 282 | if (Result.isFailure(result)) { | ||
| 283 | expect(result.failure).toBeInstanceOf(EnvironmentConnector.EnvironmentConnectNotAuthorized); | ||
| 284 | } | ||
| 285 | expect(requestCount).toBe(0); | ||
| 286 | }).pipe( | ||
| 287 | Effect.provide( | ||
| 288 | connectorTestLayer(execute, { | ||
| 289 | links: makeLinks({ | ||
| 290 | endpoint: { | ||
| 291 | httpBaseUrl: "https://127.0.0.1/", | ||
| 292 | wsBaseUrl: "wss://127.0.0.1/ws", | ||
| 293 | providerKind: "manual", | ||
| 294 | }, | ||
| 295 | }), | ||
| 296 | }), | ||
| 297 | ), | ||
| 298 | ); | ||
| 299 | }); | ||
| 300 | |||
| 301 | it.effect("rejects stale managed endpoints before sending a mint request", () => { | ||
| 302 | let requestCount = 0; | ||
| 303 | const execute = () => | ||
| 304 | Effect.sync(() => { | ||
| 305 | requestCount += 1; | ||
| 306 | throw new Error("unexpected request"); | ||
| 307 | }); | ||
| 308 | |||
| 309 | return Effect.gen(function* () { | ||
| 310 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 311 | const result = yield* Effect.result( | ||
| 312 | connector.connect({ | ||
| 313 | userId: "user_123", | ||
| 314 | environmentId: "env-connector-test", | ||
| 315 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 316 | }), | ||
| 317 | ); | ||
| 318 | |||
| 319 | expect(Result.isFailure(result)).toBe(true); | ||
| 320 | if (Result.isFailure(result)) { | ||
| 321 | expect(result.failure).toBeInstanceOf(EnvironmentConnector.EnvironmentConnectNotAuthorized); | ||
| 322 | } | ||
| 323 | expect(requestCount).toBe(0); | ||
| 324 | }).pipe( | ||
| 325 | Effect.provide( | ||
| 326 | connectorTestLayer(execute, { | ||
| 327 | links: makeLinks({ | ||
| 328 | endpoint: { | ||
| 329 | httpBaseUrl: "https://attacker.example.test/", | ||
| 330 | wsBaseUrl: "wss://attacker.example.test/ws", | ||
| 331 | providerKind: "cloudflare_tunnel", | ||
| 332 | }, | ||
| 333 | }), | ||
| 334 | }), | ||
| 335 | ), | ||
| 336 | ); | ||
| 337 | }); | ||
| 338 | |||
| 339 | it.effect("rejects unready managed endpoint allocations before sending a request", () => { | ||
| 340 | let requestCount = 0; | ||
| 341 | const execute = () => | ||
| 342 | Effect.sync(() => { | ||
| 343 | requestCount += 1; | ||
| 344 | throw new Error("unexpected request"); | ||
| 345 | }); | ||
| 346 | |||
| 347 | return Effect.gen(function* () { | ||
| 348 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 349 | const result = yield* Effect.result( | ||
| 350 | connector.status({ | ||
| 351 | userId: "user_123", | ||
| 352 | environmentId: "env-connector-test", | ||
| 353 | }), | ||
| 354 | ); | ||
| 355 | |||
| 356 | expect(Result.isFailure(result)).toBe(true); | ||
| 357 | if (Result.isFailure(result)) { | ||
| 358 | expect(result.failure).toBeInstanceOf(EnvironmentConnector.EnvironmentConnectNotAuthorized); | ||
| 359 | } | ||
| 360 | expect(requestCount).toBe(0); | ||
| 361 | }).pipe( | ||
| 362 | Effect.provide( | ||
| 363 | connectorTestLayer(execute, { | ||
| 364 | allocations: makeAllocations({ | ||
| 365 | userId: "user_123", | ||
| 366 | environmentId: "env-connector-test", | ||
| 367 | hostname: "env.example.test", | ||
| 368 | tunnelId: "tunnel-id", | ||
| 369 | tunnelName: "tunnel-name", | ||
| 370 | dnsRecordId: "dns-record-id", | ||
| 371 | readyAt: null, | ||
| 372 | }), | ||
| 373 | }), | ||
| 374 | ), | ||
| 375 | ); | ||
| 376 | }); | ||
| 377 | |||
| 378 | it.effect("rejects signed health responses with stale checkedAt timestamps", () => { | ||
| 379 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 380 | Effect.sync(() => { | ||
| 381 | const healthRequest = decodeHealthRequestBody(requestBodyText(request)); | ||
| 382 | return HttpClientResponse.fromWeb( | ||
| 383 | request, | ||
| 384 | Response.json( | ||
| 385 | signHealthResponse( | ||
| 386 | healthRequest, | ||
| 387 | environmentKeyPair.privateKey, | ||
| 388 | {}, | ||
| 389 | { | ||
| 390 | checkedAt: "2026-05-24T00:00:00.000Z", | ||
| 391 | }, | ||
| 392 | ), | ||
| 393 | { status: 200 }, | ||
| 394 | ), | ||
| 395 | ); | ||
| 396 | }); | ||
| 397 | |||
| 398 | return Effect.gen(function* () { | ||
| 399 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 400 | const result = yield* Effect.exit( | ||
| 401 | connector.status({ | ||
| 402 | userId: "user_123", | ||
| 403 | environmentId: "env-connector-test", | ||
| 404 | }), | ||
| 405 | ); | ||
| 406 | |||
| 407 | expect(result._tag).toBe("Failure"); | ||
| 408 | if (result._tag === "Failure") { | ||
| 409 | expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); | ||
| 410 | } | ||
| 411 | }).pipe(Effect.provide(connectorTestLayer(execute))); | ||
| 412 | }); | ||
| 413 | |||
| 414 | it.effect("reports offline status when the managed endpoint health request fails", () => { | ||
| 415 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 416 | Effect.succeed( | ||
| 417 | HttpClientResponse.fromWeb( | ||
| 418 | request, | ||
| 419 | Response.json( | ||
| 420 | { | ||
| 421 | _tag: "EnvironmentHttpInternalServerError", | ||
| 422 | message: "Environment is unavailable.", | ||
| 423 | }, | ||
| 424 | { status: 500 }, | ||
| 425 | ), | ||
| 426 | ), | ||
| 427 | ); | ||
| 428 | |||
| 429 | return Effect.gen(function* () { | ||
| 430 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 431 | const result = yield* connector.status({ | ||
| 432 | userId: "user_123", | ||
| 433 | environmentId: "env-connector-test", | ||
| 434 | }); | ||
| 435 | |||
| 436 | expect(result).toMatchObject({ | ||
| 437 | environmentId: "env-connector-test", | ||
| 438 | status: "offline", | ||
| 439 | error: "Managed endpoint health request failed: Environment is unavailable.", | ||
| 440 | }); | ||
| 441 | }).pipe(Effect.provide(connectorTestLayer(execute))); | ||
| 442 | }); | ||
| 443 | |||
| 444 | it.effect("rejects health responses with a mismatched top-level environment id", () => { | ||
| 445 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 446 | Effect.sync(() => { | ||
| 447 | const healthRequest = decodeHealthRequestBody(requestBodyText(request)); | ||
| 448 | return HttpClientResponse.fromWeb( | ||
| 449 | request, | ||
| 450 | Response.json( | ||
| 451 | signHealthResponse(healthRequest, environmentKeyPair.privateKey, { | ||
| 452 | environmentId: "other-env" as RelayEnvironmentHealthResponse["environmentId"], | ||
| 453 | }), | ||
| 454 | { status: 200 }, | ||
| 455 | ), | ||
| 456 | ); | ||
| 457 | }); | ||
| 458 | |||
| 459 | return Effect.gen(function* () { | ||
| 460 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 461 | const result = yield* Effect.exit( | ||
| 462 | connector.status({ | ||
| 463 | userId: "user_123", | ||
| 464 | environmentId: "env-connector-test", | ||
| 465 | }), | ||
| 466 | ); | ||
| 467 | |||
| 468 | expect(result._tag).toBe("Failure"); | ||
| 469 | if (result._tag === "Failure") { | ||
| 470 | expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); | ||
| 471 | } | ||
| 472 | }).pipe(Effect.provide(connectorTestLayer(execute))); | ||
| 473 | }); | ||
| 474 | |||
| 475 | it.effect("rejects health responses with an unsigned top-level descriptor mutation", () => { | ||
| 476 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 477 | Effect.sync(() => { | ||
| 478 | const healthRequest = decodeHealthRequestBody(requestBodyText(request)); | ||
| 479 | const response = signHealthResponse(healthRequest); | ||
| 480 | return HttpClientResponse.fromWeb( | ||
| 481 | request, | ||
| 482 | Response.json( | ||
| 483 | { | ||
| 484 | ...response, | ||
| 485 | descriptor: { | ||
| 486 | ...response.descriptor, | ||
| 487 | label: "Tampered Environment Label", | ||
| 488 | }, | ||
| 489 | } satisfies RelayEnvironmentHealthResponse, | ||
| 490 | { status: 200 }, | ||
| 491 | ), | ||
| 492 | ); | ||
| 493 | }); | ||
| 494 | |||
| 495 | return Effect.gen(function* () { | ||
| 496 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 497 | const result = yield* Effect.exit( | ||
| 498 | connector.status({ | ||
| 499 | userId: "user_123", | ||
| 500 | environmentId: "env-connector-test", | ||
| 501 | }), | ||
| 502 | ); | ||
| 503 | |||
| 504 | expect(result._tag).toBe("Failure"); | ||
| 505 | if (result._tag === "Failure") { | ||
| 506 | expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); | ||
| 507 | } | ||
| 508 | }).pipe(Effect.provide(connectorTestLayer(execute))); | ||
| 509 | }); | ||
| 510 | |||
| 511 | it.effect("rejects health responses when the linked environment public key is malformed", () => { | ||
| 512 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 513 | Effect.sync(() => { | ||
| 514 | const healthRequest = decodeHealthRequestBody(requestBodyText(request)); | ||
| 515 | return HttpClientResponse.fromWeb( | ||
| 516 | request, | ||
| 517 | Response.json(signHealthResponse(healthRequest), { status: 200 }), | ||
| 518 | ); | ||
| 519 | }); | ||
| 520 | |||
| 521 | return Effect.gen(function* () { | ||
| 522 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 523 | const result = yield* Effect.exit( | ||
| 524 | connector.status({ | ||
| 525 | userId: "user_123", | ||
| 526 | environmentId: "env-connector-test", | ||
| 527 | }), | ||
| 528 | ); | ||
| 529 | |||
| 530 | expect(result._tag).toBe("Failure"); | ||
| 531 | if (result._tag === "Failure") { | ||
| 532 | expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); | ||
| 533 | } | ||
| 534 | }).pipe( | ||
| 535 | Effect.provide( | ||
| 536 | connectorTestLayer(execute, { | ||
| 537 | links: makeLinks({ | ||
| 538 | environmentPublicKey: "not a pem public key", | ||
| 539 | }), | ||
| 540 | }), | ||
| 541 | ), | ||
| 542 | ); | ||
| 543 | }); | ||
| 544 | |||
| 545 | it.effect("mints a one-time environment credential through the linked endpoint", () => { | ||
| 546 | const seenUrls: Array<string> = []; | ||
| 547 | const seenProofs: Array<RelayCloudMintCredentialProofPayload> = []; | ||
| 548 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 549 | Effect.sync(() => { | ||
| 550 | const mintRequest = decodeMintRequestBody(requestBodyText(request)); | ||
| 551 | seenUrls.push(request.url); | ||
| 552 | seenProofs.push(decodeRequestProof(mintRequest.proof)); | ||
| 553 | return HttpClientResponse.fromWeb( | ||
| 554 | request, | ||
| 555 | Response.json(signMintResponse(mintRequest), { status: 200 }), | ||
| 556 | ); | ||
| 557 | }); | ||
| 558 | |||
| 559 | return Effect.gen(function* () { | ||
| 560 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 561 | const result = yield* connector.connect({ | ||
| 562 | userId: "user_123", | ||
| 563 | environmentId: "env-connector-test", | ||
| 564 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 565 | deviceId: "device-123", | ||
| 566 | }); | ||
| 567 | |||
| 568 | expect(seenUrls).toEqual(["https://env.example.test/api/t3-cloud/mint-credential"]); | ||
| 569 | expect(seenProofs[0]).toMatchObject({ | ||
| 570 | iss: "https://relay.example.test", | ||
| 571 | aud: "t3-env:env-connector-test", | ||
| 572 | sub: "user_123", | ||
| 573 | environmentId: "env-connector-test", | ||
| 574 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 575 | cnf: { jkt: "client-proof-key-thumbprint" }, | ||
| 576 | deviceId: "device-123", | ||
| 577 | scope: ["environment:connect"], | ||
| 578 | }); | ||
| 579 | expect(result).toMatchObject({ | ||
| 580 | environmentId: "env-connector-test", | ||
| 581 | credential: "pairing_credential", | ||
| 582 | endpoint: { | ||
| 583 | httpBaseUrl: "https://env.example.test/", | ||
| 584 | wsBaseUrl: "wss://env.example.test/ws", | ||
| 585 | }, | ||
| 586 | }); | ||
| 587 | }).pipe(Effect.provide(connectorTestLayer(execute))); | ||
| 588 | }); | ||
| 589 | |||
| 590 | it.effect("only accepts mint responses signed by the user's linked environment key", () => { | ||
| 591 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 592 | Effect.sync(() => { | ||
| 593 | const mintRequest = decodeMintRequestBody(requestBodyText(request)); | ||
| 594 | return HttpClientResponse.fromWeb( | ||
| 595 | request, | ||
| 596 | Response.json(signMintResponse(mintRequest, {}, otherEnvironmentKeyPair.privateKey), { | ||
| 597 | status: 200, | ||
| 598 | }), | ||
| 599 | ); | ||
| 600 | }); | ||
| 601 | |||
| 602 | return Effect.gen(function* () { | ||
| 603 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 604 | const result = yield* Effect.exit( | ||
| 605 | connector.connect({ | ||
| 606 | userId: "user_123", | ||
| 607 | environmentId: "env-connector-test", | ||
| 608 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 609 | }), | ||
| 610 | ); | ||
| 611 | |||
| 612 | expect(result._tag).toBe("Failure"); | ||
| 613 | if (result._tag === "Failure") { | ||
| 614 | expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); | ||
| 615 | } | ||
| 616 | }).pipe(Effect.provide(connectorTestLayer(execute))); | ||
| 617 | }); | ||
| 618 | |||
| 619 | it.effect("rejects mint responses when the linked environment public key is malformed", () => { | ||
| 620 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 621 | Effect.sync(() => { | ||
| 622 | const mintRequest = decodeMintRequestBody(requestBodyText(request)); | ||
| 623 | return HttpClientResponse.fromWeb( | ||
| 624 | request, | ||
| 625 | Response.json(signMintResponse(mintRequest), { status: 200 }), | ||
| 626 | ); | ||
| 627 | }); | ||
| 628 | |||
| 629 | return Effect.gen(function* () { | ||
| 630 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 631 | const result = yield* Effect.exit( | ||
| 632 | connector.connect({ | ||
| 633 | userId: "user_123", | ||
| 634 | environmentId: "env-connector-test", | ||
| 635 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 636 | }), | ||
| 637 | ); | ||
| 638 | |||
| 639 | expect(result._tag).toBe("Failure"); | ||
| 640 | if (result._tag === "Failure") { | ||
| 641 | expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); | ||
| 642 | } | ||
| 643 | }).pipe( | ||
| 644 | Effect.provide( | ||
| 645 | connectorTestLayer(execute, { | ||
| 646 | links: makeLinks({ | ||
| 647 | environmentPublicKey: "not a pem public key", | ||
| 648 | }), | ||
| 649 | }), | ||
| 650 | ), | ||
| 651 | ); | ||
| 652 | }); | ||
| 653 | |||
| 654 | it.effect("rejects environment mint responses with an overlong credential window", () => { | ||
| 655 | const execute = (request: HttpClientRequest.HttpClientRequest) => | ||
| 656 | Effect.sync(() => { | ||
| 657 | const mintRequest = decodeMintRequestBody(requestBodyText(request)); | ||
| 658 | return HttpClientResponse.fromWeb( | ||
| 659 | request, | ||
| 660 | Response.json( | ||
| 661 | { ...signMintResponse(mintRequest), expiresAt: "2999-01-01T00:00:00.000Z" }, | ||
| 662 | { status: 200 }, | ||
| 663 | ), | ||
| 664 | ); | ||
| 665 | }); | ||
| 666 | |||
| 667 | return Effect.gen(function* () { | ||
| 668 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 669 | const result = yield* Effect.exit( | ||
| 670 | connector.connect({ | ||
| 671 | userId: "user_123", | ||
| 672 | environmentId: "env-connector-test", | ||
| 673 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 674 | }), | ||
| 675 | ); | ||
| 676 | |||
| 677 | expect(result._tag).toBe("Failure"); | ||
| 678 | if (result._tag === "Failure") { | ||
| 679 | expect(result.cause.toString()).toContain("EnvironmentMintResponseInvalid"); | ||
| 680 | } | ||
| 681 | }).pipe(Effect.provide(connectorTestLayer(execute))); | ||
| 682 | }); | ||
| 683 | |||
| 684 | it.effect("times out hung managed endpoint mint requests", () => { | ||
| 685 | let resolveRequestStarted: (() => void) | undefined; | ||
| 686 | const requestStarted = new Promise<void>((resolve) => { | ||
| 687 | resolveRequestStarted = () => resolve(); | ||
| 688 | }); | ||
| 689 | const execute = () => | ||
| 690 | Effect.sync(() => { | ||
| 691 | resolveRequestStarted?.(); | ||
| 692 | }).pipe(Effect.andThen(Effect.never as Effect.Effect<HttpClientResponse.HttpClientResponse>)); | ||
| 693 | |||
| 694 | return Effect.gen(function* () { | ||
| 695 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 696 | const resultFiber = yield* connector | ||
| 697 | .connect({ | ||
| 698 | userId: "user_123", | ||
| 699 | environmentId: "env-connector-test", | ||
| 700 | clientProofKeyThumbprint: "client-proof-key-thumbprint", | ||
| 701 | }) | ||
| 702 | .pipe(Effect.result, Effect.forkScoped); | ||
| 703 | |||
| 704 | yield* Effect.promise(() => requestStarted); | ||
| 705 | yield* TestClock.adjust( | ||
| 706 | Duration.millis(EnvironmentConnector.ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS), | ||
| 707 | ); | ||
| 708 | const result = yield* Fiber.join(resultFiber); | ||
| 709 | |||
| 710 | expect(Result.isFailure(result)).toBe(true); | ||
| 711 | if (Result.isFailure(result)) { | ||
| 712 | expect(result.failure._tag).toBe("EnvironmentMintRequestTimedOut"); | ||
| 713 | expect(result.failure).toMatchObject({ | ||
| 714 | environmentId: "env-connector-test", | ||
| 715 | timeoutMs: EnvironmentConnector.ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS, | ||
| 716 | }); | ||
| 717 | } | ||
| 718 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), connectorTestLayer(execute)))); | ||
| 719 | }); | ||
| 720 | }); | ||
infra/relay/src/environments/EnvironmentConnector.ts created+430| ... | @@ -0,0 +1,430 @@ | ||
| 1 | import { | ||
| 2 | EnvironmentHttpBadRequestError, | ||
| 3 | EnvironmentHttpConflictError, | ||
| 4 | EnvironmentHttpForbiddenError, | ||
| 5 | EnvironmentHttpInternalServerError, | ||
| 6 | EnvironmentHttpUnauthorizedError, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime"; | ||
| 9 | import { | ||
| 10 | RelayCloudEnvironmentHealthProofPayload, | ||
| 11 | RelayEnvironmentHealthResponse, | ||
| 12 | RelayEnvironmentHealthResponseProofPayload, | ||
| 13 | RelayEnvironmentMintResponse, | ||
| 14 | RelayEnvironmentMintResponseProofPayload, | ||
| 15 | RelayCloudMintCredentialProofPayload, | ||
| 16 | type RelayEnvironmentConnectResponse, | ||
| 17 | type RelayEnvironmentStatusResponse, | ||
| 18 | } from "@t3tools/contracts/relay"; | ||
| 19 | import { | ||
| 20 | normalizeRelayIssuer, | ||
| 21 | RELAY_HEALTH_REQUEST_TYP, | ||
| 22 | RELAY_HEALTH_RESPONSE_TYP, | ||
| 23 | RELAY_MINT_REQUEST_TYP, | ||
| 24 | RELAY_MINT_RESPONSE_TYP, | ||
| 25 | signRelayJwt, | ||
| 26 | verifyRelayJwt, | ||
| 27 | } from "@t3tools/shared/relayJwt"; | ||
| 28 | import { stableStringify } from "@t3tools/shared/relaySigning"; | ||
| 29 | import * as Context from "effect/Context"; | ||
| 30 | import * as Crypto from "effect/Crypto"; | ||
| 31 | import * as Data from "effect/Data"; | ||
| 32 | import * as DateTime from "effect/DateTime"; | ||
| 33 | import * as Duration from "effect/Duration"; | ||
| 34 | import * as Effect from "effect/Effect"; | ||
| 35 | import * as Layer from "effect/Layer"; | ||
| 36 | import * as Option from "effect/Option"; | ||
| 37 | import * as Redacted from "effect/Redacted"; | ||
| 38 | import * as Schema from "effect/Schema"; | ||
| 39 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; | ||
| 40 | |||
| 41 | import * as EnvironmentLinks from "./EnvironmentLinks.ts"; | ||
| 42 | import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; | ||
| 43 | import * as RelayConfiguration from "../Config.ts"; | ||
| 44 | |||
| 45 | export class EnvironmentConnectNotAuthorized extends Data.TaggedError( | ||
| 46 | "EnvironmentConnectNotAuthorized", | ||
| 47 | )<{ | ||
| 48 | readonly environmentId: string; | ||
| 49 | }> {} | ||
| 50 | |||
| 51 | export class EnvironmentMintRequestFailed extends Data.TaggedError("EnvironmentMintRequestFailed")<{ | ||
| 52 | readonly cause: unknown; | ||
| 53 | }> {} | ||
| 54 | |||
| 55 | export class EnvironmentMintRequestTimedOut extends Data.TaggedError( | ||
| 56 | "EnvironmentMintRequestTimedOut", | ||
| 57 | )<{ | ||
| 58 | readonly environmentId: string; | ||
| 59 | readonly timeoutMs: number; | ||
| 60 | }> {} | ||
| 61 | |||
| 62 | export class EnvironmentMintResponseInvalid extends Data.TaggedError( | ||
| 63 | "EnvironmentMintResponseInvalid", | ||
| 64 | )<{ | ||
| 65 | readonly environmentId: string; | ||
| 66 | }> {} | ||
| 67 | |||
| 68 | export type EnvironmentConnectorError = | ||
| 69 | | EnvironmentConnectNotAuthorized | ||
| 70 | | EnvironmentMintRequestFailed | ||
| 71 | | EnvironmentMintRequestTimedOut | ||
| 72 | | EnvironmentMintResponseInvalid | ||
| 73 | | EnvironmentLinks.EnvironmentLinkLookupPersistenceError | ||
| 74 | | ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError; | ||
| 75 | |||
| 76 | export const ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS = 10_000; | ||
| 77 | const ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS = 60 * 1_000; | ||
| 78 | |||
| 79 | export interface EnvironmentConnectorShape { | ||
| 80 | readonly connect: (input: { | ||
| 81 | readonly userId: string; | ||
| 82 | readonly environmentId: string; | ||
| 83 | readonly clientProofKeyThumbprint: string; | ||
| 84 | readonly deviceId?: string; | ||
| 85 | }) => Effect.Effect<RelayEnvironmentConnectResponse, EnvironmentConnectorError>; | ||
| 86 | readonly status: (input: { | ||
| 87 | readonly userId: string; | ||
| 88 | readonly environmentId: string; | ||
| 89 | }) => Effect.Effect<RelayEnvironmentStatusResponse, EnvironmentConnectorError>; | ||
| 90 | } | ||
| 91 | |||
| 92 | export class EnvironmentConnector extends Context.Service< | ||
| 93 | EnvironmentConnector, | ||
| 94 | EnvironmentConnectorShape | ||
| 95 | >()("t3code-relay/environments/EnvironmentConnector") {} | ||
| 96 | |||
| 97 | const decodeMintResponseProof = Schema.decodeUnknownEffect( | ||
| 98 | RelayEnvironmentMintResponseProofPayload, | ||
| 99 | ); | ||
| 100 | const decodeHealthResponseProof = Schema.decodeUnknownEffect( | ||
| 101 | RelayEnvironmentHealthResponseProofPayload, | ||
| 102 | ); | ||
| 103 | const isEnvironmentHealthError = Schema.is( | ||
| 104 | Schema.Union([ | ||
| 105 | EnvironmentHttpBadRequestError, | ||
| 106 | EnvironmentHttpUnauthorizedError, | ||
| 107 | EnvironmentHttpForbiddenError, | ||
| 108 | EnvironmentHttpConflictError, | ||
| 109 | EnvironmentHttpInternalServerError, | ||
| 110 | ]), | ||
| 111 | ); | ||
| 112 | |||
| 113 | function environmentHealthRequestFailureMessage(cause: unknown): string { | ||
| 114 | return isEnvironmentHealthError(cause) | ||
| 115 | ? `Managed endpoint health request failed: ${cause.message}` | ||
| 116 | : "Managed endpoint health request failed."; | ||
| 117 | } | ||
| 118 | |||
| 119 | const withoutRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 120 | effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); | ||
| 121 | |||
| 122 | const verifyWithEnvironmentKeys = Effect.fnUntraced(function* <A, E>(input: { | ||
| 123 | readonly token: string; | ||
| 124 | readonly typ: string; | ||
| 125 | readonly issuer: string; | ||
| 126 | readonly audience: string; | ||
| 127 | readonly nowEpochSeconds: number; | ||
| 128 | readonly environmentPublicKeys: ReadonlyArray<string>; | ||
| 129 | readonly decodePayload: (input: unknown) => Effect.Effect<A, E>; | ||
| 130 | }) { | ||
| 131 | const { decodePayload, ...rest } = input; | ||
| 132 | for (const publicKey of input.environmentPublicKeys) { | ||
| 133 | const proof = yield* verifyRelayJwt({ ...rest, publicKey }).pipe( | ||
| 134 | Effect.flatMap(decodePayload), | ||
| 135 | Effect.option, | ||
| 136 | ); | ||
| 137 | if (Option.isSome(proof)) { | ||
| 138 | return proof.value; | ||
| 139 | } | ||
| 140 | // A linked environment can have rotated keys; try the remaining active keys. | ||
| 141 | } | ||
| 142 | return null; | ||
| 143 | }); | ||
| 144 | |||
| 145 | function verifyEnvironmentResponse(input: { | ||
| 146 | readonly response: RelayEnvironmentMintResponse; | ||
| 147 | readonly environmentId: string; | ||
| 148 | readonly requestNonce: string; | ||
| 149 | readonly clientProofKeyThumbprint: string; | ||
| 150 | readonly environmentPublicKeys: ReadonlyArray<string>; | ||
| 151 | readonly relayIssuer: string; | ||
| 152 | readonly nowEpochSeconds: number; | ||
| 153 | }) { | ||
| 154 | return verifyWithEnvironmentKeys({ | ||
| 155 | token: input.response.proof, | ||
| 156 | typ: RELAY_MINT_RESPONSE_TYP, | ||
| 157 | issuer: `t3-env:${input.environmentId}`, | ||
| 158 | audience: normalizeRelayIssuer(input.relayIssuer), | ||
| 159 | nowEpochSeconds: input.nowEpochSeconds, | ||
| 160 | environmentPublicKeys: input.environmentPublicKeys, | ||
| 161 | decodePayload: decodeMintResponseProof, | ||
| 162 | }).pipe( | ||
| 163 | Effect.map( | ||
| 164 | (proof) => | ||
| 165 | proof !== null && | ||
| 166 | proof.environmentId === input.environmentId && | ||
| 167 | proof.requestNonce === input.requestNonce && | ||
| 168 | proof.clientProofKeyThumbprint === input.clientProofKeyThumbprint && | ||
| 169 | proof.credential === input.response.credential && | ||
| 170 | Option.match(DateTime.make(input.response.expiresAt), { | ||
| 171 | onNone: () => false, | ||
| 172 | onSome: (expiresAt) => Math.floor(expiresAt.epochMilliseconds / 1_000) === proof.exp, | ||
| 173 | }), | ||
| 174 | ), | ||
| 175 | ); | ||
| 176 | } | ||
| 177 | |||
| 178 | function verifyEnvironmentHealthResponse(input: { | ||
| 179 | readonly response: RelayEnvironmentHealthResponse; | ||
| 180 | readonly environmentId: string; | ||
| 181 | readonly requestNonce: string; | ||
| 182 | readonly requestIssuedAt: DateTime.DateTime; | ||
| 183 | readonly environmentPublicKeys: ReadonlyArray<string>; | ||
| 184 | readonly relayIssuer: string; | ||
| 185 | readonly now: DateTime.DateTime; | ||
| 186 | }) { | ||
| 187 | return verifyWithEnvironmentKeys({ | ||
| 188 | token: input.response.proof, | ||
| 189 | typ: RELAY_HEALTH_RESPONSE_TYP, | ||
| 190 | issuer: `t3-env:${input.environmentId}`, | ||
| 191 | audience: normalizeRelayIssuer(input.relayIssuer), | ||
| 192 | nowEpochSeconds: Math.floor(input.now.epochMilliseconds / 1_000), | ||
| 193 | environmentPublicKeys: input.environmentPublicKeys, | ||
| 194 | decodePayload: decodeHealthResponseProof, | ||
| 195 | }).pipe( | ||
| 196 | Effect.map((proof) => { | ||
| 197 | if ( | ||
| 198 | proof === null || | ||
| 199 | input.response.environmentId !== input.environmentId || | ||
| 200 | proof.environmentId !== input.environmentId || | ||
| 201 | proof.requestNonce !== input.requestNonce || | ||
| 202 | proof.status !== input.response.status || | ||
| 203 | proof.checkedAt !== input.response.checkedAt || | ||
| 204 | stableStringify(proof.descriptor) !== stableStringify(input.response.descriptor) | ||
| 205 | ) { | ||
| 206 | return false; | ||
| 207 | } | ||
| 208 | const checkedAt = DateTime.make(input.response.checkedAt); | ||
| 209 | if (Option.isNone(checkedAt)) { | ||
| 210 | return false; | ||
| 211 | } | ||
| 212 | return ( | ||
| 213 | checkedAt.value.epochMilliseconds >= | ||
| 214 | input.requestIssuedAt.epochMilliseconds - ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS && | ||
| 215 | checkedAt.value.epochMilliseconds <= | ||
| 216 | input.now.epochMilliseconds + ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS | ||
| 217 | ); | ||
| 218 | }), | ||
| 219 | ); | ||
| 220 | } | ||
| 221 | |||
| 222 | const make = Effect.gen(function* () { | ||
| 223 | const links = yield* EnvironmentLinks.EnvironmentLinks; | ||
| 224 | const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; | ||
| 225 | const settings = yield* RelayConfiguration.RelayConfiguration; | ||
| 226 | const httpClient = yield* HttpClient.HttpClient; | ||
| 227 | const crypto = yield* Crypto.Crypto; | ||
| 228 | const relayIssuer = normalizeRelayIssuer(settings.relayIssuer); | ||
| 229 | const makeEnvironmentClient = (httpBaseUrl: string) => | ||
| 230 | makeEnvironmentHttpApiClient(httpBaseUrl).pipe( | ||
| 231 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 232 | ); | ||
| 233 | const resolveManagedEndpoint = Effect.fn("relay.environment_connector.resolve_managed_endpoint")( | ||
| 234 | function* (input: { | ||
| 235 | readonly userId: string; | ||
| 236 | readonly link: EnvironmentLinks.RelayLinkedEnvironmentRecord; | ||
| 237 | }) { | ||
| 238 | if (input.link.endpoint.providerKind !== "cloudflare_tunnel") { | ||
| 239 | return yield* new EnvironmentConnectNotAuthorized({ | ||
| 240 | environmentId: input.link.environmentId, | ||
| 241 | }); | ||
| 242 | } | ||
| 243 | const allocation = yield* allocations.get({ | ||
| 244 | userId: input.userId, | ||
| 245 | environmentId: input.link.environmentId, | ||
| 246 | }); | ||
| 247 | const endpoint = allocation | ||
| 248 | ? ManagedEndpointAllocations.resolveReadyManagedEndpoint({ | ||
| 249 | allocation, | ||
| 250 | baseDomain: settings.managedEndpointBaseDomain, | ||
| 251 | }) | ||
| 252 | : null; | ||
| 253 | if ( | ||
| 254 | endpoint === null || | ||
| 255 | endpoint.httpBaseUrl !== input.link.endpoint.httpBaseUrl || | ||
| 256 | endpoint.wsBaseUrl !== input.link.endpoint.wsBaseUrl | ||
| 257 | ) { | ||
| 258 | return yield* new EnvironmentConnectNotAuthorized({ | ||
| 259 | environmentId: input.link.environmentId, | ||
| 260 | }); | ||
| 261 | } | ||
| 262 | return endpoint; | ||
| 263 | }, | ||
| 264 | ); | ||
| 265 | |||
| 266 | return EnvironmentConnector.of({ | ||
| 267 | status: Effect.fn("relay.environment_connector.status")(function* (input) { | ||
| 268 | yield* Effect.annotateCurrentSpan({ | ||
| 269 | "relay.environment_id": input.environmentId, | ||
| 270 | "relay.operation": "status", | ||
| 271 | }); | ||
| 272 | const link = yield* links.getForUser(input); | ||
| 273 | if (!link) { | ||
| 274 | return yield* new EnvironmentConnectNotAuthorized({ environmentId: input.environmentId }); | ||
| 275 | } | ||
| 276 | const endpoint = yield* resolveManagedEndpoint({ userId: input.userId, link }); | ||
| 277 | const now = yield* DateTime.now; | ||
| 278 | const expiresAt = DateTime.add(now, { minutes: 2 }); | ||
| 279 | const nonce = yield* crypto.randomUUIDv4.pipe( | ||
| 280 | Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause })), | ||
| 281 | ); | ||
| 282 | const payload = { | ||
| 283 | iss: relayIssuer, | ||
| 284 | aud: `t3-env:${link.environmentId}`, | ||
| 285 | sub: input.userId, | ||
| 286 | jti: yield* crypto.randomUUIDv4.pipe( | ||
| 287 | Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause })), | ||
| 288 | ), | ||
| 289 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 290 | exp: Math.floor(expiresAt.epochMilliseconds / 1_000), | ||
| 291 | environmentId: link.environmentId, | ||
| 292 | nonce, | ||
| 293 | scope: ["environment:status"], | ||
| 294 | } satisfies RelayCloudEnvironmentHealthProofPayload; | ||
| 295 | const proof = yield* signRelayJwt({ | ||
| 296 | privateKey: Redacted.value(settings.cloudMintPrivateKey), | ||
| 297 | typ: RELAY_HEALTH_REQUEST_TYP, | ||
| 298 | payload, | ||
| 299 | }).pipe(Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause }))); | ||
| 300 | const checkedAt = DateTime.formatIso(now); | ||
| 301 | const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl); | ||
| 302 | const responseOption = yield* environmentClient.cloud.health({ payload: { proof } }).pipe( | ||
| 303 | withoutRedirects, | ||
| 304 | Effect.match({ | ||
| 305 | onFailure: (cause) => ({ _tag: "Failure" as const, cause }), | ||
| 306 | onSuccess: (response) => ({ _tag: "Success" as const, response }), | ||
| 307 | }), | ||
| 308 | Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)), | ||
| 309 | ); | ||
| 310 | if (Option.isNone(responseOption)) { | ||
| 311 | return { | ||
| 312 | environmentId: link.environmentId, | ||
| 313 | endpoint, | ||
| 314 | status: "offline" as const, | ||
| 315 | checkedAt, | ||
| 316 | error: "Managed endpoint health request timed out.", | ||
| 317 | }; | ||
| 318 | } | ||
| 319 | if (responseOption.value._tag === "Failure") { | ||
| 320 | return { | ||
| 321 | environmentId: link.environmentId, | ||
| 322 | endpoint, | ||
| 323 | status: "offline" as const, | ||
| 324 | checkedAt, | ||
| 325 | error: environmentHealthRequestFailureMessage(responseOption.value.cause), | ||
| 326 | }; | ||
| 327 | } | ||
| 328 | const decoded = responseOption.value.response; | ||
| 329 | const verified = yield* verifyEnvironmentHealthResponse({ | ||
| 330 | response: decoded, | ||
| 331 | environmentId: input.environmentId, | ||
| 332 | requestNonce: nonce, | ||
| 333 | requestIssuedAt: now, | ||
| 334 | environmentPublicKeys: [link.environmentPublicKey], | ||
| 335 | relayIssuer, | ||
| 336 | now: yield* DateTime.now, | ||
| 337 | }); | ||
| 338 | if (!verified) { | ||
| 339 | return yield* new EnvironmentMintResponseInvalid({ environmentId: input.environmentId }); | ||
| 340 | } | ||
| 341 | return { | ||
| 342 | environmentId: link.environmentId, | ||
| 343 | endpoint, | ||
| 344 | status: "online" as const, | ||
| 345 | checkedAt: decoded.checkedAt, | ||
| 346 | descriptor: decoded.descriptor, | ||
| 347 | }; | ||
| 348 | }), | ||
| 349 | connect: Effect.fn("relay.environment_connector.connect")(function* (input) { | ||
| 350 | yield* Effect.annotateCurrentSpan({ | ||
| 351 | "relay.environment_id": input.environmentId, | ||
| 352 | "relay.operation": "connect", | ||
| 353 | "relay.connect.has_device_id": input.deviceId !== undefined, | ||
| 354 | ...(input.deviceId ? { "relay.mobile.device_id": input.deviceId } : {}), | ||
| 355 | }); | ||
| 356 | if (input.clientProofKeyThumbprint.trim().length === 0) { | ||
| 357 | return yield* new EnvironmentConnectNotAuthorized({ environmentId: input.environmentId }); | ||
| 358 | } | ||
| 359 | const link = yield* links.getForUser(input); | ||
| 360 | if (!link) { | ||
| 361 | return yield* new EnvironmentConnectNotAuthorized({ environmentId: input.environmentId }); | ||
| 362 | } | ||
| 363 | const endpoint = yield* resolveManagedEndpoint({ userId: input.userId, link }); | ||
| 364 | const now = yield* DateTime.now; | ||
| 365 | const expiresAt = DateTime.add(now, { minutes: 2 }); | ||
| 366 | const nonce = yield* crypto.randomUUIDv4.pipe( | ||
| 367 | Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause })), | ||
| 368 | ); | ||
| 369 | const payload = { | ||
| 370 | iss: relayIssuer, | ||
| 371 | aud: `t3-env:${link.environmentId}`, | ||
| 372 | sub: input.userId, | ||
| 373 | jti: yield* crypto.randomUUIDv4.pipe( | ||
| 374 | Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause })), | ||
| 375 | ), | ||
| 376 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 377 | exp: Math.floor(expiresAt.epochMilliseconds / 1_000), | ||
| 378 | environmentId: link.environmentId, | ||
| 379 | clientProofKeyThumbprint: input.clientProofKeyThumbprint, | ||
| 380 | cnf: { jkt: input.clientProofKeyThumbprint }, | ||
| 381 | ...(input.deviceId ? { deviceId: input.deviceId } : {}), | ||
| 382 | nonce, | ||
| 383 | scope: ["environment:connect"], | ||
| 384 | } satisfies RelayCloudMintCredentialProofPayload; | ||
| 385 | const proof = yield* signRelayJwt({ | ||
| 386 | privateKey: Redacted.value(settings.cloudMintPrivateKey), | ||
| 387 | typ: RELAY_MINT_REQUEST_TYP, | ||
| 388 | payload, | ||
| 389 | }).pipe(Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause }))); | ||
| 390 | const environmentClient = yield* makeEnvironmentClient(endpoint.httpBaseUrl); | ||
| 391 | const decoded = yield* environmentClient.cloud.t3MintCredential({ payload: { proof } }).pipe( | ||
| 392 | withoutRedirects, | ||
| 393 | Effect.mapError((cause) => new EnvironmentMintRequestFailed({ cause })), | ||
| 394 | Effect.timeoutOption(Duration.millis(ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS)), | ||
| 395 | Effect.flatMap( | ||
| 396 | Option.match({ | ||
| 397 | onNone: () => | ||
| 398 | Effect.fail( | ||
| 399 | new EnvironmentMintRequestTimedOut({ | ||
| 400 | environmentId: input.environmentId, | ||
| 401 | timeoutMs: ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS, | ||
| 402 | }), | ||
| 403 | ), | ||
| 404 | onSome: Effect.succeed, | ||
| 405 | }), | ||
| 406 | ), | ||
| 407 | ); | ||
| 408 | const verified = yield* verifyEnvironmentResponse({ | ||
| 409 | response: decoded, | ||
| 410 | environmentId: input.environmentId, | ||
| 411 | requestNonce: nonce, | ||
| 412 | clientProofKeyThumbprint: input.clientProofKeyThumbprint, | ||
| 413 | environmentPublicKeys: [link.environmentPublicKey], | ||
| 414 | relayIssuer, | ||
| 415 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 416 | }); | ||
| 417 | if (!verified) { | ||
| 418 | return yield* new EnvironmentMintResponseInvalid({ environmentId: input.environmentId }); | ||
| 419 | } | ||
| 420 | return { | ||
| 421 | environmentId: link.environmentId, | ||
| 422 | endpoint, | ||
| 423 | credential: decoded.credential, | ||
| 424 | expiresAt: decoded.expiresAt, | ||
| 425 | }; | ||
| 426 | }), | ||
| 427 | }); | ||
| 428 | }); | ||
| 429 | |||
| 430 | export const layer = Layer.effect(EnvironmentConnector, make); | ||
infra/relay/src/environments/EnvironmentCredentials.test.ts created+158| ... | @@ -0,0 +1,158 @@ | ||
| 1 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import { PgDialect, QueryBuilder } from "drizzle-orm/pg-core"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | |||
| 7 | import { RelayDb, type RelayDatabase } from "../db.ts"; | ||
| 8 | import { relayEnvironmentCredentials } from "../persistence/schema.ts"; | ||
| 9 | import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; | ||
| 10 | |||
| 11 | describe("EnvironmentCredentials", () => { | ||
| 12 | it.effect( | ||
| 13 | "creates opaque credentials and revokes only older credentials for the same key", | ||
| 14 | () => { | ||
| 15 | const insertedValues: Array<{ | ||
| 16 | readonly credentialId: string; | ||
| 17 | readonly environmentId: string; | ||
| 18 | readonly environmentPublicKey: string; | ||
| 19 | readonly credentialHash: string; | ||
| 20 | readonly revokedAt: null; | ||
| 21 | readonly createdAt: string; | ||
| 22 | readonly updatedAt: string; | ||
| 23 | }> = []; | ||
| 24 | const staleCredentialRevocations: Array<{ | ||
| 25 | readonly values: Record<string, unknown>; | ||
| 26 | readonly condition: unknown; | ||
| 27 | }> = []; | ||
| 28 | |||
| 29 | const fakeDb = { | ||
| 30 | insert: (table: unknown) => { | ||
| 31 | expect(table).toBe(relayEnvironmentCredentials); | ||
| 32 | return { | ||
| 33 | values: (values: (typeof insertedValues)[number]) => { | ||
| 34 | insertedValues.push(values); | ||
| 35 | return Effect.void; | ||
| 36 | }, | ||
| 37 | }; | ||
| 38 | }, | ||
| 39 | update: (table: unknown) => { | ||
| 40 | expect(table).toBe(relayEnvironmentCredentials); | ||
| 41 | return { | ||
| 42 | set: (values: Record<string, unknown>) => ({ | ||
| 43 | where: (condition: unknown) => { | ||
| 44 | staleCredentialRevocations.push({ values, condition }); | ||
| 45 | return Effect.void; | ||
| 46 | }, | ||
| 47 | }), | ||
| 48 | }; | ||
| 49 | }, | ||
| 50 | } as unknown as RelayDatabase; | ||
| 51 | |||
| 52 | return Effect.gen(function* () { | ||
| 53 | const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; | ||
| 54 | const token = yield* credentials.create({ | ||
| 55 | environmentId: "env_test", | ||
| 56 | environmentPublicKey: "environment-public-key", | ||
| 57 | }); | ||
| 58 | const [, credentialId, secret] = token.split("_"); | ||
| 59 | |||
| 60 | expect(token).toMatch(/^t3env_[0-9a-f]{64}_[0-9a-f]{96}$/); | ||
| 61 | expect(credentialId).toHaveLength(64); | ||
| 62 | expect(secret).toHaveLength(96); | ||
| 63 | expect(insertedValues).toHaveLength(1); | ||
| 64 | expect(insertedValues[0]).toMatchObject({ | ||
| 65 | credentialId, | ||
| 66 | environmentId: "env_test", | ||
| 67 | environmentPublicKey: "environment-public-key", | ||
| 68 | revokedAt: null, | ||
| 69 | }); | ||
| 70 | expect(insertedValues[0]?.credentialHash).toMatch(/^[A-Za-z0-9_-]{43}$/); | ||
| 71 | expect(insertedValues[0]?.credentialHash).not.toContain(token); | ||
| 72 | expect(insertedValues[0]?.createdAt).toBe(insertedValues[0]?.updatedAt); | ||
| 73 | expect(staleCredentialRevocations).toHaveLength(1); | ||
| 74 | expect(staleCredentialRevocations[0]?.values.revokedAt).toEqual( | ||
| 75 | staleCredentialRevocations[0]?.values.updatedAt, | ||
| 76 | ); | ||
| 77 | |||
| 78 | const query = new PgDialect().sqlToQuery(staleCredentialRevocations[0]?.condition as never); | ||
| 79 | expect(query.sql).toContain('"relay_environment_credentials"."environment_id" = $1'); | ||
| 80 | expect(query.sql).toContain( | ||
| 81 | '"relay_environment_credentials"."environment_public_key" = $2', | ||
| 82 | ); | ||
| 83 | expect(query.sql).toContain('"relay_environment_credentials"."credential_id" <> $3'); | ||
| 84 | expect(query.sql).toContain('"relay_environment_credentials"."revoked_at" is null'); | ||
| 85 | expect(query.params).toEqual(["env_test", "environment-public-key", credentialId]); | ||
| 86 | }).pipe( | ||
| 87 | Effect.provide( | ||
| 88 | EnvironmentCredentials.layer.pipe( | ||
| 89 | Layer.provide(NodeCryptoLayer.layer), | ||
| 90 | Layer.provide(Layer.succeed(RelayDb, fakeDb)), | ||
| 91 | ), | ||
| 92 | ), | ||
| 93 | ); | ||
| 94 | }, | ||
| 95 | ); | ||
| 96 | |||
| 97 | it.effect("revokes active credentials for an environment public key", () => { | ||
| 98 | const updateValues: Array<Record<string, unknown>> = []; | ||
| 99 | const whereConditions: Array<unknown> = []; | ||
| 100 | const fakeDb = { | ||
| 101 | select: (fields: Parameters<QueryBuilder["select"]>[0]) => new QueryBuilder().select(fields), | ||
| 102 | update: (table: unknown) => { | ||
| 103 | expect(table).toBe(relayEnvironmentCredentials); | ||
| 104 | return { | ||
| 105 | set: (values: Record<string, unknown>) => { | ||
| 106 | updateValues.push(values); | ||
| 107 | return { | ||
| 108 | where: (condition: unknown) => { | ||
| 109 | whereConditions.push(condition); | ||
| 110 | return { | ||
| 111 | returning: (selection: unknown) => { | ||
| 112 | expect(selection).toBeDefined(); | ||
| 113 | return Effect.succeed([{ credentialId: "credential-1" }]); | ||
| 114 | }, | ||
| 115 | }; | ||
| 116 | }, | ||
| 117 | }; | ||
| 118 | }, | ||
| 119 | }; | ||
| 120 | }, | ||
| 121 | } as unknown as RelayDatabase; | ||
| 122 | |||
| 123 | return Effect.gen(function* () { | ||
| 124 | const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; | ||
| 125 | const revoked = yield* credentials.revokeForEnvironmentPublicKey({ | ||
| 126 | environmentId: "env_test", | ||
| 127 | environmentPublicKey: "environment-public-key", | ||
| 128 | }); | ||
| 129 | |||
| 130 | expect(revoked).toBe(true); | ||
| 131 | expect(updateValues).toHaveLength(1); | ||
| 132 | expect(updateValues[0]?.revokedAt).toEqual(updateValues[0]?.updatedAt); | ||
| 133 | expect(whereConditions).toHaveLength(1); | ||
| 134 | |||
| 135 | const query = new PgDialect().sqlToQuery(whereConditions[0] as never); | ||
| 136 | expect(query.sql).toContain('"relay_environment_credentials"."environment_id" = $1'); | ||
| 137 | expect(query.sql).toContain('"relay_environment_credentials"."environment_public_key" = $2'); | ||
| 138 | expect(query.sql).toContain('"relay_environment_credentials"."revoked_at" is null'); | ||
| 139 | expect(query.sql).toContain("not exists"); | ||
| 140 | expect(query.sql).toContain('"relay_environment_links"."environment_id" = $3'); | ||
| 141 | expect(query.sql).toContain('"relay_environment_links"."environment_public_key" = $4'); | ||
| 142 | expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); | ||
| 143 | expect(query.params).toEqual([ | ||
| 144 | "env_test", | ||
| 145 | "environment-public-key", | ||
| 146 | "env_test", | ||
| 147 | "environment-public-key", | ||
| 148 | ]); | ||
| 149 | }).pipe( | ||
| 150 | Effect.provide( | ||
| 151 | EnvironmentCredentials.layer.pipe( | ||
| 152 | Layer.provide(NodeCryptoLayer.layer), | ||
| 153 | Layer.provide(Layer.succeed(RelayDb, fakeDb)), | ||
| 154 | ), | ||
| 155 | ), | ||
| 156 | ); | ||
| 157 | }); | ||
| 158 | }); | ||
infra/relay/src/environments/EnvironmentCredentials.ts created+188| ... | @@ -0,0 +1,188 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Crypto from "effect/Crypto"; | ||
| 3 | import * as Data from "effect/Data"; | ||
| 4 | import * as DateTime from "effect/DateTime"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Encoding from "effect/Encoding"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import { and, eq, isNull, ne, notExists } from "drizzle-orm"; | ||
| 10 | |||
| 11 | import { RelayDb } from "../db.ts"; | ||
| 12 | import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts"; | ||
| 13 | |||
| 14 | export class EnvironmentCredentialCreatePersistenceError extends Data.TaggedError( | ||
| 15 | "EnvironmentCredentialCreatePersistenceError", | ||
| 16 | )<{ | ||
| 17 | readonly cause: unknown; | ||
| 18 | }> {} | ||
| 19 | |||
| 20 | export class EnvironmentCredentialAuthenticatePersistenceError extends Data.TaggedError( | ||
| 21 | "EnvironmentCredentialAuthenticatePersistenceError", | ||
| 22 | )<{ | ||
| 23 | readonly cause: unknown; | ||
| 24 | }> {} | ||
| 25 | |||
| 26 | export class EnvironmentCredentialRevokePersistenceError extends Data.TaggedError( | ||
| 27 | "EnvironmentCredentialRevokePersistenceError", | ||
| 28 | )<{ | ||
| 29 | readonly cause: unknown; | ||
| 30 | }> {} | ||
| 31 | |||
| 32 | export interface EnvironmentCredentialPrincipal { | ||
| 33 | readonly credentialId: string; | ||
| 34 | readonly environmentId: string; | ||
| 35 | readonly environmentPublicKey: string; | ||
| 36 | } | ||
| 37 | |||
| 38 | export interface EnvironmentCredentialsShape { | ||
| 39 | readonly create: (input: { | ||
| 40 | readonly environmentId: string; | ||
| 41 | readonly environmentPublicKey: string; | ||
| 42 | }) => Effect.Effect<string, EnvironmentCredentialCreatePersistenceError>; | ||
| 43 | readonly authenticate: ( | ||
| 44 | token: string, | ||
| 45 | ) => Effect.Effect< | ||
| 46 | Option.Option<EnvironmentCredentialPrincipal>, | ||
| 47 | EnvironmentCredentialAuthenticatePersistenceError | ||
| 48 | >; | ||
| 49 | readonly revokeForEnvironmentPublicKey: (input: { | ||
| 50 | readonly environmentId: string; | ||
| 51 | readonly environmentPublicKey: string; | ||
| 52 | }) => Effect.Effect<boolean, EnvironmentCredentialRevokePersistenceError>; | ||
| 53 | } | ||
| 54 | |||
| 55 | export class EnvironmentCredentials extends Context.Service< | ||
| 56 | EnvironmentCredentials, | ||
| 57 | EnvironmentCredentialsShape | ||
| 58 | >()("t3code-relay/environments/EnvironmentCredentials") {} | ||
| 59 | |||
| 60 | const make = Effect.gen(function* () { | ||
| 61 | const db = yield* RelayDb; | ||
| 62 | const crypto = yield* Crypto.Crypto; | ||
| 63 | const hashToken = (token: string) => | ||
| 64 | crypto | ||
| 65 | .digest("SHA-256", new TextEncoder().encode(token)) | ||
| 66 | .pipe(Effect.map(Encoding.encodeBase64Url)); | ||
| 67 | const randomTokenPart = (segments: number) => | ||
| 68 | Effect.map(Effect.all(Array.from({ length: segments }, () => crypto.randomUUIDv4)), (values) => | ||
| 69 | values.join("").replaceAll("-", ""), | ||
| 70 | ); | ||
| 71 | const makeCredential = Effect.fnUntraced(function* () { | ||
| 72 | const credentialId = yield* randomTokenPart(2); | ||
| 73 | const secret = yield* randomTokenPart(3); | ||
| 74 | return { | ||
| 75 | credentialId, | ||
| 76 | token: `t3env_${credentialId}_${secret}`, | ||
| 77 | }; | ||
| 78 | }); | ||
| 79 | |||
| 80 | return EnvironmentCredentials.of({ | ||
| 81 | create: Effect.fn("relay.environment_credentials.create")( | ||
| 82 | function* (input) { | ||
| 83 | yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); | ||
| 84 | const credential = yield* makeCredential(); | ||
| 85 | const credentialHash = yield* hashToken(credential.token); | ||
| 86 | const now = DateTime.formatIso(yield* DateTime.now); | ||
| 87 | yield* db.insert(relayEnvironmentCredentials).values({ | ||
| 88 | credentialId: credential.credentialId, | ||
| 89 | environmentId: input.environmentId, | ||
| 90 | environmentPublicKey: input.environmentPublicKey, | ||
| 91 | credentialHash, | ||
| 92 | revokedAt: null, | ||
| 93 | createdAt: now, | ||
| 94 | updatedAt: now, | ||
| 95 | }); | ||
| 96 | yield* db | ||
| 97 | .update(relayEnvironmentCredentials) | ||
| 98 | .set({ | ||
| 99 | revokedAt: now, | ||
| 100 | updatedAt: now, | ||
| 101 | }) | ||
| 102 | .where( | ||
| 103 | and( | ||
| 104 | eq(relayEnvironmentCredentials.environmentId, input.environmentId), | ||
| 105 | eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), | ||
| 106 | ne(relayEnvironmentCredentials.credentialId, credential.credentialId), | ||
| 107 | isNull(relayEnvironmentCredentials.revokedAt), | ||
| 108 | ), | ||
| 109 | ); | ||
| 110 | return credential.token; | ||
| 111 | }, | ||
| 112 | Effect.mapError((cause) => new EnvironmentCredentialCreatePersistenceError({ cause })), | ||
| 113 | ), | ||
| 114 | |||
| 115 | authenticate: Effect.fn("relay.environment_credentials.authenticate")( | ||
| 116 | function* (token) { | ||
| 117 | const credentialHash = yield* hashToken(token); | ||
| 118 | const rows = yield* db | ||
| 119 | .select({ | ||
| 120 | credentialId: relayEnvironmentCredentials.credentialId, | ||
| 121 | environmentId: relayEnvironmentCredentials.environmentId, | ||
| 122 | environmentPublicKey: relayEnvironmentCredentials.environmentPublicKey, | ||
| 123 | }) | ||
| 124 | .from(relayEnvironmentCredentials) | ||
| 125 | .where( | ||
| 126 | and( | ||
| 127 | eq(relayEnvironmentCredentials.credentialHash, credentialHash), | ||
| 128 | isNull(relayEnvironmentCredentials.revokedAt), | ||
| 129 | ), | ||
| 130 | ) | ||
| 131 | .limit(1); | ||
| 132 | const row = rows[0]; | ||
| 133 | if (row) { | ||
| 134 | yield* Effect.annotateCurrentSpan({ "relay.environment_id": row.environmentId }); | ||
| 135 | } | ||
| 136 | return row | ||
| 137 | ? Option.some({ | ||
| 138 | credentialId: row.credentialId, | ||
| 139 | environmentId: row.environmentId, | ||
| 140 | environmentPublicKey: row.environmentPublicKey, | ||
| 141 | }) | ||
| 142 | : Option.none(); | ||
| 143 | }, | ||
| 144 | Effect.mapError((cause) => new EnvironmentCredentialAuthenticatePersistenceError({ cause })), | ||
| 145 | ), | ||
| 146 | |||
| 147 | revokeForEnvironmentPublicKey: Effect.fn( | ||
| 148 | "relay.environment_credentials.revoke_for_environment_public_key", | ||
| 149 | )( | ||
| 150 | function* (input) { | ||
| 151 | yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); | ||
| 152 | const revokedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 153 | const rows = yield* db | ||
| 154 | .update(relayEnvironmentCredentials) | ||
| 155 | .set({ | ||
| 156 | revokedAt, | ||
| 157 | updatedAt: revokedAt, | ||
| 158 | }) | ||
| 159 | .where( | ||
| 160 | and( | ||
| 161 | eq(relayEnvironmentCredentials.environmentId, input.environmentId), | ||
| 162 | eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), | ||
| 163 | isNull(relayEnvironmentCredentials.revokedAt), | ||
| 164 | notExists( | ||
| 165 | db | ||
| 166 | .select({ userId: relayEnvironmentLinks.userId }) | ||
| 167 | .from(relayEnvironmentLinks) | ||
| 168 | .where( | ||
| 169 | and( | ||
| 170 | eq(relayEnvironmentLinks.environmentId, input.environmentId), | ||
| 171 | eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), | ||
| 172 | isNull(relayEnvironmentLinks.revokedAt), | ||
| 173 | ), | ||
| 174 | ), | ||
| 175 | ), | ||
| 176 | ), | ||
| 177 | ) | ||
| 178 | .returning({ | ||
| 179 | credentialId: relayEnvironmentCredentials.credentialId, | ||
| 180 | }); | ||
| 181 | return rows.length > 0; | ||
| 182 | }, | ||
| 183 | Effect.mapError((cause) => new EnvironmentCredentialRevokePersistenceError({ cause })), | ||
| 184 | ), | ||
| 185 | }); | ||
| 186 | }); | ||
| 187 | |||
| 188 | export const layer = Layer.effect(EnvironmentCredentials, make); | ||
infra/relay/src/environments/EnvironmentLinker.test.ts created+206| ... | @@ -0,0 +1,206 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | import type { | ||
| 3 | RelayEnvironmentLinkProofPayload, | ||
| 4 | RelayEnvironmentLinkRequest, | ||
| 5 | } from "@t3tools/contracts/relay"; | ||
| 6 | import { RELAY_LINK_PROOF_TYP } from "@t3tools/shared/relayJwt"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | ||
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Redacted from "effect/Redacted"; | ||
| 12 | import * as Result from "effect/Result"; | ||
| 13 | |||
| 14 | import * as DpopProofs from "../auth/DpopProofs.ts"; | ||
| 15 | import * as RelayTokens from "../auth/RelayTokens.ts"; | ||
| 16 | import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; | ||
| 17 | import * as EnvironmentLinks from "./EnvironmentLinks.ts"; | ||
| 18 | import * as RelayConfiguration from "../Config.ts"; | ||
| 19 | import * as EnvironmentLinker from "./EnvironmentLinker.ts"; | ||
| 20 | import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; | ||
| 21 | |||
| 22 | const relayKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 23 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 24 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 25 | }); | ||
| 26 | const environmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 27 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 28 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 29 | }); | ||
| 30 | const config = RelayConfiguration.RelayConfiguration.of({ | ||
| 31 | relayIssuer: "https://relay.example.test", | ||
| 32 | apns: { | ||
| 33 | environment: "sandbox", | ||
| 34 | teamId: "team-id", | ||
| 35 | keyId: "key-id", | ||
| 36 | privateKey: Redacted.make("private-key"), | ||
| 37 | bundleId: "com.t3tools.t3code.dev", | ||
| 38 | }, | ||
| 39 | apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), | ||
| 40 | clerkSecretKey: Redacted.make("clerk-secret"), | ||
| 41 | clerkPublishableKey: "pk_test_test", | ||
| 42 | clerkJwtAudience: "t3-code-relay", | ||
| 43 | cloudMintPrivateKey: Redacted.make(relayKeyPair.privateKey), | ||
| 44 | cloudMintPublicKey: relayKeyPair.publicKey, | ||
| 45 | managedEndpointBaseDomain: undefined, | ||
| 46 | managedEndpointNamespace: undefined, | ||
| 47 | }); | ||
| 48 | |||
| 49 | function signTestJwt(payload: object, typ: string, privateKey: string): string { | ||
| 50 | const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url"); | ||
| 51 | const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); | ||
| 52 | const signingInput = `${header}.${encodedPayload}`; | ||
| 53 | return `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), privateKey).toString("base64url")}`; | ||
| 54 | } | ||
| 55 | |||
| 56 | const makeRequest = Effect.gen(function* () { | ||
| 57 | const now = yield* DateTime.now; | ||
| 58 | const expiresAt = DateTime.add(now, { minutes: 5 }); | ||
| 59 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 60 | const challenge = yield* relayTokens.issueLinkChallenge({ | ||
| 61 | userId: "user_123", | ||
| 62 | request: { | ||
| 63 | notificationsEnabled: true, | ||
| 64 | liveActivitiesEnabled: true, | ||
| 65 | managedTunnelsEnabled: true, | ||
| 66 | }, | ||
| 67 | jti: "challenge-jti", | ||
| 68 | issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 69 | expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000), | ||
| 70 | }); | ||
| 71 | const payload = { | ||
| 72 | iss: "t3-env:env-link-test", | ||
| 73 | aud: "https://relay.example.test", | ||
| 74 | sub: "env-link-test", | ||
| 75 | jti: "link-proof-jti", | ||
| 76 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 77 | exp: Math.floor(expiresAt.epochMilliseconds / 1_000), | ||
| 78 | challenge, | ||
| 79 | environmentId: "env-link-test" as RelayEnvironmentLinkProofPayload["environmentId"], | ||
| 80 | descriptor: { | ||
| 81 | environmentId: "env-link-test" as RelayEnvironmentLinkProofPayload["environmentId"], | ||
| 82 | label: "Link Test Environment", | ||
| 83 | platform: { os: "darwin", arch: "arm64" }, | ||
| 84 | serverVersion: "0.0.0-test", | ||
| 85 | capabilities: { repositoryIdentity: true }, | ||
| 86 | }, | ||
| 87 | environmentPublicKey: environmentKeyPair.publicKey.trim(), | ||
| 88 | endpoint: { | ||
| 89 | httpBaseUrl: "https://env.example.test/", | ||
| 90 | wsBaseUrl: "wss://env.example.test/", | ||
| 91 | providerKind: "manual", | ||
| 92 | }, | ||
| 93 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 94 | scopes: ["agent_activity_notifications", "managed_tunnels"], | ||
| 95 | } satisfies RelayEnvironmentLinkProofPayload; | ||
| 96 | return { | ||
| 97 | request: { | ||
| 98 | proof: signTestJwt(payload, RELAY_LINK_PROOF_TYP, environmentKeyPair.privateKey), | ||
| 99 | notificationsEnabled: true, | ||
| 100 | liveActivitiesEnabled: true, | ||
| 101 | managedTunnelsEnabled: false, | ||
| 102 | } satisfies RelayEnvironmentLinkRequest, | ||
| 103 | payload, | ||
| 104 | }; | ||
| 105 | }); | ||
| 106 | |||
| 107 | function testLayer(input?: { | ||
| 108 | readonly upsert?: EnvironmentLinks.EnvironmentLinksShape["upsert"]; | ||
| 109 | readonly consume?: DpopProofs.DpopProofReplayShape["consume"]; | ||
| 110 | }) { | ||
| 111 | return EnvironmentLinker.layer.pipe( | ||
| 112 | Layer.provideMerge(RelayTokens.layer), | ||
| 113 | Layer.provide( | ||
| 114 | Layer.mergeAll( | ||
| 115 | Layer.succeed(RelayConfiguration.RelayConfiguration, config), | ||
| 116 | Layer.succeed(DpopProofs.DpopProofReplay, { | ||
| 117 | verifyAndConsume: () => Effect.die("unexpected DPoP proof verification"), | ||
| 118 | consume: input?.consume ?? (() => Effect.succeed(true)), | ||
| 119 | pruneExpired: Effect.void, | ||
| 120 | }), | ||
| 121 | Layer.succeed(EnvironmentLinks.EnvironmentLinks, { | ||
| 122 | upsert: input?.upsert ?? (() => Effect.void), | ||
| 123 | listUsersForEnvironment: () => Effect.succeed([]), | ||
| 124 | listDeliveryUsersForEnvironment: () => Effect.succeed([]), | ||
| 125 | listPublicKeysForEnvironment: () => Effect.succeed([]), | ||
| 126 | listForUser: () => Effect.succeed([]), | ||
| 127 | getForUser: () => Effect.succeed(null), | ||
| 128 | revokeForUser: () => Effect.succeed(false), | ||
| 129 | }), | ||
| 130 | Layer.succeed(EnvironmentCredentials.EnvironmentCredentials, { | ||
| 131 | create: () => Effect.succeed("t3env_credential_secret"), | ||
| 132 | authenticate: () => Effect.succeedNone, | ||
| 133 | revokeForEnvironmentPublicKey: () => Effect.succeed(false), | ||
| 134 | }), | ||
| 135 | Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { | ||
| 136 | deprovision: () => Effect.void, | ||
| 137 | provision: () => | ||
| 138 | Effect.succeed({ | ||
| 139 | endpoint: { | ||
| 140 | httpBaseUrl: "https://managed.example.test/", | ||
| 141 | wsBaseUrl: "wss://managed.example.test/ws", | ||
| 142 | providerKind: "cloudflare_tunnel", | ||
| 143 | }, | ||
| 144 | runtime: { providerKind: "cloudflare_tunnel", connectorToken: "connector-token" }, | ||
| 145 | }), | ||
| 146 | }), | ||
| 147 | ), | ||
| 148 | ), | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | |||
| 152 | describe("EnvironmentLinker", () => { | ||
| 153 | it.effect("uses verified JWT claims when linking an environment", () => { | ||
| 154 | let persistedEnvironmentId: string | null = null; | ||
| 155 | return Effect.gen(function* () { | ||
| 156 | const { request, payload } = yield* makeRequest; | ||
| 157 | const linker = yield* EnvironmentLinker.EnvironmentLinker; | ||
| 158 | const result = yield* linker.link({ userId: "user_123", request }); | ||
| 159 | expect(result.environmentId).toBe(payload.environmentId); | ||
| 160 | expect(result.environmentCredential).toBe("t3env_credential_secret"); | ||
| 161 | expect(persistedEnvironmentId).toBe(payload.environmentId); | ||
| 162 | }).pipe( | ||
| 163 | Effect.provide( | ||
| 164 | testLayer({ | ||
| 165 | upsert: (input) => | ||
| 166 | Effect.sync(() => { | ||
| 167 | persistedEnvironmentId = input.proof.environmentId; | ||
| 168 | }), | ||
| 169 | }), | ||
| 170 | ), | ||
| 171 | ); | ||
| 172 | }); | ||
| 173 | |||
| 174 | it.effect("rejects a tampered compact proof before persistence", () => { | ||
| 175 | let persisted = false; | ||
| 176 | return Effect.gen(function* () { | ||
| 177 | const { request } = yield* makeRequest; | ||
| 178 | const segments = request.proof.split("."); | ||
| 179 | const signature = segments[2]!; | ||
| 180 | segments[2] = `${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; | ||
| 181 | const tampered = { ...request, proof: segments.join(".") }; | ||
| 182 | const linker = yield* EnvironmentLinker.EnvironmentLinker; | ||
| 183 | const result = yield* Effect.result(linker.link({ userId: "user_123", request: tampered })); | ||
| 184 | expect(Result.isFailure(result)).toBe(true); | ||
| 185 | expect(persisted).toBe(false); | ||
| 186 | }).pipe( | ||
| 187 | Effect.provide( | ||
| 188 | testLayer({ | ||
| 189 | upsert: () => | ||
| 190 | Effect.sync(() => { | ||
| 191 | persisted = true; | ||
| 192 | }), | ||
| 193 | }), | ||
| 194 | ), | ||
| 195 | ); | ||
| 196 | }); | ||
| 197 | |||
| 198 | it.effect("rejects replayed JWT ids", () => | ||
| 199 | Effect.gen(function* () { | ||
| 200 | const { request } = yield* makeRequest; | ||
| 201 | const linker = yield* EnvironmentLinker.EnvironmentLinker; | ||
| 202 | const result = yield* Effect.result(linker.link({ userId: "user_123", request })); | ||
| 203 | expect(Result.isFailure(result)).toBe(true); | ||
| 204 | }).pipe(Effect.provide(testLayer({ consume: () => Effect.succeed(false) }))), | ||
| 205 | ); | ||
| 206 | }); | ||
infra/relay/src/environments/EnvironmentLinker.ts created+264| ... | @@ -0,0 +1,264 @@ | ||
| 1 | import { | ||
| 2 | RelayEnvironmentLinkProofPayload, | ||
| 3 | type RelayEnvironmentLinkProofInvalidReason, | ||
| 4 | type RelayEnvironmentLinkRequest, | ||
| 5 | } from "@t3tools/contracts/relay"; | ||
| 6 | import { | ||
| 7 | decodeRelayJwt, | ||
| 8 | normalizeRelayIssuer, | ||
| 9 | RELAY_LINK_PROOF_TYP, | ||
| 10 | verifyRelayJwt, | ||
| 11 | } from "@t3tools/shared/relayJwt"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Data from "effect/Data"; | ||
| 14 | import * as DateTime from "effect/DateTime"; | ||
| 15 | import * as Effect from "effect/Effect"; | ||
| 16 | import * as Layer from "effect/Layer"; | ||
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | |||
| 19 | import * as DpopProofs from "../auth/DpopProofs.ts"; | ||
| 20 | import * as RelayTokens from "../auth/RelayTokens.ts"; | ||
| 21 | import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; | ||
| 22 | import * as EnvironmentLinks from "./EnvironmentLinks.ts"; | ||
| 23 | import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; | ||
| 24 | import * as RelayConfiguration from "../Config.ts"; | ||
| 25 | |||
| 26 | export class EnvironmentLinkProofExpired extends Data.TaggedError("EnvironmentLinkProofExpired")<{ | ||
| 27 | readonly expiresAt: string; | ||
| 28 | }> {} | ||
| 29 | |||
| 30 | export class EnvironmentLinkProofInvalid extends Data.TaggedError("EnvironmentLinkProofInvalid")<{ | ||
| 31 | readonly environmentId: string; | ||
| 32 | readonly reason: RelayEnvironmentLinkProofInvalidReason; | ||
| 33 | }> {} | ||
| 34 | |||
| 35 | export type EnvironmentLinkError = | ||
| 36 | | EnvironmentLinkProofExpired | ||
| 37 | | EnvironmentLinkProofInvalid | ||
| 38 | | DpopProofs.DpopProofReplayPersistenceError | ||
| 39 | | EnvironmentLinks.EnvironmentLinkUpsertPersistenceError | ||
| 40 | | EnvironmentCredentials.EnvironmentCredentialCreatePersistenceError | ||
| 41 | | ManagedEndpointProvider.ManagedEndpointProviderError; | ||
| 42 | |||
| 43 | export interface EnvironmentLinkerShape { | ||
| 44 | readonly link: (input: { | ||
| 45 | readonly userId: string; | ||
| 46 | readonly request: RelayEnvironmentLinkRequest; | ||
| 47 | }) => Effect.Effect< | ||
| 48 | { | ||
| 49 | readonly environmentId: RelayEnvironmentLinkProofPayload["environmentId"]; | ||
| 50 | readonly endpoint: RelayEnvironmentLinkProofPayload["endpoint"]; | ||
| 51 | readonly endpointRuntime: | ||
| 52 | | ManagedEndpointProvider.ManagedEndpointProvisioningResult["runtime"] | ||
| 53 | | null; | ||
| 54 | readonly environmentCredential: string; | ||
| 55 | }, | ||
| 56 | EnvironmentLinkError | ||
| 57 | >; | ||
| 58 | } | ||
| 59 | |||
| 60 | export class EnvironmentLinker extends Context.Service<EnvironmentLinker, EnvironmentLinkerShape>()( | ||
| 61 | "t3code-relay/environments/EnvironmentLinker", | ||
| 62 | ) {} | ||
| 63 | |||
| 64 | const decodeProof = Schema.decodeUnknownEffect(RelayEnvironmentLinkProofPayload); | ||
| 65 | |||
| 66 | function proofAuthorizesRequestedCapabilities( | ||
| 67 | proof: RelayEnvironmentLinkProofPayload, | ||
| 68 | request: RelayEnvironmentLinkRequest, | ||
| 69 | ): boolean { | ||
| 70 | const scopes = new Set(proof.scopes); | ||
| 71 | if (request.managedTunnelsEnabled && !scopes.has("managed_tunnels")) { | ||
| 72 | return false; | ||
| 73 | } | ||
| 74 | return !( | ||
| 75 | (request.notificationsEnabled || request.liveActivitiesEnabled) && | ||
| 76 | !scopes.has("agent_activity_notifications") | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | |||
| 80 | function isSecureManagedEndpoint(endpoint: RelayEnvironmentLinkProofPayload["endpoint"]): boolean { | ||
| 81 | try { | ||
| 82 | const httpUrl = new URL(endpoint.httpBaseUrl); | ||
| 83 | const wsUrl = new URL(endpoint.wsBaseUrl); | ||
| 84 | return httpUrl.protocol === "https:" && wsUrl.protocol === "wss:"; | ||
| 85 | } catch { | ||
| 86 | return false; | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | function normalizeHostname(hostname: string): string { | ||
| 91 | return hostname | ||
| 92 | .trim() | ||
| 93 | .toLowerCase() | ||
| 94 | .replace(/^\[(.*)\]$/u, "$1"); | ||
| 95 | } | ||
| 96 | |||
| 97 | function isLoopbackManagedTunnelOrigin( | ||
| 98 | origin: RelayEnvironmentLinkProofPayload["origin"], | ||
| 99 | ): boolean { | ||
| 100 | const hostname = normalizeHostname(origin.localHttpHost); | ||
| 101 | return ( | ||
| 102 | (hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost") && | ||
| 103 | Number.isInteger(origin.localHttpPort) && | ||
| 104 | origin.localHttpPort > 0 && | ||
| 105 | origin.localHttpPort <= 65_535 | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | |||
| 109 | const make = Effect.gen(function* () { | ||
| 110 | const links = yield* EnvironmentLinks.EnvironmentLinks; | ||
| 111 | const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; | ||
| 112 | const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 113 | const proofReplay = yield* DpopProofs.DpopProofReplay; | ||
| 114 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 115 | const config = yield* RelayConfiguration.RelayConfiguration; | ||
| 116 | |||
| 117 | return EnvironmentLinker.of({ | ||
| 118 | link: Effect.fn("relay.environment_linker.link")(function* (input) { | ||
| 119 | const now = yield* DateTime.now; | ||
| 120 | const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); | ||
| 121 | const unverified = yield* Effect.try({ | ||
| 122 | try: () => decodeRelayJwt(input.request.proof), | ||
| 123 | catch: () => | ||
| 124 | new EnvironmentLinkProofInvalid({ | ||
| 125 | environmentId: "unknown", | ||
| 126 | reason: "invalid_signature_or_scope", | ||
| 127 | }), | ||
| 128 | }); | ||
| 129 | const decoded = yield* decodeProof(unverified).pipe(Effect.option); | ||
| 130 | if (decoded._tag === "None") { | ||
| 131 | return yield* new EnvironmentLinkProofInvalid({ | ||
| 132 | environmentId: "unknown", | ||
| 133 | reason: "invalid_signature_or_scope", | ||
| 134 | }); | ||
| 135 | } | ||
| 136 | const candidate = decoded.value; | ||
| 137 | yield* Effect.annotateCurrentSpan({ | ||
| 138 | "relay.environment_id": candidate.environmentId, | ||
| 139 | "relay.link.notifications_enabled": input.request.notificationsEnabled, | ||
| 140 | "relay.link.live_activities_enabled": input.request.liveActivitiesEnabled, | ||
| 141 | "relay.link.managed_tunnels_enabled": input.request.managedTunnelsEnabled, | ||
| 142 | }); | ||
| 143 | if (candidate.exp <= nowSeconds) { | ||
| 144 | return yield* new EnvironmentLinkProofExpired({ | ||
| 145 | expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)), | ||
| 146 | }); | ||
| 147 | } | ||
| 148 | const issuer = `t3-env:${candidate.environmentId}`; | ||
| 149 | const relayIssuer = normalizeRelayIssuer(config.relayIssuer); | ||
| 150 | const verified = yield* verifyRelayJwt({ | ||
| 151 | publicKey: candidate.environmentPublicKey, | ||
| 152 | token: input.request.proof, | ||
| 153 | typ: RELAY_LINK_PROOF_TYP, | ||
| 154 | issuer, | ||
| 155 | audience: relayIssuer, | ||
| 156 | nowEpochSeconds: nowSeconds, | ||
| 157 | }).pipe( | ||
| 158 | Effect.flatMap(decodeProof), | ||
| 159 | Effect.mapError( | ||
| 160 | () => | ||
| 161 | new EnvironmentLinkProofInvalid({ | ||
| 162 | environmentId: candidate.environmentId, | ||
| 163 | reason: "invalid_signature_or_scope", | ||
| 164 | }), | ||
| 165 | ), | ||
| 166 | ); | ||
| 167 | if ( | ||
| 168 | verified.sub !== verified.environmentId || | ||
| 169 | !proofAuthorizesRequestedCapabilities(verified, input.request) | ||
| 170 | ) { | ||
| 171 | return yield* new EnvironmentLinkProofInvalid({ | ||
| 172 | environmentId: candidate.environmentId, | ||
| 173 | reason: "invalid_signature_or_scope", | ||
| 174 | }); | ||
| 175 | } | ||
| 176 | if (verified.descriptor.environmentId !== verified.environmentId) { | ||
| 177 | return yield* new EnvironmentLinkProofInvalid({ | ||
| 178 | environmentId: verified.environmentId, | ||
| 179 | reason: "descriptor_mismatch", | ||
| 180 | }); | ||
| 181 | } | ||
| 182 | const challenge = yield* relayTokens.verifyLinkChallenge({ | ||
| 183 | token: verified.challenge, | ||
| 184 | userId: input.userId, | ||
| 185 | request: { | ||
| 186 | notificationsEnabled: input.request.notificationsEnabled, | ||
| 187 | liveActivitiesEnabled: input.request.liveActivitiesEnabled, | ||
| 188 | managedTunnelsEnabled: input.request.managedTunnelsEnabled, | ||
| 189 | }, | ||
| 190 | nowEpochSeconds: nowSeconds, | ||
| 191 | }); | ||
| 192 | if (challenge === null) { | ||
| 193 | return yield* new EnvironmentLinkProofInvalid({ | ||
| 194 | environmentId: verified.environmentId, | ||
| 195 | reason: "challenge_invalid", | ||
| 196 | }); | ||
| 197 | } | ||
| 198 | const expiresAt = DateTime.make(verified.exp * 1_000); | ||
| 199 | if (expiresAt._tag === "None") { | ||
| 200 | return yield* new EnvironmentLinkProofInvalid({ | ||
| 201 | environmentId: verified.environmentId, | ||
| 202 | reason: "invalid_signature_or_scope", | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | const consumedNonce = yield* proofReplay.consume({ | ||
| 206 | thumbprint: verified.environmentPublicKey, | ||
| 207 | jti: verified.jti, | ||
| 208 | iat: verified.iat, | ||
| 209 | expiresAt: expiresAt.value, | ||
| 210 | }); | ||
| 211 | if (!consumedNonce) { | ||
| 212 | return yield* new EnvironmentLinkProofInvalid({ | ||
| 213 | environmentId: verified.environmentId, | ||
| 214 | reason: "replayed_nonce", | ||
| 215 | }); | ||
| 216 | } | ||
| 217 | const consumedChallenge = yield* proofReplay.consume({ | ||
| 218 | thumbprint: "relay-environment-link-challenge", | ||
| 219 | jti: challenge.jti, | ||
| 220 | iat: challenge.iat, | ||
| 221 | expiresAt: expiresAt.value, | ||
| 222 | }); | ||
| 223 | if (!consumedChallenge) { | ||
| 224 | return yield* new EnvironmentLinkProofInvalid({ | ||
| 225 | environmentId: verified.environmentId, | ||
| 226 | reason: "challenge_invalid", | ||
| 227 | }); | ||
| 228 | } | ||
| 229 | if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) { | ||
| 230 | return yield* new EnvironmentLinkProofInvalid({ | ||
| 231 | environmentId: verified.environmentId, | ||
| 232 | reason: "origin_not_allowed", | ||
| 233 | }); | ||
| 234 | } | ||
| 235 | const provisioned = input.request.managedTunnelsEnabled | ||
| 236 | ? yield* managedEndpointProvider.provision({ | ||
| 237 | userId: input.userId, | ||
| 238 | environmentId: verified.environmentId, | ||
| 239 | origin: verified.origin, | ||
| 240 | }) | ||
| 241 | : null; | ||
| 242 | const endpoint = provisioned?.endpoint ?? verified.endpoint; | ||
| 243 | if (!isSecureManagedEndpoint(endpoint)) { | ||
| 244 | return yield* new EnvironmentLinkProofInvalid({ | ||
| 245 | environmentId: verified.environmentId, | ||
| 246 | reason: "endpoint_not_secure", | ||
| 247 | }); | ||
| 248 | } | ||
| 249 | yield* links.upsert({ ...input, proof: verified, endpoint }); | ||
| 250 | const environmentCredential = yield* credentials.create({ | ||
| 251 | environmentId: verified.environmentId, | ||
| 252 | environmentPublicKey: verified.environmentPublicKey, | ||
| 253 | }); | ||
| 254 | return { | ||
| 255 | environmentId: verified.environmentId, | ||
| 256 | endpoint, | ||
| 257 | endpointRuntime: provisioned?.runtime ?? null, | ||
| 258 | environmentCredential, | ||
| 259 | }; | ||
| 260 | }), | ||
| 261 | }); | ||
| 262 | }); | ||
| 263 | |||
| 264 | export const layer = Layer.effect(EnvironmentLinker, make); | ||
infra/relay/src/environments/EnvironmentLinks.test.ts created+91| ... | @@ -0,0 +1,91 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import { PgDialect } from "drizzle-orm/pg-core"; | ||
| 5 | |||
| 6 | import { RelayDb, type RelayDatabase } from "../db.ts"; | ||
| 7 | import { relayEnvironmentLinks } from "../persistence/schema.ts"; | ||
| 8 | import { EnvironmentLinks, layer } from "./EnvironmentLinks.ts"; | ||
| 9 | |||
| 10 | describe("EnvironmentLinks", () => { | ||
| 11 | it.effect("selects users when either notifications or Live Activities are enabled", () => { | ||
| 12 | const whereConditions: Array<unknown> = []; | ||
| 13 | const fakeDb = { | ||
| 14 | select: (selection: unknown) => { | ||
| 15 | expect(selection).toBeDefined(); | ||
| 16 | return { | ||
| 17 | from: (table: unknown) => { | ||
| 18 | expect(table).toBe(relayEnvironmentLinks); | ||
| 19 | return { | ||
| 20 | where: (condition: unknown) => { | ||
| 21 | whereConditions.push(condition); | ||
| 22 | return Effect.succeed([]); | ||
| 23 | }, | ||
| 24 | }; | ||
| 25 | }, | ||
| 26 | }; | ||
| 27 | }, | ||
| 28 | } as unknown as RelayDatabase; | ||
| 29 | |||
| 30 | return Effect.gen(function* () { | ||
| 31 | const links = yield* EnvironmentLinks; | ||
| 32 | expect(yield* links.listUsersForEnvironment({ environmentId: "env-1" })).toEqual([]); | ||
| 33 | expect(whereConditions).toHaveLength(1); | ||
| 34 | |||
| 35 | const query = new PgDialect().sqlToQuery(whereConditions[0] as never); | ||
| 36 | expect(query.sql).toContain('"relay_environment_links"."environment_id" = $1'); | ||
| 37 | expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); | ||
| 38 | expect(query.sql).toContain('"relay_environment_links"."notifications_enabled" = $2'); | ||
| 39 | expect(query.sql).toContain('"relay_environment_links"."live_activities_enabled" = $3'); | ||
| 40 | expect(query.sql).toContain(" or "); | ||
| 41 | expect(query.params).toEqual(["env-1", true, true]); | ||
| 42 | }).pipe(Effect.provide(layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it.effect("revokes only the active link owned by the requesting user", () => { | ||
| 46 | const updateValues: Array<Record<string, unknown>> = []; | ||
| 47 | const whereConditions: Array<unknown> = []; | ||
| 48 | const fakeDb = { | ||
| 49 | update: (table: unknown) => { | ||
| 50 | expect(table).toBe(relayEnvironmentLinks); | ||
| 51 | return { | ||
| 52 | set: (values: Record<string, unknown>) => { | ||
| 53 | updateValues.push(values); | ||
| 54 | return { | ||
| 55 | where: (condition: unknown) => { | ||
| 56 | whereConditions.push(condition); | ||
| 57 | return { | ||
| 58 | returning: (selection: unknown) => { | ||
| 59 | expect(selection).toBeDefined(); | ||
| 60 | return Effect.succeed([{ environmentId: "env-1" }]); | ||
| 61 | }, | ||
| 62 | }; | ||
| 63 | }, | ||
| 64 | }; | ||
| 65 | }, | ||
| 66 | }; | ||
| 67 | }, | ||
| 68 | } as unknown as RelayDatabase; | ||
| 69 | |||
| 70 | return Effect.gen(function* () { | ||
| 71 | const links = yield* EnvironmentLinks; | ||
| 72 | const revoked = yield* links.revokeForUser({ | ||
| 73 | userId: "user-1", | ||
| 74 | environmentId: "env-1", | ||
| 75 | }); | ||
| 76 | |||
| 77 | expect(revoked).toBe(true); | ||
| 78 | expect(updateValues).toHaveLength(1); | ||
| 79 | expect(updateValues[0]?.revokedAt).toEqual(updateValues[0]?.updatedAt); | ||
| 80 | expect(typeof updateValues[0]?.revokedAt).toBe("string"); | ||
| 81 | expect(whereConditions).toHaveLength(1); | ||
| 82 | |||
| 83 | const dialect = new PgDialect(); | ||
| 84 | const query = dialect.sqlToQuery(whereConditions[0] as never); | ||
| 85 | expect(query.sql).toContain('"relay_environment_links"."user_id" = $1'); | ||
| 86 | expect(query.sql).toContain('"relay_environment_links"."environment_id" = $2'); | ||
| 87 | expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); | ||
| 88 | expect(query.params).toEqual(["user-1", "env-1"]); | ||
| 89 | }).pipe(Effect.provide(layer.pipe(Layer.provide(Layer.succeed(RelayDb, fakeDb))))); | ||
| 90 | }); | ||
| 91 | }); | ||
infra/relay/src/environments/EnvironmentLinks.ts created+345| ... | @@ -0,0 +1,345 @@ | ||
| 1 | import type { | ||
| 2 | RelayClientEnvironmentRecord, | ||
| 3 | RelayEnvironmentLinkProofPayload, | ||
| 4 | RelayEnvironmentLinkRequest, | ||
| 5 | RelayManagedEndpoint, | ||
| 6 | } from "@t3tools/contracts/relay"; | ||
| 7 | import * as Context from "effect/Context"; | ||
| 8 | import * as Data from "effect/Data"; | ||
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import { and, eq, isNull, or } from "drizzle-orm"; | ||
| 13 | |||
| 14 | import { RelayDb } from "../db.ts"; | ||
| 15 | import { relayEnvironmentLinks } from "../persistence/schema.ts"; | ||
| 16 | |||
| 17 | export interface RelayLinkedEnvironmentRecord extends RelayClientEnvironmentRecord { | ||
| 18 | readonly environmentPublicKey: string; | ||
| 19 | } | ||
| 20 | |||
| 21 | export interface AgentAwarenessDeliveryUserRecord { | ||
| 22 | readonly userId: string; | ||
| 23 | readonly notificationsEnabled: boolean; | ||
| 24 | readonly liveActivitiesEnabled: boolean; | ||
| 25 | } | ||
| 26 | |||
| 27 | export class EnvironmentLinkUpsertPersistenceError extends Data.TaggedError( | ||
| 28 | "EnvironmentLinkUpsertPersistenceError", | ||
| 29 | )<{ | ||
| 30 | readonly cause: unknown; | ||
| 31 | }> {} | ||
| 32 | |||
| 33 | export class EnvironmentLinkUserListPersistenceError extends Data.TaggedError( | ||
| 34 | "EnvironmentLinkUserListPersistenceError", | ||
| 35 | )<{ | ||
| 36 | readonly cause: unknown; | ||
| 37 | }> {} | ||
| 38 | |||
| 39 | export class EnvironmentPublicKeyListPersistenceError extends Data.TaggedError( | ||
| 40 | "EnvironmentPublicKeyListPersistenceError", | ||
| 41 | )<{ | ||
| 42 | readonly cause: unknown; | ||
| 43 | }> {} | ||
| 44 | |||
| 45 | export class EnvironmentLinkListPersistenceError extends Data.TaggedError( | ||
| 46 | "EnvironmentLinkListPersistenceError", | ||
| 47 | )<{ | ||
| 48 | readonly cause: unknown; | ||
| 49 | }> {} | ||
| 50 | |||
| 51 | export class EnvironmentLinkLookupPersistenceError extends Data.TaggedError( | ||
| 52 | "EnvironmentLinkLookupPersistenceError", | ||
| 53 | )<{ | ||
| 54 | readonly cause: unknown; | ||
| 55 | }> {} | ||
| 56 | |||
| 57 | export class EnvironmentLinkRevokePersistenceError extends Data.TaggedError( | ||
| 58 | "EnvironmentLinkRevokePersistenceError", | ||
| 59 | )<{ | ||
| 60 | readonly cause: unknown; | ||
| 61 | }> {} | ||
| 62 | |||
| 63 | export interface EnvironmentLinksShape { | ||
| 64 | readonly upsert: (input: { | ||
| 65 | readonly userId: string; | ||
| 66 | readonly request: RelayEnvironmentLinkRequest; | ||
| 67 | readonly proof: RelayEnvironmentLinkProofPayload; | ||
| 68 | readonly endpoint: RelayManagedEndpoint; | ||
| 69 | }) => Effect.Effect<void, EnvironmentLinkUpsertPersistenceError>; | ||
| 70 | readonly listUsersForEnvironment: (input: { | ||
| 71 | readonly environmentId: string; | ||
| 72 | }) => Effect.Effect<ReadonlyArray<string>, EnvironmentLinkUserListPersistenceError>; | ||
| 73 | readonly listDeliveryUsersForEnvironment: (input: { | ||
| 74 | readonly environmentId: string; | ||
| 75 | readonly environmentPublicKey: string; | ||
| 76 | }) => Effect.Effect< | ||
| 77 | ReadonlyArray<AgentAwarenessDeliveryUserRecord>, | ||
| 78 | EnvironmentLinkUserListPersistenceError | ||
| 79 | >; | ||
| 80 | readonly listPublicKeysForEnvironment: (input: { | ||
| 81 | readonly environmentId: string; | ||
| 82 | }) => Effect.Effect<ReadonlyArray<string>, EnvironmentPublicKeyListPersistenceError>; | ||
| 83 | readonly listForUser: (input: { | ||
| 84 | readonly userId: string; | ||
| 85 | }) => Effect.Effect< | ||
| 86 | ReadonlyArray<RelayClientEnvironmentRecord>, | ||
| 87 | EnvironmentLinkListPersistenceError | ||
| 88 | >; | ||
| 89 | readonly getForUser: (input: { | ||
| 90 | readonly userId: string; | ||
| 91 | readonly environmentId: string; | ||
| 92 | }) => Effect.Effect<RelayLinkedEnvironmentRecord | null, EnvironmentLinkLookupPersistenceError>; | ||
| 93 | readonly revokeForUser: (input: { | ||
| 94 | readonly userId: string; | ||
| 95 | readonly environmentId: string; | ||
| 96 | }) => Effect.Effect<boolean, EnvironmentLinkRevokePersistenceError>; | ||
| 97 | } | ||
| 98 | |||
| 99 | export class EnvironmentLinks extends Context.Service<EnvironmentLinks, EnvironmentLinksShape>()( | ||
| 100 | "t3code-relay/environments/EnvironmentLinks", | ||
| 101 | ) {} | ||
| 102 | |||
| 103 | function agentAwarenessDeliveryUserCondition(environmentId: string) { | ||
| 104 | return and( | ||
| 105 | eq(relayEnvironmentLinks.environmentId, environmentId), | ||
| 106 | isNull(relayEnvironmentLinks.revokedAt), | ||
| 107 | or( | ||
| 108 | eq(relayEnvironmentLinks.notificationsEnabled, true), | ||
| 109 | eq(relayEnvironmentLinks.liveActivitiesEnabled, true), | ||
| 110 | ), | ||
| 111 | ); | ||
| 112 | } | ||
| 113 | |||
| 114 | function agentAwarenessDeliveryUserKeyCondition(input: { | ||
| 115 | readonly environmentId: string; | ||
| 116 | readonly environmentPublicKey: string; | ||
| 117 | }) { | ||
| 118 | return and( | ||
| 119 | agentAwarenessDeliveryUserCondition(input.environmentId), | ||
| 120 | eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), | ||
| 121 | ); | ||
| 122 | } | ||
| 123 | |||
| 124 | const make = Effect.gen(function* () { | ||
| 125 | const db = yield* RelayDb; | ||
| 126 | |||
| 127 | return EnvironmentLinks.of({ | ||
| 128 | upsert: Effect.fn("relay.environment_links.upsert")( | ||
| 129 | function* (input) { | ||
| 130 | yield* Effect.annotateCurrentSpan({ | ||
| 131 | "relay.environment_id": input.proof.environmentId, | ||
| 132 | }); | ||
| 133 | const now = DateTime.formatIso(yield* DateTime.now); | ||
| 134 | const { request, proof } = input; | ||
| 135 | const environmentId = proof.environmentId; | ||
| 136 | const { endpoint } = input; | ||
| 137 | yield* db | ||
| 138 | .insert(relayEnvironmentLinks) | ||
| 139 | .values({ | ||
| 140 | userId: input.userId, | ||
| 141 | environmentId, | ||
| 142 | environmentLabel: proof.descriptor.label, | ||
| 143 | environmentPublicKey: proof.environmentPublicKey, | ||
| 144 | endpointHttpBaseUrl: endpoint.httpBaseUrl, | ||
| 145 | endpointWsBaseUrl: endpoint.wsBaseUrl, | ||
| 146 | endpointProviderKind: endpoint.providerKind, | ||
| 147 | notificationsEnabled: request.notificationsEnabled, | ||
| 148 | liveActivitiesEnabled: request.liveActivitiesEnabled, | ||
| 149 | managedTunnelsEnabled: request.managedTunnelsEnabled, | ||
| 150 | createdByDeviceId: request.deviceId ?? null, | ||
| 151 | revokedAt: null, | ||
| 152 | createdAt: now, | ||
| 153 | updatedAt: now, | ||
| 154 | }) | ||
| 155 | .onConflictDoUpdate({ | ||
| 156 | target: [relayEnvironmentLinks.userId, relayEnvironmentLinks.environmentId], | ||
| 157 | set: { | ||
| 158 | environmentPublicKey: proof.environmentPublicKey, | ||
| 159 | environmentLabel: proof.descriptor.label, | ||
| 160 | endpointHttpBaseUrl: endpoint.httpBaseUrl, | ||
| 161 | endpointWsBaseUrl: endpoint.wsBaseUrl, | ||
| 162 | endpointProviderKind: endpoint.providerKind, | ||
| 163 | notificationsEnabled: request.notificationsEnabled, | ||
| 164 | liveActivitiesEnabled: request.liveActivitiesEnabled, | ||
| 165 | managedTunnelsEnabled: request.managedTunnelsEnabled, | ||
| 166 | createdByDeviceId: request.deviceId ?? null, | ||
| 167 | revokedAt: null, | ||
| 168 | updatedAt: now, | ||
| 169 | }, | ||
| 170 | }); | ||
| 171 | }, | ||
| 172 | Effect.mapError((cause) => new EnvironmentLinkUpsertPersistenceError({ cause })), | ||
| 173 | ), | ||
| 174 | |||
| 175 | listUsersForEnvironment: Effect.fn("relay.environment_links.list_users_for_environment")( | ||
| 176 | function* (input) { | ||
| 177 | yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); | ||
| 178 | return yield* db | ||
| 179 | .select({ userId: relayEnvironmentLinks.userId }) | ||
| 180 | .from(relayEnvironmentLinks) | ||
| 181 | .where(agentAwarenessDeliveryUserCondition(input.environmentId)) | ||
| 182 | .pipe( | ||
| 183 | Effect.map((rows) => rows.map((row) => row.userId)), | ||
| 184 | Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })), | ||
| 185 | ); | ||
| 186 | }, | ||
| 187 | ), | ||
| 188 | |||
| 189 | listDeliveryUsersForEnvironment: Effect.fn( | ||
| 190 | "relay.environment_links.list_delivery_users_for_environment", | ||
| 191 | )(function* (input) { | ||
| 192 | yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); | ||
| 193 | return yield* db | ||
| 194 | .select({ | ||
| 195 | userId: relayEnvironmentLinks.userId, | ||
| 196 | notificationsEnabled: relayEnvironmentLinks.notificationsEnabled, | ||
| 197 | liveActivitiesEnabled: relayEnvironmentLinks.liveActivitiesEnabled, | ||
| 198 | }) | ||
| 199 | .from(relayEnvironmentLinks) | ||
| 200 | .where(agentAwarenessDeliveryUserKeyCondition(input)) | ||
| 201 | .pipe( | ||
| 202 | Effect.map((rows) => | ||
| 203 | rows.map((row) => ({ | ||
| 204 | userId: row.userId, | ||
| 205 | notificationsEnabled: row.notificationsEnabled, | ||
| 206 | liveActivitiesEnabled: row.liveActivitiesEnabled, | ||
| 207 | })), | ||
| 208 | ), | ||
| 209 | Effect.mapError((cause) => new EnvironmentLinkUserListPersistenceError({ cause })), | ||
| 210 | ); | ||
| 211 | }), | ||
| 212 | |||
| 213 | listPublicKeysForEnvironment: Effect.fn( | ||
| 214 | "relay.environment_links.list_public_keys_for_environment", | ||
| 215 | )(function* (input) { | ||
| 216 | yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId }); | ||
| 217 | return yield* db | ||
| 218 | .select({ environmentPublicKey: relayEnvironmentLinks.environmentPublicKey }) | ||
| 219 | .from(relayEnvironmentLinks) | ||
| 220 | .where( | ||
| 221 | and( | ||
| 222 | eq(relayEnvironmentLinks.environmentId, input.environmentId), | ||
| 223 | isNull(relayEnvironmentLinks.revokedAt), | ||
| 224 | ), | ||
| 225 | ) | ||
| 226 | .pipe( | ||
| 227 | Effect.map((rows) => [ | ||
| 228 | ...new Set(rows.map((row) => row.environmentPublicKey).filter((key) => key.length > 0)), | ||
| 229 | ]), | ||
| 230 | Effect.mapError((cause) => new EnvironmentPublicKeyListPersistenceError({ cause })), | ||
| 231 | ); | ||
| 232 | }), | ||
| 233 | |||
| 234 | listForUser: Effect.fn("relay.environment_links.list_for_user")(function* (input) { | ||
| 235 | return yield* db | ||
| 236 | .select({ | ||
| 237 | environmentId: relayEnvironmentLinks.environmentId, | ||
| 238 | environmentLabel: relayEnvironmentLinks.environmentLabel, | ||
| 239 | endpointHttpBaseUrl: relayEnvironmentLinks.endpointHttpBaseUrl, | ||
| 240 | endpointWsBaseUrl: relayEnvironmentLinks.endpointWsBaseUrl, | ||
| 241 | endpointProviderKind: relayEnvironmentLinks.endpointProviderKind, | ||
| 242 | createdAt: relayEnvironmentLinks.createdAt, | ||
| 243 | }) | ||
| 244 | .from(relayEnvironmentLinks) | ||
| 245 | .where( | ||
| 246 | and( | ||
| 247 | eq(relayEnvironmentLinks.userId, input.userId), | ||
| 248 | isNull(relayEnvironmentLinks.revokedAt), | ||
| 249 | ), | ||
| 250 | ) | ||
| 251 | .pipe( | ||
| 252 | Effect.map((rows) => | ||
| 253 | rows.map((row) => ({ | ||
| 254 | environmentId: row.environmentId as RelayClientEnvironmentRecord["environmentId"], | ||
| 255 | label: | ||
| 256 | row.environmentLabel.trim().length > 0 ? row.environmentLabel : row.environmentId, | ||
| 257 | endpoint: { | ||
| 258 | httpBaseUrl: row.endpointHttpBaseUrl, | ||
| 259 | wsBaseUrl: row.endpointWsBaseUrl, | ||
| 260 | providerKind: | ||
| 261 | row.endpointProviderKind as RelayClientEnvironmentRecord["endpoint"]["providerKind"], | ||
| 262 | }, | ||
| 263 | linkedAt: row.createdAt, | ||
| 264 | })), | ||
| 265 | ), | ||
| 266 | Effect.mapError((cause) => new EnvironmentLinkListPersistenceError({ cause })), | ||
| 267 | ); | ||
| 268 | }), | ||
| 269 | |||
| 270 | getForUser: Effect.fn("relay.environment_links.get_for_user")(function* (input) { | ||
| 271 | yield* Effect.annotateCurrentSpan({ | ||
| 272 | "relay.environment_id": input.environmentId, | ||
| 273 | }); | ||
| 274 | return yield* db | ||
| 275 | .select({ | ||
| 276 | environmentId: relayEnvironmentLinks.environmentId, | ||
| 277 | environmentLabel: relayEnvironmentLinks.environmentLabel, | ||
| 278 | environmentPublicKey: relayEnvironmentLinks.environmentPublicKey, | ||
| 279 | endpointHttpBaseUrl: relayEnvironmentLinks.endpointHttpBaseUrl, | ||
| 280 | endpointWsBaseUrl: relayEnvironmentLinks.endpointWsBaseUrl, | ||
| 281 | endpointProviderKind: relayEnvironmentLinks.endpointProviderKind, | ||
| 282 | createdAt: relayEnvironmentLinks.createdAt, | ||
| 283 | }) | ||
| 284 | .from(relayEnvironmentLinks) | ||
| 285 | .where( | ||
| 286 | and( | ||
| 287 | eq(relayEnvironmentLinks.userId, input.userId), | ||
| 288 | eq(relayEnvironmentLinks.environmentId, input.environmentId), | ||
| 289 | isNull(relayEnvironmentLinks.revokedAt), | ||
| 290 | ), | ||
| 291 | ) | ||
| 292 | .limit(1) | ||
| 293 | .pipe( | ||
| 294 | Effect.map((rows) => { | ||
| 295 | const row = rows[0]; | ||
| 296 | return row | ||
| 297 | ? { | ||
| 298 | environmentId: row.environmentId as RelayClientEnvironmentRecord["environmentId"], | ||
| 299 | label: | ||
| 300 | row.environmentLabel.trim().length > 0 | ||
| 301 | ? row.environmentLabel | ||
| 302 | : row.environmentId, | ||
| 303 | endpoint: { | ||
| 304 | httpBaseUrl: row.endpointHttpBaseUrl, | ||
| 305 | wsBaseUrl: row.endpointWsBaseUrl, | ||
| 306 | providerKind: | ||
| 307 | row.endpointProviderKind as RelayClientEnvironmentRecord["endpoint"]["providerKind"], | ||
| 308 | }, | ||
| 309 | environmentPublicKey: row.environmentPublicKey, | ||
| 310 | linkedAt: row.createdAt, | ||
| 311 | } | ||
| 312 | : null; | ||
| 313 | }), | ||
| 314 | Effect.mapError((cause) => new EnvironmentLinkLookupPersistenceError({ cause })), | ||
| 315 | ); | ||
| 316 | }), | ||
| 317 | |||
| 318 | revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")( | ||
| 319 | function* (input) { | ||
| 320 | yield* Effect.annotateCurrentSpan({ | ||
| 321 | "relay.environment_id": input.environmentId, | ||
| 322 | }); | ||
| 323 | const revokedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 324 | const rows = yield* db | ||
| 325 | .update(relayEnvironmentLinks) | ||
| 326 | .set({ | ||
| 327 | revokedAt, | ||
| 328 | updatedAt: revokedAt, | ||
| 329 | }) | ||
| 330 | .where( | ||
| 331 | and( | ||
| 332 | eq(relayEnvironmentLinks.userId, input.userId), | ||
| 333 | eq(relayEnvironmentLinks.environmentId, input.environmentId), | ||
| 334 | isNull(relayEnvironmentLinks.revokedAt), | ||
| 335 | ), | ||
| 336 | ) | ||
| 337 | .returning({ environmentId: relayEnvironmentLinks.environmentId }); | ||
| 338 | return rows.length > 0; | ||
| 339 | }, | ||
| 340 | Effect.mapError((cause) => new EnvironmentLinkRevokePersistenceError({ cause })), | ||
| 341 | ), | ||
| 342 | }); | ||
| 343 | }); | ||
| 344 | |||
| 345 | export const layer = Layer.effect(EnvironmentLinks, make); | ||
infra/relay/src/environments/EnvironmentPublishSignatures.test.ts created+166| ... | @@ -0,0 +1,166 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import type { | ||
| 4 | RelayAgentActivityPublishProofPayload, | ||
| 5 | RelayAgentActivityPublishRequest, | ||
| 6 | RelayAgentActivityState, | ||
| 7 | } from "@t3tools/contracts/relay"; | ||
| 8 | import { RELAY_ACTIVITY_PUBLISH_TYP } from "@t3tools/shared/relayJwt"; | ||
| 9 | import { stableStringify } from "@t3tools/shared/relaySigning"; | ||
| 10 | import { describe, expect, it } from "@effect/vitest"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import * as Redacted from "effect/Redacted"; | ||
| 15 | import * as Result from "effect/Result"; | ||
| 16 | |||
| 17 | import * as DpopProofs from "../auth/DpopProofs.ts"; | ||
| 18 | import * as RelayConfiguration from "../Config.ts"; | ||
| 19 | import * as EnvironmentPublishSignatures from "./EnvironmentPublishSignatures.ts"; | ||
| 20 | |||
| 21 | const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { | ||
| 22 | privateKeyEncoding: { format: "pem", type: "pkcs8" }, | ||
| 23 | publicKeyEncoding: { format: "pem", type: "spki" }, | ||
| 24 | }); | ||
| 25 | const config = RelayConfiguration.RelayConfiguration.of({ | ||
| 26 | relayIssuer: "https://relay.example.test", | ||
| 27 | apns: { | ||
| 28 | environment: "sandbox", | ||
| 29 | teamId: "team-id", | ||
| 30 | keyId: "key-id", | ||
| 31 | privateKey: Redacted.make("private-key"), | ||
| 32 | bundleId: "com.t3tools.t3code.dev", | ||
| 33 | }, | ||
| 34 | apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), | ||
| 35 | clerkSecretKey: Redacted.make("clerk-secret"), | ||
| 36 | clerkPublishableKey: "pk_test_test", | ||
| 37 | clerkJwtAudience: "t3-code-relay", | ||
| 38 | cloudMintPrivateKey: Redacted.make(keyPair.privateKey), | ||
| 39 | cloudMintPublicKey: keyPair.publicKey, | ||
| 40 | managedEndpointBaseDomain: undefined, | ||
| 41 | managedEndpointNamespace: undefined, | ||
| 42 | }); | ||
| 43 | const state: RelayAgentActivityState = { | ||
| 44 | environmentId: "env" as RelayAgentActivityState["environmentId"], | ||
| 45 | threadId: "thread" as RelayAgentActivityState["threadId"], | ||
| 46 | projectTitle: "Project", | ||
| 47 | threadTitle: "Thread", | ||
| 48 | modelTitle: "gpt-5.4", | ||
| 49 | phase: "running", | ||
| 50 | headline: "Running", | ||
| 51 | updatedAt: "2026-05-25T00:00:00.000Z", | ||
| 52 | deepLink: "/threads/env/thread", | ||
| 53 | }; | ||
| 54 | |||
| 55 | function signTestJwt(payload: object, privateKey: string): string { | ||
| 56 | const header = Buffer.from( | ||
| 57 | JSON.stringify({ alg: "EdDSA", typ: RELAY_ACTIVITY_PUBLISH_TYP }), | ||
| 58 | ).toString("base64url"); | ||
| 59 | const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); | ||
| 60 | const signingInput = `${header}.${encodedPayload}`; | ||
| 61 | return `${signingInput}.${NodeCrypto.sign(null, Buffer.from(signingInput), privateKey).toString("base64url")}`; | ||
| 62 | } | ||
| 63 | |||
| 64 | const freshRequest = Effect.gen(function* () { | ||
| 65 | const now = yield* DateTime.now; | ||
| 66 | const payload = { | ||
| 67 | iss: "t3-env:env", | ||
| 68 | aud: "https://relay.example.test", | ||
| 69 | sub: "env", | ||
| 70 | jti: "publish-jti", | ||
| 71 | iat: Math.floor(now.epochMilliseconds / 1_000), | ||
| 72 | exp: Math.floor(DateTime.add(now, { minutes: 5 }).epochMilliseconds / 1_000), | ||
| 73 | environmentId: state.environmentId, | ||
| 74 | threadId: state.threadId, | ||
| 75 | state, | ||
| 76 | } satisfies RelayAgentActivityPublishProofPayload; | ||
| 77 | return { | ||
| 78 | state, | ||
| 79 | proof: signTestJwt(payload, keyPair.privateKey), | ||
| 80 | } satisfies RelayAgentActivityPublishRequest; | ||
| 81 | }); | ||
| 82 | |||
| 83 | function layer(replay?: Partial<DpopProofs.DpopProofReplayShape>) { | ||
| 84 | return EnvironmentPublishSignatures.layer.pipe( | ||
| 85 | Layer.provide( | ||
| 86 | Layer.merge( | ||
| 87 | Layer.succeed(RelayConfiguration.RelayConfiguration, config), | ||
| 88 | Layer.succeed(DpopProofs.DpopProofReplay, { | ||
| 89 | verifyAndConsume: | ||
| 90 | replay?.verifyAndConsume ?? (() => Effect.die("unexpected DPoP proof verification")), | ||
| 91 | consume: replay?.consume ?? (() => Effect.succeed(true)), | ||
| 92 | pruneExpired: replay?.pruneExpired ?? Effect.void, | ||
| 93 | }), | ||
| 94 | ), | ||
| 95 | ), | ||
| 96 | Layer.provideMerge(NodeServices.layer), | ||
| 97 | ); | ||
| 98 | } | ||
| 99 | |||
| 100 | describe("EnvironmentPublishSignatures", () => { | ||
| 101 | it.effect("verifies activity JWTs and scopes replay storage to the environment key", () => { | ||
| 102 | let replayThumbprint: string | null = null; | ||
| 103 | return Effect.gen(function* () { | ||
| 104 | const request = yield* freshRequest; | ||
| 105 | const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; | ||
| 106 | yield* signatures.verify({ | ||
| 107 | environmentId: state.environmentId, | ||
| 108 | environmentPublicKey: keyPair.publicKey, | ||
| 109 | threadId: state.threadId, | ||
| 110 | request, | ||
| 111 | }); | ||
| 112 | expect(replayThumbprint).toBe( | ||
| 113 | `env-publish:${NodeCrypto.createHash("sha256") | ||
| 114 | .update( | ||
| 115 | stableStringify({ | ||
| 116 | environmentId: state.environmentId, | ||
| 117 | environmentPublicKey: keyPair.publicKey, | ||
| 118 | }), | ||
| 119 | ) | ||
| 120 | .digest("base64url")}`, | ||
| 121 | ); | ||
| 122 | }).pipe( | ||
| 123 | Effect.provide( | ||
| 124 | layer({ | ||
| 125 | consume: (input) => | ||
| 126 | Effect.sync(() => { | ||
| 127 | replayThumbprint = input.thumbprint; | ||
| 128 | return true; | ||
| 129 | }), | ||
| 130 | }), | ||
| 131 | ), | ||
| 132 | ); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it.effect("rejects top-level state tampering", () => | ||
| 136 | Effect.gen(function* () { | ||
| 137 | const request = yield* freshRequest; | ||
| 138 | const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; | ||
| 139 | const result = yield* Effect.result( | ||
| 140 | signatures.verify({ | ||
| 141 | environmentId: state.environmentId, | ||
| 142 | environmentPublicKey: keyPair.publicKey, | ||
| 143 | threadId: state.threadId, | ||
| 144 | request: { ...request, state: { ...state, headline: "Tampered" } }, | ||
| 145 | }), | ||
| 146 | ); | ||
| 147 | expect(Result.isFailure(result)).toBe(true); | ||
| 148 | }).pipe(Effect.provide(layer())), | ||
| 149 | ); | ||
| 150 | |||
| 151 | it.effect("rejects replayed activity JWT ids", () => | ||
| 152 | Effect.gen(function* () { | ||
| 153 | const request = yield* freshRequest; | ||
| 154 | const signatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; | ||
| 155 | const result = yield* Effect.result( | ||
| 156 | signatures.verify({ | ||
| 157 | environmentId: state.environmentId, | ||
| 158 | environmentPublicKey: keyPair.publicKey, | ||
| 159 | threadId: state.threadId, | ||
| 160 | request, | ||
| 161 | }), | ||
| 162 | ); | ||
| 163 | expect(Result.isFailure(result)).toBe(true); | ||
| 164 | }).pipe(Effect.provide(layer({ consume: () => Effect.succeed(false) }))), | ||
| 165 | ); | ||
| 166 | }); | ||
infra/relay/src/environments/EnvironmentPublishSignatures.ts created+164| ... | @@ -0,0 +1,164 @@ | ||
| 1 | import { | ||
| 2 | RelayAgentActivityPublishProofPayload, | ||
| 3 | type RelayAgentActivityPublishRequest, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import { | ||
| 6 | decodeRelayJwt, | ||
| 7 | normalizeRelayIssuer, | ||
| 8 | RELAY_ACTIVITY_PUBLISH_TYP, | ||
| 9 | verifyRelayJwt, | ||
| 10 | } from "@t3tools/shared/relayJwt"; | ||
| 11 | import { stableStringify } from "@t3tools/shared/relaySigning"; | ||
| 12 | import * as Context from "effect/Context"; | ||
| 13 | import * as Crypto from "effect/Crypto"; | ||
| 14 | import * as Data from "effect/Data"; | ||
| 15 | import * as DateTime from "effect/DateTime"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Encoding from "effect/Encoding"; | ||
| 18 | import * as Layer from "effect/Layer"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | |||
| 21 | import * as DpopProofs from "../auth/DpopProofs.ts"; | ||
| 22 | import * as RelayConfiguration from "../Config.ts"; | ||
| 23 | |||
| 24 | export class EnvironmentPublishSignatureExpired extends Data.TaggedError( | ||
| 25 | "EnvironmentPublishSignatureExpired", | ||
| 26 | )<{ | ||
| 27 | readonly expiresAt: string; | ||
| 28 | }> {} | ||
| 29 | |||
| 30 | export class EnvironmentPublishSignatureInvalid extends Data.TaggedError( | ||
| 31 | "EnvironmentPublishSignatureInvalid", | ||
| 32 | )<{ | ||
| 33 | readonly environmentId: string; | ||
| 34 | }> {} | ||
| 35 | |||
| 36 | export class EnvironmentPublishPublicKeyMissing extends Data.TaggedError( | ||
| 37 | "EnvironmentPublishPublicKeyMissing", | ||
| 38 | )<{ | ||
| 39 | readonly environmentId: string; | ||
| 40 | }> {} | ||
| 41 | |||
| 42 | export type EnvironmentPublishSignatureError = | ||
| 43 | | EnvironmentPublishSignatureExpired | ||
| 44 | | EnvironmentPublishSignatureInvalid | ||
| 45 | | EnvironmentPublishPublicKeyMissing | ||
| 46 | | DpopProofs.DpopProofReplayPersistenceError; | ||
| 47 | |||
| 48 | export interface EnvironmentPublishSignaturesShape { | ||
| 49 | readonly verify: (input: { | ||
| 50 | readonly environmentId: string; | ||
| 51 | readonly environmentPublicKey: string; | ||
| 52 | readonly threadId: string; | ||
| 53 | readonly request: RelayAgentActivityPublishRequest; | ||
| 54 | }) => Effect.Effect<void, EnvironmentPublishSignatureError>; | ||
| 55 | } | ||
| 56 | |||
| 57 | export class EnvironmentPublishSignatures extends Context.Service< | ||
| 58 | EnvironmentPublishSignatures, | ||
| 59 | EnvironmentPublishSignaturesShape | ||
| 60 | >()("t3code-relay/environments/EnvironmentPublishSignatures") {} | ||
| 61 | |||
| 62 | const decodeProof = Schema.decodeUnknownEffect(RelayAgentActivityPublishProofPayload); | ||
| 63 | |||
| 64 | function environmentPublishReplayThumbprintData(input: { | ||
| 65 | readonly environmentId: string; | ||
| 66 | readonly environmentPublicKey: string; | ||
| 67 | }) { | ||
| 68 | return new TextEncoder().encode( | ||
| 69 | stableStringify({ | ||
| 70 | environmentId: input.environmentId, | ||
| 71 | environmentPublicKey: input.environmentPublicKey, | ||
| 72 | }), | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | |||
| 76 | const formatEnvironmentPublishReplayThumbprint = (digest: Uint8Array) => | ||
| 77 | `env-publish:${Encoding.encodeBase64Url(digest)}`; | ||
| 78 | |||
| 79 | const make = Effect.gen(function* () { | ||
| 80 | const proofReplay = yield* DpopProofs.DpopProofReplay; | ||
| 81 | const config = yield* RelayConfiguration.RelayConfiguration; | ||
| 82 | const crypto = yield* Crypto.Crypto; | ||
| 83 | |||
| 84 | return EnvironmentPublishSignatures.of({ | ||
| 85 | verify: Effect.fn("relay.environment_publish_signatures.verify")(function* (input) { | ||
| 86 | yield* Effect.annotateCurrentSpan({ | ||
| 87 | "relay.environment_id": input.environmentId, | ||
| 88 | "relay.thread_id": input.threadId, | ||
| 89 | }); | ||
| 90 | const now = yield* DateTime.now; | ||
| 91 | const decoded = yield* Effect.try({ | ||
| 92 | try: () => decodeRelayJwt(input.request.proof), | ||
| 93 | catch: () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), | ||
| 94 | }); | ||
| 95 | if ( | ||
| 96 | typeof decoded.exp === "number" && | ||
| 97 | decoded.exp <= Math.floor(now.epochMilliseconds / 1_000) | ||
| 98 | ) { | ||
| 99 | return yield* new EnvironmentPublishSignatureExpired({ | ||
| 100 | expiresAt: DateTime.formatIso(DateTime.makeUnsafe(decoded.exp * 1_000)), | ||
| 101 | }); | ||
| 102 | } | ||
| 103 | const proof = yield* verifyRelayJwt({ | ||
| 104 | publicKey: input.environmentPublicKey, | ||
| 105 | token: input.request.proof, | ||
| 106 | typ: RELAY_ACTIVITY_PUBLISH_TYP, | ||
| 107 | issuer: `t3-env:${input.environmentId}`, | ||
| 108 | audience: normalizeRelayIssuer(config.relayIssuer), | ||
| 109 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 110 | }).pipe( | ||
| 111 | Effect.flatMap(decodeProof), | ||
| 112 | Effect.mapError( | ||
| 113 | () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), | ||
| 114 | ), | ||
| 115 | ); | ||
| 116 | if ( | ||
| 117 | proof.environmentId !== input.environmentId || | ||
| 118 | proof.threadId !== input.threadId || | ||
| 119 | proof.sub !== input.environmentId || | ||
| 120 | stableStringify(proof.state) !== stableStringify(input.request.state) || | ||
| 121 | (input.request.state !== null && | ||
| 122 | (input.request.state.environmentId !== input.environmentId || | ||
| 123 | input.request.state.threadId !== input.threadId)) | ||
| 124 | ) { | ||
| 125 | return yield* new EnvironmentPublishSignatureInvalid({ | ||
| 126 | environmentId: input.environmentId, | ||
| 127 | }); | ||
| 128 | } | ||
| 129 | const expiresAt = DateTime.make(proof.exp * 1_000); | ||
| 130 | if (expiresAt._tag === "None") { | ||
| 131 | return yield* new EnvironmentPublishSignatureInvalid({ | ||
| 132 | environmentId: input.environmentId, | ||
| 133 | }); | ||
| 134 | } | ||
| 135 | const thumbprint = yield* crypto | ||
| 136 | .digest( | ||
| 137 | "SHA-256", | ||
| 138 | environmentPublishReplayThumbprintData({ | ||
| 139 | environmentId: input.environmentId, | ||
| 140 | environmentPublicKey: input.environmentPublicKey, | ||
| 141 | }), | ||
| 142 | ) | ||
| 143 | .pipe( | ||
| 144 | Effect.map(formatEnvironmentPublishReplayThumbprint), | ||
| 145 | Effect.mapError( | ||
| 146 | () => new EnvironmentPublishSignatureInvalid({ environmentId: input.environmentId }), | ||
| 147 | ), | ||
| 148 | ); | ||
| 149 | const consumedNonce = yield* proofReplay.consume({ | ||
| 150 | thumbprint, | ||
| 151 | jti: proof.jti, | ||
| 152 | iat: proof.iat, | ||
| 153 | expiresAt: expiresAt.value, | ||
| 154 | }); | ||
| 155 | if (!consumedNonce) { | ||
| 156 | return yield* new EnvironmentPublishSignatureInvalid({ | ||
| 157 | environmentId: input.environmentId, | ||
| 158 | }); | ||
| 159 | } | ||
| 160 | }), | ||
| 161 | }); | ||
| 162 | }); | ||
| 163 | |||
| 164 | export const layer = Layer.effect(EnvironmentPublishSignatures, make); | ||
infra/relay/src/environments/ManagedEndpointAllocations.ts created+200| ... | @@ -0,0 +1,200 @@ | ||
| 1 | import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; | ||
| 2 | import { and, eq } from "drizzle-orm"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Data from "effect/Data"; | ||
| 5 | import * as DateTime from "effect/DateTime"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | |||
| 9 | import { RelayDb } from "../db.ts"; | ||
| 10 | import { isManagedEndpointHostname, managedEndpointForHostname } from "../deploymentConfig.ts"; | ||
| 11 | import { relayManagedEndpointAllocations } from "../persistence/schema.ts"; | ||
| 12 | |||
| 13 | export interface ManagedEndpointAllocation { | ||
| 14 | readonly userId: string; | ||
| 15 | readonly environmentId: string; | ||
| 16 | readonly hostname: string; | ||
| 17 | readonly tunnelId: string | null; | ||
| 18 | readonly tunnelName: string; | ||
| 19 | readonly dnsRecordId: string | null; | ||
| 20 | readonly readyAt: string | null; | ||
| 21 | } | ||
| 22 | |||
| 23 | export function resolveReadyManagedEndpoint(input: { | ||
| 24 | readonly allocation: ManagedEndpointAllocation; | ||
| 25 | readonly baseDomain: string | undefined; | ||
| 26 | }): RelayManagedEndpoint | null { | ||
| 27 | if ( | ||
| 28 | !input.baseDomain || | ||
| 29 | input.allocation.readyAt === null || | ||
| 30 | input.allocation.tunnelId === null || | ||
| 31 | input.allocation.dnsRecordId === null || | ||
| 32 | !isManagedEndpointHostname(input.allocation.hostname, input.baseDomain) | ||
| 33 | ) { | ||
| 34 | return null; | ||
| 35 | } | ||
| 36 | return managedEndpointForHostname(input.allocation.hostname); | ||
| 37 | } | ||
| 38 | |||
| 39 | export class ManagedEndpointAllocationPersistenceError extends Data.TaggedError( | ||
| 40 | "ManagedEndpointAllocationPersistenceError", | ||
| 41 | )<{ | ||
| 42 | readonly cause: unknown; | ||
| 43 | }> {} | ||
| 44 | |||
| 45 | interface ManagedEndpointAllocationKey { | ||
| 46 | readonly userId: string; | ||
| 47 | readonly environmentId: string; | ||
| 48 | } | ||
| 49 | |||
| 50 | interface ReserveManagedEndpointAllocationInput extends ManagedEndpointAllocationKey { | ||
| 51 | readonly hostname: string; | ||
| 52 | readonly tunnelName: string; | ||
| 53 | } | ||
| 54 | |||
| 55 | interface RecordManagedEndpointTunnelInput extends ManagedEndpointAllocationKey { | ||
| 56 | readonly tunnelId: string; | ||
| 57 | } | ||
| 58 | |||
| 59 | interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey { | ||
| 60 | readonly dnsRecordId: string; | ||
| 61 | } | ||
| 62 | |||
| 63 | export interface ManagedEndpointAllocationsShape { | ||
| 64 | readonly get: ( | ||
| 65 | input: ManagedEndpointAllocationKey, | ||
| 66 | ) => Effect.Effect<ManagedEndpointAllocation | null, ManagedEndpointAllocationPersistenceError>; | ||
| 67 | readonly reserve: ( | ||
| 68 | input: ReserveManagedEndpointAllocationInput, | ||
| 69 | ) => Effect.Effect<ManagedEndpointAllocation, ManagedEndpointAllocationPersistenceError>; | ||
| 70 | readonly recordTunnel: ( | ||
| 71 | input: RecordManagedEndpointTunnelInput, | ||
| 72 | ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>; | ||
| 73 | readonly recordDns: ( | ||
| 74 | input: RecordManagedEndpointDnsInput, | ||
| 75 | ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>; | ||
| 76 | readonly markReady: ( | ||
| 77 | input: ManagedEndpointAllocationKey, | ||
| 78 | ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>; | ||
| 79 | readonly remove: ( | ||
| 80 | input: ManagedEndpointAllocationKey, | ||
| 81 | ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>; | ||
| 82 | } | ||
| 83 | |||
| 84 | const allocationSelection = { | ||
| 85 | userId: relayManagedEndpointAllocations.userId, | ||
| 86 | environmentId: relayManagedEndpointAllocations.environmentId, | ||
| 87 | hostname: relayManagedEndpointAllocations.hostname, | ||
| 88 | tunnelId: relayManagedEndpointAllocations.tunnelId, | ||
| 89 | tunnelName: relayManagedEndpointAllocations.tunnelName, | ||
| 90 | dnsRecordId: relayManagedEndpointAllocations.dnsRecordId, | ||
| 91 | readyAt: relayManagedEndpointAllocations.readyAt, | ||
| 92 | }; | ||
| 93 | |||
| 94 | const whereAllocation = (input: ManagedEndpointAllocationKey) => | ||
| 95 | and( | ||
| 96 | eq(relayManagedEndpointAllocations.userId, input.userId), | ||
| 97 | eq(relayManagedEndpointAllocations.environmentId, input.environmentId), | ||
| 98 | ); | ||
| 99 | |||
| 100 | const persistenceError = (cause: unknown) => | ||
| 101 | cause instanceof ManagedEndpointAllocationPersistenceError | ||
| 102 | ? cause | ||
| 103 | : new ManagedEndpointAllocationPersistenceError({ cause }); | ||
| 104 | |||
| 105 | const make = Effect.gen(function* () { | ||
| 106 | const db = yield* RelayDb; | ||
| 107 | |||
| 108 | return ManagedEndpointAllocations.of({ | ||
| 109 | get: Effect.fn("relay.managed_endpoint_allocations.get")(function* ( | ||
| 110 | input: ManagedEndpointAllocationKey, | ||
| 111 | ) { | ||
| 112 | return yield* db | ||
| 113 | .select(allocationSelection) | ||
| 114 | .from(relayManagedEndpointAllocations) | ||
| 115 | .where(whereAllocation(input)) | ||
| 116 | .limit(1) | ||
| 117 | .pipe( | ||
| 118 | Effect.map((rows) => rows[0] ?? null), | ||
| 119 | Effect.mapError(persistenceError), | ||
| 120 | ); | ||
| 121 | }), | ||
| 122 | reserve: Effect.fn("relay.managed_endpoint_allocations.reserve")(function* ( | ||
| 123 | input: ReserveManagedEndpointAllocationInput, | ||
| 124 | ) { | ||
| 125 | const now = DateTime.formatIso(yield* DateTime.now); | ||
| 126 | const inserted = yield* db | ||
| 127 | .insert(relayManagedEndpointAllocations) | ||
| 128 | .values({ | ||
| 129 | ...input, | ||
| 130 | createdAt: now, | ||
| 131 | updatedAt: now, | ||
| 132 | }) | ||
| 133 | .onConflictDoNothing() | ||
| 134 | .returning(allocationSelection); | ||
| 135 | |||
| 136 | const allocation = | ||
| 137 | inserted[0] ?? | ||
| 138 | (yield* db | ||
| 139 | .select(allocationSelection) | ||
| 140 | .from(relayManagedEndpointAllocations) | ||
| 141 | .where(whereAllocation(input)) | ||
| 142 | .limit(1) | ||
| 143 | .pipe(Effect.map((rows) => rows[0]))); | ||
| 144 | |||
| 145 | if (allocation === undefined) { | ||
| 146 | return yield* new ManagedEndpointAllocationPersistenceError({ | ||
| 147 | cause: new Error("Managed endpoint allocation was not persisted."), | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | |||
| 151 | return allocation; | ||
| 152 | }, Effect.mapError(persistenceError)), | ||
| 153 | recordTunnel: Effect.fn("relay.managed_endpoint_allocations.record_tunnel")(function* ( | ||
| 154 | input: RecordManagedEndpointTunnelInput, | ||
| 155 | ) { | ||
| 156 | yield* db | ||
| 157 | .update(relayManagedEndpointAllocations) | ||
| 158 | .set({ | ||
| 159 | tunnelId: input.tunnelId, | ||
| 160 | updatedAt: DateTime.formatIso(yield* DateTime.now), | ||
| 161 | }) | ||
| 162 | .where(whereAllocation(input)); | ||
| 163 | }, Effect.mapError(persistenceError)), | ||
| 164 | recordDns: Effect.fn("relay.managed_endpoint_allocations.record_dns")(function* ( | ||
| 165 | input: RecordManagedEndpointDnsInput, | ||
| 166 | ) { | ||
| 167 | yield* db | ||
| 168 | .update(relayManagedEndpointAllocations) | ||
| 169 | .set({ | ||
| 170 | dnsRecordId: input.dnsRecordId, | ||
| 171 | updatedAt: DateTime.formatIso(yield* DateTime.now), | ||
| 172 | }) | ||
| 173 | .where(whereAllocation(input)); | ||
| 174 | }, Effect.mapError(persistenceError)), | ||
| 175 | markReady: Effect.fn("relay.managed_endpoint_allocations.mark_ready")(function* ( | ||
| 176 | input: ManagedEndpointAllocationKey, | ||
| 177 | ) { | ||
| 178 | const now = DateTime.formatIso(yield* DateTime.now); | ||
| 179 | yield* db | ||
| 180 | .update(relayManagedEndpointAllocations) | ||
| 181 | .set({ | ||
| 182 | readyAt: now, | ||
| 183 | updatedAt: now, | ||
| 184 | }) | ||
| 185 | .where(whereAllocation(input)); | ||
| 186 | }, Effect.mapError(persistenceError)), | ||
| 187 | remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( | ||
| 188 | input: ManagedEndpointAllocationKey, | ||
| 189 | ) { | ||
| 190 | yield* db.delete(relayManagedEndpointAllocations).where(whereAllocation(input)); | ||
| 191 | }, Effect.mapError(persistenceError)), | ||
| 192 | }); | ||
| 193 | }); | ||
| 194 | |||
| 195 | export class ManagedEndpointAllocations extends Context.Service< | ||
| 196 | ManagedEndpointAllocations, | ||
| 197 | ManagedEndpointAllocationsShape | ||
| 198 | >()("t3code-relay/environments/ManagedEndpointAllocations") { | ||
| 199 | static readonly layer = Layer.effect(this, make); | ||
| 200 | } | ||
infra/relay/src/environments/ManagedEndpointProvider.test.ts created+760| ... | @@ -0,0 +1,760 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | |||
| 4 | import { describe, expect, it } from "@effect/vitest"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Redacted from "effect/Redacted"; | ||
| 8 | |||
| 9 | import * as RelayConfiguration from "../Config.ts"; | ||
| 10 | import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; | ||
| 11 | import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; | ||
| 12 | |||
| 13 | const config = RelayConfiguration.RelayConfiguration.of({ | ||
| 14 | relayIssuer: "https://relay.example.test", | ||
| 15 | apns: { | ||
| 16 | environment: "sandbox", | ||
| 17 | teamId: "team-id", | ||
| 18 | keyId: "key-id", | ||
| 19 | privateKey: Redacted.make("private-key"), | ||
| 20 | bundleId: "com.t3tools.t3code.dev", | ||
| 21 | }, | ||
| 22 | apnsDeliveryJobSigningSecret: Redacted.make("job-secret"), | ||
| 23 | clerkSecretKey: Redacted.make("clerk-secret"), | ||
| 24 | clerkPublishableKey: "pk_test_test", | ||
| 25 | clerkJwtAudience: "t3-code-relay", | ||
| 26 | cloudMintPrivateKey: Redacted.make("cloud-private-key"), | ||
| 27 | cloudMintPublicKey: "cloud-public-key", | ||
| 28 | managedEndpointBaseDomain: "t3code.test", | ||
| 29 | managedEndpointNamespace: "dev_julius", | ||
| 30 | }); | ||
| 31 | |||
| 32 | interface TunnelCall { | ||
| 33 | readonly operation: "list" | "create" | "putConfiguration" | "getToken" | "delete"; | ||
| 34 | readonly input: unknown; | ||
| 35 | } | ||
| 36 | |||
| 37 | interface DnsCall { | ||
| 38 | readonly operation: "listRecords" | "createRecord" | "updateRecord" | "deleteRecord"; | ||
| 39 | readonly input: unknown; | ||
| 40 | } | ||
| 41 | |||
| 42 | interface AllocationCall { | ||
| 43 | readonly operation: "get" | "reserve" | "recordTunnel" | "recordDns" | "markReady" | "remove"; | ||
| 44 | readonly input: unknown; | ||
| 45 | } | ||
| 46 | |||
| 47 | function allocationKey(input: { readonly userId: string; readonly environmentId: string }) { | ||
| 48 | return `${input.userId}:${input.environmentId}`; | ||
| 49 | } | ||
| 50 | |||
| 51 | function makeTunnelClient(calls: TunnelCall[] = []) { | ||
| 52 | return ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ | ||
| 53 | list: (request) => | ||
| 54 | Effect.sync(() => { | ||
| 55 | calls.push({ operation: "list", input: request }); | ||
| 56 | return { result: [] }; | ||
| 57 | }), | ||
| 58 | create: (request) => | ||
| 59 | Effect.sync(() => { | ||
| 60 | calls.push({ operation: "create", input: request }); | ||
| 61 | return { id: "tunnel-id", name: request.name }; | ||
| 62 | }), | ||
| 63 | putConfiguration: (tunnelId, tunnelConfig) => | ||
| 64 | Effect.sync(() => { | ||
| 65 | calls.push({ operation: "putConfiguration", input: { tunnelId, tunnelConfig } }); | ||
| 66 | }), | ||
| 67 | getToken: (tunnelId) => | ||
| 68 | Effect.sync(() => { | ||
| 69 | calls.push({ operation: "getToken", input: tunnelId }); | ||
| 70 | return "connector-token"; | ||
| 71 | }), | ||
| 72 | delete: (tunnelId) => | ||
| 73 | Effect.sync(() => { | ||
| 74 | calls.push({ operation: "delete", input: tunnelId }); | ||
| 75 | }), | ||
| 76 | }); | ||
| 77 | } | ||
| 78 | |||
| 79 | function makePersistentTunnelClient(calls: TunnelCall[] = []) { | ||
| 80 | let tunnel: { readonly id: string; readonly name: string } | null = null; | ||
| 81 | return ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ | ||
| 82 | list: (request) => | ||
| 83 | Effect.sync(() => { | ||
| 84 | calls.push({ operation: "list", input: request }); | ||
| 85 | return { result: tunnel === null ? [] : [tunnel] }; | ||
| 86 | }), | ||
| 87 | create: (request) => | ||
| 88 | Effect.sync(() => { | ||
| 89 | calls.push({ operation: "create", input: request }); | ||
| 90 | tunnel = { id: "tunnel-id", name: request.name }; | ||
| 91 | return tunnel; | ||
| 92 | }), | ||
| 93 | putConfiguration: (tunnelId, tunnelConfig) => | ||
| 94 | Effect.sync(() => { | ||
| 95 | calls.push({ operation: "putConfiguration", input: { tunnelId, tunnelConfig } }); | ||
| 96 | }), | ||
| 97 | getToken: (tunnelId) => | ||
| 98 | Effect.sync(() => { | ||
| 99 | calls.push({ operation: "getToken", input: tunnelId }); | ||
| 100 | return "connector-token"; | ||
| 101 | }), | ||
| 102 | delete: (tunnelId) => | ||
| 103 | Effect.sync(() => { | ||
| 104 | calls.push({ operation: "delete", input: tunnelId }); | ||
| 105 | tunnel = null; | ||
| 106 | }), | ||
| 107 | }); | ||
| 108 | } | ||
| 109 | |||
| 110 | function makeDnsClient( | ||
| 111 | calls: DnsCall[] = [], | ||
| 112 | records: ReadonlyArray<{ readonly id: string }> = [], | ||
| 113 | ) { | ||
| 114 | let currentRecords = [...records]; | ||
| 115 | return ManagedEndpointProvider.ManagedEndpointDnsClient.of({ | ||
| 116 | listRecords: (hostname) => | ||
| 117 | Effect.sync(() => { | ||
| 118 | calls.push({ operation: "listRecords", input: hostname }); | ||
| 119 | return currentRecords; | ||
| 120 | }), | ||
| 121 | createRecord: (request) => | ||
| 122 | Effect.sync(() => { | ||
| 123 | calls.push({ operation: "createRecord", input: request }); | ||
| 124 | const record = { id: "created-record-id" }; | ||
| 125 | currentRecords = [record]; | ||
| 126 | return record; | ||
| 127 | }), | ||
| 128 | updateRecord: (dnsRecordId, request) => | ||
| 129 | Effect.gen(function* () { | ||
| 130 | calls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); | ||
| 131 | if (!currentRecords.some((record) => record.id === dnsRecordId)) { | ||
| 132 | return yield* new ManagedEndpointProvider.ManagedEndpointDnsClientError({ | ||
| 133 | cause: `DNS record ${dnsRecordId} does not exist.`, | ||
| 134 | }); | ||
| 135 | } | ||
| 136 | }), | ||
| 137 | deleteRecord: (dnsRecordId) => | ||
| 138 | Effect.sync(() => { | ||
| 139 | calls.push({ operation: "deleteRecord", input: dnsRecordId }); | ||
| 140 | currentRecords = currentRecords.filter((record) => record.id !== dnsRecordId); | ||
| 141 | }), | ||
| 142 | }); | ||
| 143 | } | ||
| 144 | |||
| 145 | function makeAllocations(calls: AllocationCall[] = []) { | ||
| 146 | const allocations = new Map<string, ManagedEndpointAllocations.ManagedEndpointAllocation>(); | ||
| 147 | return ManagedEndpointAllocations.ManagedEndpointAllocations.of({ | ||
| 148 | get: (input) => | ||
| 149 | Effect.sync(() => { | ||
| 150 | calls.push({ operation: "get", input }); | ||
| 151 | return allocations.get(allocationKey(input)) ?? null; | ||
| 152 | }), | ||
| 153 | reserve: (input) => | ||
| 154 | Effect.sync(() => { | ||
| 155 | calls.push({ operation: "reserve", input }); | ||
| 156 | const allocation = allocations.get(allocationKey(input)) ?? { | ||
| 157 | ...input, | ||
| 158 | tunnelId: null, | ||
| 159 | dnsRecordId: null, | ||
| 160 | readyAt: null, | ||
| 161 | }; | ||
| 162 | allocations.set(allocationKey(input), allocation); | ||
| 163 | return allocation; | ||
| 164 | }), | ||
| 165 | recordTunnel: (input) => | ||
| 166 | Effect.sync(() => { | ||
| 167 | calls.push({ operation: "recordTunnel", input }); | ||
| 168 | const allocation = allocations.get(allocationKey(input)); | ||
| 169 | if (allocation !== undefined) { | ||
| 170 | allocations.set(allocationKey(input), { ...allocation, tunnelId: input.tunnelId }); | ||
| 171 | } | ||
| 172 | }), | ||
| 173 | recordDns: (input) => | ||
| 174 | Effect.sync(() => { | ||
| 175 | calls.push({ operation: "recordDns", input }); | ||
| 176 | const allocation = allocations.get(allocationKey(input)); | ||
| 177 | if (allocation !== undefined) { | ||
| 178 | allocations.set(allocationKey(input), { ...allocation, dnsRecordId: input.dnsRecordId }); | ||
| 179 | } | ||
| 180 | }), | ||
| 181 | markReady: (input) => | ||
| 182 | Effect.sync(() => { | ||
| 183 | calls.push({ operation: "markReady", input }); | ||
| 184 | const allocation = allocations.get(allocationKey(input)); | ||
| 185 | if (allocation !== undefined) { | ||
| 186 | allocations.set(allocationKey(input), { | ||
| 187 | ...allocation, | ||
| 188 | readyAt: "2026-06-02T00:00:00.000Z", | ||
| 189 | }); | ||
| 190 | } | ||
| 191 | }), | ||
| 192 | remove: (input) => | ||
| 193 | Effect.sync(() => { | ||
| 194 | calls.push({ operation: "remove", input }); | ||
| 195 | allocations.delete(allocationKey(input)); | ||
| 196 | }), | ||
| 197 | }); | ||
| 198 | } | ||
| 199 | |||
| 200 | function providerLayer( | ||
| 201 | tunnelClient = makeTunnelClient(), | ||
| 202 | dnsClient = makeDnsClient(), | ||
| 203 | allocations = makeAllocations(), | ||
| 204 | ) { | ||
| 205 | return ManagedEndpointProvider.layer.pipe( | ||
| 206 | Layer.provideMerge(NodeServices.layer), | ||
| 207 | Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)), | ||
| 208 | Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointTunnelClient, tunnelClient)), | ||
| 209 | Layer.provide(Layer.succeed(ManagedEndpointProvider.ManagedEndpointDnsClient, dnsClient)), | ||
| 210 | Layer.provide( | ||
| 211 | Layer.succeed(ManagedEndpointAllocations.ManagedEndpointAllocations, allocations), | ||
| 212 | ), | ||
| 213 | ); | ||
| 214 | } | ||
| 215 | |||
| 216 | function expectedManagedHostname(environmentId: string, userId = "user_ABC"): string { | ||
| 217 | const hash = NodeCrypto.createHash("sha256") | ||
| 218 | .update(`dev_julius:${userId}:${environmentId}`) | ||
| 219 | .digest("hex") | ||
| 220 | .slice(0, 16); | ||
| 221 | return `dev-julius-${hash}.t3code.test`; | ||
| 222 | } | ||
| 223 | |||
| 224 | function expectedManagedTunnelName(environmentId: string, userId = "user_ABC"): string { | ||
| 225 | const hash = NodeCrypto.createHash("sha256") | ||
| 226 | .update(`dev_julius:${userId}:${environmentId}`) | ||
| 227 | .digest("hex") | ||
| 228 | .slice(0, 16); | ||
| 229 | return `t3coderelay-managedendpoint-dev-julius-${hash}`; | ||
| 230 | } | ||
| 231 | |||
| 232 | describe("ManagedEndpointProvider", () => { | ||
| 233 | it.effect("provisions a Cloudflare tunnel endpoint and connector token", () => { | ||
| 234 | const tunnelCalls: TunnelCall[] = []; | ||
| 235 | const dnsCalls: DnsCall[] = []; | ||
| 236 | const allocationCalls: AllocationCall[] = []; | ||
| 237 | |||
| 238 | return Effect.gen(function* () { | ||
| 239 | const hostname = expectedManagedHostname("env_ABC"); | ||
| 240 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 241 | const result = yield* provider.provision({ | ||
| 242 | userId: "user_ABC", | ||
| 243 | environmentId: "env_ABC", | ||
| 244 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 245 | }); | ||
| 246 | |||
| 247 | expect(result).toEqual({ | ||
| 248 | endpoint: { | ||
| 249 | httpBaseUrl: `https://${hostname}/`, | ||
| 250 | wsBaseUrl: `wss://${hostname}/ws`, | ||
| 251 | providerKind: "cloudflare_tunnel", | ||
| 252 | }, | ||
| 253 | runtime: { | ||
| 254 | providerKind: "cloudflare_tunnel", | ||
| 255 | connectorToken: "connector-token", | ||
| 256 | tunnelId: "tunnel-id", | ||
| 257 | tunnelName: expectedManagedTunnelName("env_ABC"), | ||
| 258 | }, | ||
| 259 | }); | ||
| 260 | expect(dnsCalls).toEqual([ | ||
| 261 | { operation: "listRecords", input: hostname }, | ||
| 262 | { | ||
| 263 | operation: "createRecord", | ||
| 264 | input: { | ||
| 265 | type: "CNAME", | ||
| 266 | name: hostname, | ||
| 267 | content: "tunnel-id.cfargotunnel.com", | ||
| 268 | ttl: 1, | ||
| 269 | proxied: true, | ||
| 270 | }, | ||
| 271 | }, | ||
| 272 | ]); | ||
| 273 | expect(tunnelCalls.map((call) => call.operation)).toEqual([ | ||
| 274 | "list", | ||
| 275 | "create", | ||
| 276 | "putConfiguration", | ||
| 277 | "getToken", | ||
| 278 | ]); | ||
| 279 | expect(tunnelCalls[2]?.input).toMatchObject({ | ||
| 280 | tunnelConfig: { | ||
| 281 | ingress: [ | ||
| 282 | { | ||
| 283 | hostname, | ||
| 284 | service: "http://127.0.0.1:3773", | ||
| 285 | }, | ||
| 286 | { service: "http_status:404" }, | ||
| 287 | ], | ||
| 288 | }, | ||
| 289 | }); | ||
| 290 | expect(tunnelCalls[0]?.input).toEqual({ | ||
| 291 | name: expectedManagedTunnelName("env_ABC"), | ||
| 292 | isDeleted: false, | ||
| 293 | }); | ||
| 294 | expect(allocationCalls.map((call) => call.operation)).toEqual([ | ||
| 295 | "reserve", | ||
| 296 | "recordTunnel", | ||
| 297 | "recordDns", | ||
| 298 | "markReady", | ||
| 299 | ]); | ||
| 300 | }).pipe( | ||
| 301 | Effect.provide( | ||
| 302 | providerLayer( | ||
| 303 | makeTunnelClient(tunnelCalls), | ||
| 304 | makeDnsClient(dnsCalls), | ||
| 305 | makeAllocations(allocationCalls), | ||
| 306 | ), | ||
| 307 | ), | ||
| 308 | ); | ||
| 309 | }); | ||
| 310 | |||
| 311 | it.effect("uses stage-scoped stable names without leaking unusual environment ids", () => { | ||
| 312 | const tunnelCalls: TunnelCall[] = []; | ||
| 313 | |||
| 314 | return Effect.gen(function* () { | ||
| 315 | const environmentId = "ENV With Spaces/../Symbols!" + "x".repeat(80); | ||
| 316 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 317 | yield* provider.provision({ | ||
| 318 | userId: "user_ABC", | ||
| 319 | environmentId, | ||
| 320 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 321 | }); | ||
| 322 | |||
| 323 | const requestedName = ( | ||
| 324 | tunnelCalls.find((call) => call.operation === "list")?.input as | ||
| 325 | | { readonly name?: string } | ||
| 326 | | undefined | ||
| 327 | )?.name; | ||
| 328 | expect(requestedName).toMatch(/^t3coderelay-managedendpoint-dev-julius-[a-f0-9]{16}$/); | ||
| 329 | const configBody = ( | ||
| 330 | tunnelCalls.find((call) => call.operation === "putConfiguration")?.input as | ||
| 331 | | { readonly tunnelConfig?: unknown } | ||
| 332 | | undefined | ||
| 333 | )?.tunnelConfig; | ||
| 334 | expect(configBody).toMatchObject({ | ||
| 335 | ingress: [ | ||
| 336 | { | ||
| 337 | hostname: expect.stringMatching(/^dev-julius-[a-f0-9]{16}\.t3code\.test$/), | ||
| 338 | }, | ||
| 339 | { service: "http_status:404" }, | ||
| 340 | ], | ||
| 341 | }); | ||
| 342 | const hostname = ( | ||
| 343 | configBody as | ||
| 344 | | { | ||
| 345 | readonly ingress?: readonly [{ readonly hostname?: unknown }, unknown]; | ||
| 346 | } | ||
| 347 | | undefined | ||
| 348 | )?.ingress?.[0]?.hostname; | ||
| 349 | expect(typeof hostname === "string" ? hostname.split(".")[0]?.length : 0).toBeLessThanOrEqual( | ||
| 350 | 63, | ||
| 351 | ); | ||
| 352 | expect(tunnelCalls.find((call) => call.operation === "create")?.input).toMatchObject({ | ||
| 353 | name: requestedName, | ||
| 354 | configSrc: "cloudflare", | ||
| 355 | }); | ||
| 356 | }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls)))); | ||
| 357 | }); | ||
| 358 | |||
| 359 | it.effect("formats IPv6 loopback origins as valid Cloudflare ingress service URLs", () => { | ||
| 360 | const tunnelCalls: TunnelCall[] = []; | ||
| 361 | |||
| 362 | return Effect.gen(function* () { | ||
| 363 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 364 | yield* provider.provision({ | ||
| 365 | userId: "user_ABC", | ||
| 366 | environmentId: "env-ipv6", | ||
| 367 | origin: { localHttpHost: "::1", localHttpPort: 3773 }, | ||
| 368 | }); | ||
| 369 | |||
| 370 | expect( | ||
| 371 | tunnelCalls.find((call) => call.operation === "putConfiguration")?.input, | ||
| 372 | ).toMatchObject({ | ||
| 373 | tunnelConfig: { | ||
| 374 | ingress: [ | ||
| 375 | { | ||
| 376 | service: "http://[::1]:3773", | ||
| 377 | }, | ||
| 378 | { service: "http_status:404" }, | ||
| 379 | ], | ||
| 380 | }, | ||
| 381 | }); | ||
| 382 | }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls)))); | ||
| 383 | }); | ||
| 384 | |||
| 385 | it.effect("rejects non-loopback managed endpoint origins before calling Cloudflare", () => { | ||
| 386 | const dnsCalls: DnsCall[] = []; | ||
| 387 | |||
| 388 | return Effect.gen(function* () { | ||
| 389 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 390 | const result = yield* Effect.result( | ||
| 391 | provider.provision({ | ||
| 392 | userId: "user_ABC", | ||
| 393 | environmentId: "env_ABC", | ||
| 394 | origin: { localHttpHost: "192.168.1.10", localHttpPort: 3773 }, | ||
| 395 | }), | ||
| 396 | ); | ||
| 397 | |||
| 398 | expect(dnsCalls).toHaveLength(0); | ||
| 399 | expect(result._tag).toBe("Failure"); | ||
| 400 | if (result._tag === "Failure") { | ||
| 401 | expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); | ||
| 402 | } | ||
| 403 | }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls)))); | ||
| 404 | }); | ||
| 405 | |||
| 406 | it.effect("rejects invalid managed endpoint origin ports before calling Cloudflare", () => { | ||
| 407 | const dnsCalls: DnsCall[] = []; | ||
| 408 | |||
| 409 | return Effect.gen(function* () { | ||
| 410 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 411 | const result = yield* Effect.result( | ||
| 412 | provider.provision({ | ||
| 413 | userId: "user_ABC", | ||
| 414 | environmentId: "env_ABC", | ||
| 415 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 65_536 }, | ||
| 416 | }), | ||
| 417 | ); | ||
| 418 | |||
| 419 | expect(dnsCalls).toHaveLength(0); | ||
| 420 | expect(result._tag).toBe("Failure"); | ||
| 421 | if (result._tag === "Failure") { | ||
| 422 | expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed"); | ||
| 423 | } | ||
| 424 | }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls)))); | ||
| 425 | }); | ||
| 426 | |||
| 427 | it.effect("reconciles an existing same-host DNS record through the DNS client", () => { | ||
| 428 | const dnsCalls: DnsCall[] = []; | ||
| 429 | return Effect.gen(function* () { | ||
| 430 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 431 | yield* provider.provision({ | ||
| 432 | userId: "user_ABC", | ||
| 433 | environmentId: "env_ABC", | ||
| 434 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 435 | }); | ||
| 436 | |||
| 437 | expect(dnsCalls.map((call) => call.operation)).toEqual(["listRecords", "updateRecord"]); | ||
| 438 | expect(dnsCalls[1]?.input).toMatchObject({ dnsRecordId: "existing-record-id" }); | ||
| 439 | }).pipe( | ||
| 440 | Effect.provide( | ||
| 441 | providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls, [{ id: "existing-record-id" }])), | ||
| 442 | ), | ||
| 443 | ); | ||
| 444 | }); | ||
| 445 | |||
| 446 | it.effect("reuses checkpointed resources when provisioning is retried", () => { | ||
| 447 | const tunnelCalls: TunnelCall[] = []; | ||
| 448 | const dnsCalls: DnsCall[] = []; | ||
| 449 | const allocationCalls: AllocationCall[] = []; | ||
| 450 | const layer = providerLayer( | ||
| 451 | makePersistentTunnelClient(tunnelCalls), | ||
| 452 | makeDnsClient(dnsCalls), | ||
| 453 | makeAllocations(allocationCalls), | ||
| 454 | ); | ||
| 455 | |||
| 456 | return Effect.gen(function* () { | ||
| 457 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 458 | const request = { | ||
| 459 | userId: "user_ABC", | ||
| 460 | environmentId: "env_ABC", | ||
| 461 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 462 | } as const; | ||
| 463 | yield* provider.provision(request); | ||
| 464 | yield* provider.provision(request); | ||
| 465 | |||
| 466 | expect(tunnelCalls.map((call) => call.operation)).toEqual([ | ||
| 467 | "list", | ||
| 468 | "create", | ||
| 469 | "putConfiguration", | ||
| 470 | "getToken", | ||
| 471 | "list", | ||
| 472 | "putConfiguration", | ||
| 473 | "getToken", | ||
| 474 | ]); | ||
| 475 | expect(dnsCalls.map((call) => call.operation)).toEqual([ | ||
| 476 | "listRecords", | ||
| 477 | "createRecord", | ||
| 478 | "updateRecord", | ||
| 479 | ]); | ||
| 480 | expect(allocationCalls.map((call) => call.operation)).toEqual([ | ||
| 481 | "reserve", | ||
| 482 | "recordTunnel", | ||
| 483 | "recordDns", | ||
| 484 | "markReady", | ||
| 485 | "reserve", | ||
| 486 | "recordTunnel", | ||
| 487 | "recordDns", | ||
| 488 | "markReady", | ||
| 489 | ]); | ||
| 490 | }).pipe(Effect.provide(layer)); | ||
| 491 | }); | ||
| 492 | |||
| 493 | it.effect("recreates a checkpointed DNS record when it was removed externally", () => { | ||
| 494 | const dnsCalls: DnsCall[] = []; | ||
| 495 | const allocationCalls: AllocationCall[] = []; | ||
| 496 | const dnsClient = makeDnsClient(dnsCalls); | ||
| 497 | const layer = providerLayer( | ||
| 498 | makePersistentTunnelClient(), | ||
| 499 | dnsClient, | ||
| 500 | makeAllocations(allocationCalls), | ||
| 501 | ); | ||
| 502 | |||
| 503 | return Effect.gen(function* () { | ||
| 504 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 505 | const request = { | ||
| 506 | userId: "user_ABC", | ||
| 507 | environmentId: "env_ABC", | ||
| 508 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 509 | } as const; | ||
| 510 | yield* provider.provision(request); | ||
| 511 | yield* dnsClient.deleteRecord("created-record-id"); | ||
| 512 | yield* provider.provision(request); | ||
| 513 | |||
| 514 | expect(dnsCalls.map((call) => call.operation)).toEqual([ | ||
| 515 | "listRecords", | ||
| 516 | "createRecord", | ||
| 517 | "deleteRecord", | ||
| 518 | "updateRecord", | ||
| 519 | "listRecords", | ||
| 520 | "createRecord", | ||
| 521 | ]); | ||
| 522 | }).pipe(Effect.provide(layer)); | ||
| 523 | }); | ||
| 524 | |||
| 525 | it.effect( | ||
| 526 | "deprovisions checkpointed DNS and tunnel resources before removing the allocation", | ||
| 527 | () => { | ||
| 528 | const tunnelCalls: TunnelCall[] = []; | ||
| 529 | const dnsCalls: DnsCall[] = []; | ||
| 530 | const allocationCalls: AllocationCall[] = []; | ||
| 531 | const layer = providerLayer( | ||
| 532 | makePersistentTunnelClient(tunnelCalls), | ||
| 533 | makeDnsClient(dnsCalls), | ||
| 534 | makeAllocations(allocationCalls), | ||
| 535 | ); | ||
| 536 | |||
| 537 | return Effect.gen(function* () { | ||
| 538 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 539 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; | ||
| 540 | yield* provider.provision({ | ||
| 541 | ...key, | ||
| 542 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 543 | }); | ||
| 544 | yield* provider.deprovision(key); | ||
| 545 | |||
| 546 | expect(dnsCalls.map((call) => call.operation)).toEqual([ | ||
| 547 | "listRecords", | ||
| 548 | "createRecord", | ||
| 549 | "deleteRecord", | ||
| 550 | ]); | ||
| 551 | expect(tunnelCalls.map((call) => call.operation)).toEqual([ | ||
| 552 | "list", | ||
| 553 | "create", | ||
| 554 | "putConfiguration", | ||
| 555 | "getToken", | ||
| 556 | "delete", | ||
| 557 | ]); | ||
| 558 | expect(allocationCalls.map((call) => call.operation)).toEqual([ | ||
| 559 | "reserve", | ||
| 560 | "recordTunnel", | ||
| 561 | "recordDns", | ||
| 562 | "markReady", | ||
| 563 | "get", | ||
| 564 | "remove", | ||
| 565 | ]); | ||
| 566 | }).pipe(Effect.provide(layer)); | ||
| 567 | }, | ||
| 568 | ); | ||
| 569 | |||
| 570 | it.effect("treats an absent allocation as already deprovisioned", () => { | ||
| 571 | const tunnelCalls: TunnelCall[] = []; | ||
| 572 | const dnsCalls: DnsCall[] = []; | ||
| 573 | const allocationCalls: AllocationCall[] = []; | ||
| 574 | const layer = providerLayer( | ||
| 575 | makePersistentTunnelClient(tunnelCalls), | ||
| 576 | makeDnsClient(dnsCalls), | ||
| 577 | makeAllocations(allocationCalls), | ||
| 578 | ); | ||
| 579 | |||
| 580 | return Effect.gen(function* () { | ||
| 581 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 582 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; | ||
| 583 | yield* provider.deprovision(key); | ||
| 584 | |||
| 585 | expect(tunnelCalls).toEqual([]); | ||
| 586 | expect(dnsCalls).toEqual([]); | ||
| 587 | expect(allocationCalls).toEqual([{ operation: "get", input: key }]); | ||
| 588 | }).pipe(Effect.provide(layer)); | ||
| 589 | }); | ||
| 590 | |||
| 591 | it.effect("keeps the allocation when tunnel cleanup fails so unlink can retry", () => { | ||
| 592 | const allocationCalls: AllocationCall[] = []; | ||
| 593 | const tunnelCalls: TunnelCall[] = []; | ||
| 594 | let deleteAttempts = 0; | ||
| 595 | const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ | ||
| 596 | cause: "Cloudflare tunnel deletion failed", | ||
| 597 | }); | ||
| 598 | const tunnels = makePersistentTunnelClient(tunnelCalls); | ||
| 599 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ | ||
| 600 | ...tunnels, | ||
| 601 | delete: (tunnelId) => | ||
| 602 | Effect.gen(function* () { | ||
| 603 | tunnelCalls.push({ operation: "delete", input: tunnelId }); | ||
| 604 | deleteAttempts++; | ||
| 605 | if (deleteAttempts === 1) { | ||
| 606 | return yield* failure; | ||
| 607 | } | ||
| 608 | }), | ||
| 609 | }); | ||
| 610 | const layer = providerLayer(tunnelClient, makeDnsClient(), makeAllocations(allocationCalls)); | ||
| 611 | |||
| 612 | return Effect.gen(function* () { | ||
| 613 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 614 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; | ||
| 615 | yield* provider.provision({ | ||
| 616 | ...key, | ||
| 617 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 618 | }); | ||
| 619 | const first = yield* Effect.result(provider.deprovision(key)); | ||
| 620 | expect(first._tag).toBe("Failure"); | ||
| 621 | yield* provider.deprovision(key); | ||
| 622 | |||
| 623 | expect(allocationCalls.map((call) => call.operation)).toEqual([ | ||
| 624 | "reserve", | ||
| 625 | "recordTunnel", | ||
| 626 | "recordDns", | ||
| 627 | "markReady", | ||
| 628 | "get", | ||
| 629 | "get", | ||
| 630 | "remove", | ||
| 631 | ]); | ||
| 632 | }).pipe(Effect.provide(layer)); | ||
| 633 | }); | ||
| 634 | |||
| 635 | it.effect("treats already deleted remote resources as successfully deprovisioned", () => { | ||
| 636 | const allocationCalls: AllocationCall[] = []; | ||
| 637 | const notFound = { _tag: "NotFound" } as const; | ||
| 638 | const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({ | ||
| 639 | ...makeTunnelClient(), | ||
| 640 | delete: () => | ||
| 641 | Effect.fail( | ||
| 642 | new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause: notFound }), | ||
| 643 | ), | ||
| 644 | }); | ||
| 645 | const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ | ||
| 646 | ...makeDnsClient(), | ||
| 647 | deleteRecord: () => | ||
| 648 | Effect.fail(new ManagedEndpointProvider.ManagedEndpointDnsClientError({ cause: notFound })), | ||
| 649 | }); | ||
| 650 | const layer = providerLayer(tunnelClient, dnsClient, makeAllocations(allocationCalls)); | ||
| 651 | |||
| 652 | return Effect.gen(function* () { | ||
| 653 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 654 | const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; | ||
| 655 | yield* provider.provision({ | ||
| 656 | ...key, | ||
| 657 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 658 | }); | ||
| 659 | yield* provider.deprovision(key); | ||
| 660 | |||
| 661 | expect(allocationCalls.map((call) => call.operation)).toContain("remove"); | ||
| 662 | }).pipe(Effect.provide(layer)); | ||
| 663 | }); | ||
| 664 | |||
| 665 | it.effect("scopes managed endpoint resources by user", () => { | ||
| 666 | const tunnelCalls: TunnelCall[] = []; | ||
| 667 | |||
| 668 | return Effect.gen(function* () { | ||
| 669 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 670 | yield* provider.provision({ | ||
| 671 | userId: "user_ABC", | ||
| 672 | environmentId: "env_shared", | ||
| 673 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 674 | }); | ||
| 675 | yield* provider.provision({ | ||
| 676 | userId: "user_DEF", | ||
| 677 | environmentId: "env_shared", | ||
| 678 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 679 | }); | ||
| 680 | |||
| 681 | expect( | ||
| 682 | tunnelCalls.filter((call) => call.operation === "list").map((call) => call.input), | ||
| 683 | ).toEqual([ | ||
| 684 | { name: expectedManagedTunnelName("env_shared", "user_ABC"), isDeleted: false }, | ||
| 685 | { name: expectedManagedTunnelName("env_shared", "user_DEF"), isDeleted: false }, | ||
| 686 | ]); | ||
| 687 | }).pipe(Effect.provide(providerLayer(makeTunnelClient(tunnelCalls)))); | ||
| 688 | }); | ||
| 689 | |||
| 690 | it.effect("recovers when DNS creation reports failure after the record became visible", () => { | ||
| 691 | const dnsCalls: DnsCall[] = []; | ||
| 692 | const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ | ||
| 693 | cause: "ambiguous Cloudflare DNS response", | ||
| 694 | }); | ||
| 695 | let records: ReadonlyArray<{ readonly id: string }> = []; | ||
| 696 | const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ | ||
| 697 | listRecords: (hostname) => | ||
| 698 | Effect.sync(() => { | ||
| 699 | dnsCalls.push({ operation: "listRecords", input: hostname }); | ||
| 700 | return records; | ||
| 701 | }), | ||
| 702 | createRecord: (request) => | ||
| 703 | Effect.gen(function* () { | ||
| 704 | dnsCalls.push({ operation: "createRecord", input: request }); | ||
| 705 | records = [{ id: "created-record-id" }]; | ||
| 706 | return yield* failure; | ||
| 707 | }), | ||
| 708 | updateRecord: (dnsRecordId, request) => | ||
| 709 | Effect.sync(() => { | ||
| 710 | dnsCalls.push({ operation: "updateRecord", input: { dnsRecordId, request } }); | ||
| 711 | }), | ||
| 712 | deleteRecord: (dnsRecordId) => | ||
| 713 | Effect.sync(() => { | ||
| 714 | dnsCalls.push({ operation: "deleteRecord", input: dnsRecordId }); | ||
| 715 | }), | ||
| 716 | }); | ||
| 717 | |||
| 718 | return Effect.gen(function* () { | ||
| 719 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 720 | yield* provider.provision({ | ||
| 721 | userId: "user_ABC", | ||
| 722 | environmentId: "env_ABC", | ||
| 723 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 724 | }); | ||
| 725 | |||
| 726 | expect(dnsCalls.map((call) => call.operation)).toEqual([ | ||
| 727 | "listRecords", | ||
| 728 | "createRecord", | ||
| 729 | "listRecords", | ||
| 730 | "updateRecord", | ||
| 731 | ]); | ||
| 732 | }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); | ||
| 733 | }); | ||
| 734 | |||
| 735 | it.effect("fails provisioning when the DNS client fails", () => { | ||
| 736 | const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({ | ||
| 737 | cause: "Cloudflare DNS failure", | ||
| 738 | }); | ||
| 739 | const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({ | ||
| 740 | listRecords: () => Effect.fail(failure), | ||
| 741 | createRecord: () => Effect.die("unused"), | ||
| 742 | updateRecord: () => Effect.die("unused"), | ||
| 743 | deleteRecord: () => Effect.die("unused"), | ||
| 744 | }); | ||
| 745 | |||
| 746 | return Effect.gen(function* () { | ||
| 747 | const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 748 | const error = yield* Effect.flip( | ||
| 749 | provider.provision({ | ||
| 750 | userId: "user_ABC", | ||
| 751 | environmentId: "env_ABC", | ||
| 752 | origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, | ||
| 753 | }), | ||
| 754 | ); | ||
| 755 | |||
| 756 | expect(error._tag).toBe("ManagedEndpointProvisioningFailed"); | ||
| 757 | expect(error.cause).toBe(failure); | ||
| 758 | }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient))); | ||
| 759 | }); | ||
| 760 | }); | ||
infra/relay/src/environments/ManagedEndpointProvider.ts created+498| ... | @@ -0,0 +1,498 @@ | ||
| 1 | import * as Alchemy from "alchemy"; | ||
| 2 | import * as Cloudflare from "alchemy/Cloudflare"; | ||
| 3 | import * as Arr from "effect/Array"; | ||
| 4 | import * as Context from "effect/Context"; | ||
| 5 | import * as Crypto from "effect/Crypto"; | ||
| 6 | import * as Data from "effect/Data"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Encoding from "effect/Encoding"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import * as Option from "effect/Option"; | ||
| 11 | import * as Result from "effect/Result"; | ||
| 12 | |||
| 13 | import type { | ||
| 14 | RelayManagedEndpoint, | ||
| 15 | RelayManagedEndpointOrigin, | ||
| 16 | RelayManagedEndpointRuntimeConfig, | ||
| 17 | } from "@t3tools/contracts/relay"; | ||
| 18 | |||
| 19 | import * as RelayConfiguration from "../Config.ts"; | ||
| 20 | import { | ||
| 21 | managedEndpointDigestInput, | ||
| 22 | managedEndpointForHostname, | ||
| 23 | managedEndpointHostname, | ||
| 24 | managedEndpointTunnelName, | ||
| 25 | } from "../deploymentConfig.ts"; | ||
| 26 | import { ManagedEndpointAllocations } from "./ManagedEndpointAllocations.ts"; | ||
| 27 | |||
| 28 | export class ManagedEndpointProvisioningNotConfigured extends Data.TaggedError( | ||
| 29 | "ManagedEndpointProvisioningNotConfigured", | ||
| 30 | )<{}> {} | ||
| 31 | |||
| 32 | export class ManagedEndpointProvisioningFailed extends Data.TaggedError( | ||
| 33 | "ManagedEndpointProvisioningFailed", | ||
| 34 | )<{ | ||
| 35 | readonly cause: unknown; | ||
| 36 | }> {} | ||
| 37 | |||
| 38 | export class ManagedEndpointDeprovisioningFailed extends Data.TaggedError( | ||
| 39 | "ManagedEndpointDeprovisioningFailed", | ||
| 40 | )<{ | ||
| 41 | readonly cause: unknown; | ||
| 42 | }> {} | ||
| 43 | |||
| 44 | export class ManagedEndpointOriginNotAllowed extends Data.TaggedError( | ||
| 45 | "ManagedEndpointOriginNotAllowed", | ||
| 46 | )<{ | ||
| 47 | readonly host: string; | ||
| 48 | readonly port: number; | ||
| 49 | }> {} | ||
| 50 | |||
| 51 | export type ManagedEndpointProviderError = | ||
| 52 | | ManagedEndpointProvisioningNotConfigured | ||
| 53 | | ManagedEndpointProvisioningFailed | ||
| 54 | | ManagedEndpointOriginNotAllowed; | ||
| 55 | |||
| 56 | export interface ManagedEndpointProvisioningResult { | ||
| 57 | readonly endpoint: RelayManagedEndpoint; | ||
| 58 | readonly runtime: RelayManagedEndpointRuntimeConfig; | ||
| 59 | } | ||
| 60 | |||
| 61 | export interface ManagedEndpointProviderShape { | ||
| 62 | readonly provision: (input: { | ||
| 63 | readonly userId: string; | ||
| 64 | readonly environmentId: string; | ||
| 65 | readonly origin: RelayManagedEndpointOrigin; | ||
| 66 | }) => Effect.Effect<ManagedEndpointProvisioningResult, ManagedEndpointProviderError>; | ||
| 67 | readonly deprovision: (input: { | ||
| 68 | readonly userId: string; | ||
| 69 | readonly environmentId: string; | ||
| 70 | }) => Effect.Effect<void, ManagedEndpointDeprovisioningFailed>; | ||
| 71 | } | ||
| 72 | |||
| 73 | export class ManagedEndpointProvider extends Context.Service< | ||
| 74 | ManagedEndpointProvider, | ||
| 75 | ManagedEndpointProviderShape | ||
| 76 | >()("t3code-relay/environments/ManagedEndpointProvider") {} | ||
| 77 | |||
| 78 | interface ManagedEndpointTunnel { | ||
| 79 | readonly id?: string | null; | ||
| 80 | readonly name?: string | null; | ||
| 81 | } | ||
| 82 | |||
| 83 | export class ManagedEndpointTunnelClientError extends Data.TaggedError( | ||
| 84 | "ManagedEndpointTunnelClientError", | ||
| 85 | )<{ | ||
| 86 | readonly cause: unknown; | ||
| 87 | }> {} | ||
| 88 | |||
| 89 | export interface ManagedEndpointTunnelClientShape { | ||
| 90 | readonly list: (request: { | ||
| 91 | readonly name: string; | ||
| 92 | readonly isDeleted: false; | ||
| 93 | }) => Effect.Effect< | ||
| 94 | { readonly result: ReadonlyArray<ManagedEndpointTunnel> }, | ||
| 95 | ManagedEndpointTunnelClientError | ||
| 96 | >; | ||
| 97 | readonly create: (request: { | ||
| 98 | readonly name: string; | ||
| 99 | readonly configSrc: "cloudflare"; | ||
| 100 | }) => Effect.Effect<ManagedEndpointTunnel, ManagedEndpointTunnelClientError>; | ||
| 101 | readonly putConfiguration: ( | ||
| 102 | tunnelId: string, | ||
| 103 | config: { | ||
| 104 | readonly ingress: Array<{ | ||
| 105 | readonly hostname?: string; | ||
| 106 | readonly service: string; | ||
| 107 | }>; | ||
| 108 | }, | ||
| 109 | ) => Effect.Effect<unknown, ManagedEndpointTunnelClientError>; | ||
| 110 | readonly getToken: (tunnelId: string) => Effect.Effect<string, ManagedEndpointTunnelClientError>; | ||
| 111 | readonly delete: (tunnelId: string) => Effect.Effect<unknown, ManagedEndpointTunnelClientError>; | ||
| 112 | } | ||
| 113 | |||
| 114 | export class ManagedEndpointTunnelClient extends Context.Service< | ||
| 115 | ManagedEndpointTunnelClient, | ||
| 116 | ManagedEndpointTunnelClientShape | ||
| 117 | >()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {} | ||
| 118 | |||
| 119 | interface ManagedEndpointCnameRecordInput { | ||
| 120 | readonly type: "CNAME"; | ||
| 121 | readonly name: string; | ||
| 122 | readonly content: string; | ||
| 123 | readonly ttl: 1; | ||
| 124 | readonly proxied: true; | ||
| 125 | } | ||
| 126 | |||
| 127 | export class ManagedEndpointDnsClientError extends Data.TaggedError( | ||
| 128 | "ManagedEndpointDnsClientError", | ||
| 129 | )<{ | ||
| 130 | readonly cause: unknown; | ||
| 131 | }> {} | ||
| 132 | |||
| 133 | export interface ManagedEndpointDnsClientShape { | ||
| 134 | readonly listRecords: ( | ||
| 135 | hostname: string, | ||
| 136 | ) => Effect.Effect<ReadonlyArray<{ readonly id: string }>, ManagedEndpointDnsClientError>; | ||
| 137 | readonly createRecord: ( | ||
| 138 | request: ManagedEndpointCnameRecordInput, | ||
| 139 | ) => Effect.Effect<{ readonly id: string }, ManagedEndpointDnsClientError>; | ||
| 140 | readonly updateRecord: ( | ||
| 141 | dnsRecordId: string, | ||
| 142 | request: ManagedEndpointCnameRecordInput, | ||
| 143 | ) => Effect.Effect<unknown, ManagedEndpointDnsClientError>; | ||
| 144 | readonly deleteRecord: ( | ||
| 145 | dnsRecordId: string, | ||
| 146 | ) => Effect.Effect<unknown, ManagedEndpointDnsClientError>; | ||
| 147 | } | ||
| 148 | |||
| 149 | export class ManagedEndpointDnsClient extends Context.Service< | ||
| 150 | ManagedEndpointDnsClient, | ||
| 151 | ManagedEndpointDnsClientShape | ||
| 152 | >()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointDnsClient") {} | ||
| 153 | |||
| 154 | const requireCloudflareSettings = Effect.fnUntraced(function* ( | ||
| 155 | settings: RelayConfiguration.RelayConfigurationShape, | ||
| 156 | ) { | ||
| 157 | if (!settings.managedEndpointBaseDomain || !settings.managedEndpointNamespace) { | ||
| 158 | return yield* new ManagedEndpointProvisioningNotConfigured(); | ||
| 159 | } | ||
| 160 | return { | ||
| 161 | baseDomain: settings.managedEndpointBaseDomain, | ||
| 162 | namespace: settings.managedEndpointNamespace, | ||
| 163 | }; | ||
| 164 | }); | ||
| 165 | |||
| 166 | function formatOriginService(origin: RelayManagedEndpointOrigin): string { | ||
| 167 | const host = origin.localHttpHost.includes(":") | ||
| 168 | ? `[${origin.localHttpHost.replace(/^\[(.*)\]$/u, "$1")}]` | ||
| 169 | : origin.localHttpHost; | ||
| 170 | return `http://${host}:${origin.localHttpPort}`; | ||
| 171 | } | ||
| 172 | |||
| 173 | function normalizeHostname(hostname: string): string { | ||
| 174 | return hostname | ||
| 175 | .trim() | ||
| 176 | .toLowerCase() | ||
| 177 | .replace(/\.$/u, "") | ||
| 178 | .replace(/^\[(.*)\]$/u, "$1"); | ||
| 179 | } | ||
| 180 | |||
| 181 | function isLoopbackOrigin(origin: RelayManagedEndpointOrigin): boolean { | ||
| 182 | const hostname = normalizeHostname(origin.localHttpHost); | ||
| 183 | return ( | ||
| 184 | (hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost") && | ||
| 185 | Number.isInteger(origin.localHttpPort) && | ||
| 186 | origin.localHttpPort > 0 && | ||
| 187 | origin.localHttpPort <= 65_535 | ||
| 188 | ); | ||
| 189 | } | ||
| 190 | |||
| 191 | function isNotFoundCause(cause: unknown): boolean { | ||
| 192 | if (typeof cause !== "object" || cause === null) { | ||
| 193 | return false; | ||
| 194 | } | ||
| 195 | if ("_tag" in cause && cause._tag === "NotFound") { | ||
| 196 | return true; | ||
| 197 | } | ||
| 198 | if ("status" in cause && cause.status === 404) { | ||
| 199 | return true; | ||
| 200 | } | ||
| 201 | return "cause" in cause && isNotFoundCause(cause.cause); | ||
| 202 | } | ||
| 203 | |||
| 204 | const ignoreNotFound = <A, E>(effect: Effect.Effect<A, E>): Effect.Effect<void, E> => | ||
| 205 | effect.pipe( | ||
| 206 | Effect.asVoid, | ||
| 207 | Effect.catch((cause) => (isNotFoundCause(cause) ? Effect.void : Effect.fail(cause))), | ||
| 208 | ); | ||
| 209 | |||
| 210 | const make = Effect.gen(function* () { | ||
| 211 | const config = yield* RelayConfiguration.RelayConfiguration; | ||
| 212 | const crypto = yield* Crypto.Crypto; | ||
| 213 | const tunnels = yield* ManagedEndpointTunnelClient; | ||
| 214 | const dns = yield* ManagedEndpointDnsClient; | ||
| 215 | const allocations = yield* ManagedEndpointAllocations; | ||
| 216 | |||
| 217 | const updateExistingDnsRecords = Effect.fnUntraced(function* ( | ||
| 218 | records: ReadonlyArray<{ readonly id: string }>, | ||
| 219 | preferredDnsRecordId: string | null, | ||
| 220 | dnsRecord: ManagedEndpointCnameRecordInput, | ||
| 221 | ) { | ||
| 222 | const keptRecord = records.find((record) => record.id === preferredDnsRecordId) ?? records[0]; | ||
| 223 | if (keptRecord === undefined) { | ||
| 224 | return null; | ||
| 225 | } | ||
| 226 | yield* Effect.forEach( | ||
| 227 | records, | ||
| 228 | (record) => (record.id === keptRecord.id ? Effect.void : dns.deleteRecord(record.id)), | ||
| 229 | { discard: true }, | ||
| 230 | ); | ||
| 231 | yield* dns.updateRecord(keptRecord.id, dnsRecord); | ||
| 232 | return keptRecord.id; | ||
| 233 | }); | ||
| 234 | |||
| 235 | const ensureDnsRecord = Effect.fnUntraced(function* ( | ||
| 236 | hostname: string, | ||
| 237 | preferredDnsRecordId: string | null, | ||
| 238 | dnsRecord: ManagedEndpointCnameRecordInput, | ||
| 239 | ) { | ||
| 240 | if (preferredDnsRecordId !== null) { | ||
| 241 | const checkpointedRecordUpdated = yield* dns | ||
| 242 | .updateRecord(preferredDnsRecordId, dnsRecord) | ||
| 243 | .pipe( | ||
| 244 | Effect.as(true), | ||
| 245 | Effect.catch(() => Effect.succeed(false)), | ||
| 246 | ); | ||
| 247 | if (checkpointedRecordUpdated) { | ||
| 248 | return preferredDnsRecordId; | ||
| 249 | } | ||
| 250 | } | ||
| 251 | const existingDnsRecords = yield* dns.listRecords(hostname); | ||
| 252 | const existingDnsRecordId = yield* updateExistingDnsRecords( | ||
| 253 | existingDnsRecords, | ||
| 254 | preferredDnsRecordId, | ||
| 255 | dnsRecord, | ||
| 256 | ); | ||
| 257 | if (existingDnsRecordId !== null) { | ||
| 258 | return existingDnsRecordId; | ||
| 259 | } | ||
| 260 | return yield* dns.createRecord(dnsRecord).pipe( | ||
| 261 | Effect.map((record) => record.id), | ||
| 262 | Effect.catch((createError) => | ||
| 263 | Effect.gen(function* () { | ||
| 264 | let records = yield* dns.listRecords(hostname); | ||
| 265 | for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) { | ||
| 266 | yield* Effect.sleep("200 millis"); | ||
| 267 | records = yield* dns.listRecords(hostname); | ||
| 268 | } | ||
| 269 | return records; | ||
| 270 | }).pipe( | ||
| 271 | Effect.flatMap((records) => | ||
| 272 | records.length > 0 | ||
| 273 | ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord) | ||
| 274 | : Effect.fail(createError), | ||
| 275 | ), | ||
| 276 | Effect.flatMap((dnsRecordId) => | ||
| 277 | dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId), | ||
| 278 | ), | ||
| 279 | ), | ||
| 280 | ), | ||
| 281 | ); | ||
| 282 | }); | ||
| 283 | |||
| 284 | return ManagedEndpointProvider.of({ | ||
| 285 | deprovision: Effect.fn("relay.managed_endpoint_provider.deprovision")(function* (input) { | ||
| 286 | yield* Effect.annotateCurrentSpan({ | ||
| 287 | "relay.user_id": input.userId, | ||
| 288 | "relay.environment_id": input.environmentId, | ||
| 289 | }); | ||
| 290 | const allocation = yield* allocations | ||
| 291 | .get(input) | ||
| 292 | .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause }))); | ||
| 293 | if (allocation === null) { | ||
| 294 | return; | ||
| 295 | } | ||
| 296 | if (allocation.dnsRecordId !== null) { | ||
| 297 | yield* ignoreNotFound(dns.deleteRecord(allocation.dnsRecordId)).pipe( | ||
| 298 | Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })), | ||
| 299 | ); | ||
| 300 | } | ||
| 301 | if (allocation.tunnelId !== null) { | ||
| 302 | yield* ignoreNotFound(tunnels.delete(allocation.tunnelId)).pipe( | ||
| 303 | Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })), | ||
| 304 | ); | ||
| 305 | } | ||
| 306 | yield* allocations | ||
| 307 | .remove(input) | ||
| 308 | .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause }))); | ||
| 309 | }), | ||
| 310 | provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) { | ||
| 311 | yield* Effect.annotateCurrentSpan({ | ||
| 312 | "relay.user_id": input.userId, | ||
| 313 | "relay.environment_id": input.environmentId, | ||
| 314 | "relay.managed_endpoint.origin_host": input.origin.localHttpHost, | ||
| 315 | "relay.managed_endpoint.origin_port": input.origin.localHttpPort, | ||
| 316 | }); | ||
| 317 | if (!isLoopbackOrigin(input.origin)) { | ||
| 318 | return yield* new ManagedEndpointOriginNotAllowed({ | ||
| 319 | host: input.origin.localHttpHost, | ||
| 320 | port: input.origin.localHttpPort, | ||
| 321 | }); | ||
| 322 | } | ||
| 323 | const cf = yield* requireCloudflareSettings(config); | ||
| 324 | const environmentHash = yield* crypto | ||
| 325 | .digest( | ||
| 326 | "SHA-256", | ||
| 327 | new TextEncoder().encode( | ||
| 328 | managedEndpointDigestInput(cf.namespace, input.userId, input.environmentId), | ||
| 329 | ), | ||
| 330 | ) | ||
| 331 | .pipe( | ||
| 332 | Effect.map(Encoding.encodeHex), | ||
| 333 | Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), | ||
| 334 | ); | ||
| 335 | const allocation = yield* allocations | ||
| 336 | .reserve({ | ||
| 337 | userId: input.userId, | ||
| 338 | environmentId: input.environmentId, | ||
| 339 | hostname: managedEndpointHostname(cf.namespace, cf.baseDomain, environmentHash), | ||
| 340 | tunnelName: managedEndpointTunnelName(cf.namespace, environmentHash), | ||
| 341 | }) | ||
| 342 | .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); | ||
| 343 | const { hostname, tunnelName } = allocation; | ||
| 344 | |||
| 345 | const tunnel = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe( | ||
| 346 | Effect.map((tunnels) => tunnels.result), | ||
| 347 | Effect.map(Arr.findFirst((tunnel) => tunnel.name === tunnelName)), | ||
| 348 | Effect.flatMap( | ||
| 349 | Option.match({ | ||
| 350 | onSome: (tunnel) => Effect.succeed(tunnel), | ||
| 351 | onNone: () => tunnels.create({ name: tunnelName, configSrc: "cloudflare" }), | ||
| 352 | }), | ||
| 353 | ), | ||
| 354 | Effect.filterMapOrFail((tunnel) => | ||
| 355 | tunnel.id && tunnel.name | ||
| 356 | ? Result.succeed({ id: tunnel.id, name: tunnel.name }) | ||
| 357 | : Result.fail(new ManagedEndpointProvisioningFailed({ cause: tunnel })), | ||
| 358 | ), | ||
| 359 | Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), | ||
| 360 | ); | ||
| 361 | yield* allocations | ||
| 362 | .recordTunnel({ | ||
| 363 | userId: input.userId, | ||
| 364 | environmentId: input.environmentId, | ||
| 365 | tunnelId: tunnel.id, | ||
| 366 | }) | ||
| 367 | .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); | ||
| 368 | |||
| 369 | yield* tunnels | ||
| 370 | .putConfiguration(tunnel.id, { | ||
| 371 | ingress: [ | ||
| 372 | { | ||
| 373 | hostname, | ||
| 374 | service: formatOriginService(input.origin), | ||
| 375 | }, | ||
| 376 | { service: "http_status:404" }, | ||
| 377 | ], | ||
| 378 | }) | ||
| 379 | .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); | ||
| 380 | |||
| 381 | const dnsRecord = { | ||
| 382 | type: "CNAME", | ||
| 383 | name: hostname, | ||
| 384 | content: `${tunnel.id}.cfargotunnel.com`, | ||
| 385 | ttl: 1, | ||
| 386 | proxied: true, | ||
| 387 | } as const; | ||
| 388 | |||
| 389 | const dnsRecordId = yield* ensureDnsRecord(hostname, allocation.dnsRecordId, dnsRecord).pipe( | ||
| 390 | Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })), | ||
| 391 | ); | ||
| 392 | yield* allocations | ||
| 393 | .recordDns({ | ||
| 394 | userId: input.userId, | ||
| 395 | environmentId: input.environmentId, | ||
| 396 | dnsRecordId, | ||
| 397 | }) | ||
| 398 | .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); | ||
| 399 | |||
| 400 | const connectorToken = yield* tunnels | ||
| 401 | .getToken(tunnel.id) | ||
| 402 | .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); | ||
| 403 | yield* allocations | ||
| 404 | .markReady({ | ||
| 405 | userId: input.userId, | ||
| 406 | environmentId: input.environmentId, | ||
| 407 | }) | ||
| 408 | .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause }))); | ||
| 409 | |||
| 410 | return { | ||
| 411 | endpoint: managedEndpointForHostname(hostname), | ||
| 412 | runtime: { | ||
| 413 | providerKind: "cloudflare_tunnel", | ||
| 414 | connectorToken, | ||
| 415 | tunnelId: tunnel.id, | ||
| 416 | tunnelName: tunnel.name, | ||
| 417 | }, | ||
| 418 | } satisfies ManagedEndpointProvisioningResult; | ||
| 419 | }), | ||
| 420 | }); | ||
| 421 | }); | ||
| 422 | |||
| 423 | export const layer = Layer.effect(ManagedEndpointProvider, make); | ||
| 424 | |||
| 425 | export const layerCloudflareBindings = ( | ||
| 426 | tunnelClient: Cloudflare.TunnelReadWriteClient, | ||
| 427 | dnsClient: Cloudflare.DnsReadWriteClient, | ||
| 428 | alchemyRuntimeContext: Alchemy.BaseRuntimeContext, | ||
| 429 | ) => | ||
| 430 | layer.pipe( | ||
| 431 | Layer.provide( | ||
| 432 | Layer.mergeAll( | ||
| 433 | Layer.succeed( | ||
| 434 | ManagedEndpointTunnelClient, | ||
| 435 | ManagedEndpointTunnelClient.of({ | ||
| 436 | list: (request) => | ||
| 437 | tunnelClient.list(request).pipe( | ||
| 438 | Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), | ||
| 439 | Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), | ||
| 440 | ), | ||
| 441 | create: (request) => | ||
| 442 | tunnelClient.create(request).pipe( | ||
| 443 | Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), | ||
| 444 | Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), | ||
| 445 | ), | ||
| 446 | putConfiguration: (tunnelId, config) => | ||
| 447 | tunnelClient.putConfiguration(tunnelId, config).pipe( | ||
| 448 | Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), | ||
| 449 | Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), | ||
| 450 | ), | ||
| 451 | getToken: (tunnelId) => | ||
| 452 | tunnelClient.getToken(tunnelId).pipe( | ||
| 453 | Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), | ||
| 454 | Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), | ||
| 455 | ), | ||
| 456 | delete: (tunnelId) => | ||
| 457 | tunnelClient.delete(tunnelId).pipe( | ||
| 458 | Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })), | ||
| 459 | Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), | ||
| 460 | ), | ||
| 461 | }), | ||
| 462 | ), | ||
| 463 | Layer.succeed( | ||
| 464 | ManagedEndpointDnsClient, | ||
| 465 | ManagedEndpointDnsClient.of({ | ||
| 466 | listRecords: (hostname) => | ||
| 467 | dnsClient.listDnsRecords({ search: hostname }).pipe( | ||
| 468 | Effect.map((response) => | ||
| 469 | response.result.filter( | ||
| 470 | (record): record is typeof record & { readonly id: string } => | ||
| 471 | typeof record.id === "string" && | ||
| 472 | normalizeHostname(record.name) === normalizeHostname(hostname), | ||
| 473 | ), | ||
| 474 | ), | ||
| 475 | Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), | ||
| 476 | Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), | ||
| 477 | ), | ||
| 478 | createRecord: (request) => | ||
| 479 | dnsClient.createDnsRecord(request).pipe( | ||
| 480 | Effect.map((response) => ({ id: response.id })), | ||
| 481 | Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), | ||
| 482 | Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), | ||
| 483 | ), | ||
| 484 | updateRecord: (dnsRecordId, request) => | ||
| 485 | dnsClient.updateDnsRecord(dnsRecordId, request).pipe( | ||
| 486 | Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), | ||
| 487 | Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), | ||
| 488 | ), | ||
| 489 | deleteRecord: (dnsRecordId) => | ||
| 490 | dnsClient.deleteDnsRecord(dnsRecordId).pipe( | ||
| 491 | Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })), | ||
| 492 | Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext), | ||
| 493 | ), | ||
| 494 | }), | ||
| 495 | ), | ||
| 496 | ), | ||
| 497 | ), | ||
| 498 | ); | ||
infra/relay/src/http/Api.test.ts created+232| ... | @@ -0,0 +1,232 @@ | ||
| 1 | import { createClerkClient, verifyToken } from "@clerk/backend"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import { vi } from "vitest"; | ||
| 4 | import * as Context from "effect/Context"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Predicate from "effect/Predicate"; | ||
| 9 | import * as Redacted from "effect/Redacted"; | ||
| 10 | import * as Tracer from "effect/Tracer"; | ||
| 11 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; | ||
| 12 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | ||
| 13 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; | ||
| 14 | import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; | ||
| 15 | |||
| 16 | import { | ||
| 17 | relayCors, | ||
| 18 | relayDocsRedirectRoute, | ||
| 19 | relayEnvironmentAuthLayer, | ||
| 20 | relayNotFoundRoute, | ||
| 21 | traceRelayHttpRequestWith, | ||
| 22 | verifyRelayClientBearerToken, | ||
| 23 | withoutCapturedParentSpan, | ||
| 24 | } from "./Api.ts"; | ||
| 25 | import * as RelayConfiguration from "../Config.ts"; | ||
| 26 | import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; | ||
| 27 | |||
| 28 | vi.mock("@clerk/backend", () => ({ | ||
| 29 | createClerkClient: vi.fn(), | ||
| 30 | verifyToken: vi.fn(), | ||
| 31 | })); | ||
| 32 | |||
| 33 | const relaySettings: RelayConfiguration.RelayConfigurationShape = { | ||
| 34 | relayIssuer: "https://relay.example.test", | ||
| 35 | apns: { | ||
| 36 | teamId: "apns-team", | ||
| 37 | keyId: "apns-key", | ||
| 38 | privateKey: Redacted.make("apns-private-key"), | ||
| 39 | bundleId: "com.example.t3", | ||
| 40 | environment: "sandbox", | ||
| 41 | }, | ||
| 42 | clerkSecretKey: Redacted.make("clerk-secret-key"), | ||
| 43 | clerkPublishableKey: "pk_test_test", | ||
| 44 | clerkJwtAudience: "t3-code-relay", | ||
| 45 | apnsDeliveryJobSigningSecret: Redacted.make("apns-delivery-secret"), | ||
| 46 | cloudMintPrivateKey: Redacted.make("cloud-mint-private-key"), | ||
| 47 | cloudMintPublicKey: "cloud-mint-public-key", | ||
| 48 | managedEndpointBaseDomain: undefined, | ||
| 49 | managedEndpointNamespace: undefined, | ||
| 50 | }; | ||
| 51 | |||
| 52 | describe("relay client authentication", () => { | ||
| 53 | it.effect("preserves the existing Clerk session JWT path", () => | ||
| 54 | Effect.gen(function* () { | ||
| 55 | vi.mocked(verifyToken).mockResolvedValue({ | ||
| 56 | sub: "user_session", | ||
| 57 | aud: relaySettings.clerkJwtAudience, | ||
| 58 | } as never); | ||
| 59 | |||
| 60 | expect(yield* verifyRelayClientBearerToken(relaySettings, "session-token")).toEqual({ | ||
| 61 | sub: "user_session", | ||
| 62 | mode: "clerk_session_bearer", | ||
| 63 | }); | ||
| 64 | expect(verifyToken).toHaveBeenCalledWith("session-token", { | ||
| 65 | secretKey: "clerk-secret-key", | ||
| 66 | audience: relaySettings.clerkJwtAudience, | ||
| 67 | }); | ||
| 68 | expect(createClerkClient).not.toHaveBeenCalled(); | ||
| 69 | }).pipe( | ||
| 70 | Effect.ensuring( | ||
| 71 | Effect.sync(() => { | ||
| 72 | vi.mocked(verifyToken).mockReset(); | ||
| 73 | vi.mocked(createClerkClient).mockReset(); | ||
| 74 | }), | ||
| 75 | ), | ||
| 76 | ), | ||
| 77 | ); | ||
| 78 | |||
| 79 | it.effect("falls back to Clerk OAuth token verification for the headless CLI", () => | ||
| 80 | Effect.gen(function* () { | ||
| 81 | vi.mocked(verifyToken).mockRejectedValue(new Error("not a session JWT")); | ||
| 82 | vi.mocked(createClerkClient).mockReturnValue({ | ||
| 83 | authenticateRequest: vi.fn().mockResolvedValue({ | ||
| 84 | isAuthenticated: true, | ||
| 85 | toAuth: () => ({ userId: "user_oauth" }), | ||
| 86 | }), | ||
| 87 | } as never); | ||
| 88 | |||
| 89 | expect(yield* verifyRelayClientBearerToken(relaySettings, "oauth-token")).toEqual({ | ||
| 90 | sub: "user_oauth", | ||
| 91 | mode: "clerk_oauth_bearer", | ||
| 92 | }); | ||
| 93 | expect(createClerkClient).toHaveBeenCalledWith({ | ||
| 94 | secretKey: "clerk-secret-key", | ||
| 95 | publishableKey: "pk_test_test", | ||
| 96 | }); | ||
| 97 | }).pipe( | ||
| 98 | Effect.ensuring( | ||
| 99 | Effect.sync(() => { | ||
| 100 | vi.mocked(verifyToken).mockReset(); | ||
| 101 | vi.mocked(createClerkClient).mockReset(); | ||
| 102 | }), | ||
| 103 | ), | ||
| 104 | ), | ||
| 105 | ); | ||
| 106 | }); | ||
| 107 | |||
| 108 | describe("relay environment authentication", () => { | ||
| 109 | it.effect("preserves credential lookup persistence failures as internal errors", () => { | ||
| 110 | const failure = new EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError({ | ||
| 111 | cause: "database unavailable", | ||
| 112 | }); | ||
| 113 | const credentials: EnvironmentCredentials.EnvironmentCredentialsShape = { | ||
| 114 | create: () => Effect.die("unused create"), | ||
| 115 | authenticate: () => Effect.fail(failure), | ||
| 116 | revokeForEnvironmentPublicKey: () => Effect.die("unused revoke"), | ||
| 117 | }; | ||
| 118 | |||
| 119 | return Effect.gen(function* () { | ||
| 120 | const auth = yield* RelayEnvironmentAuth; | ||
| 121 | const error = yield* Effect.flip( | ||
| 122 | auth.environmentBearer(Effect.succeed(HttpServerResponse.empty()), { | ||
| 123 | credential: Redacted.make("environment-credential"), | ||
| 124 | endpoint: {} as never, | ||
| 125 | group: {} as never, | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | |||
| 129 | expect(Predicate.isTagged(error, "RelayInternalError")).toBe(true); | ||
| 130 | if (Predicate.isTagged(error, "RelayInternalError")) { | ||
| 131 | expect(error.reason).toBe("persistence_failed"); | ||
| 132 | } | ||
| 133 | }).pipe( | ||
| 134 | Effect.provideService( | ||
| 135 | HttpServerRequest.HttpServerRequest, | ||
| 136 | HttpServerRequest.fromWeb(new Request("https://relay.test/v1/server/link")), | ||
| 137 | ), | ||
| 138 | Effect.provideService(HttpServerRequest.ParsedSearchParams, {}), | ||
| 139 | Effect.provideService(HttpRouter.RouteContext, { | ||
| 140 | params: {}, | ||
| 141 | route: {} as never, | ||
| 142 | }), | ||
| 143 | Effect.provide( | ||
| 144 | relayEnvironmentAuthLayer.pipe( | ||
| 145 | Layer.provide(Layer.succeed(EnvironmentCredentials.EnvironmentCredentials, credentials)), | ||
| 146 | ), | ||
| 147 | ), | ||
| 148 | Effect.scoped, | ||
| 149 | ); | ||
| 150 | }); | ||
| 151 | }); | ||
| 152 | |||
| 153 | describe("relay request tracing", () => { | ||
| 154 | it.effect( | ||
| 155 | "does not parent endpoint spans to an ambient parent captured while building handlers", | ||
| 156 | () => | ||
| 157 | Effect.gen(function* () { | ||
| 158 | const spans: Array<Tracer.NativeSpan> = []; | ||
| 159 | const tracer = Tracer.make({ | ||
| 160 | span: (options) => { | ||
| 161 | const span = new Tracer.NativeSpan(options); | ||
| 162 | spans.push(span); | ||
| 163 | return span; | ||
| 164 | }, | ||
| 165 | }); | ||
| 166 | const ambientParent = Tracer.externalSpan({ | ||
| 167 | traceId: "00000000000000000000000000000001", | ||
| 168 | spanId: "0000000000000001", | ||
| 169 | sampled: true, | ||
| 170 | }); | ||
| 171 | const endpoint = yield* withoutCapturedParentSpan( | ||
| 172 | Effect.context<never>().pipe( | ||
| 173 | Effect.map((capturedContext: Context.Context<never>) => | ||
| 174 | Effect.succeed(HttpServerResponse.empty({ status: 204 })).pipe( | ||
| 175 | Effect.withSpan("relay.test.endpoint"), | ||
| 176 | Effect.provideContext(capturedContext), | ||
| 177 | ), | ||
| 178 | ), | ||
| 179 | ), | ||
| 180 | ).pipe(Effect.provideService(Tracer.ParentSpan, ambientParent)); | ||
| 181 | const request = HttpServerRequest.fromWeb( | ||
| 182 | new Request("https://relay.test/v1/mobile/devices?client=mobile", { | ||
| 183 | method: "POST", | ||
| 184 | }), | ||
| 185 | ); | ||
| 186 | |||
| 187 | yield* traceRelayHttpRequestWith(endpoint, Layer.succeed(Tracer.Tracer, tracer)).pipe( | ||
| 188 | Effect.provideService(HttpServerRequest.HttpServerRequest, request), | ||
| 189 | ); | ||
| 190 | |||
| 191 | expect(spans.map((span) => span.name)).toEqual(["http.server POST", "relay.test.endpoint"]); | ||
| 192 | expect(spans[0]?.kind).toBe("server"); | ||
| 193 | expect(spans[0]?.attributes.get("url.path")).toBe("/v1/mobile/devices"); | ||
| 194 | expect(spans[0]?.attributes.get("http.response.status_code")).toBe(204); | ||
| 195 | expect(Option.isNone(spans[0]!.parent)).toBe(true); | ||
| 196 | expect(Option.getOrUndefined(spans[1]!.parent)?.spanId).toBe(spans[0]?.spanId); | ||
| 197 | }), | ||
| 198 | ); | ||
| 199 | }); | ||
| 200 | |||
| 201 | describe("relay routing fallback", () => { | ||
| 202 | it.effect("redirects the relay root to the API docs", () => | ||
| 203 | Effect.gen(function* () { | ||
| 204 | const request = HttpServerRequest.fromWeb(new Request("https://relay.test/")); | ||
| 205 | const httpEffect = yield* HttpRouter.toHttpEffect( | ||
| 206 | Layer.mergeAll(relayDocsRedirectRoute, relayNotFoundRoute, relayCors), | ||
| 207 | ); | ||
| 208 | const response = yield* httpEffect.pipe( | ||
| 209 | Effect.provideService(HttpServerRequest.HttpServerRequest, request), | ||
| 210 | ); | ||
| 211 | |||
| 212 | expect(response.status).toBe(302); | ||
| 213 | expect(response.headers.location).toBe("/docs"); | ||
| 214 | expect(response.headers["access-control-allow-origin"]).toBe("*"); | ||
| 215 | }).pipe(Effect.scoped), | ||
| 216 | ); | ||
| 217 | |||
| 218 | it.effect("returns a CORS-compatible 404 response for unmatched paths", () => | ||
| 219 | Effect.gen(function* () { | ||
| 220 | const request = HttpServerRequest.fromWeb( | ||
| 221 | new Request("https://relay.test/v1/environmentsd", { method: "GET" }), | ||
| 222 | ); | ||
| 223 | const httpEffect = yield* HttpRouter.toHttpEffect(Layer.merge(relayNotFoundRoute, relayCors)); | ||
| 224 | const response = yield* httpEffect.pipe( | ||
| 225 | Effect.provideService(HttpServerRequest.HttpServerRequest, request), | ||
| 226 | ); | ||
| 227 | |||
| 228 | expect(response.status).toBe(404); | ||
| 229 | expect(response.headers["access-control-allow-origin"]).toBe("*"); | ||
| 230 | }).pipe(Effect.scoped), | ||
| 231 | ); | ||
| 232 | }); | ||
infra/relay/src/http/Api.ts created+1109| ... | @@ -0,0 +1,1109 @@ | ||
| 1 | import { createClerkClient, verifyToken } from "@clerk/backend"; | ||
| 2 | import { sql as drizzleSql } from "drizzle-orm"; | ||
| 3 | import * as Data from "effect/Data"; | ||
| 4 | import * as Crypto from "effect/Crypto"; | ||
| 5 | import * as Context from "effect/Context"; | ||
| 6 | import * as DateTime from "effect/DateTime"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Record from "effect/Record"; | ||
| 10 | import * as Redacted from "effect/Redacted"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as Tracer from "effect/Tracer"; | ||
| 13 | import * as HttpEffect from "effect/unstable/http/HttpEffect"; | ||
| 14 | import * as HttpMiddleware from "effect/unstable/http/HttpMiddleware"; | ||
| 15 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; | ||
| 16 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | ||
| 17 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; | ||
| 18 | import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext"; | ||
| 19 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ||
| 20 | import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"; | ||
| 21 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | ||
| 22 | |||
| 23 | import { | ||
| 24 | RelayApi, | ||
| 25 | RelayAgentActivityPublishProofExpiredError, | ||
| 26 | RelayAgentActivityPublishProofInvalidError, | ||
| 27 | RelayClientAuth, | ||
| 28 | RelayClientPrincipal, | ||
| 29 | RelayAccessTokenType, | ||
| 30 | RelayDpopClientAuth, | ||
| 31 | RelayEnvironmentConnectScope, | ||
| 32 | RelayEnvironmentStatusScope, | ||
| 33 | RelayMobileRegistrationScope, | ||
| 34 | RelayAuthInvalidError, | ||
| 35 | type RelayAuthInvalidReason, | ||
| 36 | RelayEnvironmentAuth, | ||
| 37 | RelayEnvironmentConnectNotAuthorizedError, | ||
| 38 | RelayEnvironmentEndpointTimedOutError, | ||
| 39 | RelayEnvironmentEndpointUnavailableError, | ||
| 40 | RelayEnvironmentLinkFailedError, | ||
| 41 | RelayEnvironmentLinkProofExpiredError, | ||
| 42 | RelayEnvironmentLinkProofInvalidError, | ||
| 43 | RelayEnvironmentLinkUnavailableError, | ||
| 44 | RelayEnvironmentPrincipal, | ||
| 45 | type RelayEnvironmentConnectRequest, | ||
| 46 | type RelayDpopAccessTokenScope, | ||
| 47 | RelayInternalError, | ||
| 48 | } from "@t3tools/contracts/relay"; | ||
| 49 | import { normalizeRelayIssuer } from "@t3tools/shared/relayJwt"; | ||
| 50 | |||
| 51 | import * as DeliveryAttempts from "../agentActivity/DeliveryAttempts.ts"; | ||
| 52 | import * as AgentActivityRows from "../agentActivity/AgentActivityRows.ts"; | ||
| 53 | import * as Devices from "../agentActivity/Devices.ts"; | ||
| 54 | import * as DpopProofs from "../auth/DpopProofs.ts"; | ||
| 55 | import * as RelayTokens from "../auth/RelayTokens.ts"; | ||
| 56 | import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; | ||
| 57 | import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; | ||
| 58 | import * as LiveActivities from "../agentActivity/LiveActivities.ts"; | ||
| 59 | import * as RelayConfiguration from "../Config.ts"; | ||
| 60 | import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts"; | ||
| 61 | import * as EnvironmentConnector from "../environments/EnvironmentConnector.ts"; | ||
| 62 | import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; | ||
| 63 | import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; | ||
| 64 | import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; | ||
| 65 | import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; | ||
| 66 | import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; | ||
| 67 | import { withSpanAttributes } from "../observability.ts"; | ||
| 68 | import { RelayDb } from "../db.ts"; | ||
| 69 | |||
| 70 | const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; | ||
| 71 | const relayCorsAllowedHeaders = [ | ||
| 72 | "authorization", | ||
| 73 | "b3", | ||
| 74 | "traceparent", | ||
| 75 | "content-type", | ||
| 76 | "dpop", | ||
| 77 | ] as const; | ||
| 78 | const relayCorsExposedHeaders = [ | ||
| 79 | "traceparent", | ||
| 80 | "x-t3-relay-auth-failure", | ||
| 81 | "www-authenticate", | ||
| 82 | ] as const; | ||
| 83 | |||
| 84 | const relayCorsHeaders = { | ||
| 85 | "access-control-allow-origin": "*", | ||
| 86 | "access-control-expose-headers": relayCorsExposedHeaders.join(","), | ||
| 87 | } as const; | ||
| 88 | |||
| 89 | const relayCorsPreflightHeaders = { | ||
| 90 | ...relayCorsHeaders, | ||
| 91 | "access-control-allow-methods": relayCorsAllowedMethods.join(","), | ||
| 92 | "access-control-allow-headers": relayCorsAllowedHeaders.join(","), | ||
| 93 | "access-control-max-age": "86400", | ||
| 94 | } as const; | ||
| 95 | |||
| 96 | const appendRelayCredentialResponseHeaders = HttpEffect.appendPreResponseHandler( | ||
| 97 | (_request, response) => | ||
| 98 | Effect.succeed( | ||
| 99 | HttpServerResponse.setHeaders(response, { | ||
| 100 | "cache-control": "no-store", | ||
| 101 | pragma: "no-cache", | ||
| 102 | }), | ||
| 103 | ), | ||
| 104 | ); | ||
| 105 | |||
| 106 | const appendRelayDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) => | ||
| 107 | Effect.succeed( | ||
| 108 | response.status === 401 | ||
| 109 | ? HttpServerResponse.setHeader(response, "www-authenticate", "DPoP") | ||
| 110 | : response, | ||
| 111 | ), | ||
| 112 | ); | ||
| 113 | |||
| 114 | const appendRelayTraceContextResponseHeader = Effect.gen(function* () { | ||
| 115 | const span = yield* Effect.currentParentSpan; | ||
| 116 | if (span._tag !== "Span") { | ||
| 117 | return; | ||
| 118 | } | ||
| 119 | const traceparent = HttpTraceContext.toHeaders(span).traceparent; | ||
| 120 | if (traceparent === undefined) { | ||
| 121 | return; | ||
| 122 | } | ||
| 123 | yield* HttpEffect.appendPreResponseHandler((_request, response) => | ||
| 124 | Effect.succeed(HttpServerResponse.setHeader(response, "traceparent", traceparent)), | ||
| 125 | ); | ||
| 126 | }).pipe(Effect.ignore); | ||
| 127 | |||
| 128 | export const relayCors = HttpRouter.middleware( | ||
| 129 | Effect.fnUntraced(function* <E, R>( | ||
| 130 | httpEffect: Effect.Effect< | ||
| 131 | HttpServerResponse.HttpServerResponse, | ||
| 132 | E, | ||
| 133 | HttpServerRequest.HttpServerRequest | R | ||
| 134 | >, | ||
| 135 | ) { | ||
| 136 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 137 | if (request.method === "OPTIONS") { | ||
| 138 | return HttpServerResponse.empty({ | ||
| 139 | status: 204, | ||
| 140 | headers: relayCorsPreflightHeaders, | ||
| 141 | }); | ||
| 142 | } | ||
| 143 | const response = yield* httpEffect; | ||
| 144 | return HttpServerResponse.setHeaders(response, relayCorsHeaders); | ||
| 145 | }), | ||
| 146 | { global: true }, | ||
| 147 | ); | ||
| 148 | |||
| 149 | export const relayNotFoundRoute = HttpRouter.add( | ||
| 150 | "*", | ||
| 151 | "/*", | ||
| 152 | HttpServerResponse.empty({ status: 404 }), | ||
| 153 | ); | ||
| 154 | |||
| 155 | export const relayDocsRedirectRoute = HttpRouter.add( | ||
| 156 | "GET", | ||
| 157 | "/", | ||
| 158 | HttpServerResponse.redirect("/docs"), | ||
| 159 | ); | ||
| 160 | |||
| 161 | export const traceRelayHttpRequest = <E, R>( | ||
| 162 | httpEffect: Effect.Effect< | ||
| 163 | HttpServerResponse.HttpServerResponse, | ||
| 164 | E, | ||
| 165 | HttpServerRequest.HttpServerRequest | R | ||
| 166 | >, | ||
| 167 | ) => | ||
| 168 | // HttpMiddleware finalizes its span on the dispatcher; do not close a request-scoped exporter first. | ||
| 169 | HttpMiddleware.tracer( | ||
| 170 | appendRelayTraceContextResponseHeader.pipe(Effect.andThen(httpEffect)), | ||
| 171 | ).pipe(Effect.ensuring(Effect.yieldNow)); | ||
| 172 | |||
| 173 | export const traceRelayHttpRequestWith = <E, R, LayerError, LayerRequirements>( | ||
| 174 | httpEffect: Effect.Effect< | ||
| 175 | HttpServerResponse.HttpServerResponse, | ||
| 176 | E, | ||
| 177 | HttpServerRequest.HttpServerRequest | R | ||
| 178 | >, | ||
| 179 | tracerLayer: Layer.Layer<never, LayerError, LayerRequirements>, | ||
| 180 | ) => traceRelayHttpRequest(httpEffect).pipe(Effect.provide(tracerLayer)); | ||
| 181 | |||
| 182 | export const withoutCapturedParentSpan = <A, E, R>( | ||
| 183 | effect: Effect.Effect<A, E, R>, | ||
| 184 | ): Effect.Effect<A, E, R> => | ||
| 185 | Effect.withFiber((fiber) => { | ||
| 186 | const context = fiber.context; | ||
| 187 | // HttpApiBuilder captures its build context for route handlers; an event parent would outlive export. | ||
| 188 | fiber.setContext(Context.omit(Tracer.ParentSpan)(context)); | ||
| 189 | return effect.pipe(Effect.ensuring(Effect.sync(() => fiber.setContext(context)))); | ||
| 190 | }); | ||
| 191 | |||
| 192 | export const relayClientAuthLayer = Layer.effect( | ||
| 193 | RelayClientAuth, | ||
| 194 | Effect.gen(function* () { | ||
| 195 | const config = yield* RelayConfiguration.RelayConfiguration; | ||
| 196 | return { | ||
| 197 | clientBearer: Effect.fn("relay.auth.client.bearer")(function* (httpEffect, { credential }) { | ||
| 198 | const token = readHttpAuthorizationCredential(credential); | ||
| 199 | const verified = yield* verifyRelayClientBearerToken(config, token).pipe( | ||
| 200 | Effect.tapError((error) => | ||
| 201 | Effect.annotateCurrentSpan( | ||
| 202 | "relay.auth.clerk_verification_failure", | ||
| 203 | clerkVerificationFailureReason(error.cause), | ||
| 204 | ), | ||
| 205 | ), | ||
| 206 | Effect.catch(() => relayAuthInvalidError("invalid_bearer")), | ||
| 207 | ); | ||
| 208 | if (!verified.sub) { | ||
| 209 | yield* Effect.annotateCurrentSpan({ | ||
| 210 | "relay.auth.clerk_verification_failure": "missing_subject", | ||
| 211 | }); | ||
| 212 | return yield* relayAuthInvalidError("invalid_bearer"); | ||
| 213 | } | ||
| 214 | yield* Effect.annotateCurrentSpan({ | ||
| 215 | "relay.auth.mode": verified.mode, | ||
| 216 | "relay.auth.subject": verified.sub, | ||
| 217 | }); | ||
| 218 | |||
| 219 | return yield* httpEffect.pipe( | ||
| 220 | withSpanAttributes({ "user.id": verified.sub }), | ||
| 221 | Effect.provideService(RelayClientPrincipal, { | ||
| 222 | userId: verified.sub, | ||
| 223 | token, | ||
| 224 | }), | ||
| 225 | ); | ||
| 226 | }), | ||
| 227 | }; | ||
| 228 | }), | ||
| 229 | ); | ||
| 230 | |||
| 231 | export const relayEnvironmentAuthLayer = Layer.effect( | ||
| 232 | RelayEnvironmentAuth, | ||
| 233 | Effect.gen(function* () { | ||
| 234 | const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; | ||
| 235 | return { | ||
| 236 | environmentBearer: Effect.fn("relay.auth.environment.bearer")(function* ( | ||
| 237 | httpEffect, | ||
| 238 | { credential }, | ||
| 239 | ) { | ||
| 240 | const token = readHttpAuthorizationCredential(credential); | ||
| 241 | const principal = yield* credentials | ||
| 242 | .authenticate(token) | ||
| 243 | .pipe( | ||
| 244 | Effect.catchTag("EnvironmentCredentialAuthenticatePersistenceError", () => | ||
| 245 | relayInternalErrorResponse("persistence_failed"), | ||
| 246 | ), | ||
| 247 | ); | ||
| 248 | if (principal._tag === "None") { | ||
| 249 | return yield* relayAuthInvalidError("not_authorized"); | ||
| 250 | } | ||
| 251 | yield* Effect.annotateCurrentSpan({ | ||
| 252 | "relay.auth.mode": "environment_credential", | ||
| 253 | }); | ||
| 254 | return yield* httpEffect.pipe( | ||
| 255 | withSpanAttributes({ | ||
| 256 | "relay.environment_id": principal.value.environmentId, | ||
| 257 | }), | ||
| 258 | Effect.provideService(RelayEnvironmentPrincipal, principal.value), | ||
| 259 | ); | ||
| 260 | }), | ||
| 261 | }; | ||
| 262 | }), | ||
| 263 | ); | ||
| 264 | |||
| 265 | export const relayDpopClientAuthLayer = Layer.effect( | ||
| 266 | RelayDpopClientAuth, | ||
| 267 | Effect.gen(function* () { | ||
| 268 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 269 | return { | ||
| 270 | relayDpop: Effect.fn("relay.auth.dpop_client")(function* (httpEffect, { credential }) { | ||
| 271 | yield* appendRelayDpopChallengeHeader; | ||
| 272 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 273 | if (!isDpopAuthorizationHeader(request.headers.authorization)) { | ||
| 274 | return yield* relayAuthInvalidError("invalid_bearer"); | ||
| 275 | } | ||
| 276 | const token = readHttpAuthorizationCredential(credential); | ||
| 277 | const now = yield* DateTime.now; | ||
| 278 | const verified = yield* relayTokens.verifyDpopAccessToken({ | ||
| 279 | token, | ||
| 280 | nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 281 | }); | ||
| 282 | if (!verified) { | ||
| 283 | return yield* relayAuthInvalidError("invalid_bearer"); | ||
| 284 | } | ||
| 285 | yield* Effect.annotateCurrentSpan({ | ||
| 286 | "relay.auth.mode": "dpop", | ||
| 287 | "relay.auth.subject": verified.sub, | ||
| 288 | }); | ||
| 289 | return yield* httpEffect.pipe( | ||
| 290 | withSpanAttributes({ "user.id": verified.sub }), | ||
| 291 | Effect.provideService(RelayClientPrincipal, { | ||
| 292 | userId: verified.sub, | ||
| 293 | token, | ||
| 294 | proofKeyThumbprint: verified.cnf.jkt, | ||
| 295 | dpopScopes: verified.scope, | ||
| 296 | }), | ||
| 297 | ); | ||
| 298 | }), | ||
| 299 | }; | ||
| 300 | }), | ||
| 301 | ); | ||
| 302 | |||
| 303 | function isDpopAuthorizationHeader(value: string | undefined): boolean { | ||
| 304 | return /^DPoP +/iu.test(value ?? ""); | ||
| 305 | } | ||
| 306 | |||
| 307 | function readHttpAuthorizationCredential(credential: Redacted.Redacted<string>): string { | ||
| 308 | // Effect beta.73 leaves the scheme separator in decoded HTTP credentials. | ||
| 309 | return Redacted.value(credential).trimStart(); | ||
| 310 | } | ||
| 311 | |||
| 312 | export const metadataApi = HttpApiBuilder.group( | ||
| 313 | RelayApi, | ||
| 314 | "metadata", | ||
| 315 | Effect.fnUntraced(function* (handlers) { | ||
| 316 | const settings = yield* RelayConfiguration.RelayConfiguration; | ||
| 317 | const issuer = normalizeRelayIssuer(settings.relayIssuer); | ||
| 318 | const scopes = [ | ||
| 319 | RelayEnvironmentConnectScope, | ||
| 320 | RelayEnvironmentStatusScope, | ||
| 321 | RelayMobileRegistrationScope, | ||
| 322 | ] as const; | ||
| 323 | return handlers | ||
| 324 | .handle("authorizationServer", () => | ||
| 325 | Effect.succeed({ | ||
| 326 | issuer, | ||
| 327 | token_endpoint: `${issuer}/v1/client/dpop-token`, | ||
| 328 | grant_types_supported: ["urn:ietf:params:oauth:grant-type:token-exchange"], | ||
| 329 | token_endpoint_auth_methods_supported: ["none"], | ||
| 330 | dpop_signing_alg_values_supported: ["ES256"], | ||
| 331 | scopes_supported: scopes, | ||
| 332 | }), | ||
| 333 | ) | ||
| 334 | .handle("protectedResource", () => | ||
| 335 | Effect.succeed({ | ||
| 336 | resource: issuer, | ||
| 337 | authorization_servers: [issuer], | ||
| 338 | scopes_supported: scopes, | ||
| 339 | dpop_bound_access_tokens_required: true, | ||
| 340 | dpop_signing_alg_values_supported: ["ES256"], | ||
| 341 | }), | ||
| 342 | ); | ||
| 343 | }), | ||
| 344 | ); | ||
| 345 | |||
| 346 | export const healthApi = HttpApiBuilder.group( | ||
| 347 | RelayApi, | ||
| 348 | "health", | ||
| 349 | Effect.fnUntraced(function* (handlers) { | ||
| 350 | const db = yield* RelayDb; | ||
| 351 | return handlers.handle( | ||
| 352 | "health", | ||
| 353 | Effect.fn("relay.api.health")( | ||
| 354 | function* () { | ||
| 355 | const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis); | ||
| 356 | yield* db.execute(drizzleSql`SELECT 1`); | ||
| 357 | const completedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis); | ||
| 358 | yield* Effect.logInfo("relay health db probe completed", { | ||
| 359 | durationMs: completedAt - startedAt, | ||
| 360 | }); | ||
| 361 | return { ok: true, service: "relay" as const }; | ||
| 362 | }, | ||
| 363 | Effect.catch(() => relayInternalErrorResponse("database_unavailable")), | ||
| 364 | ), | ||
| 365 | ); | ||
| 366 | }), | ||
| 367 | ); | ||
| 368 | |||
| 369 | export const mobileApi = HttpApiBuilder.group( | ||
| 370 | RelayApi, | ||
| 371 | "mobile", | ||
| 372 | Effect.fnUntraced(function* (handlers) { | ||
| 373 | const registrations = yield* MobileRegistrations.MobileRegistrations; | ||
| 374 | const dpopProofs = yield* DpopProofs.DpopProofReplay; | ||
| 375 | return handlers | ||
| 376 | .handle( | ||
| 377 | "registerDevice", | ||
| 378 | Effect.fn("relay.api.mobile.registerDevice")(function* (args) { | ||
| 379 | const { payload } = args; | ||
| 380 | const { userId, token } = yield* RelayClientPrincipal; | ||
| 381 | const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); | ||
| 382 | yield* requireDpopThumbprint(proofKeyThumbprint, { | ||
| 383 | expectedAccessToken: token, | ||
| 384 | }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); | ||
| 385 | return yield* registrations.registerDevice({ userId, payload }); | ||
| 386 | }, mapRelayCommonApiErrors("invalid_dpop")), | ||
| 387 | ) | ||
| 388 | .handle( | ||
| 389 | "registerLiveActivity", | ||
| 390 | Effect.fn("relay.api.mobile.registerLiveActivity")(function* (args) { | ||
| 391 | const { payload } = args; | ||
| 392 | const { userId, token } = yield* RelayClientPrincipal; | ||
| 393 | const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); | ||
| 394 | yield* requireDpopThumbprint(proofKeyThumbprint, { | ||
| 395 | expectedAccessToken: token, | ||
| 396 | }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); | ||
| 397 | return yield* registrations.registerLiveActivity({ userId, payload }); | ||
| 398 | }, mapRelayCommonApiErrors("invalid_dpop")), | ||
| 399 | ) | ||
| 400 | .handle( | ||
| 401 | "unregisterDevice", | ||
| 402 | Effect.fn("relay.api.mobile.unregisterDevice")(function* (args) { | ||
| 403 | const { params } = args; | ||
| 404 | const { userId, token } = yield* RelayClientPrincipal; | ||
| 405 | const proofKeyThumbprint = yield* requireDpopPrincipalScope("mobile:registration"); | ||
| 406 | yield* requireDpopThumbprint(proofKeyThumbprint, { | ||
| 407 | expectedAccessToken: token, | ||
| 408 | }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); | ||
| 409 | return yield* registrations.unregisterDevice({ userId, deviceId: params.deviceId }); | ||
| 410 | }, mapRelayCommonApiErrors("invalid_dpop")), | ||
| 411 | ); | ||
| 412 | }), | ||
| 413 | ); | ||
| 414 | |||
| 415 | export const clientApi = HttpApiBuilder.group( | ||
| 416 | RelayApi, | ||
| 417 | "client", | ||
| 418 | Effect.fnUntraced(function* (handlers) { | ||
| 419 | const config = yield* RelayConfiguration.RelayConfiguration; | ||
| 420 | const crypto = yield* Crypto.Crypto; | ||
| 421 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 422 | const linker = yield* EnvironmentLinker.EnvironmentLinker; | ||
| 423 | const links = yield* EnvironmentLinks.EnvironmentLinks; | ||
| 424 | const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; | ||
| 425 | const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; | ||
| 426 | const devices = yield* Devices.Devices; | ||
| 427 | return handlers | ||
| 428 | .handle( | ||
| 429 | "listEnvironments", | ||
| 430 | Effect.fn("relay.api.client.listEnvironments")(function* () { | ||
| 431 | const { userId } = yield* RelayClientPrincipal; | ||
| 432 | const environments = yield* links.listForUser({ userId }); | ||
| 433 | return { environments }; | ||
| 434 | }, mapRelayCommonApiErrors("not_authorized")), | ||
| 435 | ) | ||
| 436 | .handle( | ||
| 437 | "listDevices", | ||
| 438 | Effect.fn("relay.api.client.listDevices")(function* () { | ||
| 439 | const { userId } = yield* RelayClientPrincipal; | ||
| 440 | return { devices: yield* devices.listForUser({ userId }) }; | ||
| 441 | }, mapRelayCommonApiErrors("not_authorized")), | ||
| 442 | ) | ||
| 443 | .handle( | ||
| 444 | "linkEnvironment", | ||
| 445 | Effect.fn("relay.api.client.linkEnvironment")( | ||
| 446 | function* (args) { | ||
| 447 | const { payload } = args; | ||
| 448 | yield* appendRelayCredentialResponseHeaders; | ||
| 449 | const { userId } = yield* RelayClientPrincipal; | ||
| 450 | const result = yield* linker.link({ userId, request: payload }); | ||
| 451 | return { | ||
| 452 | ok: true, | ||
| 453 | cloudUserId: userId, | ||
| 454 | environmentId: result.environmentId, | ||
| 455 | endpoint: result.endpoint, | ||
| 456 | endpointRuntime: result.endpointRuntime, | ||
| 457 | relayIssuer: config.relayIssuer, | ||
| 458 | environmentCredential: result.environmentCredential, | ||
| 459 | cloudMintPublicKey: config.cloudMintPublicKey, | ||
| 460 | }; | ||
| 461 | }, | ||
| 462 | mapErrorTags({ | ||
| 463 | EnvironmentLinkProofExpired: (_error, traceId) => | ||
| 464 | new RelayEnvironmentLinkProofExpiredError({ | ||
| 465 | code: "environment_link_proof_expired", | ||
| 466 | traceId, | ||
| 467 | }), | ||
| 468 | EnvironmentLinkProofInvalid: (linkError, traceId) => | ||
| 469 | new RelayEnvironmentLinkProofInvalidError({ | ||
| 470 | code: "environment_link_proof_invalid", | ||
| 471 | reason: linkError.reason, | ||
| 472 | traceId, | ||
| 473 | }), | ||
| 474 | ManagedEndpointProvisioningNotConfigured: (_error, traceId) => | ||
| 475 | new RelayEnvironmentLinkUnavailableError({ | ||
| 476 | code: "environment_link_unavailable", | ||
| 477 | reason: "managed_endpoint_not_configured", | ||
| 478 | traceId, | ||
| 479 | }), | ||
| 480 | ManagedEndpointProvisioningFailed: (_error, traceId) => | ||
| 481 | new RelayEnvironmentLinkUnavailableError({ | ||
| 482 | code: "environment_link_unavailable", | ||
| 483 | reason: "managed_endpoint_provisioning_failed", | ||
| 484 | traceId, | ||
| 485 | }), | ||
| 486 | ManagedEndpointOriginNotAllowed: (_error, traceId) => | ||
| 487 | new RelayEnvironmentLinkProofInvalidError({ | ||
| 488 | code: "environment_link_proof_invalid", | ||
| 489 | reason: "origin_not_allowed", | ||
| 490 | traceId, | ||
| 491 | }), | ||
| 492 | EnvironmentLinkUpsertPersistenceError: (_error, traceId) => | ||
| 493 | new RelayEnvironmentLinkFailedError({ | ||
| 494 | code: "environment_link_failed", | ||
| 495 | reason: "link_persistence_failed", | ||
| 496 | traceId, | ||
| 497 | }), | ||
| 498 | EnvironmentCredentialCreatePersistenceError: (_error, traceId) => | ||
| 499 | new RelayEnvironmentLinkFailedError({ | ||
| 500 | code: "environment_link_failed", | ||
| 501 | reason: "credential_persistence_failed", | ||
| 502 | traceId, | ||
| 503 | }), | ||
| 504 | DpopProofReplayPersistenceError: (_error, traceId) => | ||
| 505 | new RelayEnvironmentLinkFailedError({ | ||
| 506 | code: "environment_link_failed", | ||
| 507 | reason: "replay_persistence_failed", | ||
| 508 | traceId, | ||
| 509 | }), | ||
| 510 | }), | ||
| 511 | mapRelayCommonApiErrors("not_authorized"), | ||
| 512 | ), | ||
| 513 | ) | ||
| 514 | .handle( | ||
| 515 | "createEnvironmentLinkChallenge", | ||
| 516 | Effect.fn("relay.api.client.createEnvironmentLinkChallenge")(function* (args) { | ||
| 517 | yield* appendRelayCredentialResponseHeaders; | ||
| 518 | const { userId } = yield* RelayClientPrincipal; | ||
| 519 | const now = yield* DateTime.now; | ||
| 520 | const expiresAt = DateTime.add(now, { minutes: 5 }); | ||
| 521 | const jti = yield* crypto.randomUUIDv4.pipe( | ||
| 522 | Effect.catch(() => relayInternalErrorResponse("internal_error")), | ||
| 523 | ); | ||
| 524 | const challenge = yield* relayTokens | ||
| 525 | .issueLinkChallenge({ | ||
| 526 | userId, | ||
| 527 | request: args.payload, | ||
| 528 | jti, | ||
| 529 | issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 530 | expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000), | ||
| 531 | }) | ||
| 532 | .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))); | ||
| 533 | return { challenge, expiresAt: DateTime.formatIso(expiresAt) }; | ||
| 534 | }, mapRelayCommonApiErrors("not_authorized")), | ||
| 535 | ) | ||
| 536 | .handle( | ||
| 537 | "unlinkEnvironment", | ||
| 538 | Effect.fn("relay.api.client.unlinkEnvironment")(function* (args) { | ||
| 539 | const { params } = args; | ||
| 540 | const { userId } = yield* RelayClientPrincipal; | ||
| 541 | yield* managedEndpointProvider | ||
| 542 | .deprovision({ | ||
| 543 | userId, | ||
| 544 | environmentId: params.environmentId, | ||
| 545 | }) | ||
| 546 | .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))); | ||
| 547 | const link = yield* links.getForUser({ | ||
| 548 | userId, | ||
| 549 | environmentId: params.environmentId, | ||
| 550 | }); | ||
| 551 | if (link === null) { | ||
| 552 | return { ok: false }; | ||
| 553 | } | ||
| 554 | const unlinked = yield* links.revokeForUser({ | ||
| 555 | userId, | ||
| 556 | environmentId: params.environmentId, | ||
| 557 | }); | ||
| 558 | if (unlinked) { | ||
| 559 | yield* credentials.revokeForEnvironmentPublicKey({ | ||
| 560 | environmentId: link.environmentId, | ||
| 561 | environmentPublicKey: link.environmentPublicKey, | ||
| 562 | }); | ||
| 563 | } | ||
| 564 | return { ok: unlinked }; | ||
| 565 | }, mapRelayCommonApiErrors("not_authorized")), | ||
| 566 | ); | ||
| 567 | }), | ||
| 568 | ); | ||
| 569 | |||
| 570 | export const tokenApi = HttpApiBuilder.group( | ||
| 571 | RelayApi, | ||
| 572 | "token", | ||
| 573 | Effect.fnUntraced(function* (handlers) { | ||
| 574 | const config = yield* RelayConfiguration.RelayConfiguration; | ||
| 575 | const crypto = yield* Crypto.Crypto; | ||
| 576 | const dpopProofs = yield* DpopProofs.DpopProofReplay; | ||
| 577 | const relayTokens = yield* RelayTokens.RelayTokens; | ||
| 578 | return handlers.handle( | ||
| 579 | "exchangeDpopAccessToken", | ||
| 580 | Effect.fn("relay.api.token.exchangeDpopAccessToken")(function* (args) { | ||
| 581 | yield* appendRelayCredentialResponseHeaders; | ||
| 582 | const issuer = normalizeRelayIssuer(config.relayIssuer); | ||
| 583 | const requestedScopes = relayTokens.resolveDpopAccessTokenScopes({ | ||
| 584 | clientId: args.payload.client_id, | ||
| 585 | scope: args.payload.scope, | ||
| 586 | }); | ||
| 587 | yield* Effect.annotateCurrentSpan({ | ||
| 588 | "relay.auth.mode": "clerk_bearer_token_exchange", | ||
| 589 | "relay.oauth.client_id": args.payload.client_id, | ||
| 590 | "relay.oauth.scopes": args.payload.scope, | ||
| 591 | }); | ||
| 592 | if (args.payload.resource !== issuer || requestedScopes === null) { | ||
| 593 | return yield* new HttpApiError.Unauthorized({}); | ||
| 594 | } | ||
| 595 | |||
| 596 | const verified = yield* verifyClerkBearerToken(config, args.payload.subject_token).pipe( | ||
| 597 | Effect.catch(() => relayAuthInvalidError("invalid_bearer")), | ||
| 598 | ); | ||
| 599 | if (!verified.sub || !hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience)) { | ||
| 600 | return yield* relayAuthInvalidError("invalid_bearer"); | ||
| 601 | } | ||
| 602 | const proofKeyThumbprint = yield* requireDpopProof().pipe( | ||
| 603 | Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs), | ||
| 604 | ); | ||
| 605 | const now = yield* DateTime.now; | ||
| 606 | const expiresAt = DateTime.add(now, { minutes: 5 }); | ||
| 607 | const jti = yield* crypto.randomUUIDv4.pipe( | ||
| 608 | Effect.catch(() => relayInternalErrorResponse("internal_error")), | ||
| 609 | ); | ||
| 610 | return { | ||
| 611 | access_token: yield* relayTokens | ||
| 612 | .issueDpopAccessToken({ | ||
| 613 | userId: verified.sub, | ||
| 614 | proofKeyThumbprint, | ||
| 615 | jti, | ||
| 616 | issuedAtEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), | ||
| 617 | expiresAtEpochSeconds: Math.floor(expiresAt.epochMilliseconds / 1_000), | ||
| 618 | clientId: args.payload.client_id, | ||
| 619 | scopes: requestedScopes, | ||
| 620 | }) | ||
| 621 | .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))), | ||
| 622 | issued_token_type: RelayAccessTokenType, | ||
| 623 | token_type: "DPoP" as const, | ||
| 624 | expires_in: 300, | ||
| 625 | scope: encodeOAuthScope(requestedScopes), | ||
| 626 | }; | ||
| 627 | }, mapRelayCommonApiErrors("invalid_dpop")), | ||
| 628 | ); | ||
| 629 | }), | ||
| 630 | ); | ||
| 631 | |||
| 632 | export const dpopClientApi = HttpApiBuilder.group( | ||
| 633 | RelayApi, | ||
| 634 | "dpopClient", | ||
| 635 | Effect.fnUntraced(function* (handlers) { | ||
| 636 | const connector = yield* EnvironmentConnector.EnvironmentConnector; | ||
| 637 | const dpopProofs = yield* DpopProofs.DpopProofReplay; | ||
| 638 | return handlers | ||
| 639 | .handle( | ||
| 640 | "connectEnvironment", | ||
| 641 | Effect.fn("relay.api.dpopClient.connectEnvironment")( | ||
| 642 | function* (args) { | ||
| 643 | const { params, payload } = args; | ||
| 644 | yield* appendRelayCredentialResponseHeaders; | ||
| 645 | const { userId, token } = yield* RelayClientPrincipal; | ||
| 646 | const proofKeyThumbprint = yield* requireDpopPrincipalScope("environment:connect"); | ||
| 647 | const requestedThumbprint = resolveConnectClientKeyThumbprint(payload); | ||
| 648 | if (!requestedThumbprint || requestedThumbprint !== proofKeyThumbprint) { | ||
| 649 | return yield* new HttpApiError.Unauthorized({}); | ||
| 650 | } | ||
| 651 | const clientProofKeyThumbprint = yield* requireDpopThumbprint(proofKeyThumbprint, { | ||
| 652 | expectedAccessToken: token, | ||
| 653 | }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); | ||
| 654 | return yield* connector.connect({ | ||
| 655 | userId, | ||
| 656 | environmentId: params.environmentId, | ||
| 657 | clientProofKeyThumbprint, | ||
| 658 | ...(payload.deviceId ? { deviceId: payload.deviceId } : {}), | ||
| 659 | }); | ||
| 660 | }, | ||
| 661 | mapRelayCommonApiErrors("invalid_dpop"), | ||
| 662 | mapErrorTags({ | ||
| 663 | EnvironmentConnectNotAuthorized: (_error, traceId) => | ||
| 664 | new RelayEnvironmentConnectNotAuthorizedError({ | ||
| 665 | code: "environment_connect_not_authorized", | ||
| 666 | traceId, | ||
| 667 | }), | ||
| 668 | EnvironmentMintRequestFailed: (_error, traceId) => | ||
| 669 | new RelayEnvironmentEndpointUnavailableError({ | ||
| 670 | code: "environment_endpoint_unavailable", | ||
| 671 | reason: "endpoint_request_failed", | ||
| 672 | traceId, | ||
| 673 | }), | ||
| 674 | EnvironmentMintRequestTimedOut: (_error, traceId) => | ||
| 675 | new RelayEnvironmentEndpointTimedOutError({ | ||
| 676 | code: "environment_endpoint_timed_out", | ||
| 677 | traceId, | ||
| 678 | }), | ||
| 679 | EnvironmentMintResponseInvalid: (_error, traceId) => | ||
| 680 | new RelayEnvironmentEndpointUnavailableError({ | ||
| 681 | code: "environment_endpoint_unavailable", | ||
| 682 | reason: "endpoint_response_invalid", | ||
| 683 | traceId, | ||
| 684 | }), | ||
| 685 | }), | ||
| 686 | ), | ||
| 687 | ) | ||
| 688 | .handle( | ||
| 689 | "getEnvironmentStatus", | ||
| 690 | Effect.fn("relay.api.dpopClient.getEnvironmentStatus")( | ||
| 691 | function* (args) { | ||
| 692 | const { params } = args; | ||
| 693 | const { userId, token } = yield* RelayClientPrincipal; | ||
| 694 | const proofKeyThumbprint = yield* requireDpopPrincipalScope("environment:status"); | ||
| 695 | yield* requireDpopThumbprint(proofKeyThumbprint, { | ||
| 696 | expectedAccessToken: token, | ||
| 697 | }).pipe(Effect.provideService(DpopProofs.DpopProofReplay, dpopProofs)); | ||
| 698 | return yield* connector.status({ | ||
| 699 | userId, | ||
| 700 | environmentId: params.environmentId, | ||
| 701 | }); | ||
| 702 | }, | ||
| 703 | mapRelayCommonApiErrors("invalid_dpop"), | ||
| 704 | mapErrorTags({ | ||
| 705 | EnvironmentConnectNotAuthorized: (_error, traceId) => | ||
| 706 | new RelayEnvironmentConnectNotAuthorizedError({ | ||
| 707 | code: "environment_connect_not_authorized", | ||
| 708 | traceId, | ||
| 709 | }), | ||
| 710 | EnvironmentMintRequestFailed: (_error, traceId) => | ||
| 711 | new RelayEnvironmentEndpointUnavailableError({ | ||
| 712 | code: "environment_endpoint_unavailable", | ||
| 713 | reason: "endpoint_request_failed", | ||
| 714 | traceId, | ||
| 715 | }), | ||
| 716 | EnvironmentMintRequestTimedOut: (_error, traceId) => | ||
| 717 | new RelayEnvironmentEndpointTimedOutError({ | ||
| 718 | code: "environment_endpoint_timed_out", | ||
| 719 | traceId, | ||
| 720 | }), | ||
| 721 | EnvironmentMintResponseInvalid: (_error, traceId) => | ||
| 722 | new RelayEnvironmentEndpointUnavailableError({ | ||
| 723 | code: "environment_endpoint_unavailable", | ||
| 724 | reason: "endpoint_response_invalid", | ||
| 725 | traceId, | ||
| 726 | }), | ||
| 727 | }), | ||
| 728 | ), | ||
| 729 | ); | ||
| 730 | }), | ||
| 731 | ); | ||
| 732 | |||
| 733 | export const serverApi = HttpApiBuilder.group( | ||
| 734 | RelayApi, | ||
| 735 | "server", | ||
| 736 | Effect.fnUntraced(function* (handlers) { | ||
| 737 | const publisher = yield* AgentActivityPublisher.AgentActivityPublisher; | ||
| 738 | const publishSignatures = yield* EnvironmentPublishSignatures.EnvironmentPublishSignatures; | ||
| 739 | return handlers.handle( | ||
| 740 | "publishAgentActivity", | ||
| 741 | Effect.fn("relay.api.server.publishAgentActivity")( | ||
| 742 | function* (args) { | ||
| 743 | const { params, payload } = args; | ||
| 744 | const principal = yield* RelayEnvironmentPrincipal; | ||
| 745 | if (principal.environmentId !== params.environmentId) { | ||
| 746 | return yield* new HttpApiError.Unauthorized({}); | ||
| 747 | } | ||
| 748 | yield* publishSignatures.verify({ | ||
| 749 | environmentId: params.environmentId, | ||
| 750 | environmentPublicKey: principal.environmentPublicKey, | ||
| 751 | threadId: params.threadId, | ||
| 752 | request: payload, | ||
| 753 | }); | ||
| 754 | return yield* publisher.publish({ | ||
| 755 | environmentId: params.environmentId, | ||
| 756 | environmentPublicKey: principal.environmentPublicKey, | ||
| 757 | threadId: params.threadId, | ||
| 758 | state: payload.state, | ||
| 759 | }); | ||
| 760 | }, | ||
| 761 | mapErrorTags({ | ||
| 762 | EnvironmentPublishPublicKeyMissing: (_error, traceId) => | ||
| 763 | new RelayAuthInvalidError({ | ||
| 764 | code: "auth_invalid", | ||
| 765 | reason: "not_authorized", | ||
| 766 | traceId, | ||
| 767 | }), | ||
| 768 | EnvironmentPublishSignatureExpired: (_error, traceId) => | ||
| 769 | new RelayAgentActivityPublishProofExpiredError({ | ||
| 770 | code: "agent_activity_publish_proof_expired", | ||
| 771 | traceId, | ||
| 772 | }), | ||
| 773 | EnvironmentPublishSignatureInvalid: (_error, traceId) => | ||
| 774 | new RelayAgentActivityPublishProofInvalidError({ | ||
| 775 | code: "agent_activity_publish_proof_invalid", | ||
| 776 | reason: "invalid_signature_or_payload", | ||
| 777 | traceId, | ||
| 778 | }), | ||
| 779 | DpopProofReplayPersistenceError: (_error, traceId) => | ||
| 780 | new RelayInternalError({ | ||
| 781 | code: "internal_error", | ||
| 782 | reason: "persistence_failed", | ||
| 783 | traceId, | ||
| 784 | }), | ||
| 785 | ApnsDeliveryJobInvalid: (_error, traceId) => | ||
| 786 | new RelayInternalError({ | ||
| 787 | code: "internal_error", | ||
| 788 | reason: "internal_error", | ||
| 789 | traceId, | ||
| 790 | }), | ||
| 791 | ApnsDeliveryJobExpired: (_error, traceId) => | ||
| 792 | new RelayInternalError({ | ||
| 793 | code: "internal_error", | ||
| 794 | reason: "internal_error", | ||
| 795 | traceId, | ||
| 796 | }), | ||
| 797 | ApnsDeliveryJobClaimInFlight: (_error, traceId) => | ||
| 798 | new RelayInternalError({ | ||
| 799 | code: "internal_error", | ||
| 800 | reason: "internal_error", | ||
| 801 | traceId, | ||
| 802 | }), | ||
| 803 | ApnsDeliveryQueueSendError: (_error, traceId) => | ||
| 804 | new RelayInternalError({ | ||
| 805 | code: "internal_error", | ||
| 806 | reason: "upstream_unavailable", | ||
| 807 | traceId, | ||
| 808 | }), | ||
| 809 | }), | ||
| 810 | mapRelayCommonApiErrors("not_authorized"), | ||
| 811 | ), | ||
| 812 | ); | ||
| 813 | }), | ||
| 814 | ); | ||
| 815 | |||
| 816 | class ClerkTokenVerificationFailed extends Data.TaggedError("ClerkTokenVerificationFailed")<{ | ||
| 817 | readonly cause: unknown; | ||
| 818 | }> {} | ||
| 819 | |||
| 820 | const isHttpUnauthorized = Schema.is(HttpApiError.Unauthorized); | ||
| 821 | |||
| 822 | const currentTraceId = Effect.currentParentSpan.pipe( | ||
| 823 | Effect.map((span) => span.traceId), | ||
| 824 | Effect.orElseSucceed(() => "unavailable"), | ||
| 825 | ); | ||
| 826 | |||
| 827 | const COMMON_AUTH_INVALID_REASONS = [ | ||
| 828 | Devices.DeviceRegistrationPersistenceError, | ||
| 829 | Devices.DeviceUnregistrationPersistenceError, | ||
| 830 | Devices.DeviceListPersistenceError, | ||
| 831 | LiveActivities.LiveActivityRegistrationPersistenceError, | ||
| 832 | EnvironmentLinks.EnvironmentLinkUserListPersistenceError, | ||
| 833 | EnvironmentLinks.EnvironmentPublicKeyListPersistenceError, | ||
| 834 | EnvironmentLinks.EnvironmentLinkListPersistenceError, | ||
| 835 | EnvironmentLinks.EnvironmentLinkLookupPersistenceError, | ||
| 836 | EnvironmentLinks.EnvironmentLinkRevokePersistenceError, | ||
| 837 | ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError, | ||
| 838 | EnvironmentCredentials.EnvironmentCredentialAuthenticatePersistenceError, | ||
| 839 | EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError, | ||
| 840 | DpopProofs.DpopProofReplayPersistenceError, | ||
| 841 | LiveActivities.LiveActivityTargetListPersistenceError, | ||
| 842 | AgentActivityRows.AgentActivityRowUpsertPersistenceError, | ||
| 843 | AgentActivityRows.AgentActivityRowDeletePersistenceError, | ||
| 844 | AgentActivityRows.AgentActivityRowListPersistenceError, | ||
| 845 | LiveActivities.LiveActivityDeliveryMarkPersistenceError, | ||
| 846 | DeliveryAttempts.DeliveryAttemptRecordPersistenceError, | ||
| 847 | ] as const; | ||
| 848 | type RelayCommonPersistenceError = InstanceType<(typeof COMMON_AUTH_INVALID_REASONS)[number]>; | ||
| 849 | |||
| 850 | type MapRelayCommonApiError<E> = | ||
| 851 | | Exclude<E, HttpApiError.Unauthorized | RelayCommonPersistenceError> | ||
| 852 | | (Extract<E, HttpApiError.Unauthorized> extends never ? never : RelayAuthInvalidError) | ||
| 853 | | (Extract<E, RelayCommonPersistenceError> extends never ? never : RelayInternalError); | ||
| 854 | |||
| 855 | function isRelayCommonPersistenceError(error: unknown): error is RelayCommonPersistenceError { | ||
| 856 | return COMMON_AUTH_INVALID_REASONS.some((ErrorType) => error instanceof ErrorType); | ||
| 857 | } | ||
| 858 | |||
| 859 | function relayInternalErrorResponse(reason: RelayInternalError["reason"]) { | ||
| 860 | return currentTraceId.pipe( | ||
| 861 | Effect.flatMap((traceId) => | ||
| 862 | Effect.fail(new RelayInternalError({ code: "internal_error", reason, traceId })), | ||
| 863 | ), | ||
| 864 | ); | ||
| 865 | } | ||
| 866 | |||
| 867 | function mapRelayCommonApiErrors(authReason: RelayAuthInvalidReason) { | ||
| 868 | const mapError = Effect.fnUntraced(function* <E>(error: E) { | ||
| 869 | const traceId = yield* currentTraceId; | ||
| 870 | if (isHttpUnauthorized(error)) { | ||
| 871 | return yield* Effect.fail( | ||
| 872 | new RelayAuthInvalidError({ | ||
| 873 | code: "auth_invalid", | ||
| 874 | reason: authReason, | ||
| 875 | traceId, | ||
| 876 | }) as MapRelayCommonApiError<E>, | ||
| 877 | ); | ||
| 878 | } | ||
| 879 | if (isRelayCommonPersistenceError(error)) { | ||
| 880 | return yield* Effect.fail( | ||
| 881 | new RelayInternalError({ | ||
| 882 | code: "internal_error", | ||
| 883 | reason: "persistence_failed", | ||
| 884 | traceId, | ||
| 885 | }) as MapRelayCommonApiError<E>, | ||
| 886 | ); | ||
| 887 | } | ||
| 888 | |||
| 889 | return yield* Effect.fail(error as MapRelayCommonApiError<E>); | ||
| 890 | }); | ||
| 891 | |||
| 892 | return <A, E, R>( | ||
| 893 | effect: Effect.Effect<A, E, R>, | ||
| 894 | ): Effect.Effect<A, MapRelayCommonApiError<E>, R> => effect.pipe(Effect.catch(mapError)); | ||
| 895 | } | ||
| 896 | |||
| 897 | type TaggedErrorTag<E> = Extract<E, { readonly _tag: string }>["_tag"]; | ||
| 898 | |||
| 899 | type MapErrorTagCases<E> = { | ||
| 900 | readonly [K in TaggedErrorTag<E>]+?: ( | ||
| 901 | error: Extract<E, { readonly _tag: K }>, | ||
| 902 | traceId: string, | ||
| 903 | ) => unknown; | ||
| 904 | }; | ||
| 905 | |||
| 906 | type MappedTagError<Cases> = Cases[keyof Cases] extends ( | ||
| 907 | ...args: ReadonlyArray<never> | ||
| 908 | ) => infer Error | ||
| 909 | ? Error | ||
| 910 | : never; | ||
| 911 | |||
| 912 | type CatchTagCases<E, Cases> = { | ||
| 913 | readonly [K in TaggedErrorTag<E>]+?: ( | ||
| 914 | error: Extract<E, { readonly _tag: K }>, | ||
| 915 | ) => Effect.Effect<never, MappedTagError<Cases>>; | ||
| 916 | } & (unknown extends E ? {} : { readonly [K in Exclude<keyof Cases, TaggedErrorTag<E>>]: never }); | ||
| 917 | |||
| 918 | function mapErrorTags< | ||
| 919 | E, | ||
| 920 | Cases extends MapErrorTagCases<E> & | ||
| 921 | (unknown extends E ? {} : { readonly [K in Exclude<keyof Cases, TaggedErrorTag<E>>]: never }), | ||
| 922 | >(cases: Cases) { | ||
| 923 | const catchCases = Record.map( | ||
| 924 | cases as Record.ReadonlyRecord< | ||
| 925 | string, | ||
| 926 | (error: never, traceId: string) => MappedTagError<Cases> | ||
| 927 | >, | ||
| 928 | (makeError) => (error: never) => | ||
| 929 | currentTraceId.pipe(Effect.flatMap((traceId) => Effect.fail(makeError(error, traceId)))), | ||
| 930 | ) as CatchTagCases<E, Cases>; | ||
| 931 | |||
| 932 | return <A, R>( | ||
| 933 | self: Effect.Effect<A, E, R>, | ||
| 934 | ): Effect.Effect<A, Exclude<E, { readonly _tag: keyof Cases }> | MappedTagError<Cases>, R> => | ||
| 935 | // @effect-diagnostics-next-line unsafeEffectTypeAssertion:off | ||
| 936 | Effect.catchTags(self, catchCases) as Effect.Effect< | ||
| 937 | A, | ||
| 938 | Exclude<E, { readonly _tag: keyof Cases }> | MappedTagError<Cases>, | ||
| 939 | R | ||
| 940 | >; | ||
| 941 | } | ||
| 942 | |||
| 943 | function resolveConnectClientKeyThumbprint(payload: RelayEnvironmentConnectRequest): string | null { | ||
| 944 | const requestedThumbprint = payload.clientKeyThumbprint ?? payload.clientProofKeyThumbprint; | ||
| 945 | if (!requestedThumbprint) { | ||
| 946 | return null; | ||
| 947 | } | ||
| 948 | if ( | ||
| 949 | payload.clientKeyThumbprint && | ||
| 950 | payload.clientProofKeyThumbprint && | ||
| 951 | payload.clientKeyThumbprint !== payload.clientProofKeyThumbprint | ||
| 952 | ) { | ||
| 953 | return null; | ||
| 954 | } | ||
| 955 | return requestedThumbprint; | ||
| 956 | } | ||
| 957 | |||
| 958 | function safeAuthFailureReason(value: string): string { | ||
| 959 | return /^[a-z0-9._-]+$/i.test(value) ? value : "unknown"; | ||
| 960 | } | ||
| 961 | |||
| 962 | function clerkVerificationFailureReason(cause: unknown): string { | ||
| 963 | if ( | ||
| 964 | cause instanceof Error && | ||
| 965 | (cause.message.startsWith("Invalid JWT audience claim ") || | ||
| 966 | cause.message.startsWith("Invalid JWT audience claim array ")) | ||
| 967 | ) { | ||
| 968 | return "audience_mismatch"; | ||
| 969 | } | ||
| 970 | if (typeof cause === "object" && cause !== null && "reason" in cause) { | ||
| 971 | const reason = (cause as { readonly reason?: unknown }).reason; | ||
| 972 | if (typeof reason === "string" && reason.length > 0) { | ||
| 973 | return safeAuthFailureReason(reason); | ||
| 974 | } | ||
| 975 | } | ||
| 976 | if (cause instanceof Error && cause.name) { | ||
| 977 | return safeAuthFailureReason(cause.name); | ||
| 978 | } | ||
| 979 | return "unknown"; | ||
| 980 | } | ||
| 981 | |||
| 982 | function hasExpectedClerkAudience(audience: unknown, expectedAudience: string): boolean { | ||
| 983 | return typeof audience === "string" | ||
| 984 | ? audience === expectedAudience | ||
| 985 | : Array.isArray(audience) && | ||
| 986 | audience.some((entry) => typeof entry === "string" && entry === expectedAudience); | ||
| 987 | } | ||
| 988 | |||
| 989 | function verifyClerkBearerToken(config: RelayConfiguration.RelayConfigurationShape, token: string) { | ||
| 990 | return Effect.tryPromise({ | ||
| 991 | try: () => | ||
| 992 | verifyToken(token, { | ||
| 993 | secretKey: Redacted.value(config.clerkSecretKey), | ||
| 994 | audience: config.clerkJwtAudience, | ||
| 995 | }), | ||
| 996 | catch: (cause) => new ClerkTokenVerificationFailed({ cause }), | ||
| 997 | }).pipe( | ||
| 998 | Effect.withSpan("verify_clerk_bearer_token", { | ||
| 999 | attributes: { "relay.auth.token_length": token.length }, | ||
| 1000 | }), | ||
| 1001 | ); | ||
| 1002 | } | ||
| 1003 | |||
| 1004 | function verifyClerkOAuthBearerToken( | ||
| 1005 | config: RelayConfiguration.RelayConfigurationShape, | ||
| 1006 | token: string, | ||
| 1007 | ) { | ||
| 1008 | return Effect.tryPromise({ | ||
| 1009 | try: async () => { | ||
| 1010 | const client = createClerkClient({ | ||
| 1011 | secretKey: Redacted.value(config.clerkSecretKey), | ||
| 1012 | publishableKey: config.clerkPublishableKey, | ||
| 1013 | }); | ||
| 1014 | const state = await client.authenticateRequest( | ||
| 1015 | new Request(config.relayIssuer, { | ||
| 1016 | headers: { authorization: `Bearer ${token}` }, | ||
| 1017 | }), | ||
| 1018 | { acceptsToken: "oauth_token" }, | ||
| 1019 | ); | ||
| 1020 | const auth = state.toAuth(); | ||
| 1021 | if (!state.isAuthenticated || !auth.userId) { | ||
| 1022 | throw new Error("Clerk OAuth token is not authenticated."); | ||
| 1023 | } | ||
| 1024 | return { sub: auth.userId }; | ||
| 1025 | }, | ||
| 1026 | catch: (cause) => new ClerkTokenVerificationFailed({ cause }), | ||
| 1027 | }); | ||
| 1028 | } | ||
| 1029 | |||
| 1030 | export function verifyRelayClientBearerToken( | ||
| 1031 | config: RelayConfiguration.RelayConfigurationShape, | ||
| 1032 | token: string, | ||
| 1033 | ) { | ||
| 1034 | return verifyClerkBearerToken(config, token).pipe( | ||
| 1035 | Effect.flatMap((verified) => | ||
| 1036 | verified.sub && hasExpectedClerkAudience(verified.aud, config.clerkJwtAudience) | ||
| 1037 | ? Effect.succeed({ sub: verified.sub, mode: "clerk_session_bearer" as const }) | ||
| 1038 | : Effect.fail(new ClerkTokenVerificationFailed({ cause: "missing_relay_audience" })), | ||
| 1039 | ), | ||
| 1040 | Effect.catch(() => | ||
| 1041 | verifyClerkOAuthBearerToken(config, token).pipe( | ||
| 1042 | Effect.map((verified) => ({ ...verified, mode: "clerk_oauth_bearer" as const })), | ||
| 1043 | ), | ||
| 1044 | ), | ||
| 1045 | ); | ||
| 1046 | } | ||
| 1047 | |||
| 1048 | const requireDpopPrincipalScope = Effect.fn("relay.api.require_dpop_principal_scope")(function* ( | ||
| 1049 | scope: RelayDpopAccessTokenScope, | ||
| 1050 | ) { | ||
| 1051 | yield* Effect.annotateCurrentSpan({ "relay.dpop.required_scope": scope }); | ||
| 1052 | const principal = yield* RelayClientPrincipal; | ||
| 1053 | if (!principal.proofKeyThumbprint || !principal.dpopScopes?.includes(scope)) { | ||
| 1054 | return yield* new HttpApiError.Unauthorized({}); | ||
| 1055 | } | ||
| 1056 | return principal.proofKeyThumbprint; | ||
| 1057 | }); | ||
| 1058 | |||
| 1059 | const requireDpopThumbprint = Effect.fn("relay.api.require_dpop_thumbprint")(function* ( | ||
| 1060 | expectedThumbprint: string, | ||
| 1061 | options?: { | ||
| 1062 | readonly expectedAccessToken?: string; | ||
| 1063 | }, | ||
| 1064 | ) { | ||
| 1065 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 1066 | const now = yield* DateTime.now; | ||
| 1067 | const url = HttpServerRequest.toURL(request); | ||
| 1068 | if (url._tag === "None") { | ||
| 1069 | return yield* new HttpApiError.Unauthorized({}); | ||
| 1070 | } | ||
| 1071 | const dpopProofs = yield* DpopProofs.DpopProofReplay; | ||
| 1072 | return yield* dpopProofs.verifyAndConsume({ | ||
| 1073 | proof: request.headers.dpop, | ||
| 1074 | method: request.method, | ||
| 1075 | url: url.value.href, | ||
| 1076 | now, | ||
| 1077 | expectedThumbprint, | ||
| 1078 | ...(options?.expectedAccessToken ? { expectedAccessToken: options.expectedAccessToken } : {}), | ||
| 1079 | }); | ||
| 1080 | }); | ||
| 1081 | |||
| 1082 | const requireDpopProof = Effect.fn("relay.api.require_dpop_proof")(function* (options?: { | ||
| 1083 | readonly expectedAccessToken?: string; | ||
| 1084 | }) { | ||
| 1085 | const request = yield* HttpServerRequest.HttpServerRequest; | ||
| 1086 | const now = yield* DateTime.now; | ||
| 1087 | const url = HttpServerRequest.toURL(request); | ||
| 1088 | if (url._tag === "None") { | ||
| 1089 | return yield* new HttpApiError.Unauthorized({}); | ||
| 1090 | } | ||
| 1091 | const dpopProofs = yield* DpopProofs.DpopProofReplay; | ||
| 1092 | return yield* dpopProofs.verifyAndConsume({ | ||
| 1093 | proof: request.headers.dpop, | ||
| 1094 | method: request.method, | ||
| 1095 | url: url.value.href, | ||
| 1096 | now, | ||
| 1097 | ...(options?.expectedAccessToken ? { expectedAccessToken: options.expectedAccessToken } : {}), | ||
| 1098 | }); | ||
| 1099 | }); | ||
| 1100 | |||
| 1101 | const relayAuthInvalidError = Effect.fnUntraced(function* (reason: RelayAuthInvalidReason) { | ||
| 1102 | const traceId = yield* currentTraceId; | ||
| 1103 | yield* Effect.annotateCurrentSpan({ | ||
| 1104 | "relay.trace_id": traceId, | ||
| 1105 | "relay.error.outbound_tag": "RelayAuthInvalidError", | ||
| 1106 | "relay.error.outbound_reason": reason, | ||
| 1107 | }); | ||
| 1108 | return yield* new RelayAuthInvalidError({ code: "auth_invalid", reason, traceId }); | ||
| 1109 | }); | ||
infra/relay/src/observability.ts created+76| ... | @@ -0,0 +1,76 @@ | ||
| 1 | import * as Alchemy from "alchemy"; | ||
| 2 | import * as Axiom from "alchemy/Axiom"; | ||
| 3 | import * as Output from "alchemy/Output"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Redacted from "effect/Redacted"; | ||
| 7 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | ||
| 8 | |||
| 9 | import { relayResourceNameForStage } from "./deploymentConfig.ts"; | ||
| 10 | |||
| 11 | const relayRecentSpansQuery = (dataset: string) => | ||
| 12 | [ | ||
| 13 | `['${dataset}']`, | ||
| 14 | `| where isnotnull(span_id) or isnotnull(trace_id)`, | ||
| 15 | `| 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({}))`, | ||
| 16 | `| extend userId = customAttributes['user']['id']`, | ||
| 17 | `| project _time, name, trace_id, span_id, duration, requestMethod, path, statusCode, endpoint, userId`, | ||
| 18 | `| order by _time desc`, | ||
| 19 | `| limit 200`, | ||
| 20 | ].join("\n"); | ||
| 21 | |||
| 22 | export const RelayObservability = Effect.gen(function* () { | ||
| 23 | const { stage } = yield* Alchemy.Stack; | ||
| 24 | const traces = yield* Axiom.Dataset("RelayTracesDataset", { | ||
| 25 | name: relayResourceNameForStage("t3-code-relay-traces", stage), | ||
| 26 | kind: "otel:traces:v1", | ||
| 27 | description: "T3 Code relay Worker HTTP request spans.", | ||
| 28 | retentionDays: 30, | ||
| 29 | useRetentionPeriod: true, | ||
| 30 | }); | ||
| 31 | |||
| 32 | const ingestToken = yield* Axiom.ApiToken("RelayAxiomIngestToken", { | ||
| 33 | name: relayResourceNameForStage("t3-code-relay-otel-ingest", stage), | ||
| 34 | description: "Owned by Alchemy. Scoped OTLP ingest token for relay HTTP spans.", | ||
| 35 | datasetCapabilities: Output.map(traces.name, (dataset) => ({ | ||
| 36 | [dataset]: { ingest: ["create" as const] }, | ||
| 37 | })), | ||
| 38 | }); | ||
| 39 | |||
| 40 | yield* Axiom.View("RelayRecentSpansView", { | ||
| 41 | name: relayResourceNameForStage("t3-code-relay-recent-spans", stage), | ||
| 42 | description: "Recent relay HTTP request spans.", | ||
| 43 | datasets: [traces.name], | ||
| 44 | aplQuery: Output.map(traces.name, relayRecentSpansQuery), | ||
| 45 | }); | ||
| 46 | |||
| 47 | return { traces, ingestToken } as const; | ||
| 48 | }); | ||
| 49 | |||
| 50 | export const withSpanAttributes = | ||
| 51 | (attributes: Record<string, unknown>) => | ||
| 52 | <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => | ||
| 53 | Effect.annotateCurrentSpan(attributes).pipe( | ||
| 54 | Effect.andThen(effect.pipe(Effect.annotateSpans(attributes))), | ||
| 55 | ); | ||
| 56 | |||
| 57 | export const makeRelayTraceLayer = (input: { | ||
| 58 | readonly tracesEndpoint: string; | ||
| 59 | readonly tracesDatasetName: string; | ||
| 60 | readonly ingestToken: Redacted.Redacted<string>; | ||
| 61 | }) => | ||
| 62 | OtlpTracer.layer({ | ||
| 63 | url: input.tracesEndpoint, | ||
| 64 | resource: { | ||
| 65 | serviceName: "t3-code-relay-worker", | ||
| 66 | attributes: { | ||
| 67 | "service.runtime": "cloudflare-worker", | ||
| 68 | "service.component": "relay", | ||
| 69 | }, | ||
| 70 | }, | ||
| 71 | headers: { | ||
| 72 | Authorization: `Bearer ${Redacted.value(input.ingestToken)}`, | ||
| 73 | "X-Axiom-Dataset": input.tracesDatasetName, | ||
| 74 | }, | ||
| 75 | exportInterval: "1 second", | ||
| 76 | }).pipe(Layer.provide(OtlpSerialization.layerJson)); | ||
infra/relay/src/persistence/schema.ts created+184| ... | @@ -0,0 +1,184 @@ | ||
| 1 | import type { | ||
| 2 | RelayAgentActivityAggregateState, | ||
| 3 | RelayAgentActivityState, | ||
| 4 | RelayAgentAwarenessPreferences, | ||
| 5 | } from "@t3tools/contracts/relay"; | ||
| 6 | import { | ||
| 7 | boolean, | ||
| 8 | index, | ||
| 9 | integer, | ||
| 10 | jsonb, | ||
| 11 | pgTable, | ||
| 12 | primaryKey, | ||
| 13 | text, | ||
| 14 | uniqueIndex, | ||
| 15 | varchar, | ||
| 16 | } from "drizzle-orm/pg-core"; | ||
| 17 | |||
| 18 | export const relayMobileDevices = pgTable( | ||
| 19 | "relay_mobile_devices", | ||
| 20 | { | ||
| 21 | userId: varchar("user_id", { length: 255 }).notNull(), | ||
| 22 | deviceId: varchar("device_id", { length: 255 }).notNull(), | ||
| 23 | label: text("label").notNull().default("iOS device"), | ||
| 24 | platform: varchar("platform", { length: 16 }).notNull().$type<"ios">(), | ||
| 25 | iosMajorVersion: integer("ios_major_version").notNull(), | ||
| 26 | appVersion: varchar("app_version", { length: 64 }), | ||
| 27 | pushToken: text("push_token"), | ||
| 28 | pushToStartToken: text("push_to_start_token"), | ||
| 29 | preferencesJson: jsonb("preferences_json").notNull().$type<RelayAgentAwarenessPreferences>(), | ||
| 30 | createdAt: varchar("created_at", { length: 64 }).notNull(), | ||
| 31 | updatedAt: varchar("updated_at", { length: 64 }).notNull(), | ||
| 32 | }, | ||
| 33 | (table) => [ | ||
| 34 | primaryKey({ columns: [table.userId, table.deviceId] }), | ||
| 35 | index("idx_relay_mobile_devices_user").on(table.userId), | ||
| 36 | uniqueIndex("idx_relay_mobile_devices_push_token").on(table.pushToken), | ||
| 37 | uniqueIndex("idx_relay_mobile_devices_push_to_start_token").on(table.pushToStartToken), | ||
| 38 | ], | ||
| 39 | ); | ||
| 40 | |||
| 41 | export const relayLiveActivities = pgTable( | ||
| 42 | "relay_live_activities", | ||
| 43 | { | ||
| 44 | userId: varchar("user_id", { length: 255 }).notNull(), | ||
| 45 | deviceId: varchar("device_id", { length: 255 }).notNull(), | ||
| 46 | activityPushToken: text("activity_push_token"), | ||
| 47 | remoteStartQueuedAt: varchar("remote_start_queued_at", { length: 64 }), | ||
| 48 | remoteStartedAt: varchar("remote_started_at", { length: 64 }), | ||
| 49 | endedAt: varchar("ended_at", { length: 64 }), | ||
| 50 | lastAggregateJson: jsonb("last_aggregate_json").$type<RelayAgentActivityAggregateState>(), | ||
| 51 | lastLiveActivityDeliveryAt: varchar("last_live_activity_delivery_at", { length: 64 }), | ||
| 52 | createdAt: varchar("created_at", { length: 64 }).notNull(), | ||
| 53 | updatedAt: varchar("updated_at", { length: 64 }).notNull(), | ||
| 54 | }, | ||
| 55 | (table) => [ | ||
| 56 | primaryKey({ columns: [table.userId, table.deviceId] }), | ||
| 57 | index("idx_relay_live_activities_user").on(table.userId), | ||
| 58 | uniqueIndex("idx_relay_live_activities_activity_push_token").on(table.activityPushToken), | ||
| 59 | ], | ||
| 60 | ); | ||
| 61 | |||
| 62 | export const relayEnvironmentLinks = pgTable( | ||
| 63 | "relay_environment_links", | ||
| 64 | { | ||
| 65 | userId: varchar("user_id", { length: 191 }).notNull(), | ||
| 66 | environmentId: varchar("environment_id", { length: 191 }).notNull(), | ||
| 67 | environmentLabel: text("environment_label").notNull().default("T3 Environment"), | ||
| 68 | environmentPublicKey: text("environment_public_key").notNull(), | ||
| 69 | endpointHttpBaseUrl: text("endpoint_http_base_url").notNull(), | ||
| 70 | endpointWsBaseUrl: text("endpoint_ws_base_url").notNull(), | ||
| 71 | endpointProviderKind: varchar("endpoint_provider_kind", { length: 32 }).notNull(), | ||
| 72 | notificationsEnabled: boolean("notifications_enabled").notNull().default(true), | ||
| 73 | liveActivitiesEnabled: boolean("live_activities_enabled").notNull().default(true), | ||
| 74 | managedTunnelsEnabled: boolean("managed_tunnels_enabled").notNull().default(false), | ||
| 75 | createdByDeviceId: varchar("created_by_device_id", { length: 191 }), | ||
| 76 | revokedAt: varchar("revoked_at", { length: 64 }), | ||
| 77 | createdAt: varchar("created_at", { length: 64 }).notNull(), | ||
| 78 | updatedAt: varchar("updated_at", { length: 64 }).notNull(), | ||
| 79 | }, | ||
| 80 | (table) => [ | ||
| 81 | primaryKey({ columns: [table.userId, table.environmentId] }), | ||
| 82 | index("idx_relay_environment_links_environment").on(table.environmentId, table.revokedAt), | ||
| 83 | ], | ||
| 84 | ); | ||
| 85 | |||
| 86 | export const relayManagedEndpointAllocations = pgTable( | ||
| 87 | "relay_managed_endpoint_allocations", | ||
| 88 | { | ||
| 89 | userId: varchar("user_id", { length: 191 }).notNull(), | ||
| 90 | environmentId: varchar("environment_id", { length: 191 }).notNull(), | ||
| 91 | hostname: text("hostname").notNull(), | ||
| 92 | tunnelId: varchar("tunnel_id", { length: 191 }), | ||
| 93 | tunnelName: text("tunnel_name").notNull(), | ||
| 94 | dnsRecordId: varchar("dns_record_id", { length: 191 }), | ||
| 95 | readyAt: varchar("ready_at", { length: 64 }), | ||
| 96 | createdAt: varchar("created_at", { length: 64 }).notNull(), | ||
| 97 | updatedAt: varchar("updated_at", { length: 64 }).notNull(), | ||
| 98 | }, | ||
| 99 | (table) => [ | ||
| 100 | primaryKey({ columns: [table.userId, table.environmentId] }), | ||
| 101 | uniqueIndex("idx_relay_managed_endpoint_allocations_hostname").on(table.hostname), | ||
| 102 | uniqueIndex("idx_relay_managed_endpoint_allocations_tunnel_name").on(table.tunnelName), | ||
| 103 | ], | ||
| 104 | ); | ||
| 105 | |||
| 106 | export const relayEnvironmentCredentials = pgTable( | ||
| 107 | "relay_environment_credentials", | ||
| 108 | { | ||
| 109 | credentialId: varchar("credential_id", { length: 64 }).primaryKey(), | ||
| 110 | environmentId: varchar("environment_id", { length: 191 }).notNull(), | ||
| 111 | environmentPublicKey: text("environment_public_key").notNull(), | ||
| 112 | credentialHash: varchar("credential_hash", { length: 191 }).notNull(), | ||
| 113 | revokedAt: varchar("revoked_at", { length: 64 }), | ||
| 114 | createdAt: varchar("created_at", { length: 64 }).notNull(), | ||
| 115 | updatedAt: varchar("updated_at", { length: 64 }).notNull(), | ||
| 116 | }, | ||
| 117 | (table) => [ | ||
| 118 | uniqueIndex("idx_relay_environment_credentials_hash").on(table.credentialHash), | ||
| 119 | index("idx_relay_environment_credentials_environment").on(table.environmentId, table.revokedAt), | ||
| 120 | index("idx_relay_environment_credentials_environment_key").on( | ||
| 121 | table.environmentId, | ||
| 122 | table.environmentPublicKey, | ||
| 123 | table.revokedAt, | ||
| 124 | ), | ||
| 125 | ], | ||
| 126 | ); | ||
| 127 | |||
| 128 | export const relayAgentActivityRows = pgTable( | ||
| 129 | "relay_agent_activity_rows", | ||
| 130 | { | ||
| 131 | environmentId: varchar("environment_id", { length: 191 }).notNull(), | ||
| 132 | environmentPublicKey: text("environment_public_key").notNull(), | ||
| 133 | threadId: varchar("thread_id", { length: 191 }).notNull(), | ||
| 134 | stateJson: jsonb("state_json").notNull().$type<RelayAgentActivityState>(), | ||
| 135 | updatedAt: varchar("updated_at", { length: 64 }).notNull(), | ||
| 136 | createdAt: varchar("created_at", { length: 64 }).notNull(), | ||
| 137 | }, | ||
| 138 | (table) => [ | ||
| 139 | primaryKey({ columns: [table.environmentId, table.environmentPublicKey, table.threadId] }), | ||
| 140 | index("idx_relay_agent_activity_rows_updated").on(table.updatedAt), | ||
| 141 | ], | ||
| 142 | ); | ||
| 143 | |||
| 144 | export const relayDeliveryAttempts = pgTable( | ||
| 145 | "relay_delivery_attempts", | ||
| 146 | { | ||
| 147 | id: varchar("id", { length: 36 }).primaryKey(), | ||
| 148 | createdAt: varchar("created_at", { length: 64 }).notNull(), | ||
| 149 | userId: varchar("user_id", { length: 255 }), | ||
| 150 | environmentId: varchar("environment_id", { length: 191 }), | ||
| 151 | threadId: varchar("thread_id", { length: 191 }), | ||
| 152 | deviceId: varchar("device_id", { length: 255 }), | ||
| 153 | kind: varchar("kind", { length: 64 }).notNull(), | ||
| 154 | sourceJobId: varchar("source_job_id", { length: 64 }), | ||
| 155 | tokenSuffix: varchar("token_suffix", { length: 16 }), | ||
| 156 | apnsStatus: integer("apns_status"), | ||
| 157 | apnsReason: text("apns_reason"), | ||
| 158 | apnsId: varchar("apns_id", { length: 128 }), | ||
| 159 | transportError: text("transport_error"), | ||
| 160 | }, | ||
| 161 | (table) => [ | ||
| 162 | index("idx_relay_delivery_attempts_environment").on( | ||
| 163 | table.environmentId, | ||
| 164 | table.threadId, | ||
| 165 | table.createdAt, | ||
| 166 | ), | ||
| 167 | uniqueIndex("idx_relay_delivery_attempts_source_job").on(table.sourceJobId), | ||
| 168 | ], | ||
| 169 | ); | ||
| 170 | |||
| 171 | export const relayDpopProofs = pgTable( | ||
| 172 | "relay_dpop_proofs", | ||
| 173 | { | ||
| 174 | thumbprint: varchar("thumbprint", { length: 128 }).notNull(), | ||
| 175 | jti: varchar("jti", { length: 255 }).notNull(), | ||
| 176 | iat: integer("iat").notNull(), | ||
| 177 | expiresAt: varchar("expires_at", { length: 64 }).notNull(), | ||
| 178 | createdAt: varchar("created_at", { length: 64 }).notNull(), | ||
| 179 | }, | ||
| 180 | (table) => [ | ||
| 181 | primaryKey({ columns: [table.thumbprint, table.jti] }), | ||
| 182 | index("idx_relay_dpop_proofs_expires_at").on(table.expiresAt), | ||
| 183 | ], | ||
| 184 | ); | ||
infra/relay/src/queues.ts created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import * as Cloudflare from "alchemy/Cloudflare"; | ||
| 2 | |||
| 3 | export const RelayApnsDeliveryDeadLetterQueue = Cloudflare.Queue( | ||
| 4 | "RelayApnsDeliveryDeadLetterQueue", | ||
| 5 | ); | ||
| 6 | |||
| 7 | export const RelayApnsDeliveryQueue = Cloudflare.Queue("RelayApnsDeliveryQueue"); | ||
infra/relay/src/worker.ts created+283| ... | @@ -0,0 +1,283 @@ | ||
| 1 | import * as Alchemy from "alchemy"; | ||
| 2 | import * as Cloudflare from "alchemy/Cloudflare"; | ||
| 3 | import * as Drizzle from "alchemy/Drizzle"; | ||
| 4 | import * as Config from "effect/Config"; | ||
| 5 | import * as Crypto from "effect/Crypto"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import * as Etag from "effect/unstable/http/Etag"; | ||
| 10 | import * as HttpPlatform from "effect/unstable/http/HttpPlatform"; | ||
| 11 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; | ||
| 12 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ||
| 13 | import * as HttpApiScalar from "effect/unstable/httpapi/HttpApiScalar"; | ||
| 14 | |||
| 15 | import { RelayApi } from "@t3tools/contracts/relay"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | clientApi, | ||
| 19 | dpopClientApi, | ||
| 20 | healthApi, | ||
| 21 | metadataApi, | ||
| 22 | mobileApi, | ||
| 23 | relayClientAuthLayer, | ||
| 24 | relayDpopClientAuthLayer, | ||
| 25 | relayCors, | ||
| 26 | relayDocsRedirectRoute, | ||
| 27 | relayEnvironmentAuthLayer, | ||
| 28 | relayNotFoundRoute, | ||
| 29 | serverApi, | ||
| 30 | traceRelayHttpRequestWith, | ||
| 31 | tokenApi, | ||
| 32 | withoutCapturedParentSpan, | ||
| 33 | } from "./http/Api.ts"; | ||
| 34 | import { ManagedEndpointZone, RelayApiZone, RelayDeploymentConfig } from "./zone.ts"; | ||
| 35 | import { makeRelayTraceLayer, RelayObservability } from "./observability.ts"; | ||
| 36 | import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts"; | ||
| 37 | import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts"; | ||
| 38 | import * as Devices from "./agentActivity/Devices.ts"; | ||
| 39 | import * as DpopProofs from "./auth/DpopProofs.ts"; | ||
| 40 | import * as RelayTokens from "./auth/RelayTokens.ts"; | ||
| 41 | import * as EnvironmentCredentials from "./environments/EnvironmentCredentials.ts"; | ||
| 42 | import * as EnvironmentLinks from "./environments/EnvironmentLinks.ts"; | ||
| 43 | import * as ManagedEndpointAllocations from "./environments/ManagedEndpointAllocations.ts"; | ||
| 44 | import * as LiveActivities from "./agentActivity/LiveActivities.ts"; | ||
| 45 | import { RelayDb, RelayHyperdrive } from "./db.ts"; | ||
| 46 | import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts"; | ||
| 47 | import * as RelayConfiguration from "./Config.ts"; | ||
| 48 | import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts"; | ||
| 49 | import * as ApnsClient from "./agentActivity/ApnsClient.ts"; | ||
| 50 | import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts"; | ||
| 51 | import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts"; | ||
| 52 | import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts"; | ||
| 53 | import * as EnvironmentLinker from "./environments/EnvironmentLinker.ts"; | ||
| 54 | import * as EnvironmentPublishSignatures from "./environments/EnvironmentPublishSignatures.ts"; | ||
| 55 | import * as ManagedEndpointProvider from "./environments/ManagedEndpointProvider.ts"; | ||
| 56 | import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts"; | ||
| 57 | |||
| 58 | const webcryptoLayer = Layer.succeed( | ||
| 59 | Crypto.Crypto, | ||
| 60 | Crypto.make({ | ||
| 61 | randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)), | ||
| 62 | digest: (algorithm, data) => | ||
| 63 | Effect.promise(async () => { | ||
| 64 | const input = new Uint8Array(data.length); | ||
| 65 | input.set(data); | ||
| 66 | return new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, input.buffer)); | ||
| 67 | }), | ||
| 68 | }), | ||
| 69 | ); | ||
| 70 | |||
| 71 | const httpPlatformNotSupportedLayer = Layer.succeed(HttpPlatform.HttpPlatform, { | ||
| 72 | fileResponse: () => Effect.die("Relay API does not serve filesystem responses"), | ||
| 73 | fileWebResponse: () => Effect.die("Relay API does not serve file responses"), | ||
| 74 | }); | ||
| 75 | |||
| 76 | const relayApiLayer = Layer.mergeAll( | ||
| 77 | healthApi, | ||
| 78 | metadataApi, | ||
| 79 | mobileApi, | ||
| 80 | clientApi, | ||
| 81 | tokenApi, | ||
| 82 | dpopClientApi, | ||
| 83 | serverApi, | ||
| 84 | ); | ||
| 85 | |||
| 86 | const CloudMintKeyPair = Alchemy.KeyPair("CloudMintKeyPair"); | ||
| 87 | const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningSecret", { | ||
| 88 | bytes: 32, | ||
| 89 | }); | ||
| 90 | |||
| 91 | export default class Api extends Cloudflare.Worker<Api>()( | ||
| 92 | "Api", | ||
| 93 | RelayDeploymentConfig.pipe( | ||
| 94 | Effect.map(({ relayPublicDomain }) => ({ | ||
| 95 | main: import.meta.filename, | ||
| 96 | compatibility: { | ||
| 97 | date: "2026-05-22", | ||
| 98 | flags: ["nodejs_compat"], | ||
| 99 | }, | ||
| 100 | domain: relayPublicDomain, | ||
| 101 | })), | ||
| 102 | Effect.orDie, | ||
| 103 | ), | ||
| 104 | Effect.gen(function* () { | ||
| 105 | // | ||
| 106 | // 1. Provision Infrastructure for the Worker to use | ||
| 107 | // | ||
| 108 | const { relayPublicOrigin, stage } = yield* RelayDeploymentConfig; | ||
| 109 | const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue; | ||
| 110 | const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue; | ||
| 111 | const cloudMintKeyPair = yield* CloudMintKeyPair; | ||
| 112 | const relayApiZone = yield* RelayApiZone; | ||
| 113 | const managedEndpointZone = yield* ManagedEndpointZone; | ||
| 114 | const randomApnsDeliveryJobSigningSecret = yield* ApnsDeliveryJobSigningSecret; | ||
| 115 | const observability = yield* RelayObservability; | ||
| 116 | |||
| 117 | // | ||
| 118 | // 2. Create bindings | ||
| 119 | // | ||
| 120 | const environment = yield* Config.schema( | ||
| 121 | RelayConfiguration.ApnsEnvironment, | ||
| 122 | "APNS_ENVIRONMENT", | ||
| 123 | ); | ||
| 124 | const apnsTeamId = yield* Config.string("APNS_TEAM_ID"); | ||
| 125 | const apnsKeyId = yield* Config.string("APNS_KEY_ID"); | ||
| 126 | const apnsBundleId = yield* Config.string("APNS_BUNDLE_ID"); | ||
| 127 | const apnsPrivateKey = yield* Config.redacted("APNS_PRIVATE_KEY"); | ||
| 128 | const apnsDeliveryJobSigningSecret = yield* randomApnsDeliveryJobSigningSecret; | ||
| 129 | const apnsDeliveryQueueSender = yield* Cloudflare.QueueBinding.bind(apnsDeliveryQueue); | ||
| 130 | |||
| 131 | const axiomDatasetName = yield* observability.traces.name; | ||
| 132 | const axiomIngestToken = yield* observability.ingestToken.token; | ||
| 133 | const axiomTracesEndpoint = yield* observability.traces.otelTracesEndpoint; | ||
| 134 | |||
| 135 | const clerkSecretKey = yield* Config.redacted("CLERK_SECRET_KEY"); | ||
| 136 | const clerkPublishableKey = yield* Config.string("CLERK_PUBLISHABLE_KEY"); | ||
| 137 | const clerkJwtAudience = yield* Config.string("CLERK_JWT_AUDIENCE"); | ||
| 138 | |||
| 139 | const cloudMintPrivateKey = yield* cloudMintKeyPair.privateKey; | ||
| 140 | const cloudMintPublicKey = yield* cloudMintKeyPair.publicKey; | ||
| 141 | const hyperdrive = yield* Cloudflare.Hyperdrive.bind(yield* RelayHyperdrive); | ||
| 142 | const db = yield* Drizzle.postgres(hyperdrive.connectionString); | ||
| 143 | |||
| 144 | const managedEndpointTunnelBinding = yield* Cloudflare.TunnelReadWrite.bind(); | ||
| 145 | // Keep Worker custom-domain reconciliation ordered after API zone provisioning. | ||
| 146 | yield* yield* relayApiZone.zoneId; | ||
| 147 | const managedEndpointDnsBinding = yield* Cloudflare.DnsReadWrite.bind(managedEndpointZone); | ||
| 148 | const managedEndpointZoneName = yield* managedEndpointZone.name; | ||
| 149 | |||
| 150 | // | ||
| 151 | // 3. Runtime layers and app construction | ||
| 152 | // | ||
| 153 | const alchemyRuntimeContext = yield* Alchemy.RuntimeContext; | ||
| 154 | |||
| 155 | const loadSettings = Effect.gen(function* () { | ||
| 156 | return RelayConfiguration.RelayConfiguration.of({ | ||
| 157 | relayIssuer: relayPublicOrigin, | ||
| 158 | apns: { | ||
| 159 | environment, | ||
| 160 | teamId: apnsTeamId, | ||
| 161 | keyId: apnsKeyId, | ||
| 162 | bundleId: apnsBundleId, | ||
| 163 | privateKey: apnsPrivateKey, | ||
| 164 | }, | ||
| 165 | apnsDeliveryJobSigningSecret: yield* apnsDeliveryJobSigningSecret, | ||
| 166 | clerkSecretKey, | ||
| 167 | clerkPublishableKey, | ||
| 168 | clerkJwtAudience, | ||
| 169 | cloudMintPrivateKey: yield* cloudMintPrivateKey, | ||
| 170 | cloudMintPublicKey: yield* cloudMintPublicKey, | ||
| 171 | managedEndpointBaseDomain: yield* managedEndpointZoneName, | ||
| 172 | managedEndpointNamespace: stage, | ||
| 173 | }); | ||
| 174 | }); | ||
| 175 | |||
| 176 | const relayTraceLayer = Layer.unwrap( | ||
| 177 | Effect.all({ | ||
| 178 | tracesDatasetName: axiomDatasetName, | ||
| 179 | tracesEndpoint: axiomTracesEndpoint, | ||
| 180 | ingestToken: axiomIngestToken, | ||
| 181 | }).pipe(Effect.map(makeRelayTraceLayer)), | ||
| 182 | ); | ||
| 183 | |||
| 184 | const runtimeLayer = Layer.empty.pipe( | ||
| 185 | Layer.provideMerge(MobileRegistrations.layer), | ||
| 186 | Layer.provideMerge(AgentActivityPublisher.layer), | ||
| 187 | Layer.provideMerge(EnvironmentConnector.layer), | ||
| 188 | Layer.provideMerge(EnvironmentLinker.layer), | ||
| 189 | Layer.provideMerge(EnvironmentPublishSignatures.layer), | ||
| 190 | Layer.provideMerge( | ||
| 191 | ManagedEndpointProvider.layerCloudflareBindings( | ||
| 192 | managedEndpointTunnelBinding, | ||
| 193 | managedEndpointDnsBinding, | ||
| 194 | alchemyRuntimeContext, | ||
| 195 | ), | ||
| 196 | ), | ||
| 197 | Layer.provideMerge(DpopProofs.layer), | ||
| 198 | Layer.provideMerge(ApnsDeliveries.layer), | ||
| 199 | Layer.provideMerge(ApnsClient.layer), | ||
| 200 | Layer.provideMerge( | ||
| 201 | ApnsDeliveryQueue.layerCloudflareQueues(apnsDeliveryQueueSender, alchemyRuntimeContext), | ||
| 202 | ), | ||
| 203 | Layer.provideMerge(AgentActivityRows.layer), | ||
| 204 | Layer.provideMerge(Devices.layer), | ||
| 205 | Layer.provideMerge(EnvironmentCredentials.layer), | ||
| 206 | Layer.provideMerge( | ||
| 207 | Layer.mergeAll( | ||
| 208 | EnvironmentLinks.layer, | ||
| 209 | ManagedEndpointAllocations.ManagedEndpointAllocations.layer, | ||
| 210 | ), | ||
| 211 | ), | ||
| 212 | Layer.provideMerge(LiveActivities.layer), | ||
| 213 | Layer.provideMerge(DeliveryAttempts.layer), | ||
| 214 | Layer.provideMerge(RelayTokens.layer), | ||
| 215 | Layer.provideMerge(Layer.succeed(RelayDb, db)), | ||
| 216 | Layer.provideMerge(Layer.effect(RelayConfiguration.RelayConfiguration, loadSettings)), | ||
| 217 | Layer.provideMerge(webcryptoLayer), | ||
| 218 | ); | ||
| 219 | |||
| 220 | const appLayer = relayApiLayer.pipe( | ||
| 221 | Layer.provideMerge(relayClientAuthLayer), | ||
| 222 | Layer.provideMerge(relayDpopClientAuthLayer), | ||
| 223 | Layer.provideMerge(relayEnvironmentAuthLayer), | ||
| 224 | Layer.provide(runtimeLayer), | ||
| 225 | ); | ||
| 226 | |||
| 227 | yield* Cloudflare.messages<unknown>(apnsDeliveryQueue, { | ||
| 228 | batchSize: 10, | ||
| 229 | maxRetries: 5, | ||
| 230 | maxWaitTime: "5 seconds", | ||
| 231 | retryDelay: "30 seconds", | ||
| 232 | // Alchemy beta.45 expects a resolved string here although Queue names are Outputs. | ||
| 233 | deadLetterQueue: apnsDeliveryDeadLetterQueue.queueName as unknown as string, | ||
| 234 | }).subscribe((stream) => | ||
| 235 | stream.pipe( | ||
| 236 | Stream.withSpan("relay.apn_delivery_queue.process_batch"), | ||
| 237 | Stream.runForEach((message) => | ||
| 238 | ApnsDeliveries.ApnsDeliveries.pipe( | ||
| 239 | Effect.flatMap((deliveries) => deliveries.processSignedJob(message.body)), | ||
| 240 | Effect.withSpan("relay.apn_delivery_queue.process_message"), | ||
| 241 | ), | ||
| 242 | ), | ||
| 243 | Effect.provide(runtimeLayer), | ||
| 244 | ), | ||
| 245 | ); | ||
| 246 | |||
| 247 | yield* Cloudflare.cron("*/5 * * * *").subscribe(() => | ||
| 248 | DpopProofs.DpopProofReplay.pipe( | ||
| 249 | Effect.flatMap((dpopProofs) => dpopProofs.pruneExpired), | ||
| 250 | Effect.withSpan("relay.cron.prune_expired_dpop_proofs"), | ||
| 251 | Effect.provide(runtimeLayer), | ||
| 252 | ), | ||
| 253 | ); | ||
| 254 | |||
| 255 | const fetch = Layer.merge( | ||
| 256 | Layer.mergeAll( | ||
| 257 | HttpApiBuilder.layer(RelayApi, { openapiPath: "/openapi.json" }).pipe( | ||
| 258 | Layer.provide(appLayer), | ||
| 259 | ), | ||
| 260 | HttpApiScalar.layer(RelayApi, { path: "/docs" }), | ||
| 261 | relayDocsRedirectRoute, | ||
| 262 | ).pipe(Layer.provide([Etag.layerWeak, httpPlatformNotSupportedLayer, relayCors])), | ||
| 263 | relayNotFoundRoute, | ||
| 264 | ).pipe( | ||
| 265 | HttpRouter.toHttpEffect, | ||
| 266 | withoutCapturedParentSpan, | ||
| 267 | Effect.flatMap((httpEffect) => traceRelayHttpRequestWith(httpEffect, relayTraceLayer)), | ||
| 268 | ); | ||
| 269 | |||
| 270 | return { fetch }; | ||
| 271 | }).pipe( | ||
| 272 | Effect.provide( | ||
| 273 | Layer.empty.pipe( | ||
| 274 | Layer.provideMerge(Cloudflare.HyperdriveBindingLive), | ||
| 275 | Layer.provideMerge(Cloudflare.CronEventSourceLive), | ||
| 276 | Layer.provideMerge(Cloudflare.QueueBindingLive), | ||
| 277 | Layer.provideMerge(Cloudflare.QueueEventSourceLive), | ||
| 278 | Layer.provideMerge(Cloudflare.TunnelReadWriteLive), | ||
| 279 | Layer.provideMerge(Cloudflare.DnsReadWriteLive), | ||
| 280 | ), | ||
| 281 | ), | ||
| 282 | ), | ||
| 283 | ) {} | ||
infra/relay/src/zone.ts created+66| ... | @@ -0,0 +1,66 @@ | ||
| 1 | import * as Alchemy from "alchemy"; | ||
| 2 | import { adopt } from "alchemy/AdoptPolicy"; | ||
| 3 | import * as Cloudflare from "alchemy/Cloudflare"; | ||
| 4 | import * as Config from "effect/Config"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | MANAGED_ENDPOINT_ZONE_OWNER_STAGE, | ||
| 10 | relayOwnsManagedEndpointZone, | ||
| 11 | relayPublicDomainForStage, | ||
| 12 | } from "./deploymentConfig.ts"; | ||
| 13 | |||
| 14 | function withLogicalId<Resource extends object>(resource: Resource, logicalId: string): Resource { | ||
| 15 | return new Proxy(resource, { | ||
| 16 | has: (target, property) => property === "LogicalId" || property in target, | ||
| 17 | get: (target, property, receiver) => | ||
| 18 | property === "LogicalId" ? logicalId : Reflect.get(target, property, receiver), | ||
| 19 | }); | ||
| 20 | } | ||
| 21 | |||
| 22 | export const RelayDeploymentConfig = Effect.gen(function* () { | ||
| 23 | const { stage } = yield* Alchemy.Stack; | ||
| 24 | const relayApiZoneName = yield* Config.nonEmptyString("RELAY_API_ZONE_NAME"); | ||
| 25 | const managedEndpointZoneName = yield* Config.nonEmptyString("RELAY_TUNNEL_ZONE_NAME"); | ||
| 26 | const relayPublicDomainOverride = yield* Config.nonEmptyString("RELAY_DOMAIN").pipe( | ||
| 27 | Config.option, | ||
| 28 | ); | ||
| 29 | const relayPublicDomain = Option.getOrElse(relayPublicDomainOverride, () => | ||
| 30 | relayPublicDomainForStage(stage, relayApiZoneName), | ||
| 31 | ); | ||
| 32 | |||
| 33 | return { | ||
| 34 | stage, | ||
| 35 | relayPublicDomain, | ||
| 36 | relayPublicOrigin: `https://${relayPublicDomain}`, | ||
| 37 | relayApiZoneName, | ||
| 38 | managedEndpointZoneName, | ||
| 39 | }; | ||
| 40 | }); | ||
| 41 | |||
| 42 | export const ManagedEndpointZone = RelayDeploymentConfig.pipe( | ||
| 43 | Effect.flatMap(({ stage, managedEndpointZoneName }) => | ||
| 44 | relayOwnsManagedEndpointZone(stage) | ||
| 45 | ? Cloudflare.Zone("ManagedEndpointZone", { name: managedEndpointZoneName }).pipe(adopt(true)) | ||
| 46 | : Cloudflare.Zone.ref("ManagedEndpointZone", { | ||
| 47 | stage: MANAGED_ENDPOINT_ZONE_OWNER_STAGE, | ||
| 48 | }).pipe( | ||
| 49 | // Alchemy beta's DNS binding policy uses LogicalId to derive a | ||
| 50 | // stable SID, but Resource.ref returns a lazy output proxy. | ||
| 51 | Effect.map((zone) => withLogicalId(zone, "ManagedEndpointZone")), | ||
| 52 | ), | ||
| 53 | ), | ||
| 54 | ); | ||
| 55 | |||
| 56 | export const RelayApiZone = RelayDeploymentConfig.pipe( | ||
| 57 | Effect.flatMap(({ stage, relayApiZoneName, managedEndpointZoneName }) => | ||
| 58 | relayApiZoneName === managedEndpointZoneName | ||
| 59 | ? ManagedEndpointZone | ||
| 60 | : relayOwnsManagedEndpointZone(stage) | ||
| 61 | ? Cloudflare.Zone("RelayApiZone", { name: relayApiZoneName }).pipe(adopt(true)) | ||
| 62 | : Cloudflare.Zone.ref("RelayApiZone", { | ||
| 63 | stage: MANAGED_ENDPOINT_ZONE_OWNER_STAGE, | ||
| 64 | }), | ||
| 65 | ), | ||
| 66 | ); | ||
infra/relay/tsconfig.json created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | { | ||
| 2 | "extends": "../../tsconfig.base.json", | ||
| 3 | "compilerOptions": { | ||
| 4 | "target": "ES2023", | ||
| 5 | "lib": ["ES2023", "WebWorker"], | ||
| 6 | "types": ["@cloudflare/workers-types", "node"] | ||
| 7 | }, | ||
| 8 | "include": ["alchemy.run.ts", "scripts/**/*.ts", "src/**/*.ts"] | ||
| 9 | } | ||
packages/client-runtime/src/index.ts+2| ... | @@ -26,3 +26,5 @@ export * from "./composerPathSearchState.ts"; | ... | @@ -26,3 +26,5 @@ export * from "./composerPathSearchState.ts"; |
| 26 | export * from "./archivedThreadsState.ts"; | 26 | export * from "./archivedThreadsState.ts"; |
| 27 | export * from "./checkpointDiffState.ts"; | 27 | export * from "./checkpointDiffState.ts"; |
| 28 | export * from "./remote.ts"; | 28 | export * from "./remote.ts"; |
| 29 | export * from "./managedRelay.ts"; | ||
| 30 | export * from "./managedRelayState.ts"; |
packages/client-runtime/src/managedRelay.test.ts created+185| ... | @@ -0,0 +1,185 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import * as Duration from "effect/Duration"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Fiber from "effect/Fiber"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as TestClock from "effect/testing/TestClock"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | MANAGED_RELAY_REQUEST_TIMEOUT_MS, | ||
| 12 | ManagedRelayClient, | ||
| 13 | ManagedRelayDpopSigner, | ||
| 14 | managedRelayClientLayer, | ||
| 15 | type ManagedRelayDpopProofInput, | ||
| 16 | } from "./managedRelay.ts"; | ||
| 17 | import { remoteHttpClientLayer } from "./remote.ts"; | ||
| 18 | |||
| 19 | function managedRelayTestLayer( | ||
| 20 | fetchFn: typeof globalThis.fetch, | ||
| 21 | relayUrl = "https://relay.example.test", | ||
| 22 | ) { | ||
| 23 | const httpClientLayer = remoteHttpClientLayer(fetchFn); | ||
| 24 | const signerLayer = Layer.succeed( | ||
| 25 | ManagedRelayDpopSigner, | ||
| 26 | ManagedRelayDpopSigner.of({ | ||
| 27 | thumbprint: Effect.succeed("client-thumbprint"), | ||
| 28 | createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), | ||
| 29 | }), | ||
| 30 | ); | ||
| 31 | return managedRelayClientLayer({ | ||
| 32 | relayUrl, | ||
| 33 | clientId: "t3-mobile", | ||
| 34 | }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); | ||
| 35 | } | ||
| 36 | |||
| 37 | describe("ManagedRelayClient", () => { | ||
| 38 | it.effect("rejects unsafe relay URLs before sending credentials", () => { | ||
| 39 | let requestCount = 0; | ||
| 40 | const fetchFn = (() => { | ||
| 41 | requestCount += 1; | ||
| 42 | return Promise.resolve(Response.json({})); | ||
| 43 | }) satisfies typeof globalThis.fetch; | ||
| 44 | |||
| 45 | return Effect.gen(function* () { | ||
| 46 | const relayClient = yield* ManagedRelayClient; | ||
| 47 | const error = yield* relayClient | ||
| 48 | .listEnvironments({ clerkToken: "clerk-token" }) | ||
| 49 | .pipe(Effect.flip); | ||
| 50 | |||
| 51 | expect(error).toMatchObject({ | ||
| 52 | _tag: "ManagedRelayClientError", | ||
| 53 | message: "Relay URL must be a secure absolute HTTPS origin.", | ||
| 54 | }); | ||
| 55 | expect(requestCount).toBe(0); | ||
| 56 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); | ||
| 57 | }); | ||
| 58 | |||
| 59 | it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { | ||
| 60 | let tokenExchangeCount = 0; | ||
| 61 | const fetchFn = ((input) => { | ||
| 62 | const url = String(input); | ||
| 63 | if (url.endsWith("/v1/client/dpop-token")) { | ||
| 64 | tokenExchangeCount += 1; | ||
| 65 | return Promise.resolve( | ||
| 66 | Response.json({ | ||
| 67 | access_token: `relay-token-${tokenExchangeCount}`, | ||
| 68 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 69 | token_type: "DPoP", | ||
| 70 | expires_in: 10, | ||
| 71 | scope: RelayEnvironmentStatusScope, | ||
| 72 | }), | ||
| 73 | ); | ||
| 74 | } | ||
| 75 | return Promise.resolve( | ||
| 76 | Response.json({ | ||
| 77 | environmentId: "env-1", | ||
| 78 | endpoint: { | ||
| 79 | httpBaseUrl: "https://desktop.example.test/", | ||
| 80 | wsBaseUrl: "wss://desktop.example.test/ws", | ||
| 81 | providerKind: "cloudflare_tunnel", | ||
| 82 | }, | ||
| 83 | status: "online", | ||
| 84 | checkedAt: "2026-05-25T00:01:00.000Z", | ||
| 85 | descriptor: { | ||
| 86 | environmentId: "env-1", | ||
| 87 | label: "Desktop", | ||
| 88 | platform: { os: "darwin", arch: "arm64" }, | ||
| 89 | serverVersion: "0.0.0-test", | ||
| 90 | capabilities: { repositoryIdentity: true }, | ||
| 91 | }, | ||
| 92 | }), | ||
| 93 | ); | ||
| 94 | }) satisfies typeof globalThis.fetch; | ||
| 95 | |||
| 96 | return Effect.gen(function* () { | ||
| 97 | const relayClient = yield* ManagedRelayClient; | ||
| 98 | const statusInput = { | ||
| 99 | clerkToken: "clerk-token", | ||
| 100 | scopes: [RelayEnvironmentStatusScope], | ||
| 101 | environmentId: EnvironmentId.make("env-1"), | ||
| 102 | } as const; | ||
| 103 | |||
| 104 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 105 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 106 | expect(tokenExchangeCount).toBe(1); | ||
| 107 | |||
| 108 | yield* TestClock.adjust(Duration.seconds(6)); | ||
| 109 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 110 | expect(tokenExchangeCount).toBe(2); | ||
| 111 | |||
| 112 | yield* relayClient.resetTokenCache; | ||
| 113 | yield* relayClient.getEnvironmentStatus(statusInput); | ||
| 114 | expect(tokenExchangeCount).toBe(3); | ||
| 115 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it.effect("times out stalled relay environment listing requests", () => { | ||
| 119 | const fetchFn = (() => | ||
| 120 | new Promise<Response>(() => undefined)) satisfies typeof globalThis.fetch; | ||
| 121 | |||
| 122 | return Effect.gen(function* () { | ||
| 123 | const relayClient = yield* ManagedRelayClient; | ||
| 124 | const errorFiber = yield* relayClient | ||
| 125 | .listEnvironments({ clerkToken: "clerk-token" }) | ||
| 126 | .pipe(Effect.flip, Effect.forkScoped); | ||
| 127 | |||
| 128 | yield* Effect.yieldNow; | ||
| 129 | yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); | ||
| 130 | const error = yield* Fiber.join(errorFiber); | ||
| 131 | |||
| 132 | expect(error).toMatchObject({ | ||
| 133 | _tag: "ManagedRelayClientError", | ||
| 134 | message: "Relay environment listing timed out.", | ||
| 135 | }); | ||
| 136 | }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); | ||
| 137 | }); | ||
| 138 | |||
| 139 | it.effect("lists account devices through the Clerk bearer client endpoint", () => { | ||
| 140 | const fetchFn = ((input, init) => { | ||
| 141 | expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); | ||
| 142 | expect(init?.headers).toMatchObject({ | ||
| 143 | authorization: "Bearer clerk-token", | ||
| 144 | }); | ||
| 145 | return Promise.resolve( | ||
| 146 | Response.json({ | ||
| 147 | devices: [ | ||
| 148 | { | ||
| 149 | deviceId: "device-1", | ||
| 150 | label: "Julius's iPhone", | ||
| 151 | platform: "ios", | ||
| 152 | iosMajorVersion: 18, | ||
| 153 | appVersion: "1.0.0", | ||
| 154 | notifications: { | ||
| 155 | enabled: false, | ||
| 156 | notifyOnApproval: true, | ||
| 157 | notifyOnInput: true, | ||
| 158 | notifyOnCompletion: true, | ||
| 159 | notifyOnFailure: true, | ||
| 160 | }, | ||
| 161 | liveActivities: { | ||
| 162 | enabled: true, | ||
| 163 | }, | ||
| 164 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 165 | }, | ||
| 166 | ], | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | }) satisfies typeof globalThis.fetch; | ||
| 170 | |||
| 171 | return Effect.gen(function* () { | ||
| 172 | const relayClient = yield* ManagedRelayClient; | ||
| 173 | const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); | ||
| 174 | expect(devices).toMatchObject([ | ||
| 175 | { | ||
| 176 | deviceId: "device-1", | ||
| 177 | label: "Julius's iPhone", | ||
| 178 | notifications: { | ||
| 179 | enabled: false, | ||
| 180 | }, | ||
| 181 | }, | ||
| 182 | ]); | ||
| 183 | }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); | ||
| 184 | }); | ||
| 185 | }); | ||
packages/client-runtime/src/managedRelay.ts created+510| ... | @@ -0,0 +1,510 @@ | ||
| 1 | import { | ||
| 2 | RelayAccessTokenType, | ||
| 3 | RelayApi, | ||
| 4 | type RelayClientEnvironmentRecord, | ||
| 5 | type RelayClientDeviceRecord, | ||
| 6 | RelayConnectEnvironmentEndpoint, | ||
| 7 | type RelayDeviceRegistrationRequest, | ||
| 8 | type RelayDpopAccessTokenScope, | ||
| 9 | RelayDpopTokenExchangeGrantType, | ||
| 10 | type RelayEnvironmentConnectRequest, | ||
| 11 | type RelayEnvironmentConnectResponse, | ||
| 12 | type RelayEnvironmentLinkChallengeRequest, | ||
| 13 | type RelayEnvironmentLinkChallengeResponse, | ||
| 14 | type RelayEnvironmentLinkRequest, | ||
| 15 | type RelayEnvironmentLinkResponse, | ||
| 16 | type RelayEnvironmentStatusResponse, | ||
| 17 | RelayExchangeDpopAccessTokenEndpoint, | ||
| 18 | RelayGetEnvironmentStatusEndpoint, | ||
| 19 | RelayJwtSubjectTokenType, | ||
| 20 | type RelayLiveActivityRegistrationRequest, | ||
| 21 | RelayMobileRegistrationScope, | ||
| 22 | type RelayOkResponse, | ||
| 23 | type RelayPublicClientId, | ||
| 24 | RelayRegisterDeviceEndpoint, | ||
| 25 | RelayRegisterLiveActivityEndpoint, | ||
| 26 | RelayUnregisterDeviceEndpoint, | ||
| 27 | } from "@t3tools/contracts/relay"; | ||
| 28 | import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; | ||
| 29 | import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; | ||
| 30 | import * as Clock from "effect/Clock"; | ||
| 31 | import * as Context from "effect/Context"; | ||
| 32 | import * as Data from "effect/Data"; | ||
| 33 | import * as Duration from "effect/Duration"; | ||
| 34 | import * as Effect from "effect/Effect"; | ||
| 35 | import * as Layer from "effect/Layer"; | ||
| 36 | import * as Option from "effect/Option"; | ||
| 37 | import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
| 38 | import type { HttpMethod } from "effect/unstable/http/HttpMethod"; | ||
| 39 | import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | ||
| 40 | |||
| 41 | export interface ManagedRelayDpopProofInput { | ||
| 42 | readonly method: HttpMethod; | ||
| 43 | readonly url: string; | ||
| 44 | readonly accessToken?: string; | ||
| 45 | } | ||
| 46 | |||
| 47 | export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ | ||
| 48 | readonly cause: unknown; | ||
| 49 | }> {} | ||
| 50 | |||
| 51 | export interface ManagedRelayDpopSignerShape { | ||
| 52 | readonly thumbprint: Effect.Effect<string, ManagedRelayDpopSignerError>; | ||
| 53 | readonly createProof: ( | ||
| 54 | input: ManagedRelayDpopProofInput, | ||
| 55 | ) => Effect.Effect<string, ManagedRelayDpopSignerError>; | ||
| 56 | } | ||
| 57 | |||
| 58 | export class ManagedRelayDpopSigner extends Context.Service< | ||
| 59 | ManagedRelayDpopSigner, | ||
| 60 | ManagedRelayDpopSignerShape | ||
| 61 | >()("@t3tools/client-runtime/managedRelay/ManagedRelayDpopSigner") {} | ||
| 62 | |||
| 63 | export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ | ||
| 64 | readonly message: string; | ||
| 65 | readonly cause?: unknown; | ||
| 66 | }> {} | ||
| 67 | |||
| 68 | export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; | ||
| 69 | |||
| 70 | interface CachedRelayAccessToken { | ||
| 71 | readonly clerkToken: string; | ||
| 72 | readonly thumbprint: string; | ||
| 73 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 74 | readonly accessToken: string; | ||
| 75 | readonly expiresAtMillis: number; | ||
| 76 | } | ||
| 77 | |||
| 78 | export interface ManagedRelayAuthorization { | ||
| 79 | readonly accessToken: string; | ||
| 80 | readonly proof: string; | ||
| 81 | readonly thumbprint: string; | ||
| 82 | } | ||
| 83 | |||
| 84 | export interface ManagedRelayClientLayerOptions { | ||
| 85 | readonly relayUrl: string; | ||
| 86 | readonly clientId: RelayPublicClientId; | ||
| 87 | } | ||
| 88 | |||
| 89 | export interface ManagedRelayClientShape { | ||
| 90 | readonly relayUrl: string; | ||
| 91 | readonly listEnvironments: (input: { | ||
| 92 | readonly clerkToken: string; | ||
| 93 | }) => Effect.Effect<ReadonlyArray<RelayClientEnvironmentRecord>, ManagedRelayClientError>; | ||
| 94 | readonly listDevices: (input: { | ||
| 95 | readonly clerkToken: string; | ||
| 96 | }) => Effect.Effect<ReadonlyArray<RelayClientDeviceRecord>, ManagedRelayClientError>; | ||
| 97 | readonly createEnvironmentLinkChallenge: (input: { | ||
| 98 | readonly clerkToken: string; | ||
| 99 | readonly payload: RelayEnvironmentLinkChallengeRequest; | ||
| 100 | }) => Effect.Effect<RelayEnvironmentLinkChallengeResponse, ManagedRelayClientError>; | ||
| 101 | readonly linkEnvironment: (input: { | ||
| 102 | readonly clerkToken: string; | ||
| 103 | readonly payload: RelayEnvironmentLinkRequest; | ||
| 104 | }) => Effect.Effect<RelayEnvironmentLinkResponse, ManagedRelayClientError>; | ||
| 105 | readonly unlinkEnvironment: (input: { | ||
| 106 | readonly clerkToken: string; | ||
| 107 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 108 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 109 | readonly getEnvironmentStatus: (input: { | ||
| 110 | readonly clerkToken: string; | ||
| 111 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 112 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 113 | }) => Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelayClientError>; | ||
| 114 | readonly connectEnvironment: (input: { | ||
| 115 | readonly clerkToken: string; | ||
| 116 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 117 | readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; | ||
| 118 | readonly deviceId?: string; | ||
| 119 | }) => Effect.Effect<RelayEnvironmentConnectResponse, ManagedRelayClientError>; | ||
| 120 | readonly registerDevice: (input: { | ||
| 121 | readonly clerkToken: string; | ||
| 122 | readonly payload: RelayDeviceRegistrationRequest; | ||
| 123 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 124 | readonly unregisterDevice: (input: { | ||
| 125 | readonly clerkToken: string; | ||
| 126 | readonly deviceId: string; | ||
| 127 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 128 | readonly registerLiveActivity: (input: { | ||
| 129 | readonly clerkToken: string; | ||
| 130 | readonly payload: RelayLiveActivityRegistrationRequest; | ||
| 131 | }) => Effect.Effect<RelayOkResponse, ManagedRelayClientError>; | ||
| 132 | readonly resetTokenCache: Effect.Effect<void>; | ||
| 133 | } | ||
| 134 | |||
| 135 | export class ManagedRelayClient extends Context.Service< | ||
| 136 | ManagedRelayClient, | ||
| 137 | ManagedRelayClientShape | ||
| 138 | >()("@t3tools/client-runtime/managedRelay/ManagedRelayClient") {} | ||
| 139 | |||
| 140 | function relayClientError(message: string, cause?: unknown): ManagedRelayClientError { | ||
| 141 | return new ManagedRelayClientError({ message, ...(cause === undefined ? {} : { cause }) }); | ||
| 142 | } | ||
| 143 | |||
| 144 | function timeoutRelayRequest(message: string) { | ||
| 145 | return <A, E, R>( | ||
| 146 | request: Effect.Effect<A, E, R>, | ||
| 147 | ): Effect.Effect<A, E | ManagedRelayClientError, R> => | ||
| 148 | request.pipe( | ||
| 149 | Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), | ||
| 150 | Effect.flatMap( | ||
| 151 | Option.match({ | ||
| 152 | onNone: () => Effect.fail(relayClientError(message)), | ||
| 153 | onSome: Effect.succeed, | ||
| 154 | }), | ||
| 155 | ), | ||
| 156 | ); | ||
| 157 | } | ||
| 158 | |||
| 159 | function tokenMatches( | ||
| 160 | token: CachedRelayAccessToken, | ||
| 161 | input: { | ||
| 162 | readonly clerkToken: string; | ||
| 163 | readonly thumbprint: string; | ||
| 164 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 165 | readonly nowMillis: number; | ||
| 166 | }, | ||
| 167 | ): boolean { | ||
| 168 | return ( | ||
| 169 | token.clerkToken === input.clerkToken && | ||
| 170 | token.thumbprint === input.thumbprint && | ||
| 171 | token.expiresAtMillis > input.nowMillis + 5_000 && | ||
| 172 | input.scopes.every((scope) => token.scopes.includes(scope)) | ||
| 173 | ); | ||
| 174 | } | ||
| 175 | |||
| 176 | function bearerHeaders(clerkToken: string) { | ||
| 177 | return { authorization: `Bearer ${clerkToken}` }; | ||
| 178 | } | ||
| 179 | |||
| 180 | function dpopHeaders(authorization: ManagedRelayAuthorization) { | ||
| 181 | return { | ||
| 182 | authorization: `DPoP ${authorization.accessToken}`, | ||
| 183 | dpop: authorization.proof, | ||
| 184 | }; | ||
| 185 | } | ||
| 186 | |||
| 187 | function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { | ||
| 188 | const unavailable = () => | ||
| 189 | Effect.fail(relayClientError("Relay URL must be a secure absolute HTTPS origin.")); | ||
| 190 | return ManagedRelayClient.of({ | ||
| 191 | relayUrl, | ||
| 192 | listEnvironments: unavailable, | ||
| 193 | listDevices: unavailable, | ||
| 194 | createEnvironmentLinkChallenge: unavailable, | ||
| 195 | linkEnvironment: unavailable, | ||
| 196 | unlinkEnvironment: unavailable, | ||
| 197 | getEnvironmentStatus: unavailable, | ||
| 198 | connectEnvironment: unavailable, | ||
| 199 | registerDevice: unavailable, | ||
| 200 | unregisterDevice: unavailable, | ||
| 201 | registerLiveActivity: unavailable, | ||
| 202 | resetTokenCache: Effect.void, | ||
| 203 | }); | ||
| 204 | } | ||
| 205 | |||
| 206 | export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { | ||
| 207 | return Layer.effect( | ||
| 208 | ManagedRelayClient, | ||
| 209 | Effect.gen(function* () { | ||
| 210 | const relayUrl = normalizeSecureRelayUrl(options.relayUrl); | ||
| 211 | if (relayUrl === null) { | ||
| 212 | return disabledManagedRelayClient(options.relayUrl); | ||
| 213 | } | ||
| 214 | const signer = yield* ManagedRelayDpopSigner; | ||
| 215 | const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); | ||
| 216 | const cachedTokens = yield* SynchronizedRef.make<ReadonlyArray<CachedRelayAccessToken>>([]); | ||
| 217 | const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); | ||
| 218 | |||
| 219 | type DpopProofTarget = Pick<ManagedRelayDpopProofInput, "method" | "url">; | ||
| 220 | const dpopProofTargets = { | ||
| 221 | exchangeAccessToken: (): DpopProofTarget => ({ | ||
| 222 | method: RelayExchangeDpopAccessTokenEndpoint.method, | ||
| 223 | url: urlBuilder.token.exchangeDpopAccessToken(), | ||
| 224 | }), | ||
| 225 | getEnvironmentStatus: ( | ||
| 226 | environmentId: RelayClientEnvironmentRecord["environmentId"], | ||
| 227 | ): DpopProofTarget => ({ | ||
| 228 | method: RelayGetEnvironmentStatusEndpoint.method, | ||
| 229 | url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), | ||
| 230 | }), | ||
| 231 | connectEnvironment: ( | ||
| 232 | environmentId: RelayClientEnvironmentRecord["environmentId"], | ||
| 233 | ): DpopProofTarget => ({ | ||
| 234 | method: RelayConnectEnvironmentEndpoint.method, | ||
| 235 | url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), | ||
| 236 | }), | ||
| 237 | registerDevice: (): DpopProofTarget => ({ | ||
| 238 | method: RelayRegisterDeviceEndpoint.method, | ||
| 239 | url: urlBuilder.mobile.registerDevice(), | ||
| 240 | }), | ||
| 241 | unregisterDevice: (deviceId: string): DpopProofTarget => ({ | ||
| 242 | method: RelayUnregisterDeviceEndpoint.method, | ||
| 243 | url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), | ||
| 244 | }), | ||
| 245 | registerLiveActivity: (): DpopProofTarget => ({ | ||
| 246 | method: RelayRegisterLiveActivityEndpoint.method, | ||
| 247 | url: urlBuilder.mobile.registerLiveActivity(), | ||
| 248 | }), | ||
| 249 | }; | ||
| 250 | |||
| 251 | const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( | ||
| 252 | function* (input: { | ||
| 253 | readonly clerkToken: string; | ||
| 254 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 255 | readonly thumbprint: string; | ||
| 256 | }) { | ||
| 257 | const nowMillis = yield* Clock.currentTimeMillis; | ||
| 258 | return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { | ||
| 259 | const activeTokens = tokens.filter( | ||
| 260 | (token) => token.expiresAtMillis > nowMillis + 5_000, | ||
| 261 | ); | ||
| 262 | const cached = activeTokens.find((token) => | ||
| 263 | tokenMatches(token, { ...input, nowMillis }), | ||
| 264 | ); | ||
| 265 | if (cached) { | ||
| 266 | return Effect.succeed([cached, activeTokens] as const); | ||
| 267 | } | ||
| 268 | return Effect.gen(function* () { | ||
| 269 | const proof = yield* signer | ||
| 270 | .createProof(dpopProofTargets.exchangeAccessToken()) | ||
| 271 | .pipe( | ||
| 272 | Effect.mapError((cause) => | ||
| 273 | relayClientError("Could not create relay token DPoP proof.", cause), | ||
| 274 | ), | ||
| 275 | ); | ||
| 276 | const response = yield* client.token | ||
| 277 | .exchangeDpopAccessToken({ | ||
| 278 | headers: { dpop: proof }, | ||
| 279 | payload: { | ||
| 280 | grant_type: RelayDpopTokenExchangeGrantType, | ||
| 281 | subject_token: input.clerkToken, | ||
| 282 | subject_token_type: RelayJwtSubjectTokenType, | ||
| 283 | requested_token_type: RelayAccessTokenType, | ||
| 284 | resource: relayUrl, | ||
| 285 | scope: encodeOAuthScope(input.scopes), | ||
| 286 | client_id: options.clientId, | ||
| 287 | }, | ||
| 288 | }) | ||
| 289 | .pipe( | ||
| 290 | Effect.mapError((cause) => | ||
| 291 | relayClientError("Could not exchange relay DPoP access token.", cause), | ||
| 292 | ), | ||
| 293 | timeoutRelayRequest("Relay DPoP access token exchange timed out."), | ||
| 294 | ); | ||
| 295 | if (!oauthScopeSetEquals(response.scope, input.scopes)) { | ||
| 296 | return yield* relayClientError( | ||
| 297 | "Relay granted unexpected DPoP access token scopes.", | ||
| 298 | ); | ||
| 299 | } | ||
| 300 | const next: CachedRelayAccessToken = { | ||
| 301 | clerkToken: input.clerkToken, | ||
| 302 | thumbprint: input.thumbprint, | ||
| 303 | scopes: input.scopes, | ||
| 304 | accessToken: response.access_token, | ||
| 305 | expiresAtMillis: nowMillis + response.expires_in * 1_000, | ||
| 306 | }; | ||
| 307 | return [next, [...activeTokens, next]] as const; | ||
| 308 | }); | ||
| 309 | }); | ||
| 310 | }, | ||
| 311 | ); | ||
| 312 | |||
| 313 | const authorize = (input: { | ||
| 314 | readonly clerkToken: string; | ||
| 315 | readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 316 | readonly target: DpopProofTarget; | ||
| 317 | }) => | ||
| 318 | Effect.gen(function* () { | ||
| 319 | const thumbprint = yield* signer.thumbprint.pipe( | ||
| 320 | Effect.mapError((cause) => | ||
| 321 | relayClientError("Could not load relay DPoP proof key.", cause), | ||
| 322 | ), | ||
| 323 | ); | ||
| 324 | const token = yield* obtainAccessToken({ | ||
| 325 | clerkToken: input.clerkToken, | ||
| 326 | scopes: input.scopes, | ||
| 327 | thumbprint, | ||
| 328 | }); | ||
| 329 | const proof = yield* signer | ||
| 330 | .createProof({ | ||
| 331 | ...input.target, | ||
| 332 | accessToken: token.accessToken, | ||
| 333 | }) | ||
| 334 | .pipe( | ||
| 335 | Effect.mapError((cause) => | ||
| 336 | relayClientError("Could not create relay request DPoP proof.", cause), | ||
| 337 | ), | ||
| 338 | ); | ||
| 339 | return { accessToken: token.accessToken, proof, thumbprint }; | ||
| 340 | }); | ||
| 341 | |||
| 342 | const authorizeMobileRegistration = (input: { | ||
| 343 | readonly clerkToken: string; | ||
| 344 | readonly target: DpopProofTarget; | ||
| 345 | }) => | ||
| 346 | authorize({ | ||
| 347 | ...input, | ||
| 348 | scopes: [RelayMobileRegistrationScope], | ||
| 349 | }); | ||
| 350 | |||
| 351 | return ManagedRelayClient.of({ | ||
| 352 | relayUrl, | ||
| 353 | listEnvironments: (input) => | ||
| 354 | client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe( | ||
| 355 | Effect.map((response) => response.environments), | ||
| 356 | Effect.mapError((cause) => | ||
| 357 | relayClientError("Could not list relay-managed environments.", cause), | ||
| 358 | ), | ||
| 359 | timeoutRelayRequest("Relay environment listing timed out."), | ||
| 360 | ), | ||
| 361 | listDevices: (input) => | ||
| 362 | client.client | ||
| 363 | .listDevices({ | ||
| 364 | headers: bearerHeaders(input.clerkToken), | ||
| 365 | }) | ||
| 366 | .pipe( | ||
| 367 | Effect.map((response) => response.devices), | ||
| 368 | Effect.mapError((cause) => | ||
| 369 | relayClientError("Could not list relay client devices.", cause), | ||
| 370 | ), | ||
| 371 | timeoutRelayRequest("Relay client device listing timed out."), | ||
| 372 | ), | ||
| 373 | createEnvironmentLinkChallenge: (input) => | ||
| 374 | client.client | ||
| 375 | .createEnvironmentLinkChallenge({ | ||
| 376 | headers: bearerHeaders(input.clerkToken), | ||
| 377 | payload: input.payload, | ||
| 378 | }) | ||
| 379 | .pipe( | ||
| 380 | Effect.mapError((cause) => | ||
| 381 | relayClientError("Could not create relay environment link challenge.", cause), | ||
| 382 | ), | ||
| 383 | timeoutRelayRequest("Relay environment link challenge timed out."), | ||
| 384 | ), | ||
| 385 | linkEnvironment: (input) => | ||
| 386 | client.client | ||
| 387 | .linkEnvironment({ | ||
| 388 | headers: bearerHeaders(input.clerkToken), | ||
| 389 | payload: input.payload, | ||
| 390 | }) | ||
| 391 | .pipe( | ||
| 392 | Effect.mapError((cause) => | ||
| 393 | relayClientError("Could not link relay environment.", cause), | ||
| 394 | ), | ||
| 395 | timeoutRelayRequest("Relay environment linking timed out."), | ||
| 396 | ), | ||
| 397 | unlinkEnvironment: (input) => | ||
| 398 | client.client | ||
| 399 | .unlinkEnvironment({ | ||
| 400 | headers: bearerHeaders(input.clerkToken), | ||
| 401 | params: { environmentId: input.environmentId }, | ||
| 402 | }) | ||
| 403 | .pipe( | ||
| 404 | Effect.mapError((cause) => | ||
| 405 | relayClientError("Could not unlink relay environment.", cause), | ||
| 406 | ), | ||
| 407 | timeoutRelayRequest("Relay environment unlinking timed out."), | ||
| 408 | ), | ||
| 409 | getEnvironmentStatus: (input) => | ||
| 410 | Effect.gen(function* () { | ||
| 411 | const authorization = yield* authorize({ | ||
| 412 | clerkToken: input.clerkToken, | ||
| 413 | scopes: input.scopes, | ||
| 414 | target: dpopProofTargets.getEnvironmentStatus(input.environmentId), | ||
| 415 | }); | ||
| 416 | return yield* client.dpopClient | ||
| 417 | .getEnvironmentStatus({ | ||
| 418 | headers: dpopHeaders(authorization), | ||
| 419 | params: { environmentId: input.environmentId }, | ||
| 420 | }) | ||
| 421 | .pipe( | ||
| 422 | Effect.mapError((cause) => | ||
| 423 | relayClientError("Could not get relay environment status.", cause), | ||
| 424 | ), | ||
| 425 | timeoutRelayRequest("Relay environment status request timed out."), | ||
| 426 | ); | ||
| 427 | }), | ||
| 428 | connectEnvironment: (input) => | ||
| 429 | Effect.gen(function* () { | ||
| 430 | const authorization = yield* authorize({ | ||
| 431 | clerkToken: input.clerkToken, | ||
| 432 | scopes: input.scopes, | ||
| 433 | target: dpopProofTargets.connectEnvironment(input.environmentId), | ||
| 434 | }); | ||
| 435 | const payload: RelayEnvironmentConnectRequest = { | ||
| 436 | ...(input.deviceId ? { deviceId: input.deviceId } : {}), | ||
| 437 | clientKeyThumbprint: authorization.thumbprint, | ||
| 438 | }; | ||
| 439 | return yield* client.dpopClient | ||
| 440 | .connectEnvironment({ | ||
| 441 | headers: dpopHeaders(authorization), | ||
| 442 | params: { environmentId: input.environmentId }, | ||
| 443 | payload, | ||
| 444 | }) | ||
| 445 | .pipe( | ||
| 446 | Effect.mapError((cause) => | ||
| 447 | relayClientError("Could not connect relay environment.", cause), | ||
| 448 | ), | ||
| 449 | timeoutRelayRequest("Relay environment connection timed out."), | ||
| 450 | ); | ||
| 451 | }), | ||
| 452 | registerDevice: (input) => | ||
| 453 | Effect.gen(function* () { | ||
| 454 | const authorization = yield* authorizeMobileRegistration({ | ||
| 455 | clerkToken: input.clerkToken, | ||
| 456 | target: dpopProofTargets.registerDevice(), | ||
| 457 | }); | ||
| 458 | return yield* client.mobile | ||
| 459 | .registerDevice({ | ||
| 460 | headers: dpopHeaders(authorization), | ||
| 461 | payload: input.payload, | ||
| 462 | }) | ||
| 463 | .pipe( | ||
| 464 | Effect.mapError((cause) => | ||
| 465 | relayClientError("Could not register relay mobile device.", cause), | ||
| 466 | ), | ||
| 467 | timeoutRelayRequest("Relay mobile device registration timed out."), | ||
| 468 | ); | ||
| 469 | }), | ||
| 470 | unregisterDevice: (input) => | ||
| 471 | Effect.gen(function* () { | ||
| 472 | const authorization = yield* authorizeMobileRegistration({ | ||
| 473 | clerkToken: input.clerkToken, | ||
| 474 | target: dpopProofTargets.unregisterDevice(input.deviceId), | ||
| 475 | }); | ||
| 476 | return yield* client.mobile | ||
| 477 | .unregisterDevice({ | ||
| 478 | headers: dpopHeaders(authorization), | ||
| 479 | params: { deviceId: input.deviceId }, | ||
| 480 | }) | ||
| 481 | .pipe( | ||
| 482 | Effect.mapError((cause) => | ||
| 483 | relayClientError("Could not unregister relay mobile device.", cause), | ||
| 484 | ), | ||
| 485 | timeoutRelayRequest("Relay mobile device unregistration timed out."), | ||
| 486 | ); | ||
| 487 | }), | ||
| 488 | registerLiveActivity: (input) => | ||
| 489 | Effect.gen(function* () { | ||
| 490 | const authorization = yield* authorizeMobileRegistration({ | ||
| 491 | clerkToken: input.clerkToken, | ||
| 492 | target: dpopProofTargets.registerLiveActivity(), | ||
| 493 | }); | ||
| 494 | return yield* client.mobile | ||
| 495 | .registerLiveActivity({ | ||
| 496 | headers: dpopHeaders(authorization), | ||
| 497 | payload: input.payload, | ||
| 498 | }) | ||
| 499 | .pipe( | ||
| 500 | Effect.mapError((cause) => | ||
| 501 | relayClientError("Could not register relay live activity.", cause), | ||
| 502 | ), | ||
| 503 | timeoutRelayRequest("Relay Live Activity registration timed out."), | ||
| 504 | ); | ||
| 505 | }), | ||
| 506 | resetTokenCache: SynchronizedRef.set(cachedTokens, []), | ||
| 507 | }); | ||
| 508 | }), | ||
| 509 | ); | ||
| 510 | } | ||
packages/client-runtime/src/managedRelayState.test.ts created+155| ... | @@ -0,0 +1,155 @@ | ||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | ||
| 2 | import type { | ||
| 3 | RelayClientDeviceRecord, | ||
| 4 | RelayClientEnvironmentRecord, | ||
| 5 | RelayEnvironmentStatusResponse, | ||
| 6 | } from "@t3tools/contracts/relay"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import { Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 10 | import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| 11 | |||
| 12 | import { ManagedRelayClient, type ManagedRelayClientShape } from "./managedRelay.ts"; | ||
| 13 | import { | ||
| 14 | createManagedRelayQueryManager, | ||
| 15 | createManagedRelaySession, | ||
| 16 | managedRelaySessionAtom, | ||
| 17 | readManagedRelaySnapshotState, | ||
| 18 | setManagedRelaySession, | ||
| 19 | waitForManagedRelayClerkToken, | ||
| 20 | } from "./managedRelayState.ts"; | ||
| 21 | |||
| 22 | let registry = AtomRegistry.make(); | ||
| 23 | |||
| 24 | const environment = { | ||
| 25 | environmentId: EnvironmentId.make("environment-1"), | ||
| 26 | label: "Main environment", | ||
| 27 | endpoint: { | ||
| 28 | httpBaseUrl: "https://environment.example.test", | ||
| 29 | wsBaseUrl: "wss://environment.example.test", | ||
| 30 | providerKind: "cloudflare_tunnel", | ||
| 31 | }, | ||
| 32 | linkedAt: "2026-06-01T00:00:00.000Z", | ||
| 33 | } satisfies RelayClientEnvironmentRecord; | ||
| 34 | |||
| 35 | const device = { | ||
| 36 | deviceId: "device-1", | ||
| 37 | label: "Julius iPhone", | ||
| 38 | platform: "ios", | ||
| 39 | iosMajorVersion: 18, | ||
| 40 | appVersion: null, | ||
| 41 | notifications: { | ||
| 42 | enabled: true, | ||
| 43 | notifyOnApproval: true, | ||
| 44 | notifyOnInput: true, | ||
| 45 | notifyOnCompletion: true, | ||
| 46 | notifyOnFailure: true, | ||
| 47 | }, | ||
| 48 | liveActivities: { | ||
| 49 | enabled: true, | ||
| 50 | }, | ||
| 51 | updatedAt: "2026-06-01T00:00:00.000Z", | ||
| 52 | } satisfies RelayClientDeviceRecord; | ||
| 53 | |||
| 54 | function resetRegistry() { | ||
| 55 | registry.dispose(); | ||
| 56 | registry = AtomRegistry.make(); | ||
| 57 | } | ||
| 58 | |||
| 59 | function createManager(overrides?: Partial<ManagedRelayClientShape>) { | ||
| 60 | const client = ManagedRelayClient.of({ | ||
| 61 | relayUrl: "https://relay.example.test", | ||
| 62 | listEnvironments: () => Effect.succeed([environment]), | ||
| 63 | listDevices: () => Effect.succeed([device]), | ||
| 64 | createEnvironmentLinkChallenge: () => Effect.die("unused"), | ||
| 65 | linkEnvironment: () => Effect.die("unused"), | ||
| 66 | unlinkEnvironment: () => Effect.die("unused"), | ||
| 67 | getEnvironmentStatus: () => | ||
| 68 | Effect.succeed({ | ||
| 69 | environmentId: environment.environmentId, | ||
| 70 | endpoint: environment.endpoint, | ||
| 71 | status: "online", | ||
| 72 | checkedAt: "2026-06-01T00:00:00.000Z", | ||
| 73 | }), | ||
| 74 | connectEnvironment: () => Effect.die("unused"), | ||
| 75 | registerDevice: () => Effect.die("unused"), | ||
| 76 | unregisterDevice: () => Effect.die("unused"), | ||
| 77 | registerLiveActivity: () => Effect.die("unused"), | ||
| 78 | resetTokenCache: Effect.void, | ||
| 79 | ...overrides, | ||
| 80 | }); | ||
| 81 | const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); | ||
| 82 | return createManagedRelayQueryManager(runtime, { staleTimeMs: 60_000 }); | ||
| 83 | } | ||
| 84 | |||
| 85 | function setSession() { | ||
| 86 | setManagedRelaySession( | ||
| 87 | registry, | ||
| 88 | createManagedRelaySession({ | ||
| 89 | accountId: "account-1", | ||
| 90 | readClerkToken: () => Promise.resolve("clerk-token"), | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | } | ||
| 94 | |||
| 95 | describe("createManagedRelayQueryManager", () => { | ||
| 96 | afterEach(resetRegistry); | ||
| 97 | |||
| 98 | it("waits for the current cloud session before reading its token", async () => { | ||
| 99 | const token = Effect.runPromise(waitForManagedRelayClerkToken(registry)); | ||
| 100 | |||
| 101 | setSession(); | ||
| 102 | |||
| 103 | await expect(token).resolves.toBe("clerk-token"); | ||
| 104 | expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); | ||
| 105 | }); | ||
| 106 | |||
| 107 | it("keeps environment snapshots cached and refreshes them explicitly", async () => { | ||
| 108 | const listEnvironments = vi.fn(() => Effect.succeed([environment])); | ||
| 109 | const manager = createManager({ listEnvironments }); | ||
| 110 | setSession(); | ||
| 111 | const atom = manager.environmentsAtom("account-1"); | ||
| 112 | |||
| 113 | registry.get(atom); | ||
| 114 | await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(1)); | ||
| 115 | |||
| 116 | registry.get(manager.environmentsAtom("account-1")); | ||
| 117 | expect(listEnvironments).toHaveBeenCalledTimes(1); | ||
| 118 | |||
| 119 | manager.refreshEnvironments(registry, "account-1"); | ||
| 120 | await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(2)); | ||
| 121 | }); | ||
| 122 | |||
| 123 | it("loads device snapshots through the current account session", async () => { | ||
| 124 | const listDevices = vi.fn(() => Effect.succeed([device])); | ||
| 125 | const manager = createManager({ listDevices }); | ||
| 126 | setSession(); | ||
| 127 | const atom = manager.devicesAtom("account-1"); | ||
| 128 | |||
| 129 | registry.get(atom); | ||
| 130 | await vi.waitFor(() => { | ||
| 131 | expect(readManagedRelaySnapshotState(registry.get(atom)).data).toEqual([device]); | ||
| 132 | }); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("rejects status responses for a different environment", async () => { | ||
| 136 | const mismatchedStatus = { | ||
| 137 | environmentId: EnvironmentId.make("environment-2"), | ||
| 138 | endpoint: environment.endpoint, | ||
| 139 | status: "online", | ||
| 140 | checkedAt: "2026-06-01T00:00:00.000Z", | ||
| 141 | } satisfies RelayEnvironmentStatusResponse; | ||
| 142 | const manager = createManager({ | ||
| 143 | getEnvironmentStatus: () => Effect.succeed(mismatchedStatus), | ||
| 144 | }); | ||
| 145 | setSession(); | ||
| 146 | const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); | ||
| 147 | |||
| 148 | registry.get(atom); | ||
| 149 | await vi.waitFor(() => { | ||
| 150 | expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( | ||
| 151 | "Relay returned status for a different environment.", | ||
| 152 | ); | ||
| 153 | }); | ||
| 154 | }); | ||
| 155 | }); | ||
packages/client-runtime/src/managedRelayState.ts created+289| ... | @@ -0,0 +1,289 @@ | ||
| 1 | import type { | ||
| 2 | RelayClientEnvironmentRecord, | ||
| 3 | RelayEnvironmentStatusResponse, | ||
| 4 | } from "@t3tools/contracts/relay"; | ||
| 5 | import { | ||
| 6 | RelayEnvironmentConnectScope, | ||
| 7 | RelayEnvironmentStatusScope, | ||
| 8 | } from "@t3tools/contracts/relay"; | ||
| 9 | import * as Cause from "effect/Cause"; | ||
| 10 | import * as Data from "effect/Data"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 14 | |||
| 15 | import { ManagedRelayClient } from "./managedRelay.ts"; | ||
| 16 | |||
| 17 | const DEFAULT_STALE_TIME_MS = 15_000; | ||
| 18 | const DEFAULT_IDLE_TTL_MS = 5 * 60_000; | ||
| 19 | |||
| 20 | export interface ManagedRelaySession { | ||
| 21 | readonly accountId: string; | ||
| 22 | readonly readClerkToken: () => Effect.Effect<string | null, ManagedRelaySessionError>; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface ManagedRelaySnapshotState<A> { | ||
| 26 | readonly data: A | null; | ||
| 27 | readonly error: string | null; | ||
| 28 | readonly isPending: boolean; | ||
| 29 | } | ||
| 30 | |||
| 31 | export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{ | ||
| 32 | readonly message: string; | ||
| 33 | readonly cause?: unknown; | ||
| 34 | }> {} | ||
| 35 | |||
| 36 | export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySnapshotError")<{ | ||
| 37 | readonly message: string; | ||
| 38 | }> {} | ||
| 39 | |||
| 40 | export const managedRelaySessionAtom = Atom.make<ManagedRelaySession | null>(null).pipe( | ||
| 41 | Atom.keepAlive, | ||
| 42 | Atom.withLabel("managed-relay:session"), | ||
| 43 | ); | ||
| 44 | |||
| 45 | export function createManagedRelaySession(input: { | ||
| 46 | readonly accountId: string; | ||
| 47 | readonly readClerkToken: () => Promise<string | null>; | ||
| 48 | }): ManagedRelaySession { | ||
| 49 | return { | ||
| 50 | accountId: input.accountId, | ||
| 51 | readClerkToken: () => | ||
| 52 | Effect.tryPromise({ | ||
| 53 | try: input.readClerkToken, | ||
| 54 | catch: (cause) => | ||
| 55 | new ManagedRelaySessionError({ | ||
| 56 | message: "Could not obtain the T3 Cloud session token.", | ||
| 57 | cause, | ||
| 58 | }), | ||
| 59 | }), | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | |||
| 63 | export function setManagedRelaySession( | ||
| 64 | registry: AtomRegistry.AtomRegistry, | ||
| 65 | session: ManagedRelaySession | null, | ||
| 66 | ): void { | ||
| 67 | registry.set(managedRelaySessionAtom, session); | ||
| 68 | } | ||
| 69 | |||
| 70 | function readSessionClerkToken( | ||
| 71 | session: ManagedRelaySession, | ||
| 72 | ): Effect.Effect<string, ManagedRelaySessionError> { | ||
| 73 | return session.readClerkToken().pipe( | ||
| 74 | Effect.flatMap((token) => | ||
| 75 | token | ||
| 76 | ? Effect.succeed(token) | ||
| 77 | : Effect.fail( | ||
| 78 | new ManagedRelaySessionError({ | ||
| 79 | message: "The T3 Cloud session token is unavailable.", | ||
| 80 | }), | ||
| 81 | ), | ||
| 82 | ), | ||
| 83 | ); | ||
| 84 | } | ||
| 85 | |||
| 86 | export function waitForManagedRelayClerkToken( | ||
| 87 | registry: AtomRegistry.AtomRegistry, | ||
| 88 | ): Effect.Effect<string, ManagedRelaySessionError> { | ||
| 89 | return Effect.callback<string, ManagedRelaySessionError>((resume) => { | ||
| 90 | let unsubscribe: (() => void) | undefined; | ||
| 91 | let completed = false; | ||
| 92 | const readCurrentSession = () => { | ||
| 93 | if (completed) { | ||
| 94 | return true; | ||
| 95 | } | ||
| 96 | const session = registry.get(managedRelaySessionAtom); | ||
| 97 | if (!session) { | ||
| 98 | return false; | ||
| 99 | } | ||
| 100 | completed = true; | ||
| 101 | unsubscribe?.(); | ||
| 102 | resume(readSessionClerkToken(session)); | ||
| 103 | return true; | ||
| 104 | }; | ||
| 105 | |||
| 106 | if (readCurrentSession()) { | ||
| 107 | return; | ||
| 108 | } | ||
| 109 | |||
| 110 | unsubscribe = registry.subscribe(managedRelaySessionAtom, readCurrentSession); | ||
| 111 | readCurrentSession(); | ||
| 112 | return Effect.sync(() => unsubscribe?.()); | ||
| 113 | }); | ||
| 114 | } | ||
| 115 | |||
| 116 | function requireClerkToken( | ||
| 117 | get: Atom.AtomContext, | ||
| 118 | accountId: string, | ||
| 119 | ): Effect.Effect<string, ManagedRelaySessionError> { | ||
| 120 | const session = get(managedRelaySessionAtom); | ||
| 121 | if (!session || session.accountId !== accountId) { | ||
| 122 | return Effect.fail( | ||
| 123 | new ManagedRelaySessionError({ | ||
| 124 | message: "Sign in to T3 Cloud before loading relay data.", | ||
| 125 | }), | ||
| 126 | ); | ||
| 127 | } | ||
| 128 | return readSessionClerkToken(session); | ||
| 129 | } | ||
| 130 | |||
| 131 | function statusKey(input: { | ||
| 132 | readonly accountId: string; | ||
| 133 | readonly environment: RelayClientEnvironmentRecord; | ||
| 134 | }): string { | ||
| 135 | return JSON.stringify(input); | ||
| 136 | } | ||
| 137 | |||
| 138 | function parseStatusKey(key: string): { | ||
| 139 | readonly accountId: string; | ||
| 140 | readonly environment: RelayClientEnvironmentRecord; | ||
| 141 | } { | ||
| 142 | return JSON.parse(key) as { | ||
| 143 | readonly accountId: string; | ||
| 144 | readonly environment: RelayClientEnvironmentRecord; | ||
| 145 | }; | ||
| 146 | } | ||
| 147 | |||
| 148 | function endpointMatches( | ||
| 149 | left: RelayClientEnvironmentRecord["endpoint"], | ||
| 150 | right: RelayClientEnvironmentRecord["endpoint"], | ||
| 151 | ): boolean { | ||
| 152 | return ( | ||
| 153 | left.httpBaseUrl === right.httpBaseUrl && | ||
| 154 | left.wsBaseUrl === right.wsBaseUrl && | ||
| 155 | left.providerKind === right.providerKind | ||
| 156 | ); | ||
| 157 | } | ||
| 158 | |||
| 159 | function validateEnvironmentStatus( | ||
| 160 | environment: RelayClientEnvironmentRecord, | ||
| 161 | status: RelayEnvironmentStatusResponse, | ||
| 162 | ): Effect.Effect<RelayEnvironmentStatusResponse, ManagedRelaySnapshotError> { | ||
| 163 | if (status.environmentId !== environment.environmentId) { | ||
| 164 | return Effect.fail( | ||
| 165 | new ManagedRelaySnapshotError({ | ||
| 166 | message: "Relay returned status for a different environment.", | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | } | ||
| 170 | if (!endpointMatches(status.endpoint, environment.endpoint)) { | ||
| 171 | return Effect.fail( | ||
| 172 | new ManagedRelaySnapshotError({ | ||
| 173 | message: "Relay returned status for a different endpoint.", | ||
| 174 | }), | ||
| 175 | ); | ||
| 176 | } | ||
| 177 | if (status.descriptor && status.descriptor.environmentId !== environment.environmentId) { | ||
| 178 | return Effect.fail( | ||
| 179 | new ManagedRelaySnapshotError({ | ||
| 180 | message: "Relay returned status descriptor for a different environment.", | ||
| 181 | }), | ||
| 182 | ); | ||
| 183 | } | ||
| 184 | return Effect.succeed(status); | ||
| 185 | } | ||
| 186 | |||
| 187 | export function readManagedRelaySnapshotState<A>( | ||
| 188 | result: AsyncResult.AsyncResult<A, unknown>, | ||
| 189 | ): ManagedRelaySnapshotState<A> { | ||
| 190 | let error: string | null = null; | ||
| 191 | if (result._tag === "Failure") { | ||
| 192 | const cause = Cause.squash(result.cause); | ||
| 193 | error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data."; | ||
| 194 | } | ||
| 195 | return { | ||
| 196 | data: Option.getOrNull(AsyncResult.value(result)), | ||
| 197 | error, | ||
| 198 | isPending: result.waiting, | ||
| 199 | }; | ||
| 200 | } | ||
| 201 | |||
| 202 | export function createManagedRelayQueryManager( | ||
| 203 | runtime: Atom.AtomRuntime<ManagedRelayClient>, | ||
| 204 | options?: { | ||
| 205 | readonly staleTimeMs?: number; | ||
| 206 | readonly idleTtlMs?: number; | ||
| 207 | }, | ||
| 208 | ) { | ||
| 209 | const staleTime = options?.staleTimeMs ?? DEFAULT_STALE_TIME_MS; | ||
| 210 | const idleTtl = options?.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; | ||
| 211 | |||
| 212 | const environmentsAtom = Atom.family((accountId: string) => | ||
| 213 | runtime | ||
| 214 | .atom((get) => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | const clerkToken = yield* requireClerkToken(get, accountId); | ||
| 217 | const relay = yield* ManagedRelayClient; | ||
| 218 | return yield* relay.listEnvironments({ clerkToken }); | ||
| 219 | }), | ||
| 220 | ) | ||
| 221 | .pipe( | ||
| 222 | Atom.swr({ staleTime, revalidateOnMount: true }), | ||
| 223 | Atom.setIdleTTL(idleTtl), | ||
| 224 | Atom.withLabel(`managed-relay:environments:${accountId}`), | ||
| 225 | ), | ||
| 226 | ); | ||
| 227 | |||
| 228 | const devicesAtom = Atom.family((accountId: string) => | ||
| 229 | runtime | ||
| 230 | .atom((get) => | ||
| 231 | Effect.gen(function* () { | ||
| 232 | const clerkToken = yield* requireClerkToken(get, accountId); | ||
| 233 | const relay = yield* ManagedRelayClient; | ||
| 234 | return yield* relay.listDevices({ clerkToken }); | ||
| 235 | }), | ||
| 236 | ) | ||
| 237 | .pipe( | ||
| 238 | Atom.swr({ staleTime, revalidateOnMount: true }), | ||
| 239 | Atom.setIdleTTL(idleTtl), | ||
| 240 | Atom.withLabel(`managed-relay:devices:${accountId}`), | ||
| 241 | ), | ||
| 242 | ); | ||
| 243 | |||
| 244 | const environmentStatusAtom = Atom.family((key: string) => { | ||
| 245 | const { accountId, environment } = parseStatusKey(key); | ||
| 246 | return runtime | ||
| 247 | .atom((get) => | ||
| 248 | Effect.gen(function* () { | ||
| 249 | const clerkToken = yield* requireClerkToken(get, accountId); | ||
| 250 | const relay = yield* ManagedRelayClient; | ||
| 251 | const status = yield* relay.getEnvironmentStatus({ | ||
| 252 | clerkToken, | ||
| 253 | scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], | ||
| 254 | environmentId: environment.environmentId, | ||
| 255 | }); | ||
| 256 | return yield* validateEnvironmentStatus(environment, status); | ||
| 257 | }), | ||
| 258 | ) | ||
| 259 | .pipe( | ||
| 260 | Atom.swr({ staleTime, revalidateOnMount: true }), | ||
| 261 | Atom.setIdleTTL(idleTtl), | ||
| 262 | Atom.withLabel(`managed-relay:environment-status:${key}`), | ||
| 263 | ); | ||
| 264 | }); | ||
| 265 | |||
| 266 | return { | ||
| 267 | environmentsAtom, | ||
| 268 | devicesAtom, | ||
| 269 | environmentStatusAtom: (input: { | ||
| 270 | readonly accountId: string; | ||
| 271 | readonly environment: RelayClientEnvironmentRecord; | ||
| 272 | }) => environmentStatusAtom(statusKey(input)), | ||
| 273 | refreshEnvironments(registry: AtomRegistry.AtomRegistry, accountId: string): void { | ||
| 274 | registry.refresh(environmentsAtom(accountId)); | ||
| 275 | }, | ||
| 276 | refreshDevices(registry: AtomRegistry.AtomRegistry, accountId: string): void { | ||
| 277 | registry.refresh(devicesAtom(accountId)); | ||
| 278 | }, | ||
| 279 | refreshEnvironmentStatus( | ||
| 280 | registry: AtomRegistry.AtomRegistry, | ||
| 281 | input: { | ||
| 282 | readonly accountId: string; | ||
| 283 | readonly environment: RelayClientEnvironmentRecord; | ||
| 284 | }, | ||
| 285 | ): void { | ||
| 286 | registry.refresh(environmentStatusAtom(statusKey(input))); | ||
| 287 | }, | ||
| 288 | }; | ||
| 289 | } | ||
packages/client-runtime/src/remote.test.ts+114-25| ... | @@ -8,8 +8,11 @@ import * as TestClock from "effect/testing/TestClock"; | ... | @@ -8,8 +8,11 @@ import * as TestClock from "effect/testing/TestClock"; |
| 8 | import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; | 8 | import { EnvironmentAuthInvalidError } from "@t3tools/contracts"; |
| 9 | import { | 9 | import { |
| 10 | bootstrapRemoteBearerSession, | 10 | bootstrapRemoteBearerSession, |
| 11 | exchangeRemoteDpopAccessToken, | ||
| 12 | fetchRemoteDpopSessionState, | ||
| 11 | fetchRemoteEnvironmentDescriptor, | 13 | fetchRemoteEnvironmentDescriptor, |
| 12 | fetchRemoteSessionState, | 14 | fetchRemoteSessionState, |
| 15 | issueRemoteDpopWebSocketTicket, | ||
| 13 | issueRemoteWebSocketTicket, | 16 | issueRemoteWebSocketTicket, |
| 14 | remoteHttpClientLayer, | 17 | remoteHttpClientLayer, |
| 15 | RemoteEnvironmentAuthInvalidJsonError, | 18 | RemoteEnvironmentAuthInvalidJsonError, |
| ... | @@ -123,6 +126,55 @@ describe("remote", () => { | ... | @@ -123,6 +126,55 @@ describe("remote", () => { |
| 123 | }), | 126 | }), |
| 124 | ); | 127 | ); |
| 125 | 128 | ||
| 129 | it.effect("exchanges managed credentials and admits websocket requests with DPoP", () => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | const fetch = recordedFetch( | ||
| 132 | Response.json({ | ||
| 133 | access_token: "dpop-access-token", | ||
| 134 | issued_token_type: "urn:ietf:params:oauth:token-type:access_token", | ||
| 135 | token_type: "DPoP", | ||
| 136 | expires_in: 3600, | ||
| 137 | scope: "orchestration:read orchestration:operate terminal:operate review:write", | ||
| 138 | }), | ||
| 139 | Response.json({ | ||
| 140 | ticket: "ws-ticket", | ||
| 141 | expiresAt: "2026-05-01T12:05:00.000Z", | ||
| 142 | }), | ||
| 143 | ); | ||
| 144 | |||
| 145 | const token = yield* exchangeRemoteDpopAccessToken({ | ||
| 146 | httpBaseUrl: "https://remote.example.com/", | ||
| 147 | credential: "one-time-credential", | ||
| 148 | dpopProof: "token-proof", | ||
| 149 | clientMetadata: { | ||
| 150 | label: "T3 Code Mobile", | ||
| 151 | deviceType: "mobile", | ||
| 152 | os: "iOS", | ||
| 153 | }, | ||
| 154 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 155 | yield* issueRemoteDpopWebSocketTicket({ | ||
| 156 | httpBaseUrl: "https://remote.example.com/", | ||
| 157 | accessToken: token.access_token, | ||
| 158 | dpopProof: "resource-proof", | ||
| 159 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 160 | |||
| 161 | expectFetchCall(fetch.calls, 1, { | ||
| 162 | url: "https://remote.example.com/oauth/token", | ||
| 163 | method: "POST", | ||
| 164 | headers: { dpop: "token-proof", "content-type": "application/x-www-form-urlencoded" }, | ||
| 165 | 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", | ||
| 166 | }); | ||
| 167 | expectFetchCall(fetch.calls, 2, { | ||
| 168 | url: "https://remote.example.com/api/auth/websocket-ticket", | ||
| 169 | method: "POST", | ||
| 170 | headers: { | ||
| 171 | authorization: "DPoP dpop-access-token", | ||
| 172 | dpop: "resource-proof", | ||
| 173 | }, | ||
| 174 | }); | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | |||
| 126 | it.effect("submits optional client display metadata during bearer token exchange", () => | 178 | it.effect("submits optional client display metadata during bearer token exchange", () => |
| 127 | Effect.gen(function* () { | 179 | Effect.gen(function* () { |
| 128 | const fetch = recordedFetch( | 180 | const fetch = recordedFetch( |
| ... | @@ -186,7 +238,7 @@ describe("remote", () => { | ... | @@ -186,7 +238,7 @@ describe("remote", () => { |
| 186 | }), | 238 | }), |
| 187 | ); | 239 | ); |
| 188 | 240 | ||
| 189 | it.effect("loads remote session state and websocket tokens over bearer auth", () => | 241 | it.effect("loads remote session state and websocket tickets over bearer auth", () => |
| 190 | Effect.gen(function* () { | 242 | Effect.gen(function* () { |
| 191 | const fetch = recordedFetch( | 243 | const fetch = recordedFetch( |
| 192 | Response.json( | 244 | Response.json( |
| ... | @@ -257,11 +309,11 @@ describe("remote", () => { | ... | @@ -257,11 +309,11 @@ describe("remote", () => { |
| 257 | ], | 309 | ], |
| 258 | }); | 310 | }); |
| 259 | 311 | ||
| 260 | const token = yield* issueRemoteWebSocketTicket({ | 312 | const ticket = yield* issueRemoteWebSocketTicket({ |
| 261 | httpBaseUrl: "https://remote.example.com/", | 313 | httpBaseUrl: "https://remote.example.com/", |
| 262 | bearerToken: "bearer-token", | 314 | bearerToken: "bearer-token", |
| 263 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | 315 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 264 | expect(token).toMatchObject({ | 316 | expect(ticket).toMatchObject({ |
| 265 | ticket: "ws-ticket", | 317 | ticket: "ws-ticket", |
| 266 | }); | 318 | }); |
| 267 | 319 | ||
| ... | @@ -286,6 +338,45 @@ describe("remote", () => { | ... | @@ -286,6 +338,45 @@ describe("remote", () => { |
| 286 | }), | 338 | }), |
| 287 | ); | 339 | ); |
| 288 | 340 | ||
| 341 | it.effect("loads remote session state with a DPoP-bound access token", () => | ||
| 342 | Effect.gen(function* () { | ||
| 343 | const fetch = recordedFetch( | ||
| 344 | Response.json({ | ||
| 345 | authenticated: true, | ||
| 346 | auth: { | ||
| 347 | policy: "remote-reachable", | ||
| 348 | bootstrapMethods: ["one-time-token"], | ||
| 349 | sessionMethods: ["dpop-access-token"], | ||
| 350 | sessionCookieName: "t3_session", | ||
| 351 | }, | ||
| 352 | sessionMethod: "dpop-access-token", | ||
| 353 | scopes: [ | ||
| 354 | "orchestration:read", | ||
| 355 | "orchestration:operate", | ||
| 356 | "terminal:operate", | ||
| 357 | "review:write", | ||
| 358 | ], | ||
| 359 | expiresAt: "2026-05-01T12:00:00.000Z", | ||
| 360 | }), | ||
| 361 | ); | ||
| 362 | |||
| 363 | yield* fetchRemoteDpopSessionState({ | ||
| 364 | httpBaseUrl: "https://remote.example.com/", | ||
| 365 | accessToken: "dpop-access-token", | ||
| 366 | dpopProof: "dpop-proof", | ||
| 367 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | ||
| 368 | |||
| 369 | expectFetchCall(fetch.calls, 1, { | ||
| 370 | url: "https://remote.example.com/api/auth/session", | ||
| 371 | method: "GET", | ||
| 372 | headers: { | ||
| 373 | authorization: "DPoP dpop-access-token", | ||
| 374 | dpop: "dpop-proof", | ||
| 375 | }, | ||
| 376 | }); | ||
| 377 | }), | ||
| 378 | ); | ||
| 379 | |||
| 289 | it.effect("fails hung fetch requests on the configured timeout", () => | 380 | it.effect("fails hung fetch requests on the configured timeout", () => |
| 290 | Effect.gen(function* () { | 381 | Effect.gen(function* () { |
| 291 | const fetch = hangingFetch(); | 382 | const fetch = hangingFetch(); |
| ... | @@ -360,27 +451,25 @@ describe("remote", () => { | ... | @@ -360,27 +451,25 @@ describe("remote", () => { |
| 360 | }), | 451 | }), |
| 361 | ); | 452 | ); |
| 362 | 453 | ||
| 363 | it.effect( | 454 | it.effect("mints a websocket url that targets the rpc route with a short-lived ticket", () => |
| 364 | "mints a websocket url that targets the rpc route with a short-lived websocket ticket", | 455 | Effect.gen(function* () { |
| 365 | () => | 456 | const fetch = recordedFetch( |
| 366 | Effect.gen(function* () { | 457 | Response.json( |
| 367 | const fetch = recordedFetch( | 458 | { |
| 368 | Response.json( | 459 | ticket: "ws-ticket", |
| 369 | { | 460 | expiresAt: "2026-05-01T12:05:00.000Z", |
| 370 | ticket: "ws-ticket", | 461 | }, |
| 371 | expiresAt: "2026-05-01T12:05:00.000Z", | 462 | { status: 200 }, |
| 372 | }, | 463 | ), |
| 373 | { status: 200 }, | 464 | ); |
| 374 | ), | 465 | |
| 375 | ); | 466 | const url = yield* resolveRemoteWebSocketConnectionUrl({ |
| 376 | 467 | wsBaseUrl: "wss://remote.example.com/", | |
| 377 | const url = yield* resolveRemoteWebSocketConnectionUrl({ | 468 | httpBaseUrl: "https://remote.example.com/", |
| 378 | wsBaseUrl: "wss://remote.example.com/", | 469 | bearerToken: "bearer-token", |
| 379 | httpBaseUrl: "https://remote.example.com/", | 470 | }).pipe(provideRemoteHttp(fetch.fetchFn)); |
| 380 | bearerToken: "bearer-token", | 471 | |
| 381 | }).pipe(provideRemoteHttp(fetch.fetchFn)); | 472 | expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); |
| 382 | 473 | }), | |
| 383 | expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket"); | ||
| 384 | }), | ||
| 385 | ); | 474 | ); |
| 386 | }); | 475 | }); |
packages/client-runtime/src/remote.ts+108-13| ... | @@ -7,7 +7,6 @@ import { | ... | @@ -7,7 +7,6 @@ import { |
| 7 | EnvironmentHttpApi, | 7 | EnvironmentHttpApi, |
| 8 | EnvironmentHttpCommonError, | 8 | EnvironmentHttpCommonError, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | ||
| 11 | import type { | 10 | import type { |
| 12 | EnvironmentAuthInvalidError, | 11 | EnvironmentAuthInvalidError, |
| 13 | EnvironmentInternalError, | 12 | EnvironmentInternalError, |
| ... | @@ -15,6 +14,7 @@ import type { | ... | @@ -15,6 +14,7 @@ import type { |
| 15 | EnvironmentRequestInvalidError, | 14 | EnvironmentRequestInvalidError, |
| 16 | EnvironmentScopeRequiredError, | 15 | EnvironmentScopeRequiredError, |
| 17 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 17 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | ||
| 18 | import * as Data from "effect/Data"; | 18 | import * as Data from "effect/Data"; |
| 19 | import * as Duration from "effect/Duration"; | 19 | import * as Duration from "effect/Duration"; |
| 20 | import * as Effect from "effect/Effect"; | 20 | import * as Effect from "effect/Effect"; |
| ... | @@ -27,7 +27,7 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; | ... | @@ -27,7 +27,7 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; |
| 27 | const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; | 27 | const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; |
| 28 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); | 28 | const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); |
| 29 | 29 | ||
| 30 | const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { | 30 | export const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { |
| 31 | const url = new URL(httpBaseUrl); | 31 | const url = new URL(httpBaseUrl); |
| 32 | url.pathname = pathname; | 32 | url.pathname = pathname; |
| 33 | url.search = ""; | 33 | url.search = ""; |
| ... | @@ -43,6 +43,14 @@ const remoteApiBaseUrl = (httpBaseUrl: string): string => { | ... | @@ -43,6 +43,14 @@ const remoteApiBaseUrl = (httpBaseUrl: string): string => { |
| 43 | return url.toString(); | 43 | return url.toString(); |
| 44 | }; | 44 | }; |
| 45 | 45 | ||
| 46 | const clientMetadataTokenExchangeFields = ( | ||
| 47 | clientMetadata: AuthClientPresentationMetadata | undefined, | ||
| 48 | ) => ({ | ||
| 49 | ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), | ||
| 50 | ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), | ||
| 51 | ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), | ||
| 52 | }); | ||
| 53 | |||
| 46 | export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( | 54 | export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( |
| 47 | "RemoteEnvironmentAuthFetchError", | 55 | "RemoteEnvironmentAuthFetchError", |
| 48 | )<{ | 56 | )<{ |
| ... | @@ -162,14 +170,39 @@ const executeRemoteRequest = <A, E, R>( | ... | @@ -162,14 +170,39 @@ const executeRemoteRequest = <A, E, R>( |
| 162 | ); | 170 | ); |
| 163 | 171 | ||
| 164 | export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => | 172 | export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => |
| 165 | Effect.gen(function* () { | 173 | HttpApiClient.make(EnvironmentHttpApi, { |
| 166 | const httpClient = yield* HttpClient.HttpClient; | 174 | baseUrl: remoteApiBaseUrl(httpBaseUrl), |
| 167 | return yield* HttpApiClient.makeWith(EnvironmentHttpApi, { | ||
| 168 | httpClient, | ||
| 169 | baseUrl: remoteApiBaseUrl(httpBaseUrl), | ||
| 170 | }); | ||
| 171 | }); | 175 | }); |
| 172 | 176 | ||
| 177 | export const exchangeRemoteDpopAccessToken = Effect.fn( | ||
| 178 | "clientRuntime.remote.exchangeRemoteDpopAccessToken", | ||
| 179 | )(function* (input: { | ||
| 180 | readonly httpBaseUrl: string; | ||
| 181 | readonly credential: string; | ||
| 182 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 183 | readonly clientMetadata?: AuthClientPresentationMetadata; | ||
| 184 | readonly dpopProof: string; | ||
| 185 | readonly timeoutMs?: number; | ||
| 186 | }) { | ||
| 187 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 188 | const response = yield* executeRemoteRequest( | ||
| 189 | remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), | ||
| 190 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 191 | client.auth.token({ | ||
| 192 | headers: { dpop: input.dpopProof }, | ||
| 193 | payload: { | ||
| 194 | grant_type: AuthTokenExchangeGrantType, | ||
| 195 | subject_token: input.credential, | ||
| 196 | subject_token_type: AuthEnvironmentBootstrapTokenType, | ||
| 197 | requested_token_type: AuthAccessTokenType, | ||
| 198 | ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), | ||
| 199 | ...clientMetadataTokenExchangeFields(input.clientMetadata), | ||
| 200 | }, | ||
| 201 | }), | ||
| 202 | ); | ||
| 203 | return response; | ||
| 204 | }); | ||
| 205 | |||
| 173 | export const bootstrapRemoteBearerSession = Effect.fn( | 206 | export const bootstrapRemoteBearerSession = Effect.fn( |
| 174 | "clientRuntime.remote.bootstrapRemoteBearerSession", | 207 | "clientRuntime.remote.bootstrapRemoteBearerSession", |
| 175 | )(function* (input: { | 208 | )(function* (input: { |
| ... | @@ -184,17 +217,14 @@ export const bootstrapRemoteBearerSession = Effect.fn( | ... | @@ -184,17 +217,14 @@ export const bootstrapRemoteBearerSession = Effect.fn( |
| 184 | remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), | 217 | remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), |
| 185 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | 218 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, |
| 186 | client.auth.token({ | 219 | client.auth.token({ |
| 220 | headers: {}, | ||
| 187 | payload: { | 221 | payload: { |
| 188 | grant_type: AuthTokenExchangeGrantType, | 222 | grant_type: AuthTokenExchangeGrantType, |
| 189 | subject_token: input.credential, | 223 | subject_token: input.credential, |
| 190 | subject_token_type: AuthEnvironmentBootstrapTokenType, | 224 | subject_token_type: AuthEnvironmentBootstrapTokenType, |
| 191 | requested_token_type: AuthAccessTokenType, | 225 | requested_token_type: AuthAccessTokenType, |
| 192 | ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), | 226 | ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), |
| 193 | ...(input.clientMetadata?.label ? { client_label: input.clientMetadata.label } : {}), | 227 | ...clientMetadataTokenExchangeFields(input.clientMetadata), |
| 194 | ...(input.clientMetadata?.deviceType | ||
| 195 | ? { client_device_type: input.clientMetadata.deviceType } | ||
| 196 | : {}), | ||
| 197 | ...(input.clientMetadata?.os ? { client_os: input.clientMetadata.os } : {}), | ||
| 198 | }, | 228 | }, |
| 199 | }), | 229 | }), |
| 200 | ); | 230 | ); |
| ... | @@ -219,6 +249,27 @@ export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemo | ... | @@ -219,6 +249,27 @@ export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemo |
| 219 | }, | 249 | }, |
| 220 | ); | 250 | ); |
| 221 | 251 | ||
| 252 | export const fetchRemoteDpopSessionState = Effect.fn( | ||
| 253 | "clientRuntime.remote.fetchRemoteDpopSessionState", | ||
| 254 | )(function* (input: { | ||
| 255 | readonly httpBaseUrl: string; | ||
| 256 | readonly accessToken: string; | ||
| 257 | readonly dpopProof: string; | ||
| 258 | readonly timeoutMs?: number; | ||
| 259 | }) { | ||
| 260 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 261 | return yield* executeRemoteRequest( | ||
| 262 | remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), | ||
| 263 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 264 | client.auth.session({ | ||
| 265 | headers: { | ||
| 266 | authorization: `DPoP ${input.accessToken}`, | ||
| 267 | dpop: input.dpopProof, | ||
| 268 | }, | ||
| 269 | }), | ||
| 270 | ); | ||
| 271 | }); | ||
| 272 | |||
| 222 | export const fetchRemoteEnvironmentDescriptor = Effect.fn( | 273 | export const fetchRemoteEnvironmentDescriptor = Effect.fn( |
| 223 | "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", | 274 | "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", |
| 224 | )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { | 275 | )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { |
| ... | @@ -249,6 +300,27 @@ export const issueRemoteWebSocketTicket = Effect.fn( | ... | @@ -249,6 +300,27 @@ export const issueRemoteWebSocketTicket = Effect.fn( |
| 249 | ); | 300 | ); |
| 250 | }); | 301 | }); |
| 251 | 302 | ||
| 303 | export const issueRemoteDpopWebSocketTicket = Effect.fn( | ||
| 304 | "clientRuntime.remote.issueRemoteDpopWebSocketTicket", | ||
| 305 | )(function* (input: { | ||
| 306 | readonly httpBaseUrl: string; | ||
| 307 | readonly accessToken: string; | ||
| 308 | readonly dpopProof: string; | ||
| 309 | readonly timeoutMs?: number; | ||
| 310 | }) { | ||
| 311 | const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); | ||
| 312 | return yield* executeRemoteRequest( | ||
| 313 | remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), | ||
| 314 | input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, | ||
| 315 | client.auth.webSocketTicket({ | ||
| 316 | headers: { | ||
| 317 | authorization: `DPoP ${input.accessToken}`, | ||
| 318 | dpop: input.dpopProof, | ||
| 319 | }, | ||
| 320 | }), | ||
| 321 | ); | ||
| 322 | }); | ||
| 323 | |||
| 252 | export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | 324 | export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 253 | "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl", | 325 | "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl", |
| 254 | )(function* (input: { | 326 | )(function* (input: { |
| ... | @@ -270,3 +342,26 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( | ... | @@ -270,3 +342,26 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( |
| 270 | url.searchParams.set("wsTicket", issued.ticket); | 342 | url.searchParams.set("wsTicket", issued.ticket); |
| 271 | return url.toString(); | 343 | return url.toString(); |
| 272 | }); | 344 | }); |
| 345 | |||
| 346 | export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( | ||
| 347 | "clientRuntime.remote.resolveRemoteDpopWebSocketConnectionUrl", | ||
| 348 | )(function* (input: { | ||
| 349 | readonly wsBaseUrl: string; | ||
| 350 | readonly httpBaseUrl: string; | ||
| 351 | readonly accessToken: string; | ||
| 352 | readonly dpopProof: string; | ||
| 353 | readonly timeoutMs?: number; | ||
| 354 | }) { | ||
| 355 | const issued = yield* issueRemoteDpopWebSocketTicket({ | ||
| 356 | httpBaseUrl: input.httpBaseUrl, | ||
| 357 | accessToken: input.accessToken, | ||
| 358 | dpopProof: input.dpopProof, | ||
| 359 | ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), | ||
| 360 | }); | ||
| 361 | const url = new URL(input.wsBaseUrl); | ||
| 362 | if (url.pathname === "" || url.pathname === "/") { | ||
| 363 | url.pathname = "/ws"; | ||
| 364 | } | ||
| 365 | url.searchParams.set("wsTicket", issued.ticket); | ||
| 366 | return url.toString(); | ||
| 367 | }); |
packages/client-runtime/src/transportError.test.ts+8| ... | @@ -11,6 +11,14 @@ describe("isTransportConnectionErrorMessage", () => { | ... | @@ -11,6 +11,14 @@ describe("isTransportConnectionErrorMessage", () => { |
| 11 | expect(isTransportConnectionErrorMessage("SocketOpenError: ECONNREFUSED")).toBe(true); | 11 | expect(isTransportConnectionErrorMessage("SocketOpenError: ECONNREFUSED")).toBe(true); |
| 12 | }); | 12 | }); |
| 13 | 13 | ||
| 14 | it("returns true for React Native disconnected socket errors", () => { | ||
| 15 | expect( | ||
| 16 | isTransportConnectionErrorMessage( | ||
| 17 | "The operation couldn't be completed. Socket is not connected", | ||
| 18 | ), | ||
| 19 | ).toBe(true); | ||
| 20 | }); | ||
| 21 | |||
| 14 | it("returns true for the T3 server WebSocket message", () => { | 22 | it("returns true for the T3 server WebSocket message", () => { |
| 15 | expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( | 23 | expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( |
| 16 | true, | 24 | true, |
packages/client-runtime/src/transportError.ts+1| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | const TRANSPORT_ERROR_PATTERNS = [ | 1 | const TRANSPORT_ERROR_PATTERNS = [ |
| 2 | /\bSocketCloseError\b/i, | 2 | /\bSocketCloseError\b/i, |
| 3 | /\bSocketOpenError\b/i, | 3 | /\bSocketOpenError\b/i, |
| 4 | /\bSocket is not connected\b/i, | ||
| 4 | /Unable to connect to the T3 server WebSocket\./i, | 5 | /Unable to connect to the T3 server WebSocket\./i, |
| 5 | /\bping timeout\b/i, | 6 | /\bping timeout\b/i, |
| 6 | ] as const; | 7 | ] as const; |
packages/client-runtime/src/wsRpcClient.ts+28| ... | @@ -4,6 +4,8 @@ import { | ... | @@ -4,6 +4,8 @@ import { |
| 4 | type GitRunStackedActionResult, | 4 | type GitRunStackedActionResult, |
| 5 | type LocalApi, | 5 | type LocalApi, |
| 6 | ORCHESTRATION_WS_METHODS, | 6 | ORCHESTRATION_WS_METHODS, |
| 7 | type RelayClientInstallProgressEvent, | ||
| 8 | type RelayClientStatus, | ||
| 7 | type ServerSettingsPatch, | 9 | type ServerSettingsPatch, |
| 8 | type VcsStatusResult, | 10 | type VcsStatusResult, |
| 9 | type VcsStatusStreamEvent, | 11 | type VcsStatusStreamEvent, |
| ... | @@ -150,6 +152,12 @@ export interface WsRpcClient { | ... | @@ -150,6 +152,12 @@ export interface WsRpcClient { |
| 150 | >; | 152 | >; |
| 151 | readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>; | 153 | readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>; |
| 152 | }; | 154 | }; |
| 155 | readonly cloud: { | ||
| 156 | readonly getRelayClientStatus: RpcUnaryNoArgMethod<typeof WS_METHODS.cloudGetRelayClientStatus>; | ||
| 157 | readonly installRelayClient: ( | ||
| 158 | onProgress?: (event: RelayClientInstallProgressEvent) => void, | ||
| 159 | ) => Promise<RelayClientStatus>; | ||
| 160 | }; | ||
| 153 | readonly orchestration: { | 161 | readonly orchestration: { |
| 154 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; | 162 | readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>; |
| 155 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; | 163 | readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>; |
| ... | @@ -320,6 +328,26 @@ export function createWsRpcClient( | ... | @@ -320,6 +328,26 @@ export function createWsRpcClient( |
| 320 | signalProcess: (input) => | 328 | signalProcess: (input) => |
| 321 | transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), | 329 | transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), |
| 322 | }, | 330 | }, |
| 331 | cloud: { | ||
| 332 | getRelayClientStatus: () => | ||
| 333 | transport.request((client) => client[WS_METHODS.cloudGetRelayClientStatus]({})), | ||
| 334 | installRelayClient: async (onProgress) => { | ||
| 335 | let installed: RelayClientStatus | null = null; | ||
| 336 | await transport.requestStream( | ||
| 337 | (client) => client[WS_METHODS.cloudInstallRelayClient]({}), | ||
| 338 | (event) => { | ||
| 339 | onProgress?.(event); | ||
| 340 | if (event.type === "complete") { | ||
| 341 | installed = event.status; | ||
| 342 | } | ||
| 343 | }, | ||
| 344 | ); | ||
| 345 | if (installed) { | ||
| 346 | return installed; | ||
| 347 | } | ||
| 348 | throw new Error("Relay client install stream completed without a final status."); | ||
| 349 | }, | ||
| 350 | }, | ||
| 323 | orchestration: { | 351 | orchestration: { |
| 324 | dispatchCommand: (input) => | 352 | dispatchCommand: (input) => |
| 325 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), | 353 | transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), |
packages/client-runtime/src/wsTransport.test.ts+2-2| ... | @@ -788,7 +788,7 @@ describe("WsTransport", () => { | ... | @@ -788,7 +788,7 @@ describe("WsTransport", () => { |
| 788 | () => | 788 | () => |
| 789 | Stream.suspend(() => { | 789 | Stream.suspend(() => { |
| 790 | attempts += 1; | 790 | attempts += 1; |
| 791 | return Stream.fail(new Error("SocketCloseError: WebSocket closed")); | 791 | return Stream.fail(new Error("Socket is not connected")); |
| 792 | }), | 792 | }), |
| 793 | vi.fn(), | 793 | vi.fn(), |
| 794 | { retryDelay: 10 }, | 794 | { retryDelay: 10 }, |
| ... | @@ -805,7 +805,7 @@ describe("WsTransport", () => { | ... | @@ -805,7 +805,7 @@ describe("WsTransport", () => { |
| 805 | }); | 805 | }); |
| 806 | 806 | ||
| 807 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { | 807 | expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { |
| 808 | error: "SocketCloseError: WebSocket closed", | 808 | error: "Socket is not connected", |
| 809 | }); | 809 | }); |
| 810 | 810 | ||
| 811 | unsubscribe(); | 811 | unsubscribe(); |
packages/contracts/package.json+4| ... | @@ -14,6 +14,10 @@ | ... | @@ -14,6 +14,10 @@ |
| 14 | "./settings": { | 14 | "./settings": { |
| 15 | "types": "./src/settings.ts", | 15 | "types": "./src/settings.ts", |
| 16 | "import": "./src/settings.ts" | 16 | "import": "./src/settings.ts" |
| 17 | }, | ||
| 18 | "./relay": { | ||
| 19 | "types": "./src/relay.ts", | ||
| 20 | "import": "./src/relay.ts" | ||
| 17 | } | 21 | } |
| 18 | }, | 22 | }, |
| 19 | "scripts": { | 23 | "scripts": { |
packages/contracts/src/auth.ts+4-1| ... | @@ -63,10 +63,13 @@ export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type; | ... | @@ -63,10 +63,13 @@ export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type; |
| 63 | * app after bootstrap/pairing | 63 | * app after bootstrap/pairing |
| 64 | * - `bearer-access-token`: scoped token suitable for non-cookie or | 64 | * - `bearer-access-token`: scoped token suitable for non-cookie or |
| 65 | * non-browser clients | 65 | * non-browser clients |
| 66 | * - `dpop-access-token`: scoped proof-of-possession token used by managed | ||
| 67 | * relay connections | ||
| 66 | */ | 68 | */ |
| 67 | export const ServerAuthSessionMethod = Schema.Literals([ | 69 | export const ServerAuthSessionMethod = Schema.Literals([ |
| 68 | "browser-session-cookie", | 70 | "browser-session-cookie", |
| 69 | "bearer-access-token", | 71 | "bearer-access-token", |
| 72 | "dpop-access-token", | ||
| 70 | ]); | 73 | ]); |
| 71 | export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; | 74 | export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type; |
| 72 | 75 | ||
| ... | @@ -184,7 +187,7 @@ export type AuthTokenExchangeRequest = typeof AuthTokenExchangeRequest.Type; | ... | @@ -184,7 +187,7 @@ export type AuthTokenExchangeRequest = typeof AuthTokenExchangeRequest.Type; |
| 184 | export const AuthAccessTokenResult = Schema.Struct({ | 187 | export const AuthAccessTokenResult = Schema.Struct({ |
| 185 | access_token: TrimmedNonEmptyString, | 188 | access_token: TrimmedNonEmptyString, |
| 186 | issued_token_type: Schema.Literal(AuthAccessTokenType), | 189 | issued_token_type: Schema.Literal(AuthAccessTokenType), |
| 187 | token_type: Schema.Literal("Bearer"), | 190 | token_type: Schema.Literals(["Bearer", "DPoP"]), |
| 188 | expires_in: Schema.Number, | 191 | expires_in: Schema.Number, |
| 189 | scope: TrimmedNonEmptyString, | 192 | scope: TrimmedNonEmptyString, |
| 190 | }); | 193 | }); |
packages/contracts/src/editor.ts+1-1| ... | @@ -54,6 +54,6 @@ export class ExternalLauncherError extends Schema.TaggedErrorClass<ExternalLaunc | ... | @@ -54,6 +54,6 @@ export class ExternalLauncherError extends Schema.TaggedErrorClass<ExternalLaunc |
| 54 | "ExternalLauncherError", | 54 | "ExternalLauncherError", |
| 55 | { | 55 | { |
| 56 | message: Schema.String, | 56 | message: Schema.String, |
| 57 | cause: Schema.optional(Schema.Defect), | 57 | cause: Schema.optional(Schema.Defect()), |
| 58 | }, | 58 | }, |
| 59 | ) {} | 59 | ) {} |
packages/contracts/src/environmentHttp.ts+183-1| ... | @@ -31,9 +31,23 @@ import { | ... | @@ -31,9 +31,23 @@ import { |
| 31 | DispatchResult, | 31 | DispatchResult, |
| 32 | OrchestrationReadModel, | 32 | OrchestrationReadModel, |
| 33 | } from "./orchestration.ts"; | 33 | } from "./orchestration.ts"; |
| 34 | import { | ||
| 35 | RelayCloudEnvironmentHealthRequest, | ||
| 36 | RelayCloudMintCredentialRequest, | ||
| 37 | RelayEnvironmentConfigRequest, | ||
| 38 | RelayEnvironmentHealthResponse, | ||
| 39 | RelayEnvironmentLinkProof, | ||
| 40 | RelayEnvironmentMintResponse, | ||
| 41 | RelayLinkProofRequest, | ||
| 42 | } from "./relay.ts"; | ||
| 34 | 43 | ||
| 35 | const OptionalBearerHeaders = Schema.Struct({ | 44 | const OptionalBearerHeaders = Schema.Struct({ |
| 36 | authorization: Schema.optionalKey(Schema.String), | 45 | authorization: Schema.optionalKey(Schema.String), |
| 46 | dpop: Schema.optionalKey(Schema.String), | ||
| 47 | }); | ||
| 48 | |||
| 49 | const OptionalDpopProofHeaders = Schema.Struct({ | ||
| 50 | dpop: Schema.optionalKey(Schema.String), | ||
| 37 | }); | 51 | }); |
| 38 | 52 | ||
| 39 | export const EnvironmentRequestInvalidReason = Schema.Literals([ | 53 | export const EnvironmentRequestInvalidReason = Schema.Literals([ |
| ... | @@ -154,6 +168,81 @@ const EnvironmentAuthenticationErrors = [ | ... | @@ -154,6 +168,81 @@ const EnvironmentAuthenticationErrors = [ |
| 154 | EnvironmentAuthInvalidError, | 168 | EnvironmentAuthInvalidError, |
| 155 | EnvironmentInternalError, | 169 | EnvironmentInternalError, |
| 156 | ] as const; | 170 | ] as const; |
| 171 | |||
| 172 | export class EnvironmentHttpBadRequestError extends Schema.TaggedErrorClass<EnvironmentHttpBadRequestError>()( | ||
| 173 | "EnvironmentHttpBadRequestError", | ||
| 174 | { | ||
| 175 | message: Schema.String, | ||
| 176 | }, | ||
| 177 | { httpApiStatus: 400 }, | ||
| 178 | ) { | ||
| 179 | [HttpServerRespondable.symbol]() { | ||
| 180 | return HttpServerResponse.schemaJson(EnvironmentHttpBadRequestError)(this, { status: 400 }); | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | export class EnvironmentHttpUnauthorizedError extends Schema.TaggedErrorClass<EnvironmentHttpUnauthorizedError>()( | ||
| 185 | "EnvironmentHttpUnauthorizedError", | ||
| 186 | { | ||
| 187 | message: Schema.String, | ||
| 188 | }, | ||
| 189 | { httpApiStatus: 401 }, | ||
| 190 | ) { | ||
| 191 | [HttpServerRespondable.symbol]() { | ||
| 192 | return HttpServerResponse.schemaJson(EnvironmentHttpUnauthorizedError)(this, { status: 401 }); | ||
| 193 | } | ||
| 194 | } | ||
| 195 | |||
| 196 | export class EnvironmentHttpForbiddenError extends Schema.TaggedErrorClass<EnvironmentHttpForbiddenError>()( | ||
| 197 | "EnvironmentHttpForbiddenError", | ||
| 198 | { | ||
| 199 | message: Schema.String, | ||
| 200 | }, | ||
| 201 | { httpApiStatus: 403 }, | ||
| 202 | ) { | ||
| 203 | [HttpServerRespondable.symbol]() { | ||
| 204 | return HttpServerResponse.schemaJson(EnvironmentHttpForbiddenError)(this, { status: 403 }); | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | export class EnvironmentHttpInternalServerError extends Schema.TaggedErrorClass<EnvironmentHttpInternalServerError>()( | ||
| 209 | "EnvironmentHttpInternalServerError", | ||
| 210 | { | ||
| 211 | message: Schema.String, | ||
| 212 | }, | ||
| 213 | { httpApiStatus: 500 }, | ||
| 214 | ) { | ||
| 215 | [HttpServerRespondable.symbol]() { | ||
| 216 | return HttpServerResponse.schemaJson(EnvironmentHttpInternalServerError)(this, { status: 500 }); | ||
| 217 | } | ||
| 218 | } | ||
| 219 | |||
| 220 | export class EnvironmentHttpConflictError extends Schema.TaggedErrorClass<EnvironmentHttpConflictError>()( | ||
| 221 | "EnvironmentHttpConflictError", | ||
| 222 | { | ||
| 223 | message: Schema.String, | ||
| 224 | }, | ||
| 225 | { httpApiStatus: 409 }, | ||
| 226 | ) { | ||
| 227 | [HttpServerRespondable.symbol]() { | ||
| 228 | return HttpServerResponse.schemaJson(EnvironmentHttpConflictError)(this, { status: 409 }); | ||
| 229 | } | ||
| 230 | } | ||
| 231 | |||
| 232 | export class EnvironmentCloudEndpointUnavailableError extends Schema.TaggedErrorClass<EnvironmentCloudEndpointUnavailableError>()( | ||
| 233 | "EnvironmentCloudEndpointUnavailableError", | ||
| 234 | { | ||
| 235 | message: Schema.String, | ||
| 236 | endpointRuntimeStatus: Schema.Unknown, | ||
| 237 | }, | ||
| 238 | { httpApiStatus: 503 }, | ||
| 239 | ) { | ||
| 240 | [HttpServerRespondable.symbol]() { | ||
| 241 | return HttpServerResponse.schemaJson(EnvironmentCloudEndpointUnavailableError)(this, { | ||
| 242 | status: 503, | ||
| 243 | }); | ||
| 244 | } | ||
| 245 | } | ||
| 157 | const EnvironmentSessionCreationErrors = [ | 246 | const EnvironmentSessionCreationErrors = [ |
| 158 | EnvironmentAuthInvalidError, | 247 | EnvironmentAuthInvalidError, |
| 159 | EnvironmentInternalError, | 248 | EnvironmentInternalError, |
| ... | @@ -191,6 +280,7 @@ export interface EnvironmentSessionPrincipalShape { | ... | @@ -191,6 +280,7 @@ export interface EnvironmentSessionPrincipalShape { |
| 191 | readonly subject: string; | 280 | readonly subject: string; |
| 192 | readonly method: ServerAuthSessionMethod; | 281 | readonly method: ServerAuthSessionMethod; |
| 193 | readonly scopes: ReadonlySet<AuthEnvironmentScope>; | 282 | readonly scopes: ReadonlySet<AuthEnvironmentScope>; |
| 283 | readonly proofKeyThumbprint?: string; | ||
| 194 | readonly expiresAt?: DateTime.DateTime; | 284 | readonly expiresAt?: DateTime.DateTime; |
| 195 | } | 285 | } |
| 196 | 286 | ||
| ... | @@ -206,6 +296,35 @@ export class EnvironmentAuthenticatedAuth extends HttpApiMiddleware.Service< | ... | @@ -206,6 +296,35 @@ export class EnvironmentAuthenticatedAuth extends HttpApiMiddleware.Service< |
| 206 | error: EnvironmentAuthenticationErrors, | 296 | error: EnvironmentAuthenticationErrors, |
| 207 | }) {} | 297 | }) {} |
| 208 | 298 | ||
| 299 | const EnvironmentHttpCloudErrors = [ | ||
| 300 | EnvironmentHttpBadRequestError, | ||
| 301 | EnvironmentHttpUnauthorizedError, | ||
| 302 | EnvironmentHttpForbiddenError, | ||
| 303 | EnvironmentHttpConflictError, | ||
| 304 | EnvironmentHttpInternalServerError, | ||
| 305 | EnvironmentScopeRequiredError, | ||
| 306 | ] as const; | ||
| 307 | |||
| 308 | export const EnvironmentCloudRelayConfigResult = Schema.Struct({ | ||
| 309 | ok: Schema.Boolean, | ||
| 310 | endpointRuntimeStatus: Schema.Unknown, | ||
| 311 | }); | ||
| 312 | export type EnvironmentCloudRelayConfigResult = typeof EnvironmentCloudRelayConfigResult.Type; | ||
| 313 | |||
| 314 | export const EnvironmentCloudLinkStateResult = Schema.Struct({ | ||
| 315 | linked: Schema.Boolean, | ||
| 316 | cloudUserId: Schema.NullOr(Schema.String), | ||
| 317 | relayUrl: Schema.NullOr(Schema.String), | ||
| 318 | relayIssuer: Schema.NullOr(Schema.String), | ||
| 319 | publishAgentActivity: Schema.Boolean, | ||
| 320 | }); | ||
| 321 | export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type; | ||
| 322 | |||
| 323 | export const EnvironmentCloudPreferencesRequest = Schema.Struct({ | ||
| 324 | publishAgentActivity: Schema.Boolean, | ||
| 325 | }); | ||
| 326 | export type EnvironmentCloudPreferencesRequest = typeof EnvironmentCloudPreferencesRequest.Type; | ||
| 327 | |||
| 209 | export const AuthPairingLinkRevokeResult = Schema.Struct({ | 328 | export const AuthPairingLinkRevokeResult = Schema.Struct({ |
| 210 | revoked: Schema.Boolean, | 329 | revoked: Schema.Boolean, |
| 211 | }); | 330 | }); |
| ... | @@ -244,6 +363,7 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") | ... | @@ -244,6 +363,7 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") |
| 244 | ) | 363 | ) |
| 245 | .add( | 364 | .add( |
| 246 | HttpApiEndpoint.post("token", "/oauth/token", { | 365 | HttpApiEndpoint.post("token", "/oauth/token", { |
| 366 | headers: OptionalDpopProofHeaders, | ||
| 247 | payload: AuthTokenExchangeRequest, | 367 | payload: AuthTokenExchangeRequest, |
| 248 | success: AuthAccessTokenResult, | 368 | success: AuthAccessTokenResult, |
| 249 | error: EnvironmentTokenExchangeErrors, | 369 | error: EnvironmentTokenExchangeErrors, |
| ... | @@ -319,7 +439,69 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr | ... | @@ -319,7 +439,69 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr |
| 319 | }).middleware(EnvironmentAuthenticatedAuth), | 439 | }).middleware(EnvironmentAuthenticatedAuth), |
| 320 | ) {} | 440 | ) {} |
| 321 | 441 | ||
| 442 | export class EnvironmentCloudHttpApi extends HttpApiGroup.make("cloud") | ||
| 443 | .add( | ||
| 444 | HttpApiEndpoint.post("linkProof", "/api/cloud/link-proof", { | ||
| 445 | headers: OptionalBearerHeaders, | ||
| 446 | payload: RelayLinkProofRequest, | ||
| 447 | success: RelayEnvironmentLinkProof, | ||
| 448 | error: EnvironmentHttpCloudErrors, | ||
| 449 | }).middleware(EnvironmentAuthenticatedAuth), | ||
| 450 | ) | ||
| 451 | .add( | ||
| 452 | HttpApiEndpoint.post("relayConfig", "/api/cloud/relay-config", { | ||
| 453 | headers: OptionalBearerHeaders, | ||
| 454 | payload: RelayEnvironmentConfigRequest, | ||
| 455 | success: EnvironmentCloudRelayConfigResult, | ||
| 456 | error: [...EnvironmentHttpCloudErrors, EnvironmentCloudEndpointUnavailableError], | ||
| 457 | }).middleware(EnvironmentAuthenticatedAuth), | ||
| 458 | ) | ||
| 459 | .add( | ||
| 460 | HttpApiEndpoint.get("linkState", "/api/cloud/link-state", { | ||
| 461 | headers: OptionalBearerHeaders, | ||
| 462 | success: EnvironmentCloudLinkStateResult, | ||
| 463 | error: EnvironmentHttpCloudErrors, | ||
| 464 | }).middleware(EnvironmentAuthenticatedAuth), | ||
| 465 | ) | ||
| 466 | .add( | ||
| 467 | HttpApiEndpoint.post("unlink", "/api/cloud/unlink", { | ||
| 468 | headers: OptionalBearerHeaders, | ||
| 469 | success: EnvironmentCloudRelayConfigResult, | ||
| 470 | error: EnvironmentHttpCloudErrors, | ||
| 471 | }).middleware(EnvironmentAuthenticatedAuth), | ||
| 472 | ) | ||
| 473 | .add( | ||
| 474 | HttpApiEndpoint.post("preferences", "/api/cloud/preferences", { | ||
| 475 | headers: OptionalBearerHeaders, | ||
| 476 | payload: EnvironmentCloudPreferencesRequest, | ||
| 477 | success: EnvironmentCloudLinkStateResult, | ||
| 478 | error: EnvironmentHttpCloudErrors, | ||
| 479 | }).middleware(EnvironmentAuthenticatedAuth), | ||
| 480 | ) | ||
| 481 | .add( | ||
| 482 | HttpApiEndpoint.post("health", "/api/t3-cloud/health", { | ||
| 483 | payload: RelayCloudEnvironmentHealthRequest, | ||
| 484 | success: RelayEnvironmentHealthResponse, | ||
| 485 | error: EnvironmentHttpCloudErrors, | ||
| 486 | }), | ||
| 487 | ) | ||
| 488 | .add( | ||
| 489 | HttpApiEndpoint.post("mintCredential", "/api/cloud/mint-credential", { | ||
| 490 | payload: RelayCloudMintCredentialRequest, | ||
| 491 | success: RelayEnvironmentMintResponse, | ||
| 492 | error: EnvironmentHttpCloudErrors, | ||
| 493 | }), | ||
| 494 | ) | ||
| 495 | .add( | ||
| 496 | HttpApiEndpoint.post("t3MintCredential", "/api/t3-cloud/mint-credential", { | ||
| 497 | payload: RelayCloudMintCredentialRequest, | ||
| 498 | success: RelayEnvironmentMintResponse, | ||
| 499 | error: EnvironmentHttpCloudErrors, | ||
| 500 | }), | ||
| 501 | ) {} | ||
| 502 | |||
| 322 | export class EnvironmentHttpApi extends HttpApi.make("environment") | 503 | export class EnvironmentHttpApi extends HttpApi.make("environment") |
| 323 | .add(EnvironmentMetadataHttpApi) | 504 | .add(EnvironmentMetadataHttpApi) |
| 324 | .add(EnvironmentAuthHttpApi) | 505 | .add(EnvironmentAuthHttpApi) |
| 325 | .add(EnvironmentOrchestrationHttpApi) {} | 506 | .add(EnvironmentOrchestrationHttpApi) |
| 507 | .add(EnvironmentCloudHttpApi) {} |
packages/contracts/src/filesystem.ts+1-1| ... | @@ -25,6 +25,6 @@ export class FilesystemBrowseError extends Schema.TaggedErrorClass<FilesystemBro | ... | @@ -25,6 +25,6 @@ export class FilesystemBrowseError extends Schema.TaggedErrorClass<FilesystemBro |
| 25 | "FilesystemBrowseError", | 25 | "FilesystemBrowseError", |
| 26 | { | 26 | { |
| 27 | message: TrimmedNonEmptyString, | 27 | message: TrimmedNonEmptyString, |
| 28 | cause: Schema.optional(Schema.Defect), | 28 | cause: Schema.optional(Schema.Defect()), |
| 29 | }, | 29 | }, |
| 30 | ) {} | 30 | ) {} |
packages/contracts/src/git.ts+3-3| ... | @@ -322,7 +322,7 @@ export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()( | ... | @@ -322,7 +322,7 @@ export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()( |
| 322 | command: Schema.String, | 322 | command: Schema.String, |
| 323 | cwd: Schema.String, | 323 | cwd: Schema.String, |
| 324 | detail: Schema.String, | 324 | detail: Schema.String, |
| 325 | cause: Schema.optional(Schema.Defect), | 325 | cause: Schema.optional(Schema.Defect()), |
| 326 | }) { | 326 | }) { |
| 327 | override get message(): string { | 327 | override get message(): string { |
| 328 | return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; | 328 | return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; |
| ... | @@ -334,7 +334,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationE | ... | @@ -334,7 +334,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationE |
| 334 | { | 334 | { |
| 335 | operation: Schema.String, | 335 | operation: Schema.String, |
| 336 | detail: Schema.String, | 336 | detail: Schema.String, |
| 337 | cause: Schema.optional(Schema.Defect), | 337 | cause: Schema.optional(Schema.Defect()), |
| 338 | }, | 338 | }, |
| 339 | ) { | 339 | ) { |
| 340 | override get message(): string { | 340 | override get message(): string { |
| ... | @@ -345,7 +345,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationE | ... | @@ -345,7 +345,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationE |
| 345 | export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", { | 345 | export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", { |
| 346 | operation: Schema.String, | 346 | operation: Schema.String, |
| 347 | detail: Schema.String, | 347 | detail: Schema.String, |
| 348 | cause: Schema.optional(Schema.Defect), | 348 | cause: Schema.optional(Schema.Defect()), |
| 349 | }) { | 349 | }) { |
| 350 | override get message(): string { | 350 | override get message(): string { |
| 351 | return `Git manager failed in ${this.operation}: ${this.detail}`; | 351 | return `Git manager failed in ${this.operation}: ${this.detail}`; |
packages/contracts/src/index.ts+1| ... | @@ -2,6 +2,7 @@ export * from "./baseSchemas.ts"; | ... | @@ -2,6 +2,7 @@ export * from "./baseSchemas.ts"; |
| 2 | export * from "./auth.ts"; | 2 | export * from "./auth.ts"; |
| 3 | export * from "./environment.ts"; | 3 | export * from "./environment.ts"; |
| 4 | export * from "./environmentHttp.ts"; | 4 | export * from "./environmentHttp.ts"; |
| 5 | export * from "./relayClient.ts"; | ||
| 5 | export * from "./desktopBootstrap.ts"; | 6 | export * from "./desktopBootstrap.ts"; |
| 6 | export * from "./remoteAccess.ts"; | 7 | export * from "./remoteAccess.ts"; |
| 7 | export * from "./ipc.ts"; | 8 | export * from "./ipc.ts"; |
packages/contracts/src/ipc.ts+28| ... | @@ -338,6 +338,11 @@ export const PersistedSavedEnvironmentRecordSchema = Schema.Struct({ | ... | @@ -338,6 +338,11 @@ export const PersistedSavedEnvironmentRecordSchema = Schema.Struct({ |
| 338 | createdAt: Schema.String, | 338 | createdAt: Schema.String, |
| 339 | lastConnectedAt: Schema.NullOr(Schema.String), | 339 | lastConnectedAt: Schema.NullOr(Schema.String), |
| 340 | desktopSsh: Schema.optionalKey(DesktopSshEnvironmentTargetSchema), | 340 | desktopSsh: Schema.optionalKey(DesktopSshEnvironmentTargetSchema), |
| 341 | relayManaged: Schema.optionalKey( | ||
| 342 | Schema.Struct({ | ||
| 343 | relayUrl: Schema.String, | ||
| 344 | }), | ||
| 345 | ), | ||
| 341 | }); | 346 | }); |
| 342 | export type PersistedSavedEnvironmentRecord = typeof PersistedSavedEnvironmentRecordSchema.Type; | 347 | export type PersistedSavedEnvironmentRecord = typeof PersistedSavedEnvironmentRecordSchema.Type; |
| 343 | 348 | ||
| ... | @@ -372,6 +377,23 @@ export const PickFolderOptionsSchema = Schema.Struct({ | ... | @@ -372,6 +377,23 @@ export const PickFolderOptionsSchema = Schema.Struct({ |
| 372 | initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), | 377 | initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), |
| 373 | }); | 378 | }); |
| 374 | 379 | ||
| 380 | export const DesktopCloudAuthFetchInputSchema = Schema.Struct({ | ||
| 381 | url: Schema.String, | ||
| 382 | method: Schema.optionalKey(Schema.String), | ||
| 383 | headers: Schema.Record(Schema.String, Schema.String), | ||
| 384 | body: Schema.optionalKey(Schema.String), | ||
| 385 | }); | ||
| 386 | export type DesktopCloudAuthFetchInput = typeof DesktopCloudAuthFetchInputSchema.Type; | ||
| 387 | |||
| 388 | export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ | ||
| 389 | ok: Schema.Boolean, | ||
| 390 | status: Schema.Number, | ||
| 391 | statusText: Schema.String, | ||
| 392 | headers: Schema.Record(Schema.String, Schema.String), | ||
| 393 | body: Schema.String, | ||
| 394 | }); | ||
| 395 | export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; | ||
| 396 | |||
| 375 | export interface DesktopBridge { | 397 | export interface DesktopBridge { |
| 376 | getAppBranding: () => DesktopAppBranding | null; | 398 | getAppBranding: () => DesktopAppBranding | null; |
| 377 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; | 399 | getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; |
| ... | @@ -417,6 +439,12 @@ export interface DesktopBridge { | ... | @@ -417,6 +439,12 @@ export interface DesktopBridge { |
| 417 | position?: { x: number; y: number }, | 439 | position?: { x: number; y: number }, |
| 418 | ) => Promise<T | null>; | 440 | ) => Promise<T | null>; |
| 419 | openExternal: (url: string) => Promise<boolean>; | 441 | openExternal: (url: string) => Promise<boolean>; |
| 442 | createCloudAuthRequest: () => Promise<string>; | ||
| 443 | getCloudAuthToken: () => Promise<string | null>; | ||
| 444 | setCloudAuthToken: (token: string) => Promise<boolean>; | ||
| 445 | clearCloudAuthToken: () => Promise<void>; | ||
| 446 | fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise<DesktopCloudAuthFetchResult>; | ||
| 447 | onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void; | ||
| 420 | onMenuAction: (listener: (action: string) => void) => () => void; | 448 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 421 | getUpdateState: () => Promise<DesktopUpdateState>; | 449 | getUpdateState: () => Promise<DesktopUpdateState>; |
| 422 | setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>; | 450 | setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>; |
packages/contracts/src/keybindings.ts+1-1| ... | @@ -153,7 +153,7 @@ export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsC | ... | @@ -153,7 +153,7 @@ export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsC |
| 153 | { | 153 | { |
| 154 | configPath: Schema.String, | 154 | configPath: Schema.String, |
| 155 | detail: Schema.String, | 155 | detail: Schema.String, |
| 156 | cause: Schema.optional(Schema.Defect), | 156 | cause: Schema.optional(Schema.Defect()), |
| 157 | }, | 157 | }, |
| 158 | ) { | 158 | ) { |
| 159 | override get message(): string { | 159 | override get message(): string { |
packages/contracts/src/orchestration.ts+5-5| ... | @@ -1241,7 +1241,7 @@ export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<Orche | ... | @@ -1241,7 +1241,7 @@ export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<Orche |
| 1241 | "OrchestrationGetSnapshotError", | 1241 | "OrchestrationGetSnapshotError", |
| 1242 | { | 1242 | { |
| 1243 | message: TrimmedNonEmptyString, | 1243 | message: TrimmedNonEmptyString, |
| 1244 | cause: Schema.optional(Schema.Defect), | 1244 | cause: Schema.optional(Schema.Defect()), |
| 1245 | }, | 1245 | }, |
| 1246 | ) {} | 1246 | ) {} |
| 1247 | 1247 | ||
| ... | @@ -1249,7 +1249,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass<O | ... | @@ -1249,7 +1249,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass<O |
| 1249 | "OrchestrationDispatchCommandError", | 1249 | "OrchestrationDispatchCommandError", |
| 1250 | { | 1250 | { |
| 1251 | message: TrimmedNonEmptyString, | 1251 | message: TrimmedNonEmptyString, |
| 1252 | cause: Schema.optional(Schema.Defect), | 1252 | cause: Schema.optional(Schema.Defect()), |
| 1253 | }, | 1253 | }, |
| 1254 | ) {} | 1254 | ) {} |
| 1255 | 1255 | ||
| ... | @@ -1257,7 +1257,7 @@ export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass<Orche | ... | @@ -1257,7 +1257,7 @@ export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass<Orche |
| 1257 | "OrchestrationGetTurnDiffError", | 1257 | "OrchestrationGetTurnDiffError", |
| 1258 | { | 1258 | { |
| 1259 | message: TrimmedNonEmptyString, | 1259 | message: TrimmedNonEmptyString, |
| 1260 | cause: Schema.optional(Schema.Defect), | 1260 | cause: Schema.optional(Schema.Defect()), |
| 1261 | }, | 1261 | }, |
| 1262 | ) {} | 1262 | ) {} |
| 1263 | 1263 | ||
| ... | @@ -1265,7 +1265,7 @@ export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass | ... | @@ -1265,7 +1265,7 @@ export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass |
| 1265 | "OrchestrationGetFullThreadDiffError", | 1265 | "OrchestrationGetFullThreadDiffError", |
| 1266 | { | 1266 | { |
| 1267 | message: TrimmedNonEmptyString, | 1267 | message: TrimmedNonEmptyString, |
| 1268 | cause: Schema.optional(Schema.Defect), | 1268 | cause: Schema.optional(Schema.Defect()), |
| 1269 | }, | 1269 | }, |
| 1270 | ) {} | 1270 | ) {} |
| 1271 | 1271 | ||
| ... | @@ -1273,6 +1273,6 @@ export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass<Orch | ... | @@ -1273,6 +1273,6 @@ export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass<Orch |
| 1273 | "OrchestrationReplayEventsError", | 1273 | "OrchestrationReplayEventsError", |
| 1274 | { | 1274 | { |
| 1275 | message: TrimmedNonEmptyString, | 1275 | message: TrimmedNonEmptyString, |
| 1276 | cause: Schema.optional(Schema.Defect), | 1276 | cause: Schema.optional(Schema.Defect()), |
| 1277 | }, | 1277 | }, |
| 1278 | ) {} | 1278 | ) {} |
packages/contracts/src/project.ts+2-2| ... | @@ -30,7 +30,7 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSe | ... | @@ -30,7 +30,7 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSe |
| 30 | "ProjectSearchEntriesError", | 30 | "ProjectSearchEntriesError", |
| 31 | { | 31 | { |
| 32 | message: TrimmedNonEmptyString, | 32 | message: TrimmedNonEmptyString, |
| 33 | cause: Schema.optional(Schema.Defect), | 33 | cause: Schema.optional(Schema.Defect()), |
| 34 | }, | 34 | }, |
| 35 | ) {} | 35 | ) {} |
| 36 | 36 | ||
| ... | @@ -50,6 +50,6 @@ export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteF | ... | @@ -50,6 +50,6 @@ export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteF |
| 50 | "ProjectWriteFileError", | 50 | "ProjectWriteFileError", |
| 51 | { | 51 | { |
| 52 | message: TrimmedNonEmptyString, | 52 | message: TrimmedNonEmptyString, |
| 53 | cause: Schema.optional(Schema.Defect), | 53 | cause: Schema.optional(Schema.Defect()), |
| 54 | }, | 54 | }, |
| 55 | ) {} | 55 | ) {} |
packages/contracts/src/relay.test.ts created+16| ... | @@ -0,0 +1,16 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import * as OpenApi from "effect/unstable/httpapi/OpenApi"; | ||
| 3 | |||
| 4 | import { RelayApi } from "./relay.ts"; | ||
| 5 | |||
| 6 | describe("RelayApi security", () => { | ||
| 7 | it("describes DPoP access tokens using the HTTP DPoP authorization scheme", () => { | ||
| 8 | const document = OpenApi.fromApi(RelayApi); | ||
| 9 | |||
| 10 | expect(document.components.securitySchemes?.relayDpop).toEqual({ | ||
| 11 | type: "http", | ||
| 12 | scheme: "DPoP", | ||
| 13 | description: "DPoP-bound access token. Requests must also include the DPoP proof JWT header.", | ||
| 14 | }); | ||
| 15 | }); | ||
| 16 | }); | ||
packages/contracts/src/relay.ts created+971| ... | @@ -0,0 +1,971 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as HttpApi from "effect/unstable/httpapi/HttpApi"; | ||
| 4 | import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint"; | ||
| 5 | import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup"; | ||
| 6 | import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware"; | ||
| 7 | import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; | ||
| 8 | import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity"; | ||
| 9 | import * as OpenApi from "effect/unstable/httpapi/OpenApi"; | ||
| 10 | |||
| 11 | import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 12 | import { ExecutionEnvironmentDescriptor } from "./environment.ts"; | ||
| 13 | |||
| 14 | export const RelayAgentAwarenessPlatform = Schema.Literal("ios"); | ||
| 15 | export type RelayAgentAwarenessPlatform = typeof RelayAgentAwarenessPlatform.Type; | ||
| 16 | |||
| 17 | export const RelayAgentAwarenessPhase = Schema.Literals([ | ||
| 18 | "starting", | ||
| 19 | "running", | ||
| 20 | "waiting_for_approval", | ||
| 21 | "waiting_for_input", | ||
| 22 | "completed", | ||
| 23 | "failed", | ||
| 24 | "stale", | ||
| 25 | ]); | ||
| 26 | export type RelayAgentAwarenessPhase = typeof RelayAgentAwarenessPhase.Type; | ||
| 27 | |||
| 28 | export const RelayAgentAwarenessPreferences = Schema.Struct({ | ||
| 29 | liveActivitiesEnabled: Schema.Boolean, | ||
| 30 | notificationsEnabled: Schema.Boolean, | ||
| 31 | notifyOnApproval: Schema.Boolean, | ||
| 32 | notifyOnInput: Schema.Boolean, | ||
| 33 | notifyOnCompletion: Schema.Boolean, | ||
| 34 | notifyOnFailure: Schema.Boolean, | ||
| 35 | }); | ||
| 36 | export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type; | ||
| 37 | |||
| 38 | export const RelayDeviceRegistrationRequest = Schema.Struct({ | ||
| 39 | deviceId: TrimmedNonEmptyString, | ||
| 40 | label: TrimmedNonEmptyString, | ||
| 41 | platform: RelayAgentAwarenessPlatform, | ||
| 42 | iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), | ||
| 43 | appVersion: Schema.optional(TrimmedNonEmptyString), | ||
| 44 | pushToken: Schema.optional(TrimmedNonEmptyString), | ||
| 45 | pushToStartToken: Schema.optional(TrimmedNonEmptyString), | ||
| 46 | preferences: RelayAgentAwarenessPreferences, | ||
| 47 | }); | ||
| 48 | export type RelayDeviceRegistrationRequest = typeof RelayDeviceRegistrationRequest.Type; | ||
| 49 | |||
| 50 | export const RelayClientDeviceRecord = Schema.Struct({ | ||
| 51 | deviceId: TrimmedNonEmptyString, | ||
| 52 | label: TrimmedNonEmptyString, | ||
| 53 | platform: RelayAgentAwarenessPlatform, | ||
| 54 | iosMajorVersion: Schema.Int.check(Schema.isGreaterThanOrEqualTo(18)), | ||
| 55 | appVersion: Schema.NullOr(TrimmedNonEmptyString), | ||
| 56 | notifications: Schema.Struct({ | ||
| 57 | enabled: Schema.Boolean, | ||
| 58 | notifyOnApproval: Schema.Boolean, | ||
| 59 | notifyOnInput: Schema.Boolean, | ||
| 60 | notifyOnCompletion: Schema.Boolean, | ||
| 61 | notifyOnFailure: Schema.Boolean, | ||
| 62 | }), | ||
| 63 | liveActivities: Schema.Struct({ | ||
| 64 | enabled: Schema.Boolean, | ||
| 65 | }), | ||
| 66 | updatedAt: TrimmedNonEmptyString, | ||
| 67 | }); | ||
| 68 | export type RelayClientDeviceRecord = typeof RelayClientDeviceRecord.Type; | ||
| 69 | |||
| 70 | export const RelayListDevicesResponse = Schema.Struct({ | ||
| 71 | devices: Schema.Array(RelayClientDeviceRecord), | ||
| 72 | }); | ||
| 73 | export type RelayListDevicesResponse = typeof RelayListDevicesResponse.Type; | ||
| 74 | |||
| 75 | export const RelayLiveActivityRegistrationRequest = Schema.Struct({ | ||
| 76 | deviceId: TrimmedNonEmptyString, | ||
| 77 | activityPushToken: TrimmedNonEmptyString, | ||
| 78 | }); | ||
| 79 | export type RelayLiveActivityRegistrationRequest = typeof RelayLiveActivityRegistrationRequest.Type; | ||
| 80 | |||
| 81 | export const RelayDeviceUnregistrationParams = Schema.Struct({ | ||
| 82 | deviceId: TrimmedNonEmptyString, | ||
| 83 | }); | ||
| 84 | export type RelayDeviceUnregistrationParams = typeof RelayDeviceUnregistrationParams.Type; | ||
| 85 | |||
| 86 | export const RelayAgentActivityState = Schema.Struct({ | ||
| 87 | environmentId: EnvironmentId, | ||
| 88 | threadId: ThreadId, | ||
| 89 | projectTitle: TrimmedNonEmptyString, | ||
| 90 | threadTitle: TrimmedNonEmptyString, | ||
| 91 | phase: RelayAgentAwarenessPhase, | ||
| 92 | headline: TrimmedNonEmptyString, | ||
| 93 | detail: Schema.optional(TrimmedNonEmptyString), | ||
| 94 | modelTitle: TrimmedNonEmptyString, | ||
| 95 | updatedAt: TrimmedNonEmptyString, | ||
| 96 | deepLink: TrimmedNonEmptyString, | ||
| 97 | }); | ||
| 98 | export type RelayAgentActivityState = typeof RelayAgentActivityState.Type; | ||
| 99 | |||
| 100 | export const RelayAgentActivityAggregateRow = Schema.Struct({ | ||
| 101 | environmentId: EnvironmentId, | ||
| 102 | threadId: ThreadId, | ||
| 103 | projectTitle: TrimmedNonEmptyString, | ||
| 104 | threadTitle: TrimmedNonEmptyString, | ||
| 105 | modelTitle: TrimmedNonEmptyString, | ||
| 106 | phase: RelayAgentAwarenessPhase, | ||
| 107 | status: TrimmedNonEmptyString, | ||
| 108 | updatedAt: TrimmedNonEmptyString, | ||
| 109 | deepLink: TrimmedNonEmptyString, | ||
| 110 | }); | ||
| 111 | export type RelayAgentActivityAggregateRow = typeof RelayAgentActivityAggregateRow.Type; | ||
| 112 | |||
| 113 | export const RelayAgentActivityAggregateState = Schema.Struct({ | ||
| 114 | title: TrimmedNonEmptyString, | ||
| 115 | subtitle: TrimmedNonEmptyString, | ||
| 116 | activeCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | ||
| 117 | updatedAt: TrimmedNonEmptyString, | ||
| 118 | activities: Schema.Array(RelayAgentActivityAggregateRow), | ||
| 119 | }); | ||
| 120 | export type RelayAgentActivityAggregateState = typeof RelayAgentActivityAggregateState.Type; | ||
| 121 | |||
| 122 | export const RelayManagedEndpointProviderKind = Schema.Literals([ | ||
| 123 | "manual", | ||
| 124 | "cloudflare_tunnel", | ||
| 125 | "t3_relay", | ||
| 126 | ]); | ||
| 127 | export type RelayManagedEndpointProviderKind = typeof RelayManagedEndpointProviderKind.Type; | ||
| 128 | |||
| 129 | export const RelayManagedEndpoint = Schema.Struct({ | ||
| 130 | httpBaseUrl: TrimmedNonEmptyString, | ||
| 131 | wsBaseUrl: TrimmedNonEmptyString, | ||
| 132 | providerKind: RelayManagedEndpointProviderKind, | ||
| 133 | }); | ||
| 134 | export type RelayManagedEndpoint = typeof RelayManagedEndpoint.Type; | ||
| 135 | |||
| 136 | export const RelayManagedEndpointOrigin = Schema.Struct({ | ||
| 137 | localHttpHost: TrimmedNonEmptyString, | ||
| 138 | localHttpPort: Schema.Int.check( | ||
| 139 | Schema.isGreaterThanOrEqualTo(1), | ||
| 140 | Schema.isLessThanOrEqualTo(65_535), | ||
| 141 | ), | ||
| 142 | }); | ||
| 143 | export type RelayManagedEndpointOrigin = typeof RelayManagedEndpointOrigin.Type; | ||
| 144 | |||
| 145 | export const RelayManagedEndpointRuntimeConfig = Schema.Struct({ | ||
| 146 | providerKind: RelayManagedEndpointProviderKind, | ||
| 147 | connectorToken: TrimmedNonEmptyString, | ||
| 148 | tunnelId: Schema.optional(TrimmedNonEmptyString), | ||
| 149 | tunnelName: Schema.optional(TrimmedNonEmptyString), | ||
| 150 | }); | ||
| 151 | export type RelayManagedEndpointRuntimeConfig = typeof RelayManagedEndpointRuntimeConfig.Type; | ||
| 152 | |||
| 153 | export const RelayLinkProofRequest = Schema.Struct({ | ||
| 154 | challenge: Schema.String, | ||
| 155 | relayIssuer: Schema.String, | ||
| 156 | endpoint: RelayManagedEndpoint, | ||
| 157 | origin: RelayManagedEndpointOrigin, | ||
| 158 | }); | ||
| 159 | export type RelayLinkProofRequest = typeof RelayLinkProofRequest.Type; | ||
| 160 | |||
| 161 | export const RelayEnvironmentConfigRequest = Schema.Struct({ | ||
| 162 | relayUrl: Schema.String, | ||
| 163 | relayIssuer: Schema.optional(Schema.String), | ||
| 164 | cloudUserId: Schema.String, | ||
| 165 | environmentCredential: Schema.String, | ||
| 166 | cloudMintPublicKey: Schema.String, | ||
| 167 | endpointRuntime: Schema.NullOr(RelayManagedEndpointRuntimeConfig), | ||
| 168 | }); | ||
| 169 | export type RelayEnvironmentConfigRequest = typeof RelayEnvironmentConfigRequest.Type; | ||
| 170 | |||
| 171 | const RelaySignedJwtRegisteredClaims = { | ||
| 172 | iss: TrimmedNonEmptyString, | ||
| 173 | aud: TrimmedNonEmptyString, | ||
| 174 | sub: TrimmedNonEmptyString, | ||
| 175 | jti: TrimmedNonEmptyString, | ||
| 176 | iat: Schema.Int, | ||
| 177 | exp: Schema.Int, | ||
| 178 | } as const; | ||
| 179 | |||
| 180 | export const RelayAgentActivityPublishProofPayload = Schema.Struct({ | ||
| 181 | ...RelaySignedJwtRegisteredClaims, | ||
| 182 | environmentId: EnvironmentId, | ||
| 183 | threadId: ThreadId, | ||
| 184 | state: Schema.NullOr(RelayAgentActivityState), | ||
| 185 | }); | ||
| 186 | export type RelayAgentActivityPublishProofPayload = | ||
| 187 | typeof RelayAgentActivityPublishProofPayload.Type; | ||
| 188 | export type RelayAgentActivityPublishProof = string; | ||
| 189 | |||
| 190 | export const RelayAgentActivityPublishRequest = Schema.Struct({ | ||
| 191 | state: Schema.NullOr(RelayAgentActivityState).annotate({ | ||
| 192 | description: "Current agent-awareness state, or null to remove the published state.", | ||
| 193 | }), | ||
| 194 | proof: TrimmedNonEmptyString.annotate({ | ||
| 195 | description: "Environment-signed JWT covering this published activity state.", | ||
| 196 | }), | ||
| 197 | }).annotate({ description: "Publishes a signed agent-awareness update from an environment." }); | ||
| 198 | export type RelayAgentActivityPublishRequest = typeof RelayAgentActivityPublishRequest.Type; | ||
| 199 | |||
| 200 | export const RelayEnvironmentLinkScope = Schema.Literals([ | ||
| 201 | "agent_activity_notifications", | ||
| 202 | "managed_tunnels", | ||
| 203 | ]); | ||
| 204 | export type RelayEnvironmentLinkScope = typeof RelayEnvironmentLinkScope.Type; | ||
| 205 | |||
| 206 | export const RelayEnvironmentLinkProofPayload = Schema.Struct({ | ||
| 207 | ...RelaySignedJwtRegisteredClaims, | ||
| 208 | challenge: TrimmedNonEmptyString, | ||
| 209 | descriptor: ExecutionEnvironmentDescriptor, | ||
| 210 | environmentId: EnvironmentId, | ||
| 211 | environmentPublicKey: TrimmedNonEmptyString, | ||
| 212 | endpoint: RelayManagedEndpoint, | ||
| 213 | origin: RelayManagedEndpointOrigin, | ||
| 214 | scopes: Schema.Array(RelayEnvironmentLinkScope), | ||
| 215 | }); | ||
| 216 | export type RelayEnvironmentLinkProofPayload = typeof RelayEnvironmentLinkProofPayload.Type; | ||
| 217 | |||
| 218 | export const RelayEnvironmentLinkProof = TrimmedNonEmptyString; | ||
| 219 | export type RelayEnvironmentLinkProof = typeof RelayEnvironmentLinkProof.Type; | ||
| 220 | |||
| 221 | export const RelayEnvironmentLinkChallengeRequest = Schema.Struct({ | ||
| 222 | notificationsEnabled: Schema.Boolean.annotate({ | ||
| 223 | description: "Whether this link may deliver push notifications.", | ||
| 224 | }), | ||
| 225 | liveActivitiesEnabled: Schema.Boolean.annotate({ | ||
| 226 | description: "Whether this link may update Live Activities.", | ||
| 227 | }), | ||
| 228 | managedTunnelsEnabled: Schema.Boolean.annotate({ | ||
| 229 | description: "Whether the relay should provision a managed tunnel for this environment.", | ||
| 230 | }), | ||
| 231 | }).annotate({ description: "Requested capabilities for a new environment-link challenge." }); | ||
| 232 | export type RelayEnvironmentLinkChallengeRequest = typeof RelayEnvironmentLinkChallengeRequest.Type; | ||
| 233 | |||
| 234 | export const RelayEnvironmentLinkChallengeResponse = Schema.Struct({ | ||
| 235 | challenge: TrimmedNonEmptyString, | ||
| 236 | expiresAt: TrimmedNonEmptyString, | ||
| 237 | }); | ||
| 238 | export type RelayEnvironmentLinkChallengeResponse = | ||
| 239 | typeof RelayEnvironmentLinkChallengeResponse.Type; | ||
| 240 | |||
| 241 | export const RelayEnvironmentLinkRequest = Schema.Struct({ | ||
| 242 | deviceId: Schema.optional( | ||
| 243 | TrimmedNonEmptyString.annotate({ | ||
| 244 | description: "Optional client device identifier associated with this link.", | ||
| 245 | }), | ||
| 246 | ), | ||
| 247 | proof: RelayEnvironmentLinkProof.annotate({ | ||
| 248 | description: "Environment-signed proof bound to a previously issued link challenge.", | ||
| 249 | }), | ||
| 250 | notificationsEnabled: Schema.Boolean, | ||
| 251 | liveActivitiesEnabled: Schema.Boolean, | ||
| 252 | managedTunnelsEnabled: Schema.Boolean, | ||
| 253 | }).annotate({ description: "Links an authenticated cloud user to a T3 environment." }); | ||
| 254 | export type RelayEnvironmentLinkRequest = typeof RelayEnvironmentLinkRequest.Type; | ||
| 255 | |||
| 256 | export const RelayEnvironmentLinkResponse = Schema.Struct({ | ||
| 257 | ok: Schema.Boolean, | ||
| 258 | cloudUserId: TrimmedNonEmptyString, | ||
| 259 | environmentId: EnvironmentId, | ||
| 260 | endpoint: RelayManagedEndpoint, | ||
| 261 | endpointRuntime: Schema.NullOr(RelayManagedEndpointRuntimeConfig), | ||
| 262 | relayIssuer: TrimmedNonEmptyString, | ||
| 263 | environmentCredential: TrimmedNonEmptyString, | ||
| 264 | cloudMintPublicKey: TrimmedNonEmptyString, | ||
| 265 | }); | ||
| 266 | export type RelayEnvironmentLinkResponse = typeof RelayEnvironmentLinkResponse.Type; | ||
| 267 | |||
| 268 | export const RelayEnvironmentLinkProofInvalidReason = Schema.Literals([ | ||
| 269 | "invalid_signature_or_scope", | ||
| 270 | "descriptor_mismatch", | ||
| 271 | "replayed_nonce", | ||
| 272 | "challenge_invalid", | ||
| 273 | "origin_not_allowed", | ||
| 274 | "endpoint_not_secure", | ||
| 275 | ]); | ||
| 276 | export type RelayEnvironmentLinkProofInvalidReason = | ||
| 277 | typeof RelayEnvironmentLinkProofInvalidReason.Type; | ||
| 278 | |||
| 279 | export const RelayEnvironmentLinkFailedReason = Schema.Literals([ | ||
| 280 | "link_persistence_failed", | ||
| 281 | "credential_persistence_failed", | ||
| 282 | "replay_persistence_failed", | ||
| 283 | "internal_error", | ||
| 284 | ]); | ||
| 285 | export type RelayEnvironmentLinkFailedReason = typeof RelayEnvironmentLinkFailedReason.Type; | ||
| 286 | |||
| 287 | export const RelayEnvironmentLinkUnavailableReason = Schema.Literals([ | ||
| 288 | "managed_endpoint_not_configured", | ||
| 289 | "managed_endpoint_provisioning_failed", | ||
| 290 | ]); | ||
| 291 | export type RelayEnvironmentLinkUnavailableReason = | ||
| 292 | typeof RelayEnvironmentLinkUnavailableReason.Type; | ||
| 293 | |||
| 294 | export const RelayEnvironmentEndpointUnavailableReason = Schema.Literals([ | ||
| 295 | "endpoint_request_failed", | ||
| 296 | "endpoint_response_invalid", | ||
| 297 | ]); | ||
| 298 | export type RelayEnvironmentEndpointUnavailableReason = | ||
| 299 | typeof RelayEnvironmentEndpointUnavailableReason.Type; | ||
| 300 | |||
| 301 | export const RelayAgentActivityPublishProofInvalidReason = Schema.Literals([ | ||
| 302 | "invalid_signature_or_payload", | ||
| 303 | "replayed_nonce", | ||
| 304 | ]); | ||
| 305 | export type RelayAgentActivityPublishProofInvalidReason = | ||
| 306 | typeof RelayAgentActivityPublishProofInvalidReason.Type; | ||
| 307 | |||
| 308 | export const RelayAuthInvalidReason = Schema.Literals([ | ||
| 309 | "missing_bearer", | ||
| 310 | "invalid_bearer", | ||
| 311 | "invalid_dpop", | ||
| 312 | "not_authorized", | ||
| 313 | ]); | ||
| 314 | export type RelayAuthInvalidReason = typeof RelayAuthInvalidReason.Type; | ||
| 315 | |||
| 316 | export const RelayInternalErrorReason = Schema.Literals([ | ||
| 317 | "database_unavailable", | ||
| 318 | "persistence_failed", | ||
| 319 | "upstream_unavailable", | ||
| 320 | "internal_error", | ||
| 321 | ]); | ||
| 322 | export type RelayInternalErrorReason = typeof RelayInternalErrorReason.Type; | ||
| 323 | |||
| 324 | export class RelayAuthInvalidError extends Schema.TaggedErrorClass<RelayAuthInvalidError>()( | ||
| 325 | "RelayAuthInvalidError", | ||
| 326 | { | ||
| 327 | code: Schema.Literal("auth_invalid"), | ||
| 328 | reason: RelayAuthInvalidReason, | ||
| 329 | traceId: TrimmedNonEmptyString, | ||
| 330 | }, | ||
| 331 | { httpApiStatus: 401 }, | ||
| 332 | ) {} | ||
| 333 | |||
| 334 | export class RelayEnvironmentLinkProofExpiredError extends Schema.TaggedErrorClass<RelayEnvironmentLinkProofExpiredError>()( | ||
| 335 | "RelayEnvironmentLinkProofExpiredError", | ||
| 336 | { | ||
| 337 | code: Schema.Literal("environment_link_proof_expired"), | ||
| 338 | traceId: TrimmedNonEmptyString, | ||
| 339 | }, | ||
| 340 | { httpApiStatus: 401 }, | ||
| 341 | ) {} | ||
| 342 | |||
| 343 | export class RelayEnvironmentLinkProofInvalidError extends Schema.TaggedErrorClass<RelayEnvironmentLinkProofInvalidError>()( | ||
| 344 | "RelayEnvironmentLinkProofInvalidError", | ||
| 345 | { | ||
| 346 | code: Schema.Literal("environment_link_proof_invalid"), | ||
| 347 | reason: RelayEnvironmentLinkProofInvalidReason, | ||
| 348 | traceId: TrimmedNonEmptyString, | ||
| 349 | }, | ||
| 350 | { httpApiStatus: 400 }, | ||
| 351 | ) {} | ||
| 352 | |||
| 353 | export class RelayEnvironmentConnectNotAuthorizedError extends Schema.TaggedErrorClass<RelayEnvironmentConnectNotAuthorizedError>()( | ||
| 354 | "RelayEnvironmentConnectNotAuthorizedError", | ||
| 355 | { | ||
| 356 | code: Schema.Literal("environment_connect_not_authorized"), | ||
| 357 | traceId: TrimmedNonEmptyString, | ||
| 358 | }, | ||
| 359 | { httpApiStatus: 403 }, | ||
| 360 | ) {} | ||
| 361 | |||
| 362 | export class RelayEnvironmentEndpointUnavailableError extends Schema.TaggedErrorClass<RelayEnvironmentEndpointUnavailableError>()( | ||
| 363 | "RelayEnvironmentEndpointUnavailableError", | ||
| 364 | { | ||
| 365 | code: Schema.Literal("environment_endpoint_unavailable"), | ||
| 366 | reason: RelayEnvironmentEndpointUnavailableReason, | ||
| 367 | traceId: TrimmedNonEmptyString, | ||
| 368 | }, | ||
| 369 | { httpApiStatus: 502 }, | ||
| 370 | ) {} | ||
| 371 | |||
| 372 | export class RelayEnvironmentEndpointTimedOutError extends Schema.TaggedErrorClass<RelayEnvironmentEndpointTimedOutError>()( | ||
| 373 | "RelayEnvironmentEndpointTimedOutError", | ||
| 374 | { | ||
| 375 | code: Schema.Literal("environment_endpoint_timed_out"), | ||
| 376 | traceId: TrimmedNonEmptyString, | ||
| 377 | }, | ||
| 378 | { httpApiStatus: 504 }, | ||
| 379 | ) {} | ||
| 380 | |||
| 381 | export class RelayEnvironmentLinkFailedError extends Schema.TaggedErrorClass<RelayEnvironmentLinkFailedError>()( | ||
| 382 | "RelayEnvironmentLinkFailedError", | ||
| 383 | { | ||
| 384 | code: Schema.Literal("environment_link_failed"), | ||
| 385 | reason: RelayEnvironmentLinkFailedReason, | ||
| 386 | traceId: TrimmedNonEmptyString, | ||
| 387 | }, | ||
| 388 | { httpApiStatus: 500 }, | ||
| 389 | ) {} | ||
| 390 | |||
| 391 | export class RelayEnvironmentLinkUnavailableError extends Schema.TaggedErrorClass<RelayEnvironmentLinkUnavailableError>()( | ||
| 392 | "RelayEnvironmentLinkUnavailableError", | ||
| 393 | { | ||
| 394 | code: Schema.Literal("environment_link_unavailable"), | ||
| 395 | reason: RelayEnvironmentLinkUnavailableReason, | ||
| 396 | traceId: TrimmedNonEmptyString, | ||
| 397 | }, | ||
| 398 | { httpApiStatus: 503 }, | ||
| 399 | ) {} | ||
| 400 | |||
| 401 | export class RelayAgentActivityPublishProofExpiredError extends Schema.TaggedErrorClass<RelayAgentActivityPublishProofExpiredError>()( | ||
| 402 | "RelayAgentActivityPublishProofExpiredError", | ||
| 403 | { | ||
| 404 | code: Schema.Literal("agent_activity_publish_proof_expired"), | ||
| 405 | traceId: TrimmedNonEmptyString, | ||
| 406 | }, | ||
| 407 | { httpApiStatus: 401 }, | ||
| 408 | ) {} | ||
| 409 | |||
| 410 | export class RelayAgentActivityPublishProofInvalidError extends Schema.TaggedErrorClass<RelayAgentActivityPublishProofInvalidError>()( | ||
| 411 | "RelayAgentActivityPublishProofInvalidError", | ||
| 412 | { | ||
| 413 | code: Schema.Literal("agent_activity_publish_proof_invalid"), | ||
| 414 | reason: RelayAgentActivityPublishProofInvalidReason, | ||
| 415 | traceId: TrimmedNonEmptyString, | ||
| 416 | }, | ||
| 417 | { httpApiStatus: 401 }, | ||
| 418 | ) {} | ||
| 419 | |||
| 420 | export class RelayInternalError extends Schema.TaggedErrorClass<RelayInternalError>()( | ||
| 421 | "RelayInternalError", | ||
| 422 | { | ||
| 423 | code: Schema.Literal("internal_error"), | ||
| 424 | reason: RelayInternalErrorReason, | ||
| 425 | traceId: TrimmedNonEmptyString, | ||
| 426 | }, | ||
| 427 | { httpApiStatus: 500 }, | ||
| 428 | ) {} | ||
| 429 | |||
| 430 | export const RelayProtectedError = Schema.Union([ | ||
| 431 | RelayAuthInvalidError, | ||
| 432 | RelayEnvironmentLinkProofExpiredError, | ||
| 433 | RelayEnvironmentLinkProofInvalidError, | ||
| 434 | RelayEnvironmentConnectNotAuthorizedError, | ||
| 435 | RelayEnvironmentEndpointUnavailableError, | ||
| 436 | RelayEnvironmentEndpointTimedOutError, | ||
| 437 | RelayEnvironmentLinkFailedError, | ||
| 438 | RelayEnvironmentLinkUnavailableError, | ||
| 439 | RelayAgentActivityPublishProofExpiredError, | ||
| 440 | RelayAgentActivityPublishProofInvalidError, | ||
| 441 | RelayInternalError, | ||
| 442 | ]); | ||
| 443 | export type RelayProtectedError = typeof RelayProtectedError.Type; | ||
| 444 | |||
| 445 | const RelayAuthAndInternalErrors = [RelayAuthInvalidError, RelayInternalError] as const; | ||
| 446 | |||
| 447 | const RelayEnvironmentLinkErrors = [ | ||
| 448 | RelayAuthInvalidError, | ||
| 449 | RelayEnvironmentLinkProofExpiredError, | ||
| 450 | RelayEnvironmentLinkProofInvalidError, | ||
| 451 | RelayEnvironmentLinkUnavailableError, | ||
| 452 | RelayEnvironmentLinkFailedError, | ||
| 453 | RelayInternalError, | ||
| 454 | ] as const; | ||
| 455 | |||
| 456 | const RelayEnvironmentConnectErrors = [ | ||
| 457 | RelayAuthInvalidError, | ||
| 458 | RelayEnvironmentConnectNotAuthorizedError, | ||
| 459 | RelayEnvironmentEndpointUnavailableError, | ||
| 460 | RelayEnvironmentEndpointTimedOutError, | ||
| 461 | RelayInternalError, | ||
| 462 | ] as const; | ||
| 463 | |||
| 464 | const RelayAgentActivityPublishErrors = [ | ||
| 465 | RelayAuthInvalidError, | ||
| 466 | RelayAgentActivityPublishProofExpiredError, | ||
| 467 | RelayAgentActivityPublishProofInvalidError, | ||
| 468 | RelayInternalError, | ||
| 469 | ] as const; | ||
| 470 | |||
| 471 | export interface RelayClientPrincipalShape { | ||
| 472 | readonly userId: string; | ||
| 473 | readonly token: string; | ||
| 474 | readonly proofKeyThumbprint?: string; | ||
| 475 | readonly dpopScopes?: ReadonlyArray<RelayDpopAccessTokenScope>; | ||
| 476 | } | ||
| 477 | |||
| 478 | export class RelayClientPrincipal extends Context.Service< | ||
| 479 | RelayClientPrincipal, | ||
| 480 | RelayClientPrincipalShape | ||
| 481 | >()("@t3tools/contracts/relay/RelayClientPrincipal") {} | ||
| 482 | |||
| 483 | export interface RelayEnvironmentPrincipalShape { | ||
| 484 | readonly environmentId: string; | ||
| 485 | readonly environmentPublicKey: string; | ||
| 486 | } | ||
| 487 | |||
| 488 | export class RelayEnvironmentPrincipal extends Context.Service< | ||
| 489 | RelayEnvironmentPrincipal, | ||
| 490 | RelayEnvironmentPrincipalShape | ||
| 491 | >()("@t3tools/contracts/relay/RelayEnvironmentPrincipal") {} | ||
| 492 | |||
| 493 | const RelayClientBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe( | ||
| 494 | HttpApiSecurity.annotate( | ||
| 495 | OpenApi.Description, | ||
| 496 | "Clerk session or OAuth bearer token for the signed-in T3 Cloud user.", | ||
| 497 | ), | ||
| 498 | ); | ||
| 499 | |||
| 500 | export class RelayClientAuth extends HttpApiMiddleware.Service< | ||
| 501 | RelayClientAuth, | ||
| 502 | { provides: RelayClientPrincipal } | ||
| 503 | >()("RelayClientAuth", { | ||
| 504 | error: RelayAuthInvalidError, | ||
| 505 | security: { clientBearer: RelayClientBearerAuthorization }, | ||
| 506 | }) {} | ||
| 507 | |||
| 508 | const RelayEnvironmentBearerAuthorization = HttpApiSecurity.http({ scheme: "bearer" }).pipe( | ||
| 509 | HttpApiSecurity.annotate( | ||
| 510 | OpenApi.Description, | ||
| 511 | "Relay-issued environment credential installed when the environment is linked.", | ||
| 512 | ), | ||
| 513 | ); | ||
| 514 | |||
| 515 | export class RelayEnvironmentAuth extends HttpApiMiddleware.Service< | ||
| 516 | RelayEnvironmentAuth, | ||
| 517 | { provides: RelayEnvironmentPrincipal } | ||
| 518 | >()("RelayEnvironmentAuth", { | ||
| 519 | error: [RelayAuthInvalidError, RelayInternalError], | ||
| 520 | security: { environmentBearer: RelayEnvironmentBearerAuthorization }, | ||
| 521 | }) {} | ||
| 522 | |||
| 523 | const RelayDpopAuthorization = HttpApiSecurity.http({ scheme: "DPoP" }).pipe( | ||
| 524 | HttpApiSecurity.annotate( | ||
| 525 | OpenApi.Description, | ||
| 526 | "DPoP-bound access token. Requests must also include the DPoP proof JWT header.", | ||
| 527 | ), | ||
| 528 | ); | ||
| 529 | |||
| 530 | export class RelayDpopClientAuth extends HttpApiMiddleware.Service< | ||
| 531 | RelayDpopClientAuth, | ||
| 532 | { provides: RelayClientPrincipal } | ||
| 533 | >()("RelayDpopClientAuth", { | ||
| 534 | error: RelayAuthInvalidError, | ||
| 535 | security: { relayDpop: RelayDpopAuthorization }, | ||
| 536 | }) {} | ||
| 537 | |||
| 538 | export const RelayClientEnvironmentRecord = Schema.Struct({ | ||
| 539 | environmentId: EnvironmentId, | ||
| 540 | label: TrimmedNonEmptyString, | ||
| 541 | endpoint: RelayManagedEndpoint, | ||
| 542 | linkedAt: TrimmedNonEmptyString, | ||
| 543 | }); | ||
| 544 | export type RelayClientEnvironmentRecord = typeof RelayClientEnvironmentRecord.Type; | ||
| 545 | |||
| 546 | export const RelayListEnvironmentsResponse = Schema.Struct({ | ||
| 547 | environments: Schema.Array(RelayClientEnvironmentRecord), | ||
| 548 | }); | ||
| 549 | export type RelayListEnvironmentsResponse = typeof RelayListEnvironmentsResponse.Type; | ||
| 550 | |||
| 551 | export const RelayEnvironmentConnectRequest = Schema.Struct({ | ||
| 552 | deviceId: Schema.optional( | ||
| 553 | TrimmedNonEmptyString.annotate({ | ||
| 554 | description: "Optional client device identifier requesting the connection.", | ||
| 555 | }), | ||
| 556 | ), | ||
| 557 | clientKeyThumbprint: Schema.optional( | ||
| 558 | TrimmedNonEmptyString.annotate({ | ||
| 559 | description: "Deprecated alias for clientProofKeyThumbprint.", | ||
| 560 | }), | ||
| 561 | ), | ||
| 562 | clientProofKeyThumbprint: Schema.optional( | ||
| 563 | TrimmedNonEmptyString.annotate({ | ||
| 564 | description: "JWK thumbprint that the minted environment credential must be bound to.", | ||
| 565 | }), | ||
| 566 | ), | ||
| 567 | }).annotate({ description: "Requests a short-lived credential for connecting to an environment." }); | ||
| 568 | export type RelayEnvironmentConnectRequest = typeof RelayEnvironmentConnectRequest.Type; | ||
| 569 | |||
| 570 | export const RelayEnvironmentConnectScope = "environment:connect" as const; | ||
| 571 | export const RelayEnvironmentStatusScope = "environment:status" as const; | ||
| 572 | export const RelayMobileRegistrationScope = "mobile:registration" as const; | ||
| 573 | export const RelayDpopAccessTokenScope = Schema.Literals([ | ||
| 574 | RelayEnvironmentConnectScope, | ||
| 575 | RelayEnvironmentStatusScope, | ||
| 576 | RelayMobileRegistrationScope, | ||
| 577 | ]); | ||
| 578 | export type RelayDpopAccessTokenScope = typeof RelayDpopAccessTokenScope.Type; | ||
| 579 | |||
| 580 | export const RelayDpopTokenExchangeGrantType = | ||
| 581 | "urn:ietf:params:oauth:grant-type:token-exchange" as const; | ||
| 582 | export const RelayJwtSubjectTokenType = "urn:ietf:params:oauth:token-type:jwt" as const; | ||
| 583 | export const RelayAccessTokenType = "urn:ietf:params:oauth:token-type:access_token" as const; | ||
| 584 | export const RelayPublicClientId = Schema.Literals(["t3-mobile", "t3-web"]); | ||
| 585 | export type RelayPublicClientId = typeof RelayPublicClientId.Type; | ||
| 586 | export const RelayMobileClientId = "t3-mobile" as const; | ||
| 587 | export const RelayWebClientId = "t3-web" as const; | ||
| 588 | |||
| 589 | export const RelayDpopAccessTokenRequest = Schema.Struct({ | ||
| 590 | grant_type: Schema.Literal(RelayDpopTokenExchangeGrantType), | ||
| 591 | subject_token: TrimmedNonEmptyString.annotate({ | ||
| 592 | description: "Clerk bearer token for the signed-in cloud user.", | ||
| 593 | }), | ||
| 594 | subject_token_type: Schema.Literal(RelayJwtSubjectTokenType), | ||
| 595 | requested_token_type: Schema.Literal(RelayAccessTokenType), | ||
| 596 | resource: TrimmedNonEmptyString.annotate({ | ||
| 597 | description: "Relay issuer URL that will receive the DPoP-bound access token.", | ||
| 598 | }), | ||
| 599 | scope: TrimmedNonEmptyString.annotate({ | ||
| 600 | description: "Space-separated relay scopes requested by the client.", | ||
| 601 | }), | ||
| 602 | client_id: RelayPublicClientId, | ||
| 603 | }) | ||
| 604 | .annotate({ description: "OAuth token exchange request for a DPoP-bound relay access token." }) | ||
| 605 | .pipe(HttpApiSchema.asFormUrlEncoded()); | ||
| 606 | export type RelayDpopAccessTokenRequest = typeof RelayDpopAccessTokenRequest.Type; | ||
| 607 | |||
| 608 | export const RelayDpopAccessTokenResponse = Schema.Struct({ | ||
| 609 | access_token: TrimmedNonEmptyString, | ||
| 610 | issued_token_type: Schema.Literal(RelayAccessTokenType), | ||
| 611 | token_type: Schema.Literal("DPoP"), | ||
| 612 | expires_in: Schema.Int.check(Schema.isGreaterThan(0)), | ||
| 613 | scope: TrimmedNonEmptyString, | ||
| 614 | }); | ||
| 615 | export type RelayDpopAccessTokenResponse = typeof RelayDpopAccessTokenResponse.Type; | ||
| 616 | |||
| 617 | export const RelayBearerRequestHeaders = Schema.Struct({ | ||
| 618 | authorization: TrimmedNonEmptyString, | ||
| 619 | }); | ||
| 620 | |||
| 621 | export const RelayDpopProofRequestHeaders = Schema.Struct({ | ||
| 622 | dpop: TrimmedNonEmptyString, | ||
| 623 | }); | ||
| 624 | |||
| 625 | export const RelayDpopRequestHeaders = Schema.Struct({ | ||
| 626 | authorization: TrimmedNonEmptyString, | ||
| 627 | dpop: TrimmedNonEmptyString, | ||
| 628 | }); | ||
| 629 | |||
| 630 | export const RelayAuthorizationServerMetadata = Schema.Struct({ | ||
| 631 | issuer: TrimmedNonEmptyString, | ||
| 632 | token_endpoint: TrimmedNonEmptyString, | ||
| 633 | grant_types_supported: Schema.Array(Schema.Literal(RelayDpopTokenExchangeGrantType)), | ||
| 634 | token_endpoint_auth_methods_supported: Schema.Array(Schema.Literal("none")), | ||
| 635 | dpop_signing_alg_values_supported: Schema.Array(Schema.Literal("ES256")), | ||
| 636 | scopes_supported: Schema.Array(RelayDpopAccessTokenScope), | ||
| 637 | }); | ||
| 638 | |||
| 639 | export const RelayProtectedResourceMetadata = Schema.Struct({ | ||
| 640 | resource: TrimmedNonEmptyString, | ||
| 641 | authorization_servers: Schema.Array(TrimmedNonEmptyString), | ||
| 642 | scopes_supported: Schema.Array(RelayDpopAccessTokenScope), | ||
| 643 | dpop_bound_access_tokens_required: Schema.Boolean, | ||
| 644 | dpop_signing_alg_values_supported: Schema.Array(Schema.Literal("ES256")), | ||
| 645 | }); | ||
| 646 | |||
| 647 | export const RelayEnvironmentUnlinkParams = Schema.Struct({ | ||
| 648 | environmentId: EnvironmentId, | ||
| 649 | }); | ||
| 650 | export type RelayEnvironmentUnlinkParams = typeof RelayEnvironmentUnlinkParams.Type; | ||
| 651 | |||
| 652 | export const RelayEnvironmentConnectResponse = Schema.Struct({ | ||
| 653 | environmentId: EnvironmentId, | ||
| 654 | endpoint: RelayManagedEndpoint, | ||
| 655 | credential: TrimmedNonEmptyString, | ||
| 656 | expiresAt: TrimmedNonEmptyString, | ||
| 657 | }); | ||
| 658 | export type RelayEnvironmentConnectResponse = typeof RelayEnvironmentConnectResponse.Type; | ||
| 659 | |||
| 660 | export const RelayEnvironmentStatusValue = Schema.Literals(["online", "offline"]); | ||
| 661 | export type RelayEnvironmentStatusValue = typeof RelayEnvironmentStatusValue.Type; | ||
| 662 | |||
| 663 | export const RelayEnvironmentStatusResponse = Schema.Struct({ | ||
| 664 | environmentId: EnvironmentId, | ||
| 665 | endpoint: RelayManagedEndpoint, | ||
| 666 | status: RelayEnvironmentStatusValue, | ||
| 667 | checkedAt: TrimmedNonEmptyString, | ||
| 668 | descriptor: Schema.optional(ExecutionEnvironmentDescriptor), | ||
| 669 | error: Schema.optional(TrimmedNonEmptyString), | ||
| 670 | }); | ||
| 671 | export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type; | ||
| 672 | |||
| 673 | export const RelayCloudMintCredentialProofPayload = Schema.Struct({ | ||
| 674 | ...RelaySignedJwtRegisteredClaims, | ||
| 675 | environmentId: EnvironmentId, | ||
| 676 | clientProofKeyThumbprint: TrimmedNonEmptyString, | ||
| 677 | cnf: Schema.Struct({ | ||
| 678 | jkt: TrimmedNonEmptyString, | ||
| 679 | }), | ||
| 680 | deviceId: Schema.optional(TrimmedNonEmptyString), | ||
| 681 | nonce: TrimmedNonEmptyString, | ||
| 682 | scope: Schema.Array(Schema.Literal("environment:connect")), | ||
| 683 | }); | ||
| 684 | export type RelayCloudMintCredentialProofPayload = typeof RelayCloudMintCredentialProofPayload.Type; | ||
| 685 | |||
| 686 | export const RelayCloudMintCredentialProof = TrimmedNonEmptyString; | ||
| 687 | export type RelayCloudMintCredentialProof = typeof RelayCloudMintCredentialProof.Type; | ||
| 688 | |||
| 689 | export const RelayCloudMintCredentialRequest = Schema.Struct({ | ||
| 690 | proof: RelayCloudMintCredentialProof, | ||
| 691 | }); | ||
| 692 | export type RelayCloudMintCredentialRequest = typeof RelayCloudMintCredentialRequest.Type; | ||
| 693 | |||
| 694 | export const RelayCloudEnvironmentHealthProofPayload = Schema.Struct({ | ||
| 695 | ...RelaySignedJwtRegisteredClaims, | ||
| 696 | environmentId: EnvironmentId, | ||
| 697 | nonce: TrimmedNonEmptyString, | ||
| 698 | scope: Schema.Array(Schema.Literal("environment:status")), | ||
| 699 | }); | ||
| 700 | export type RelayCloudEnvironmentHealthProofPayload = | ||
| 701 | typeof RelayCloudEnvironmentHealthProofPayload.Type; | ||
| 702 | |||
| 703 | export const RelayCloudEnvironmentHealthProof = TrimmedNonEmptyString; | ||
| 704 | export type RelayCloudEnvironmentHealthProof = typeof RelayCloudEnvironmentHealthProof.Type; | ||
| 705 | |||
| 706 | export const RelayCloudEnvironmentHealthRequest = Schema.Struct({ | ||
| 707 | proof: RelayCloudEnvironmentHealthProof, | ||
| 708 | }); | ||
| 709 | export type RelayCloudEnvironmentHealthRequest = typeof RelayCloudEnvironmentHealthRequest.Type; | ||
| 710 | |||
| 711 | export const RelayEnvironmentHealthResponseProofPayload = Schema.Struct({ | ||
| 712 | ...RelaySignedJwtRegisteredClaims, | ||
| 713 | environmentId: EnvironmentId, | ||
| 714 | requestNonce: TrimmedNonEmptyString, | ||
| 715 | status: Schema.Literal("online"), | ||
| 716 | descriptor: ExecutionEnvironmentDescriptor, | ||
| 717 | checkedAt: TrimmedNonEmptyString, | ||
| 718 | }); | ||
| 719 | export type RelayEnvironmentHealthResponseProofPayload = | ||
| 720 | typeof RelayEnvironmentHealthResponseProofPayload.Type; | ||
| 721 | |||
| 722 | export const RelayEnvironmentHealthResponse = Schema.Struct({ | ||
| 723 | environmentId: EnvironmentId, | ||
| 724 | status: Schema.Literal("online"), | ||
| 725 | descriptor: ExecutionEnvironmentDescriptor, | ||
| 726 | checkedAt: TrimmedNonEmptyString, | ||
| 727 | proof: TrimmedNonEmptyString, | ||
| 728 | }); | ||
| 729 | export type RelayEnvironmentHealthResponse = typeof RelayEnvironmentHealthResponse.Type; | ||
| 730 | |||
| 731 | export const RelayEnvironmentMintResponseProofPayload = Schema.Struct({ | ||
| 732 | ...RelaySignedJwtRegisteredClaims, | ||
| 733 | environmentId: EnvironmentId, | ||
| 734 | clientProofKeyThumbprint: TrimmedNonEmptyString, | ||
| 735 | requestNonce: TrimmedNonEmptyString, | ||
| 736 | credential: TrimmedNonEmptyString, | ||
| 737 | }); | ||
| 738 | export type RelayEnvironmentMintResponseProofPayload = | ||
| 739 | typeof RelayEnvironmentMintResponseProofPayload.Type; | ||
| 740 | |||
| 741 | export const RelayEnvironmentMintResponse = Schema.Struct({ | ||
| 742 | credential: TrimmedNonEmptyString, | ||
| 743 | expiresAt: TrimmedNonEmptyString, | ||
| 744 | proof: TrimmedNonEmptyString, | ||
| 745 | }); | ||
| 746 | export type RelayEnvironmentMintResponse = typeof RelayEnvironmentMintResponse.Type; | ||
| 747 | |||
| 748 | export const RelayDeliveryKind = Schema.Literals([ | ||
| 749 | "live_activity_start", | ||
| 750 | "live_activity_update", | ||
| 751 | "live_activity_end", | ||
| 752 | "push_notification", | ||
| 753 | ]); | ||
| 754 | export type RelayDeliveryKind = typeof RelayDeliveryKind.Type; | ||
| 755 | |||
| 756 | export const RelayDeliveryResult = Schema.Struct({ | ||
| 757 | deviceId: TrimmedNonEmptyString, | ||
| 758 | kind: RelayDeliveryKind, | ||
| 759 | ok: Schema.Boolean, | ||
| 760 | queued: Schema.optional(Schema.Boolean), | ||
| 761 | apnsStatus: Schema.NullOr(Schema.Number), | ||
| 762 | apnsReason: Schema.NullOr(Schema.String), | ||
| 763 | apnsId: Schema.NullOr(Schema.String), | ||
| 764 | }); | ||
| 765 | export type RelayDeliveryResult = typeof RelayDeliveryResult.Type; | ||
| 766 | |||
| 767 | export const RelayOkResponse = Schema.Struct({ | ||
| 768 | ok: Schema.Boolean, | ||
| 769 | }); | ||
| 770 | export type RelayOkResponse = typeof RelayOkResponse.Type; | ||
| 771 | |||
| 772 | export const RelayPublishResponse = Schema.Struct({ | ||
| 773 | ok: Schema.Boolean, | ||
| 774 | deliveries: Schema.Array(RelayDeliveryResult), | ||
| 775 | }); | ||
| 776 | export type RelayPublishResponse = typeof RelayPublishResponse.Type; | ||
| 777 | |||
| 778 | export const RelayHealthResponse = Schema.Struct({ | ||
| 779 | ok: Schema.Boolean, | ||
| 780 | service: Schema.Literal("relay"), | ||
| 781 | }); | ||
| 782 | export type RelayHealthResponse = typeof RelayHealthResponse.Type; | ||
| 783 | |||
| 784 | export const RelayHealthGroup = HttpApiGroup.make("health") | ||
| 785 | .add( | ||
| 786 | HttpApiEndpoint.get("health", "/health", { | ||
| 787 | success: RelayHealthResponse, | ||
| 788 | error: RelayInternalError, | ||
| 789 | }).annotate(OpenApi.Summary, "Check relay health"), | ||
| 790 | ) | ||
| 791 | .annotate(OpenApi.Description, "Service health and readiness."); | ||
| 792 | |||
| 793 | export const RelayMetadataGroup = HttpApiGroup.make("metadata") | ||
| 794 | .add( | ||
| 795 | HttpApiEndpoint.get("authorizationServer", "/.well-known/oauth-authorization-server", { | ||
| 796 | success: RelayAuthorizationServerMetadata, | ||
| 797 | }).annotate(OpenApi.Summary, "Read OAuth authorization-server metadata"), | ||
| 798 | HttpApiEndpoint.get("protectedResource", "/.well-known/oauth-protected-resource", { | ||
| 799 | success: RelayProtectedResourceMetadata, | ||
| 800 | }).annotate(OpenApi.Summary, "Read OAuth protected-resource metadata"), | ||
| 801 | ) | ||
| 802 | .annotate(OpenApi.Description, "OAuth and DPoP discovery metadata."); | ||
| 803 | |||
| 804 | export const RelayRegisterDeviceEndpoint = HttpApiEndpoint.post( | ||
| 805 | "registerDevice", | ||
| 806 | "/v1/mobile/devices", | ||
| 807 | { | ||
| 808 | headers: RelayDpopRequestHeaders, | ||
| 809 | payload: RelayDeviceRegistrationRequest, | ||
| 810 | success: RelayOkResponse, | ||
| 811 | error: RelayAuthAndInternalErrors, | ||
| 812 | }, | ||
| 813 | ).annotate(OpenApi.Summary, "Register or update a mobile device"); | ||
| 814 | |||
| 815 | export const RelayRegisterLiveActivityEndpoint = HttpApiEndpoint.post( | ||
| 816 | "registerLiveActivity", | ||
| 817 | "/v1/mobile/live-activities", | ||
| 818 | { | ||
| 819 | headers: RelayDpopRequestHeaders, | ||
| 820 | payload: RelayLiveActivityRegistrationRequest, | ||
| 821 | success: RelayOkResponse, | ||
| 822 | error: RelayAuthAndInternalErrors, | ||
| 823 | }, | ||
| 824 | ).annotate(OpenApi.Summary, "Register a Live Activity push token"); | ||
| 825 | |||
| 826 | export const RelayUnregisterDeviceEndpoint = HttpApiEndpoint.delete( | ||
| 827 | "unregisterDevice", | ||
| 828 | "/v1/mobile/devices/:deviceId", | ||
| 829 | { | ||
| 830 | headers: RelayDpopRequestHeaders, | ||
| 831 | params: RelayDeviceUnregistrationParams, | ||
| 832 | success: RelayOkResponse, | ||
| 833 | error: RelayAuthAndInternalErrors, | ||
| 834 | }, | ||
| 835 | ).annotate(OpenApi.Summary, "Unregister a mobile device"); | ||
| 836 | |||
| 837 | export const RelayMobileGroup = HttpApiGroup.make("mobile") | ||
| 838 | .add( | ||
| 839 | RelayRegisterDeviceEndpoint, | ||
| 840 | RelayRegisterLiveActivityEndpoint, | ||
| 841 | RelayUnregisterDeviceEndpoint, | ||
| 842 | ) | ||
| 843 | .annotate(OpenApi.Description, "Mobile push-notification and Live Activity registration.") | ||
| 844 | .middleware(RelayDpopClientAuth); | ||
| 845 | |||
| 846 | export const RelayClientGroup = HttpApiGroup.make("client") | ||
| 847 | .add( | ||
| 848 | HttpApiEndpoint.get("listEnvironments", "/v1/environments", { | ||
| 849 | headers: RelayBearerRequestHeaders, | ||
| 850 | success: RelayListEnvironmentsResponse, | ||
| 851 | error: RelayAuthAndInternalErrors, | ||
| 852 | }).annotate(OpenApi.Summary, "List linked environments"), | ||
| 853 | HttpApiEndpoint.get("listDevices", "/v1/client/devices", { | ||
| 854 | headers: RelayBearerRequestHeaders, | ||
| 855 | success: RelayListDevicesResponse, | ||
| 856 | error: RelayAuthAndInternalErrors, | ||
| 857 | }).annotate(OpenApi.Summary, "List registered mobile devices"), | ||
| 858 | HttpApiEndpoint.post("linkEnvironment", "/v1/client/environment-links", { | ||
| 859 | headers: RelayBearerRequestHeaders, | ||
| 860 | payload: RelayEnvironmentLinkRequest, | ||
| 861 | success: RelayEnvironmentLinkResponse, | ||
| 862 | error: RelayEnvironmentLinkErrors, | ||
| 863 | }).annotate(OpenApi.Summary, "Link an environment"), | ||
| 864 | HttpApiEndpoint.post( | ||
| 865 | "createEnvironmentLinkChallenge", | ||
| 866 | "/v1/client/environment-link-challenges", | ||
| 867 | { | ||
| 868 | headers: RelayBearerRequestHeaders, | ||
| 869 | payload: RelayEnvironmentLinkChallengeRequest, | ||
| 870 | success: RelayEnvironmentLinkChallengeResponse, | ||
| 871 | error: RelayAuthAndInternalErrors, | ||
| 872 | }, | ||
| 873 | ).annotate(OpenApi.Summary, "Create an environment-link challenge"), | ||
| 874 | HttpApiEndpoint.delete("unlinkEnvironment", "/v1/client/environment-links/:environmentId", { | ||
| 875 | headers: RelayBearerRequestHeaders, | ||
| 876 | params: RelayEnvironmentUnlinkParams, | ||
| 877 | success: RelayOkResponse, | ||
| 878 | error: RelayAuthAndInternalErrors, | ||
| 879 | }).annotate(OpenApi.Summary, "Unlink an environment"), | ||
| 880 | ) | ||
| 881 | .annotate(OpenApi.Description, "Cloud-user environment links and registered devices.") | ||
| 882 | .middleware(RelayClientAuth); | ||
| 883 | |||
| 884 | export const RelayExchangeDpopAccessTokenEndpoint = HttpApiEndpoint.post( | ||
| 885 | "exchangeDpopAccessToken", | ||
| 886 | "/v1/client/dpop-token", | ||
| 887 | { | ||
| 888 | headers: RelayDpopProofRequestHeaders, | ||
| 889 | payload: RelayDpopAccessTokenRequest, | ||
| 890 | success: RelayDpopAccessTokenResponse, | ||
| 891 | error: RelayAuthAndInternalErrors, | ||
| 892 | }, | ||
| 893 | ) | ||
| 894 | .annotate(OpenApi.Summary, "Exchange a Clerk token for a DPoP access token") | ||
| 895 | .annotate( | ||
| 896 | OpenApi.Description, | ||
| 897 | "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.", | ||
| 898 | ); | ||
| 899 | |||
| 900 | export const RelayTokenGroup = HttpApiGroup.make("token") | ||
| 901 | .add(RelayExchangeDpopAccessTokenEndpoint) | ||
| 902 | .annotate(OpenApi.Description, "OAuth token exchange for DPoP-bound client access."); | ||
| 903 | |||
| 904 | export const RelayConnectEnvironmentEndpoint = HttpApiEndpoint.post( | ||
| 905 | "connectEnvironment", | ||
| 906 | "/v1/environments/:environmentId/connect", | ||
| 907 | { | ||
| 908 | headers: RelayDpopRequestHeaders, | ||
| 909 | params: Schema.Struct({ | ||
| 910 | environmentId: EnvironmentId, | ||
| 911 | }), | ||
| 912 | payload: RelayEnvironmentConnectRequest, | ||
| 913 | success: RelayEnvironmentConnectResponse, | ||
| 914 | error: RelayEnvironmentConnectErrors, | ||
| 915 | }, | ||
| 916 | ).annotate(OpenApi.Summary, "Connect to an environment"); | ||
| 917 | |||
| 918 | export const RelayGetEnvironmentStatusEndpoint = HttpApiEndpoint.post( | ||
| 919 | "getEnvironmentStatus", | ||
| 920 | "/v1/environments/:environmentId/status", | ||
| 921 | { | ||
| 922 | headers: RelayDpopRequestHeaders, | ||
| 923 | params: Schema.Struct({ | ||
| 924 | environmentId: EnvironmentId, | ||
| 925 | }), | ||
| 926 | success: RelayEnvironmentStatusResponse, | ||
| 927 | error: RelayEnvironmentConnectErrors, | ||
| 928 | }, | ||
| 929 | ).annotate(OpenApi.Summary, "Check environment status"); | ||
| 930 | |||
| 931 | export const RelayDpopClientGroup = HttpApiGroup.make("dpopClient") | ||
| 932 | .add(RelayConnectEnvironmentEndpoint, RelayGetEnvironmentStatusEndpoint) | ||
| 933 | .annotate(OpenApi.Description, "DPoP-authenticated client access to linked environments.") | ||
| 934 | .middleware(RelayDpopClientAuth); | ||
| 935 | |||
| 936 | export const RelayServerGroup = HttpApiGroup.make("server") | ||
| 937 | .add( | ||
| 938 | HttpApiEndpoint.post( | ||
| 939 | "publishAgentActivity", | ||
| 940 | "/v1/environments/:environmentId/threads/:threadId/agent-activity", | ||
| 941 | { | ||
| 942 | params: Schema.Struct({ | ||
| 943 | environmentId: EnvironmentId, | ||
| 944 | threadId: ThreadId, | ||
| 945 | }), | ||
| 946 | payload: RelayAgentActivityPublishRequest, | ||
| 947 | success: RelayPublishResponse, | ||
| 948 | error: RelayAgentActivityPublishErrors, | ||
| 949 | }, | ||
| 950 | ).annotate(OpenApi.Summary, "Publish agent activity"), | ||
| 951 | ) | ||
| 952 | .annotate(OpenApi.Description, "Environment-authenticated activity publication.") | ||
| 953 | .middleware(RelayEnvironmentAuth); | ||
| 954 | |||
| 955 | export const RelayApi = HttpApi.make("RelayApi") | ||
| 956 | .add( | ||
| 957 | RelayHealthGroup, | ||
| 958 | RelayMetadataGroup, | ||
| 959 | RelayMobileGroup, | ||
| 960 | RelayClientGroup, | ||
| 961 | RelayTokenGroup, | ||
| 962 | RelayDpopClientGroup, | ||
| 963 | RelayServerGroup, | ||
| 964 | ) | ||
| 965 | .annotate(OpenApi.Title, "T3 Code Relay API") | ||
| 966 | .annotate(OpenApi.Version, "1.0.0") | ||
| 967 | .annotate( | ||
| 968 | OpenApi.Description, | ||
| 969 | "Control-plane API for linking T3 environments, connecting authorized clients, and publishing agent activity.", | ||
| 970 | ); | ||
| 971 | export type RelayApi = typeof RelayApi; | ||
packages/contracts/src/relayClient.ts created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | export const RelayClientStatusSchema = Schema.Union([ | ||
| 4 | Schema.Struct({ | ||
| 5 | status: Schema.Literal("available"), | ||
| 6 | executablePath: Schema.String, | ||
| 7 | source: Schema.Literals(["override", "managed", "path"]), | ||
| 8 | version: Schema.String, | ||
| 9 | }), | ||
| 10 | Schema.Struct({ | ||
| 11 | status: Schema.Literal("missing"), | ||
| 12 | version: Schema.String, | ||
| 13 | }), | ||
| 14 | Schema.Struct({ | ||
| 15 | status: Schema.Literal("unsupported"), | ||
| 16 | platform: Schema.String, | ||
| 17 | arch: Schema.String, | ||
| 18 | version: Schema.String, | ||
| 19 | }), | ||
| 20 | ]); | ||
| 21 | export type RelayClientStatus = typeof RelayClientStatusSchema.Type; | ||
| 22 | |||
| 23 | export const RelayClientInstallProgressStageSchema = Schema.Literals([ | ||
| 24 | "checking", | ||
| 25 | "waiting_for_lock", | ||
| 26 | "downloading", | ||
| 27 | "verifying", | ||
| 28 | "installing", | ||
| 29 | "validating", | ||
| 30 | "activating", | ||
| 31 | ]); | ||
| 32 | export type RelayClientInstallProgressStage = typeof RelayClientInstallProgressStageSchema.Type; | ||
| 33 | |||
| 34 | export const RelayClientInstallProgressEventSchema = Schema.Union([ | ||
| 35 | Schema.Struct({ | ||
| 36 | type: Schema.Literal("progress"), | ||
| 37 | stage: RelayClientInstallProgressStageSchema, | ||
| 38 | }), | ||
| 39 | Schema.Struct({ | ||
| 40 | type: Schema.Literal("complete"), | ||
| 41 | status: RelayClientStatusSchema, | ||
| 42 | }), | ||
| 43 | ]); | ||
| 44 | export type RelayClientInstallProgressEvent = typeof RelayClientInstallProgressEventSchema.Type; | ||
| 45 | |||
| 46 | export const RelayClientInstallFailureReasonSchema = Schema.Literals([ | ||
| 47 | "download_failed", | ||
| 48 | "invalid_checksum", | ||
| 49 | "install_locked", | ||
| 50 | "override_missing", | ||
| 51 | "unsupported_platform", | ||
| 52 | "validation_failed", | ||
| 53 | "write_failed", | ||
| 54 | ]); | ||
| 55 | export type RelayClientInstallFailureReason = typeof RelayClientInstallFailureReasonSchema.Type; | ||
| 56 | |||
| 57 | export class RelayClientInstallFailedError extends Schema.TaggedErrorClass<RelayClientInstallFailedError>()( | ||
| 58 | "RelayClientInstallFailedError", | ||
| 59 | { | ||
| 60 | reason: RelayClientInstallFailureReasonSchema, | ||
| 61 | message: Schema.String, | ||
| 62 | }, | ||
| 63 | ) {} | ||
packages/contracts/src/rpc.ts+24| ... | @@ -58,6 +58,11 @@ import { | ... | @@ -58,6 +58,11 @@ import { |
| 58 | OrchestrationRpcSchemas, | 58 | OrchestrationRpcSchemas, |
| 59 | } from "./orchestration.ts"; | 59 | } from "./orchestration.ts"; |
| 60 | import { ProviderInstanceId } from "./providerInstance.ts"; | 60 | import { ProviderInstanceId } from "./providerInstance.ts"; |
| 61 | import { | ||
| 62 | RelayClientInstallFailedError, | ||
| 63 | RelayClientInstallProgressEventSchema, | ||
| 64 | RelayClientStatusSchema, | ||
| 65 | } from "./relayClient.ts"; | ||
| 61 | import { | 66 | import { |
| 62 | ProjectSearchEntriesError, | 67 | ProjectSearchEntriesError, |
| 63 | ProjectSearchEntriesInput, | 68 | ProjectSearchEntriesInput, |
| ... | @@ -166,6 +171,10 @@ export const WS_METHODS = { | ... | @@ -166,6 +171,10 @@ export const WS_METHODS = { |
| 166 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", | 171 | serverGetProcessResourceHistory: "server.getProcessResourceHistory", |
| 167 | serverSignalProcess: "server.signalProcess", | 172 | serverSignalProcess: "server.signalProcess", |
| 168 | 173 | ||
| 174 | // Cloud environment methods | ||
| 175 | cloudGetRelayClientStatus: "cloud.getRelayClientStatus", | ||
| 176 | cloudInstallRelayClient: "cloud.installRelayClient", | ||
| 177 | |||
| 169 | // Source control methods | 178 | // Source control methods |
| 170 | sourceControlLookupRepository: "sourceControl.lookupRepository", | 179 | sourceControlLookupRepository: "sourceControl.lookupRepository", |
| 171 | sourceControlCloneRepository: "sourceControl.cloneRepository", | 180 | sourceControlCloneRepository: "sourceControl.cloneRepository", |
| ... | @@ -263,6 +272,19 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, | ... | @@ -263,6 +272,19 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, |
| 263 | error: EnvironmentAuthorizationError, | 272 | error: EnvironmentAuthorizationError, |
| 264 | }); | 273 | }); |
| 265 | 274 | ||
| 275 | export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { | ||
| 276 | payload: Schema.Struct({}), | ||
| 277 | success: RelayClientStatusSchema, | ||
| 278 | error: EnvironmentAuthorizationError, | ||
| 279 | }); | ||
| 280 | |||
| 281 | export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRelayClient, { | ||
| 282 | payload: Schema.Struct({}), | ||
| 283 | success: RelayClientInstallProgressEventSchema, | ||
| 284 | error: Schema.Union([RelayClientInstallFailedError, EnvironmentAuthorizationError]), | ||
| 285 | stream: true, | ||
| 286 | }); | ||
| 287 | |||
| 266 | export const WsSourceControlLookupRepositoryRpc = Rpc.make( | 288 | export const WsSourceControlLookupRepositoryRpc = Rpc.make( |
| 267 | WS_METHODS.sourceControlLookupRepository, | 289 | WS_METHODS.sourceControlLookupRepository, |
| 268 | { | 290 | { |
| ... | @@ -536,6 +558,8 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -536,6 +558,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 536 | WsServerGetProcessDiagnosticsRpc, | 558 | WsServerGetProcessDiagnosticsRpc, |
| 537 | WsServerGetProcessResourceHistoryRpc, | 559 | WsServerGetProcessResourceHistoryRpc, |
| 538 | WsServerSignalProcessRpc, | 560 | WsServerSignalProcessRpc, |
| 561 | WsCloudGetRelayClientStatusRpc, | ||
| 562 | WsCloudInstallRelayClientRpc, | ||
| 539 | WsSourceControlLookupRepositoryRpc, | 563 | WsSourceControlLookupRepositoryRpc, |
| 540 | WsSourceControlCloneRepositoryRpc, | 564 | WsSourceControlCloneRepositoryRpc, |
| 541 | WsSourceControlPublishRepositoryRpc, | 565 | WsSourceControlPublishRepositoryRpc, |
packages/contracts/src/server.ts+1-1| ... | @@ -550,7 +550,7 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass<ServerPro | ... | @@ -550,7 +550,7 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass<ServerPro |
| 550 | { | 550 | { |
| 551 | provider: ProviderDriverKind, | 551 | provider: ProviderDriverKind, |
| 552 | reason: TrimmedNonEmptyString, | 552 | reason: TrimmedNonEmptyString, |
| 553 | cause: Schema.optional(Schema.Defect), | 553 | cause: Schema.optional(Schema.Defect()), |
| 554 | }, | 554 | }, |
| 555 | ) { | 555 | ) { |
| 556 | override get message(): string { | 556 | override get message(): string { |
packages/contracts/src/settings.ts+1-1| ... | @@ -390,7 +390,7 @@ export class ServerSettingsError extends Schema.TaggedErrorClass<ServerSettingsE | ... | @@ -390,7 +390,7 @@ export class ServerSettingsError extends Schema.TaggedErrorClass<ServerSettingsE |
| 390 | { | 390 | { |
| 391 | settingsPath: Schema.String, | 391 | settingsPath: Schema.String, |
| 392 | detail: Schema.String, | 392 | detail: Schema.String, |
| 393 | cause: Schema.optional(Schema.Defect), | 393 | cause: Schema.optional(Schema.Defect()), |
| 394 | }, | 394 | }, |
| 395 | ) { | 395 | ) { |
| 396 | override get message(): string { | 396 | override get message(): string { |
packages/contracts/src/sourceControl.ts+2-2| ... | @@ -156,7 +156,7 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo | ... | @@ -156,7 +156,7 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo |
| 156 | provider: SourceControlProviderKind, | 156 | provider: SourceControlProviderKind, |
| 157 | operation: Schema.String, | 157 | operation: Schema.String, |
| 158 | detail: Schema.String, | 158 | detail: Schema.String, |
| 159 | cause: Schema.optional(Schema.Defect), | 159 | cause: Schema.optional(Schema.Defect()), |
| 160 | }, | 160 | }, |
| 161 | ) { | 161 | ) { |
| 162 | override get message(): string { | 162 | override get message(): string { |
| ... | @@ -170,7 +170,7 @@ export class SourceControlRepositoryError extends Schema.TaggedErrorClass<Source | ... | @@ -170,7 +170,7 @@ export class SourceControlRepositoryError extends Schema.TaggedErrorClass<Source |
| 170 | provider: SourceControlProviderKind, | 170 | provider: SourceControlProviderKind, |
| 171 | operation: Schema.String, | 171 | operation: Schema.String, |
| 172 | detail: Schema.String, | 172 | detail: Schema.String, |
| 173 | cause: Schema.optional(Schema.Defect), | 173 | cause: Schema.optional(Schema.Defect()), |
| 174 | }, | 174 | }, |
| 175 | ) { | 175 | ) { |
| 176 | override get message(): string { | 176 | override get message(): string { |
packages/contracts/src/terminal.ts+2-2| ... | @@ -237,7 +237,7 @@ export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>( | ... | @@ -237,7 +237,7 @@ export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>( |
| 237 | { | 237 | { |
| 238 | cwd: Schema.String, | 238 | cwd: Schema.String, |
| 239 | reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]), | 239 | reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]), |
| 240 | cause: Schema.optional(Schema.Defect), | 240 | cause: Schema.optional(Schema.Defect()), |
| 241 | }, | 241 | }, |
| 242 | ) { | 242 | ) { |
| 243 | override get message() { | 243 | override get message() { |
| ... | @@ -266,7 +266,7 @@ export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistor | ... | @@ -266,7 +266,7 @@ export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistor |
| 266 | operation: Schema.Literals(["read", "truncate", "migrate"]), | 266 | operation: Schema.Literals(["read", "truncate", "migrate"]), |
| 267 | threadId: Schema.String, | 267 | threadId: Schema.String, |
| 268 | terminalId: Schema.String, | 268 | terminalId: Schema.String, |
| 269 | cause: Schema.optional(Schema.Defect), | 269 | cause: Schema.optional(Schema.Defect()), |
| 270 | }, | 270 | }, |
| 271 | ) { | 271 | ) { |
| 272 | override get message() { | 272 | override get message() { |
packages/contracts/src/vcs.ts+3-3| ... | @@ -92,7 +92,7 @@ export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpaw | ... | @@ -92,7 +92,7 @@ export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpaw |
| 92 | operation: Schema.String, | 92 | operation: Schema.String, |
| 93 | command: Schema.String, | 93 | command: Schema.String, |
| 94 | cwd: Schema.String, | 94 | cwd: Schema.String, |
| 95 | cause: Schema.Defect, | 95 | cause: Schema.Defect(), |
| 96 | }, | 96 | }, |
| 97 | ) { | 97 | ) { |
| 98 | override get message(): string { | 98 | override get message(): string { |
| ... | @@ -150,7 +150,7 @@ export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecod | ... | @@ -150,7 +150,7 @@ export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecod |
| 150 | command: Schema.String, | 150 | command: Schema.String, |
| 151 | cwd: Schema.String, | 151 | cwd: Schema.String, |
| 152 | detail: Schema.String, | 152 | detail: Schema.String, |
| 153 | cause: Schema.optional(Schema.Defect), | 153 | cause: Schema.optional(Schema.Defect()), |
| 154 | }, | 154 | }, |
| 155 | ) { | 155 | ) { |
| 156 | override get message(): string { | 156 | override get message(): string { |
| ... | @@ -200,7 +200,7 @@ export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepo | ... | @@ -200,7 +200,7 @@ export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepo |
| 200 | operation: Schema.String, | 200 | operation: Schema.String, |
| 201 | cwd: Schema.String, | 201 | cwd: Schema.String, |
| 202 | detail: Schema.String, | 202 | detail: Schema.String, |
| 203 | cause: Schema.optional(Schema.Defect), | 203 | cause: Schema.optional(Schema.Defect()), |
| 204 | }, | 204 | }, |
| 205 | ) { | 205 | ) { |
| 206 | override get message(): string { | 206 | override get message(): string { |
packages/effect-acp/src/errors.ts+4-4| ... | @@ -4,7 +4,7 @@ import * as AcpSchema from "./_generated/schema.gen.ts"; | ... | @@ -4,7 +4,7 @@ import * as AcpSchema from "./_generated/schema.gen.ts"; |
| 4 | 4 | ||
| 5 | export class AcpSpawnError extends Schema.TaggedErrorClass<AcpSpawnError>()("AcpSpawnError", { | 5 | export class AcpSpawnError extends Schema.TaggedErrorClass<AcpSpawnError>()("AcpSpawnError", { |
| 6 | command: Schema.optional(Schema.String), | 6 | command: Schema.optional(Schema.String), |
| 7 | cause: Schema.Defect, | 7 | cause: Schema.Defect(), |
| 8 | }) { | 8 | }) { |
| 9 | override get message() { | 9 | override get message() { |
| 10 | return this.command | 10 | return this.command |
| ... | @@ -17,7 +17,7 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass<AcpProcessExi | ... | @@ -17,7 +17,7 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass<AcpProcessExi |
| 17 | "AcpProcessExitedError", | 17 | "AcpProcessExitedError", |
| 18 | { | 18 | { |
| 19 | code: Schema.optional(Schema.Number), | 19 | code: Schema.optional(Schema.Number), |
| 20 | cause: Schema.optional(Schema.Defect), | 20 | cause: Schema.optional(Schema.Defect()), |
| 21 | }, | 21 | }, |
| 22 | ) { | 22 | ) { |
| 23 | override get message() { | 23 | override get message() { |
| ... | @@ -31,7 +31,7 @@ export class AcpProtocolParseError extends Schema.TaggedErrorClass<AcpProtocolPa | ... | @@ -31,7 +31,7 @@ export class AcpProtocolParseError extends Schema.TaggedErrorClass<AcpProtocolPa |
| 31 | "AcpProtocolParseError", | 31 | "AcpProtocolParseError", |
| 32 | { | 32 | { |
| 33 | detail: Schema.String, | 33 | detail: Schema.String, |
| 34 | cause: Schema.optional(Schema.Defect), | 34 | cause: Schema.optional(Schema.Defect()), |
| 35 | }, | 35 | }, |
| 36 | ) { | 36 | ) { |
| 37 | override get message() { | 37 | override get message() { |
| ... | @@ -43,7 +43,7 @@ export class AcpTransportError extends Schema.TaggedErrorClass<AcpTransportError | ... | @@ -43,7 +43,7 @@ export class AcpTransportError extends Schema.TaggedErrorClass<AcpTransportError |
| 43 | "AcpTransportError", | 43 | "AcpTransportError", |
| 44 | { | 44 | { |
| 45 | detail: Schema.String, | 45 | detail: Schema.String, |
| 46 | cause: Schema.Defect, | 46 | cause: Schema.Defect(), |
| 47 | }, | 47 | }, |
| 48 | ) {} | 48 | ) {} |
| 49 | 49 |
packages/effect-codex-app-server/scripts/generate.ts+1-1| ... | @@ -63,7 +63,7 @@ interface JsonSchemaFile { | ... | @@ -63,7 +63,7 @@ interface JsonSchemaFile { |
| 63 | 63 | ||
| 64 | class GeneratorError extends Schema.TaggedErrorClass<GeneratorError>()("GeneratorError", { | 64 | class GeneratorError extends Schema.TaggedErrorClass<GeneratorError>()("GeneratorError", { |
| 65 | detail: Schema.String, | 65 | detail: Schema.String, |
| 66 | cause: Schema.optional(Schema.Defect), | 66 | cause: Schema.optional(Schema.Defect()), |
| 67 | }) { | 67 | }) { |
| 68 | override get message() { | 68 | override get message() { |
| 69 | return this.detail; | 69 | return this.detail; |
packages/effect-codex-app-server/src/errors.ts+4-4| ... | @@ -10,7 +10,7 @@ export class CodexAppServerSpawnError extends Schema.TaggedErrorClass<CodexAppSe | ... | @@ -10,7 +10,7 @@ export class CodexAppServerSpawnError extends Schema.TaggedErrorClass<CodexAppSe |
| 10 | "CodexAppServerSpawnError", | 10 | "CodexAppServerSpawnError", |
| 11 | { | 11 | { |
| 12 | command: Schema.optional(Schema.String), | 12 | command: Schema.optional(Schema.String), |
| 13 | cause: Schema.Defect, | 13 | cause: Schema.Defect(), |
| 14 | }, | 14 | }, |
| 15 | ) { | 15 | ) { |
| 16 | override get message() { | 16 | override get message() { |
| ... | @@ -24,7 +24,7 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass<Co | ... | @@ -24,7 +24,7 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass<Co |
| 24 | "CodexAppServerProcessExitedError", | 24 | "CodexAppServerProcessExitedError", |
| 25 | { | 25 | { |
| 26 | code: Schema.optional(Schema.Number), | 26 | code: Schema.optional(Schema.Number), |
| 27 | cause: Schema.optional(Schema.Defect), | 27 | cause: Schema.optional(Schema.Defect()), |
| 28 | }, | 28 | }, |
| 29 | ) { | 29 | ) { |
| 30 | override get message() { | 30 | override get message() { |
| ... | @@ -38,7 +38,7 @@ export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass<Co | ... | @@ -38,7 +38,7 @@ export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass<Co |
| 38 | "CodexAppServerProtocolParseError", | 38 | "CodexAppServerProtocolParseError", |
| 39 | { | 39 | { |
| 40 | detail: Schema.String, | 40 | detail: Schema.String, |
| 41 | cause: Schema.optional(Schema.Defect), | 41 | cause: Schema.optional(Schema.Defect()), |
| 42 | }, | 42 | }, |
| 43 | ) { | 43 | ) { |
| 44 | override get message() { | 44 | override get message() { |
| ... | @@ -50,7 +50,7 @@ export class CodexAppServerTransportError extends Schema.TaggedErrorClass<CodexA | ... | @@ -50,7 +50,7 @@ export class CodexAppServerTransportError extends Schema.TaggedErrorClass<CodexA |
| 50 | "CodexAppServerTransportError", | 50 | "CodexAppServerTransportError", |
| 51 | { | 51 | { |
| 52 | detail: Schema.String, | 52 | detail: Schema.String, |
| 53 | cause: Schema.Defect, | 53 | cause: Schema.Defect(), |
| 54 | }, | 54 | }, |
| 55 | ) { | 55 | ) { |
| 56 | override get message() { | 56 | override get message() { |
packages/shared/package.json+35| ... | @@ -11,6 +11,10 @@ | ... | @@ -11,6 +11,10 @@ |
| 11 | "types": "./src/advertisedEndpoint.ts", | 11 | "types": "./src/advertisedEndpoint.ts", |
| 12 | "import": "./src/advertisedEndpoint.ts" | 12 | "import": "./src/advertisedEndpoint.ts" |
| 13 | }, | 13 | }, |
| 14 | "./agentAwareness": { | ||
| 15 | "types": "./src/agentAwareness.ts", | ||
| 16 | "import": "./src/agentAwareness.ts" | ||
| 17 | }, | ||
| 14 | "./git": { | 18 | "./git": { |
| 15 | "types": "./src/git.ts", | 19 | "types": "./src/git.ts", |
| 16 | "import": "./src/git.ts" | 20 | "import": "./src/git.ts" |
| ... | @@ -83,6 +87,30 @@ | ... | @@ -83,6 +87,30 @@ |
| 83 | "types": "./src/remote.ts", | 87 | "types": "./src/remote.ts", |
| 84 | "import": "./src/remote.ts" | 88 | "import": "./src/remote.ts" |
| 85 | }, | 89 | }, |
| 90 | "./relaySigning": { | ||
| 91 | "types": "./src/relaySigning.ts", | ||
| 92 | "import": "./src/relaySigning.ts" | ||
| 93 | }, | ||
| 94 | "./dpop": { | ||
| 95 | "types": "./src/dpop.ts", | ||
| 96 | "import": "./src/dpop.ts" | ||
| 97 | }, | ||
| 98 | "./dpopCommon": { | ||
| 99 | "types": "./src/dpopCommon.ts", | ||
| 100 | "import": "./src/dpopCommon.ts" | ||
| 101 | }, | ||
| 102 | "./relayAuth": { | ||
| 103 | "types": "./src/relayAuth.ts", | ||
| 104 | "import": "./src/relayAuth.ts" | ||
| 105 | }, | ||
| 106 | "./relayUrl": { | ||
| 107 | "types": "./src/relayUrl.ts", | ||
| 108 | "import": "./src/relayUrl.ts" | ||
| 109 | }, | ||
| 110 | "./relayJwt": { | ||
| 111 | "types": "./src/relayJwt.ts", | ||
| 112 | "import": "./src/relayJwt.ts" | ||
| 113 | }, | ||
| 86 | "./oauthScope": { | 114 | "./oauthScope": { |
| 87 | "types": "./src/oauthScope.ts", | 115 | "types": "./src/oauthScope.ts", |
| 88 | "import": "./src/oauthScope.ts" | 116 | "import": "./src/oauthScope.ts" |
| ... | @@ -114,6 +142,10 @@ | ... | @@ -114,6 +142,10 @@ |
| 114 | "./terminalLabels": { | 142 | "./terminalLabels": { |
| 115 | "types": "./src/terminalLabels.ts", | 143 | "types": "./src/terminalLabels.ts", |
| 116 | "import": "./src/terminalLabels.ts" | 144 | "import": "./src/terminalLabels.ts" |
| 145 | }, | ||
| 146 | "./relayClient": { | ||
| 147 | "types": "./src/relayClient.ts", | ||
| 148 | "import": "./src/relayClient.ts" | ||
| 117 | } | 149 | } |
| 118 | }, | 150 | }, |
| 119 | "scripts": { | 151 | "scripts": { |
| ... | @@ -121,8 +153,11 @@ | ... | @@ -121,8 +153,11 @@ |
| 121 | "test": "vp test run" | 153 | "test": "vp test run" |
| 122 | }, | 154 | }, |
| 123 | "dependencies": { | 155 | "dependencies": { |
| 156 | "@noble/curves": "catalog:", | ||
| 157 | "@noble/hashes": "catalog:", | ||
| 124 | "@t3tools/contracts": "workspace:*", | 158 | "@t3tools/contracts": "workspace:*", |
| 125 | "effect": "catalog:", | 159 | "effect": "catalog:", |
| 160 | "jose": "catalog:", | ||
| 126 | "yaml": "catalog:" | 161 | "yaml": "catalog:" |
| 127 | }, | 162 | }, |
| 128 | "devDependencies": { | 163 | "devDependencies": { |
packages/shared/src/agentAwareness.test.ts created+128| ... | @@ -0,0 +1,128 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import type { | ||
| 4 | EnvironmentId, | ||
| 5 | OrchestrationProjectShell, | ||
| 6 | OrchestrationThreadShell, | ||
| 7 | ThreadId, | ||
| 8 | TurnId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | import { projectThreadAwareness } from "./agentAwareness.ts"; | ||
| 13 | |||
| 14 | const NOW = "2026-05-22T12:00:00.000Z"; | ||
| 15 | |||
| 16 | const project = { | ||
| 17 | title: "t3code", | ||
| 18 | } satisfies Pick<OrchestrationProjectShell, "title">; | ||
| 19 | |||
| 20 | function thread( | ||
| 21 | overrides: Partial<OrchestrationThreadShell> = {}, | ||
| 22 | ): Pick< | ||
| 23 | OrchestrationThreadShell, | ||
| 24 | | "id" | ||
| 25 | | "title" | ||
| 26 | | "modelSelection" | ||
| 27 | | "session" | ||
| 28 | | "latestTurn" | ||
| 29 | | "updatedAt" | ||
| 30 | | "hasPendingApprovals" | ||
| 31 | | "hasPendingUserInput" | ||
| 32 | > { | ||
| 33 | return { | ||
| 34 | id: "thread-1" as ThreadId, | ||
| 35 | title: "Fix failing CI", | ||
| 36 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, | ||
| 37 | session: null, | ||
| 38 | latestTurn: null, | ||
| 39 | updatedAt: NOW, | ||
| 40 | hasPendingApprovals: false, | ||
| 41 | hasPendingUserInput: false, | ||
| 42 | ...overrides, | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 46 | describe("projectThreadAwareness", () => { | ||
| 47 | it("returns null for idle threads without an active awareness state", () => { | ||
| 48 | expect( | ||
| 49 | projectThreadAwareness({ | ||
| 50 | environmentId: "env-1" as EnvironmentId, | ||
| 51 | project, | ||
| 52 | thread: thread(), | ||
| 53 | }), | ||
| 54 | ).toBeNull(); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("prioritizes approval requests over running state", () => { | ||
| 58 | const state = projectThreadAwareness({ | ||
| 59 | environmentId: "env-1" as EnvironmentId, | ||
| 60 | project, | ||
| 61 | thread: thread({ | ||
| 62 | hasPendingApprovals: true, | ||
| 63 | session: { | ||
| 64 | threadId: "thread-1" as ThreadId, | ||
| 65 | status: "running", | ||
| 66 | providerName: "Codex", | ||
| 67 | runtimeMode: "full-access", | ||
| 68 | activeTurnId: "turn-1" as TurnId, | ||
| 69 | lastError: null, | ||
| 70 | updatedAt: NOW, | ||
| 71 | }, | ||
| 72 | }), | ||
| 73 | }); | ||
| 74 | |||
| 75 | expect(state?.phase).toBe("waiting_for_approval"); | ||
| 76 | expect(state?.headline).toBe("Approval needed"); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it("projects running provider sessions", () => { | ||
| 80 | const state = projectThreadAwareness({ | ||
| 81 | environmentId: "env-1" as EnvironmentId, | ||
| 82 | project, | ||
| 83 | thread: thread({ | ||
| 84 | session: { | ||
| 85 | threadId: "thread-1" as ThreadId, | ||
| 86 | status: "running", | ||
| 87 | providerName: "Codex", | ||
| 88 | runtimeMode: "full-access", | ||
| 89 | activeTurnId: "turn-1" as TurnId, | ||
| 90 | lastError: null, | ||
| 91 | updatedAt: NOW, | ||
| 92 | }, | ||
| 93 | }), | ||
| 94 | }); | ||
| 95 | |||
| 96 | expect(state).toMatchObject({ | ||
| 97 | phase: "running", | ||
| 98 | headline: "Agent is working", | ||
| 99 | detail: "Codex is active.", | ||
| 100 | modelTitle: "gpt-5.4", | ||
| 101 | deepLink: "/threads/env-1/thread-1", | ||
| 102 | }); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("projects failures with the session error detail", () => { | ||
| 106 | const state = projectThreadAwareness({ | ||
| 107 | environmentId: "env-1" as EnvironmentId, | ||
| 108 | project, | ||
| 109 | thread: thread({ | ||
| 110 | session: { | ||
| 111 | threadId: "thread-1" as ThreadId, | ||
| 112 | status: "error", | ||
| 113 | providerName: "Codex", | ||
| 114 | runtimeMode: "full-access", | ||
| 115 | activeTurnId: null, | ||
| 116 | lastError: "Provider process exited.", | ||
| 117 | updatedAt: NOW, | ||
| 118 | }, | ||
| 119 | }), | ||
| 120 | }); | ||
| 121 | |||
| 122 | expect(state).toMatchObject({ | ||
| 123 | phase: "failed", | ||
| 124 | headline: "Agent failed", | ||
| 125 | detail: "Provider process exited.", | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | }); | ||
packages/shared/src/agentAwareness.ts created+142| ... | @@ -0,0 +1,142 @@ | ||
| 1 | import type { | ||
| 2 | EnvironmentId, | ||
| 3 | OrchestrationProjectShell, | ||
| 4 | OrchestrationThreadShell, | ||
| 5 | ThreadId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | export type AgentAwarenessPhase = | ||
| 9 | | "starting" | ||
| 10 | | "running" | ||
| 11 | | "waiting_for_approval" | ||
| 12 | | "waiting_for_input" | ||
| 13 | | "completed" | ||
| 14 | | "failed" | ||
| 15 | | "stale"; | ||
| 16 | |||
| 17 | export interface AgentAwarenessState { | ||
| 18 | readonly environmentId: EnvironmentId; | ||
| 19 | readonly threadId: ThreadId; | ||
| 20 | readonly projectTitle: string; | ||
| 21 | readonly threadTitle: string; | ||
| 22 | readonly phase: AgentAwarenessPhase; | ||
| 23 | readonly headline: string; | ||
| 24 | readonly detail?: string; | ||
| 25 | readonly modelTitle: string; | ||
| 26 | readonly updatedAt: string; | ||
| 27 | readonly deepLink: string; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface ProjectThreadAwarenessInput { | ||
| 31 | readonly environmentId: EnvironmentId; | ||
| 32 | readonly project: Pick<OrchestrationProjectShell, "title">; | ||
| 33 | readonly thread: Pick< | ||
| 34 | OrchestrationThreadShell, | ||
| 35 | | "id" | ||
| 36 | | "title" | ||
| 37 | | "modelSelection" | ||
| 38 | | "session" | ||
| 39 | | "latestTurn" | ||
| 40 | | "updatedAt" | ||
| 41 | | "hasPendingApprovals" | ||
| 42 | | "hasPendingUserInput" | ||
| 43 | >; | ||
| 44 | } | ||
| 45 | |||
| 46 | export function buildAgentAwarenessDeepLink(input: { | ||
| 47 | readonly environmentId: EnvironmentId; | ||
| 48 | readonly threadId: ThreadId; | ||
| 49 | }): string { | ||
| 50 | return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`; | ||
| 51 | } | ||
| 52 | |||
| 53 | export function isTerminalAgentAwarenessPhase(phase: AgentAwarenessPhase): boolean { | ||
| 54 | return phase === "completed" || phase === "failed"; | ||
| 55 | } | ||
| 56 | |||
| 57 | export function isInterruptiveAgentAwarenessPhase(phase: AgentAwarenessPhase): boolean { | ||
| 58 | return phase === "waiting_for_approval" || phase === "waiting_for_input" || phase === "failed"; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function projectThreadAwareness( | ||
| 62 | input: ProjectThreadAwarenessInput, | ||
| 63 | ): AgentAwarenessState | null { | ||
| 64 | const { environmentId, project, thread } = input; | ||
| 65 | const phase = resolveThreadAwarenessPhase(thread); | ||
| 66 | if (!phase) { | ||
| 67 | return null; | ||
| 68 | } | ||
| 69 | |||
| 70 | const detail = detailForPhase(phase, thread); | ||
| 71 | return { | ||
| 72 | environmentId, | ||
| 73 | threadId: thread.id, | ||
| 74 | projectTitle: project.title, | ||
| 75 | threadTitle: thread.title, | ||
| 76 | phase, | ||
| 77 | headline: headlineForPhase(phase), | ||
| 78 | ...(detail === undefined ? {} : { detail }), | ||
| 79 | modelTitle: thread.modelSelection.model, | ||
| 80 | updatedAt: thread.updatedAt, | ||
| 81 | deepLink: buildAgentAwarenessDeepLink({ environmentId, threadId: thread.id }), | ||
| 82 | }; | ||
| 83 | } | ||
| 84 | |||
| 85 | function resolveThreadAwarenessPhase( | ||
| 86 | thread: ProjectThreadAwarenessInput["thread"], | ||
| 87 | ): AgentAwarenessPhase | null { | ||
| 88 | if (thread.hasPendingApprovals) { | ||
| 89 | return "waiting_for_approval"; | ||
| 90 | } | ||
| 91 | if (thread.hasPendingUserInput) { | ||
| 92 | return "waiting_for_input"; | ||
| 93 | } | ||
| 94 | if (thread.session?.status === "error" || thread.latestTurn?.state === "error") { | ||
| 95 | return "failed"; | ||
| 96 | } | ||
| 97 | if (thread.session?.status === "starting") { | ||
| 98 | return "starting"; | ||
| 99 | } | ||
| 100 | if (thread.session?.status === "running" || thread.latestTurn?.state === "running") { | ||
| 101 | return "running"; | ||
| 102 | } | ||
| 103 | if (thread.latestTurn?.state === "completed") { | ||
| 104 | return "completed"; | ||
| 105 | } | ||
| 106 | return null; | ||
| 107 | } | ||
| 108 | |||
| 109 | function headlineForPhase(phase: AgentAwarenessPhase): string { | ||
| 110 | switch (phase) { | ||
| 111 | case "starting": | ||
| 112 | return "Starting agent"; | ||
| 113 | case "running": | ||
| 114 | return "Agent is working"; | ||
| 115 | case "waiting_for_approval": | ||
| 116 | return "Approval needed"; | ||
| 117 | case "waiting_for_input": | ||
| 118 | return "Waiting for input"; | ||
| 119 | case "completed": | ||
| 120 | return "Agent finished"; | ||
| 121 | case "failed": | ||
| 122 | return "Agent failed"; | ||
| 123 | case "stale": | ||
| 124 | return "Update delayed"; | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | function detailForPhase( | ||
| 129 | phase: AgentAwarenessPhase, | ||
| 130 | thread: ProjectThreadAwarenessInput["thread"], | ||
| 131 | ): string | undefined { | ||
| 132 | if (phase === "failed") { | ||
| 133 | return thread.session?.lastError ?? undefined; | ||
| 134 | } | ||
| 135 | if (phase === "completed") { | ||
| 136 | return "Review the completed task."; | ||
| 137 | } | ||
| 138 | if (phase === "running" && thread.session?.providerName) { | ||
| 139 | return `${thread.session.providerName} is active.`; | ||
| 140 | } | ||
| 141 | return undefined; | ||
| 142 | } | ||
packages/shared/src/dpop.test.ts created+205| ... | @@ -0,0 +1,205 @@ | ||
| 1 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | |||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | computeDpopAccessTokenHash, | ||
| 7 | computeDpopJwkThumbprint, | ||
| 8 | normalizeDpopHtu, | ||
| 9 | type DpopPublicJwk, | ||
| 10 | verifyDpopProof, | ||
| 11 | } from "./dpop.ts"; | ||
| 12 | |||
| 13 | function signDpopProof(input: { | ||
| 14 | readonly method: string; | ||
| 15 | readonly url: string; | ||
| 16 | readonly iat: number; | ||
| 17 | readonly privateKey: NodeCrypto.KeyObject; | ||
| 18 | readonly publicJwk: DpopPublicJwk | (DpopPublicJwk & { readonly d: string }); | ||
| 19 | readonly accessToken?: string; | ||
| 20 | }) { | ||
| 21 | const header = Buffer.from( | ||
| 22 | JSON.stringify({ | ||
| 23 | typ: "dpop+jwt", | ||
| 24 | alg: "ES256", | ||
| 25 | jwk: input.publicJwk, | ||
| 26 | }), | ||
| 27 | ).toString("base64url"); | ||
| 28 | const payload = Buffer.from( | ||
| 29 | JSON.stringify({ | ||
| 30 | htm: input.method, | ||
| 31 | htu: input.url, | ||
| 32 | jti: "proof-1", | ||
| 33 | iat: input.iat, | ||
| 34 | ...(input.accessToken ? { ath: computeDpopAccessTokenHash(input.accessToken) } : {}), | ||
| 35 | }), | ||
| 36 | ).toString("base64url"); | ||
| 37 | const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { | ||
| 38 | key: input.privateKey, | ||
| 39 | dsaEncoding: "ieee-p1363", | ||
| 40 | }).toString("base64url"); | ||
| 41 | return `${header}.${payload}.${signature}`; | ||
| 42 | } | ||
| 43 | |||
| 44 | describe("verifyDpopProof", () => { | ||
| 45 | const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { | ||
| 46 | namedCurve: "P-256", | ||
| 47 | }); | ||
| 48 | const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk; | ||
| 49 | const proof = signDpopProof({ | ||
| 50 | method: "POST", | ||
| 51 | url: "https://example.com/oauth/token", | ||
| 52 | iat: 100, | ||
| 53 | privateKey, | ||
| 54 | publicJwk, | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => { | ||
| 58 | const thumbprint = computeDpopJwkThumbprint(publicJwk); | ||
| 59 | expect( | ||
| 60 | verifyDpopProof({ | ||
| 61 | proof, | ||
| 62 | method: "POST", | ||
| 63 | url: "https://example.com/oauth/token", | ||
| 64 | nowEpochSeconds: 101, | ||
| 65 | expectedThumbprint: thumbprint, | ||
| 66 | }), | ||
| 67 | ).toMatchObject({ | ||
| 68 | ok: true, | ||
| 69 | thumbprint, | ||
| 70 | jti: "proof-1", | ||
| 71 | }); | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("rejects method, URL, thumbprint, and time-window mismatches", () => { | ||
| 75 | const thumbprint = computeDpopJwkThumbprint(publicJwk); | ||
| 76 | expect( | ||
| 77 | verifyDpopProof({ | ||
| 78 | proof, | ||
| 79 | method: "GET", | ||
| 80 | url: "https://example.com/oauth/token", | ||
| 81 | nowEpochSeconds: 101, | ||
| 82 | expectedThumbprint: thumbprint, | ||
| 83 | }), | ||
| 84 | ).toMatchObject({ ok: false }); | ||
| 85 | expect( | ||
| 86 | verifyDpopProof({ | ||
| 87 | proof, | ||
| 88 | method: "POST", | ||
| 89 | url: "https://example.com/other", | ||
| 90 | nowEpochSeconds: 101, | ||
| 91 | expectedThumbprint: thumbprint, | ||
| 92 | }), | ||
| 93 | ).toMatchObject({ ok: false }); | ||
| 94 | expect( | ||
| 95 | verifyDpopProof({ | ||
| 96 | proof, | ||
| 97 | method: "POST", | ||
| 98 | url: "https://example.com/oauth/token", | ||
| 99 | nowEpochSeconds: 101, | ||
| 100 | expectedThumbprint: "other-thumbprint", | ||
| 101 | }), | ||
| 102 | ).toMatchObject({ ok: false }); | ||
| 103 | expect( | ||
| 104 | verifyDpopProof({ | ||
| 105 | proof, | ||
| 106 | method: "POST", | ||
| 107 | url: "https://example.com/oauth/token", | ||
| 108 | nowEpochSeconds: 1_000, | ||
| 109 | expectedThumbprint: thumbprint, | ||
| 110 | }), | ||
| 111 | ).toMatchObject({ ok: false }); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("requires the RFC 9449 access token hash when an access token is expected", () => { | ||
| 115 | const thumbprint = computeDpopJwkThumbprint(publicJwk); | ||
| 116 | const accessTokenProof = signDpopProof({ | ||
| 117 | method: "POST", | ||
| 118 | url: "https://example.com/v1/environments/env/connect", | ||
| 119 | iat: 100, | ||
| 120 | privateKey, | ||
| 121 | publicJwk, | ||
| 122 | accessToken: "clerk-access-token", | ||
| 123 | }); | ||
| 124 | |||
| 125 | expect( | ||
| 126 | verifyDpopProof({ | ||
| 127 | proof: accessTokenProof, | ||
| 128 | method: "POST", | ||
| 129 | url: "https://example.com/v1/environments/env/connect", | ||
| 130 | nowEpochSeconds: 101, | ||
| 131 | expectedThumbprint: thumbprint, | ||
| 132 | expectedAccessToken: "clerk-access-token", | ||
| 133 | }), | ||
| 134 | ).toMatchObject({ ok: true }); | ||
| 135 | expect( | ||
| 136 | verifyDpopProof({ | ||
| 137 | proof, | ||
| 138 | method: "POST", | ||
| 139 | url: "https://example.com/oauth/token", | ||
| 140 | nowEpochSeconds: 101, | ||
| 141 | expectedThumbprint: thumbprint, | ||
| 142 | expectedAccessToken: "clerk-access-token", | ||
| 143 | }), | ||
| 144 | ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); | ||
| 145 | expect( | ||
| 146 | verifyDpopProof({ | ||
| 147 | proof: accessTokenProof, | ||
| 148 | method: "POST", | ||
| 149 | url: "https://example.com/v1/environments/env/connect", | ||
| 150 | nowEpochSeconds: 101, | ||
| 151 | expectedThumbprint: thumbprint, | ||
| 152 | expectedAccessToken: "other-access-token", | ||
| 153 | }), | ||
| 154 | ).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." }); | ||
| 155 | }); | ||
| 156 | |||
| 157 | it("normalizes htu by excluding query and fragment components per RFC 9449", () => { | ||
| 158 | expect(normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag")).toBe( | ||
| 159 | "https://example.com/v1/environments/env/connect", | ||
| 160 | ); | ||
| 161 | |||
| 162 | const thumbprint = computeDpopJwkThumbprint(publicJwk); | ||
| 163 | const queryProof = signDpopProof({ | ||
| 164 | method: "POST", | ||
| 165 | url: "https://example.com/v1/environments/env/connect", | ||
| 166 | iat: 100, | ||
| 167 | privateKey, | ||
| 168 | publicJwk, | ||
| 169 | }); | ||
| 170 | |||
| 171 | expect( | ||
| 172 | verifyDpopProof({ | ||
| 173 | proof: queryProof, | ||
| 174 | method: "POST", | ||
| 175 | url: "https://example.com/v1/environments/env/connect?foo=bar#frag", | ||
| 176 | nowEpochSeconds: 101, | ||
| 177 | expectedThumbprint: thumbprint, | ||
| 178 | }), | ||
| 179 | ).toMatchObject({ ok: true }); | ||
| 180 | }); | ||
| 181 | |||
| 182 | it("rejects DPoP public JWK headers that expose private key material", () => { | ||
| 183 | const thumbprint = computeDpopJwkThumbprint(publicJwk); | ||
| 184 | const privateJwk = privateKey.export({ format: "jwk" }) as DpopPublicJwk & { | ||
| 185 | readonly d: string; | ||
| 186 | }; | ||
| 187 | const proofWithPrivateJwk = signDpopProof({ | ||
| 188 | method: "POST", | ||
| 189 | url: "https://example.com/oauth/token", | ||
| 190 | iat: 100, | ||
| 191 | privateKey, | ||
| 192 | publicJwk: privateJwk, | ||
| 193 | }); | ||
| 194 | |||
| 195 | expect( | ||
| 196 | verifyDpopProof({ | ||
| 197 | proof: proofWithPrivateJwk, | ||
| 198 | method: "POST", | ||
| 199 | url: "https://example.com/oauth/token", | ||
| 200 | nowEpochSeconds: 101, | ||
| 201 | expectedThumbprint: thumbprint, | ||
| 202 | }), | ||
| 203 | ).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." }); | ||
| 204 | }); | ||
| 205 | }); | ||
packages/shared/src/dpop.ts created+200| ... | @@ -0,0 +1,200 @@ | ||
| 1 | import { p256 } from "@noble/curves/nist"; | ||
| 2 | import { sha256 } from "@noble/hashes/sha2"; | ||
| 3 | import * as Encoding from "effect/Encoding"; | ||
| 4 | import * as Result from "effect/Result"; | ||
| 5 | import * as Schema from "effect/Schema"; | ||
| 6 | |||
| 7 | import { stableStringify } from "./relaySigning.ts"; | ||
| 8 | |||
| 9 | const DPOP_TYP = "dpop+jwt"; | ||
| 10 | const DPOP_ALG = "ES256"; | ||
| 11 | const DEFAULT_MAX_AGE_SECONDS = 300; | ||
| 12 | |||
| 13 | export const DpopPublicJwk = Schema.Struct({ | ||
| 14 | kty: Schema.Literal("EC"), | ||
| 15 | crv: Schema.Literal("P-256"), | ||
| 16 | x: Schema.String.check(Schema.isNonEmpty()), | ||
| 17 | y: Schema.String.check(Schema.isNonEmpty()), | ||
| 18 | }); | ||
| 19 | export type DpopPublicJwk = typeof DpopPublicJwk.Type; | ||
| 20 | const isDpopPublicJwk = Schema.is(DpopPublicJwk); | ||
| 21 | |||
| 22 | interface DpopJwtHeader { | ||
| 23 | readonly typ: string; | ||
| 24 | readonly alg: string; | ||
| 25 | readonly jwk: DpopPublicJwk; | ||
| 26 | } | ||
| 27 | |||
| 28 | interface DpopJwtPayload { | ||
| 29 | readonly htm: string; | ||
| 30 | readonly htu: string; | ||
| 31 | readonly jti: string; | ||
| 32 | readonly iat: number; | ||
| 33 | readonly ath?: string; | ||
| 34 | } | ||
| 35 | |||
| 36 | export type DpopVerificationResult = | ||
| 37 | | { | ||
| 38 | readonly ok: true; | ||
| 39 | readonly thumbprint: string; | ||
| 40 | readonly jti: string; | ||
| 41 | readonly iat: number; | ||
| 42 | } | ||
| 43 | | { | ||
| 44 | readonly ok: false; | ||
| 45 | readonly reason: string; | ||
| 46 | }; | ||
| 47 | |||
| 48 | function base64UrlToBytes(value: string): Uint8Array { | ||
| 49 | return Result.getOrThrow(Encoding.decodeBase64Url(value)); | ||
| 50 | } | ||
| 51 | |||
| 52 | function decodeBase64UrlJson(value: string): unknown { | ||
| 53 | return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown; | ||
| 54 | } | ||
| 55 | |||
| 56 | function isDpopJwtHeader(value: unknown): value is DpopJwtHeader { | ||
| 57 | if (typeof value !== "object" || value === null) { | ||
| 58 | return false; | ||
| 59 | } | ||
| 60 | const record = value as Record<string, unknown>; | ||
| 61 | return ( | ||
| 62 | record.typ === DPOP_TYP && | ||
| 63 | record.alg === DPOP_ALG && | ||
| 64 | typeof record.jwk === "object" && | ||
| 65 | record.jwk !== null && | ||
| 66 | !("d" in record.jwk) && | ||
| 67 | isDpopPublicJwk(record.jwk) | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | |||
| 71 | function isDpopJwtPayload(value: unknown): value is DpopJwtPayload { | ||
| 72 | if (typeof value !== "object" || value === null) { | ||
| 73 | return false; | ||
| 74 | } | ||
| 75 | const record = value as Record<string, unknown>; | ||
| 76 | return ( | ||
| 77 | typeof record.htm === "string" && | ||
| 78 | record.htm.length > 0 && | ||
| 79 | typeof record.htu === "string" && | ||
| 80 | record.htu.length > 0 && | ||
| 81 | typeof record.jti === "string" && | ||
| 82 | record.jti.length > 0 && | ||
| 83 | typeof record.iat === "number" && | ||
| 84 | Number.isInteger(record.iat) | ||
| 85 | ); | ||
| 86 | } | ||
| 87 | |||
| 88 | function dpopThumbprintInput(jwk: DpopPublicJwk): string { | ||
| 89 | return stableStringify({ | ||
| 90 | crv: jwk.crv, | ||
| 91 | kty: jwk.kty, | ||
| 92 | x: jwk.x, | ||
| 93 | y: jwk.y, | ||
| 94 | }); | ||
| 95 | } | ||
| 96 | |||
| 97 | export function normalizeDpopHtu(url: string): string | null { | ||
| 98 | try { | ||
| 99 | const parsed = new URL(url); | ||
| 100 | parsed.hash = ""; | ||
| 101 | parsed.search = ""; | ||
| 102 | return parsed.toString(); | ||
| 103 | } catch { | ||
| 104 | return null; | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | export function computeDpopJwkThumbprint(jwk: DpopPublicJwk): string { | ||
| 109 | return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); | ||
| 110 | } | ||
| 111 | |||
| 112 | export function computeDpopAccessTokenHash(accessToken: string): string { | ||
| 113 | return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken))); | ||
| 114 | } | ||
| 115 | |||
| 116 | function publicKeyBytesFromJwk(jwk: DpopPublicJwk): Uint8Array { | ||
| 117 | const x = base64UrlToBytes(jwk.x); | ||
| 118 | const y = base64UrlToBytes(jwk.y); | ||
| 119 | if (x.length !== 32 || y.length !== 32) { | ||
| 120 | throw new Error("Invalid P-256 public key coordinate length."); | ||
| 121 | } | ||
| 122 | const publicKey = new Uint8Array(65); | ||
| 123 | publicKey[0] = 0x04; | ||
| 124 | publicKey.set(x, 1); | ||
| 125 | publicKey.set(y, 33); | ||
| 126 | return publicKey; | ||
| 127 | } | ||
| 128 | |||
| 129 | export function verifyDpopProof(input: { | ||
| 130 | readonly proof: string | null | undefined; | ||
| 131 | readonly method: string; | ||
| 132 | readonly url: string; | ||
| 133 | readonly nowEpochSeconds: number; | ||
| 134 | readonly expectedThumbprint?: string; | ||
| 135 | readonly expectedAccessToken?: string; | ||
| 136 | readonly maxAgeSeconds?: number; | ||
| 137 | }): DpopVerificationResult { | ||
| 138 | if (!input.proof?.trim()) { | ||
| 139 | return { ok: false, reason: "Missing DPoP proof." }; | ||
| 140 | } | ||
| 141 | |||
| 142 | const parts = input.proof.split("."); | ||
| 143 | if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) { | ||
| 144 | return { ok: false, reason: "Invalid DPoP compact JWT." }; | ||
| 145 | } | ||
| 146 | |||
| 147 | try { | ||
| 148 | const header = decodeBase64UrlJson(parts[0]); | ||
| 149 | const payload = decodeBase64UrlJson(parts[1]); | ||
| 150 | if (!isDpopJwtHeader(header)) { | ||
| 151 | return { ok: false, reason: "Invalid DPoP JWT header." }; | ||
| 152 | } | ||
| 153 | if (!isDpopJwtPayload(payload)) { | ||
| 154 | return { ok: false, reason: "Invalid DPoP JWT payload." }; | ||
| 155 | } | ||
| 156 | |||
| 157 | const thumbprint = computeDpopJwkThumbprint(header.jwk); | ||
| 158 | if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) { | ||
| 159 | return { ok: false, reason: "DPoP key thumbprint mismatch." }; | ||
| 160 | } | ||
| 161 | if (payload.htm.toUpperCase() !== input.method.toUpperCase()) { | ||
| 162 | return { ok: false, reason: "DPoP method mismatch." }; | ||
| 163 | } | ||
| 164 | const normalizedHtu = normalizeDpopHtu(input.url); | ||
| 165 | if (normalizedHtu === null || payload.htu !== normalizedHtu) { | ||
| 166 | return { ok: false, reason: "DPoP URL mismatch." }; | ||
| 167 | } | ||
| 168 | if (input.expectedAccessToken) { | ||
| 169 | const expectedAth = computeDpopAccessTokenHash(input.expectedAccessToken); | ||
| 170 | if (payload.ath !== expectedAth) { | ||
| 171 | return { ok: false, reason: "DPoP access token hash mismatch." }; | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | const maxAgeSeconds = input.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS; | ||
| 176 | if ( | ||
| 177 | payload.iat > input.nowEpochSeconds + 5 || | ||
| 178 | input.nowEpochSeconds - payload.iat > maxAgeSeconds | ||
| 179 | ) { | ||
| 180 | return { ok: false, reason: "DPoP proof is outside the allowed time window." }; | ||
| 181 | } | ||
| 182 | |||
| 183 | const signature = base64UrlToBytes(parts[2]); | ||
| 184 | const signatureInputHash = sha256(new TextEncoder().encode(`${parts[0]}.${parts[1]}`)); | ||
| 185 | const verified = p256.verify(signature, signatureInputHash, publicKeyBytesFromJwk(header.jwk), { | ||
| 186 | prehash: false, | ||
| 187 | format: "compact", | ||
| 188 | }); | ||
| 189 | return verified | ||
| 190 | ? { | ||
| 191 | ok: true, | ||
| 192 | thumbprint, | ||
| 193 | jti: payload.jti, | ||
| 194 | iat: payload.iat, | ||
| 195 | } | ||
| 196 | : { ok: false, reason: "Invalid DPoP signature." }; | ||
| 197 | } catch { | ||
| 198 | return { ok: false, reason: "Invalid DPoP proof." }; | ||
| 199 | } | ||
| 200 | } | ||
packages/shared/src/dpopCommon.ts created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | export const DpopPublicJwk = Schema.Struct({ | ||
| 4 | kty: Schema.Literal("EC"), | ||
| 5 | crv: Schema.Literal("P-256"), | ||
| 6 | x: Schema.String.check(Schema.isNonEmpty()), | ||
| 7 | y: Schema.String.check(Schema.isNonEmpty()), | ||
| 8 | }); | ||
| 9 | export type DpopPublicJwk = typeof DpopPublicJwk.Type; | ||
| 10 | |||
| 11 | export function normalizeDpopHtu(url: string): string | null { | ||
| 12 | try { | ||
| 13 | const parsed = new URL(url); | ||
| 14 | parsed.hash = ""; | ||
| 15 | parsed.search = ""; | ||
| 16 | return parsed.toString(); | ||
| 17 | } catch { | ||
| 18 | return null; | ||
| 19 | } | ||
| 20 | } | ||
packages/shared/src/oauthScope.ts+11-1| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u; | 1 | const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u; |
| 2 | 2 | ||
| 3 | /** | 3 | /** |
| 4 | * Decodes an RFC 6749 `scope` value as a set while preserving first-seen order. | 4 | * Decodes an RFC 6749 `scope` value as a set while preserving its first-seen |
| 5 | * order for canonical responses and logs. | ||
| 5 | */ | 6 | */ |
| 6 | export function parseOAuthScope(value: string): ReadonlyArray<string> | null { | 7 | export function parseOAuthScope(value: string): ReadonlyArray<string> | null { |
| 7 | if (value.length === 0) { | 8 | if (value.length === 0) { |
| ... | @@ -25,6 +26,15 @@ export function encodeOAuthScope(scopes: ReadonlyArray<string>): string { | ... | @@ -25,6 +26,15 @@ export function encodeOAuthScope(scopes: ReadonlyArray<string>): string { |
| 25 | return encoded; | 26 | return encoded; |
| 26 | } | 27 | } |
| 27 | 28 | ||
| 29 | export function oauthScopeSetEquals(value: string, expectedScopes: ReadonlyArray<string>): boolean { | ||
| 30 | const scopes = parseOAuthScope(value); | ||
| 31 | return ( | ||
| 32 | scopes !== null && | ||
| 33 | scopes.length === new Set(expectedScopes).size && | ||
| 34 | scopes.every((scope) => expectedScopes.includes(scope)) | ||
| 35 | ); | ||
| 36 | } | ||
| 37 | |||
| 28 | export function parseAllowedOAuthScope<Scope extends string>(input: { | 38 | export function parseAllowedOAuthScope<Scope extends string>(input: { |
| 29 | readonly value: string; | 39 | readonly value: string; |
| 30 | readonly allowedScopes: ReadonlySet<Scope>; | 40 | readonly allowedScopes: ReadonlySet<Scope>; |
packages/shared/src/relayAuth.test.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | clerkFrontendApiHostnameFromPublishableKey, | ||
| 5 | isAllowedClerkFrontendApiHostname, | ||
| 6 | } from "./relayAuth.ts"; | ||
| 7 | |||
| 8 | const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`; | ||
| 9 | |||
| 10 | describe("Clerk relay auth", () => { | ||
| 11 | it("derives a custom Frontend API hostname from a Clerk publishable key", () => { | ||
| 12 | expect(clerkFrontendApiHostnameFromPublishableKey(clerkPublishableKey("clerk.t3.codes"))).toBe( | ||
| 13 | "clerk.t3.codes", | ||
| 14 | ); | ||
| 15 | }); | ||
| 16 | |||
| 17 | it("allows standard Clerk hosts and an exact configured custom hostname", () => { | ||
| 18 | expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.dev", null)).toBe(true); | ||
| 19 | expect(isAllowedClerkFrontendApiHostname("example.clerk.accounts.com", null)).toBe(true); | ||
| 20 | expect(isAllowedClerkFrontendApiHostname("clerk.t3.codes", "clerk.t3.codes")).toBe(true); | ||
| 21 | expect(isAllowedClerkFrontendApiHostname("attacker.example", "clerk.t3.codes")).toBe(false); | ||
| 22 | expect(isAllowedClerkFrontendApiHostname("nested.clerk.t3.codes", "clerk.t3.codes")).toBe( | ||
| 23 | false, | ||
| 24 | ); | ||
| 25 | }); | ||
| 26 | }); | ||
packages/shared/src/relayAuth.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | export function clerkFrontendApiUrlFromPublishableKey(publishableKey: string): string { | ||
| 2 | const encodedFrontendApi = publishableKey.split("_").slice(2).join("_"); | ||
| 3 | const frontendApi = globalThis.atob(encodedFrontendApi).replace(/\$$/u, ""); | ||
| 4 | if (frontendApi.length === 0 || frontendApi.includes("/")) { | ||
| 5 | throw new Error("Invalid Clerk publishable key."); | ||
| 6 | } | ||
| 7 | return `https://${frontendApi}`; | ||
| 8 | } | ||
| 9 | |||
| 10 | export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: string): string { | ||
| 11 | return new URL(clerkFrontendApiUrlFromPublishableKey(publishableKey)).hostname; | ||
| 12 | } | ||
| 13 | |||
| 14 | export function isAllowedClerkFrontendApiHostname( | ||
| 15 | hostname: string, | ||
| 16 | configuredHostname: string | null, | ||
| 17 | ): boolean { | ||
| 18 | return ( | ||
| 19 | hostname.endsWith(".clerk.accounts.dev") || | ||
| 20 | hostname.endsWith(".clerk.accounts.com") || | ||
| 21 | hostname === configuredHostname | ||
| 22 | ); | ||
| 23 | } | ||
| 24 | |||
| 25 | export function relayClerkTokenOptions(template: string) { | ||
| 26 | return { | ||
| 27 | template, | ||
| 28 | skipCache: true, | ||
| 29 | } as const; | ||
| 30 | } | ||
packages/shared/src/relayClient.test.ts created+273| ... | @@ -0,0 +1,273 @@ | ||
| 1 | import { sha256 } from "@noble/hashes/sha2"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Encoding from "effect/Encoding"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Sink from "effect/Sink"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | RelayClientInstallError, | ||
| 16 | CLOUDFLARED_VERSION, | ||
| 17 | makeCloudflaredRelayClient, | ||
| 18 | resolveManagedCloudflaredPath, | ||
| 19 | } from "./relayClient.ts"; | ||
| 20 | |||
| 21 | const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} }); | ||
| 22 | |||
| 23 | function makeHandle(exitCode = 0) { | ||
| 24 | return ChildProcessSpawner.makeHandle({ | ||
| 25 | pid: ChildProcessSpawner.ProcessId(100), | ||
| 26 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), | ||
| 27 | isRunning: Effect.succeed(false), | ||
| 28 | kill: () => Effect.void, | ||
| 29 | unref: Effect.succeed(Effect.void), | ||
| 30 | stdin: Sink.drain, | ||
| 31 | stdout: Stream.empty, | ||
| 32 | stderr: Stream.empty, | ||
| 33 | all: Stream.empty, | ||
| 34 | getInputFd: () => Sink.drain, | ||
| 35 | getOutputFd: () => Stream.empty, | ||
| 36 | }); | ||
| 37 | } | ||
| 38 | |||
| 39 | const makeHttpClientLayer = (bytes: Uint8Array) => | ||
| 40 | Layer.succeed( | ||
| 41 | HttpClient.HttpClient, | ||
| 42 | HttpClient.make((request) => | ||
| 43 | Effect.succeed( | ||
| 44 | HttpClientResponse.fromWeb(request, new Response(bytes.buffer as ArrayBuffer)), | ||
| 45 | ), | ||
| 46 | ), | ||
| 47 | ); | ||
| 48 | |||
| 49 | const makeSpawnerLayer = (commands: Array<string>) => | ||
| 50 | Layer.succeed( | ||
| 51 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 52 | ChildProcessSpawner.make((command) => | ||
| 53 | Effect.sync(() => { | ||
| 54 | commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command"); | ||
| 55 | return makeHandle(); | ||
| 56 | }), | ||
| 57 | ), | ||
| 58 | ); | ||
| 59 | |||
| 60 | describe("RelayClient", () => { | ||
| 61 | it.effect("resolves explicit overrides before managed and PATH executables", () => | ||
| 62 | Effect.gen(function* () { | ||
| 63 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 64 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 65 | prefix: "t3-cloudflared-test-", | ||
| 66 | }); | ||
| 67 | const overridePath = `${baseDir}/override-cloudflared`; | ||
| 68 | yield* fileSystem.writeFileString(overridePath, "override"); | ||
| 69 | yield* fileSystem.chmod(overridePath, 0o755); | ||
| 70 | const manager = yield* makeCloudflaredRelayClient({ | ||
| 71 | baseDir, | ||
| 72 | platform: "linux", | ||
| 73 | arch: "x64", | ||
| 74 | configProvider: () => | ||
| 75 | ConfigProvider.fromEnv({ | ||
| 76 | env: { | ||
| 77 | PATH: "", | ||
| 78 | T3CODE_CLOUDFLARED_PATH: overridePath, | ||
| 79 | }, | ||
| 80 | }), | ||
| 81 | }); | ||
| 82 | |||
| 83 | expect(yield* manager.resolve).toEqual({ | ||
| 84 | status: "available", | ||
| 85 | executablePath: overridePath, | ||
| 86 | source: "override", | ||
| 87 | version: CLOUDFLARED_VERSION, | ||
| 88 | }); | ||
| 89 | }).pipe( | ||
| 90 | Effect.scoped, | ||
| 91 | Effect.provide( | ||
| 92 | Layer.mergeAll( | ||
| 93 | NodeServices.layer, | ||
| 94 | makeHttpClientLayer(new Uint8Array()), | ||
| 95 | makeSpawnerLayer([]), | ||
| 96 | ), | ||
| 97 | ), | ||
| 98 | ), | ||
| 99 | ); | ||
| 100 | |||
| 101 | it.effect("downloads, verifies, validates, and atomically installs the managed executable", () => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 104 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 105 | prefix: "t3-cloudflared-test-", | ||
| 106 | }); | ||
| 107 | const bytes = new TextEncoder().encode("test-cloudflared-binary"); | ||
| 108 | const manager = yield* makeCloudflaredRelayClient({ | ||
| 109 | baseDir, | ||
| 110 | platform: "linux", | ||
| 111 | arch: "x64", | ||
| 112 | releaseAsset: { | ||
| 113 | url: "https://example.test/cloudflared", | ||
| 114 | sha256: Encoding.encodeHex(sha256(bytes)), | ||
| 115 | archive: "binary", | ||
| 116 | }, | ||
| 117 | configProvider: emptyConfigProvider, | ||
| 118 | }); | ||
| 119 | |||
| 120 | const progress: Array<string> = []; | ||
| 121 | const installed = yield* manager.installWithProgress((event) => | ||
| 122 | Effect.sync(() => { | ||
| 123 | if (event.type === "progress") { | ||
| 124 | progress.push(event.stage); | ||
| 125 | } | ||
| 126 | }), | ||
| 127 | ); | ||
| 128 | const managedPath = resolveManagedCloudflaredPath({ | ||
| 129 | baseDir, | ||
| 130 | platform: "linux", | ||
| 131 | arch: "x64", | ||
| 132 | }); | ||
| 133 | expect(installed).toEqual({ | ||
| 134 | status: "available", | ||
| 135 | executablePath: managedPath, | ||
| 136 | source: "managed", | ||
| 137 | version: CLOUDFLARED_VERSION, | ||
| 138 | }); | ||
| 139 | expect(new TextDecoder().decode(yield* fileSystem.readFile(managedPath))).toBe( | ||
| 140 | "test-cloudflared-binary", | ||
| 141 | ); | ||
| 142 | expect(progress).toEqual([ | ||
| 143 | "checking", | ||
| 144 | "waiting_for_lock", | ||
| 145 | "downloading", | ||
| 146 | "verifying", | ||
| 147 | "installing", | ||
| 148 | "validating", | ||
| 149 | "activating", | ||
| 150 | ]); | ||
| 151 | expect(yield* manager.resolve).toEqual(installed); | ||
| 152 | }).pipe( | ||
| 153 | Effect.scoped, | ||
| 154 | Effect.provide( | ||
| 155 | Layer.mergeAll( | ||
| 156 | NodeServices.layer, | ||
| 157 | makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")), | ||
| 158 | makeSpawnerLayer([]), | ||
| 159 | ), | ||
| 160 | ), | ||
| 161 | ), | ||
| 162 | ); | ||
| 163 | |||
| 164 | it.effect("rejects downloads whose checksum does not match the pinned manifest", () => | ||
| 165 | Effect.gen(function* () { | ||
| 166 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 167 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 168 | prefix: "t3-cloudflared-test-", | ||
| 169 | }); | ||
| 170 | const manager = yield* makeCloudflaredRelayClient({ | ||
| 171 | baseDir, | ||
| 172 | platform: "linux", | ||
| 173 | arch: "x64", | ||
| 174 | releaseAsset: { | ||
| 175 | url: "https://example.test/cloudflared", | ||
| 176 | sha256: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))), | ||
| 177 | archive: "binary", | ||
| 178 | }, | ||
| 179 | configProvider: emptyConfigProvider, | ||
| 180 | }); | ||
| 181 | |||
| 182 | const error = yield* manager.install.pipe(Effect.flip); | ||
| 183 | expect(error).toBeInstanceOf(RelayClientInstallError); | ||
| 184 | expect(error.reason).toBe("invalid_checksum"); | ||
| 185 | }).pipe( | ||
| 186 | Effect.scoped, | ||
| 187 | Effect.provide( | ||
| 188 | Layer.mergeAll( | ||
| 189 | NodeServices.layer, | ||
| 190 | makeHttpClientLayer(new TextEncoder().encode("tampered")), | ||
| 191 | makeSpawnerLayer([]), | ||
| 192 | ), | ||
| 193 | ), | ||
| 194 | ), | ||
| 195 | ); | ||
| 196 | |||
| 197 | it.effect("serializes concurrent installs within one runtime", () => { | ||
| 198 | const commands: Array<string> = []; | ||
| 199 | const bytes = new TextEncoder().encode("test-cloudflared-binary"); | ||
| 200 | return Effect.gen(function* () { | ||
| 201 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 202 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 203 | prefix: "t3-cloudflared-test-", | ||
| 204 | }); | ||
| 205 | const manager = yield* makeCloudflaredRelayClient({ | ||
| 206 | baseDir, | ||
| 207 | platform: "linux", | ||
| 208 | arch: "x64", | ||
| 209 | releaseAsset: { | ||
| 210 | url: "https://example.test/cloudflared", | ||
| 211 | sha256: Encoding.encodeHex(sha256(bytes)), | ||
| 212 | archive: "binary", | ||
| 213 | }, | ||
| 214 | configProvider: emptyConfigProvider, | ||
| 215 | }); | ||
| 216 | |||
| 217 | const [first, second] = yield* Effect.all([manager.install, manager.install], { | ||
| 218 | concurrency: "unbounded", | ||
| 219 | }); | ||
| 220 | expect(second).toEqual(first); | ||
| 221 | expect(commands).toHaveLength(1); | ||
| 222 | }).pipe( | ||
| 223 | Effect.scoped, | ||
| 224 | Effect.provide( | ||
| 225 | Layer.mergeAll(NodeServices.layer, makeHttpClientLayer(bytes), makeSpawnerLayer(commands)), | ||
| 226 | ), | ||
| 227 | ); | ||
| 228 | }); | ||
| 229 | |||
| 230 | it.effect("observes PATH changes after the manager has been constructed", () => | ||
| 231 | Effect.gen(function* () { | ||
| 232 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 233 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 234 | prefix: "t3-cloudflared-test-", | ||
| 235 | }); | ||
| 236 | const binDir = `${baseDir}/bin`; | ||
| 237 | const executablePath = `${binDir}/cloudflared`; | ||
| 238 | let path = ""; | ||
| 239 | const manager = yield* makeCloudflaredRelayClient({ | ||
| 240 | baseDir, | ||
| 241 | platform: "linux", | ||
| 242 | arch: "x64", | ||
| 243 | configProvider: () => ConfigProvider.fromEnv({ env: { PATH: path } }), | ||
| 244 | }); | ||
| 245 | |||
| 246 | expect(yield* manager.resolve).toEqual({ | ||
| 247 | status: "missing", | ||
| 248 | version: CLOUDFLARED_VERSION, | ||
| 249 | }); | ||
| 250 | |||
| 251 | yield* fileSystem.makeDirectory(binDir); | ||
| 252 | yield* fileSystem.writeFileString(executablePath, "cloudflared"); | ||
| 253 | yield* fileSystem.chmod(executablePath, 0o755); | ||
| 254 | path = binDir; | ||
| 255 | |||
| 256 | expect(yield* manager.resolve).toEqual({ | ||
| 257 | status: "available", | ||
| 258 | executablePath, | ||
| 259 | source: "path", | ||
| 260 | version: CLOUDFLARED_VERSION, | ||
| 261 | }); | ||
| 262 | }).pipe( | ||
| 263 | Effect.scoped, | ||
| 264 | Effect.provide( | ||
| 265 | Layer.mergeAll( | ||
| 266 | NodeServices.layer, | ||
| 267 | makeHttpClientLayer(new Uint8Array()), | ||
| 268 | makeSpawnerLayer([]), | ||
| 269 | ), | ||
| 270 | ), | ||
| 271 | ), | ||
| 272 | ); | ||
| 273 | }); | ||
packages/shared/src/relayClient.ts created+502| ... | @@ -0,0 +1,502 @@ | ||
| 1 | import * as Clock from "effect/Clock"; | ||
| 2 | import type { | ||
| 3 | RelayClientInstallProgressEvent, | ||
| 4 | RelayClientInstallProgressStage, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Config from "effect/Config"; | ||
| 7 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 8 | import * as Context from "effect/Context"; | ||
| 9 | import * as Crypto from "effect/Crypto"; | ||
| 10 | import * as Data from "effect/Data"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Encoding from "effect/Encoding"; | ||
| 13 | import * as FileSystem from "effect/FileSystem"; | ||
| 14 | import * as Layer from "effect/Layer"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | import * as Path from "effect/Path"; | ||
| 17 | import * as PlatformError from "effect/PlatformError"; | ||
| 18 | import * as Semaphore from "effect/Semaphore"; | ||
| 19 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 20 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 21 | |||
| 22 | export const CLOUDFLARED_VERSION = "2026.5.2"; | ||
| 23 | export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; | ||
| 24 | |||
| 25 | export type RelayClientExecutableSource = "override" | "managed" | "path"; | ||
| 26 | |||
| 27 | export type RelayClientStatus = | ||
| 28 | | { | ||
| 29 | readonly status: "available"; | ||
| 30 | readonly executablePath: string; | ||
| 31 | readonly source: RelayClientExecutableSource; | ||
| 32 | readonly version: string; | ||
| 33 | } | ||
| 34 | | { | ||
| 35 | readonly status: "missing"; | ||
| 36 | readonly version: string; | ||
| 37 | } | ||
| 38 | | { | ||
| 39 | readonly status: "unsupported"; | ||
| 40 | readonly platform: NodeJS.Platform; | ||
| 41 | readonly arch: string; | ||
| 42 | readonly version: string; | ||
| 43 | }; | ||
| 44 | |||
| 45 | export type AvailableRelayClient = Extract<RelayClientStatus, { readonly status: "available" }>; | ||
| 46 | |||
| 47 | export class RelayClientInstallError extends Data.TaggedError("RelayClientInstallError")<{ | ||
| 48 | readonly reason: | ||
| 49 | | "download_failed" | ||
| 50 | | "invalid_checksum" | ||
| 51 | | "install_locked" | ||
| 52 | | "override_missing" | ||
| 53 | | "unsupported_platform" | ||
| 54 | | "validation_failed" | ||
| 55 | | "write_failed"; | ||
| 56 | readonly message: string; | ||
| 57 | readonly cause?: unknown; | ||
| 58 | }> {} | ||
| 59 | |||
| 60 | class CloudflaredCommandError extends Data.TaggedError("CloudflaredCommandError")<{ | ||
| 61 | readonly command: string; | ||
| 62 | readonly exitCode: number; | ||
| 63 | }> {} | ||
| 64 | |||
| 65 | export interface CloudflaredReleaseAsset { | ||
| 66 | readonly url: string; | ||
| 67 | readonly sha256: string; | ||
| 68 | readonly archive: "binary" | "tgz"; | ||
| 69 | } | ||
| 70 | |||
| 71 | const CLOUDFLARED_RELEASE_ASSETS: Readonly< | ||
| 72 | Partial<Record<`${NodeJS.Platform}-${string}`, CloudflaredReleaseAsset>> | ||
| 73 | > = { | ||
| 74 | "darwin-arm64": { | ||
| 75 | url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-darwin-arm64.tgz", | ||
| 76 | sha256: "ba94054c9fd4297645093d59d51442e5e546d07bb0516120e694a13d5b216d38", | ||
| 77 | archive: "tgz", | ||
| 78 | }, | ||
| 79 | "darwin-x64": { | ||
| 80 | url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-darwin-amd64.tgz", | ||
| 81 | sha256: "7240f709506bc2c1eb9da4d89cf2555499c60280ecb854b7d80e8f17d4b7903d", | ||
| 82 | archive: "tgz", | ||
| 83 | }, | ||
| 84 | "linux-arm64": { | ||
| 85 | url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-linux-arm64", | ||
| 86 | sha256: "5a4e8ce2701105271412059f44b6a0bf1ae4542b4d98ff3180c0c019443a5815", | ||
| 87 | archive: "binary", | ||
| 88 | }, | ||
| 89 | "linux-x64": { | ||
| 90 | url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-linux-amd64", | ||
| 91 | sha256: "5286698547f03df745adb2355f04c12dde52ef425491e81f433642d695521886", | ||
| 92 | archive: "binary", | ||
| 93 | }, | ||
| 94 | "win32-x64": { | ||
| 95 | url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-windows-amd64.exe", | ||
| 96 | sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7", | ||
| 97 | archive: "binary", | ||
| 98 | }, | ||
| 99 | }; | ||
| 100 | |||
| 101 | const INSTALL_LOCK_RETRY_COUNT = 100; | ||
| 102 | const INSTALL_LOCK_RETRY_DELAY = "100 millis"; | ||
| 103 | const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; | ||
| 104 | |||
| 105 | const trimmedString = (name: string) => | ||
| 106 | Config.string(name).pipe( | ||
| 107 | Config.option, | ||
| 108 | Config.map( | ||
| 109 | Option.flatMap((value) => { | ||
| 110 | const trimmed = value.trim(); | ||
| 111 | return trimmed.length > 0 ? Option.some(trimmed) : Option.none(); | ||
| 112 | }), | ||
| 113 | ), | ||
| 114 | ); | ||
| 115 | |||
| 116 | const CloudflaredConfig = Config.all({ | ||
| 117 | executableOverride: trimmedString(CLOUDFLARED_PATH_ENV_NAME), | ||
| 118 | path: trimmedString("PATH"), | ||
| 119 | }); | ||
| 120 | |||
| 121 | export interface CloudflaredRelayClientOptions { | ||
| 122 | readonly baseDir: string; | ||
| 123 | readonly platform?: NodeJS.Platform; | ||
| 124 | readonly arch?: string; | ||
| 125 | readonly releaseAsset?: CloudflaredReleaseAsset; | ||
| 126 | readonly configProvider?: () => ConfigProvider.ConfigProvider; | ||
| 127 | } | ||
| 128 | |||
| 129 | export interface RelayClientShape { | ||
| 130 | readonly resolve: Effect.Effect<RelayClientStatus>; | ||
| 131 | readonly install: Effect.Effect<AvailableRelayClient, RelayClientInstallError>; | ||
| 132 | readonly installWithProgress: ( | ||
| 133 | report: (event: RelayClientInstallProgressEvent) => Effect.Effect<void>, | ||
| 134 | ) => Effect.Effect<AvailableRelayClient, RelayClientInstallError>; | ||
| 135 | } | ||
| 136 | |||
| 137 | export class RelayClient extends Context.Service<RelayClient, RelayClientShape>()( | ||
| 138 | "@t3tools/shared/relayClient", | ||
| 139 | ) {} | ||
| 140 | |||
| 141 | function executableFileName(platform: NodeJS.Platform): string { | ||
| 142 | return platform === "win32" ? "cloudflared.exe" : "cloudflared"; | ||
| 143 | } | ||
| 144 | |||
| 145 | export function resolveManagedCloudflaredPath(input: { | ||
| 146 | readonly baseDir: string; | ||
| 147 | readonly platform: NodeJS.Platform; | ||
| 148 | readonly arch: string; | ||
| 149 | }): string { | ||
| 150 | const separator = input.platform === "win32" ? "\\" : "/"; | ||
| 151 | return [ | ||
| 152 | input.baseDir.replace(/[\\/]+$/u, ""), | ||
| 153 | "tools", | ||
| 154 | "cloudflared", | ||
| 155 | CLOUDFLARED_VERSION, | ||
| 156 | `${input.platform}-${input.arch}`, | ||
| 157 | executableFileName(input.platform), | ||
| 158 | ].join(separator); | ||
| 159 | } | ||
| 160 | |||
| 161 | function resolveReleaseAsset( | ||
| 162 | platform: NodeJS.Platform, | ||
| 163 | arch: string, | ||
| 164 | ): CloudflaredReleaseAsset | null { | ||
| 165 | return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null; | ||
| 166 | } | ||
| 167 | |||
| 168 | function isAlreadyExists(error: PlatformError.PlatformError): boolean { | ||
| 169 | return error.reason._tag === "AlreadyExists"; | ||
| 170 | } | ||
| 171 | |||
| 172 | const wrapInstallFailure = | ||
| 173 | ( | ||
| 174 | reason: RelayClientInstallError["reason"], | ||
| 175 | message: string, | ||
| 176 | ): (<E, R>( | ||
| 177 | effect: Effect.Effect<void, E, R>, | ||
| 178 | ) => Effect.Effect<void, RelayClientInstallError, R>) => | ||
| 179 | (effect) => | ||
| 180 | effect.pipe( | ||
| 181 | Effect.mapError( | ||
| 182 | (cause) => | ||
| 183 | new RelayClientInstallError({ | ||
| 184 | reason, | ||
| 185 | message, | ||
| 186 | cause, | ||
| 187 | }), | ||
| 188 | ), | ||
| 189 | ); | ||
| 190 | |||
| 191 | export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function* ( | ||
| 192 | options: CloudflaredRelayClientOptions, | ||
| 193 | ): Effect.fn.Return< | ||
| 194 | RelayClientShape, | ||
| 195 | never, | ||
| 196 | | ChildProcessSpawner.ChildProcessSpawner | ||
| 197 | | Crypto.Crypto | ||
| 198 | | FileSystem.FileSystem | ||
| 199 | | HttpClient.HttpClient | ||
| 200 | | Path.Path | ||
| 201 | > { | ||
| 202 | const crypto = yield* Crypto.Crypto; | ||
| 203 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 204 | const httpClient = yield* HttpClient.HttpClient; | ||
| 205 | const path = yield* Path.Path; | ||
| 206 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 207 | const installSemaphore = yield* Semaphore.make(1); | ||
| 208 | const platform = options.platform ?? process.platform; | ||
| 209 | const arch = options.arch ?? process.arch; | ||
| 210 | const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch); | ||
| 211 | const loadCloudflaredConfig = Effect.suspend(() => | ||
| 212 | CloudflaredConfig.pipe( | ||
| 213 | Effect.provideService( | ||
| 214 | ConfigProvider.ConfigProvider, | ||
| 215 | options.configProvider?.() ?? ConfigProvider.fromEnv(), | ||
| 216 | ), | ||
| 217 | ), | ||
| 218 | ).pipe(Effect.orDie); | ||
| 219 | const managedPath = path.join( | ||
| 220 | options.baseDir, | ||
| 221 | "tools", | ||
| 222 | "cloudflared", | ||
| 223 | CLOUDFLARED_VERSION, | ||
| 224 | `${platform}-${arch}`, | ||
| 225 | executableFileName(platform), | ||
| 226 | ); | ||
| 227 | |||
| 228 | const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* ( | ||
| 229 | executablePath: string, | ||
| 230 | ) { | ||
| 231 | const info = yield* fileSystem.stat(executablePath).pipe(Effect.option); | ||
| 232 | if (Option.isNone(info) || info.value.type !== "File") return false; | ||
| 233 | return platform === "win32" || (info.value.mode & 0o111) !== 0; | ||
| 234 | }); | ||
| 235 | |||
| 236 | const resolvePathExecutable = Effect.gen(function* () { | ||
| 237 | const config = yield* loadCloudflaredConfig; | ||
| 238 | const pathValue = Option.getOrUndefined(config.path); | ||
| 239 | if (!pathValue) return null; | ||
| 240 | const delimiter = platform === "win32" ? ";" : ":"; | ||
| 241 | for (const directory of pathValue.split(delimiter)) { | ||
| 242 | const trimmed = directory.trim().replace(/^"|"$/gu, ""); | ||
| 243 | if (trimmed.length === 0) continue; | ||
| 244 | const candidate = path.join(trimmed, executableFileName(platform)); | ||
| 245 | if (yield* isExecutableFile(candidate)) return candidate; | ||
| 246 | } | ||
| 247 | return null; | ||
| 248 | }); | ||
| 249 | |||
| 250 | const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { | ||
| 251 | const config = yield* loadCloudflaredConfig; | ||
| 252 | if (Option.isSome(config.executableOverride)) { | ||
| 253 | return (yield* isExecutableFile(config.executableOverride.value)) | ||
| 254 | ? { | ||
| 255 | status: "available", | ||
| 256 | executablePath: config.executableOverride.value, | ||
| 257 | source: "override", | ||
| 258 | version: CLOUDFLARED_VERSION, | ||
| 259 | } | ||
| 260 | : { status: "missing", version: CLOUDFLARED_VERSION }; | ||
| 261 | } | ||
| 262 | if (yield* isExecutableFile(managedPath)) { | ||
| 263 | return { | ||
| 264 | status: "available", | ||
| 265 | executablePath: managedPath, | ||
| 266 | source: "managed", | ||
| 267 | version: CLOUDFLARED_VERSION, | ||
| 268 | }; | ||
| 269 | } | ||
| 270 | const pathExecutable = yield* resolvePathExecutable; | ||
| 271 | if (pathExecutable) { | ||
| 272 | return { | ||
| 273 | status: "available", | ||
| 274 | executablePath: pathExecutable, | ||
| 275 | source: "path", | ||
| 276 | version: CLOUDFLARED_VERSION, | ||
| 277 | }; | ||
| 278 | } | ||
| 279 | return releaseAsset | ||
| 280 | ? { status: "missing", version: CLOUDFLARED_VERSION } | ||
| 281 | : { | ||
| 282 | status: "unsupported", | ||
| 283 | platform, | ||
| 284 | arch, | ||
| 285 | version: CLOUDFLARED_VERSION, | ||
| 286 | }; | ||
| 287 | }); | ||
| 288 | |||
| 289 | const runCommand = Effect.fn("cloudflared.runCommand")(function* ( | ||
| 290 | command: string, | ||
| 291 | args: ReadonlyArray<string>, | ||
| 292 | ) { | ||
| 293 | const child = yield* spawner.spawn( | ||
| 294 | ChildProcess.make(command, args, { | ||
| 295 | shell: false, | ||
| 296 | stdout: "ignore", | ||
| 297 | stderr: "ignore", | ||
| 298 | }), | ||
| 299 | ); | ||
| 300 | const exitCode = Number(yield* child.exitCode); | ||
| 301 | if (exitCode !== 0) { | ||
| 302 | return yield* new CloudflaredCommandError({ command, exitCode }); | ||
| 303 | } | ||
| 304 | }); | ||
| 305 | |||
| 306 | const downloadAsset = Effect.fn("cloudflared.downloadAsset")(function* ( | ||
| 307 | asset: CloudflaredReleaseAsset, | ||
| 308 | report: (stage: RelayClientInstallProgressStage) => Effect.Effect<void>, | ||
| 309 | ) { | ||
| 310 | yield* report("downloading"); | ||
| 311 | const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe( | ||
| 312 | Effect.flatMap(HttpClientResponse.filterStatusOk), | ||
| 313 | Effect.mapError( | ||
| 314 | (cause) => | ||
| 315 | new RelayClientInstallError({ | ||
| 316 | reason: "download_failed", | ||
| 317 | message: "Could not download the relay client.", | ||
| 318 | cause, | ||
| 319 | }), | ||
| 320 | ), | ||
| 321 | ); | ||
| 322 | const bytes = new Uint8Array( | ||
| 323 | yield* response.arrayBuffer.pipe( | ||
| 324 | Effect.mapError( | ||
| 325 | (cause) => | ||
| 326 | new RelayClientInstallError({ | ||
| 327 | reason: "download_failed", | ||
| 328 | message: "Could not read the downloaded relay client binary.", | ||
| 329 | cause, | ||
| 330 | }), | ||
| 331 | ), | ||
| 332 | ), | ||
| 333 | ); | ||
| 334 | yield* report("verifying"); | ||
| 335 | const checksum = yield* crypto.digest("SHA-256", bytes).pipe( | ||
| 336 | Effect.mapError( | ||
| 337 | (cause) => | ||
| 338 | new RelayClientInstallError({ | ||
| 339 | reason: "validation_failed", | ||
| 340 | message: "Could not verify the downloaded relay client checksum.", | ||
| 341 | cause, | ||
| 342 | }), | ||
| 343 | ), | ||
| 344 | ); | ||
| 345 | if (Encoding.encodeHex(checksum) !== asset.sha256) { | ||
| 346 | return yield* new RelayClientInstallError({ | ||
| 347 | reason: "invalid_checksum", | ||
| 348 | message: "Downloaded relay client checksum did not match the pinned release.", | ||
| 349 | }); | ||
| 350 | } | ||
| 351 | return bytes; | ||
| 352 | }); | ||
| 353 | |||
| 354 | const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* ( | ||
| 355 | lockPath: string, | ||
| 356 | ) { | ||
| 357 | for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { | ||
| 358 | const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( | ||
| 359 | Effect.as(true), | ||
| 360 | Effect.catch((error) => | ||
| 361 | isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error), | ||
| 362 | ), | ||
| 363 | ); | ||
| 364 | if (acquired) return; | ||
| 365 | |||
| 366 | const now = yield* Clock.currentTimeMillis; | ||
| 367 | const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option); | ||
| 368 | const mtime = Option.flatMap(lockInfo, (info) => info.mtime); | ||
| 369 | if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) { | ||
| 370 | yield* fileSystem.remove(lockPath, { force: true }); | ||
| 371 | continue; | ||
| 372 | } | ||
| 373 | yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); | ||
| 374 | } | ||
| 375 | return yield* new RelayClientInstallError({ | ||
| 376 | reason: "install_locked", | ||
| 377 | message: "Another relay client installation is still in progress.", | ||
| 378 | }); | ||
| 379 | }); | ||
| 380 | |||
| 381 | const installUnlocked = Effect.fn("cloudflared.installUnlocked")(function* ( | ||
| 382 | report: (stage: RelayClientInstallProgressStage) => Effect.Effect<void>, | ||
| 383 | ) { | ||
| 384 | yield* report("checking"); | ||
| 385 | const existing = yield* resolve; | ||
| 386 | if (existing.status === "available") return existing; | ||
| 387 | const config = yield* loadCloudflaredConfig; | ||
| 388 | if (Option.isSome(config.executableOverride)) { | ||
| 389 | return yield* new RelayClientInstallError({ | ||
| 390 | reason: "override_missing", | ||
| 391 | message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, | ||
| 392 | }); | ||
| 393 | } | ||
| 394 | if (!releaseAsset) { | ||
| 395 | return yield* new RelayClientInstallError({ | ||
| 396 | reason: "unsupported_platform", | ||
| 397 | message: `T3 Code does not provide a managed relay client binary for ${platform}-${arch}.`, | ||
| 398 | }); | ||
| 399 | } | ||
| 400 | |||
| 401 | const managedDirectory = path.dirname(managedPath); | ||
| 402 | const lockPath = `${managedPath}.lock`; | ||
| 403 | yield* fileSystem | ||
| 404 | .makeDirectory(managedDirectory, { recursive: true }) | ||
| 405 | .pipe( | ||
| 406 | wrapInstallFailure("write_failed", "Could not create the relay client tool directory."), | ||
| 407 | ); | ||
| 408 | yield* report("waiting_for_lock"); | ||
| 409 | yield* acquireInstallLock(lockPath).pipe( | ||
| 410 | Effect.catchTag("PlatformError", (cause) => | ||
| 411 | Effect.fail( | ||
| 412 | new RelayClientInstallError({ | ||
| 413 | reason: "write_failed", | ||
| 414 | message: "Could not acquire the relay client installation lock.", | ||
| 415 | cause, | ||
| 416 | }), | ||
| 417 | ), | ||
| 418 | ), | ||
| 419 | ); | ||
| 420 | return yield* Effect.gen(function* () { | ||
| 421 | const afterLock = yield* resolve; | ||
| 422 | if (afterLock.status === "available") return afterLock; | ||
| 423 | |||
| 424 | const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 425 | directory: managedDirectory, | ||
| 426 | prefix: ".install-", | ||
| 427 | }); | ||
| 428 | const archivePath = path.join( | ||
| 429 | tempDirectory, | ||
| 430 | releaseAsset.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform), | ||
| 431 | ); | ||
| 432 | const download = yield* downloadAsset(releaseAsset, report); | ||
| 433 | yield* report("installing"); | ||
| 434 | yield* fileSystem | ||
| 435 | .writeFile(archivePath, download) | ||
| 436 | .pipe(wrapInstallFailure("write_failed", "Could not write the relay client download.")); | ||
| 437 | |||
| 438 | const executablePath = path.join(tempDirectory, executableFileName(platform)); | ||
| 439 | if (releaseAsset.archive === "tgz") { | ||
| 440 | yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe( | ||
| 441 | wrapInstallFailure("write_failed", "Could not extract the relay client."), | ||
| 442 | ); | ||
| 443 | } | ||
| 444 | if (platform !== "win32") { | ||
| 445 | yield* fileSystem | ||
| 446 | .chmod(executablePath, 0o755) | ||
| 447 | .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); | ||
| 448 | } | ||
| 449 | yield* report("validating"); | ||
| 450 | yield* runCommand(executablePath, ["--version"]).pipe( | ||
| 451 | wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), | ||
| 452 | ); | ||
| 453 | |||
| 454 | const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; | ||
| 455 | yield* report("activating"); | ||
| 456 | yield* fileSystem | ||
| 457 | .rename(executablePath, stagedPath) | ||
| 458 | .pipe(wrapInstallFailure("write_failed", "Could not stage the relay client.")); | ||
| 459 | yield* fileSystem | ||
| 460 | .rename(stagedPath, managedPath) | ||
| 461 | .pipe( | ||
| 462 | wrapInstallFailure("write_failed", "Could not activate the relay client."), | ||
| 463 | Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), | ||
| 464 | ); | ||
| 465 | return { | ||
| 466 | status: "available", | ||
| 467 | executablePath: managedPath, | ||
| 468 | source: "managed", | ||
| 469 | version: CLOUDFLARED_VERSION, | ||
| 470 | } satisfies AvailableRelayClient; | ||
| 471 | }).pipe( | ||
| 472 | Effect.scoped, | ||
| 473 | Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), | ||
| 474 | Effect.catch((cause) => | ||
| 475 | cause instanceof RelayClientInstallError | ||
| 476 | ? Effect.fail(cause) | ||
| 477 | : Effect.fail( | ||
| 478 | new RelayClientInstallError({ | ||
| 479 | reason: "write_failed", | ||
| 480 | message: "Could not install the relay client.", | ||
| 481 | cause, | ||
| 482 | }), | ||
| 483 | ), | ||
| 484 | ), | ||
| 485 | ); | ||
| 486 | }); | ||
| 487 | const installWithProgress: RelayClientShape["installWithProgress"] = (report) => | ||
| 488 | installSemaphore.withPermit( | ||
| 489 | installUnlocked((stage) => | ||
| 490 | report({ | ||
| 491 | type: "progress", | ||
| 492 | stage, | ||
| 493 | }), | ||
| 494 | ), | ||
| 495 | ); | ||
| 496 | const install = installWithProgress(() => Effect.void); | ||
| 497 | |||
| 498 | return RelayClient.of({ resolve, install, installWithProgress }); | ||
| 499 | }); | ||
| 500 | |||
| 501 | export const layerCloudflared = (options: CloudflaredRelayClientOptions) => | ||
| 502 | Layer.effect(RelayClient, makeCloudflaredRelayClient(options)); | ||
packages/shared/src/relayJwt.ts created+69| ... | @@ -0,0 +1,69 @@ | ||
| 1 | import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose"; | ||
| 2 | import * as Data from "effect/Data"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | |||
| 6 | export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt"; | ||
| 7 | export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt"; | ||
| 8 | export const RELAY_HEALTH_REQUEST_TYP = "t3-cloud-health+jwt"; | ||
| 9 | export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt"; | ||
| 10 | export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt"; | ||
| 11 | export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt"; | ||
| 12 | |||
| 13 | export class RelayJwtError extends Data.TaggedError("RelayJwtError")<{ | ||
| 14 | readonly cause: unknown; | ||
| 15 | }> {} | ||
| 16 | |||
| 17 | export function normalizeRelayIssuer(value: string): string { | ||
| 18 | return value.trim().replace(/\/+$/gu, ""); | ||
| 19 | } | ||
| 20 | |||
| 21 | export function decodeRelayJwt(token: string): JWTPayload { | ||
| 22 | return decodeJwt(token); | ||
| 23 | } | ||
| 24 | |||
| 25 | function normalizePem(value: string): string { | ||
| 26 | return value.replace(/\\n/gu, "\n").trim(); | ||
| 27 | } | ||
| 28 | |||
| 29 | export function signRelayJwt(input: { | ||
| 30 | readonly privateKey: string; | ||
| 31 | readonly typ: string; | ||
| 32 | readonly payload: JWTPayload; | ||
| 33 | }): Effect.Effect<string, RelayJwtError> { | ||
| 34 | return Effect.tryPromise({ | ||
| 35 | try: async () => { | ||
| 36 | const key = await importPKCS8(normalizePem(input.privateKey), "EdDSA"); | ||
| 37 | return new SignJWT(input.payload) | ||
| 38 | .setProtectedHeader({ alg: "EdDSA", typ: input.typ }) | ||
| 39 | .sign(key); | ||
| 40 | }, | ||
| 41 | catch: (cause) => new RelayJwtError({ cause }), | ||
| 42 | }); | ||
| 43 | } | ||
| 44 | |||
| 45 | export function verifyRelayJwt(input: { | ||
| 46 | readonly publicKey: string; | ||
| 47 | readonly token: string; | ||
| 48 | readonly typ: string; | ||
| 49 | readonly issuer: string; | ||
| 50 | readonly audience: string; | ||
| 51 | readonly nowEpochSeconds: number; | ||
| 52 | }): Effect.Effect<JWTPayload, RelayJwtError> { | ||
| 53 | return Effect.tryPromise({ | ||
| 54 | try: async () => { | ||
| 55 | const key = await importSPKI(normalizePem(input.publicKey), "EdDSA"); | ||
| 56 | const verified = await jwtVerify(input.token, key, { | ||
| 57 | algorithms: ["EdDSA"], | ||
| 58 | typ: input.typ, | ||
| 59 | issuer: input.issuer, | ||
| 60 | audience: input.audience, | ||
| 61 | maxTokenAge: "5 minutes", | ||
| 62 | clockTolerance: 60, | ||
| 63 | currentDate: DateTime.toDate(DateTime.makeUnsafe(input.nowEpochSeconds * 1_000)), | ||
| 64 | }); | ||
| 65 | return verified.payload; | ||
| 66 | }, | ||
| 67 | catch: (cause) => new RelayJwtError({ cause }), | ||
| 68 | }); | ||
| 69 | } | ||
packages/shared/src/relaySigning.test.ts created+18| ... | @@ -0,0 +1,18 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { stableStringify } from "./relaySigning.ts"; | ||
| 4 | |||
| 5 | describe("relaySigning", () => { | ||
| 6 | it("canonicalizes object keys recursively", () => { | ||
| 7 | expect( | ||
| 8 | stableStringify({ | ||
| 9 | z: 1, | ||
| 10 | a: { | ||
| 11 | y: true, | ||
| 12 | b: null, | ||
| 13 | }, | ||
| 14 | list: [{ c: "three", a: "one" }], | ||
| 15 | }), | ||
| 16 | ).toBe('{"a":{"b":null,"y":true},"list":[{"a":"one","c":"three"}],"z":1}'); | ||
| 17 | }); | ||
| 18 | }); | ||
packages/shared/src/relaySigning.ts created+17| ... | @@ -0,0 +1,17 @@ | ||
| 1 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 2 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 3 | } | ||
| 4 | |||
| 5 | export function stableStringify(value: unknown): string { | ||
| 6 | if (Array.isArray(value)) { | ||
| 7 | return `[${value.map(stableStringify).join(",")}]`; | ||
| 8 | } | ||
| 9 | if (isRecord(value)) { | ||
| 10 | return `{${Object.entries(value) | ||
| 11 | .filter(([, entryValue]) => entryValue !== undefined) | ||
| 12 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 13 | .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`) | ||
| 14 | .join(",")}}`; | ||
| 15 | } | ||
| 16 | return JSON.stringify(value) ?? "null"; | ||
| 17 | } | ||
packages/shared/src/relayUrl.test.ts created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { isSecureRelayUrl, normalizeSecureRelayUrl } from "./relayUrl.ts"; | ||
| 4 | |||
| 5 | describe("normalizeSecureRelayUrl", () => { | ||
| 6 | it("normalizes secure relay origins", () => { | ||
| 7 | expect(normalizeSecureRelayUrl(" https://relay.example.test/// ")).toBe( | ||
| 8 | "https://relay.example.test", | ||
| 9 | ); | ||
| 10 | expect(normalizeSecureRelayUrl("https://relay.example.test:8443/")).toBe( | ||
| 11 | "https://relay.example.test:8443", | ||
| 12 | ); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it.each([ | ||
| 16 | "http://relay.example.test", | ||
| 17 | "https://user:password@relay.example.test", | ||
| 18 | "https://relay.example.test/path", | ||
| 19 | "https://relay.example.test?query=value", | ||
| 20 | "https://relay.example.test#fragment", | ||
| 21 | "not a url", | ||
| 22 | ])("rejects unsafe relay URL %s", (value) => { | ||
| 23 | expect(normalizeSecureRelayUrl(value)).toBeNull(); | ||
| 24 | expect(isSecureRelayUrl(value)).toBe(false); | ||
| 25 | }); | ||
| 26 | }); | ||
packages/shared/src/relayUrl.ts created+22| ... | @@ -0,0 +1,22 @@ | ||
| 1 | export function normalizeSecureRelayUrl(value: string): string | null { | ||
| 2 | try { | ||
| 3 | const url = new URL(value.trim()); | ||
| 4 | if ( | ||
| 5 | url.protocol !== "https:" || | ||
| 6 | url.username.length > 0 || | ||
| 7 | url.password.length > 0 || | ||
| 8 | url.search.length > 0 || | ||
| 9 | url.hash.length > 0 || | ||
| 10 | !/^\/+$/u.test(url.pathname) | ||
| 11 | ) { | ||
| 12 | return null; | ||
| 13 | } | ||
| 14 | return url.origin; | ||
| 15 | } catch { | ||
| 16 | return null; | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | export function isSecureRelayUrl(value: string): boolean { | ||
| 21 | return normalizeSecureRelayUrl(value) !== null; | ||
| 22 | } | ||
packages/ssh/src/tunnel.test.ts+1-1| ... | @@ -8,7 +8,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -8,7 +8,7 @@ import * as Layer from "effect/Layer"; |
| 8 | import * as Result from "effect/Result"; | 8 | import * as Result from "effect/Result"; |
| 9 | import * as Sink from "effect/Sink"; | 9 | import * as Sink from "effect/Sink"; |
| 10 | import * as Stream from "effect/Stream"; | 10 | import * as Stream from "effect/Stream"; |
| 11 | import * as TestClock from "effect/testing/TestClock"; | 11 | import { TestClock } from "effect/testing"; |
| 12 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 12 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 13 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 13 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 14 | 14 |
patches/effect@4.0.0-beta.73.patch deleted-291| ... | @@ -1,291 +0,0 @@ | ||
| 1 | diff --git a/dist/unstable/rpc/RpcClient.d.ts b/dist/unstable/rpc/RpcClient.d.ts | ||
| 2 | index b0f61df..ead663e 100644 | ||
| 3 | --- a/dist/unstable/rpc/RpcClient.d.ts | ||
| 4 | +++ b/dist/unstable/rpc/RpcClient.d.ts | ||
| 5 | @@ -2,6 +2,7 @@ import * as Cause from "../../Cause.ts"; | ||
| 6 | import * as Context from "../../Context.ts"; | ||
| 7 | import type * as Duration from "../../Duration.ts"; | ||
| 8 | import * as Effect from "../../Effect.ts"; | ||
| 9 | +import * as Exit from "../../Exit.ts"; | ||
| 10 | import * as Layer from "../../Layer.ts"; | ||
| 11 | import * as Queue from "../../Queue.ts"; | ||
| 12 | import * as Schedule from "../../Schedule.ts"; | ||
| 13 | @@ -260,9 +261,40 @@ export declare const layerProtocolWorker: (options: { | ||
| 14 | readonly targetUtilization?: number | undefined; | ||
| 15 | readonly timeToLive: Duration.Input; | ||
| 16 | }) => Layer.Layer<Protocol, WorkerError, Worker.WorkerPlatform | Worker.Spawner>; | ||
| 17 | +declare const RequestHooks_base: Context.ServiceClass<RequestHooks, "effect/rpc/RpcClient/RequestHooks", { | ||
| 18 | + readonly onRequestStart?: ((info: { | ||
| 19 | + readonly id: RequestId; | ||
| 20 | + readonly tag: string; | ||
| 21 | + readonly stream: boolean; | ||
| 22 | + }) => Effect.Effect<void>) | undefined; | ||
| 23 | + readonly onRequestChunk?: ((info: { | ||
| 24 | + readonly id: RequestId; | ||
| 25 | + readonly tag: string; | ||
| 26 | + readonly chunkCount: number; | ||
| 27 | + }) => Effect.Effect<void>) | undefined; | ||
| 28 | + readonly onRequestExit?: ((info: { | ||
| 29 | + readonly id: RequestId; | ||
| 30 | + readonly tag: string; | ||
| 31 | + readonly stream: boolean; | ||
| 32 | + readonly exit: Exit.Exit<unknown, unknown>; | ||
| 33 | + }) => Effect.Effect<void>) | undefined; | ||
| 34 | + readonly onRequestInterrupt?: ((info: { | ||
| 35 | + readonly id: RequestId; | ||
| 36 | + readonly tag?: string | undefined; | ||
| 37 | + }) => Effect.Effect<void>) | undefined; | ||
| 38 | +}>; | ||
| 39 | +/** | ||
| 40 | + * @since 4.0.0 | ||
| 41 | + * @category RequestHooks | ||
| 42 | + */ | ||
| 43 | +export declare class RequestHooks extends RequestHooks_base { | ||
| 44 | +} | ||
| 45 | declare const ConnectionHooks_base: Context.ServiceClass<ConnectionHooks, "effect/rpc/RpcClient/ConnectionHooks", { | ||
| 46 | readonly onConnect: Effect.Effect<void>; | ||
| 47 | readonly onDisconnect: Effect.Effect<void>; | ||
| 48 | + readonly onPing?: Effect.Effect<void> | undefined; | ||
| 49 | + readonly onPong?: Effect.Effect<void> | undefined; | ||
| 50 | + readonly onPingTimeout?: Effect.Effect<void> | undefined; | ||
| 51 | }>; | ||
| 52 | /** | ||
| 53 | * Represents optional client protocol hooks that run when a transport connects | ||
| 54 | @@ -279,4 +311,4 @@ declare const ConnectionHooks_base: Context.ServiceClass<ConnectionHooks, "effec | ||
| 55 | export declare class ConnectionHooks extends ConnectionHooks_base { | ||
| 56 | } | ||
| 57 | export {}; | ||
| 58 | -//# sourceMappingURL=RpcClient.d.ts.map | ||
| 59 | \ No newline at end of file | ||
| 60 | +//# sourceMappingURL=RpcClient.d.ts.map | ||
| 61 | diff --git a/dist/unstable/rpc/RpcClient.js b/dist/unstable/rpc/RpcClient.js | ||
| 62 | index 811a8a8..f9cf2eb 100644 | ||
| 63 | --- a/dist/unstable/rpc/RpcClient.js | ||
| 64 | +++ b/dist/unstable/rpc/RpcClient.js | ||
| 65 | @@ -46,6 +46,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 66 | const services = yield* Effect.context(); | ||
| 67 | const scope = Context.get(services, Scope.Scope); | ||
| 68 | const entries = new Map(); | ||
| 69 | + const hooks = yield* Effect.serviceOption(RequestHooks); | ||
| 70 | let isShutdown = false; | ||
| 71 | yield* Scope.addFinalizer(scope, Effect.withFiber(parent => { | ||
| 72 | isShutdown = true; | ||
| 73 | @@ -83,6 +84,11 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 74 | return Effect.interrupt; | ||
| 75 | } | ||
| 76 | const id = generateRequestId(); | ||
| 77 | + const onStart = Option.isSome(hooks) && hooks.value.onRequestStart ? hooks.value.onRequestStart({ | ||
| 78 | + id, | ||
| 79 | + tag: rpc._tag, | ||
| 80 | + stream: false | ||
| 81 | + }) : Effect.void; | ||
| 82 | const send = middleware(message => options.onFromClient({ | ||
| 83 | message, | ||
| 84 | context, | ||
| 85 | @@ -100,7 +106,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 86 | headers: Headers.merge(parentFiber.getRef(CurrentHeaders), headers) | ||
| 87 | }); | ||
| 88 | if (discard) { | ||
| 89 | - return send; | ||
| 90 | + return onStart.pipe(Effect.andThen(send)); | ||
| 91 | } | ||
| 92 | let fiber; | ||
| 93 | return Effect.onInterrupt(Effect.callback(resume => { | ||
| 94 | @@ -118,7 +124,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 95 | } | ||
| 96 | }; | ||
| 97 | entries.set(id, entry); | ||
| 98 | - fiber = send.pipe(span ? Effect.withParentSpan(span, { | ||
| 99 | + fiber = onStart.pipe(Effect.andThen(send), span ? Effect.withParentSpan(span, { | ||
| 100 | captureStackTrace: false | ||
| 101 | }) : identity, Effect.runForkWith(parentFiber.context)); | ||
| 102 | fiber.addObserver(exit => { | ||
| 103 | @@ -127,8 +133,9 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 104 | } | ||
| 105 | }); | ||
| 106 | }), interruptors => { | ||
| 107 | + const entry = entries.get(id); | ||
| 108 | entries.delete(id); | ||
| 109 | - return Effect.andThen(Fiber.interrupt(fiber), sendInterrupt(id, Array.from(interruptors), context)); | ||
| 110 | + return Effect.andThen(Fiber.interrupt(fiber), sendInterrupt(id, Array.from(interruptors), context, entry?.rpc._tag)); | ||
| 111 | }); | ||
| 112 | }); | ||
| 113 | const onStreamRequest = Effect.fnUntraced(function* (rpc, middleware, payload, headers, streamBufferSize, context) { | ||
| 114 | @@ -140,11 +147,17 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 115 | }); | ||
| 116 | const fiber = Fiber.getCurrent(); | ||
| 117 | const id = generateRequestId(); | ||
| 118 | + const onStart = Option.isSome(hooks) && hooks.value.onRequestStart ? hooks.value.onRequestStart({ | ||
| 119 | + id, | ||
| 120 | + tag: rpc._tag, | ||
| 121 | + stream: true | ||
| 122 | + }) : Effect.void; | ||
| 123 | const scope = Context.getUnsafe(fiber.context, Scope.Scope); | ||
| 124 | yield* Scope.addFinalizerExit(scope, exit => { | ||
| 125 | - if (!entries.has(id)) return Effect.void; | ||
| 126 | + const entry = entries.get(id); | ||
| 127 | + if (!entry) return Effect.void; | ||
| 128 | entries.delete(id); | ||
| 129 | - return sendInterrupt(id, Exit.isFailure(exit) ? Array.from(Cause.interruptors(exit.cause)) : [], context); | ||
| 130 | + return sendInterrupt(id, Exit.isFailure(exit) ? Array.from(Cause.interruptors(exit.cause)) : [], context, entry.rpc._tag); | ||
| 131 | }); | ||
| 132 | const queue = yield* Queue.bounded(streamBufferSize); | ||
| 133 | entries.set(id, { | ||
| 134 | @@ -152,9 +165,10 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 135 | rpc, | ||
| 136 | queue, | ||
| 137 | scope, | ||
| 138 | - context | ||
| 139 | + context, | ||
| 140 | + chunkCount: 0 | ||
| 141 | }); | ||
| 142 | - yield* middleware(message => options.onFromClient({ | ||
| 143 | + yield* onStart.pipe(Effect.andThen(middleware(message => options.onFromClient({ | ||
| 144 | message, | ||
| 145 | context, | ||
| 146 | discard: false | ||
| 147 | @@ -169,7 +183,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 148 | sampled: span.sampled | ||
| 149 | } : {}), | ||
| 150 | headers: Headers.merge(fiber.getRef(CurrentHeaders), headers) | ||
| 151 | - }).pipe(span ? Effect.withParentSpan(span, { | ||
| 152 | + }))).pipe(span ? Effect.withParentSpan(span, { | ||
| 153 | captureStackTrace: false | ||
| 154 | }) : identity, Effect.catchCause(error => Queue.failCause(queue, error)), Effect.interruptible, Effect.forkIn(scope, { | ||
| 155 | startImmediately: true | ||
| 156 | @@ -199,7 +213,10 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 157 | }); | ||
| 158 | }; | ||
| 159 | }; | ||
| 160 | - const sendInterrupt = (requestId, interruptors, context) => Effect.callback(resume => { | ||
| 161 | + const sendInterrupt = (requestId, interruptors, context, tag) => (Option.isSome(hooks) && hooks.value.onRequestInterrupt ? hooks.value.onRequestInterrupt({ | ||
| 162 | + id: requestId, | ||
| 163 | + tag | ||
| 164 | + }) : Effect.void).pipe(Effect.andThen(Effect.callback(resume => { | ||
| 165 | const parentFiber = Fiber.getCurrent(); | ||
| 166 | const fiber = options.onFromClient({ | ||
| 167 | message: { | ||
| 168 | @@ -213,7 +230,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 169 | fiber.addObserver(() => { | ||
| 170 | resume(Effect.void); | ||
| 171 | }); | ||
| 172 | - }); | ||
| 173 | + }))); | ||
| 174 | const write = message => { | ||
| 175 | switch (message._tag) { | ||
| 176 | case "Chunk": | ||
| 177 | @@ -221,7 +238,13 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 178 | const requestId = message.requestId; | ||
| 179 | const entry = entries.get(requestId); | ||
| 180 | if (!entry || entry._tag !== "Queue") return Effect.void; | ||
| 181 | - return Queue.offerAll(entry.queue, message.values).pipe(supportsAck ? Effect.flatMap(() => options.onFromClient({ | ||
| 182 | + const chunkCount = entry.chunkCount += message.values.length; | ||
| 183 | + const onChunk = Option.isSome(hooks) && hooks.value.onRequestChunk ? hooks.value.onRequestChunk({ | ||
| 184 | + id: requestId, | ||
| 185 | + tag: entry.rpc._tag, | ||
| 186 | + chunkCount | ||
| 187 | + }) : Effect.void; | ||
| 188 | + return Queue.offerAll(entry.queue, message.values).pipe(Effect.andThen(onChunk), supportsAck ? Effect.flatMap(() => options.onFromClient({ | ||
| 189 | message: { | ||
| 190 | _tag: "Ack", | ||
| 191 | requestId: message.requestId | ||
| 192 | @@ -236,11 +259,18 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 193 | const entry = entries.get(requestId); | ||
| 194 | if (!entry) return Effect.void; | ||
| 195 | entries.delete(requestId); | ||
| 196 | + const onExit = Option.isSome(hooks) && hooks.value.onRequestExit ? hooks.value.onRequestExit({ | ||
| 197 | + id: requestId, | ||
| 198 | + tag: entry.rpc._tag, | ||
| 199 | + stream: entry._tag === "Queue", | ||
| 200 | + exit: message.exit | ||
| 201 | + }) : Effect.void; | ||
| 202 | if (entry._tag === "Effect") { | ||
| 203 | - entry.resume(message.exit); | ||
| 204 | - return Effect.void; | ||
| 205 | + return onExit.pipe(Effect.andThen(Effect.sync(() => { | ||
| 206 | + entry.resume(message.exit); | ||
| 207 | + }))); | ||
| 208 | } | ||
| 209 | - return message.exit._tag === "Success" ? Queue.end(entry.queue) : Queue.failCause(entry.queue, message.exit.cause); | ||
| 210 | + return onExit.pipe(Effect.andThen(message.exit._tag === "Success" ? Queue.end(entry.queue) : Queue.failCause(entry.queue, message.exit.cause))); | ||
| 211 | } | ||
| 212 | case "Defect": | ||
| 213 | { | ||
| 214 | @@ -568,7 +598,7 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 215 | const requestClientMap = new Map(); | ||
| 216 | const write = yield* socket.writer; | ||
| 217 | let parser = serialization.makeUnsafe(); | ||
| 218 | - const pinger = yield* makePinger(write(parser.encode(constPing))); | ||
| 219 | + const pinger = yield* makePinger(write(parser.encode(constPing)), Option.isSome(hooks) ? hooks.value : undefined); | ||
| 220 | let currentError; | ||
| 221 | const onOpen = Effect.suspend(() => { | ||
| 222 | currentError = undefined; | ||
| 223 | @@ -588,8 +618,7 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 224 | body: () => { | ||
| 225 | const response = responses[i++]; | ||
| 226 | if (response._tag === "Pong") { | ||
| 227 | - pinger.onPong(); | ||
| 228 | - return Effect.void; | ||
| 229 | + return pinger.onPong; | ||
| 230 | } | ||
| 231 | if ("requestId" in response) { | ||
| 232 | const clientId = requestClientMap.get(response.requestId); | ||
| 233 | @@ -617,12 +646,12 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 234 | } | ||
| 235 | }, { | ||
| 236 | onOpen | ||
| 237 | - }).pipe(Effect.raceFirst(Effect.flatMap(pinger.timeout, () => Effect.fail(new Socket.SocketError({ | ||
| 238 | + }).pipe(Effect.raceFirst(Effect.flatMap(pinger.timeout, () => (Option.isSome(hooks) && hooks.value.onPingTimeout ? hooks.value.onPingTimeout : Effect.void).pipe(Effect.andThen(Effect.fail(new Socket.SocketError({ | ||
| 239 | reason: new Socket.SocketOpenError({ | ||
| 240 | kind: "Timeout", | ||
| 241 | cause: new Error("ping timeout") | ||
| 242 | }) | ||
| 243 | - }))))); | ||
| 244 | + }))))))); | ||
| 245 | }).pipe(Effect.flatMap(() => Effect.fail(new Socket.SocketError({ | ||
| 246 | reason: new Socket.SocketCloseError({ | ||
| 247 | code: 1000 | ||
| 248 | @@ -664,20 +693,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 249 | }; | ||
| 250 | })); | ||
| 251 | const defaultRetryPolicy = /*#__PURE__*/Schedule.exponential(500, 1.5).pipe(/*#__PURE__*/Schedule.either(/*#__PURE__*/Schedule.spaced(5000))); | ||
| 252 | -const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) { | ||
| 253 | +const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) { | ||
| 254 | let recievedPong = true; | ||
| 255 | const latch = Latch.makeUnsafe(); | ||
| 256 | const reset = () => { | ||
| 257 | recievedPong = true; | ||
| 258 | latch.closeUnsafe(); | ||
| 259 | }; | ||
| 260 | - const onPong = () => { | ||
| 261 | + const onPong = Effect.sync(() => { | ||
| 262 | recievedPong = true; | ||
| 263 | - }; | ||
| 264 | + }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void)); | ||
| 265 | yield* Effect.suspend(() => { | ||
| 266 | if (!recievedPong) return latch.open; | ||
| 267 | recievedPong = false; | ||
| 268 | - return writePing; | ||
| 269 | + return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); | ||
| 270 | }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); | ||
| 271 | return { | ||
| 272 | timeout: latch.await, | ||
| 273 | @@ -820,6 +849,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun | ||
| 274 | * @since 4.0.0 | ||
| 275 | */ | ||
| 276 | export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PURE__*/Layer.effect(Protocol)); | ||
| 277 | +/** | ||
| 278 | + * @since 4.0.0 | ||
| 279 | + * @category RequestHooks | ||
| 280 | + */ | ||
| 281 | +export class RequestHooks extends /*#__PURE__*/Context.Service()("effect/rpc/RpcClient/RequestHooks") {} | ||
| 282 | /** | ||
| 283 | * Represents optional client protocol hooks that run when a transport connects | ||
| 284 | * and disconnects. | ||
| 285 | @@ -835,4 +869,4 @@ export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PU | ||
| 286 | export class ConnectionHooks extends /*#__PURE__*/Context.Service()("effect/rpc/RpcClient/ConnectionHooks") {} | ||
| 287 | // internal | ||
| 288 | const decodeDefect = /*#__PURE__*/Schema.decodeSync(Schema.Defect); | ||
| 289 | -//# sourceMappingURL=RpcClient.js.map | ||
| 290 | \ No newline at end of file | ||
| 291 | +//# sourceMappingURL=RpcClient.js.map | ||
patches/effect@4.0.0-beta.78.patch created+291| ... | @@ -0,0 +1,291 @@ | ||
| 1 | diff --git a/dist/unstable/rpc/RpcClient.d.ts b/dist/unstable/rpc/RpcClient.d.ts | ||
| 2 | index b0f61df..ead663e 100644 | ||
| 3 | --- a/dist/unstable/rpc/RpcClient.d.ts | ||
| 4 | +++ b/dist/unstable/rpc/RpcClient.d.ts | ||
| 5 | @@ -2,6 +2,7 @@ import * as Cause from "../../Cause.ts"; | ||
| 6 | import * as Context from "../../Context.ts"; | ||
| 7 | import type * as Duration from "../../Duration.ts"; | ||
| 8 | import * as Effect from "../../Effect.ts"; | ||
| 9 | +import * as Exit from "../../Exit.ts"; | ||
| 10 | import * as Layer from "../../Layer.ts"; | ||
| 11 | import * as Queue from "../../Queue.ts"; | ||
| 12 | import * as Schedule from "../../Schedule.ts"; | ||
| 13 | @@ -260,9 +261,40 @@ export declare const layerProtocolWorker: (options: { | ||
| 14 | readonly targetUtilization?: number | undefined; | ||
| 15 | readonly timeToLive: Duration.Input; | ||
| 16 | }) => Layer.Layer<Protocol, WorkerError, Worker.WorkerPlatform | Worker.Spawner>; | ||
| 17 | +declare const RequestHooks_base: Context.ServiceClass<RequestHooks, "effect/rpc/RpcClient/RequestHooks", { | ||
| 18 | + readonly onRequestStart?: ((info: { | ||
| 19 | + readonly id: RequestId; | ||
| 20 | + readonly tag: string; | ||
| 21 | + readonly stream: boolean; | ||
| 22 | + }) => Effect.Effect<void>) | undefined; | ||
| 23 | + readonly onRequestChunk?: ((info: { | ||
| 24 | + readonly id: RequestId; | ||
| 25 | + readonly tag: string; | ||
| 26 | + readonly chunkCount: number; | ||
| 27 | + }) => Effect.Effect<void>) | undefined; | ||
| 28 | + readonly onRequestExit?: ((info: { | ||
| 29 | + readonly id: RequestId; | ||
| 30 | + readonly tag: string; | ||
| 31 | + readonly stream: boolean; | ||
| 32 | + readonly exit: Exit.Exit<unknown, unknown>; | ||
| 33 | + }) => Effect.Effect<void>) | undefined; | ||
| 34 | + readonly onRequestInterrupt?: ((info: { | ||
| 35 | + readonly id: RequestId; | ||
| 36 | + readonly tag?: string | undefined; | ||
| 37 | + }) => Effect.Effect<void>) | undefined; | ||
| 38 | +}>; | ||
| 39 | +/** | ||
| 40 | + * @since 4.0.0 | ||
| 41 | + * @category RequestHooks | ||
| 42 | + */ | ||
| 43 | +export declare class RequestHooks extends RequestHooks_base { | ||
| 44 | +} | ||
| 45 | declare const ConnectionHooks_base: Context.ServiceClass<ConnectionHooks, "effect/rpc/RpcClient/ConnectionHooks", { | ||
| 46 | readonly onConnect: Effect.Effect<void>; | ||
| 47 | readonly onDisconnect: Effect.Effect<void>; | ||
| 48 | + readonly onPing?: Effect.Effect<void> | undefined; | ||
| 49 | + readonly onPong?: Effect.Effect<void> | undefined; | ||
| 50 | + readonly onPingTimeout?: Effect.Effect<void> | undefined; | ||
| 51 | }>; | ||
| 52 | /** | ||
| 53 | * Represents optional client protocol hooks that run when a transport connects | ||
| 54 | @@ -279,4 +311,4 @@ declare const ConnectionHooks_base: Context.ServiceClass<ConnectionHooks, "effec | ||
| 55 | export declare class ConnectionHooks extends ConnectionHooks_base { | ||
| 56 | } | ||
| 57 | export {}; | ||
| 58 | -//# sourceMappingURL=RpcClient.d.ts.map | ||
| 59 | \ No newline at end of file | ||
| 60 | +//# sourceMappingURL=RpcClient.d.ts.map | ||
| 61 | diff --git a/dist/unstable/rpc/RpcClient.js b/dist/unstable/rpc/RpcClient.js | ||
| 62 | index a3161eb..0cb81f3 100644 | ||
| 63 | --- a/dist/unstable/rpc/RpcClient.js | ||
| 64 | +++ b/dist/unstable/rpc/RpcClient.js | ||
| 65 | @@ -46,6 +46,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 66 | const services = yield* Effect.context(); | ||
| 67 | const scope = Context.get(services, Scope.Scope); | ||
| 68 | const entries = new Map(); | ||
| 69 | + const hooks = yield* Effect.serviceOption(RequestHooks); | ||
| 70 | let isShutdown = false; | ||
| 71 | yield* Scope.addFinalizer(scope, Effect.withFiber(parent => { | ||
| 72 | isShutdown = true; | ||
| 73 | @@ -83,6 +84,11 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 74 | return Effect.interrupt; | ||
| 75 | } | ||
| 76 | const id = generateRequestId(); | ||
| 77 | + const onStart = Option.isSome(hooks) && hooks.value.onRequestStart ? hooks.value.onRequestStart({ | ||
| 78 | + id, | ||
| 79 | + tag: rpc._tag, | ||
| 80 | + stream: false | ||
| 81 | + }) : Effect.void; | ||
| 82 | const send = middleware(message => options.onFromClient({ | ||
| 83 | message, | ||
| 84 | context, | ||
| 85 | @@ -100,7 +106,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 86 | headers: Headers.merge(parentFiber.getRef(CurrentHeaders), headers) | ||
| 87 | }); | ||
| 88 | if (discard) { | ||
| 89 | - return send; | ||
| 90 | + return onStart.pipe(Effect.andThen(send)); | ||
| 91 | } | ||
| 92 | let fiber; | ||
| 93 | return Effect.onInterrupt(Effect.callback(resume => { | ||
| 94 | @@ -118,7 +124,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 95 | } | ||
| 96 | }; | ||
| 97 | entries.set(id, entry); | ||
| 98 | - fiber = send.pipe(span ? Effect.withParentSpan(span, { | ||
| 99 | + fiber = onStart.pipe(Effect.andThen(send), span ? Effect.withParentSpan(span, { | ||
| 100 | captureStackTrace: false | ||
| 101 | }) : identity, Effect.runForkWith(parentFiber.context)); | ||
| 102 | fiber.addObserver(exit => { | ||
| 103 | @@ -127,8 +133,9 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 104 | } | ||
| 105 | }); | ||
| 106 | }), interruptors => { | ||
| 107 | + const entry = entries.get(id); | ||
| 108 | entries.delete(id); | ||
| 109 | - return Effect.andThen(Fiber.interrupt(fiber), sendInterrupt(id, Array.from(interruptors), context)); | ||
| 110 | + return Effect.andThen(Fiber.interrupt(fiber), sendInterrupt(id, Array.from(interruptors), context, entry?.rpc._tag)); | ||
| 111 | }); | ||
| 112 | }); | ||
| 113 | const onStreamRequest = Effect.fnUntraced(function* (rpc, middleware, payload, headers, streamBufferSize, context) { | ||
| 114 | @@ -140,11 +147,17 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 115 | }); | ||
| 116 | const fiber = Fiber.getCurrent(); | ||
| 117 | const id = generateRequestId(); | ||
| 118 | + const onStart = Option.isSome(hooks) && hooks.value.onRequestStart ? hooks.value.onRequestStart({ | ||
| 119 | + id, | ||
| 120 | + tag: rpc._tag, | ||
| 121 | + stream: true | ||
| 122 | + }) : Effect.void; | ||
| 123 | const scope = Context.getUnsafe(fiber.context, Scope.Scope); | ||
| 124 | yield* Scope.addFinalizerExit(scope, exit => { | ||
| 125 | - if (!entries.has(id)) return Effect.void; | ||
| 126 | + const entry = entries.get(id); | ||
| 127 | + if (!entry) return Effect.void; | ||
| 128 | entries.delete(id); | ||
| 129 | - return sendInterrupt(id, Exit.isFailure(exit) ? Array.from(Cause.interruptors(exit.cause)) : [], context); | ||
| 130 | + return sendInterrupt(id, Exit.isFailure(exit) ? Array.from(Cause.interruptors(exit.cause)) : [], context, entry.rpc._tag); | ||
| 131 | }); | ||
| 132 | const queue = yield* Queue.bounded(streamBufferSize); | ||
| 133 | entries.set(id, { | ||
| 134 | @@ -152,9 +165,10 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 135 | rpc, | ||
| 136 | queue, | ||
| 137 | scope, | ||
| 138 | - context | ||
| 139 | + context, | ||
| 140 | + chunkCount: 0 | ||
| 141 | }); | ||
| 142 | - yield* middleware(message => options.onFromClient({ | ||
| 143 | + yield* onStart.pipe(Effect.andThen(middleware(message => options.onFromClient({ | ||
| 144 | message, | ||
| 145 | context, | ||
| 146 | discard: false | ||
| 147 | @@ -169,7 +183,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 148 | sampled: span.sampled | ||
| 149 | } : {}), | ||
| 150 | headers: Headers.merge(fiber.getRef(CurrentHeaders), headers) | ||
| 151 | - }).pipe(span ? Effect.withParentSpan(span, { | ||
| 152 | + }))).pipe(span ? Effect.withParentSpan(span, { | ||
| 153 | captureStackTrace: false | ||
| 154 | }) : identity, Effect.catchCause(error => Queue.failCause(queue, error)), Effect.interruptible, Effect.forkIn(scope, { | ||
| 155 | startImmediately: true | ||
| 156 | @@ -199,7 +213,10 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 157 | }); | ||
| 158 | }; | ||
| 159 | }; | ||
| 160 | - const sendInterrupt = (requestId, interruptors, context) => Effect.callback(resume => { | ||
| 161 | + const sendInterrupt = (requestId, interruptors, context, tag) => (Option.isSome(hooks) && hooks.value.onRequestInterrupt ? hooks.value.onRequestInterrupt({ | ||
| 162 | + id: requestId, | ||
| 163 | + tag | ||
| 164 | + }) : Effect.void).pipe(Effect.andThen(Effect.callback(resume => { | ||
| 165 | const parentFiber = Fiber.getCurrent(); | ||
| 166 | const fiber = options.onFromClient({ | ||
| 167 | message: { | ||
| 168 | @@ -213,7 +230,7 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 169 | fiber.addObserver(() => { | ||
| 170 | resume(Effect.void); | ||
| 171 | }); | ||
| 172 | - }); | ||
| 173 | + }))); | ||
| 174 | const write = message => { | ||
| 175 | switch (message._tag) { | ||
| 176 | case "Chunk": | ||
| 177 | @@ -221,7 +238,13 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 178 | const requestId = message.requestId; | ||
| 179 | const entry = entries.get(requestId); | ||
| 180 | if (!entry || entry._tag !== "Queue") return Effect.void; | ||
| 181 | - return Queue.offerAll(entry.queue, message.values).pipe(supportsAck ? Effect.flatMap(() => options.onFromClient({ | ||
| 182 | + const chunkCount = entry.chunkCount += message.values.length; | ||
| 183 | + const onChunk = Option.isSome(hooks) && hooks.value.onRequestChunk ? hooks.value.onRequestChunk({ | ||
| 184 | + id: requestId, | ||
| 185 | + tag: entry.rpc._tag, | ||
| 186 | + chunkCount | ||
| 187 | + }) : Effect.void; | ||
| 188 | + return Queue.offerAll(entry.queue, message.values).pipe(Effect.andThen(onChunk), supportsAck ? Effect.flatMap(() => options.onFromClient({ | ||
| 189 | message: { | ||
| 190 | _tag: "Ack", | ||
| 191 | requestId: message.requestId | ||
| 192 | @@ -236,11 +259,18 @@ export const makeNoSerialization = /*#__PURE__*/Effect.fnUntraced(function* (gro | ||
| 193 | const entry = entries.get(requestId); | ||
| 194 | if (!entry) return Effect.void; | ||
| 195 | entries.delete(requestId); | ||
| 196 | + const onExit = Option.isSome(hooks) && hooks.value.onRequestExit ? hooks.value.onRequestExit({ | ||
| 197 | + id: requestId, | ||
| 198 | + tag: entry.rpc._tag, | ||
| 199 | + stream: entry._tag === "Queue", | ||
| 200 | + exit: message.exit | ||
| 201 | + }) : Effect.void; | ||
| 202 | if (entry._tag === "Effect") { | ||
| 203 | - entry.resume(message.exit); | ||
| 204 | - return Effect.void; | ||
| 205 | + return onExit.pipe(Effect.andThen(Effect.sync(() => { | ||
| 206 | + entry.resume(message.exit); | ||
| 207 | + }))); | ||
| 208 | } | ||
| 209 | - return message.exit._tag === "Success" ? Queue.end(entry.queue) : Queue.failCause(entry.queue, message.exit.cause); | ||
| 210 | + return onExit.pipe(Effect.andThen(message.exit._tag === "Success" ? Queue.end(entry.queue) : Queue.failCause(entry.queue, message.exit.cause))); | ||
| 211 | } | ||
| 212 | case "Defect": | ||
| 213 | { | ||
| 214 | @@ -568,7 +598,7 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 215 | const requestClientMap = new Map(); | ||
| 216 | const write = yield* socket.writer; | ||
| 217 | let parser = serialization.makeUnsafe(); | ||
| 218 | - const pinger = yield* makePinger(write(parser.encode(constPing))); | ||
| 219 | + const pinger = yield* makePinger(write(parser.encode(constPing)), Option.isSome(hooks) ? hooks.value : undefined); | ||
| 220 | let currentError; | ||
| 221 | const onOpen = Effect.suspend(() => { | ||
| 222 | currentError = undefined; | ||
| 223 | @@ -588,8 +618,7 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 224 | body: () => { | ||
| 225 | const response = responses[i++]; | ||
| 226 | if (response._tag === "Pong") { | ||
| 227 | - pinger.onPong(); | ||
| 228 | - return Effect.void; | ||
| 229 | + return pinger.onPong; | ||
| 230 | } | ||
| 231 | if ("requestId" in response) { | ||
| 232 | const clientId = requestClientMap.get(response.requestId); | ||
| 233 | @@ -617,12 +646,12 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 234 | } | ||
| 235 | }, { | ||
| 236 | onOpen | ||
| 237 | - }).pipe(Effect.raceFirst(Effect.flatMap(pinger.timeout, () => Effect.fail(new Socket.SocketError({ | ||
| 238 | + }).pipe(Effect.raceFirst(Effect.flatMap(pinger.timeout, () => (Option.isSome(hooks) && hooks.value.onPingTimeout ? hooks.value.onPingTimeout : Effect.void).pipe(Effect.andThen(Effect.fail(new Socket.SocketError({ | ||
| 239 | reason: new Socket.SocketOpenError({ | ||
| 240 | kind: "Timeout", | ||
| 241 | cause: new Error("ping timeout") | ||
| 242 | }) | ||
| 243 | - }))))); | ||
| 244 | + }))))))); | ||
| 245 | }).pipe(Effect.flatMap(() => Effect.fail(new Socket.SocketError({ | ||
| 246 | reason: new Socket.SocketCloseError({ | ||
| 247 | code: 1000 | ||
| 248 | @@ -664,20 +693,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun | ||
| 249 | }; | ||
| 250 | })); | ||
| 251 | const defaultRetryPolicy = /*#__PURE__*/Schedule.exponential(500, 1.5).pipe(/*#__PURE__*/Schedule.either(/*#__PURE__*/Schedule.spaced(5000))); | ||
| 252 | -const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) { | ||
| 253 | +const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) { | ||
| 254 | let recievedPong = true; | ||
| 255 | const latch = Latch.makeUnsafe(); | ||
| 256 | const reset = () => { | ||
| 257 | recievedPong = true; | ||
| 258 | latch.closeUnsafe(); | ||
| 259 | }; | ||
| 260 | - const onPong = () => { | ||
| 261 | + const onPong = Effect.sync(() => { | ||
| 262 | recievedPong = true; | ||
| 263 | - }; | ||
| 264 | + }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void)); | ||
| 265 | yield* Effect.suspend(() => { | ||
| 266 | if (!recievedPong) return latch.open; | ||
| 267 | recievedPong = false; | ||
| 268 | - return writePing; | ||
| 269 | + return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); | ||
| 270 | }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); | ||
| 271 | return { | ||
| 272 | timeout: latch.await, | ||
| 273 | @@ -820,6 +849,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun | ||
| 274 | * @since 4.0.0 | ||
| 275 | */ | ||
| 276 | export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PURE__*/Layer.effect(Protocol)); | ||
| 277 | +/** | ||
| 278 | + * @since 4.0.0 | ||
| 279 | + * @category RequestHooks | ||
| 280 | + */ | ||
| 281 | +export class RequestHooks extends /*#__PURE__*/Context.Service()("effect/rpc/RpcClient/RequestHooks") {} | ||
| 282 | /** | ||
| 283 | * Represents optional client protocol hooks that run when a transport connects | ||
| 284 | * and disconnects. | ||
| 285 | @@ -835,4 +869,4 @@ export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PU | ||
| 286 | export class ConnectionHooks extends /*#__PURE__*/Context.Service()("effect/rpc/RpcClient/ConnectionHooks") {} | ||
| 287 | // internal | ||
| 288 | const decodeDefect = /*#__PURE__*/Schema.decodeSync(/*#__PURE__*/Schema.Defect()); | ||
| 289 | -//# sourceMappingURL=RpcClient.js.map | ||
| 290 | \ No newline at end of file | ||
| 291 | +//# sourceMappingURL=RpcClient.js.map | ||
pnpm-lock.yaml+3424-440| ... | @@ -7,11 +7,17 @@ settings: | ... | @@ -7,11 +7,17 @@ settings: |
| 7 | catalogs: | 7 | catalogs: |
| 8 | default: | 8 | default: |
| 9 | '@effect/openapi-generator': | 9 | '@effect/openapi-generator': |
| 10 | specifier: 4.0.0-beta.73 | 10 | specifier: 4.0.0-beta.78 |
| 11 | version: 4.0.0-beta.73 | 11 | version: 4.0.0-beta.78 |
| 12 | '@effect/tsgo': | 12 | '@effect/tsgo': |
| 13 | specifier: 0.11.4 | 13 | specifier: 0.11.4 |
| 14 | version: 0.11.4 | 14 | version: 0.11.4 |
| 15 | '@noble/curves': | ||
| 16 | specifier: 1.9.1 | ||
| 17 | version: 1.9.1 | ||
| 18 | '@noble/hashes': | ||
| 19 | specifier: 1.8.0 | ||
| 20 | version: 1.8.0 | ||
| 15 | '@pierre/diffs': | 21 | '@pierre/diffs': |
| 16 | specifier: 1.1.20 | 22 | specifier: 1.1.20 |
| 17 | version: 1.1.20 | 23 | version: 1.1.20 |
| ... | @@ -24,6 +30,9 @@ catalogs: | ... | @@ -24,6 +30,9 @@ catalogs: |
| 24 | '@vitest/runner': | 30 | '@vitest/runner': |
| 25 | specifier: ^4.1.8 | 31 | specifier: ^4.1.8 |
| 26 | version: 4.1.8 | 32 | version: 4.1.8 |
| 33 | jose: | ||
| 34 | specifier: 6.2.2 | ||
| 35 | version: 6.2.2 | ||
| 27 | typescript: | 36 | typescript: |
| 28 | specifier: ~6.0.3 | 37 | specifier: ~6.0.3 |
| 29 | version: 6.0.3 | 38 | version: 6.0.3 |
| ... | @@ -32,14 +41,21 @@ catalogs: | ... | @@ -32,14 +41,21 @@ catalogs: |
| 32 | version: 0.1.24 | 41 | version: 0.1.24 |
| 33 | 42 | ||
| 34 | overrides: | 43 | overrides: |
| 35 | '@effect/atom-react': 4.0.0-beta.73 | 44 | '@clerk/clerk-js>@base-org/account': '-' |
| 36 | '@effect/platform-bun': 4.0.0-beta.73 | 45 | '@clerk/clerk-js>@coinbase/wallet-sdk': '-' |
| 37 | '@effect/platform-node': 4.0.0-beta.73 | 46 | '@clerk/clerk-js>@solana/wallet-adapter-base': '-' |
| 38 | '@effect/platform-node-shared': 4.0.0-beta.73 | 47 | '@clerk/clerk-js>@solana/wallet-adapter-react': '-' |
| 39 | '@effect/sql-sqlite-bun': 4.0.0-beta.73 | 48 | '@clerk/clerk-js>@solana/wallet-standard': '-' |
| 40 | '@effect/vitest': 4.0.0-beta.73 | 49 | '@clerk/clerk-js>@wallet-standard/core': '-' |
| 50 | '@effect/atom-react': 4.0.0-beta.78 | ||
| 51 | '@effect/platform-bun': 4.0.0-beta.78 | ||
| 52 | '@effect/platform-node': 4.0.0-beta.78 | ||
| 53 | '@effect/platform-node-shared': 4.0.0-beta.78 | ||
| 54 | '@effect/sql-pg': 4.0.0-beta.78 | ||
| 55 | '@effect/sql-sqlite-bun': 4.0.0-beta.78 | ||
| 56 | '@effect/vitest': 4.0.0-beta.78 | ||
| 41 | '@types/node': 24.12.4 | 57 | '@types/node': 24.12.4 |
| 42 | effect: 4.0.0-beta.73 | 58 | effect: 4.0.0-beta.78 |
| 43 | vite: npm:@voidzero-dev/vite-plus-core@latest | 59 | vite: npm:@voidzero-dev/vite-plus-core@latest |
| 44 | vitest: npm:@voidzero-dev/vite-plus-test@latest | 60 | vitest: npm:@voidzero-dev/vite-plus-test@latest |
| 45 | yaml: ^2.9.0 | 61 | yaml: ^2.9.0 |
| ... | @@ -53,9 +69,9 @@ patchedDependencies: | ... | @@ -53,9 +69,9 @@ patchedDependencies: |
| 53 | '@pierre/diffs@1.1.20': | 69 | '@pierre/diffs@1.1.20': |
| 54 | hash: e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0 | 70 | hash: e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0 |
| 55 | path: patches/@pierre%2Fdiffs@1.1.20.patch | 71 | path: patches/@pierre%2Fdiffs@1.1.20.patch |
| 56 | effect@4.0.0-beta.73: | 72 | effect@4.0.0-beta.78: |
| 57 | hash: a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01 | 73 | hash: 883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754 |
| 58 | path: patches/effect@4.0.0-beta.73.patch | 74 | path: patches/effect@4.0.0-beta.78.patch |
| 59 | react-native-nitro-modules@0.35.9: | 75 | react-native-nitro-modules@0.35.9: |
| 60 | hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 | 76 | hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 |
| 61 | path: patches/react-native-nitro-modules@0.35.9.patch | 77 | path: patches/react-native-nitro-modules@0.35.9.patch |
| ... | @@ -87,16 +103,16 @@ importers: | ... | @@ -87,16 +103,16 @@ importers: |
| 87 | 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)' | 103 | 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)' |
| 88 | vite-plus: | 104 | vite-plus: |
| 89 | specifier: 'catalog:' | 105 | specifier: 'catalog:' |
| 90 | 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) | 106 | 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) |
| 91 | vitest: | 107 | vitest: |
| 92 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 108 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 93 | 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)' | 109 | 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)' |
| 94 | 110 | ||
| 95 | apps/desktop: | 111 | apps/desktop: |
| 96 | dependencies: | 112 | dependencies: |
| 97 | '@effect/platform-node': | 113 | '@effect/platform-node': |
| 98 | specifier: 4.0.0-beta.73 | 114 | specifier: 4.0.0-beta.78 |
| 99 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 115 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 100 | '@t3tools/client-runtime': | 116 | '@t3tools/client-runtime': |
| 101 | specifier: workspace:* | 117 | specifier: workspace:* |
| 102 | version: link:../../packages/client-runtime | 118 | version: link:../../packages/client-runtime |
| ... | @@ -113,8 +129,8 @@ importers: | ... | @@ -113,8 +129,8 @@ importers: |
| 113 | specifier: workspace:* | 129 | specifier: workspace:* |
| 114 | version: link:../../packages/tailscale | 130 | version: link:../../packages/tailscale |
| 115 | effect: | 131 | effect: |
| 116 | specifier: 4.0.0-beta.73 | 132 | specifier: 4.0.0-beta.78 |
| 117 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 133 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 118 | electron: | 134 | electron: |
| 119 | specifier: 41.5.0 | 135 | specifier: 41.5.0 |
| 120 | version: 41.5.0 | 136 | version: 41.5.0 |
| ... | @@ -123,8 +139,8 @@ importers: | ... | @@ -123,8 +139,8 @@ importers: |
| 123 | version: 6.8.3 | 139 | version: 6.8.3 |
| 124 | devDependencies: | 140 | devDependencies: |
| 125 | '@effect/vitest': | 141 | '@effect/vitest': |
| 126 | specifier: 4.0.0-beta.73 | 142 | specifier: 4.0.0-beta.78 |
| 127 | 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)) | 143 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 128 | '@types/node': | 144 | '@types/node': |
| 129 | specifier: 24.12.4 | 145 | specifier: 24.12.4 |
| 130 | version: 24.12.4 | 146 | version: 24.12.4 |
| ... | @@ -136,16 +152,16 @@ importers: | ... | @@ -136,16 +152,16 @@ importers: |
| 136 | version: 26.8.1(electron-builder-squirrel-windows@26.8.1) | 152 | version: 26.8.1(electron-builder-squirrel-windows@26.8.1) |
| 137 | vite-plus: | 153 | vite-plus: |
| 138 | specifier: 'catalog:' | 154 | specifier: 'catalog:' |
| 139 | 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) | 155 | 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) |
| 140 | vitest: | 156 | vitest: |
| 141 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 157 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 142 | 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)' | 158 | 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)' |
| 143 | 159 | ||
| 144 | apps/marketing: | 160 | apps/marketing: |
| 145 | dependencies: | 161 | dependencies: |
| 146 | astro: | 162 | astro: |
| 147 | specifier: ^6.0.4 | 163 | specifier: ^6.0.4 |
| 148 | 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) | 164 | 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) |
| 149 | devDependencies: | 165 | devDependencies: |
| 150 | '@astrojs/check': | 166 | '@astrojs/check': |
| 151 | specifier: ^0.9.7 | 167 | specifier: ^0.9.7 |
| ... | @@ -161,25 +177,34 @@ importers: | ... | @@ -161,25 +177,34 @@ importers: |
| 161 | dependencies: | 177 | dependencies: |
| 162 | '@callstack/liquid-glass': | 178 | '@callstack/liquid-glass': |
| 163 | specifier: ^0.7.1 | 179 | specifier: ^0.7.1 |
| 164 | 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) | 180 | version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 181 | '@clerk/expo': | ||
| 182 | specifier: ^3.3.0 | ||
| 183 | version: 3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 165 | '@effect/atom-react': | 184 | '@effect/atom-react': |
| 166 | specifier: 4.0.0-beta.73 | 185 | specifier: 4.0.0-beta.78 |
| 167 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(react@19.2.3)(scheduler@0.27.0) | 186 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0) |
| 168 | '@expo-google-fonts/dm-sans': | 187 | '@expo-google-fonts/dm-sans': |
| 169 | specifier: ^0.4.2 | 188 | specifier: ^0.4.2 |
| 170 | version: 0.4.2 | 189 | version: 0.4.2 |
| 171 | '@expo/ui': | 190 | '@expo/ui': |
| 172 | specifier: ~56.0.8 | 191 | specifier: ~56.0.8 |
| 173 | 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) | 192 | version: 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) |
| 174 | '@legendapp/list': | 193 | '@legendapp/list': |
| 175 | specifier: 3.0.0-beta.44 | 194 | specifier: 3.0.0-beta.44 |
| 176 | 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) | 195 | version: 3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 196 | '@noble/curves': | ||
| 197 | specifier: 'catalog:' | ||
| 198 | version: 1.9.1 | ||
| 199 | '@noble/hashes': | ||
| 200 | specifier: 'catalog:' | ||
| 201 | version: 1.8.0 | ||
| 177 | '@pierre/diffs': | 202 | '@pierre/diffs': |
| 178 | specifier: 'catalog:' | 203 | specifier: 'catalog:' |
| 179 | version: 1.1.20(patch_hash=e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 204 | version: 1.1.20(patch_hash=e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| 180 | '@react-native-menu/menu': | 205 | '@react-native-menu/menu': |
| 181 | specifier: ^2.0.0 | 206 | specifier: ^2.0.0 |
| 182 | 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) | 207 | version: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 183 | '@shikijs/core': | 208 | '@shikijs/core': |
| 184 | specifier: 3.23.0 | 209 | specifier: 3.23.0 |
| 185 | version: 3.23.0 | 210 | version: 3.23.0 |
| ... | @@ -214,38 +239,41 @@ importers: | ... | @@ -214,38 +239,41 @@ importers: |
| 214 | specifier: 8.0.3 | 239 | specifier: 8.0.3 |
| 215 | version: 8.0.3 | 240 | version: 8.0.3 |
| 216 | effect: | 241 | effect: |
| 217 | specifier: 4.0.0-beta.73 | 242 | specifier: 4.0.0-beta.78 |
| 218 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 243 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 219 | expo: | 244 | expo: |
| 220 | specifier: ^56.0.0 | 245 | specifier: ^56.0.0 |
| 221 | 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) | 246 | version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 247 | expo-auth-session: | ||
| 248 | specifier: ~56.0.12 | ||
| 249 | version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 222 | expo-build-properties: | 250 | expo-build-properties: |
| 223 | specifier: ~56.0.15 | 251 | specifier: ~56.0.15 |
| 224 | version: 56.0.16(expo@56.0.8) | 252 | version: 56.0.16(expo@56.0.8) |
| 225 | expo-camera: | 253 | expo-camera: |
| 226 | specifier: ~56.0.7 | 254 | specifier: ~56.0.7 |
| 227 | 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) | 255 | version: 56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 228 | expo-clipboard: | 256 | expo-clipboard: |
| 229 | specifier: ~56.0.3 | 257 | specifier: ~56.0.3 |
| 230 | 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) | 258 | version: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 231 | expo-constants: | 259 | expo-constants: |
| 232 | specifier: ~56.0.16 | 260 | specifier: ~56.0.16 |
| 233 | 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)) | 261 | version: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 234 | expo-crypto: | 262 | expo-crypto: |
| 235 | specifier: ~56.0.4 | 263 | specifier: ~56.0.4 |
| 236 | version: 56.0.4(expo@56.0.8) | 264 | version: 56.0.4(expo@56.0.8) |
| 237 | expo-dev-client: | 265 | expo-dev-client: |
| 238 | specifier: ~56.0.16 | 266 | specifier: ~56.0.16 |
| 239 | 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)) | 267 | version: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 240 | expo-file-system: | 268 | expo-file-system: |
| 241 | specifier: ~56.0.7 | 269 | specifier: ~56.0.7 |
| 242 | 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)) | 270 | version: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 243 | expo-font: | 271 | expo-font: |
| 244 | specifier: ~56.0.5 | 272 | specifier: ~56.0.5 |
| 245 | 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) | 273 | version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 246 | expo-glass-effect: | 274 | expo-glass-effect: |
| 247 | specifier: ~56.0.4 | 275 | specifier: ~56.0.4 |
| 248 | 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) | 276 | version: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 249 | expo-haptics: | 277 | expo-haptics: |
| 250 | specifier: ~56.0.3 | 278 | specifier: ~56.0.3 |
| 251 | version: 56.0.3(expo@56.0.8) | 279 | version: 56.0.3(expo@56.0.8) |
| ... | @@ -254,13 +282,16 @@ importers: | ... | @@ -254,13 +282,16 @@ importers: |
| 254 | version: 56.0.15(expo@56.0.8) | 282 | version: 56.0.15(expo@56.0.8) |
| 255 | expo-linking: | 283 | expo-linking: |
| 256 | specifier: ~56.0.12 | 284 | specifier: ~56.0.12 |
| 257 | 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) | 285 | version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 286 | expo-notifications: | ||
| 287 | specifier: ~56.0.14 | ||
| 288 | version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) | ||
| 258 | expo-paste-input: | 289 | expo-paste-input: |
| 259 | specifier: ^0.1.15 | 290 | specifier: ^0.1.15 |
| 260 | 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) | 291 | version: 0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 261 | expo-router: | 292 | expo-router: |
| 262 | specifier: ~56.2.7 | 293 | specifier: ~56.2.7 |
| 263 | version: 56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51) | 294 | version: 56.2.8(c021de11d02907bd585610408f5252e8) |
| 264 | expo-secure-store: | 295 | expo-secure-store: |
| 265 | specifier: ~56.0.4 | 296 | specifier: ~56.0.4 |
| 266 | version: 56.0.4(expo@56.0.8) | 297 | version: 56.0.4(expo@56.0.8) |
| ... | @@ -269,10 +300,16 @@ importers: | ... | @@ -269,10 +300,16 @@ importers: |
| 269 | version: 56.0.10(expo@56.0.8)(typescript@6.0.3) | 300 | version: 56.0.10(expo@56.0.8)(typescript@6.0.3) |
| 270 | expo-symbols: | 301 | expo-symbols: |
| 271 | specifier: ~56.0.5 | 302 | specifier: ~56.0.5 |
| 272 | 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) | 303 | version: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 273 | expo-updates: | 304 | expo-updates: |
| 274 | specifier: ~56.0.17 | 305 | specifier: ~56.0.17 |
| 275 | 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) | 306 | version: 56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 307 | expo-web-browser: | ||
| 308 | specifier: ~56.0.5 | ||
| 309 | version: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | ||
| 310 | expo-widgets: | ||
| 311 | specifier: ~56.0.15 | ||
| 312 | version: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) | ||
| 276 | punycode: | 313 | punycode: |
| 277 | specifier: ^2.3.1 | 314 | specifier: ^2.3.1 |
| 278 | version: 2.3.1 | 315 | version: 2.3.1 |
| ... | @@ -284,40 +321,40 @@ importers: | ... | @@ -284,40 +321,40 @@ importers: |
| 284 | version: 19.2.3(react@19.2.3) | 321 | version: 19.2.3(react@19.2.3) |
| 285 | react-native: | 322 | react-native: |
| 286 | specifier: 0.85.3 | 323 | specifier: 0.85.3 |
| 287 | 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) | 324 | version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 288 | react-native-gesture-handler: | 325 | react-native-gesture-handler: |
| 289 | specifier: ~2.31.1 | 326 | specifier: ~2.31.1 |
| 290 | 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) | 327 | version: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 291 | react-native-image-viewing: | 328 | react-native-image-viewing: |
| 292 | specifier: ^0.2.2 | 329 | specifier: ^0.2.2 |
| 293 | 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) | 330 | version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 294 | react-native-keyboard-controller: | 331 | react-native-keyboard-controller: |
| 295 | specifier: 1.21.6 | 332 | specifier: 1.21.6 |
| 296 | 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) | 333 | version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 297 | react-native-nitro-markdown: | 334 | react-native-nitro-markdown: |
| 298 | specifier: ^0.5.0 | 335 | specifier: ^0.5.0 |
| 299 | 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) | 336 | version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 300 | react-native-nitro-modules: | 337 | react-native-nitro-modules: |
| 301 | specifier: ^0.35.4 | 338 | specifier: ^0.35.4 |
| 302 | 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) | 339 | version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 303 | react-native-reanimated: | 340 | react-native-reanimated: |
| 304 | specifier: 4.3.1 | 341 | specifier: 4.3.1 |
| 305 | 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) | 342 | version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 306 | react-native-safe-area-context: | 343 | react-native-safe-area-context: |
| 307 | specifier: ~5.7.0 | 344 | specifier: ~5.7.0 |
| 308 | 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) | 345 | version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 309 | react-native-screens: | 346 | react-native-screens: |
| 310 | specifier: 4.25.2 | 347 | specifier: 4.25.2 |
| 311 | 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) | 348 | version: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 312 | react-native-shiki-engine: | 349 | react-native-shiki-engine: |
| 313 | specifier: ^0.3.9 | 350 | specifier: ^0.3.9 |
| 314 | 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) | 351 | version: 0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 315 | react-native-svg: | 352 | react-native-svg: |
| 316 | specifier: 15.15.4 | 353 | specifier: 15.15.4 |
| 317 | 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) | 354 | version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 318 | react-native-worklets: | 355 | react-native-worklets: |
| 319 | specifier: 0.8.3 | 356 | specifier: 0.8.3 |
| 320 | 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) | 357 | version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 321 | shiki: | 358 | shiki: |
| 322 | specifier: 3.23.0 | 359 | specifier: 3.23.0 |
| 323 | version: 3.23.0 | 360 | version: 3.23.0 |
| ... | @@ -326,14 +363,17 @@ importers: | ... | @@ -326,14 +363,17 @@ importers: |
| 326 | version: 3.6.0 | 363 | version: 3.6.0 |
| 327 | uniwind: | 364 | uniwind: |
| 328 | specifier: ^1.6.2 | 365 | specifier: ^1.6.2 |
| 329 | 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) | 366 | version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) |
| 330 | devDependencies: | 367 | devDependencies: |
| 368 | '@effect/vitest': | ||
| 369 | specifier: 4.0.0-beta.78 | ||
| 370 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 331 | '@types/react': | 371 | '@types/react': |
| 332 | specifier: ~19.2.0 | 372 | specifier: ~19.2.0 |
| 333 | version: 19.2.16 | 373 | version: 19.2.16 |
| 334 | babel-preset-expo: | 374 | babel-preset-expo: |
| 335 | specifier: ~56.0.0 | 375 | specifier: ~56.0.0 |
| 336 | version: 56.0.14(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@56.0.8)(react-refresh@0.14.2) | 376 | 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) |
| 337 | tailwindcss: | 377 | tailwindcss: |
| 338 | specifier: ^4.0.0 | 378 | specifier: ^4.0.0 |
| 339 | version: 4.3.0 | 379 | version: 4.3.0 |
| ... | @@ -347,17 +387,17 @@ importers: | ... | @@ -347,17 +387,17 @@ importers: |
| 347 | specifier: ^0.3.154 | 387 | specifier: ^0.3.154 |
| 348 | 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) | 388 | 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) |
| 349 | '@effect/platform-bun': | 389 | '@effect/platform-bun': |
| 350 | specifier: 4.0.0-beta.73 | 390 | specifier: 4.0.0-beta.78 |
| 351 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) | 391 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) |
| 352 | '@effect/platform-node': | 392 | '@effect/platform-node': |
| 353 | specifier: 4.0.0-beta.73 | 393 | specifier: 4.0.0-beta.78 |
| 354 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 394 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 355 | '@effect/platform-node-shared': | 395 | '@effect/platform-node-shared': |
| 356 | specifier: 4.0.0-beta.73 | 396 | specifier: 4.0.0-beta.78 |
| 357 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) | 397 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) |
| 358 | '@effect/sql-sqlite-bun': | 398 | '@effect/sql-sqlite-bun': |
| 359 | specifier: 4.0.0-beta.73 | 399 | specifier: 4.0.0-beta.78 |
| 360 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) | 400 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 361 | '@opencode-ai/sdk': | 401 | '@opencode-ai/sdk': |
| 362 | specifier: ^1.3.15 | 402 | specifier: ^1.3.15 |
| 363 | version: 1.15.13 | 403 | version: 1.15.13 |
| ... | @@ -365,15 +405,15 @@ importers: | ... | @@ -365,15 +405,15 @@ importers: |
| 365 | specifier: 'catalog:' | 405 | specifier: 'catalog:' |
| 366 | version: 1.1.20(patch_hash=e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 406 | version: 1.1.20(patch_hash=e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 367 | effect: | 407 | effect: |
| 368 | specifier: 4.0.0-beta.73 | 408 | specifier: 4.0.0-beta.78 |
| 369 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 409 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 370 | node-pty: | 410 | node-pty: |
| 371 | specifier: ^1.1.0 | 411 | specifier: ^1.1.0 |
| 372 | version: 1.1.0 | 412 | version: 1.1.0 |
| 373 | devDependencies: | 413 | devDependencies: |
| 374 | '@effect/vitest': | 414 | '@effect/vitest': |
| 375 | specifier: 4.0.0-beta.73 | 415 | specifier: 4.0.0-beta.78 |
| 376 | 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)) | 416 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 377 | '@t3tools/contracts': | 417 | '@t3tools/contracts': |
| 378 | specifier: workspace:* | 418 | specifier: workspace:* |
| 379 | version: link:../../packages/contracts | 419 | version: link:../../packages/contracts |
| ... | @@ -400,16 +440,22 @@ importers: | ... | @@ -400,16 +440,22 @@ importers: |
| 400 | version: link:../../packages/effect-codex-app-server | 440 | version: link:../../packages/effect-codex-app-server |
| 401 | vite-plus: | 441 | vite-plus: |
| 402 | specifier: 'catalog:' | 442 | specifier: 'catalog:' |
| 403 | 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) | 443 | 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) |
| 404 | vitest: | 444 | vitest: |
| 405 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 445 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 406 | 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)' | 446 | 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)' |
| 407 | 447 | ||
| 408 | apps/web: | 448 | apps/web: |
| 409 | dependencies: | 449 | dependencies: |
| 410 | '@base-ui/react': | 450 | '@base-ui/react': |
| 411 | specifier: ^1.4.1 | 451 | specifier: ^1.4.1 |
| 412 | version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 452 | version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 453 | '@clerk/clerk-js': | ||
| 454 | specifier: ^6.13.0 | ||
| 455 | version: 6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 456 | '@clerk/react': | ||
| 457 | specifier: ^6.7.2 | ||
| 458 | version: 6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 413 | '@dnd-kit/core': | 459 | '@dnd-kit/core': |
| 414 | specifier: ^6.3.1 | 460 | specifier: ^6.3.1 |
| 415 | version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 461 | version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| ... | @@ -423,8 +469,8 @@ importers: | ... | @@ -423,8 +469,8 @@ importers: |
| 423 | specifier: ^3.2.2 | 469 | specifier: ^3.2.2 |
| 424 | version: 3.2.2(react@19.2.6) | 470 | version: 3.2.2(react@19.2.6) |
| 425 | '@effect/atom-react': | 471 | '@effect/atom-react': |
| 426 | specifier: 4.0.0-beta.73 | 472 | specifier: 4.0.0-beta.78 |
| 427 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(react@19.2.6)(scheduler@0.27.0) | 473 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.6)(scheduler@0.27.0) |
| 428 | '@formkit/auto-animate': | 474 | '@formkit/auto-animate': |
| 429 | specifier: ^0.9.0 | 475 | specifier: ^0.9.0 |
| 430 | version: 0.9.0 | 476 | version: 0.9.0 |
| ... | @@ -465,8 +511,11 @@ importers: | ... | @@ -465,8 +511,11 @@ importers: |
| 465 | specifier: ^0.7.1 | 511 | specifier: ^0.7.1 |
| 466 | version: 0.7.1 | 512 | version: 0.7.1 |
| 467 | effect: | 513 | effect: |
| 468 | specifier: 4.0.0-beta.73 | 514 | specifier: 4.0.0-beta.78 |
| 469 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 515 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 516 | jose: | ||
| 517 | specifier: 'catalog:' | ||
| 518 | version: 6.2.2 | ||
| 470 | lexical: | 519 | lexical: |
| 471 | specifier: ^0.41.0 | 520 | specifier: ^0.41.0 |
| 472 | version: 0.41.0 | 521 | version: 0.41.0 |
| ... | @@ -493,8 +542,11 @@ importers: | ... | @@ -493,8 +542,11 @@ importers: |
| 493 | version: 5.0.14(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) | 542 | version: 5.0.14(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) |
| 494 | devDependencies: | 543 | devDependencies: |
| 495 | '@effect/platform-node': | 544 | '@effect/platform-node': |
| 496 | specifier: 4.0.0-beta.73 | 545 | specifier: 4.0.0-beta.78 |
| 497 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 546 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 547 | '@effect/vitest': | ||
| 548 | specifier: 4.0.0-beta.78 | ||
| 549 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 498 | '@rolldown/plugin-babel': | 550 | '@rolldown/plugin-babel': |
| 499 | specifier: ^0.2.0 | 551 | specifier: ^0.2.0 |
| 500 | 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) | 552 | 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,38 +588,84 @@ importers: | ... | @@ -536,38 +588,84 @@ importers: |
| 536 | 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)' | 588 | 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)' |
| 537 | vite-plus: | 589 | vite-plus: |
| 538 | specifier: 'catalog:' | 590 | specifier: 'catalog:' |
| 539 | 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) | 591 | 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) |
| 540 | vitest: | 592 | vitest: |
| 541 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 593 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 542 | 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)' | 594 | 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)' |
| 543 | vitest-browser-react: | 595 | vitest-browser-react: |
| 544 | specifier: ^2.0.5 | 596 | specifier: ^2.0.5 |
| 545 | 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) | 597 | 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) |
| 598 | |||
| 599 | infra/relay: | ||
| 600 | dependencies: | ||
| 601 | '@clerk/backend': | ||
| 602 | specifier: 3.4.14 | ||
| 603 | version: 3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 604 | '@effect/sql-pg': | ||
| 605 | specifier: 4.0.0-beta.78 | ||
| 606 | version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 607 | '@t3tools/client-runtime': | ||
| 608 | specifier: workspace:* | ||
| 609 | version: link:../../packages/client-runtime | ||
| 610 | '@t3tools/contracts': | ||
| 611 | specifier: workspace:* | ||
| 612 | version: link:../../packages/contracts | ||
| 613 | '@t3tools/shared': | ||
| 614 | specifier: workspace:* | ||
| 615 | version: link:../../packages/shared | ||
| 616 | alchemy: | ||
| 617 | specifier: https://pkg.ing/alchemy/8b9c241 | ||
| 618 | version: https://pkg.ing/alchemy/8b9c241(9558be4f034193caa013885b5f553eec) | ||
| 619 | drizzle-orm: | ||
| 620 | specifier: 1.0.0-rc.3 | ||
| 621 | version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) | ||
| 622 | effect: | ||
| 623 | specifier: 4.0.0-beta.78 | ||
| 624 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) | ||
| 625 | devDependencies: | ||
| 626 | '@cloudflare/workers-types': | ||
| 627 | specifier: ^4.20260601.1 | ||
| 628 | version: 4.20260604.1 | ||
| 629 | '@effect/platform-node': | ||
| 630 | specifier: 4.0.0-beta.78 | ||
| 631 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) | ||
| 632 | '@effect/vitest': | ||
| 633 | specifier: 4.0.0-beta.78 | ||
| 634 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 635 | '@types/node': | ||
| 636 | specifier: 24.12.4 | ||
| 637 | version: 24.12.4 | ||
| 638 | drizzle-kit: | ||
| 639 | specifier: 1.0.0-rc.3 | ||
| 640 | version: 1.0.0-rc.3 | ||
| 641 | vitest: | ||
| 642 | specifier: npm:@voidzero-dev/vite-plus-test@latest | ||
| 643 | 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)' | ||
| 546 | 644 | ||
| 547 | oxlint-plugin-t3code: | 645 | oxlint-plugin-t3code: |
| 548 | dependencies: | 646 | dependencies: |
| 549 | '@effect/platform-node': | 647 | '@effect/platform-node': |
| 550 | specifier: 4.0.0-beta.73 | 648 | specifier: 4.0.0-beta.78 |
| 551 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 649 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 552 | '@oxlint/plugins': | 650 | '@oxlint/plugins': |
| 553 | specifier: ^1.63.0 | 651 | specifier: ^1.63.0 |
| 554 | version: 1.68.0 | 652 | version: 1.68.0 |
| 555 | effect: | 653 | effect: |
| 556 | specifier: 4.0.0-beta.73 | 654 | specifier: 4.0.0-beta.78 |
| 557 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 655 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 558 | devDependencies: | 656 | devDependencies: |
| 559 | '@effect/vitest': | 657 | '@effect/vitest': |
| 560 | specifier: 4.0.0-beta.73 | 658 | specifier: 4.0.0-beta.78 |
| 561 | 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)) | 659 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 562 | '@types/bun': | 660 | '@types/bun': |
| 563 | specifier: 'catalog:' | 661 | specifier: 'catalog:' |
| 564 | version: 1.3.14 | 662 | version: 1.3.14 |
| 565 | vite-plus: | 663 | vite-plus: |
| 566 | specifier: 'catalog:' | 664 | specifier: 'catalog:' |
| 567 | 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) | 665 | 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) |
| 568 | vitest: | 666 | vitest: |
| 569 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 667 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 570 | 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)' | 668 | 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)' |
| 571 | 669 | ||
| 572 | packages/client-runtime: | 670 | packages/client-runtime: |
| 573 | dependencies: | 671 | dependencies: |
| ... | @@ -578,112 +676,121 @@ importers: | ... | @@ -578,112 +676,121 @@ importers: |
| 578 | specifier: workspace:* | 676 | specifier: workspace:* |
| 579 | version: link:../shared | 677 | version: link:../shared |
| 580 | effect: | 678 | effect: |
| 581 | specifier: 4.0.0-beta.73 | 679 | specifier: 4.0.0-beta.78 |
| 582 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 680 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 583 | devDependencies: | 681 | devDependencies: |
| 584 | '@effect/vitest': | 682 | '@effect/vitest': |
| 585 | specifier: 4.0.0-beta.73 | 683 | specifier: 4.0.0-beta.78 |
| 586 | 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)) | 684 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 587 | vite-plus: | 685 | vite-plus: |
| 588 | specifier: 'catalog:' | 686 | specifier: 'catalog:' |
| 589 | 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) | 687 | 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) |
| 590 | vitest: | 688 | vitest: |
| 591 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 689 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 592 | 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)' | 690 | 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)' |
| 593 | 691 | ||
| 594 | packages/contracts: | 692 | packages/contracts: |
| 595 | dependencies: | 693 | dependencies: |
| 596 | effect: | 694 | effect: |
| 597 | specifier: 4.0.0-beta.73 | 695 | specifier: 4.0.0-beta.78 |
| 598 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 696 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 599 | devDependencies: | 697 | devDependencies: |
| 600 | '@effect/vitest': | 698 | '@effect/vitest': |
| 601 | specifier: 4.0.0-beta.73 | 699 | specifier: 4.0.0-beta.78 |
| 602 | 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)) | 700 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 603 | vite-plus: | 701 | vite-plus: |
| 604 | specifier: 'catalog:' | 702 | specifier: 'catalog:' |
| 605 | 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) | 703 | 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) |
| 606 | vitest: | 704 | vitest: |
| 607 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 705 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 608 | 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)' | 706 | 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)' |
| 609 | 707 | ||
| 610 | packages/effect-acp: | 708 | packages/effect-acp: |
| 611 | dependencies: | 709 | dependencies: |
| 612 | effect: | 710 | effect: |
| 613 | specifier: 4.0.0-beta.73 | 711 | specifier: 4.0.0-beta.78 |
| 614 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 712 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 615 | devDependencies: | 713 | devDependencies: |
| 616 | '@effect/openapi-generator': | 714 | '@effect/openapi-generator': |
| 617 | specifier: 'catalog:' | 715 | specifier: 'catalog:' |
| 618 | 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)) | 716 | version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 619 | '@effect/platform-node': | 717 | '@effect/platform-node': |
| 620 | specifier: 4.0.0-beta.73 | 718 | specifier: 4.0.0-beta.78 |
| 621 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 719 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 622 | '@effect/vitest': | 720 | '@effect/vitest': |
| 623 | specifier: 4.0.0-beta.73 | 721 | specifier: 4.0.0-beta.78 |
| 624 | 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)) | 722 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 625 | '@types/node': | 723 | '@types/node': |
| 626 | specifier: 24.12.4 | 724 | specifier: 24.12.4 |
| 627 | version: 24.12.4 | 725 | version: 24.12.4 |
| 628 | vite-plus: | 726 | vite-plus: |
| 629 | specifier: 'catalog:' | 727 | specifier: 'catalog:' |
| 630 | 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) | 728 | 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) |
| 631 | vitest: | 729 | vitest: |
| 632 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 730 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 633 | 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)' | 731 | 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)' |
| 634 | 732 | ||
| 635 | packages/effect-codex-app-server: | 733 | packages/effect-codex-app-server: |
| 636 | dependencies: | 734 | dependencies: |
| 637 | effect: | 735 | effect: |
| 638 | specifier: 4.0.0-beta.73 | 736 | specifier: 4.0.0-beta.78 |
| 639 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 737 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 640 | devDependencies: | 738 | devDependencies: |
| 641 | '@effect/openapi-generator': | 739 | '@effect/openapi-generator': |
| 642 | specifier: 'catalog:' | 740 | specifier: 'catalog:' |
| 643 | 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)) | 741 | version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 644 | '@effect/platform-node': | 742 | '@effect/platform-node': |
| 645 | specifier: 4.0.0-beta.73 | 743 | specifier: 4.0.0-beta.78 |
| 646 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 744 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 647 | '@effect/vitest': | 745 | '@effect/vitest': |
| 648 | specifier: 4.0.0-beta.73 | 746 | specifier: 4.0.0-beta.78 |
| 649 | 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)) | 747 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 650 | '@types/node': | 748 | '@types/node': |
| 651 | specifier: 24.12.4 | 749 | specifier: 24.12.4 |
| 652 | version: 24.12.4 | 750 | version: 24.12.4 |
| 653 | vite-plus: | 751 | vite-plus: |
| 654 | specifier: 'catalog:' | 752 | specifier: 'catalog:' |
| 655 | 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) | 753 | 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) |
| 656 | vitest: | 754 | vitest: |
| 657 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 755 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 658 | 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)' | 756 | 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)' |
| 659 | 757 | ||
| 660 | packages/shared: | 758 | packages/shared: |
| 661 | dependencies: | 759 | dependencies: |
| 760 | '@noble/curves': | ||
| 761 | specifier: 'catalog:' | ||
| 762 | version: 1.9.1 | ||
| 763 | '@noble/hashes': | ||
| 764 | specifier: 'catalog:' | ||
| 765 | version: 1.8.0 | ||
| 662 | '@t3tools/contracts': | 766 | '@t3tools/contracts': |
| 663 | specifier: workspace:* | 767 | specifier: workspace:* |
| 664 | version: link:../contracts | 768 | version: link:../contracts |
| 665 | effect: | 769 | effect: |
| 666 | specifier: 4.0.0-beta.73 | 770 | specifier: 4.0.0-beta.78 |
| 667 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 771 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 772 | jose: | ||
| 773 | specifier: 'catalog:' | ||
| 774 | version: 6.2.2 | ||
| 668 | yaml: | 775 | yaml: |
| 669 | specifier: ^2.9.0 | 776 | specifier: ^2.9.0 |
| 670 | version: 2.9.0 | 777 | version: 2.9.0 |
| 671 | devDependencies: | 778 | devDependencies: |
| 672 | '@effect/platform-node': | 779 | '@effect/platform-node': |
| 673 | specifier: 4.0.0-beta.73 | 780 | specifier: 4.0.0-beta.78 |
| 674 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 781 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 675 | '@effect/vitest': | 782 | '@effect/vitest': |
| 676 | specifier: 4.0.0-beta.73 | 783 | specifier: 4.0.0-beta.78 |
| 677 | 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)) | 784 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 678 | '@types/node': | 785 | '@types/node': |
| 679 | specifier: 24.12.4 | 786 | specifier: 24.12.4 |
| 680 | version: 24.12.4 | 787 | version: 24.12.4 |
| 681 | vite-plus: | 788 | vite-plus: |
| 682 | specifier: 'catalog:' | 789 | specifier: 'catalog:' |
| 683 | 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) | 790 | 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) |
| 684 | vitest: | 791 | vitest: |
| 685 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 792 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 686 | 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)' | 793 | 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)' |
| 687 | 794 | ||
| 688 | packages/ssh: | 795 | packages/ssh: |
| 689 | dependencies: | 796 | dependencies: |
| ... | @@ -694,46 +801,46 @@ importers: | ... | @@ -694,46 +801,46 @@ importers: |
| 694 | specifier: workspace:* | 801 | specifier: workspace:* |
| 695 | version: link:../shared | 802 | version: link:../shared |
| 696 | effect: | 803 | effect: |
| 697 | specifier: 4.0.0-beta.73 | 804 | specifier: 4.0.0-beta.78 |
| 698 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 805 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 699 | devDependencies: | 806 | devDependencies: |
| 700 | '@effect/platform-node': | 807 | '@effect/platform-node': |
| 701 | specifier: 4.0.0-beta.73 | 808 | specifier: 4.0.0-beta.78 |
| 702 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 809 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 703 | '@effect/vitest': | 810 | '@effect/vitest': |
| 704 | specifier: 4.0.0-beta.73 | 811 | specifier: 4.0.0-beta.78 |
| 705 | 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)) | 812 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 706 | '@types/node': | 813 | '@types/node': |
| 707 | specifier: 24.12.4 | 814 | specifier: 24.12.4 |
| 708 | version: 24.12.4 | 815 | version: 24.12.4 |
| 709 | vite-plus: | 816 | vite-plus: |
| 710 | specifier: 'catalog:' | 817 | specifier: 'catalog:' |
| 711 | 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) | 818 | 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) |
| 712 | vitest: | 819 | vitest: |
| 713 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 820 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 714 | 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)' | 821 | 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)' |
| 715 | 822 | ||
| 716 | packages/tailscale: | 823 | packages/tailscale: |
| 717 | dependencies: | 824 | dependencies: |
| 718 | '@effect/platform-node': | 825 | '@effect/platform-node': |
| 719 | specifier: 4.0.0-beta.73 | 826 | specifier: 4.0.0-beta.78 |
| 720 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 827 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 721 | effect: | 828 | effect: |
| 722 | specifier: 4.0.0-beta.73 | 829 | specifier: 4.0.0-beta.78 |
| 723 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 830 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 724 | devDependencies: | 831 | devDependencies: |
| 725 | '@effect/vitest': | 832 | '@effect/vitest': |
| 726 | specifier: 4.0.0-beta.73 | 833 | specifier: 4.0.0-beta.78 |
| 727 | 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)) | 834 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 728 | '@types/node': | 835 | '@types/node': |
| 729 | specifier: 24.12.4 | 836 | specifier: 24.12.4 |
| 730 | version: 24.12.4 | 837 | version: 24.12.4 |
| 731 | vite-plus: | 838 | vite-plus: |
| 732 | specifier: 'catalog:' | 839 | specifier: 'catalog:' |
| 733 | 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) | 840 | 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) |
| 734 | vitest: | 841 | vitest: |
| 735 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 842 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 736 | 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)' | 843 | 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)' |
| 737 | 844 | ||
| 738 | scripts: | 845 | scripts: |
| 739 | dependencies: | 846 | dependencies: |
| ... | @@ -741,8 +848,8 @@ importers: | ... | @@ -741,8 +848,8 @@ importers: |
| 741 | specifier: ^0.2.77 | 848 | specifier: ^0.2.77 |
| 742 | version: 0.2.141(zod@4.4.3) | 849 | version: 0.2.141(zod@4.4.3) |
| 743 | '@effect/platform-node': | 850 | '@effect/platform-node': |
| 744 | specifier: 4.0.0-beta.73 | 851 | specifier: 4.0.0-beta.78 |
| 745 | version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 852 | version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 746 | '@t3tools/contracts': | 853 | '@t3tools/contracts': |
| 747 | specifier: workspace:* | 854 | specifier: workspace:* |
| 748 | version: link:../packages/contracts | 855 | version: link:../packages/contracts |
| ... | @@ -750,24 +857,24 @@ importers: | ... | @@ -750,24 +857,24 @@ importers: |
| 750 | specifier: workspace:* | 857 | specifier: workspace:* |
| 751 | version: link:../packages/shared | 858 | version: link:../packages/shared |
| 752 | effect: | 859 | effect: |
| 753 | specifier: 4.0.0-beta.73 | 860 | specifier: 4.0.0-beta.78 |
| 754 | version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 861 | version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 755 | yaml: | 862 | yaml: |
| 756 | specifier: ^2.9.0 | 863 | specifier: ^2.9.0 |
| 757 | version: 2.9.0 | 864 | version: 2.9.0 |
| 758 | devDependencies: | 865 | devDependencies: |
| 759 | '@effect/vitest': | 866 | '@effect/vitest': |
| 760 | specifier: 4.0.0-beta.73 | 867 | specifier: 4.0.0-beta.78 |
| 761 | 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)) | 868 | version: 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 762 | '@types/bun': | 869 | '@types/bun': |
| 763 | specifier: 'catalog:' | 870 | specifier: 'catalog:' |
| 764 | version: 1.3.14 | 871 | version: 1.3.14 |
| 765 | vite-plus: | 872 | vite-plus: |
| 766 | specifier: 'catalog:' | 873 | specifier: 'catalog:' |
| 767 | 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) | 874 | 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) |
| 768 | vitest: | 875 | vitest: |
| 769 | specifier: npm:@voidzero-dev/vite-plus-test@latest | 876 | specifier: npm:@voidzero-dev/vite-plus-test@latest |
| 770 | 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)' | 877 | 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)' |
| 771 | 878 | ||
| 772 | packages: | 879 | packages: |
| 773 | 880 | ||
| ... | @@ -777,6 +884,13 @@ packages: | ... | @@ -777,6 +884,13 @@ packages: |
| 777 | '@adobe/css-tools@4.5.0': | 884 | '@adobe/css-tools@4.5.0': |
| 778 | resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} | 885 | resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} |
| 779 | 886 | ||
| 887 | '@alcalzone/ansi-tokenize@0.2.5': | ||
| 888 | resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} | ||
| 889 | engines: {node: '>=18'} | ||
| 890 | |||
| 891 | '@alchemy.run/node-utils@0.0.4': | ||
| 892 | resolution: {integrity: sha512-TiIhPXCTCi3tk0zmdYJJ14CNSesSfsJxXdIOP0HTSItQ1mZWLocrF7qCuEWKyW/IEFzp6kaiOf19aIA/mbCp1g==} | ||
| 893 | |||
| 780 | '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141': | 894 | '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141': |
| 781 | resolution: {integrity: sha512-9HZ0ot6+FwOfQ1aeMqQLH4IJGMm/DcP08SysDxscVjBm6l2JjqleHohxi3zid0DurfGweqT+4x9GScJffwg55g==} | 895 | resolution: {integrity: sha512-9HZ0ot6+FwOfQ1aeMqQLH4IJGMm/DcP08SysDxscVjBm6l2JjqleHohxi3zid0DurfGweqT+4x9GScJffwg55g==} |
| 782 | cpu: [arm64] | 896 | cpu: [arm64] |
| ... | @@ -921,6 +1035,99 @@ packages: | ... | @@ -921,6 +1035,99 @@ packages: |
| 921 | '@astrojs/yaml2ts@0.2.4': | 1035 | '@astrojs/yaml2ts@0.2.4': |
| 922 | resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} | 1036 | resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} |
| 923 | 1037 | ||
| 1038 | '@aws-crypto/crc32@5.2.0': | ||
| 1039 | resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} | ||
| 1040 | engines: {node: '>=16.0.0'} | ||
| 1041 | |||
| 1042 | '@aws-crypto/sha256-browser@5.2.0': | ||
| 1043 | resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} | ||
| 1044 | |||
| 1045 | '@aws-crypto/sha256-js@5.2.0': | ||
| 1046 | resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} | ||
| 1047 | engines: {node: '>=16.0.0'} | ||
| 1048 | |||
| 1049 | '@aws-crypto/supports-web-crypto@5.2.0': | ||
| 1050 | resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} | ||
| 1051 | |||
| 1052 | '@aws-crypto/util@5.2.0': | ||
| 1053 | resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} | ||
| 1054 | |||
| 1055 | '@aws-sdk/client-cognito-identity@3.1062.0': | ||
| 1056 | resolution: {integrity: sha512-QA5z/Pl3aTMR3+bmiHoC6MpKYa4FMk/9lNP7k104uKuUsjMqP4ysRa43IwdcbI9sH023T//kSJCLxrxa2CP/Tw==} | ||
| 1057 | engines: {node: '>=20.0.0'} | ||
| 1058 | |||
| 1059 | '@aws-sdk/core@3.974.17': | ||
| 1060 | resolution: {integrity: sha512-r8o4h2K7j6P9ngno+8ei0aK0U/4JwDb7A2fMMxGVoSqDN8AFlIzSDeZHME9LcVLR2codyhtr1WAAg+/nmkeeMA==} | ||
| 1061 | engines: {node: '>=20.0.0'} | ||
| 1062 | |||
| 1063 | '@aws-sdk/credential-provider-cognito-identity@3.972.41': | ||
| 1064 | resolution: {integrity: sha512-byGPybEQe9ejeyUzhWjtjfh0ctv25HsRx2djF/Tl2j9+DAuAmhjq0NqSRqYZEoSe8vJObXz5RYDtJYAmdupBig==} | ||
| 1065 | engines: {node: '>=20.0.0'} | ||
| 1066 | |||
| 1067 | '@aws-sdk/credential-provider-env@3.972.43': | ||
| 1068 | resolution: {integrity: sha512-g0XVQKzaA/4cq1vz1IvCQwYM+1Pkv01J9yHDpCTXekVuGZRDEz0wqBQ1AuYTq7FM6uik4uBGH8Tb5d9YvgeA7g==} | ||
| 1069 | engines: {node: '>=20.0.0'} | ||
| 1070 | |||
| 1071 | '@aws-sdk/credential-provider-http@3.972.45': | ||
| 1072 | resolution: {integrity: sha512-w9PuOoKCt6+xoESvY+zlV0u3PKQ0mVL259PcsVR6a3S/uYJJHnIi4r1NxdJHEcNldUVRIciltWnFMGBR4YEm3g==} | ||
| 1073 | engines: {node: '>=20.0.0'} | ||
| 1074 | |||
| 1075 | '@aws-sdk/credential-provider-ini@3.972.49': | ||
| 1076 | resolution: {integrity: sha512-83r5MK+PERv9irzky1o5aNbXiLuaLfeB7N8MrktB9USpoebdNtuG0Ek9ieIxpGH1aZ9a0nIaDaLjEr3EmOV3Ng==} | ||
| 1077 | engines: {node: '>=20.0.0'} | ||
| 1078 | |||
| 1079 | '@aws-sdk/credential-provider-login@3.972.48': | ||
| 1080 | resolution: {integrity: sha512-amPGeF6fcvLInK4Pu2k2Y2jHFR6MpaIKrZrbaf0QUnV3tjzjWh442eifZ2+KcmzFdsqyvyjBqAhq2JNLt1C5gA==} | ||
| 1081 | engines: {node: '>=20.0.0'} | ||
| 1082 | |||
| 1083 | '@aws-sdk/credential-provider-node@3.972.51': | ||
| 1084 | resolution: {integrity: sha512-mbhSY3ytXIGMuBoJsWCivk+63dtVlenT6wstUra07Lar4Ln2MVL8/j5zCTIOog+ig5/FlFJ8gcFU4nQZV+Jh4Q==} | ||
| 1085 | engines: {node: '>=20.0.0'} | ||
| 1086 | |||
| 1087 | '@aws-sdk/credential-provider-process@3.972.43': | ||
| 1088 | resolution: {integrity: sha512-GPokLNyvTfCmuaHk+v3GKVs4ZT3cMu5kgS2a+NPkOMt96cq6fSIK0g+mZHpGS6Cd4QGrPKesANEaLUKgOskTzg==} | ||
| 1089 | engines: {node: '>=20.0.0'} | ||
| 1090 | |||
| 1091 | '@aws-sdk/credential-provider-sso@3.972.48': | ||
| 1092 | resolution: {integrity: sha512-tf0sD47SeTgCDfOWYssctzGgwAuk8/ECjb7bom4wZ7P1om0qE8i2yjniUdvysmANm5haARr35O8vZnTe/UEtpQ==} | ||
| 1093 | engines: {node: '>=20.0.0'} | ||
| 1094 | |||
| 1095 | '@aws-sdk/credential-provider-web-identity@3.972.48': | ||
| 1096 | resolution: {integrity: sha512-YYsumc2oe09gl4l+fjfmR64JDn6+0o4Ql5HMBkMuhFazO1tZlE5NjSnZM3oXHwenPjh2qow0TFgSIVjfWfsojg==} | ||
| 1097 | engines: {node: '>=20.0.0'} | ||
| 1098 | |||
| 1099 | '@aws-sdk/credential-providers@3.1062.0': | ||
| 1100 | resolution: {integrity: sha512-QS2UT3srjNppZv6mq7V0igqK/ThYKqRWwDscxDsMEmmEE5JqCPPSqFW71aEpkvXaMdgmG8xEpt4RtNHpZ30cTA==} | ||
| 1101 | engines: {node: '>=20.0.0'} | ||
| 1102 | |||
| 1103 | '@aws-sdk/nested-clients@3.997.16': | ||
| 1104 | resolution: {integrity: sha512-bGvfDgC2KQePjEmZdltScPPLKFoyjPElAXeZcLfvZ58J1AO283//WGtvp9GdnryLHTi7gis0UoCezqh0vl/nig==} | ||
| 1105 | engines: {node: '>=20.0.0'} | ||
| 1106 | |||
| 1107 | '@aws-sdk/signature-v4-multi-region@3.996.31': | ||
| 1108 | resolution: {integrity: sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ==} | ||
| 1109 | engines: {node: '>=20.0.0'} | ||
| 1110 | |||
| 1111 | '@aws-sdk/token-providers@3.1062.0': | ||
| 1112 | resolution: {integrity: sha512-fvHh53zSm2FoQPgkw9thH5D7sd13bC0nPyuZb+mQJ85l5v7lQnsZ97u6e6YkJJN/LU1Mxm1/DLGrIIRR2L7tZw==} | ||
| 1113 | engines: {node: '>=20.0.0'} | ||
| 1114 | |||
| 1115 | '@aws-sdk/types@3.973.10': | ||
| 1116 | resolution: {integrity: sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g==} | ||
| 1117 | engines: {node: '>=20.0.0'} | ||
| 1118 | |||
| 1119 | '@aws-sdk/util-locate-window@3.965.5': | ||
| 1120 | resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} | ||
| 1121 | engines: {node: '>=20.0.0'} | ||
| 1122 | |||
| 1123 | '@aws-sdk/xml-builder@3.972.27': | ||
| 1124 | resolution: {integrity: sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g==} | ||
| 1125 | engines: {node: '>=20.0.0'} | ||
| 1126 | |||
| 1127 | '@aws/lambda-invoke-store@0.2.4': | ||
| 1128 | resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} | ||
| 1129 | engines: {node: '>=18.0.0'} | ||
| 1130 | |||
| 924 | '@babel/code-frame@7.29.7': | 1131 | '@babel/code-frame@7.29.7': |
| 925 | resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} | 1132 | resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} |
| 926 | engines: {node: '>=6.9.0'} | 1133 | engines: {node: '>=6.9.0'} |
| ... | @@ -1349,18 +1556,193 @@ packages: | ... | @@ -1349,18 +1556,193 @@ packages: |
| 1349 | resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} | 1556 | resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} |
| 1350 | engines: {node: '>=18'} | 1557 | engines: {node: '>=18'} |
| 1351 | 1558 | ||
| 1559 | '@clack/core@0.5.0': | ||
| 1560 | resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} | ||
| 1561 | |||
| 1352 | '@clack/core@1.4.0': | 1562 | '@clack/core@1.4.0': |
| 1353 | resolution: {integrity: sha512-7Wctjq6f7c1CPz8sPpkwUnz8yRgVANkpNupb81q432FjcJg4l+Sw7XANdNSdWfAKq0IHI0JTcUeK5dxs/HrGPw==} | 1563 | resolution: {integrity: sha512-7Wctjq6f7c1CPz8sPpkwUnz8yRgVANkpNupb81q432FjcJg4l+Sw7XANdNSdWfAKq0IHI0JTcUeK5dxs/HrGPw==} |
| 1354 | engines: {node: '>= 20.12.0'} | 1564 | engines: {node: '>= 20.12.0'} |
| 1355 | 1565 | ||
| 1566 | '@clack/prompts@0.11.0': | ||
| 1567 | resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} | ||
| 1568 | |||
| 1356 | '@clack/prompts@1.5.0': | 1569 | '@clack/prompts@1.5.0': |
| 1357 | resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} | 1570 | resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} |
| 1358 | engines: {node: '>= 20.12.0'} | 1571 | engines: {node: '>= 20.12.0'} |
| 1359 | 1572 | ||
| 1573 | '@clerk/backend@3.4.14': | ||
| 1574 | resolution: {integrity: sha512-0iaMT7k4wDk31QVC3HMaoeVFttblwsCECTHKNQpbRzIyD8j2gHdKEw/FNjffoyqyBqPw869IQlk1YokUlwVAqQ==} | ||
| 1575 | engines: {node: '>=20.9.0'} | ||
| 1576 | |||
| 1577 | '@clerk/clerk-js@6.14.0': | ||
| 1578 | resolution: {integrity: sha512-xreDPw31OIk/VQj36qdgjzc4Rk2HwMar25nOu/ts2gf7PrbhU4XQdrtnt74g4fTmSMp8xeyjzHqa9adDXVjISw==} | ||
| 1579 | engines: {node: '>=20.9.0'} | ||
| 1580 | |||
| 1581 | '@clerk/expo@3.3.1': | ||
| 1582 | resolution: {integrity: sha512-c4g64z5sgJoGYjK0NeasNwOMy9Di7cEjICq56BHSowdOuB+6UGtWBNw+yHzgS1gxi2kJgl7WQCmmXRsoZNWxAg==} | ||
| 1583 | engines: {node: '>=20.9.0'} | ||
| 1584 | peerDependencies: | ||
| 1585 | '@clerk/expo-passkeys': '>=0.0.6' | ||
| 1586 | expo: '>=53 <57' | ||
| 1587 | expo-apple-authentication: '>=7.0.0' | ||
| 1588 | expo-auth-session: '>=5' | ||
| 1589 | expo-constants: '>=12' | ||
| 1590 | expo-crypto: '>=12' | ||
| 1591 | expo-local-authentication: '>=13.5.0' | ||
| 1592 | expo-secure-store: '>=12.4.0' | ||
| 1593 | expo-web-browser: '>=12.5.0' | ||
| 1594 | react: ^18.0.0 || ^19.0.0 | ||
| 1595 | react-dom: ^18.0.0 || ^19.0.0 | ||
| 1596 | react-native: '>=0.73' | ||
| 1597 | peerDependenciesMeta: | ||
| 1598 | '@clerk/expo-passkeys': | ||
| 1599 | optional: true | ||
| 1600 | expo-apple-authentication: | ||
| 1601 | optional: true | ||
| 1602 | expo-auth-session: | ||
| 1603 | optional: true | ||
| 1604 | expo-constants: | ||
| 1605 | optional: true | ||
| 1606 | expo-crypto: | ||
| 1607 | optional: true | ||
| 1608 | expo-local-authentication: | ||
| 1609 | optional: true | ||
| 1610 | expo-secure-store: | ||
| 1611 | optional: true | ||
| 1612 | expo-web-browser: | ||
| 1613 | optional: true | ||
| 1614 | |||
| 1615 | '@clerk/react@6.7.3': | ||
| 1616 | resolution: {integrity: sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==} | ||
| 1617 | engines: {node: '>=20.9.0'} | ||
| 1618 | peerDependencies: | ||
| 1619 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | ||
| 1620 | react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | ||
| 1621 | |||
| 1622 | '@clerk/shared@4.15.0': | ||
| 1623 | resolution: {integrity: sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==} | ||
| 1624 | engines: {node: '>=20.9.0'} | ||
| 1625 | peerDependencies: | ||
| 1626 | react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | ||
| 1627 | react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 | ||
| 1628 | peerDependenciesMeta: | ||
| 1629 | react: | ||
| 1630 | optional: true | ||
| 1631 | react-dom: | ||
| 1632 | optional: true | ||
| 1633 | |||
| 1634 | '@cloudflare/unenv-preset@2.16.1': | ||
| 1635 | resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} | ||
| 1636 | peerDependencies: | ||
| 1637 | unenv: 2.0.0-rc.24 | ||
| 1638 | workerd: '>1.20260305.0 <2.0.0-0' | ||
| 1639 | peerDependenciesMeta: | ||
| 1640 | workerd: | ||
| 1641 | optional: true | ||
| 1642 | |||
| 1643 | '@cloudflare/workerd-darwin-64@1.20260526.1': | ||
| 1644 | resolution: {integrity: sha512-/pR3GH3gfv0PUp7DjI8v0aAIDOqFwibq4bg5xT7TZgcVdBV/cJQWckdXCMqiRtHiawLwogUX00EIOINkYJ1Zqg==} | ||
| 1645 | engines: {node: '>=16'} | ||
| 1646 | cpu: [x64] | ||
| 1647 | os: [darwin] | ||
| 1648 | |||
| 1649 | '@cloudflare/workerd-darwin-arm64@1.20260526.1': | ||
| 1650 | resolution: {integrity: sha512-rcyu0iANYfaiezKh3Mcao1O4IIgVfQldxduiL5TZT1sP0NIeRY4YReSTrzPxNnXxSYaIqaqRHMcHbUM/ic4knA==} | ||
| 1651 | engines: {node: '>=16'} | ||
| 1652 | cpu: [arm64] | ||
| 1653 | os: [darwin] | ||
| 1654 | |||
| 1655 | '@cloudflare/workerd-linux-64@1.20260526.1': | ||
| 1656 | resolution: {integrity: sha512-5EZAEnlLwa9oGJRo8Nd3iY5Wcd9ROGNNG90xNIGp8MEjj8v2jTn42NC47fCZKFdnLj3+S+vWEhu1x0GVJnALjA==} | ||
| 1657 | engines: {node: '>=16'} | ||
| 1658 | cpu: [x64] | ||
| 1659 | os: [linux] | ||
| 1660 | |||
| 1661 | '@cloudflare/workerd-linux-arm64@1.20260526.1': | ||
| 1662 | resolution: {integrity: sha512-X/YBQXeXFeCN7QTStoWrATEBc9WKl7PIqkw/dQkjyJ72gh3rkLe0+Xkzp3wO7gtxTDQMa7NPGy1W4+sdMf8q1g==} | ||
| 1663 | engines: {node: '>=16'} | ||
| 1664 | cpu: [arm64] | ||
| 1665 | os: [linux] | ||
| 1666 | |||
| 1667 | '@cloudflare/workerd-windows-64@1.20260526.1': | ||
| 1668 | resolution: {integrity: sha512-R+tqpFFdcfZIljx8fIW9rj9fRTtDgfoA2yonsfAGa6e8snrmr+38mdFHtkRC0D3UyZpn/hOtmXiUBfdX2gMR7Q==} | ||
| 1669 | engines: {node: '>=16'} | ||
| 1670 | cpu: [x64] | ||
| 1671 | os: [win32] | ||
| 1672 | |||
| 1673 | '@cloudflare/workers-types@4.20260604.1': | ||
| 1674 | resolution: {integrity: sha512-nVTydUwPcz9WfwZ/6xAmqs8uUVeGbDVmyPvSYrYAo4CQTyi0122SoE1Suw8RvqnN60XXHPChAjDqSIjnr/mbmg==} | ||
| 1675 | |||
| 1360 | '@develar/schema-utils@2.6.5': | 1676 | '@develar/schema-utils@2.6.5': |
| 1361 | resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} | 1677 | resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} |
| 1362 | engines: {node: '>= 8.9.0'} | 1678 | engines: {node: '>= 8.9.0'} |
| 1363 | 1679 | ||
| 1680 | '@distilled.cloud/aws@0.23.1': | ||
| 1681 | resolution: {integrity: sha512-xvNljahdVVCKo7uGgwFS097qnzPbfwAe7enHAzyKWXlBXuJtqlnVgPj61eDKxsqI6fz20weG54DkEcjbF3ocGw==} | ||
| 1682 | peerDependencies: | ||
| 1683 | effect: 4.0.0-beta.78 | ||
| 1684 | |||
| 1685 | '@distilled.cloud/axiom@0.23.1': | ||
| 1686 | resolution: {integrity: sha512-VWOF8mPpvhqW0iP0PNWSnx6PuZx6H0xwWiURd25Zvg7cf6F8yu0E+rcAKT3UwhWYwlROJaWguu5lQXadCcKs8w==} | ||
| 1687 | peerDependencies: | ||
| 1688 | effect: 4.0.0-beta.78 | ||
| 1689 | |||
| 1690 | '@distilled.cloud/cloudflare-rolldown-plugin@0.10.5': | ||
| 1691 | resolution: {integrity: sha512-hIIvNinKQEIsPLluIPQ6/q4C5cAp7KNIMGtpOh/ZKaUClvvV3gFoZvgpjZHNpScXXeKDqs3WZbFux+gteGg80w==} | ||
| 1692 | peerDependencies: | ||
| 1693 | rolldown: ^1.0.1 | ||
| 1694 | peerDependenciesMeta: | ||
| 1695 | rolldown: | ||
| 1696 | optional: true | ||
| 1697 | |||
| 1698 | '@distilled.cloud/cloudflare-runtime@0.10.5': | ||
| 1699 | resolution: {integrity: sha512-UVQTC8/JXRNGqs8EFDRTwuCJ4nMQwVE74ft7RIL2ZKPdvR6GiUKaNz4e6ZYhNMQ+MkzDVX7FOhytu3Z01nJZ0w==} | ||
| 1700 | peerDependencies: | ||
| 1701 | '@distilled.cloud/cloudflare': ^0.23.1 | ||
| 1702 | '@effect/platform-bun': 4.0.0-beta.78 | ||
| 1703 | '@effect/platform-node': 4.0.0-beta.78 | ||
| 1704 | effect: 4.0.0-beta.78 | ||
| 1705 | peerDependenciesMeta: | ||
| 1706 | '@effect/platform-bun': | ||
| 1707 | optional: true | ||
| 1708 | '@effect/platform-node': | ||
| 1709 | optional: true | ||
| 1710 | |||
| 1711 | '@distilled.cloud/cloudflare-vite-plugin@0.10.5': | ||
| 1712 | resolution: {integrity: sha512-5QefxDOUMWijIuEywOEjGBtPVGeBEzTmVx3Fl9HVvmU2oSse+nHeA3eWz1vaRql/8e8WFRYPfSAyYrfUOmHe5Q==} | ||
| 1713 | peerDependencies: | ||
| 1714 | '@distilled.cloud/cloudflare': ^0.23.1 | ||
| 1715 | '@distilled.cloud/cloudflare-runtime': 0.10.5 | ||
| 1716 | '@effect/platform-bun': 4.0.0-beta.78 | ||
| 1717 | '@effect/platform-node': 4.0.0-beta.78 | ||
| 1718 | effect: 4.0.0-beta.78 | ||
| 1719 | vite: ^7.0.0 || ^8.0.0 | ||
| 1720 | peerDependenciesMeta: | ||
| 1721 | '@effect/platform-bun': | ||
| 1722 | optional: true | ||
| 1723 | '@effect/platform-node': | ||
| 1724 | optional: true | ||
| 1725 | |||
| 1726 | '@distilled.cloud/cloudflare@0.23.1': | ||
| 1727 | resolution: {integrity: sha512-/OKikqtYpn1SQUlgEvHO3Y32VskQPql6x575anfLCcanCcU9ufj+Lz7ZNKxYL85ZQCvuPD3gbZ8areVvWPcU6A==} | ||
| 1728 | peerDependencies: | ||
| 1729 | effect: 4.0.0-beta.78 | ||
| 1730 | |||
| 1731 | '@distilled.cloud/core@0.23.1': | ||
| 1732 | resolution: {integrity: sha512-WQNSiPfiXuHeaQteSK5HgEiHjxdIPtOgE+YOugU56Eio1/EkSH84Q/FDqi5StJ0MVyhN9kjzO0lWwZL2//ZeHA==} | ||
| 1733 | peerDependencies: | ||
| 1734 | effect: 4.0.0-beta.78 | ||
| 1735 | |||
| 1736 | '@distilled.cloud/neon@0.23.1': | ||
| 1737 | resolution: {integrity: sha512-mq66jHVSyX14vqAc8RS11b7mKY6hp5ByDWVbl0QEQ4/Pq5wSBwmb7ZTLPfum2Qbs25VfXSQz9rC+mWGDjbB2cw==} | ||
| 1738 | peerDependencies: | ||
| 1739 | effect: 4.0.0-beta.78 | ||
| 1740 | |||
| 1741 | '@distilled.cloud/planetscale@0.23.1': | ||
| 1742 | resolution: {integrity: sha512-YJlwrU8zdFScPB7zIv7vLxyImBCFMu8qrkMINcJ1RddaRBtH7XnnvuuRv0GlFknzcTJ9ox12YlHHHI6BhEQl/w==} | ||
| 1743 | peerDependencies: | ||
| 1744 | effect: 4.0.0-beta.78 | ||
| 1745 | |||
| 1364 | '@dnd-kit/accessibility@3.1.1': | 1746 | '@dnd-kit/accessibility@3.1.1': |
| 1365 | resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} | 1747 | resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} |
| 1366 | peerDependencies: | 1748 | peerDependencies: |
| ... | @@ -1389,42 +1771,50 @@ packages: | ... | @@ -1389,42 +1771,50 @@ packages: |
| 1389 | peerDependencies: | 1771 | peerDependencies: |
| 1390 | react: '>=16.8.0' | 1772 | react: '>=16.8.0' |
| 1391 | 1773 | ||
| 1392 | '@effect/atom-react@4.0.0-beta.73': | 1774 | '@drizzle-team/brocli@0.11.0': |
| 1393 | resolution: {integrity: sha512-RjNqEMV3z3iEFRBtJ1RVuxBNFMYxhDo2iRwck3kBYjrBuXWrKIRyqnunWJdL2KX97Gqp0orxaujG7qnIA5aIEA==} | 1775 | resolution: {integrity: sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg==} |
| 1776 | |||
| 1777 | '@effect/atom-react@4.0.0-beta.78': | ||
| 1778 | resolution: {integrity: sha512-cgxDXJaD0wlbQXbp6tiEmmY+yajwurB0ynkFG20RVucvH4LsQMB3ogiHe0mt42wGggfbVYMEDxgBpQdqDRY8yA==} | ||
| 1394 | peerDependencies: | 1779 | peerDependencies: |
| 1395 | effect: 4.0.0-beta.73 | 1780 | effect: 4.0.0-beta.78 |
| 1396 | react: ^19.2.4 | 1781 | react: ^19.2.4 |
| 1397 | scheduler: '*' | 1782 | scheduler: '*' |
| 1398 | 1783 | ||
| 1399 | '@effect/openapi-generator@4.0.0-beta.73': | 1784 | '@effect/openapi-generator@4.0.0-beta.78': |
| 1400 | resolution: {integrity: sha512-tN1mKreMo6kypkuPy8Jt3eGoI0DberzD2/5XxEh17lTdyLg7/SUZv3LssRxtzO5+uIYzLNIHfT2zESz7gjCnwA==} | 1785 | resolution: {integrity: sha512-qhKRcZCNQ5b0Klrct+AC/tPQgIDBxVsD0MkQLIzqvLU3qRHaNd5yHo7kxFf/DuhCyyL++xZfbHsPdq3VdLIByg==} |
| 1401 | hasBin: true | 1786 | hasBin: true |
| 1402 | peerDependencies: | 1787 | peerDependencies: |
| 1403 | '@effect/platform-node': 4.0.0-beta.73 | 1788 | '@effect/platform-node': 4.0.0-beta.78 |
| 1404 | effect: 4.0.0-beta.73 | 1789 | effect: 4.0.0-beta.78 |
| 1405 | 1790 | ||
| 1406 | '@effect/platform-bun@4.0.0-beta.73': | 1791 | '@effect/platform-bun@4.0.0-beta.78': |
| 1407 | resolution: {integrity: sha512-AQcE89eEVp0xA3SafaswiHPfo6V2YPg7T6PQbQqYK4/S2Sia5cadCm/6y7pfRmuOd4jkWI6VMbNtEDkM0NOBpw==} | 1792 | resolution: {integrity: sha512-lmPCL1G7SlkCWCguX3rDPS7kKuvJ/AN4pjS7IXb/5SoauHPd67iUdc1ZbB7o6lwTChJaIfWNNPkUWygiaUeJiA==} |
| 1408 | peerDependencies: | 1793 | peerDependencies: |
| 1409 | effect: 4.0.0-beta.73 | 1794 | effect: 4.0.0-beta.78 |
| 1410 | 1795 | ||
| 1411 | '@effect/platform-node-shared@4.0.0-beta.73': | 1796 | '@effect/platform-node-shared@4.0.0-beta.78': |
| 1412 | resolution: {integrity: sha512-/7L8eftDS40VWgLx2GVtBtb8x97Vx4OF5IlRJfh14L5xDFdb9MZXxOuHfHAi+71d90pkbFpOFVtQCSOBWKRAWg==} | 1797 | resolution: {integrity: sha512-mo0ddTPATyCMyqzQasYDL7+NI29vozoMplom+qu9f/onDTd4xG5hvEEfGxfL0Ljygui6keG/YE/E9OZVf2z5WA==} |
| 1413 | engines: {node: '>=18.0.0'} | 1798 | engines: {node: '>=18.0.0'} |
| 1414 | peerDependencies: | 1799 | peerDependencies: |
| 1415 | effect: 4.0.0-beta.73 | 1800 | effect: 4.0.0-beta.78 |
| 1416 | 1801 | ||
| 1417 | '@effect/platform-node@4.0.0-beta.73': | 1802 | '@effect/platform-node@4.0.0-beta.78': |
| 1418 | resolution: {integrity: sha512-YYkUnmcs6EiRk8K/xNCU3l3jl1YbrhCHCJp+y32DWlKhhZot24/hvw0zYw0aymAtuI9dzAdPryVZkksH4+7ovw==} | 1803 | resolution: {integrity: sha512-8ONrIS5/R9dq+0BJ6v3kUXNEkfjU6S3GzIYCH5gmHdiriRvIoBhXYNAITfRvZpfx1JPrKuP70cHyuQDjmJcDkQ==} |
| 1419 | engines: {node: '>=18.0.0'} | 1804 | engines: {node: '>=18.0.0'} |
| 1420 | peerDependencies: | 1805 | peerDependencies: |
| 1421 | effect: 4.0.0-beta.73 | 1806 | effect: 4.0.0-beta.78 |
| 1422 | ioredis: ^5.7.0 | 1807 | ioredis: ^5.7.0 |
| 1423 | 1808 | ||
| 1424 | '@effect/sql-sqlite-bun@4.0.0-beta.73': | 1809 | '@effect/sql-pg@4.0.0-beta.78': |
| 1425 | resolution: {integrity: sha512-dkTDDeBLjaKo7h1IE049Oj2BDKQOT+Kc57KbBVaSk36dRdsEmVspRMP7OGbZj3etJU3lL1GJi1BIfnb4N3uMRw==} | 1810 | resolution: {integrity: sha512-G7OZhImyPyHsmN7+bpIfl+llrxQz4qUOCDAHWBXafzyE1AntL5Syi0/NZLyE8X1LzGKasCNl4FTCjxKSEOdiBQ==} |
| 1811 | peerDependencies: | ||
| 1812 | effect: 4.0.0-beta.78 | ||
| 1813 | |||
| 1814 | '@effect/sql-sqlite-bun@4.0.0-beta.78': | ||
| 1815 | resolution: {integrity: sha512-NohNoFvLAcGjCDH86QqFKvMELfGDCWII7I9howCPYRLvJDNcSSNahHjOKRAdfMEU17ZljwqoshIX2YyYMBVygw==} | ||
| 1426 | peerDependencies: | 1816 | peerDependencies: |
| 1427 | effect: 4.0.0-beta.73 | 1817 | effect: 4.0.0-beta.78 |
| 1428 | 1818 | ||
| 1429 | '@effect/tsgo-darwin-arm64@0.11.4': | 1819 | '@effect/tsgo-darwin-arm64@0.11.4': |
| 1430 | resolution: {integrity: sha512-rslT4W8tWY8pdd48kDpY7Q+yo0xkrmcEySG1+sJfb06w7TwbywjHsGlC0BmcUaQt0qmLY+BApbWWDfZw7fgwjw==} | 1820 | resolution: {integrity: sha512-rslT4W8tWY8pdd48kDpY7Q+yo0xkrmcEySG1+sJfb06w7TwbywjHsGlC0BmcUaQt0qmLY+BApbWWDfZw7fgwjw==} |
| ... | @@ -1465,10 +1855,10 @@ packages: | ... | @@ -1465,10 +1855,10 @@ packages: |
| 1465 | resolution: {integrity: sha512-wliq5Dis2gLoIUKoCRT3Iv5kpO+fKrUBJPPxIsHADeirk9HMQevKvYaBpGS3Khw1tNLmoQWsSX559owJAdHgBA==} | 1855 | resolution: {integrity: sha512-wliq5Dis2gLoIUKoCRT3Iv5kpO+fKrUBJPPxIsHADeirk9HMQevKvYaBpGS3Khw1tNLmoQWsSX559owJAdHgBA==} |
| 1466 | hasBin: true | 1856 | hasBin: true |
| 1467 | 1857 | ||
| 1468 | '@effect/vitest@4.0.0-beta.73': | 1858 | '@effect/vitest@4.0.0-beta.78': |
| 1469 | resolution: {integrity: sha512-HIZa0aekh0ak1YfzCv8GTN75Vbvxp8g+W+z9KczvtUT+LvitX3i9nhLWZLKRrUP1LaJFLPJS2UG9ZGXjxgbjBg==} | 1859 | resolution: {integrity: sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ==} |
| 1470 | peerDependencies: | 1860 | peerDependencies: |
| 1471 | effect: 4.0.0-beta.73 | 1861 | effect: 4.0.0-beta.78 |
| 1472 | vitest: ^3.0.0 || ^4.0.0 | 1862 | vitest: ^3.0.0 || ^4.0.0 |
| 1473 | 1863 | ||
| 1474 | '@egjs/hammerjs@2.0.17': | 1864 | '@egjs/hammerjs@2.0.17': |
| ... | @@ -1548,156 +1938,312 @@ packages: | ... | @@ -1548,156 +1938,312 @@ packages: |
| 1548 | '@epic-web/invariant@1.0.0': | 1938 | '@epic-web/invariant@1.0.0': |
| 1549 | resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} | 1939 | resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} |
| 1550 | 1940 | ||
| 1941 | '@esbuild/aix-ppc64@0.25.12': | ||
| 1942 | resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} | ||
| 1943 | engines: {node: '>=18'} | ||
| 1944 | cpu: [ppc64] | ||
| 1945 | os: [aix] | ||
| 1946 | |||
| 1551 | '@esbuild/aix-ppc64@0.27.7': | 1947 | '@esbuild/aix-ppc64@0.27.7': |
| 1552 | resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} | 1948 | resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} |
| 1553 | engines: {node: '>=18'} | 1949 | engines: {node: '>=18'} |
| 1554 | cpu: [ppc64] | 1950 | cpu: [ppc64] |
| 1555 | os: [aix] | 1951 | os: [aix] |
| 1556 | 1952 | ||
| 1953 | '@esbuild/android-arm64@0.25.12': | ||
| 1954 | resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} | ||
| 1955 | engines: {node: '>=18'} | ||
| 1956 | cpu: [arm64] | ||
| 1957 | os: [android] | ||
| 1958 | |||
| 1557 | '@esbuild/android-arm64@0.27.7': | 1959 | '@esbuild/android-arm64@0.27.7': |
| 1558 | resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} | 1960 | resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} |
| 1559 | engines: {node: '>=18'} | 1961 | engines: {node: '>=18'} |
| 1560 | cpu: [arm64] | 1962 | cpu: [arm64] |
| 1561 | os: [android] | 1963 | os: [android] |
| 1562 | 1964 | ||
| 1965 | '@esbuild/android-arm@0.25.12': | ||
| 1966 | resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} | ||
| 1967 | engines: {node: '>=18'} | ||
| 1968 | cpu: [arm] | ||
| 1969 | os: [android] | ||
| 1970 | |||
| 1563 | '@esbuild/android-arm@0.27.7': | 1971 | '@esbuild/android-arm@0.27.7': |
| 1564 | resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} | 1972 | resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} |
| 1565 | engines: {node: '>=18'} | 1973 | engines: {node: '>=18'} |
| 1566 | cpu: [arm] | 1974 | cpu: [arm] |
| 1567 | os: [android] | 1975 | os: [android] |
| 1568 | 1976 | ||
| 1977 | '@esbuild/android-x64@0.25.12': | ||
| 1978 | resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} | ||
| 1979 | engines: {node: '>=18'} | ||
| 1980 | cpu: [x64] | ||
| 1981 | os: [android] | ||
| 1982 | |||
| 1569 | '@esbuild/android-x64@0.27.7': | 1983 | '@esbuild/android-x64@0.27.7': |
| 1570 | resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} | 1984 | resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} |
| 1571 | engines: {node: '>=18'} | 1985 | engines: {node: '>=18'} |
| 1572 | cpu: [x64] | 1986 | cpu: [x64] |
| 1573 | os: [android] | 1987 | os: [android] |
| 1574 | 1988 | ||
| 1989 | '@esbuild/darwin-arm64@0.25.12': | ||
| 1990 | resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} | ||
| 1991 | engines: {node: '>=18'} | ||
| 1992 | cpu: [arm64] | ||
| 1993 | os: [darwin] | ||
| 1994 | |||
| 1575 | '@esbuild/darwin-arm64@0.27.7': | 1995 | '@esbuild/darwin-arm64@0.27.7': |
| 1576 | resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} | 1996 | resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} |
| 1577 | engines: {node: '>=18'} | 1997 | engines: {node: '>=18'} |
| 1578 | cpu: [arm64] | 1998 | cpu: [arm64] |
| 1579 | os: [darwin] | 1999 | os: [darwin] |
| 1580 | 2000 | ||
| 2001 | '@esbuild/darwin-x64@0.25.12': | ||
| 2002 | resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} | ||
| 2003 | engines: {node: '>=18'} | ||
| 2004 | cpu: [x64] | ||
| 2005 | os: [darwin] | ||
| 2006 | |||
| 1581 | '@esbuild/darwin-x64@0.27.7': | 2007 | '@esbuild/darwin-x64@0.27.7': |
| 1582 | resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} | 2008 | resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} |
| 1583 | engines: {node: '>=18'} | 2009 | engines: {node: '>=18'} |
| 1584 | cpu: [x64] | 2010 | cpu: [x64] |
| 1585 | os: [darwin] | 2011 | os: [darwin] |
| 1586 | 2012 | ||
| 2013 | '@esbuild/freebsd-arm64@0.25.12': | ||
| 2014 | resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} | ||
| 2015 | engines: {node: '>=18'} | ||
| 2016 | cpu: [arm64] | ||
| 2017 | os: [freebsd] | ||
| 2018 | |||
| 1587 | '@esbuild/freebsd-arm64@0.27.7': | 2019 | '@esbuild/freebsd-arm64@0.27.7': |
| 1588 | resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} | 2020 | resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} |
| 1589 | engines: {node: '>=18'} | 2021 | engines: {node: '>=18'} |
| 1590 | cpu: [arm64] | 2022 | cpu: [arm64] |
| 1591 | os: [freebsd] | 2023 | os: [freebsd] |
| 1592 | 2024 | ||
| 2025 | '@esbuild/freebsd-x64@0.25.12': | ||
| 2026 | resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} | ||
| 2027 | engines: {node: '>=18'} | ||
| 2028 | cpu: [x64] | ||
| 2029 | os: [freebsd] | ||
| 2030 | |||
| 1593 | '@esbuild/freebsd-x64@0.27.7': | 2031 | '@esbuild/freebsd-x64@0.27.7': |
| 1594 | resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} | 2032 | resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} |
| 1595 | engines: {node: '>=18'} | 2033 | engines: {node: '>=18'} |
| 1596 | cpu: [x64] | 2034 | cpu: [x64] |
| 1597 | os: [freebsd] | 2035 | os: [freebsd] |
| 1598 | 2036 | ||
| 2037 | '@esbuild/linux-arm64@0.25.12': | ||
| 2038 | resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} | ||
| 2039 | engines: {node: '>=18'} | ||
| 2040 | cpu: [arm64] | ||
| 2041 | os: [linux] | ||
| 2042 | |||
| 1599 | '@esbuild/linux-arm64@0.27.7': | 2043 | '@esbuild/linux-arm64@0.27.7': |
| 1600 | resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} | 2044 | resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} |
| 1601 | engines: {node: '>=18'} | 2045 | engines: {node: '>=18'} |
| 1602 | cpu: [arm64] | 2046 | cpu: [arm64] |
| 1603 | os: [linux] | 2047 | os: [linux] |
| 1604 | 2048 | ||
| 2049 | '@esbuild/linux-arm@0.25.12': | ||
| 2050 | resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} | ||
| 2051 | engines: {node: '>=18'} | ||
| 2052 | cpu: [arm] | ||
| 2053 | os: [linux] | ||
| 2054 | |||
| 1605 | '@esbuild/linux-arm@0.27.7': | 2055 | '@esbuild/linux-arm@0.27.7': |
| 1606 | resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} | 2056 | resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} |
| 1607 | engines: {node: '>=18'} | 2057 | engines: {node: '>=18'} |
| 1608 | cpu: [arm] | 2058 | cpu: [arm] |
| 1609 | os: [linux] | 2059 | os: [linux] |
| 1610 | 2060 | ||
| 2061 | '@esbuild/linux-ia32@0.25.12': | ||
| 2062 | resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} | ||
| 2063 | engines: {node: '>=18'} | ||
| 2064 | cpu: [ia32] | ||
| 2065 | os: [linux] | ||
| 2066 | |||
| 1611 | '@esbuild/linux-ia32@0.27.7': | 2067 | '@esbuild/linux-ia32@0.27.7': |
| 1612 | resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} | 2068 | resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} |
| 1613 | engines: {node: '>=18'} | 2069 | engines: {node: '>=18'} |
| 1614 | cpu: [ia32] | 2070 | cpu: [ia32] |
| 1615 | os: [linux] | 2071 | os: [linux] |
| 1616 | 2072 | ||
| 2073 | '@esbuild/linux-loong64@0.25.12': | ||
| 2074 | resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} | ||
| 2075 | engines: {node: '>=18'} | ||
| 2076 | cpu: [loong64] | ||
| 2077 | os: [linux] | ||
| 2078 | |||
| 1617 | '@esbuild/linux-loong64@0.27.7': | 2079 | '@esbuild/linux-loong64@0.27.7': |
| 1618 | resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} | 2080 | resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} |
| 1619 | engines: {node: '>=18'} | 2081 | engines: {node: '>=18'} |
| 1620 | cpu: [loong64] | 2082 | cpu: [loong64] |
| 1621 | os: [linux] | 2083 | os: [linux] |
| 1622 | 2084 | ||
| 2085 | '@esbuild/linux-mips64el@0.25.12': | ||
| 2086 | resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} | ||
| 2087 | engines: {node: '>=18'} | ||
| 2088 | cpu: [mips64el] | ||
| 2089 | os: [linux] | ||
| 2090 | |||
| 1623 | '@esbuild/linux-mips64el@0.27.7': | 2091 | '@esbuild/linux-mips64el@0.27.7': |
| 1624 | resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} | 2092 | resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} |
| 1625 | engines: {node: '>=18'} | 2093 | engines: {node: '>=18'} |
| 1626 | cpu: [mips64el] | 2094 | cpu: [mips64el] |
| 1627 | os: [linux] | 2095 | os: [linux] |
| 1628 | 2096 | ||
| 2097 | '@esbuild/linux-ppc64@0.25.12': | ||
| 2098 | resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} | ||
| 2099 | engines: {node: '>=18'} | ||
| 2100 | cpu: [ppc64] | ||
| 2101 | os: [linux] | ||
| 2102 | |||
| 1629 | '@esbuild/linux-ppc64@0.27.7': | 2103 | '@esbuild/linux-ppc64@0.27.7': |
| 1630 | resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} | 2104 | resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} |
| 1631 | engines: {node: '>=18'} | 2105 | engines: {node: '>=18'} |
| 1632 | cpu: [ppc64] | 2106 | cpu: [ppc64] |
| 1633 | os: [linux] | 2107 | os: [linux] |
| 1634 | 2108 | ||
| 2109 | '@esbuild/linux-riscv64@0.25.12': | ||
| 2110 | resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} | ||
| 2111 | engines: {node: '>=18'} | ||
| 2112 | cpu: [riscv64] | ||
| 2113 | os: [linux] | ||
| 2114 | |||
| 1635 | '@esbuild/linux-riscv64@0.27.7': | 2115 | '@esbuild/linux-riscv64@0.27.7': |
| 1636 | resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} | 2116 | resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} |
| 1637 | engines: {node: '>=18'} | 2117 | engines: {node: '>=18'} |
| 1638 | cpu: [riscv64] | 2118 | cpu: [riscv64] |
| 1639 | os: [linux] | 2119 | os: [linux] |
| 1640 | 2120 | ||
| 2121 | '@esbuild/linux-s390x@0.25.12': | ||
| 2122 | resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} | ||
| 2123 | engines: {node: '>=18'} | ||
| 2124 | cpu: [s390x] | ||
| 2125 | os: [linux] | ||
| 2126 | |||
| 1641 | '@esbuild/linux-s390x@0.27.7': | 2127 | '@esbuild/linux-s390x@0.27.7': |
| 1642 | resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} | 2128 | resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} |
| 1643 | engines: {node: '>=18'} | 2129 | engines: {node: '>=18'} |
| 1644 | cpu: [s390x] | 2130 | cpu: [s390x] |
| 1645 | os: [linux] | 2131 | os: [linux] |
| 1646 | 2132 | ||
| 2133 | '@esbuild/linux-x64@0.25.12': | ||
| 2134 | resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} | ||
| 2135 | engines: {node: '>=18'} | ||
| 2136 | cpu: [x64] | ||
| 2137 | os: [linux] | ||
| 2138 | |||
| 1647 | '@esbuild/linux-x64@0.27.7': | 2139 | '@esbuild/linux-x64@0.27.7': |
| 1648 | resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} | 2140 | resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} |
| 1649 | engines: {node: '>=18'} | 2141 | engines: {node: '>=18'} |
| 1650 | cpu: [x64] | 2142 | cpu: [x64] |
| 1651 | os: [linux] | 2143 | os: [linux] |
| 1652 | 2144 | ||
| 2145 | '@esbuild/netbsd-arm64@0.25.12': | ||
| 2146 | resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} | ||
| 2147 | engines: {node: '>=18'} | ||
| 2148 | cpu: [arm64] | ||
| 2149 | os: [netbsd] | ||
| 2150 | |||
| 1653 | '@esbuild/netbsd-arm64@0.27.7': | 2151 | '@esbuild/netbsd-arm64@0.27.7': |
| 1654 | resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} | 2152 | resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} |
| 1655 | engines: {node: '>=18'} | 2153 | engines: {node: '>=18'} |
| 1656 | cpu: [arm64] | 2154 | cpu: [arm64] |
| 1657 | os: [netbsd] | 2155 | os: [netbsd] |
| 1658 | 2156 | ||
| 2157 | '@esbuild/netbsd-x64@0.25.12': | ||
| 2158 | resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} | ||
| 2159 | engines: {node: '>=18'} | ||
| 2160 | cpu: [x64] | ||
| 2161 | os: [netbsd] | ||
| 2162 | |||
| 1659 | '@esbuild/netbsd-x64@0.27.7': | 2163 | '@esbuild/netbsd-x64@0.27.7': |
| 1660 | resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} | 2164 | resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} |
| 1661 | engines: {node: '>=18'} | 2165 | engines: {node: '>=18'} |
| 1662 | cpu: [x64] | 2166 | cpu: [x64] |
| 1663 | os: [netbsd] | 2167 | os: [netbsd] |
| 1664 | 2168 | ||
| 2169 | '@esbuild/openbsd-arm64@0.25.12': | ||
| 2170 | resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} | ||
| 2171 | engines: {node: '>=18'} | ||
| 2172 | cpu: [arm64] | ||
| 2173 | os: [openbsd] | ||
| 2174 | |||
| 1665 | '@esbuild/openbsd-arm64@0.27.7': | 2175 | '@esbuild/openbsd-arm64@0.27.7': |
| 1666 | resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} | 2176 | resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} |
| 1667 | engines: {node: '>=18'} | 2177 | engines: {node: '>=18'} |
| 1668 | cpu: [arm64] | 2178 | cpu: [arm64] |
| 1669 | os: [openbsd] | 2179 | os: [openbsd] |
| 1670 | 2180 | ||
| 2181 | '@esbuild/openbsd-x64@0.25.12': | ||
| 2182 | resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} | ||
| 2183 | engines: {node: '>=18'} | ||
| 2184 | cpu: [x64] | ||
| 2185 | os: [openbsd] | ||
| 2186 | |||
| 1671 | '@esbuild/openbsd-x64@0.27.7': | 2187 | '@esbuild/openbsd-x64@0.27.7': |
| 1672 | resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} | 2188 | resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} |
| 1673 | engines: {node: '>=18'} | 2189 | engines: {node: '>=18'} |
| 1674 | cpu: [x64] | 2190 | cpu: [x64] |
| 1675 | os: [openbsd] | 2191 | os: [openbsd] |
| 1676 | 2192 | ||
| 2193 | '@esbuild/openharmony-arm64@0.25.12': | ||
| 2194 | resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} | ||
| 2195 | engines: {node: '>=18'} | ||
| 2196 | cpu: [arm64] | ||
| 2197 | os: [openharmony] | ||
| 2198 | |||
| 1677 | '@esbuild/openharmony-arm64@0.27.7': | 2199 | '@esbuild/openharmony-arm64@0.27.7': |
| 1678 | resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} | 2200 | resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} |
| 1679 | engines: {node: '>=18'} | 2201 | engines: {node: '>=18'} |
| 1680 | cpu: [arm64] | 2202 | cpu: [arm64] |
| 1681 | os: [openharmony] | 2203 | os: [openharmony] |
| 1682 | 2204 | ||
| 2205 | '@esbuild/sunos-x64@0.25.12': | ||
| 2206 | resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} | ||
| 2207 | engines: {node: '>=18'} | ||
| 2208 | cpu: [x64] | ||
| 2209 | os: [sunos] | ||
| 2210 | |||
| 1683 | '@esbuild/sunos-x64@0.27.7': | 2211 | '@esbuild/sunos-x64@0.27.7': |
| 1684 | resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} | 2212 | resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} |
| 1685 | engines: {node: '>=18'} | 2213 | engines: {node: '>=18'} |
| 1686 | cpu: [x64] | 2214 | cpu: [x64] |
| 1687 | os: [sunos] | 2215 | os: [sunos] |
| 1688 | 2216 | ||
| 2217 | '@esbuild/win32-arm64@0.25.12': | ||
| 2218 | resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} | ||
| 2219 | engines: {node: '>=18'} | ||
| 2220 | cpu: [arm64] | ||
| 2221 | os: [win32] | ||
| 2222 | |||
| 1689 | '@esbuild/win32-arm64@0.27.7': | 2223 | '@esbuild/win32-arm64@0.27.7': |
| 1690 | resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} | 2224 | resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} |
| 1691 | engines: {node: '>=18'} | 2225 | engines: {node: '>=18'} |
| 1692 | cpu: [arm64] | 2226 | cpu: [arm64] |
| 1693 | os: [win32] | 2227 | os: [win32] |
| 1694 | 2228 | ||
| 2229 | '@esbuild/win32-ia32@0.25.12': | ||
| 2230 | resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} | ||
| 2231 | engines: {node: '>=18'} | ||
| 2232 | cpu: [ia32] | ||
| 2233 | os: [win32] | ||
| 2234 | |||
| 1695 | '@esbuild/win32-ia32@0.27.7': | 2235 | '@esbuild/win32-ia32@0.27.7': |
| 1696 | resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} | 2236 | resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} |
| 1697 | engines: {node: '>=18'} | 2237 | engines: {node: '>=18'} |
| 1698 | cpu: [ia32] | 2238 | cpu: [ia32] |
| 1699 | os: [win32] | 2239 | os: [win32] |
| 1700 | 2240 | ||
| 2241 | '@esbuild/win32-x64@0.25.12': | ||
| 2242 | resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} | ||
| 2243 | engines: {node: '>=18'} | ||
| 2244 | cpu: [x64] | ||
| 2245 | os: [win32] | ||
| 2246 | |||
| 1701 | '@esbuild/win32-x64@0.27.7': | 2247 | '@esbuild/win32-x64@0.27.7': |
| 1702 | resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} | 2248 | resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} |
| 1703 | engines: {node: '>=18'} | 2249 | engines: {node: '>=18'} |
| ... | @@ -2136,6 +2682,10 @@ packages: | ... | @@ -2136,6 +2682,10 @@ packages: |
| 2136 | '@jridgewell/trace-mapping@0.3.31': | 2682 | '@jridgewell/trace-mapping@0.3.31': |
| 2137 | resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} | 2683 | resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} |
| 2138 | 2684 | ||
| 2685 | '@js-temporal/polyfill@0.5.1': | ||
| 2686 | resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} | ||
| 2687 | engines: {node: '>=12'} | ||
| 2688 | |||
| 2139 | '@legendapp/list@3.0.0-beta.44': | 2689 | '@legendapp/list@3.0.0-beta.44': |
| 2140 | resolution: {integrity: sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A==} | 2690 | resolution: {integrity: sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A==} |
| 2141 | peerDependencies: | 2691 | peerDependencies: |
| ... | @@ -2222,6 +2772,63 @@ packages: | ... | @@ -2222,6 +2772,63 @@ packages: |
| 2222 | peerDependencies: | 2772 | peerDependencies: |
| 2223 | yjs: '>=13.5.22' | 2773 | yjs: '>=13.5.22' |
| 2224 | 2774 | ||
| 2775 | '@libsql/client@0.17.3': | ||
| 2776 | resolution: {integrity: sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA==} | ||
| 2777 | |||
| 2778 | '@libsql/core@0.17.3': | ||
| 2779 | resolution: {integrity: sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg==} | ||
| 2780 | |||
| 2781 | '@libsql/darwin-arm64@0.5.29': | ||
| 2782 | resolution: {integrity: sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A==} | ||
| 2783 | cpu: [arm64] | ||
| 2784 | os: [darwin] | ||
| 2785 | |||
| 2786 | '@libsql/darwin-x64@0.5.29': | ||
| 2787 | resolution: {integrity: sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ==} | ||
| 2788 | cpu: [x64] | ||
| 2789 | os: [darwin] | ||
| 2790 | |||
| 2791 | '@libsql/hrana-client@0.10.0': | ||
| 2792 | resolution: {integrity: sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw==} | ||
| 2793 | |||
| 2794 | '@libsql/isomorphic-ws@0.1.5': | ||
| 2795 | resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} | ||
| 2796 | |||
| 2797 | '@libsql/linux-arm-gnueabihf@0.5.29': | ||
| 2798 | resolution: {integrity: sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ==} | ||
| 2799 | cpu: [arm] | ||
| 2800 | os: [linux] | ||
| 2801 | |||
| 2802 | '@libsql/linux-arm-musleabihf@0.5.29': | ||
| 2803 | resolution: {integrity: sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg==} | ||
| 2804 | cpu: [arm] | ||
| 2805 | os: [linux] | ||
| 2806 | |||
| 2807 | '@libsql/linux-arm64-gnu@0.5.29': | ||
| 2808 | resolution: {integrity: sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w==} | ||
| 2809 | cpu: [arm64] | ||
| 2810 | os: [linux] | ||
| 2811 | |||
| 2812 | '@libsql/linux-arm64-musl@0.5.29': | ||
| 2813 | resolution: {integrity: sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg==} | ||
| 2814 | cpu: [arm64] | ||
| 2815 | os: [linux] | ||
| 2816 | |||
| 2817 | '@libsql/linux-x64-gnu@0.5.29': | ||
| 2818 | resolution: {integrity: sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg==} | ||
| 2819 | cpu: [x64] | ||
| 2820 | os: [linux] | ||
| 2821 | |||
| 2822 | '@libsql/linux-x64-musl@0.5.29': | ||
| 2823 | resolution: {integrity: sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w==} | ||
| 2824 | cpu: [x64] | ||
| 2825 | os: [linux] | ||
| 2826 | |||
| 2827 | '@libsql/win32-x64-msvc@0.5.29': | ||
| 2828 | resolution: {integrity: sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg==} | ||
| 2829 | cpu: [x64] | ||
| 2830 | os: [win32] | ||
| 2831 | |||
| 2225 | '@malept/cross-spawn-promise@2.0.0': | 2832 | '@malept/cross-spawn-promise@2.0.0': |
| 2226 | resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} | 2833 | resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} |
| 2227 | engines: {node: '>= 12.13.0'} | 2834 | engines: {node: '>= 12.13.0'} |
| ... | @@ -2280,6 +2887,84 @@ packages: | ... | @@ -2280,6 +2887,84 @@ packages: |
| 2280 | '@emnapi/core': ^1.7.1 | 2887 | '@emnapi/core': ^1.7.1 |
| 2281 | '@emnapi/runtime': ^1.7.1 | 2888 | '@emnapi/runtime': ^1.7.1 |
| 2282 | 2889 | ||
| 2890 | '@neon-rs/load@0.0.4': | ||
| 2891 | resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} | ||
| 2892 | |||
| 2893 | '@noble/curves@1.9.1': | ||
| 2894 | resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} | ||
| 2895 | engines: {node: ^14.21.3 || >=16} | ||
| 2896 | |||
| 2897 | '@noble/hashes@1.8.0': | ||
| 2898 | resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} | ||
| 2899 | engines: {node: ^14.21.3 || >=16} | ||
| 2900 | |||
| 2901 | '@nodable/entities@2.1.1': | ||
| 2902 | resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} | ||
| 2903 | |||
| 2904 | '@nodelib/fs.scandir@2.1.5': | ||
| 2905 | resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} | ||
| 2906 | engines: {node: '>= 8'} | ||
| 2907 | |||
| 2908 | '@nodelib/fs.stat@2.0.5': | ||
| 2909 | resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} | ||
| 2910 | engines: {node: '>= 8'} | ||
| 2911 | |||
| 2912 | '@nodelib/fs.walk@1.2.8': | ||
| 2913 | resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} | ||
| 2914 | engines: {node: '>= 8'} | ||
| 2915 | |||
| 2916 | '@octokit/auth-token@6.0.0': | ||
| 2917 | resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} | ||
| 2918 | engines: {node: '>= 20'} | ||
| 2919 | |||
| 2920 | '@octokit/core@7.0.6': | ||
| 2921 | resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} | ||
| 2922 | engines: {node: '>= 20'} | ||
| 2923 | |||
| 2924 | '@octokit/endpoint@11.0.3': | ||
| 2925 | resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} | ||
| 2926 | engines: {node: '>= 20'} | ||
| 2927 | |||
| 2928 | '@octokit/graphql@9.0.3': | ||
| 2929 | resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} | ||
| 2930 | engines: {node: '>= 20'} | ||
| 2931 | |||
| 2932 | '@octokit/openapi-types@27.0.0': | ||
| 2933 | resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} | ||
| 2934 | |||
| 2935 | '@octokit/plugin-paginate-rest@14.0.0': | ||
| 2936 | resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} | ||
| 2937 | engines: {node: '>= 20'} | ||
| 2938 | peerDependencies: | ||
| 2939 | '@octokit/core': '>=6' | ||
| 2940 | |||
| 2941 | '@octokit/plugin-request-log@6.0.0': | ||
| 2942 | resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} | ||
| 2943 | engines: {node: '>= 20'} | ||
| 2944 | peerDependencies: | ||
| 2945 | '@octokit/core': '>=6' | ||
| 2946 | |||
| 2947 | '@octokit/plugin-rest-endpoint-methods@17.0.0': | ||
| 2948 | resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} | ||
| 2949 | engines: {node: '>= 20'} | ||
| 2950 | peerDependencies: | ||
| 2951 | '@octokit/core': '>=6' | ||
| 2952 | |||
| 2953 | '@octokit/request-error@7.1.0': | ||
| 2954 | resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} | ||
| 2955 | engines: {node: '>= 20'} | ||
| 2956 | |||
| 2957 | '@octokit/request@10.0.10': | ||
| 2958 | resolution: {integrity: sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w==} | ||
| 2959 | engines: {node: '>= 20'} | ||
| 2960 | |||
| 2961 | '@octokit/rest@22.0.1': | ||
| 2962 | resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} | ||
| 2963 | engines: {node: '>= 20'} | ||
| 2964 | |||
| 2965 | '@octokit/types@16.0.0': | ||
| 2966 | resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} | ||
| 2967 | |||
| 2283 | '@open-draft/deferred-promise@2.2.0': | 2968 | '@open-draft/deferred-promise@2.2.0': |
| 2284 | resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} | 2969 | resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} |
| 2285 | 2970 | ||
| ... | @@ -2302,6 +2987,9 @@ packages: | ... | @@ -2302,6 +2987,9 @@ packages: |
| 2302 | '@oxc-project/types@0.127.0': | 2987 | '@oxc-project/types@0.127.0': |
| 2303 | resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} | 2988 | resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} |
| 2304 | 2989 | ||
| 2990 | '@oxc-project/types@0.130.0': | ||
| 2991 | resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} | ||
| 2992 | |||
| 2305 | '@oxc-project/types@0.133.0': | 2993 | '@oxc-project/types@0.133.0': |
| 2306 | resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} | 2994 | resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} |
| 2307 | 2995 | ||
| ... | @@ -2909,6 +3597,12 @@ packages: | ... | @@ -2909,6 +3597,12 @@ packages: |
| 2909 | cpu: [arm64] | 3597 | cpu: [arm64] |
| 2910 | os: [android] | 3598 | os: [android] |
| 2911 | 3599 | ||
| 3600 | '@rolldown/binding-android-arm64@1.0.1': | ||
| 3601 | resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} | ||
| 3602 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3603 | cpu: [arm64] | ||
| 3604 | os: [android] | ||
| 3605 | |||
| 2912 | '@rolldown/binding-android-arm64@1.0.3': | 3606 | '@rolldown/binding-android-arm64@1.0.3': |
| 2913 | resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} | 3607 | resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} |
| 2914 | engines: {node: ^20.19.0 || >=22.12.0} | 3608 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -2921,6 +3615,12 @@ packages: | ... | @@ -2921,6 +3615,12 @@ packages: |
| 2921 | cpu: [arm64] | 3615 | cpu: [arm64] |
| 2922 | os: [darwin] | 3616 | os: [darwin] |
| 2923 | 3617 | ||
| 3618 | '@rolldown/binding-darwin-arm64@1.0.1': | ||
| 3619 | resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} | ||
| 3620 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3621 | cpu: [arm64] | ||
| 3622 | os: [darwin] | ||
| 3623 | |||
| 2924 | '@rolldown/binding-darwin-arm64@1.0.3': | 3624 | '@rolldown/binding-darwin-arm64@1.0.3': |
| 2925 | resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} | 3625 | resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} |
| 2926 | engines: {node: ^20.19.0 || >=22.12.0} | 3626 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -2933,6 +3633,12 @@ packages: | ... | @@ -2933,6 +3633,12 @@ packages: |
| 2933 | cpu: [x64] | 3633 | cpu: [x64] |
| 2934 | os: [darwin] | 3634 | os: [darwin] |
| 2935 | 3635 | ||
| 3636 | '@rolldown/binding-darwin-x64@1.0.1': | ||
| 3637 | resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} | ||
| 3638 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3639 | cpu: [x64] | ||
| 3640 | os: [darwin] | ||
| 3641 | |||
| 2936 | '@rolldown/binding-darwin-x64@1.0.3': | 3642 | '@rolldown/binding-darwin-x64@1.0.3': |
| 2937 | resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} | 3643 | resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} |
| 2938 | engines: {node: ^20.19.0 || >=22.12.0} | 3644 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -2945,6 +3651,12 @@ packages: | ... | @@ -2945,6 +3651,12 @@ packages: |
| 2945 | cpu: [x64] | 3651 | cpu: [x64] |
| 2946 | os: [freebsd] | 3652 | os: [freebsd] |
| 2947 | 3653 | ||
| 3654 | '@rolldown/binding-freebsd-x64@1.0.1': | ||
| 3655 | resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} | ||
| 3656 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3657 | cpu: [x64] | ||
| 3658 | os: [freebsd] | ||
| 3659 | |||
| 2948 | '@rolldown/binding-freebsd-x64@1.0.3': | 3660 | '@rolldown/binding-freebsd-x64@1.0.3': |
| 2949 | resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} | 3661 | resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} |
| 2950 | engines: {node: ^20.19.0 || >=22.12.0} | 3662 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -2957,6 +3669,12 @@ packages: | ... | @@ -2957,6 +3669,12 @@ packages: |
| 2957 | cpu: [arm] | 3669 | cpu: [arm] |
| 2958 | os: [linux] | 3670 | os: [linux] |
| 2959 | 3671 | ||
| 3672 | '@rolldown/binding-linux-arm-gnueabihf@1.0.1': | ||
| 3673 | resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} | ||
| 3674 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3675 | cpu: [arm] | ||
| 3676 | os: [linux] | ||
| 3677 | |||
| 2960 | '@rolldown/binding-linux-arm-gnueabihf@1.0.3': | 3678 | '@rolldown/binding-linux-arm-gnueabihf@1.0.3': |
| 2961 | resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} | 3679 | resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} |
| 2962 | engines: {node: ^20.19.0 || >=22.12.0} | 3680 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -2969,6 +3687,12 @@ packages: | ... | @@ -2969,6 +3687,12 @@ packages: |
| 2969 | cpu: [arm64] | 3687 | cpu: [arm64] |
| 2970 | os: [linux] | 3688 | os: [linux] |
| 2971 | 3689 | ||
| 3690 | '@rolldown/binding-linux-arm64-gnu@1.0.1': | ||
| 3691 | resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} | ||
| 3692 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3693 | cpu: [arm64] | ||
| 3694 | os: [linux] | ||
| 3695 | |||
| 2972 | '@rolldown/binding-linux-arm64-gnu@1.0.3': | 3696 | '@rolldown/binding-linux-arm64-gnu@1.0.3': |
| 2973 | resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} | 3697 | resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} |
| 2974 | engines: {node: ^20.19.0 || >=22.12.0} | 3698 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -2981,6 +3705,12 @@ packages: | ... | @@ -2981,6 +3705,12 @@ packages: |
| 2981 | cpu: [arm64] | 3705 | cpu: [arm64] |
| 2982 | os: [linux] | 3706 | os: [linux] |
| 2983 | 3707 | ||
| 3708 | '@rolldown/binding-linux-arm64-musl@1.0.1': | ||
| 3709 | resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} | ||
| 3710 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3711 | cpu: [arm64] | ||
| 3712 | os: [linux] | ||
| 3713 | |||
| 2984 | '@rolldown/binding-linux-arm64-musl@1.0.3': | 3714 | '@rolldown/binding-linux-arm64-musl@1.0.3': |
| 2985 | resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} | 3715 | resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} |
| 2986 | engines: {node: ^20.19.0 || >=22.12.0} | 3716 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -2993,6 +3723,12 @@ packages: | ... | @@ -2993,6 +3723,12 @@ packages: |
| 2993 | cpu: [ppc64] | 3723 | cpu: [ppc64] |
| 2994 | os: [linux] | 3724 | os: [linux] |
| 2995 | 3725 | ||
| 3726 | '@rolldown/binding-linux-ppc64-gnu@1.0.1': | ||
| 3727 | resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} | ||
| 3728 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3729 | cpu: [ppc64] | ||
| 3730 | os: [linux] | ||
| 3731 | |||
| 2996 | '@rolldown/binding-linux-ppc64-gnu@1.0.3': | 3732 | '@rolldown/binding-linux-ppc64-gnu@1.0.3': |
| 2997 | resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} | 3733 | resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} |
| 2998 | engines: {node: ^20.19.0 || >=22.12.0} | 3734 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -3005,6 +3741,12 @@ packages: | ... | @@ -3005,6 +3741,12 @@ packages: |
| 3005 | cpu: [s390x] | 3741 | cpu: [s390x] |
| 3006 | os: [linux] | 3742 | os: [linux] |
| 3007 | 3743 | ||
| 3744 | '@rolldown/binding-linux-s390x-gnu@1.0.1': | ||
| 3745 | resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} | ||
| 3746 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3747 | cpu: [s390x] | ||
| 3748 | os: [linux] | ||
| 3749 | |||
| 3008 | '@rolldown/binding-linux-s390x-gnu@1.0.3': | 3750 | '@rolldown/binding-linux-s390x-gnu@1.0.3': |
| 3009 | resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} | 3751 | resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} |
| 3010 | engines: {node: ^20.19.0 || >=22.12.0} | 3752 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -3017,6 +3759,12 @@ packages: | ... | @@ -3017,6 +3759,12 @@ packages: |
| 3017 | cpu: [x64] | 3759 | cpu: [x64] |
| 3018 | os: [linux] | 3760 | os: [linux] |
| 3019 | 3761 | ||
| 3762 | '@rolldown/binding-linux-x64-gnu@1.0.1': | ||
| 3763 | resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} | ||
| 3764 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3765 | cpu: [x64] | ||
| 3766 | os: [linux] | ||
| 3767 | |||
| 3020 | '@rolldown/binding-linux-x64-gnu@1.0.3': | 3768 | '@rolldown/binding-linux-x64-gnu@1.0.3': |
| 3021 | resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} | 3769 | resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} |
| 3022 | engines: {node: ^20.19.0 || >=22.12.0} | 3770 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -3029,6 +3777,12 @@ packages: | ... | @@ -3029,6 +3777,12 @@ packages: |
| 3029 | cpu: [x64] | 3777 | cpu: [x64] |
| 3030 | os: [linux] | 3778 | os: [linux] |
| 3031 | 3779 | ||
| 3780 | '@rolldown/binding-linux-x64-musl@1.0.1': | ||
| 3781 | resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} | ||
| 3782 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3783 | cpu: [x64] | ||
| 3784 | os: [linux] | ||
| 3785 | |||
| 3032 | '@rolldown/binding-linux-x64-musl@1.0.3': | 3786 | '@rolldown/binding-linux-x64-musl@1.0.3': |
| 3033 | resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} | 3787 | resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} |
| 3034 | engines: {node: ^20.19.0 || >=22.12.0} | 3788 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -3041,6 +3795,12 @@ packages: | ... | @@ -3041,6 +3795,12 @@ packages: |
| 3041 | cpu: [arm64] | 3795 | cpu: [arm64] |
| 3042 | os: [openharmony] | 3796 | os: [openharmony] |
| 3043 | 3797 | ||
| 3798 | '@rolldown/binding-openharmony-arm64@1.0.1': | ||
| 3799 | resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} | ||
| 3800 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3801 | cpu: [arm64] | ||
| 3802 | os: [openharmony] | ||
| 3803 | |||
| 3044 | '@rolldown/binding-openharmony-arm64@1.0.3': | 3804 | '@rolldown/binding-openharmony-arm64@1.0.3': |
| 3045 | resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} | 3805 | resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} |
| 3046 | engines: {node: ^20.19.0 || >=22.12.0} | 3806 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -3052,6 +3812,11 @@ packages: | ... | @@ -3052,6 +3812,11 @@ packages: |
| 3052 | engines: {node: ^20.19.0 || >=22.12.0} | 3812 | engines: {node: ^20.19.0 || >=22.12.0} |
| 3053 | cpu: [wasm32] | 3813 | cpu: [wasm32] |
| 3054 | 3814 | ||
| 3815 | '@rolldown/binding-wasm32-wasi@1.0.1': | ||
| 3816 | resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} | ||
| 3817 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3818 | cpu: [wasm32] | ||
| 3819 | |||
| 3055 | '@rolldown/binding-wasm32-wasi@1.0.3': | 3820 | '@rolldown/binding-wasm32-wasi@1.0.3': |
| 3056 | resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} | 3821 | resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} |
| 3057 | engines: {node: ^20.19.0 || >=22.12.0} | 3822 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -3063,6 +3828,12 @@ packages: | ... | @@ -3063,6 +3828,12 @@ packages: |
| 3063 | cpu: [arm64] | 3828 | cpu: [arm64] |
| 3064 | os: [win32] | 3829 | os: [win32] |
| 3065 | 3830 | ||
| 3831 | '@rolldown/binding-win32-arm64-msvc@1.0.1': | ||
| 3832 | resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} | ||
| 3833 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3834 | cpu: [arm64] | ||
| 3835 | os: [win32] | ||
| 3836 | |||
| 3066 | '@rolldown/binding-win32-arm64-msvc@1.0.3': | 3837 | '@rolldown/binding-win32-arm64-msvc@1.0.3': |
| 3067 | resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} | 3838 | resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} |
| 3068 | engines: {node: ^20.19.0 || >=22.12.0} | 3839 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -3075,6 +3846,12 @@ packages: | ... | @@ -3075,6 +3846,12 @@ packages: |
| 3075 | cpu: [x64] | 3846 | cpu: [x64] |
| 3076 | os: [win32] | 3847 | os: [win32] |
| 3077 | 3848 | ||
| 3849 | '@rolldown/binding-win32-x64-msvc@1.0.1': | ||
| 3850 | resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} | ||
| 3851 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 3852 | cpu: [x64] | ||
| 3853 | os: [win32] | ||
| 3854 | |||
| 3078 | '@rolldown/binding-win32-x64-msvc@1.0.3': | 3855 | '@rolldown/binding-win32-x64-msvc@1.0.3': |
| 3079 | resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} | 3856 | resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} |
| 3080 | engines: {node: ^20.19.0 || >=22.12.0} | 3857 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -3297,9 +4074,67 @@ packages: | ... | @@ -3297,9 +4074,67 @@ packages: |
| 3297 | resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} | 4074 | resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} |
| 3298 | engines: {node: '>=10'} | 4075 | engines: {node: '>=10'} |
| 3299 | 4076 | ||
| 4077 | '@smithy/core@3.24.6': | ||
| 4078 | resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} | ||
| 4079 | engines: {node: '>=18.0.0'} | ||
| 4080 | |||
| 4081 | '@smithy/credential-provider-imds@4.3.8': | ||
| 4082 | resolution: {integrity: sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==} | ||
| 4083 | engines: {node: '>=18.0.0'} | ||
| 4084 | |||
| 4085 | '@smithy/fetch-http-handler@5.4.6': | ||
| 4086 | resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} | ||
| 4087 | engines: {node: '>=18.0.0'} | ||
| 4088 | |||
| 4089 | '@smithy/is-array-buffer@2.2.0': | ||
| 4090 | resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} | ||
| 4091 | engines: {node: '>=14.0.0'} | ||
| 4092 | |||
| 4093 | '@smithy/node-config-provider@4.4.6': | ||
| 4094 | resolution: {integrity: sha512-M+gG6eQ0y073mSmNB+erRXJvwpsqsN72ol2w6vcd8FEKeG7pqYK0JvzfVqONkPj2ElBB2pg+cU13I850b//Wag==} | ||
| 4095 | engines: {node: '>=18.0.0'} | ||
| 4096 | |||
| 4097 | '@smithy/node-http-handler@4.7.7': | ||
| 4098 | resolution: {integrity: sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==} | ||
| 4099 | engines: {node: '>=18.0.0'} | ||
| 4100 | |||
| 4101 | '@smithy/shared-ini-file-loader@4.5.6': | ||
| 4102 | resolution: {integrity: sha512-In8gYD2R66EKlGAq9QrNKVrMOGaGBD7LUNp2kUjeQ4V9zNktFIXBPmrCySr4YYo+jVeVL6CnWj26sOamcF0qIg==} | ||
| 4103 | engines: {node: '>=18.0.0'} | ||
| 4104 | |||
| 4105 | '@smithy/signature-v4@5.4.6': | ||
| 4106 | resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} | ||
| 4107 | engines: {node: '>=18.0.0'} | ||
| 4108 | |||
| 4109 | '@smithy/types@4.14.3': | ||
| 4110 | resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} | ||
| 4111 | engines: {node: '>=18.0.0'} | ||
| 4112 | |||
| 4113 | '@smithy/util-base64@4.4.6': | ||
| 4114 | resolution: {integrity: sha512-V6ApAGvCQnb7Wy1Sy60AQc+7UOEaNQxvAXBLdMi5Zzm66cmX0srvfAxDmg7BGuJ+9H9ez0PPWS/AeFgWxwGavA==} | ||
| 4115 | engines: {node: '>=18.0.0'} | ||
| 4116 | |||
| 4117 | '@smithy/util-buffer-from@2.2.0': | ||
| 4118 | resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} | ||
| 4119 | engines: {node: '>=14.0.0'} | ||
| 4120 | |||
| 4121 | '@smithy/util-utf8@2.3.0': | ||
| 4122 | resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} | ||
| 4123 | engines: {node: '>=14.0.0'} | ||
| 4124 | |||
| 4125 | '@stablelib/base64@1.0.1': | ||
| 4126 | resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} | ||
| 4127 | |||
| 3300 | '@standard-schema/spec@1.1.0': | 4128 | '@standard-schema/spec@1.1.0': |
| 3301 | resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} | 4129 | resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} |
| 3302 | 4130 | ||
| 4131 | '@stripe/stripe-js@5.6.0': | ||
| 4132 | resolution: {integrity: sha512-w8CEY73X/7tw2KKlL3iOk679V9bWseE4GzNz3zlaYxcTjmcmWOathRb0emgo/QQ3eoNzmq68+2Y2gxluAv3xGw==} | ||
| 4133 | engines: {node: '>=12.16'} | ||
| 4134 | |||
| 4135 | '@swc/helpers@0.5.21': | ||
| 4136 | resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} | ||
| 4137 | |||
| 3303 | '@szmarczak/http-timer@4.0.6': | 4138 | '@szmarczak/http-timer@4.0.6': |
| 3304 | resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} | 4139 | resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} |
| 3305 | engines: {node: '>=10'} | 4140 | engines: {node: '>=10'} |
| ... | @@ -3595,6 +4430,9 @@ packages: | ... | @@ -3595,6 +4430,9 @@ packages: |
| 3595 | '@types/aria-query@5.0.4': | 4430 | '@types/aria-query@5.0.4': |
| 3596 | resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} | 4431 | resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} |
| 3597 | 4432 | ||
| 4433 | '@types/aws-lambda@8.10.161': | ||
| 4434 | resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} | ||
| 4435 | |||
| 3598 | '@types/babel__core@7.20.5': | 4436 | '@types/babel__core@7.20.5': |
| 3599 | resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} | 4437 | resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} |
| 3600 | 4438 | ||
| ... | @@ -3969,6 +4807,12 @@ packages: | ... | @@ -3969,6 +4807,12 @@ packages: |
| 3969 | '@xterm/xterm@6.0.0': | 4807 | '@xterm/xterm@6.0.0': |
| 3970 | resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} | 4808 | resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} |
| 3971 | 4809 | ||
| 4810 | '@zxcvbn-ts/core@3.0.4': | ||
| 4811 | resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==} | ||
| 4812 | |||
| 4813 | '@zxcvbn-ts/language-common@3.0.4': | ||
| 4814 | resolution: {integrity: sha512-viSNNnRYtc7ULXzxrQIVUNwHAPSXRtoIwy/Tq4XQQdIknBzw4vz36lQLF6mvhMlTIlpjoN/Z1GFu/fwiAlUSsw==} | ||
| 4815 | |||
| 3972 | abbrev@4.0.0: | 4816 | abbrev@4.0.0: |
| 3973 | resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} | 4817 | resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} |
| 3974 | engines: {node: ^20.17.0 || >=22.9.0} | 4818 | engines: {node: ^20.17.0 || >=22.9.0} |
| ... | @@ -4021,6 +4865,38 @@ packages: | ... | @@ -4021,6 +4865,38 @@ packages: |
| 4021 | ajv@8.20.0: | 4865 | ajv@8.20.0: |
| 4022 | resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} | 4866 | resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} |
| 4023 | 4867 | ||
| 4868 | alchemy@https://pkg.ing/alchemy/8b9c241: | ||
| 4869 | resolution: {tarball: https://pkg.ing/alchemy/8b9c241} | ||
| 4870 | version: 2.0.0-beta.51 | ||
| 4871 | hasBin: true | ||
| 4872 | peerDependencies: | ||
| 4873 | '@effect/platform-bun': 4.0.0-beta.78 | ||
| 4874 | '@effect/platform-node': 4.0.0-beta.78 | ||
| 4875 | '@effect/sql-pg': 4.0.0-beta.78 | ||
| 4876 | drizzle-kit: '>=1.0.0-rc.1' | ||
| 4877 | drizzle-orm: '>=1.0.0-rc.1' | ||
| 4878 | effect: 4.0.0-beta.78 | ||
| 4879 | vite: ^8.0.7 | ||
| 4880 | ws: ^8.20.0 | ||
| 4881 | peerDependenciesMeta: | ||
| 4882 | '@effect/platform-bun': | ||
| 4883 | optional: true | ||
| 4884 | '@effect/platform-node': | ||
| 4885 | optional: true | ||
| 4886 | '@effect/sql-pg': | ||
| 4887 | optional: true | ||
| 4888 | drizzle-kit: | ||
| 4889 | optional: true | ||
| 4890 | drizzle-orm: | ||
| 4891 | optional: true | ||
| 4892 | vite: | ||
| 4893 | optional: true | ||
| 4894 | ws: | ||
| 4895 | optional: true | ||
| 4896 | |||
| 4897 | alien-signals@2.0.6: | ||
| 4898 | resolution: {integrity: sha512-P3TxJSe31bUHBiblg59oU1PpaWPtmxF9GhJ/cB7OkgJ0qN/ifFSKUI25/v8ZhsT+lIG6ac8DpTOplXxORX6F3Q==} | ||
| 4899 | |||
| 4024 | anser@1.4.10: | 4900 | anser@1.4.10: |
| 4025 | resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} | 4901 | resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} |
| 4026 | 4902 | ||
| ... | @@ -4028,6 +4904,10 @@ packages: | ... | @@ -4028,6 +4904,10 @@ packages: |
| 4028 | resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} | 4904 | resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} |
| 4029 | engines: {node: '>=8'} | 4905 | engines: {node: '>=8'} |
| 4030 | 4906 | ||
| 4907 | ansi-escapes@7.3.0: | ||
| 4908 | resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} | ||
| 4909 | engines: {node: '>=18'} | ||
| 4910 | |||
| 4031 | ansi-regex@4.1.1: | 4911 | ansi-regex@4.1.1: |
| 4032 | resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} | 4912 | resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} |
| 4033 | engines: {node: '>=6'} | 4913 | engines: {node: '>=6'} |
| ... | @@ -4036,6 +4916,10 @@ packages: | ... | @@ -4036,6 +4916,10 @@ packages: |
| 4036 | resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} | 4916 | resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} |
| 4037 | engines: {node: '>=8'} | 4917 | engines: {node: '>=8'} |
| 4038 | 4918 | ||
| 4919 | ansi-regex@6.2.2: | ||
| 4920 | resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} | ||
| 4921 | engines: {node: '>=12'} | ||
| 4922 | |||
| 4039 | ansi-styles@3.2.1: | 4923 | ansi-styles@3.2.1: |
| 4040 | resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} | 4924 | resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} |
| 4041 | engines: {node: '>=4'} | 4925 | engines: {node: '>=4'} |
| ... | @@ -4048,6 +4932,10 @@ packages: | ... | @@ -4048,6 +4932,10 @@ packages: |
| 4048 | resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} | 4932 | resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} |
| 4049 | engines: {node: '>=10'} | 4933 | engines: {node: '>=10'} |
| 4050 | 4934 | ||
| 4935 | ansi-styles@6.2.3: | ||
| 4936 | resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} | ||
| 4937 | engines: {node: '>=12'} | ||
| 4938 | |||
| 4051 | ansis@4.3.1: | 4939 | ansis@4.3.1: |
| 4052 | resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} | 4940 | resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} |
| 4053 | engines: {node: '>=14'} | 4941 | engines: {node: '>=14'} |
| ... | @@ -4123,6 +5011,17 @@ packages: | ... | @@ -4123,6 +5011,17 @@ packages: |
| 4123 | resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} | 5011 | resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} |
| 4124 | engines: {node: '>= 4.0.0'} | 5012 | engines: {node: '>= 4.0.0'} |
| 4125 | 5013 | ||
| 5014 | auto-bind@5.0.1: | ||
| 5015 | resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} | ||
| 5016 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} | ||
| 5017 | |||
| 5018 | aws-ssl-profiles@1.1.2: | ||
| 5019 | resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} | ||
| 5020 | engines: {node: '>= 6.0.0'} | ||
| 5021 | |||
| 5022 | aws4fetch@1.0.20: | ||
| 5023 | resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} | ||
| 5024 | |||
| 4126 | axobject-query@4.1.0: | 5025 | axobject-query@4.1.0: |
| 4127 | resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} | 5026 | resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} |
| 4128 | engines: {node: '>= 0.4'} | 5027 | engines: {node: '>= 0.4'} |
| ... | @@ -4172,6 +5071,9 @@ packages: | ... | @@ -4172,6 +5071,9 @@ packages: |
| 4172 | expo-widgets: | 5071 | expo-widgets: |
| 4173 | optional: true | 5072 | optional: true |
| 4174 | 5073 | ||
| 5074 | badgin@1.2.3: | ||
| 5075 | resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} | ||
| 5076 | |||
| 4175 | bail@2.0.2: | 5077 | bail@2.0.2: |
| 4176 | resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} | 5078 | resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} |
| 4177 | 5079 | ||
| ... | @@ -4185,6 +5087,9 @@ packages: | ... | @@ -4185,6 +5087,9 @@ packages: |
| 4185 | barcode-detector@3.2.0: | 5087 | barcode-detector@3.2.0: |
| 4186 | resolution: {integrity: sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==} | 5088 | resolution: {integrity: sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==} |
| 4187 | 5089 | ||
| 5090 | base-64@1.0.0: | ||
| 5091 | resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} | ||
| 5092 | |||
| 4188 | base64-js@1.5.1: | 5093 | base64-js@1.5.1: |
| 4189 | resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} | 5094 | resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} |
| 4190 | 5095 | ||
| ... | @@ -4193,6 +5098,9 @@ packages: | ... | @@ -4193,6 +5098,9 @@ packages: |
| 4193 | engines: {node: '>=6.0.0'} | 5098 | engines: {node: '>=6.0.0'} |
| 4194 | hasBin: true | 5099 | hasBin: true |
| 4195 | 5100 | ||
| 5101 | before-after-hook@4.0.0: | ||
| 5102 | resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} | ||
| 5103 | |||
| 4196 | big-integer@1.6.52: | 5104 | big-integer@1.6.52: |
| 4197 | resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} | 5105 | resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} |
| 4198 | engines: {node: '>=0.6'} | 5106 | engines: {node: '>=0.6'} |
| ... | @@ -4208,6 +5116,9 @@ packages: | ... | @@ -4208,6 +5116,9 @@ packages: |
| 4208 | resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} | 5116 | resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} |
| 4209 | deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. | 5117 | deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. |
| 4210 | 5118 | ||
| 5119 | bowser@2.14.1: | ||
| 5120 | resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} | ||
| 5121 | |||
| 4211 | bplist-creator@0.1.0: | 5122 | bplist-creator@0.1.0: |
| 4212 | resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} | 5123 | resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} |
| 4213 | 5124 | ||
| ... | @@ -4233,6 +5144,9 @@ packages: | ... | @@ -4233,6 +5144,9 @@ packages: |
| 4233 | resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} | 5144 | resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} |
| 4234 | engines: {node: '>=8'} | 5145 | engines: {node: '>=8'} |
| 4235 | 5146 | ||
| 5147 | browser-tabs-lock@1.3.0: | ||
| 5148 | resolution: {integrity: sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==} | ||
| 5149 | |||
| 4236 | browserslist@4.28.2: | 5150 | browserslist@4.28.2: |
| 4237 | resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} | 5151 | resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} |
| 4238 | engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} | 5152 | engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} |
| ... | @@ -4250,6 +5164,10 @@ packages: | ... | @@ -4250,6 +5164,10 @@ packages: |
| 4250 | buffer@5.7.1: | 5164 | buffer@5.7.1: |
| 4251 | resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} | 5165 | resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} |
| 4252 | 5166 | ||
| 5167 | bufferutil@4.1.0: | ||
| 5168 | resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} | ||
| 5169 | engines: {node: '>=6.14.2'} | ||
| 5170 | |||
| 4253 | builder-util-runtime@9.5.1: | 5171 | builder-util-runtime@9.5.1: |
| 4254 | resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} | 5172 | resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} |
| 4255 | engines: {node: '>=12.0.0'} | 5173 | engines: {node: '>=12.0.0'} |
| ... | @@ -4287,6 +5205,12 @@ packages: | ... | @@ -4287,6 +5205,12 @@ packages: |
| 4287 | caniuse-lite@1.0.30001793: | 5205 | caniuse-lite@1.0.30001793: |
| 4288 | resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} | 5206 | resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} |
| 4289 | 5207 | ||
| 5208 | capnweb@0.6.1: | ||
| 5209 | resolution: {integrity: sha512-fmhV26QPd1ewf5R74h55oVZnGwIcSaRMzbfLQUy8+zOBjuTmT3KXoT8wxHvnp1m9Ht9BoUUS5ZwNLoVLfQTyBg==} | ||
| 5210 | |||
| 5211 | capnweb@0.7.0: | ||
| 5212 | resolution: {integrity: sha512-zO7tt5ch2tImacaR/oMd7e1dqi/fWU7hjZdvQMv6Yo3v9uUGA8cPIUQGvfQTu2c+NgyE/j/oDmMaUlf1PXyfJw==} | ||
| 5213 | |||
| 4290 | ccount@2.0.1: | 5214 | ccount@2.0.1: |
| 4291 | resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} | 5215 | resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} |
| 4292 | 5216 | ||
| ... | @@ -4298,6 +5222,10 @@ packages: | ... | @@ -4298,6 +5222,10 @@ packages: |
| 4298 | resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} | 5222 | resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} |
| 4299 | engines: {node: '>=10'} | 5223 | engines: {node: '>=10'} |
| 4300 | 5224 | ||
| 5225 | chalk@5.6.2: | ||
| 5226 | resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} | ||
| 5227 | engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} | ||
| 5228 | |||
| 4301 | character-entities-html4@2.1.0: | 5229 | character-entities-html4@2.1.0: |
| 4302 | resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} | 5230 | resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} |
| 4303 | 5231 | ||
| ... | @@ -4351,10 +5279,18 @@ packages: | ... | @@ -4351,10 +5279,18 @@ packages: |
| 4351 | class-variance-authority@0.7.1: | 5279 | class-variance-authority@0.7.1: |
| 4352 | resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} | 5280 | resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} |
| 4353 | 5281 | ||
| 5282 | cli-boxes@3.0.0: | ||
| 5283 | resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} | ||
| 5284 | engines: {node: '>=10'} | ||
| 5285 | |||
| 4354 | cli-cursor@2.1.0: | 5286 | cli-cursor@2.1.0: |
| 4355 | resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} | 5287 | resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} |
| 4356 | engines: {node: '>=4'} | 5288 | engines: {node: '>=4'} |
| 4357 | 5289 | ||
| 5290 | cli-cursor@4.0.0: | ||
| 5291 | resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} | ||
| 5292 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} | ||
| 5293 | |||
| 4358 | cli-spinners@2.9.2: | 5294 | cli-spinners@2.9.2: |
| 4359 | resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} | 5295 | resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} |
| 4360 | engines: {node: '>=6'} | 5296 | engines: {node: '>=6'} |
| ... | @@ -4363,6 +5299,10 @@ packages: | ... | @@ -4363,6 +5299,10 @@ packages: |
| 4363 | resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} | 5299 | resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} |
| 4364 | engines: {node: '>=8'} | 5300 | engines: {node: '>=8'} |
| 4365 | 5301 | ||
| 5302 | cli-truncate@5.2.0: | ||
| 5303 | resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} | ||
| 5304 | engines: {node: '>=20'} | ||
| 5305 | |||
| 4366 | cli-width@4.1.0: | 5306 | cli-width@4.1.0: |
| 4367 | resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} | 5307 | resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} |
| 4368 | engines: {node: '>= 12'} | 5308 | engines: {node: '>= 12'} |
| ... | @@ -4389,6 +5329,10 @@ packages: | ... | @@ -4389,6 +5329,10 @@ packages: |
| 4389 | resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} | 5329 | resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} |
| 4390 | engines: {node: '>=0.10.0'} | 5330 | engines: {node: '>=0.10.0'} |
| 4391 | 5331 | ||
| 5332 | code-excerpt@4.0.0: | ||
| 5333 | resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} | ||
| 5334 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} | ||
| 5335 | |||
| 4392 | color-convert@1.9.3: | 5336 | color-convert@1.9.3: |
| 4393 | resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} | 5337 | resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} |
| 4394 | 5338 | ||
| ... | @@ -4477,6 +5421,10 @@ packages: | ... | @@ -4477,6 +5421,10 @@ packages: |
| 4477 | convert-source-map@2.0.0: | 5421 | convert-source-map@2.0.0: |
| 4478 | resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} | 5422 | resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} |
| 4479 | 5423 | ||
| 5424 | convert-to-spaces@2.0.1: | ||
| 5425 | resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} | ||
| 5426 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} | ||
| 5427 | |||
| 4480 | cookie-es@1.2.3: | 5428 | cookie-es@1.2.3: |
| 4481 | resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} | 5429 | resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} |
| 4482 | 5430 | ||
| ... | @@ -4498,6 +5446,9 @@ packages: | ... | @@ -4498,6 +5446,9 @@ packages: |
| 4498 | core-js-compat@3.49.0: | 5446 | core-js-compat@3.49.0: |
| 4499 | resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} | 5447 | resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} |
| 4500 | 5448 | ||
| 5449 | core-js@3.47.0: | ||
| 5450 | resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} | ||
| 5451 | |||
| 4501 | core-util-is@1.0.2: | 5452 | core-util-is@1.0.2: |
| 4502 | resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} | 5453 | resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} |
| 4503 | 5454 | ||
| ... | @@ -4523,6 +5474,9 @@ packages: | ... | @@ -4523,6 +5474,9 @@ packages: |
| 4523 | crossws@0.3.5: | 5474 | crossws@0.3.5: |
| 4524 | resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} | 5475 | resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} |
| 4525 | 5476 | ||
| 5477 | crypto-js@4.2.0: | ||
| 5478 | resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} | ||
| 5479 | |||
| 4526 | css-select@5.2.2: | 5480 | css-select@5.2.2: |
| 4527 | resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} | 5481 | resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} |
| 4528 | 5482 | ||
| ... | @@ -4637,6 +5591,10 @@ packages: | ... | @@ -4637,6 +5591,10 @@ packages: |
| 4637 | resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} | 5591 | resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} |
| 4638 | engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} | 5592 | engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} |
| 4639 | 5593 | ||
| 5594 | detect-libc@2.0.2: | ||
| 5595 | resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} | ||
| 5596 | engines: {node: '>=8'} | ||
| 5597 | |||
| 4640 | detect-libc@2.1.2: | 5598 | detect-libc@2.1.2: |
| 4641 | resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} | 5599 | resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} |
| 4642 | engines: {node: '>=8'} | 5600 | engines: {node: '>=8'} |
| ... | @@ -4699,6 +5657,126 @@ packages: | ... | @@ -4699,6 +5657,126 @@ packages: |
| 4699 | resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} | 5657 | resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} |
| 4700 | engines: {node: '>=12'} | 5658 | engines: {node: '>=12'} |
| 4701 | 5659 | ||
| 5660 | drizzle-kit@1.0.0-rc.3: | ||
| 5661 | resolution: {integrity: sha512-lwGIi6GTlEYzrtac1Szecns+zrAxjaOoNysfBCKRQOXQE7nMJ8IU2I2S6ek3FHEm1N30dQb5YrTdqV7zNH6n7A==} | ||
| 5662 | hasBin: true | ||
| 5663 | |||
| 5664 | drizzle-orm@1.0.0-rc.3: | ||
| 5665 | resolution: {integrity: sha512-akZOa5UxapFbdBG8IDkfBRpSZJpMHaOJtGgp7oi1oHaiU8S3KN92waHo2l5aRuv1D9tMGYpv3BQFOsiGcNjLTQ==} | ||
| 5666 | peerDependencies: | ||
| 5667 | '@aws-sdk/client-rds-data': '>=3' | ||
| 5668 | '@cloudflare/workers-types': '>=4' | ||
| 5669 | '@effect/sql-pg': 4.0.0-beta.78 | ||
| 5670 | '@electric-sql/pglite': '>=0.2.0' | ||
| 5671 | '@libsql/client': '>=0.10.0' | ||
| 5672 | '@libsql/client-wasm': '>=0.10.0' | ||
| 5673 | '@neondatabase/serverless': '>=0.10.0' | ||
| 5674 | '@op-engineering/op-sqlite': '>=2' | ||
| 5675 | '@opentelemetry/api': ^1.4.1 | ||
| 5676 | '@planetscale/database': '>=1.13' | ||
| 5677 | '@sinclair/typebox': '>=0.34.8' | ||
| 5678 | '@sqlitecloud/drivers': '>=1.0.653' | ||
| 5679 | '@tidbcloud/serverless': '*' | ||
| 5680 | '@tursodatabase/database': '>=0.2.1' | ||
| 5681 | '@tursodatabase/database-common': '>=0.2.1' | ||
| 5682 | '@tursodatabase/database-wasm': '>=0.2.1' | ||
| 5683 | '@types/better-sqlite3': '*' | ||
| 5684 | '@types/mssql': ^9.1.4 | ||
| 5685 | '@types/pg': '*' | ||
| 5686 | '@types/sql.js': '*' | ||
| 5687 | '@upstash/redis': '>=1.34.7' | ||
| 5688 | '@vercel/postgres': '>=0.8.0' | ||
| 5689 | '@xata.io/client': '*' | ||
| 5690 | arktype: '>=2.0.0' | ||
| 5691 | better-sqlite3: '>=9.3.0' | ||
| 5692 | bun-types: '*' | ||
| 5693 | effect: 4.0.0-beta.78 | ||
| 5694 | expo-sqlite: '>=14.0.0' | ||
| 5695 | mssql: ^11.0.1 | ||
| 5696 | mysql2: '>=2' | ||
| 5697 | pg: '>=8' | ||
| 5698 | postgres: '>=3' | ||
| 5699 | sql.js: '>=1' | ||
| 5700 | sqlite3: '>=5' | ||
| 5701 | typebox: '>=1.0.0' | ||
| 5702 | valibot: '>=1.0.0-beta.7' | ||
| 5703 | zod: ^3.25.0 || ^4.0.0 | ||
| 5704 | peerDependenciesMeta: | ||
| 5705 | '@aws-sdk/client-rds-data': | ||
| 5706 | optional: true | ||
| 5707 | '@cloudflare/workers-types': | ||
| 5708 | optional: true | ||
| 5709 | '@effect/sql-pg': | ||
| 5710 | optional: true | ||
| 5711 | '@electric-sql/pglite': | ||
| 5712 | optional: true | ||
| 5713 | '@libsql/client': | ||
| 5714 | optional: true | ||
| 5715 | '@libsql/client-wasm': | ||
| 5716 | optional: true | ||
| 5717 | '@neondatabase/serverless': | ||
| 5718 | optional: true | ||
| 5719 | '@op-engineering/op-sqlite': | ||
| 5720 | optional: true | ||
| 5721 | '@opentelemetry/api': | ||
| 5722 | optional: true | ||
| 5723 | '@planetscale/database': | ||
| 5724 | optional: true | ||
| 5725 | '@sinclair/typebox': | ||
| 5726 | optional: true | ||
| 5727 | '@sqlitecloud/drivers': | ||
| 5728 | optional: true | ||
| 5729 | '@tidbcloud/serverless': | ||
| 5730 | optional: true | ||
| 5731 | '@tursodatabase/database': | ||
| 5732 | optional: true | ||
| 5733 | '@tursodatabase/database-common': | ||
| 5734 | optional: true | ||
| 5735 | '@tursodatabase/database-wasm': | ||
| 5736 | optional: true | ||
| 5737 | '@types/better-sqlite3': | ||
| 5738 | optional: true | ||
| 5739 | '@types/mssql': | ||
| 5740 | optional: true | ||
| 5741 | '@types/pg': | ||
| 5742 | optional: true | ||
| 5743 | '@types/sql.js': | ||
| 5744 | optional: true | ||
| 5745 | '@upstash/redis': | ||
| 5746 | optional: true | ||
| 5747 | '@vercel/postgres': | ||
| 5748 | optional: true | ||
| 5749 | '@xata.io/client': | ||
| 5750 | optional: true | ||
| 5751 | arktype: | ||
| 5752 | optional: true | ||
| 5753 | better-sqlite3: | ||
| 5754 | optional: true | ||
| 5755 | bun-types: | ||
| 5756 | optional: true | ||
| 5757 | effect: | ||
| 5758 | optional: true | ||
| 5759 | expo-sqlite: | ||
| 5760 | optional: true | ||
| 5761 | mssql: | ||
| 5762 | optional: true | ||
| 5763 | mysql2: | ||
| 5764 | optional: true | ||
| 5765 | pg: | ||
| 5766 | optional: true | ||
| 5767 | postgres: | ||
| 5768 | optional: true | ||
| 5769 | sql.js: | ||
| 5770 | optional: true | ||
| 5771 | sqlite3: | ||
| 5772 | optional: true | ||
| 5773 | typebox: | ||
| 5774 | optional: true | ||
| 5775 | valibot: | ||
| 5776 | optional: true | ||
| 5777 | zod: | ||
| 5778 | optional: true | ||
| 5779 | |||
| 4702 | dset@3.1.4: | 5780 | dset@3.1.4: |
| 4703 | resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} | 5781 | resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} |
| 4704 | engines: {node: '>=4'} | 5782 | engines: {node: '>=4'} |
| ... | @@ -4710,8 +5788,8 @@ packages: | ... | @@ -4710,8 +5788,8 @@ packages: |
| 4710 | ee-first@1.1.1: | 5788 | ee-first@1.1.1: |
| 4711 | resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} | 5789 | resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} |
| 4712 | 5790 | ||
| 4713 | effect@4.0.0-beta.73: | 5791 | effect@4.0.0-beta.78: |
| 4714 | resolution: {integrity: sha512-rL7jjpT31JhxNiZ0X4sgu+WSJi7uwKTX2jz2OW7PrPLfz5DPjT9iXxxVrekxwpfGdgo92bvxbCc5kBlXgusQJw==} | 5792 | resolution: {integrity: sha512-j79Rl9QpHwMz/ZJWLNpZoiVj9N7zHqiLKN5EcYd/A8J1oqejILWQLfc4HPlvqHqKC8SK55LJ+X4gy4ONJ+JpfQ==} |
| 4715 | 5793 | ||
| 4716 | ejs@3.1.10: | 5794 | ejs@3.1.10: |
| 4717 | resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} | 5795 | resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} |
| ... | @@ -4747,6 +5825,9 @@ packages: | ... | @@ -4747,6 +5825,9 @@ packages: |
| 4747 | emmet@2.4.11: | 5825 | emmet@2.4.11: |
| 4748 | resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} | 5826 | resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} |
| 4749 | 5827 | ||
| 5828 | emoji-regex@10.6.0: | ||
| 5829 | resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} | ||
| 5830 | |||
| 4750 | emoji-regex@8.0.0: | 5831 | emoji-regex@8.0.0: |
| 4751 | resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} | 5832 | resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} |
| 4752 | 5833 | ||
| ... | @@ -4777,6 +5858,10 @@ packages: | ... | @@ -4777,6 +5858,10 @@ packages: |
| 4777 | resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} | 5858 | resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} |
| 4778 | engines: {node: '>=6'} | 5859 | engines: {node: '>=6'} |
| 4779 | 5860 | ||
| 5861 | environment@1.1.0: | ||
| 5862 | resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} | ||
| 5863 | engines: {node: '>=18'} | ||
| 5864 | |||
| 4780 | err-code@2.0.3: | 5865 | err-code@2.0.3: |
| 4781 | resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} | 5866 | resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} |
| 4782 | 5867 | ||
| ... | @@ -4805,9 +5890,17 @@ packages: | ... | @@ -4805,9 +5890,17 @@ packages: |
| 4805 | resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} | 5890 | resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} |
| 4806 | engines: {node: '>= 0.4'} | 5891 | engines: {node: '>= 0.4'} |
| 4807 | 5892 | ||
| 5893 | es-toolkit@1.47.0: | ||
| 5894 | resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} | ||
| 5895 | |||
| 4808 | es6-error@4.1.1: | 5896 | es6-error@4.1.1: |
| 4809 | resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} | 5897 | resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} |
| 4810 | 5898 | ||
| 5899 | esbuild@0.25.12: | ||
| 5900 | resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} | ||
| 5901 | engines: {node: '>=18'} | ||
| 5902 | hasBin: true | ||
| 5903 | |||
| 4811 | esbuild@0.27.7: | 5904 | esbuild@0.27.7: |
| 4812 | resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} | 5905 | resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} |
| 4813 | engines: {node: '>=18'} | 5906 | engines: {node: '>=18'} |
| ... | @@ -4824,6 +5917,10 @@ packages: | ... | @@ -4824,6 +5917,10 @@ packages: |
| 4824 | resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} | 5917 | resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} |
| 4825 | engines: {node: '>=0.8.0'} | 5918 | engines: {node: '>=0.8.0'} |
| 4826 | 5919 | ||
| 5920 | escape-string-regexp@2.0.0: | ||
| 5921 | resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} | ||
| 5922 | engines: {node: '>=8'} | ||
| 5923 | |||
| 4827 | escape-string-regexp@4.0.0: | 5924 | escape-string-regexp@4.0.0: |
| 4828 | resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} | 5925 | resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} |
| 4829 | engines: {node: '>=10'} | 5926 | engines: {node: '>=10'} |
| ... | @@ -4857,6 +5954,11 @@ packages: | ... | @@ -4857,6 +5954,11 @@ packages: |
| 4857 | resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} | 5954 | resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} |
| 4858 | engines: {node: '>=18.0.0'} | 5955 | engines: {node: '>=18.0.0'} |
| 4859 | 5956 | ||
| 5957 | expo-application@56.0.3: | ||
| 5958 | resolution: {integrity: sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==} | ||
| 5959 | peerDependencies: | ||
| 5960 | expo: '*' | ||
| 5961 | |||
| 4860 | expo-asset@56.0.15: | 5962 | expo-asset@56.0.15: |
| 4861 | resolution: {integrity: sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ==} | 5963 | resolution: {integrity: sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ==} |
| 4862 | peerDependencies: | 5964 | peerDependencies: |
| ... | @@ -4864,6 +5966,12 @@ packages: | ... | @@ -4864,6 +5966,12 @@ packages: |
| 4864 | react: '*' | 5966 | react: '*' |
| 4865 | react-native: '*' | 5967 | react-native: '*' |
| 4866 | 5968 | ||
| 5969 | expo-auth-session@56.0.13: | ||
| 5970 | resolution: {integrity: sha512-LR8Suq8BHKRFBUcAKTMmZufCcDcr0sQa8rIYit1r7kshrqAy9glIUU4aqHt8tflW/ISN0x1vU+HU8AQaackM0A==} | ||
| 5971 | peerDependencies: | ||
| 5972 | react: '*' | ||
| 5973 | react-native: '*' | ||
| 5974 | |||
| 4867 | expo-build-properties@56.0.16: | 5975 | expo-build-properties@56.0.16: |
| 4868 | resolution: {integrity: sha512-C3avazYP2fR8efJBBmhx8yITjIRDaIe3ULPk0YfACP61QfnWC9u3LxaDNNaiIvYfZ+CLne30W+nS5F6pdgO/8g==} | 5976 | resolution: {integrity: sha512-C3avazYP2fR8efJBBmhx8yITjIRDaIe3ULPk0YfACP61QfnWC9u3LxaDNNaiIvYfZ+CLne30W+nS5F6pdgO/8g==} |
| 4869 | peerDependencies: | 5977 | peerDependencies: |
| ... | @@ -4997,6 +6105,13 @@ packages: | ... | @@ -4997,6 +6105,13 @@ packages: |
| 4997 | peerDependencies: | 6105 | peerDependencies: |
| 4998 | react-native: '*' | 6106 | react-native: '*' |
| 4999 | 6107 | ||
| 6108 | expo-notifications@56.0.15: | ||
| 6109 | resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==} | ||
| 6110 | peerDependencies: | ||
| 6111 | expo: '*' | ||
| 6112 | react: '*' | ||
| 6113 | react-native: '*' | ||
| 6114 | |||
| 5000 | expo-paste-input@0.1.15: | 6115 | expo-paste-input@0.1.15: |
| 5001 | resolution: {integrity: sha512-wV0z1s0/Q4MB+lNiSwbMfck+1Im6sDIcgyCi5Y0aF35n5kcM4FHSOjmRfzn3UbkMlvxmYfDiHeHvIqn22qatqA==} | 6116 | resolution: {integrity: sha512-wV0z1s0/Q4MB+lNiSwbMfck+1Im6sDIcgyCi5Y0aF35n5kcM4FHSOjmRfzn3UbkMlvxmYfDiHeHvIqn22qatqA==} |
| 5002 | peerDependencies: | 6117 | peerDependencies: |
| ... | @@ -5078,6 +6193,19 @@ packages: | ... | @@ -5078,6 +6193,19 @@ packages: |
| 5078 | expo-dev-client: | 6193 | expo-dev-client: |
| 5079 | optional: true | 6194 | optional: true |
| 5080 | 6195 | ||
| 6196 | expo-web-browser@56.0.5: | ||
| 6197 | resolution: {integrity: sha512-kaN+wcR5lHwPCH1IgrU1XyPUQvBRzdF1TMp65uAF9iUCyipqYnmrvV87eqAmrdkFFopWVgU7FcxPu1UZw+gvUQ==} | ||
| 6198 | peerDependencies: | ||
| 6199 | expo: '*' | ||
| 6200 | react-native: '*' | ||
| 6201 | |||
| 6202 | expo-widgets@56.0.16: | ||
| 6203 | resolution: {integrity: sha512-EmWVvskad2XlgCk6j9wz/OqSAeaGxjD6x6Wji99TVjjFd+sTD6W3YJbz3QW/BKTVRcll95m6XjJDCa6B9t9f9A==} | ||
| 6204 | peerDependencies: | ||
| 6205 | expo: '*' | ||
| 6206 | react: '*' | ||
| 6207 | react-native: '*' | ||
| 6208 | |||
| 5081 | expo@56.0.8: | 6209 | expo@56.0.8: |
| 5082 | resolution: {integrity: sha512-GzQi5450yrCk5JRSlm0epsmtURBErh0wS77uWLZImFdnPICuX912MaRWooR+Q1Sw/7aQjp9F+KXH+dvrqGxpeQ==} | 6210 | resolution: {integrity: sha512-GzQi5450yrCk5JRSlm0epsmtURBErh0wS77uWLZImFdnPICuX912MaRWooR+Q1Sw/7aQjp9F+KXH+dvrqGxpeQ==} |
| 5083 | hasBin: true | 6211 | hasBin: true |
| ... | @@ -5133,9 +6261,16 @@ packages: | ... | @@ -5133,9 +6261,16 @@ packages: |
| 5133 | fast-deep-equal@3.1.3: | 6261 | fast-deep-equal@3.1.3: |
| 5134 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} | 6262 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} |
| 5135 | 6263 | ||
| 6264 | fast-glob@3.3.3: | ||
| 6265 | resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} | ||
| 6266 | engines: {node: '>=8.6.0'} | ||
| 6267 | |||
| 5136 | fast-json-stable-stringify@2.1.0: | 6268 | fast-json-stable-stringify@2.1.0: |
| 5137 | resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} | 6269 | resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} |
| 5138 | 6270 | ||
| 6271 | fast-sha256@1.3.0: | ||
| 6272 | resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} | ||
| 6273 | |||
| 5139 | fast-string-truncated-width@3.0.3: | 6274 | fast-string-truncated-width@3.0.3: |
| 5140 | resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} | 6275 | resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} |
| 5141 | 6276 | ||
| ... | @@ -5148,6 +6283,24 @@ packages: | ... | @@ -5148,6 +6283,24 @@ packages: |
| 5148 | fast-wrap-ansi@0.2.2: | 6283 | fast-wrap-ansi@0.2.2: |
| 5149 | resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} | 6284 | resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} |
| 5150 | 6285 | ||
| 6286 | fast-xml-builder@1.2.0: | ||
| 6287 | resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} | ||
| 6288 | |||
| 6289 | fast-xml-parser@5.7.3: | ||
| 6290 | resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} | ||
| 6291 | hasBin: true | ||
| 6292 | |||
| 6293 | fast-xml-parser@5.8.0: | ||
| 6294 | resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} | ||
| 6295 | hasBin: true | ||
| 6296 | |||
| 6297 | fastest-levenshtein@1.0.16: | ||
| 6298 | resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} | ||
| 6299 | engines: {node: '>= 4.9.1'} | ||
| 6300 | |||
| 6301 | fastq@1.20.1: | ||
| 6302 | resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} | ||
| 6303 | |||
| 5151 | fb-dotslash@0.5.8: | 6304 | fb-dotslash@0.5.8: |
| 5152 | resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} | 6305 | resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} |
| 5153 | engines: {node: '>=20'} | 6306 | engines: {node: '>=20'} |
| ... | @@ -5262,6 +6415,9 @@ packages: | ... | @@ -5262,6 +6415,9 @@ packages: |
| 5262 | function-bind@1.1.2: | 6415 | function-bind@1.1.2: |
| 5263 | resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} | 6416 | resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} |
| 5264 | 6417 | ||
| 6418 | generate-function@2.3.1: | ||
| 6419 | resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} | ||
| 6420 | |||
| 5265 | gensync@1.0.0-beta.2: | 6421 | gensync@1.0.0-beta.2: |
| 5266 | resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} | 6422 | resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} |
| 5267 | engines: {node: '>=6.9.0'} | 6423 | engines: {node: '>=6.9.0'} |
| ... | @@ -5270,6 +6426,10 @@ packages: | ... | @@ -5270,6 +6426,10 @@ packages: |
| 5270 | resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} | 6426 | resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} |
| 5271 | engines: {node: 6.* || 8.* || >= 10.*} | 6427 | engines: {node: 6.* || 8.* || >= 10.*} |
| 5272 | 6428 | ||
| 6429 | get-east-asian-width@1.6.0: | ||
| 6430 | resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} | ||
| 6431 | engines: {node: '>=18'} | ||
| 6432 | |||
| 5273 | get-intrinsic@1.3.0: | 6433 | get-intrinsic@1.3.0: |
| 5274 | resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} | 6434 | resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} |
| 5275 | engines: {node: '>= 0.4'} | 6435 | engines: {node: '>= 0.4'} |
| ... | @@ -5286,6 +6446,9 @@ packages: | ... | @@ -5286,6 +6446,9 @@ packages: |
| 5286 | resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} | 6446 | resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} |
| 5287 | engines: {node: '>=8'} | 6447 | engines: {node: '>=8'} |
| 5288 | 6448 | ||
| 6449 | get-tsconfig@4.14.0: | ||
| 6450 | resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} | ||
| 6451 | |||
| 5289 | get-tsconfig@5.0.0-beta.4: | 6452 | get-tsconfig@5.0.0-beta.4: |
| 5290 | resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} | 6453 | resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} |
| 5291 | engines: {node: '>=20.20.0'} | 6454 | engines: {node: '>=20.20.0'} |
| ... | @@ -5297,6 +6460,13 @@ packages: | ... | @@ -5297,6 +6460,13 @@ packages: |
| 5297 | github-slugger@2.0.0: | 6460 | github-slugger@2.0.0: |
| 5298 | resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} | 6461 | resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} |
| 5299 | 6462 | ||
| 6463 | glob-parent@5.1.2: | ||
| 6464 | resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} | ||
| 6465 | engines: {node: '>= 6'} | ||
| 6466 | |||
| 6467 | glob-to-regexp@0.4.1: | ||
| 6468 | resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} | ||
| 6469 | |||
| 5300 | glob@13.0.6: | 6470 | glob@13.0.6: |
| 5301 | resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} | 6471 | resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} |
| 5302 | engines: {node: 18 || 20 || >=22} | 6472 | engines: {node: 18 || 20 || >=22} |
| ... | @@ -5461,6 +6631,9 @@ packages: | ... | @@ -5461,6 +6631,9 @@ packages: |
| 5461 | resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} | 6631 | resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} |
| 5462 | engines: {node: '>=0.10.0'} | 6632 | engines: {node: '>=0.10.0'} |
| 5463 | 6633 | ||
| 6634 | idb-keyval@6.2.1: | ||
| 6635 | resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} | ||
| 6636 | |||
| 5464 | ieee754@1.2.1: | 6637 | ieee754@1.2.1: |
| 5465 | resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} | 6638 | resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} |
| 5466 | 6639 | ||
| ... | @@ -5473,10 +6646,17 @@ packages: | ... | @@ -5473,10 +6646,17 @@ packages: |
| 5473 | engines: {node: '>=16.x'} | 6646 | engines: {node: '>=16.x'} |
| 5474 | hasBin: true | 6647 | hasBin: true |
| 5475 | 6648 | ||
| 6649 | immediate@3.0.6: | ||
| 6650 | resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} | ||
| 6651 | |||
| 5476 | indent-string@4.0.0: | 6652 | indent-string@4.0.0: |
| 5477 | resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} | 6653 | resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} |
| 5478 | engines: {node: '>=8'} | 6654 | engines: {node: '>=8'} |
| 5479 | 6655 | ||
| 6656 | indent-string@5.0.0: | ||
| 6657 | resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} | ||
| 6658 | engines: {node: '>=12'} | ||
| 6659 | |||
| 5480 | inflight@1.0.6: | 6660 | inflight@1.0.6: |
| 5481 | resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} | 6661 | resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} |
| 5482 | 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. | 6662 | 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 +6668,19 @@ packages: | ... | @@ -5488,6 +6668,19 @@ packages: |
| 5488 | resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} | 6668 | resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} |
| 5489 | engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} | 6669 | engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} |
| 5490 | 6670 | ||
| 6671 | ink@6.8.0: | ||
| 6672 | resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} | ||
| 6673 | engines: {node: '>=20'} | ||
| 6674 | peerDependencies: | ||
| 6675 | '@types/react': '>=19.0.0' | ||
| 6676 | react: '>=19.0.0' | ||
| 6677 | react-devtools-core: '>=6.1.2' | ||
| 6678 | peerDependenciesMeta: | ||
| 6679 | '@types/react': | ||
| 6680 | optional: true | ||
| 6681 | react-devtools-core: | ||
| 6682 | optional: true | ||
| 6683 | |||
| 5491 | inline-style-parser@0.2.7: | 6684 | inline-style-parser@0.2.7: |
| 5492 | resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} | 6685 | resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} |
| 5493 | 6686 | ||
| ... | @@ -5540,13 +6733,30 @@ packages: | ... | @@ -5540,13 +6733,30 @@ packages: |
| 5540 | engines: {node: '>=20'} | 6733 | engines: {node: '>=20'} |
| 5541 | hasBin: true | 6734 | hasBin: true |
| 5542 | 6735 | ||
| 6736 | is-extglob@2.1.1: | ||
| 6737 | resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} | ||
| 6738 | engines: {node: '>=0.10.0'} | ||
| 6739 | |||
| 5543 | is-fullwidth-code-point@3.0.0: | 6740 | is-fullwidth-code-point@3.0.0: |
| 5544 | resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} | 6741 | resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} |
| 5545 | engines: {node: '>=8'} | 6742 | engines: {node: '>=8'} |
| 5546 | 6743 | ||
| 6744 | is-fullwidth-code-point@5.1.0: | ||
| 6745 | resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} | ||
| 6746 | engines: {node: '>=18'} | ||
| 6747 | |||
| 6748 | is-glob@4.0.3: | ||
| 6749 | resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} | ||
| 6750 | engines: {node: '>=0.10.0'} | ||
| 6751 | |||
| 5547 | is-hexadecimal@2.0.1: | 6752 | is-hexadecimal@2.0.1: |
| 5548 | resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} | 6753 | resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} |
| 5549 | 6754 | ||
| 6755 | is-in-ci@2.0.0: | ||
| 6756 | resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} | ||
| 6757 | engines: {node: '>=20'} | ||
| 6758 | hasBin: true | ||
| 6759 | |||
| 5550 | is-inside-container@1.0.0: | 6760 | is-inside-container@1.0.0: |
| 5551 | resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} | 6761 | resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} |
| 5552 | engines: {node: '>=14.16'} | 6762 | engines: {node: '>=14.16'} |
| ... | @@ -5566,6 +6776,9 @@ packages: | ... | @@ -5566,6 +6776,9 @@ packages: |
| 5566 | is-promise@4.0.0: | 6776 | is-promise@4.0.0: |
| 5567 | resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} | 6777 | resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} |
| 5568 | 6778 | ||
| 6779 | is-property@1.0.2: | ||
| 6780 | resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} | ||
| 6781 | |||
| 5569 | is-wsl@2.2.0: | 6782 | is-wsl@2.2.0: |
| 5570 | resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} | 6783 | resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} |
| 5571 | engines: {node: '>=8'} | 6784 | engines: {node: '>=8'} |
| ... | @@ -5574,6 +6787,9 @@ packages: | ... | @@ -5574,6 +6787,9 @@ packages: |
| 5574 | resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} | 6787 | resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} |
| 5575 | engines: {node: '>=16'} | 6788 | engines: {node: '>=16'} |
| 5576 | 6789 | ||
| 6790 | isarray@1.0.0: | ||
| 6791 | resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} | ||
| 6792 | |||
| 5577 | isbinaryfile@4.0.10: | 6793 | isbinaryfile@4.0.10: |
| 5578 | resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} | 6794 | resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} |
| 5579 | engines: {node: '>= 8.0.0'} | 6795 | engines: {node: '>= 8.0.0'} |
| ... | @@ -5628,9 +6844,19 @@ packages: | ... | @@ -5628,9 +6844,19 @@ packages: |
| 5628 | resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} | 6844 | resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} |
| 5629 | hasBin: true | 6845 | hasBin: true |
| 5630 | 6846 | ||
| 6847 | jose@6.2.2: | ||
| 6848 | resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} | ||
| 6849 | |||
| 5631 | jose@6.2.3: | 6850 | jose@6.2.3: |
| 5632 | resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} | 6851 | resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} |
| 5633 | 6852 | ||
| 6853 | js-base64@3.7.8: | ||
| 6854 | resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} | ||
| 6855 | |||
| 6856 | js-cookie@3.0.7: | ||
| 6857 | resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} | ||
| 6858 | engines: {node: '>=20'} | ||
| 6859 | |||
| 5634 | js-tokens@4.0.0: | 6860 | js-tokens@4.0.0: |
| 5635 | resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} | 6861 | resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} |
| 5636 | 6862 | ||
| ... | @@ -5638,6 +6864,9 @@ packages: | ... | @@ -5638,6 +6864,9 @@ packages: |
| 5638 | resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} | 6864 | resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} |
| 5639 | hasBin: true | 6865 | hasBin: true |
| 5640 | 6866 | ||
| 6867 | jsbi@4.3.2: | ||
| 6868 | resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} | ||
| 6869 | |||
| 5641 | jsc-safe-url@0.2.4: | 6870 | jsc-safe-url@0.2.4: |
| 5642 | resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} | 6871 | resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} |
| 5643 | 6872 | ||
| ... | @@ -5665,6 +6894,9 @@ packages: | ... | @@ -5665,6 +6894,9 @@ packages: |
| 5665 | json-stringify-safe@5.0.1: | 6894 | json-stringify-safe@5.0.1: |
| 5666 | resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} | 6895 | resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} |
| 5667 | 6896 | ||
| 6897 | json-with-bigint@3.5.8: | ||
| 6898 | resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} | ||
| 6899 | |||
| 5668 | json5@2.2.3: | 6900 | json5@2.2.3: |
| 5669 | resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} | 6901 | resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} |
| 5670 | engines: {node: '>=6'} | 6902 | engines: {node: '>=6'} |
| ... | @@ -5682,6 +6914,9 @@ packages: | ... | @@ -5682,6 +6914,9 @@ packages: |
| 5682 | jsonfile@6.2.1: | 6914 | jsonfile@6.2.1: |
| 5683 | resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} | 6915 | resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} |
| 5684 | 6916 | ||
| 6917 | jszip@3.10.1: | ||
| 6918 | resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} | ||
| 6919 | |||
| 5685 | keyv@4.5.4: | 6920 | keyv@4.5.4: |
| 5686 | resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} | 6921 | resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} |
| 5687 | 6922 | ||
| ... | @@ -5715,6 +6950,20 @@ packages: | ... | @@ -5715,6 +6950,20 @@ packages: |
| 5715 | engines: {node: '>=16'} | 6950 | engines: {node: '>=16'} |
| 5716 | hasBin: true | 6951 | hasBin: true |
| 5717 | 6952 | ||
| 6953 | libsodium-wrappers@0.8.4: | ||
| 6954 | resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==} | ||
| 6955 | |||
| 6956 | libsodium@0.8.4: | ||
| 6957 | resolution: {integrity: sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==} | ||
| 6958 | |||
| 6959 | libsql@0.5.29: | ||
| 6960 | resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} | ||
| 6961 | cpu: [x64, arm64, wasm32, arm] | ||
| 6962 | os: [darwin, linux, win32] | ||
| 6963 | |||
| 6964 | lie@3.3.0: | ||
| 6965 | resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} | ||
| 6966 | |||
| 5718 | lighthouse-logger@1.4.2: | 6967 | lighthouse-logger@1.4.2: |
| 5719 | resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} | 6968 | resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} |
| 5720 | 6969 | ||
| ... | @@ -5942,6 +7191,9 @@ packages: | ... | @@ -5942,6 +7191,9 @@ packages: |
| 5942 | resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} | 7191 | resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} |
| 5943 | engines: {node: '>=4'} | 7192 | engines: {node: '>=4'} |
| 5944 | 7193 | ||
| 7194 | long@5.3.2: | ||
| 7195 | resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} | ||
| 7196 | |||
| 5945 | longest-streak@3.1.0: | 7197 | longest-streak@3.1.0: |
| 5946 | resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} | 7198 | resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} |
| 5947 | 7199 | ||
| ... | @@ -5967,6 +7219,10 @@ packages: | ... | @@ -5967,6 +7219,10 @@ packages: |
| 5967 | resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} | 7219 | resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} |
| 5968 | engines: {node: '>=10'} | 7220 | engines: {node: '>=10'} |
| 5969 | 7221 | ||
| 7222 | lru.min@1.1.4: | ||
| 7223 | resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} | ||
| 7224 | engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} | ||
| 7225 | |||
| 5970 | lru_map@0.4.1: | 7226 | lru_map@0.4.1: |
| 5971 | resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} | 7227 | resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} |
| 5972 | 7228 | ||
| ... | @@ -6073,6 +7329,10 @@ packages: | ... | @@ -6073,6 +7329,10 @@ packages: |
| 6073 | merge-stream@2.0.0: | 7329 | merge-stream@2.0.0: |
| 6074 | resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} | 7330 | resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} |
| 6075 | 7331 | ||
| 7332 | merge2@1.4.1: | ||
| 7333 | resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} | ||
| 7334 | engines: {node: '>= 8'} | ||
| 7335 | |||
| 6076 | metro-babel-transformer@0.84.4: | 7336 | metro-babel-transformer@0.84.4: |
| 6077 | resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} | 7337 | resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==} |
| 6078 | engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} | 7338 | engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} |
| ... | @@ -6254,6 +7514,10 @@ packages: | ... | @@ -6254,6 +7514,10 @@ packages: |
| 6254 | resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} | 7514 | resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} |
| 6255 | engines: {node: '>=4'} | 7515 | engines: {node: '>=4'} |
| 6256 | 7516 | ||
| 7517 | mimic-fn@2.1.0: | ||
| 7518 | resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} | ||
| 7519 | engines: {node: '>=6'} | ||
| 7520 | |||
| 6257 | mimic-response@1.0.1: | 7521 | mimic-response@1.0.1: |
| 6258 | resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} | 7522 | resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} |
| 6259 | engines: {node: '>=4'} | 7523 | engines: {node: '>=4'} |
| ... | @@ -6341,6 +7605,16 @@ packages: | ... | @@ -6341,6 +7605,16 @@ packages: |
| 6341 | resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} | 7605 | resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} |
| 6342 | engines: {node: ^18.17.0 || >=20.5.0} | 7606 | engines: {node: ^18.17.0 || >=20.5.0} |
| 6343 | 7607 | ||
| 7608 | mysql2@3.22.4: | ||
| 7609 | resolution: {integrity: sha512-CtXYlmL7ZamiYKbmqkamQHWJROUHSfm+f3kByzGfknw7kW51mcB2ouMUqYq1XfYxbXmnWo6RhPydx6OCqdgcmQ==} | ||
| 7610 | engines: {node: '>= 8.0'} | ||
| 7611 | peerDependencies: | ||
| 7612 | '@types/node': 24.12.4 | ||
| 7613 | |||
| 7614 | named-placeholders@1.1.6: | ||
| 7615 | resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} | ||
| 7616 | engines: {node: '>=8.0.0'} | ||
| 7617 | |||
| 6344 | nanoid@3.3.12: | 7618 | nanoid@3.3.12: |
| 6345 | resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} | 7619 | resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} |
| 6346 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} | 7620 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} |
| ... | @@ -6389,6 +7663,10 @@ packages: | ... | @@ -6389,6 +7663,10 @@ packages: |
| 6389 | resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} | 7663 | resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} |
| 6390 | hasBin: true | 7664 | hasBin: true |
| 6391 | 7665 | ||
| 7666 | node-gyp-build@4.8.4: | ||
| 7667 | resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} | ||
| 7668 | hasBin: true | ||
| 7669 | |||
| 6392 | node-gyp@12.3.0: | 7670 | node-gyp@12.3.0: |
| 6393 | resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} | 7671 | resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} |
| 6394 | engines: {node: ^20.17.0 || >=22.9.0} | 7672 | engines: {node: ^20.17.0 || >=22.9.0} |
| ... | @@ -6446,6 +7724,9 @@ packages: | ... | @@ -6446,6 +7724,9 @@ packages: |
| 6446 | resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} | 7724 | resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} |
| 6447 | engines: {node: '>= 0.4'} | 7725 | engines: {node: '>= 0.4'} |
| 6448 | 7726 | ||
| 7727 | obuf@1.1.2: | ||
| 7728 | resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} | ||
| 7729 | |||
| 6449 | obug@2.1.1: | 7730 | obug@2.1.1: |
| 6450 | resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} | 7731 | resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} |
| 6451 | 7732 | ||
| ... | @@ -6474,6 +7755,10 @@ packages: | ... | @@ -6474,6 +7755,10 @@ packages: |
| 6474 | resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} | 7755 | resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} |
| 6475 | engines: {node: '>=4'} | 7756 | engines: {node: '>=4'} |
| 6476 | 7757 | ||
| 7758 | onetime@5.1.2: | ||
| 7759 | resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} | ||
| 7760 | engines: {node: '>=6'} | ||
| 7761 | |||
| 6477 | oniguruma-parser@0.12.2: | 7762 | oniguruma-parser@0.12.2: |
| 6478 | resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} | 7763 | resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} |
| 6479 | 7764 | ||
| ... | @@ -6488,6 +7773,10 @@ packages: | ... | @@ -6488,6 +7773,10 @@ packages: |
| 6488 | resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} | 7773 | resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} |
| 6489 | engines: {node: '>=6'} | 7774 | engines: {node: '>=6'} |
| 6490 | 7775 | ||
| 7776 | os-paths@7.4.0: | ||
| 7777 | resolution: {integrity: sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA==} | ||
| 7778 | engines: {node: '>= 4.0'} | ||
| 7779 | |||
| 6491 | outvariant@1.4.3: | 7780 | outvariant@1.4.3: |
| 6492 | resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} | 7781 | resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} |
| 6493 | 7782 | ||
| ... | @@ -6544,6 +7833,9 @@ packages: | ... | @@ -6544,6 +7833,9 @@ packages: |
| 6544 | package-manager-detector@1.6.0: | 7833 | package-manager-detector@1.6.0: |
| 6545 | resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} | 7834 | resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} |
| 6546 | 7835 | ||
| 7836 | pako@1.0.11: | ||
| 7837 | resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} | ||
| 7838 | |||
| 6547 | parse-entities@4.0.2: | 7839 | parse-entities@4.0.2: |
| 6548 | resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} | 7840 | resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} |
| 6549 | 7841 | ||
| ... | @@ -6561,9 +7853,17 @@ packages: | ... | @@ -6561,9 +7853,17 @@ packages: |
| 6561 | resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} | 7853 | resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} |
| 6562 | engines: {node: '>= 0.8'} | 7854 | engines: {node: '>= 0.8'} |
| 6563 | 7855 | ||
| 7856 | patch-console@2.0.0: | ||
| 7857 | resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} | ||
| 7858 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} | ||
| 7859 | |||
| 6564 | path-browserify@1.0.1: | 7860 | path-browserify@1.0.1: |
| 6565 | resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} | 7861 | resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} |
| 6566 | 7862 | ||
| 7863 | path-expression-matcher@1.5.0: | ||
| 7864 | resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} | ||
| 7865 | engines: {node: '>=14.0.0'} | ||
| 7866 | |||
| 6567 | path-is-absolute@1.0.1: | 7867 | path-is-absolute@1.0.1: |
| 6568 | resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} | 7868 | resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} |
| 6569 | engines: {node: '>=0.10.0'} | 7869 | engines: {node: '>=0.10.0'} |
| ... | @@ -6595,8 +7895,58 @@ packages: | ... | @@ -6595,8 +7895,58 @@ packages: |
| 6595 | resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} | 7895 | resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} |
| 6596 | engines: {node: '>=12', npm: '>=6'} | 7896 | engines: {node: '>=12', npm: '>=6'} |
| 6597 | 7897 | ||
| 6598 | pend@1.2.0: | 7898 | pend@1.2.0: |
| 6599 | resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} | 7899 | resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} |
| 7900 | |||
| 7901 | pg-cloudflare@1.4.0: | ||
| 7902 | resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} | ||
| 7903 | |||
| 7904 | pg-connection-string@2.12.0: | ||
| 7905 | resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} | ||
| 7906 | |||
| 7907 | pg-connection-string@2.13.0: | ||
| 7908 | resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} | ||
| 7909 | |||
| 7910 | pg-cursor@2.20.0: | ||
| 7911 | resolution: {integrity: sha512-HP/EbUafheaUOs7DxlG6tda/rhmsX2hCTJJJ+gCnhljGyNEs6pBHddbNuomlW3DqEhP3zYD+GqBWkYnJPIZ4tA==} | ||
| 7912 | peerDependencies: | ||
| 7913 | pg: ^8 | ||
| 7914 | |||
| 7915 | pg-int8@1.0.1: | ||
| 7916 | resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} | ||
| 7917 | engines: {node: '>=4.0.0'} | ||
| 7918 | |||
| 7919 | pg-numeric@1.0.2: | ||
| 7920 | resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} | ||
| 7921 | engines: {node: '>=4'} | ||
| 7922 | |||
| 7923 | pg-pool@3.14.0: | ||
| 7924 | resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} | ||
| 7925 | peerDependencies: | ||
| 7926 | pg: '>=8.0' | ||
| 7927 | |||
| 7928 | pg-protocol@1.14.0: | ||
| 7929 | resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} | ||
| 7930 | |||
| 7931 | pg-types@2.2.0: | ||
| 7932 | resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} | ||
| 7933 | engines: {node: '>=4'} | ||
| 7934 | |||
| 7935 | pg-types@4.1.0: | ||
| 7936 | resolution: {integrity: sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==} | ||
| 7937 | engines: {node: '>=10'} | ||
| 7938 | |||
| 7939 | pg@8.21.0: | ||
| 7940 | resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} | ||
| 7941 | engines: {node: '>= 16.0.0'} | ||
| 7942 | peerDependencies: | ||
| 7943 | pg-native: '>=3.0.1' | ||
| 7944 | peerDependenciesMeta: | ||
| 7945 | pg-native: | ||
| 7946 | optional: true | ||
| 7947 | |||
| 7948 | pgpass@1.0.5: | ||
| 7949 | resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} | ||
| 6600 | 7950 | ||
| 6601 | piccolore@0.1.3: | 7951 | piccolore@0.1.3: |
| 6602 | resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} | 7952 | resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} |
| ... | @@ -6650,6 +8000,41 @@ packages: | ... | @@ -6650,6 +8000,41 @@ packages: |
| 6650 | resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} | 8000 | resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} |
| 6651 | engines: {node: ^10 || ^12 || >=14} | 8001 | engines: {node: ^10 || ^12 || >=14} |
| 6652 | 8002 | ||
| 8003 | postgres-array@2.0.0: | ||
| 8004 | resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} | ||
| 8005 | engines: {node: '>=4'} | ||
| 8006 | |||
| 8007 | postgres-array@3.0.4: | ||
| 8008 | resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} | ||
| 8009 | engines: {node: '>=12'} | ||
| 8010 | |||
| 8011 | postgres-bytea@1.0.1: | ||
| 8012 | resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} | ||
| 8013 | engines: {node: '>=0.10.0'} | ||
| 8014 | |||
| 8015 | postgres-bytea@3.0.0: | ||
| 8016 | resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} | ||
| 8017 | engines: {node: '>= 6'} | ||
| 8018 | |||
| 8019 | postgres-date@1.0.7: | ||
| 8020 | resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} | ||
| 8021 | engines: {node: '>=0.10.0'} | ||
| 8022 | |||
| 8023 | postgres-date@2.1.0: | ||
| 8024 | resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} | ||
| 8025 | engines: {node: '>=12'} | ||
| 8026 | |||
| 8027 | postgres-interval@1.2.0: | ||
| 8028 | resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} | ||
| 8029 | engines: {node: '>=0.10.0'} | ||
| 8030 | |||
| 8031 | postgres-interval@3.0.0: | ||
| 8032 | resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} | ||
| 8033 | engines: {node: '>=12'} | ||
| 8034 | |||
| 8035 | postgres-range@1.1.4: | ||
| 8036 | resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} | ||
| 8037 | |||
| 6653 | postject@1.0.0-alpha.6: | 8038 | postject@1.0.0-alpha.6: |
| 6654 | resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} | 8039 | resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} |
| 6655 | engines: {node: '>=14.0.0'} | 8040 | engines: {node: '>=14.0.0'} |
| ... | @@ -6684,10 +8069,16 @@ packages: | ... | @@ -6684,10 +8069,16 @@ packages: |
| 6684 | resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} | 8069 | resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} |
| 6685 | engines: {node: ^20.17.0 || >=22.9.0} | 8070 | engines: {node: ^20.17.0 || >=22.9.0} |
| 6686 | 8071 | ||
| 8072 | process-nextick-args@2.0.1: | ||
| 8073 | resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} | ||
| 8074 | |||
| 6687 | progress@2.0.3: | 8075 | progress@2.0.3: |
| 6688 | resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} | 8076 | resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} |
| 6689 | engines: {node: '>=0.4.0'} | 8077 | engines: {node: '>=0.4.0'} |
| 6690 | 8078 | ||
| 8079 | promise-limit@2.7.0: | ||
| 8080 | resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} | ||
| 8081 | |||
| 6691 | promise-retry@2.0.1: | 8082 | promise-retry@2.0.1: |
| 6692 | resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} | 8083 | resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} |
| 6693 | engines: {node: '>=10'} | 8084 | engines: {node: '>=10'} |
| ... | @@ -6727,6 +8118,9 @@ packages: | ... | @@ -6727,6 +8118,9 @@ packages: |
| 6727 | resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} | 8118 | resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} |
| 6728 | engines: {node: '>=6'} | 8119 | engines: {node: '>=6'} |
| 6729 | 8120 | ||
| 8121 | queue-microtask@1.2.3: | ||
| 8122 | resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} | ||
| 8123 | |||
| 6730 | queue@6.0.2: | 8124 | queue@6.0.2: |
| 6731 | resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} | 8125 | resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} |
| 6732 | 8126 | ||
| ... | @@ -6874,6 +8268,11 @@ packages: | ... | @@ -6874,6 +8268,11 @@ packages: |
| 6874 | react: '*' | 8268 | react: '*' |
| 6875 | react-native: '*' | 8269 | react-native: '*' |
| 6876 | 8270 | ||
| 8271 | react-native-url-polyfill@2.0.0: | ||
| 8272 | resolution: {integrity: sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==} | ||
| 8273 | peerDependencies: | ||
| 8274 | react-native: '*' | ||
| 8275 | |||
| 6877 | react-native-worklets@0.8.3: | 8276 | react-native-worklets@0.8.3: |
| 6878 | resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==} | 8277 | resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==} |
| 6879 | peerDependencies: | 8278 | peerDependencies: |
| ... | @@ -6896,6 +8295,12 @@ packages: | ... | @@ -6896,6 +8295,12 @@ packages: |
| 6896 | '@types/react': | 8295 | '@types/react': |
| 6897 | optional: true | 8296 | optional: true |
| 6898 | 8297 | ||
| 8298 | react-reconciler@0.33.0: | ||
| 8299 | resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} | ||
| 8300 | engines: {node: '>=0.10.0'} | ||
| 8301 | peerDependencies: | ||
| 8302 | react: ^19.2.0 | ||
| 8303 | |||
| 6899 | react-refresh@0.14.2: | 8304 | react-refresh@0.14.2: |
| 6900 | resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} | 8305 | resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} |
| 6901 | engines: {node: '>=0.10.0'} | 8306 | engines: {node: '>=0.10.0'} |
| ... | @@ -6942,6 +8347,9 @@ packages: | ... | @@ -6942,6 +8347,9 @@ packages: |
| 6942 | resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} | 8347 | resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} |
| 6943 | hasBin: true | 8348 | hasBin: true |
| 6944 | 8349 | ||
| 8350 | readable-stream@2.3.8: | ||
| 8351 | resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} | ||
| 8352 | |||
| 6945 | readdirp@4.1.2: | 8353 | readdirp@4.1.2: |
| 6946 | resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} | 8354 | resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} |
| 6947 | engines: {node: '>= 14.18.0'} | 8355 | engines: {node: '>= 14.18.0'} |
| ... | @@ -7066,6 +8474,10 @@ packages: | ... | @@ -7066,6 +8474,10 @@ packages: |
| 7066 | resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} | 8474 | resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} |
| 7067 | engines: {node: '>=4'} | 8475 | engines: {node: '>=4'} |
| 7068 | 8476 | ||
| 8477 | restore-cursor@4.0.0: | ||
| 8478 | resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} | ||
| 8479 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} | ||
| 8480 | |||
| 7069 | retext-latin@4.0.0: | 8481 | retext-latin@4.0.0: |
| 7070 | resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} | 8482 | resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} |
| 7071 | 8483 | ||
| ... | @@ -7085,6 +8497,10 @@ packages: | ... | @@ -7085,6 +8497,10 @@ packages: |
| 7085 | rettime@0.10.1: | 8497 | rettime@0.10.1: |
| 7086 | resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} | 8498 | resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} |
| 7087 | 8499 | ||
| 8500 | reusify@1.1.0: | ||
| 8501 | resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} | ||
| 8502 | engines: {iojs: '>=1.0.0', node: '>=0.10.0'} | ||
| 8503 | |||
| 7088 | rimraf@2.6.3: | 8504 | rimraf@2.6.3: |
| 7089 | resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} | 8505 | resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} |
| 7090 | deprecated: Rimraf versions prior to v4 are no longer supported | 8506 | deprecated: Rimraf versions prior to v4 are no longer supported |
| ... | @@ -7099,6 +8515,11 @@ packages: | ... | @@ -7099,6 +8515,11 @@ packages: |
| 7099 | engines: {node: ^20.19.0 || >=22.12.0} | 8515 | engines: {node: ^20.19.0 || >=22.12.0} |
| 7100 | hasBin: true | 8516 | hasBin: true |
| 7101 | 8517 | ||
| 8518 | rolldown@1.0.1: | ||
| 8519 | resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} | ||
| 8520 | engines: {node: ^20.19.0 || >=22.12.0} | ||
| 8521 | hasBin: true | ||
| 8522 | |||
| 7102 | rolldown@1.0.3: | 8523 | rolldown@1.0.3: |
| 7103 | resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} | 8524 | resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} |
| 7104 | engines: {node: ^20.19.0 || >=22.12.0} | 8525 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | @@ -7113,6 +8534,12 @@ packages: | ... | @@ -7113,6 +8534,12 @@ packages: |
| 7113 | resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} | 8534 | resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} |
| 7114 | engines: {node: '>= 18'} | 8535 | engines: {node: '>= 18'} |
| 7115 | 8536 | ||
| 8537 | run-parallel@1.2.0: | ||
| 8538 | resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} | ||
| 8539 | |||
| 8540 | safe-buffer@5.1.2: | ||
| 8541 | resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} | ||
| 8542 | |||
| 7116 | safe-buffer@5.2.1: | 8543 | safe-buffer@5.2.1: |
| 7117 | resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} | 8544 | resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} |
| 7118 | 8545 | ||
| ... | @@ -7187,6 +8614,9 @@ packages: | ... | @@ -7187,6 +8614,9 @@ packages: |
| 7187 | server-only@0.0.1: | 8614 | server-only@0.0.1: |
| 7188 | resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} | 8615 | resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} |
| 7189 | 8616 | ||
| 8617 | setimmediate@1.0.5: | ||
| 8618 | resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} | ||
| 8619 | |||
| 7190 | setprototypeof@1.2.0: | 8620 | setprototypeof@1.2.0: |
| 7191 | resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} | 8621 | resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} |
| 7192 | 8622 | ||
| ... | @@ -7264,6 +8694,10 @@ packages: | ... | @@ -7264,6 +8694,10 @@ packages: |
| 7264 | resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} | 8694 | resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} |
| 7265 | engines: {node: '>=8'} | 8695 | engines: {node: '>=8'} |
| 7266 | 8696 | ||
| 8697 | slice-ansi@8.0.0: | ||
| 8698 | resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} | ||
| 8699 | engines: {node: '>=20'} | ||
| 8700 | |||
| 7267 | slugify@1.6.9: | 8701 | slugify@1.6.9: |
| 7268 | resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} | 8702 | resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} |
| 7269 | engines: {node: '>=8.0.0'} | 8703 | engines: {node: '>=8.0.0'} |
| ... | @@ -7298,9 +8732,21 @@ packages: | ... | @@ -7298,9 +8732,21 @@ packages: |
| 7298 | resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} | 8732 | resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} |
| 7299 | engines: {node: '>=6'} | 8733 | engines: {node: '>=6'} |
| 7300 | 8734 | ||
| 8735 | split2@4.2.0: | ||
| 8736 | resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} | ||
| 8737 | engines: {node: '>= 10.x'} | ||
| 8738 | |||
| 7301 | sprintf-js@1.1.3: | 8739 | sprintf-js@1.1.3: |
| 7302 | resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} | 8740 | resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} |
| 7303 | 8741 | ||
| 8742 | sql-escaper@1.3.3: | ||
| 8743 | resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} | ||
| 8744 | engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} | ||
| 8745 | |||
| 8746 | stack-utils@2.0.6: | ||
| 8747 | resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} | ||
| 8748 | engines: {node: '>=10'} | ||
| 8749 | |||
| 7304 | stackframe@1.3.4: | 8750 | stackframe@1.3.4: |
| 7305 | resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} | 8751 | resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} |
| 7306 | 8752 | ||
| ... | @@ -7311,6 +8757,9 @@ packages: | ... | @@ -7311,6 +8757,9 @@ packages: |
| 7311 | standard-as-callback@2.1.0: | 8757 | standard-as-callback@2.1.0: |
| 7312 | resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} | 8758 | resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} |
| 7313 | 8759 | ||
| 8760 | standardwebhooks@1.0.0: | ||
| 8761 | resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} | ||
| 8762 | |||
| 7314 | stat-mode@1.0.0: | 8763 | stat-mode@1.0.0: |
| 7315 | resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} | 8764 | resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} |
| 7316 | engines: {node: '>= 6'} | 8765 | engines: {node: '>= 6'} |
| ... | @@ -7341,6 +8790,17 @@ packages: | ... | @@ -7341,6 +8790,17 @@ packages: |
| 7341 | resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} | 8790 | resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} |
| 7342 | engines: {node: '>=8'} | 8791 | engines: {node: '>=8'} |
| 7343 | 8792 | ||
| 8793 | string-width@7.2.0: | ||
| 8794 | resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} | ||
| 8795 | engines: {node: '>=18'} | ||
| 8796 | |||
| 8797 | string-width@8.2.1: | ||
| 8798 | resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} | ||
| 8799 | engines: {node: '>=20'} | ||
| 8800 | |||
| 8801 | string_decoder@1.1.1: | ||
| 8802 | resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} | ||
| 8803 | |||
| 7344 | stringify-entities@4.0.4: | 8804 | stringify-entities@4.0.4: |
| 7345 | resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} | 8805 | resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} |
| 7346 | 8806 | ||
| ... | @@ -7352,10 +8812,17 @@ packages: | ... | @@ -7352,10 +8812,17 @@ packages: |
| 7352 | resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} | 8812 | resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} |
| 7353 | engines: {node: '>=8'} | 8813 | engines: {node: '>=8'} |
| 7354 | 8814 | ||
| 8815 | strip-ansi@7.2.0: | ||
| 8816 | resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} | ||
| 8817 | engines: {node: '>=12'} | ||
| 8818 | |||
| 7355 | strip-indent@3.0.0: | 8819 | strip-indent@3.0.0: |
| 7356 | resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} | 8820 | resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} |
| 7357 | engines: {node: '>=8'} | 8821 | engines: {node: '>=8'} |
| 7358 | 8822 | ||
| 8823 | strnum@2.3.0: | ||
| 8824 | resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} | ||
| 8825 | |||
| 7359 | structured-headers@0.4.1: | 8826 | structured-headers@0.4.1: |
| 7360 | resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} | 8827 | resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} |
| 7361 | 8828 | ||
| ... | @@ -7429,6 +8896,10 @@ packages: | ... | @@ -7429,6 +8896,10 @@ packages: |
| 7429 | resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} | 8896 | resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==} |
| 7430 | engines: {node: '>=8'} | 8897 | engines: {node: '>=8'} |
| 7431 | 8898 | ||
| 8899 | terminal-size@4.0.1: | ||
| 8900 | resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} | ||
| 8901 | engines: {node: '>=18'} | ||
| 8902 | |||
| 7432 | terser@5.48.0: | 8903 | terser@5.48.0: |
| 7433 | resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} | 8904 | resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} |
| 7434 | engines: {node: '>=10'} | 8905 | engines: {node: '>=10'} |
| ... | @@ -7575,10 +9046,17 @@ packages: | ... | @@ -7575,10 +9046,17 @@ packages: |
| 7575 | resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} | 9046 | resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} |
| 7576 | engines: {node: '>=18.17'} | 9047 | engines: {node: '>=18.17'} |
| 7577 | 9048 | ||
| 9049 | undici@7.27.1: | ||
| 9050 | resolution: {integrity: sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==} | ||
| 9051 | engines: {node: '>=20.18.1'} | ||
| 9052 | |||
| 7578 | undici@8.3.0: | 9053 | undici@8.3.0: |
| 7579 | resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} | 9054 | resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} |
| 7580 | engines: {node: '>=22.19.0'} | 9055 | engines: {node: '>=22.19.0'} |
| 7581 | 9056 | ||
| 9057 | unenv@2.0.0-rc.24: | ||
| 9058 | resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} | ||
| 9059 | |||
| 7582 | unicode-canonical-property-names-ecmascript@2.0.1: | 9060 | unicode-canonical-property-names-ecmascript@2.0.1: |
| 7583 | resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} | 9061 | resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} |
| 7584 | engines: {node: '>=4'} | 9062 | engines: {node: '>=4'} |
| ... | @@ -7628,6 +9106,9 @@ packages: | ... | @@ -7628,6 +9106,9 @@ packages: |
| 7628 | unist-util-visit@5.1.0: | 9106 | unist-util-visit@5.1.0: |
| 7629 | resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} | 9107 | resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} |
| 7630 | 9108 | ||
| 9109 | universal-user-agent@7.0.3: | ||
| 9110 | resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} | ||
| 9111 | |||
| 7631 | universalify@0.1.2: | 9112 | universalify@0.1.2: |
| 7632 | resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} | 9113 | resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} |
| 7633 | engines: {node: '>= 4.0.0'} | 9114 | engines: {node: '>= 4.0.0'} |
| ... | @@ -7765,9 +9246,16 @@ packages: | ... | @@ -7765,9 +9246,16 @@ packages: |
| 7765 | peerDependencies: | 9246 | peerDependencies: |
| 7766 | react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 | 9247 | react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 |
| 7767 | 9248 | ||
| 9249 | utf-8-validate@6.0.6: | ||
| 9250 | resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} | ||
| 9251 | engines: {node: '>=6.14.2'} | ||
| 9252 | |||
| 7768 | utf8-byte-length@1.0.5: | 9253 | utf8-byte-length@1.0.5: |
| 7769 | resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} | 9254 | resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} |
| 7770 | 9255 | ||
| 9256 | util-deprecate@1.0.2: | ||
| 9257 | resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} | ||
| 9258 | |||
| 7771 | utils-merge@1.0.1: | 9259 | utils-merge@1.0.1: |
| 7772 | resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} | 9260 | resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} |
| 7773 | engines: {node: '>= 0.4.0'} | 9261 | engines: {node: '>= 0.4.0'} |
| ... | @@ -7942,6 +9430,10 @@ packages: | ... | @@ -7942,6 +9430,10 @@ packages: |
| 7942 | web-namespaces@2.0.1: | 9430 | web-namespaces@2.0.1: |
| 7943 | resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} | 9431 | resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} |
| 7944 | 9432 | ||
| 9433 | webidl-conversions@5.0.0: | ||
| 9434 | resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} | ||
| 9435 | engines: {node: '>=8'} | ||
| 9436 | |||
| 7945 | webpack-virtual-modules@0.6.2: | 9437 | webpack-virtual-modules@0.6.2: |
| 7946 | resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} | 9438 | resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} |
| 7947 | 9439 | ||
| ... | @@ -7951,6 +9443,10 @@ packages: | ... | @@ -7951,6 +9443,10 @@ packages: |
| 7951 | whatwg-url-minimum@0.1.2: | 9443 | whatwg-url-minimum@0.1.2: |
| 7952 | resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} | 9444 | resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} |
| 7953 | 9445 | ||
| 9446 | whatwg-url-without-unicode@8.0.0-3: | ||
| 9447 | resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==} | ||
| 9448 | engines: {node: '>=10'} | ||
| 9449 | |||
| 7954 | which-pm-runs@1.1.0: | 9450 | which-pm-runs@1.1.0: |
| 7955 | resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} | 9451 | resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} |
| 7956 | engines: {node: '>=4'} | 9452 | engines: {node: '>=4'} |
| ... | @@ -7970,6 +9466,15 @@ packages: | ... | @@ -7970,6 +9466,15 @@ packages: |
| 7970 | engines: {node: ^20.17.0 || >=22.9.0} | 9466 | engines: {node: ^20.17.0 || >=22.9.0} |
| 7971 | hasBin: true | 9467 | hasBin: true |
| 7972 | 9468 | ||
| 9469 | widest-line@6.0.0: | ||
| 9470 | resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} | ||
| 9471 | engines: {node: '>=20'} | ||
| 9472 | |||
| 9473 | workerd@1.20260526.1: | ||
| 9474 | resolution: {integrity: sha512-IHzymht98p10JH1zzwdCpbViAqw97HrwKl7+KfZeASFMsYSrIsAULWdPn0LRC5FTUzBpamLNyKCCKxbgXHgRHQ==} | ||
| 9475 | engines: {node: '>=16'} | ||
| 9476 | hasBin: true | ||
| 9477 | |||
| 7973 | wrap-ansi@6.2.0: | 9478 | wrap-ansi@6.2.0: |
| 7974 | resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} | 9479 | resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} |
| 7975 | engines: {node: '>=8'} | 9480 | engines: {node: '>=8'} |
| ... | @@ -7978,6 +9483,10 @@ packages: | ... | @@ -7978,6 +9483,10 @@ packages: |
| 7978 | resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} | 9483 | resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} |
| 7979 | engines: {node: '>=10'} | 9484 | engines: {node: '>=10'} |
| 7980 | 9485 | ||
| 9486 | wrap-ansi@9.0.2: | ||
| 9487 | resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} | ||
| 9488 | engines: {node: '>=18'} | ||
| 9489 | |||
| 7981 | wrappy@1.0.2: | 9490 | wrappy@1.0.2: |
| 7982 | resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} | 9491 | resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} |
| 7983 | 9492 | ||
| ... | @@ -8009,6 +9518,18 @@ packages: | ... | @@ -8009,6 +9518,18 @@ packages: |
| 8009 | resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} | 9518 | resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} |
| 8010 | engines: {node: '>=10.0.0'} | 9519 | engines: {node: '>=10.0.0'} |
| 8011 | 9520 | ||
| 9521 | xdg-app-paths@8.3.0: | ||
| 9522 | resolution: {integrity: sha512-mgxlWVZw0TNWHoGmXq+NC3uhCIc55dDpAlDkMQUaIAcQzysb0kxctwv//fvuW61/nAAeUBJMQ8mnZjMmuYwOcQ==} | ||
| 9523 | engines: {node: '>= 4.0'} | ||
| 9524 | |||
| 9525 | xdg-portable@10.6.0: | ||
| 9526 | resolution: {integrity: sha512-xrcqhWDvtZ7WLmt8G4f3hHy37iK7D2idtosRgkeiSPZEPmBShp0VfmRBLWAPC6zLF48APJ21yfea+RfQMF4/Aw==} | ||
| 9527 | engines: {node: '>= 4.0'} | ||
| 9528 | |||
| 9529 | xml-naming@0.1.0: | ||
| 9530 | resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} | ||
| 9531 | engines: {node: '>=16.0.0'} | ||
| 9532 | |||
| 8012 | xml2js@0.6.0: | 9533 | xml2js@0.6.0: |
| 8013 | resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} | 9534 | resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} |
| 8014 | engines: {node: '>=4.0.0'} | 9535 | engines: {node: '>=4.0.0'} |
| ... | @@ -8021,6 +9542,10 @@ packages: | ... | @@ -8021,6 +9542,10 @@ packages: |
| 8021 | resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} | 9542 | resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} |
| 8022 | engines: {node: '>=8.0'} | 9543 | engines: {node: '>=8.0'} |
| 8023 | 9544 | ||
| 9545 | xtend@4.0.2: | ||
| 9546 | resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} | ||
| 9547 | engines: {node: '>=0.4'} | ||
| 9548 | |||
| 8024 | xxhash-wasm@1.1.0: | 9549 | xxhash-wasm@1.1.0: |
| 8025 | resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} | 9550 | resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} |
| 8026 | 9551 | ||
| ... | @@ -8078,6 +9603,9 @@ packages: | ... | @@ -8078,6 +9603,9 @@ packages: |
| 8078 | resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} | 9603 | resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} |
| 8079 | engines: {node: '>=18'} | 9604 | engines: {node: '>=18'} |
| 8080 | 9605 | ||
| 9606 | yoga-layout@3.2.1: | ||
| 9607 | resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} | ||
| 9608 | |||
| 8081 | zod-to-json-schema@3.25.2: | 9609 | zod-to-json-schema@3.25.2: |
| 8082 | resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} | 9610 | resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} |
| 8083 | peerDependencies: | 9611 | peerDependencies: |
| ... | @@ -8121,6 +9649,13 @@ snapshots: | ... | @@ -8121,6 +9649,13 @@ snapshots: |
| 8121 | 9649 | ||
| 8122 | '@adobe/css-tools@4.5.0': {} | 9650 | '@adobe/css-tools@4.5.0': {} |
| 8123 | 9651 | ||
| 9652 | '@alcalzone/ansi-tokenize@0.2.5': | ||
| 9653 | dependencies: | ||
| 9654 | ansi-styles: 6.2.3 | ||
| 9655 | is-fullwidth-code-point: 5.1.0 | ||
| 9656 | |||
| 9657 | '@alchemy.run/node-utils@0.0.4': {} | ||
| 9658 | |||
| 8124 | '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141': | 9659 | '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141': |
| 8125 | optional: true | 9660 | optional: true |
| 8126 | 9661 | ||
| ... | @@ -8297,6 +9832,220 @@ snapshots: | ... | @@ -8297,6 +9832,220 @@ snapshots: |
| 8297 | dependencies: | 9832 | dependencies: |
| 8298 | yaml: 2.9.0 | 9833 | yaml: 2.9.0 |
| 8299 | 9834 | ||
| 9835 | '@aws-crypto/crc32@5.2.0': | ||
| 9836 | dependencies: | ||
| 9837 | '@aws-crypto/util': 5.2.0 | ||
| 9838 | '@aws-sdk/types': 3.973.10 | ||
| 9839 | tslib: 2.8.1 | ||
| 9840 | |||
| 9841 | '@aws-crypto/sha256-browser@5.2.0': | ||
| 9842 | dependencies: | ||
| 9843 | '@aws-crypto/sha256-js': 5.2.0 | ||
| 9844 | '@aws-crypto/supports-web-crypto': 5.2.0 | ||
| 9845 | '@aws-crypto/util': 5.2.0 | ||
| 9846 | '@aws-sdk/types': 3.973.10 | ||
| 9847 | '@aws-sdk/util-locate-window': 3.965.5 | ||
| 9848 | '@smithy/util-utf8': 2.3.0 | ||
| 9849 | tslib: 2.8.1 | ||
| 9850 | |||
| 9851 | '@aws-crypto/sha256-js@5.2.0': | ||
| 9852 | dependencies: | ||
| 9853 | '@aws-crypto/util': 5.2.0 | ||
| 9854 | '@aws-sdk/types': 3.973.10 | ||
| 9855 | tslib: 2.8.1 | ||
| 9856 | |||
| 9857 | '@aws-crypto/supports-web-crypto@5.2.0': | ||
| 9858 | dependencies: | ||
| 9859 | tslib: 2.8.1 | ||
| 9860 | |||
| 9861 | '@aws-crypto/util@5.2.0': | ||
| 9862 | dependencies: | ||
| 9863 | '@aws-sdk/types': 3.973.10 | ||
| 9864 | '@smithy/util-utf8': 2.3.0 | ||
| 9865 | tslib: 2.8.1 | ||
| 9866 | |||
| 9867 | '@aws-sdk/client-cognito-identity@3.1062.0': | ||
| 9868 | dependencies: | ||
| 9869 | '@aws-crypto/sha256-browser': 5.2.0 | ||
| 9870 | '@aws-crypto/sha256-js': 5.2.0 | ||
| 9871 | '@aws-sdk/core': 3.974.17 | ||
| 9872 | '@aws-sdk/credential-provider-node': 3.972.51 | ||
| 9873 | '@aws-sdk/types': 3.973.10 | ||
| 9874 | '@smithy/core': 3.24.6 | ||
| 9875 | '@smithy/fetch-http-handler': 5.4.6 | ||
| 9876 | '@smithy/node-http-handler': 4.7.7 | ||
| 9877 | '@smithy/types': 4.14.3 | ||
| 9878 | tslib: 2.8.1 | ||
| 9879 | |||
| 9880 | '@aws-sdk/core@3.974.17': | ||
| 9881 | dependencies: | ||
| 9882 | '@aws-sdk/types': 3.973.10 | ||
| 9883 | '@aws-sdk/xml-builder': 3.972.27 | ||
| 9884 | '@aws/lambda-invoke-store': 0.2.4 | ||
| 9885 | '@smithy/core': 3.24.6 | ||
| 9886 | '@smithy/signature-v4': 5.4.6 | ||
| 9887 | '@smithy/types': 4.14.3 | ||
| 9888 | bowser: 2.14.1 | ||
| 9889 | tslib: 2.8.1 | ||
| 9890 | |||
| 9891 | '@aws-sdk/credential-provider-cognito-identity@3.972.41': | ||
| 9892 | dependencies: | ||
| 9893 | '@aws-sdk/nested-clients': 3.997.16 | ||
| 9894 | '@aws-sdk/types': 3.973.10 | ||
| 9895 | '@smithy/core': 3.24.6 | ||
| 9896 | '@smithy/types': 4.14.3 | ||
| 9897 | tslib: 2.8.1 | ||
| 9898 | |||
| 9899 | '@aws-sdk/credential-provider-env@3.972.43': | ||
| 9900 | dependencies: | ||
| 9901 | '@aws-sdk/core': 3.974.17 | ||
| 9902 | '@aws-sdk/types': 3.973.10 | ||
| 9903 | '@smithy/core': 3.24.6 | ||
| 9904 | '@smithy/types': 4.14.3 | ||
| 9905 | tslib: 2.8.1 | ||
| 9906 | |||
| 9907 | '@aws-sdk/credential-provider-http@3.972.45': | ||
| 9908 | dependencies: | ||
| 9909 | '@aws-sdk/core': 3.974.17 | ||
| 9910 | '@aws-sdk/types': 3.973.10 | ||
| 9911 | '@smithy/core': 3.24.6 | ||
| 9912 | '@smithy/fetch-http-handler': 5.4.6 | ||
| 9913 | '@smithy/node-http-handler': 4.7.7 | ||
| 9914 | '@smithy/types': 4.14.3 | ||
| 9915 | tslib: 2.8.1 | ||
| 9916 | |||
| 9917 | '@aws-sdk/credential-provider-ini@3.972.49': | ||
| 9918 | dependencies: | ||
| 9919 | '@aws-sdk/core': 3.974.17 | ||
| 9920 | '@aws-sdk/credential-provider-env': 3.972.43 | ||
| 9921 | '@aws-sdk/credential-provider-http': 3.972.45 | ||
| 9922 | '@aws-sdk/credential-provider-login': 3.972.48 | ||
| 9923 | '@aws-sdk/credential-provider-process': 3.972.43 | ||
| 9924 | '@aws-sdk/credential-provider-sso': 3.972.48 | ||
| 9925 | '@aws-sdk/credential-provider-web-identity': 3.972.48 | ||
| 9926 | '@aws-sdk/nested-clients': 3.997.16 | ||
| 9927 | '@aws-sdk/types': 3.973.10 | ||
| 9928 | '@smithy/core': 3.24.6 | ||
| 9929 | '@smithy/credential-provider-imds': 4.3.8 | ||
| 9930 | '@smithy/types': 4.14.3 | ||
| 9931 | tslib: 2.8.1 | ||
| 9932 | |||
| 9933 | '@aws-sdk/credential-provider-login@3.972.48': | ||
| 9934 | dependencies: | ||
| 9935 | '@aws-sdk/core': 3.974.17 | ||
| 9936 | '@aws-sdk/nested-clients': 3.997.16 | ||
| 9937 | '@aws-sdk/types': 3.973.10 | ||
| 9938 | '@smithy/core': 3.24.6 | ||
| 9939 | '@smithy/types': 4.14.3 | ||
| 9940 | tslib: 2.8.1 | ||
| 9941 | |||
| 9942 | '@aws-sdk/credential-provider-node@3.972.51': | ||
| 9943 | dependencies: | ||
| 9944 | '@aws-sdk/credential-provider-env': 3.972.43 | ||
| 9945 | '@aws-sdk/credential-provider-http': 3.972.45 | ||
| 9946 | '@aws-sdk/credential-provider-ini': 3.972.49 | ||
| 9947 | '@aws-sdk/credential-provider-process': 3.972.43 | ||
| 9948 | '@aws-sdk/credential-provider-sso': 3.972.48 | ||
| 9949 | '@aws-sdk/credential-provider-web-identity': 3.972.48 | ||
| 9950 | '@aws-sdk/types': 3.973.10 | ||
| 9951 | '@smithy/core': 3.24.6 | ||
| 9952 | '@smithy/credential-provider-imds': 4.3.8 | ||
| 9953 | '@smithy/types': 4.14.3 | ||
| 9954 | tslib: 2.8.1 | ||
| 9955 | |||
| 9956 | '@aws-sdk/credential-provider-process@3.972.43': | ||
| 9957 | dependencies: | ||
| 9958 | '@aws-sdk/core': 3.974.17 | ||
| 9959 | '@aws-sdk/types': 3.973.10 | ||
| 9960 | '@smithy/core': 3.24.6 | ||
| 9961 | '@smithy/types': 4.14.3 | ||
| 9962 | tslib: 2.8.1 | ||
| 9963 | |||
| 9964 | '@aws-sdk/credential-provider-sso@3.972.48': | ||
| 9965 | dependencies: | ||
| 9966 | '@aws-sdk/core': 3.974.17 | ||
| 9967 | '@aws-sdk/nested-clients': 3.997.16 | ||
| 9968 | '@aws-sdk/token-providers': 3.1062.0 | ||
| 9969 | '@aws-sdk/types': 3.973.10 | ||
| 9970 | '@smithy/core': 3.24.6 | ||
| 9971 | '@smithy/types': 4.14.3 | ||
| 9972 | tslib: 2.8.1 | ||
| 9973 | |||
| 9974 | '@aws-sdk/credential-provider-web-identity@3.972.48': | ||
| 9975 | dependencies: | ||
| 9976 | '@aws-sdk/core': 3.974.17 | ||
| 9977 | '@aws-sdk/nested-clients': 3.997.16 | ||
| 9978 | '@aws-sdk/types': 3.973.10 | ||
| 9979 | '@smithy/core': 3.24.6 | ||
| 9980 | '@smithy/types': 4.14.3 | ||
| 9981 | tslib: 2.8.1 | ||
| 9982 | |||
| 9983 | '@aws-sdk/credential-providers@3.1062.0': | ||
| 9984 | dependencies: | ||
| 9985 | '@aws-sdk/client-cognito-identity': 3.1062.0 | ||
| 9986 | '@aws-sdk/core': 3.974.17 | ||
| 9987 | '@aws-sdk/credential-provider-cognito-identity': 3.972.41 | ||
| 9988 | '@aws-sdk/credential-provider-env': 3.972.43 | ||
| 9989 | '@aws-sdk/credential-provider-http': 3.972.45 | ||
| 9990 | '@aws-sdk/credential-provider-ini': 3.972.49 | ||
| 9991 | '@aws-sdk/credential-provider-login': 3.972.48 | ||
| 9992 | '@aws-sdk/credential-provider-node': 3.972.51 | ||
| 9993 | '@aws-sdk/credential-provider-process': 3.972.43 | ||
| 9994 | '@aws-sdk/credential-provider-sso': 3.972.48 | ||
| 9995 | '@aws-sdk/credential-provider-web-identity': 3.972.48 | ||
| 9996 | '@aws-sdk/nested-clients': 3.997.16 | ||
| 9997 | '@aws-sdk/types': 3.973.10 | ||
| 9998 | '@smithy/core': 3.24.6 | ||
| 9999 | '@smithy/credential-provider-imds': 4.3.8 | ||
| 10000 | '@smithy/types': 4.14.3 | ||
| 10001 | tslib: 2.8.1 | ||
| 10002 | |||
| 10003 | '@aws-sdk/nested-clients@3.997.16': | ||
| 10004 | dependencies: | ||
| 10005 | '@aws-crypto/sha256-browser': 5.2.0 | ||
| 10006 | '@aws-crypto/sha256-js': 5.2.0 | ||
| 10007 | '@aws-sdk/core': 3.974.17 | ||
| 10008 | '@aws-sdk/signature-v4-multi-region': 3.996.31 | ||
| 10009 | '@aws-sdk/types': 3.973.10 | ||
| 10010 | '@smithy/core': 3.24.6 | ||
| 10011 | '@smithy/fetch-http-handler': 5.4.6 | ||
| 10012 | '@smithy/node-http-handler': 4.7.7 | ||
| 10013 | '@smithy/types': 4.14.3 | ||
| 10014 | tslib: 2.8.1 | ||
| 10015 | |||
| 10016 | '@aws-sdk/signature-v4-multi-region@3.996.31': | ||
| 10017 | dependencies: | ||
| 10018 | '@aws-sdk/types': 3.973.10 | ||
| 10019 | '@smithy/signature-v4': 5.4.6 | ||
| 10020 | '@smithy/types': 4.14.3 | ||
| 10021 | tslib: 2.8.1 | ||
| 10022 | |||
| 10023 | '@aws-sdk/token-providers@3.1062.0': | ||
| 10024 | dependencies: | ||
| 10025 | '@aws-sdk/core': 3.974.17 | ||
| 10026 | '@aws-sdk/nested-clients': 3.997.16 | ||
| 10027 | '@aws-sdk/types': 3.973.10 | ||
| 10028 | '@smithy/core': 3.24.6 | ||
| 10029 | '@smithy/types': 4.14.3 | ||
| 10030 | tslib: 2.8.1 | ||
| 10031 | |||
| 10032 | '@aws-sdk/types@3.973.10': | ||
| 10033 | dependencies: | ||
| 10034 | '@smithy/types': 4.14.3 | ||
| 10035 | tslib: 2.8.1 | ||
| 10036 | |||
| 10037 | '@aws-sdk/util-locate-window@3.965.5': | ||
| 10038 | dependencies: | ||
| 10039 | tslib: 2.8.1 | ||
| 10040 | |||
| 10041 | '@aws-sdk/xml-builder@3.972.27': | ||
| 10042 | dependencies: | ||
| 10043 | '@smithy/types': 4.14.3 | ||
| 10044 | fast-xml-parser: 5.7.3 | ||
| 10045 | tslib: 2.8.1 | ||
| 10046 | |||
| 10047 | '@aws/lambda-invoke-store@0.2.4': {} | ||
| 10048 | |||
| 8300 | '@babel/code-frame@7.29.7': | 10049 | '@babel/code-frame@7.29.7': |
| 8301 | dependencies: | 10050 | dependencies: |
| 8302 | '@babel/helper-validator-identifier': 7.29.7 | 10051 | '@babel/helper-validator-identifier': 7.29.7 |
| ... | @@ -8813,33 +10562,238 @@ snapshots: | ... | @@ -8813,33 +10562,238 @@ snapshots: |
| 8813 | reselect: 5.2.0 | 10562 | reselect: 5.2.0 |
| 8814 | use-sync-external-store: 1.6.0(react@19.2.6) | 10563 | use-sync-external-store: 1.6.0(react@19.2.6) |
| 8815 | optionalDependencies: | 10564 | optionalDependencies: |
| 8816 | '@types/react': 19.2.16 | 10565 | '@types/react': 19.2.16 |
| 10566 | |||
| 10567 | '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | ||
| 10568 | dependencies: | ||
| 10569 | react: 19.2.3 | ||
| 10570 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | ||
| 10571 | |||
| 10572 | '@capsizecss/unpack@4.0.0': | ||
| 10573 | dependencies: | ||
| 10574 | fontkitten: 1.0.3 | ||
| 10575 | |||
| 10576 | '@clack/core@0.5.0': | ||
| 10577 | dependencies: | ||
| 10578 | picocolors: 1.1.1 | ||
| 10579 | sisteransi: 1.0.5 | ||
| 10580 | |||
| 10581 | '@clack/core@1.4.0': | ||
| 10582 | dependencies: | ||
| 10583 | fast-wrap-ansi: 0.2.2 | ||
| 10584 | sisteransi: 1.0.5 | ||
| 10585 | |||
| 10586 | '@clack/prompts@0.11.0': | ||
| 10587 | dependencies: | ||
| 10588 | '@clack/core': 0.5.0 | ||
| 10589 | picocolors: 1.1.1 | ||
| 10590 | sisteransi: 1.0.5 | ||
| 10591 | |||
| 10592 | '@clack/prompts@1.5.0': | ||
| 10593 | dependencies: | ||
| 10594 | '@clack/core': 1.4.0 | ||
| 10595 | fast-string-width: 3.0.2 | ||
| 10596 | fast-wrap-ansi: 0.2.2 | ||
| 10597 | sisteransi: 1.0.5 | ||
| 10598 | |||
| 10599 | '@clerk/backend@3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | ||
| 10600 | dependencies: | ||
| 10601 | '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 10602 | standardwebhooks: 1.0.0 | ||
| 10603 | tslib: 2.8.1 | ||
| 10604 | transitivePeerDependencies: | ||
| 10605 | - react | ||
| 10606 | - react-dom | ||
| 10607 | |||
| 10608 | '@clerk/clerk-js@6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | ||
| 10609 | dependencies: | ||
| 10610 | '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | ||
| 10611 | '@stripe/stripe-js': 5.6.0 | ||
| 10612 | '@swc/helpers': 0.5.21 | ||
| 10613 | '@tanstack/query-core': 5.100.14 | ||
| 10614 | '@zxcvbn-ts/core': 3.0.4 | ||
| 10615 | '@zxcvbn-ts/language-common': 3.0.4 | ||
| 10616 | alien-signals: 2.0.6 | ||
| 10617 | browser-tabs-lock: 1.3.0 | ||
| 10618 | core-js: 3.47.0 | ||
| 10619 | crypto-js: 4.2.0 | ||
| 10620 | dequal: 2.0.3 | ||
| 10621 | transitivePeerDependencies: | ||
| 10622 | - react | ||
| 10623 | - react-dom | ||
| 10624 | |||
| 10625 | '@clerk/clerk-js@6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | ||
| 10626 | dependencies: | ||
| 10627 | '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 10628 | '@stripe/stripe-js': 5.6.0 | ||
| 10629 | '@swc/helpers': 0.5.21 | ||
| 10630 | '@tanstack/query-core': 5.100.14 | ||
| 10631 | '@zxcvbn-ts/core': 3.0.4 | ||
| 10632 | '@zxcvbn-ts/language-common': 3.0.4 | ||
| 10633 | alien-signals: 2.0.6 | ||
| 10634 | browser-tabs-lock: 1.3.0 | ||
| 10635 | core-js: 3.47.0 | ||
| 10636 | crypto-js: 4.2.0 | ||
| 10637 | dequal: 2.0.3 | ||
| 10638 | transitivePeerDependencies: | ||
| 10639 | - react | ||
| 10640 | - react-dom | ||
| 10641 | |||
| 10642 | '@clerk/expo@3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | ||
| 10643 | dependencies: | ||
| 10644 | '@clerk/clerk-js': 6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | ||
| 10645 | '@clerk/react': 6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | ||
| 10646 | '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | ||
| 10647 | base-64: 1.0.0 | ||
| 10648 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) | ||
| 10649 | react: 19.2.3 | ||
| 10650 | react-dom: 19.2.3(react@19.2.3) | ||
| 10651 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | ||
| 10652 | react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | ||
| 10653 | tslib: 2.8.1 | ||
| 10654 | optionalDependencies: | ||
| 10655 | expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 10656 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | ||
| 10657 | expo-crypto: 56.0.4(expo@56.0.8) | ||
| 10658 | expo-secure-store: 56.0.4(expo@56.0.8) | ||
| 10659 | expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | ||
| 10660 | |||
| 10661 | '@clerk/react@6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | ||
| 10662 | dependencies: | ||
| 10663 | '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | ||
| 10664 | react: 19.2.3 | ||
| 10665 | react-dom: 19.2.3(react@19.2.3) | ||
| 10666 | tslib: 2.8.1 | ||
| 10667 | |||
| 10668 | '@clerk/react@6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | ||
| 10669 | dependencies: | ||
| 10670 | '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | ||
| 10671 | react: 19.2.6 | ||
| 10672 | react-dom: 19.2.6(react@19.2.6) | ||
| 10673 | tslib: 2.8.1 | ||
| 10674 | |||
| 10675 | '@clerk/shared@4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | ||
| 10676 | dependencies: | ||
| 10677 | '@tanstack/query-core': 5.100.14 | ||
| 10678 | dequal: 2.0.3 | ||
| 10679 | glob-to-regexp: 0.4.1 | ||
| 10680 | js-cookie: 3.0.7 | ||
| 10681 | optionalDependencies: | ||
| 10682 | react: 19.2.3 | ||
| 10683 | react-dom: 19.2.3(react@19.2.3) | ||
| 10684 | |||
| 10685 | '@clerk/shared@4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | ||
| 10686 | dependencies: | ||
| 10687 | '@tanstack/query-core': 5.100.14 | ||
| 10688 | dequal: 2.0.3 | ||
| 10689 | glob-to-regexp: 0.4.1 | ||
| 10690 | js-cookie: 3.0.7 | ||
| 10691 | optionalDependencies: | ||
| 10692 | react: 19.2.6 | ||
| 10693 | react-dom: 19.2.6(react@19.2.6) | ||
| 10694 | |||
| 10695 | '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1)': | ||
| 10696 | dependencies: | ||
| 10697 | unenv: 2.0.0-rc.24 | ||
| 10698 | optionalDependencies: | ||
| 10699 | workerd: 1.20260526.1 | ||
| 10700 | |||
| 10701 | '@cloudflare/workerd-darwin-64@1.20260526.1': | ||
| 10702 | optional: true | ||
| 10703 | |||
| 10704 | '@cloudflare/workerd-darwin-arm64@1.20260526.1': | ||
| 10705 | optional: true | ||
| 10706 | |||
| 10707 | '@cloudflare/workerd-linux-64@1.20260526.1': | ||
| 10708 | optional: true | ||
| 10709 | |||
| 10710 | '@cloudflare/workerd-linux-arm64@1.20260526.1': | ||
| 10711 | optional: true | ||
| 10712 | |||
| 10713 | '@cloudflare/workerd-windows-64@1.20260526.1': | ||
| 10714 | optional: true | ||
| 10715 | |||
| 10716 | '@cloudflare/workers-types@4.20260604.1': {} | ||
| 10717 | |||
| 10718 | '@develar/schema-utils@2.6.5': | ||
| 10719 | dependencies: | ||
| 10720 | ajv: 6.15.0 | ||
| 10721 | ajv-keywords: 3.5.2(ajv@6.15.0) | ||
| 10722 | |||
| 10723 | '@distilled.cloud/aws@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': | ||
| 10724 | dependencies: | ||
| 10725 | '@aws-crypto/crc32': 5.2.0 | ||
| 10726 | '@aws-crypto/util': 5.2.0 | ||
| 10727 | '@aws-sdk/credential-providers': 3.1062.0 | ||
| 10728 | '@aws-sdk/types': 3.973.10 | ||
| 10729 | '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 10730 | '@smithy/shared-ini-file-loader': 4.5.6 | ||
| 10731 | '@smithy/types': 4.14.3 | ||
| 10732 | '@smithy/util-base64': 4.4.6 | ||
| 10733 | aws4fetch: 1.0.20 | ||
| 10734 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) | ||
| 10735 | fast-xml-parser: 5.8.0 | ||
| 10736 | |||
| 10737 | '@distilled.cloud/axiom@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': | ||
| 10738 | dependencies: | ||
| 10739 | '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 10740 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) | ||
| 10741 | |||
| 10742 | '@distilled.cloud/cloudflare-rolldown-plugin@0.10.5(rolldown@1.0.1)(workerd@1.20260526.1)': | ||
| 10743 | dependencies: | ||
| 10744 | '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1) | ||
| 10745 | magic-string: 0.30.21 | ||
| 10746 | unenv: 2.0.0-rc.24 | ||
| 10747 | optionalDependencies: | ||
| 10748 | rolldown: 1.0.1 | ||
| 10749 | transitivePeerDependencies: | ||
| 10750 | - workerd | ||
| 10751 | |||
| 10752 | '@distilled.cloud/cloudflare-runtime@0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': | ||
| 10753 | dependencies: | ||
| 10754 | '@alchemy.run/node-utils': 0.0.4 | ||
| 10755 | '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 10756 | capnweb: 0.7.0 | ||
| 10757 | chokidar: 4.0.3 | ||
| 10758 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) | ||
| 10759 | workerd: 1.20260526.1 | ||
| 10760 | xdg-app-paths: 8.3.0 | ||
| 10761 | optionalDependencies: | ||
| 10762 | '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) | ||
| 10763 | '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) | ||
| 8817 | 10764 | ||
| 8818 | '@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)': | 10765 | '@distilled.cloud/cloudflare-vite-plugin@0.10.5(874b2206c1318ea354f257cb5515510e)': |
| 8819 | dependencies: | 10766 | dependencies: |
| 8820 | react: 19.2.3 | 10767 | '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 8821 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 10768 | '@distilled.cloud/cloudflare-rolldown-plugin': 0.10.5(rolldown@1.0.1)(workerd@1.20260526.1) |
| 10769 | '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 10770 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) | ||
| 10771 | 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)' | ||
| 10772 | optionalDependencies: | ||
| 10773 | '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) | ||
| 10774 | '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) | ||
| 10775 | transitivePeerDependencies: | ||
| 10776 | - rolldown | ||
| 10777 | - workerd | ||
| 8822 | 10778 | ||
| 8823 | '@capsizecss/unpack@4.0.0': | 10779 | '@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': |
| 8824 | dependencies: | 10780 | dependencies: |
| 8825 | fontkitten: 1.0.3 | 10781 | '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 10782 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) | ||
| 8826 | 10783 | ||
| 8827 | '@clack/core@1.4.0': | 10784 | '@distilled.cloud/core@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': |
| 8828 | dependencies: | 10785 | dependencies: |
| 8829 | fast-wrap-ansi: 0.2.2 | 10786 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8830 | sisteransi: 1.0.5 | ||
| 8831 | 10787 | ||
| 8832 | '@clack/prompts@1.5.0': | 10788 | '@distilled.cloud/neon@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': |
| 8833 | dependencies: | 10789 | dependencies: |
| 8834 | '@clack/core': 1.4.0 | 10790 | '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 8835 | fast-string-width: 3.0.2 | 10791 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8836 | fast-wrap-ansi: 0.2.2 | ||
| 8837 | sisteransi: 1.0.5 | ||
| 8838 | 10792 | ||
| 8839 | '@develar/schema-utils@2.6.5': | 10793 | '@distilled.cloud/planetscale@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': |
| 8840 | dependencies: | 10794 | dependencies: |
| 8841 | ajv: 6.15.0 | 10795 | '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) |
| 8842 | ajv-keywords: 3.5.2(ajv@6.15.0) | 10796 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8843 | 10797 | ||
| 8844 | '@dnd-kit/accessibility@3.1.1(react@19.2.6)': | 10798 | '@dnd-kit/accessibility@3.1.1(react@19.2.6)': |
| 8845 | dependencies: | 10799 | dependencies: |
| ... | @@ -8873,44 +10827,46 @@ snapshots: | ... | @@ -8873,44 +10827,46 @@ snapshots: |
| 8873 | react: 19.2.6 | 10827 | react: 19.2.6 |
| 8874 | tslib: 2.8.1 | 10828 | tslib: 2.8.1 |
| 8875 | 10829 | ||
| 8876 | '@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)': | 10830 | '@drizzle-team/brocli@0.11.0': {} |
| 10831 | |||
| 10832 | '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0)': | ||
| 8877 | dependencies: | 10833 | dependencies: |
| 8878 | effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 10834 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8879 | react: 19.2.3 | 10835 | react: 19.2.3 |
| 8880 | scheduler: 0.27.0 | 10836 | scheduler: 0.27.0 |
| 8881 | 10837 | ||
| 8882 | '@effect/atom-react@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(react@19.2.6)(scheduler@0.27.0)': | 10838 | '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.6)(scheduler@0.27.0)': |
| 8883 | dependencies: | 10839 | dependencies: |
| 8884 | effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 10840 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8885 | react: 19.2.6 | 10841 | react: 19.2.6 |
| 8886 | scheduler: 0.27.0 | 10842 | scheduler: 0.27.0 |
| 8887 | 10843 | ||
| 8888 | '@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))': | 10844 | '@effect/openapi-generator@4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': |
| 8889 | dependencies: | 10845 | dependencies: |
| 8890 | '@effect/platform-node': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0) | 10846 | '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 8891 | effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 10847 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8892 | 10848 | ||
| 8893 | '@effect/platform-bun@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': | 10849 | '@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6)': |
| 8894 | dependencies: | 10850 | dependencies: |
| 8895 | '@effect/platform-node-shared': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) | 10851 | '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) |
| 8896 | effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 10852 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8897 | transitivePeerDependencies: | 10853 | transitivePeerDependencies: |
| 8898 | - bufferutil | 10854 | - bufferutil |
| 8899 | - utf-8-validate | 10855 | - utf-8-validate |
| 8900 | 10856 | ||
| 8901 | '@effect/platform-node-shared@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': | 10857 | '@effect/platform-node-shared@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6)': |
| 8902 | dependencies: | 10858 | dependencies: |
| 8903 | '@types/ws': 8.18.1 | 10859 | '@types/ws': 8.18.1 |
| 8904 | effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 10860 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8905 | ws: 8.21.0 | 10861 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 8906 | transitivePeerDependencies: | 10862 | transitivePeerDependencies: |
| 8907 | - bufferutil | 10863 | - bufferutil |
| 8908 | - utf-8-validate | 10864 | - utf-8-validate |
| 8909 | 10865 | ||
| 8910 | '@effect/platform-node@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)': | 10866 | '@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6)': |
| 8911 | dependencies: | 10867 | dependencies: |
| 8912 | '@effect/platform-node-shared': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)) | 10868 | '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) |
| 8913 | effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 10869 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8914 | ioredis: 5.11.0 | 10870 | ioredis: 5.11.0 |
| 8915 | mime: 4.1.0 | 10871 | mime: 4.1.0 |
| 8916 | undici: 8.3.0 | 10872 | undici: 8.3.0 |
| ... | @@ -8918,9 +10874,20 @@ snapshots: | ... | @@ -8918,9 +10874,20 @@ snapshots: |
| 8918 | - bufferutil | 10874 | - bufferutil |
| 8919 | - utf-8-validate | 10875 | - utf-8-validate |
| 8920 | 10876 | ||
| 8921 | '@effect/sql-sqlite-bun@4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))': | 10877 | '@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': |
| 10878 | dependencies: | ||
| 10879 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) | ||
| 10880 | pg: 8.21.0 | ||
| 10881 | pg-connection-string: 2.12.0 | ||
| 10882 | pg-cursor: 2.20.0(pg@8.21.0) | ||
| 10883 | pg-pool: 3.14.0(pg@8.21.0) | ||
| 10884 | pg-types: 4.1.0 | ||
| 10885 | transitivePeerDependencies: | ||
| 10886 | - pg-native | ||
| 10887 | |||
| 10888 | '@effect/sql-sqlite-bun@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': | ||
| 8922 | dependencies: | 10889 | dependencies: |
| 8923 | effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 10890 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8924 | 10891 | ||
| 8925 | '@effect/tsgo-darwin-arm64@0.11.4': | 10892 | '@effect/tsgo-darwin-arm64@0.11.4': |
| 8926 | optional: true | 10893 | optional: true |
| ... | @@ -8953,10 +10920,10 @@ snapshots: | ... | @@ -8953,10 +10920,10 @@ snapshots: |
| 8953 | '@effect/tsgo-win32-arm64': 0.11.4 | 10920 | '@effect/tsgo-win32-arm64': 0.11.4 |
| 8954 | '@effect/tsgo-win32-x64': 0.11.4 | 10921 | '@effect/tsgo-win32-x64': 0.11.4 |
| 8955 | 10922 | ||
| 8956 | '@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))': | 10923 | '@effect/vitest@4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': |
| 8957 | dependencies: | 10924 | dependencies: |
| 8958 | effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01) | 10925 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) |
| 8959 | 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)' | 10926 | 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)' |
| 8960 | 10927 | ||
| 8961 | '@egjs/hammerjs@2.0.17': | 10928 | '@egjs/hammerjs@2.0.17': |
| 8962 | dependencies: | 10929 | dependencies: |
| ... | @@ -9096,81 +11063,159 @@ snapshots: | ... | @@ -9096,81 +11063,159 @@ snapshots: |
| 9096 | 11063 | ||
| 9097 | '@epic-web/invariant@1.0.0': {} | 11064 | '@epic-web/invariant@1.0.0': {} |
| 9098 | 11065 | ||
| 11066 | '@esbuild/aix-ppc64@0.25.12': | ||
| 11067 | optional: true | ||
| 11068 | |||
| 9099 | '@esbuild/aix-ppc64@0.27.7': | 11069 | '@esbuild/aix-ppc64@0.27.7': |
| 9100 | optional: true | 11070 | optional: true |
| 9101 | 11071 | ||
| 11072 | '@esbuild/android-arm64@0.25.12': | ||
| 11073 | optional: true | ||
| 11074 | |||
| 9102 | '@esbuild/android-arm64@0.27.7': | 11075 | '@esbuild/android-arm64@0.27.7': |
| 9103 | optional: true | 11076 | optional: true |
| 9104 | 11077 | ||
| 11078 | '@esbuild/android-arm@0.25.12': | ||
| 11079 | optional: true | ||
| 11080 | |||
| 9105 | '@esbuild/android-arm@0.27.7': | 11081 | '@esbuild/android-arm@0.27.7': |
| 9106 | optional: true | 11082 | optional: true |
| 9107 | 11083 | ||
| 11084 | '@esbuild/android-x64@0.25.12': | ||
| 11085 | optional: true | ||
| 11086 | |||
| 9108 | '@esbuild/android-x64@0.27.7': | 11087 | '@esbuild/android-x64@0.27.7': |
| 9109 | optional: true | 11088 | optional: true |
| 9110 | 11089 | ||
| 11090 | '@esbuild/darwin-arm64@0.25.12': | ||
| 11091 | optional: true | ||
| 11092 | |||
| 9111 | '@esbuild/darwin-arm64@0.27.7': | 11093 | '@esbuild/darwin-arm64@0.27.7': |
| 9112 | optional: true | 11094 | optional: true |
| 9113 | 11095 | ||
| 11096 | '@esbuild/darwin-x64@0.25.12': | ||
| 11097 | optional: true | ||
| 11098 | |||
| 9114 | '@esbuild/darwin-x64@0.27.7': | 11099 | '@esbuild/darwin-x64@0.27.7': |
| 9115 | optional: true | 11100 | optional: true |
| 9116 | 11101 | ||
| 11102 | '@esbuild/freebsd-arm64@0.25.12': | ||
| 11103 | optional: true | ||
| 11104 | |||
| 9117 | '@esbuild/freebsd-arm64@0.27.7': | 11105 | '@esbuild/freebsd-arm64@0.27.7': |
| 9118 | optional: true | 11106 | optional: true |
| 9119 | 11107 | ||
| 11108 | '@esbuild/freebsd-x64@0.25.12': | ||
| 11109 | optional: true | ||
| 11110 | |||
| 9120 | '@esbuild/freebsd-x64@0.27.7': | 11111 | '@esbuild/freebsd-x64@0.27.7': |
| 9121 | optional: true | 11112 | optional: true |
| 9122 | 11113 | ||
| 11114 | '@esbuild/linux-arm64@0.25.12': | ||
| 11115 | optional: true | ||
| 11116 | |||
| 9123 | '@esbuild/linux-arm64@0.27.7': | 11117 | '@esbuild/linux-arm64@0.27.7': |
| 9124 | optional: true | 11118 | optional: true |
| 9125 | 11119 | ||
| 11120 | '@esbuild/linux-arm@0.25.12': | ||
| 11121 | optional: true | ||
| 11122 | |||
| 9126 | '@esbuild/linux-arm@0.27.7': | 11123 | '@esbuild/linux-arm@0.27.7': |
| 9127 | optional: true | 11124 | optional: true |
| 9128 | 11125 | ||
| 11126 | '@esbuild/linux-ia32@0.25.12': | ||
| 11127 | optional: true | ||
| 11128 | |||
| 9129 | '@esbuild/linux-ia32@0.27.7': | 11129 | '@esbuild/linux-ia32@0.27.7': |
| 9130 | optional: true | 11130 | optional: true |
| 9131 | 11131 | ||
| 11132 | '@esbuild/linux-loong64@0.25.12': | ||
| 11133 | optional: true | ||
| 11134 | |||
| 9132 | '@esbuild/linux-loong64@0.27.7': | 11135 | '@esbuild/linux-loong64@0.27.7': |
| 9133 | optional: true | 11136 | optional: true |
| 9134 | 11137 | ||
| 11138 | '@esbuild/linux-mips64el@0.25.12': | ||
| 11139 | optional: true | ||
| 11140 | |||
| 9135 | '@esbuild/linux-mips64el@0.27.7': | 11141 | '@esbuild/linux-mips64el@0.27.7': |
| 9136 | optional: true | 11142 | optional: true |
| 9137 | 11143 | ||
| 11144 | '@esbuild/linux-ppc64@0.25.12': | ||
| 11145 | optional: true | ||
| 11146 | |||
| 9138 | '@esbuild/linux-ppc64@0.27.7': | 11147 | '@esbuild/linux-ppc64@0.27.7': |
| 9139 | optional: true | 11148 | optional: true |
| 9140 | 11149 | ||
| 11150 | '@esbuild/linux-riscv64@0.25.12': | ||
| 11151 | optional: true | ||
| 11152 | |||
| 9141 | '@esbuild/linux-riscv64@0.27.7': | 11153 | '@esbuild/linux-riscv64@0.27.7': |
| 9142 | optional: true | 11154 | optional: true |
| 9143 | 11155 | ||
| 11156 | '@esbuild/linux-s390x@0.25.12': | ||
| 11157 | optional: true | ||
| 11158 | |||
| 9144 | '@esbuild/linux-s390x@0.27.7': | 11159 | '@esbuild/linux-s390x@0.27.7': |
| 9145 | optional: true | 11160 | optional: true |
| 9146 | 11161 | ||
| 11162 | '@esbuild/linux-x64@0.25.12': | ||
| 11163 | optional: true | ||
| 11164 | |||
| 9147 | '@esbuild/linux-x64@0.27.7': | 11165 | '@esbuild/linux-x64@0.27.7': |
| 9148 | optional: true | 11166 | optional: true |
| 9149 | 11167 | ||
| 11168 | '@esbuild/netbsd-arm64@0.25.12': | ||
| 11169 | optional: true | ||
| 11170 | |||
| 9150 | '@esbuild/netbsd-arm64@0.27.7': | 11171 | '@esbuild/netbsd-arm64@0.27.7': |
| 9151 | optional: true | 11172 | optional: true |
| 9152 | 11173 | ||
| 11174 | '@esbuild/netbsd-x64@0.25.12': | ||
| 11175 | optional: true | ||
| 11176 | |||
| 9153 | '@esbuild/netbsd-x64@0.27.7': | 11177 | '@esbuild/netbsd-x64@0.27.7': |
| 9154 | optional: true | 11178 | optional: true |
| 9155 | 11179 | ||
| 11180 | '@esbuild/openbsd-arm64@0.25.12': | ||
| 11181 | optional: true | ||
| 11182 | |||
| 9156 | '@esbuild/openbsd-arm64@0.27.7': | 11183 | '@esbuild/openbsd-arm64@0.27.7': |
| 9157 | optional: true | 11184 | optional: true |
| 9158 | 11185 | ||
| 11186 | '@esbuild/openbsd-x64@0.25.12': | ||
| 11187 | optional: true | ||
| 11188 | |||
| 9159 | '@esbuild/openbsd-x64@0.27.7': | 11189 | '@esbuild/openbsd-x64@0.27.7': |
| 9160 | optional: true | 11190 | optional: true |
| 9161 | 11191 | ||
| 11192 | '@esbuild/openharmony-arm64@0.25.12': | ||
| 11193 | optional: true | ||
| 11194 | |||
| 9162 | '@esbuild/openharmony-arm64@0.27.7': | 11195 | '@esbuild/openharmony-arm64@0.27.7': |
| 9163 | optional: true | 11196 | optional: true |
| 9164 | 11197 | ||
| 11198 | '@esbuild/sunos-x64@0.25.12': | ||
| 11199 | optional: true | ||
| 11200 | |||
| 9165 | '@esbuild/sunos-x64@0.27.7': | 11201 | '@esbuild/sunos-x64@0.27.7': |
| 9166 | optional: true | 11202 | optional: true |
| 9167 | 11203 | ||
| 11204 | '@esbuild/win32-arm64@0.25.12': | ||
| 11205 | optional: true | ||
| 11206 | |||
| 9168 | '@esbuild/win32-arm64@0.27.7': | 11207 | '@esbuild/win32-arm64@0.27.7': |
| 9169 | optional: true | 11208 | optional: true |
| 9170 | 11209 | ||
| 11210 | '@esbuild/win32-ia32@0.25.12': | ||
| 11211 | optional: true | ||
| 11212 | |||
| 9171 | '@esbuild/win32-ia32@0.27.7': | 11213 | '@esbuild/win32-ia32@0.27.7': |
| 9172 | optional: true | 11214 | optional: true |
| 9173 | 11215 | ||
| 11216 | '@esbuild/win32-x64@0.25.12': | ||
| 11217 | optional: true | ||
| 11218 | |||
| 9174 | '@esbuild/win32-x64@0.27.7': | 11219 | '@esbuild/win32-x64@0.27.7': |
| 9175 | optional: true | 11220 | optional: true |
| 9176 | 11221 | ||
| ... | @@ -9178,7 +11223,7 @@ snapshots: | ... | @@ -9178,7 +11223,7 @@ snapshots: |
| 9178 | 11223 | ||
| 9179 | '@expo-google-fonts/material-symbols@0.4.38': {} | 11224 | '@expo-google-fonts/material-symbols@0.4.38': {} |
| 9180 | 11225 | ||
| 9181 | '@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)': | 11226 | '@expo/cli@56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': |
| 9182 | dependencies: | 11227 | dependencies: |
| 9183 | '@expo/code-signing-certificates': 0.0.6 | 11228 | '@expo/code-signing-certificates': 0.0.6 |
| 9184 | '@expo/config': 56.0.9(typescript@6.0.3) | 11229 | '@expo/config': 56.0.9(typescript@6.0.3) |
| ... | @@ -9188,9 +11233,9 @@ snapshots: | ... | @@ -9188,9 +11233,9 @@ snapshots: |
| 9188 | '@expo/image-utils': 0.10.1(typescript@6.0.3) | 11233 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| 9189 | '@expo/inline-modules': 0.0.10(typescript@6.0.3) | 11234 | '@expo/inline-modules': 0.0.10(typescript@6.0.3) |
| 9190 | '@expo/json-file': 10.2.0 | 11235 | '@expo/json-file': 10.2.0 |
| 9191 | '@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) | 11236 | '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 9192 | '@expo/metro': 56.0.0 | 11237 | '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 9193 | '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(expo@56.0.8)(typescript@6.0.3) | 11238 | '@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) |
| 9194 | '@expo/metro-file-map': 56.0.3 | 11239 | '@expo/metro-file-map': 56.0.3 |
| 9195 | '@expo/osascript': 2.6.0 | 11240 | '@expo/osascript': 2.6.0 |
| 9196 | '@expo/package-manager': 1.12.1 | 11241 | '@expo/package-manager': 1.12.1 |
| ... | @@ -9202,7 +11247,7 @@ snapshots: | ... | @@ -9202,7 +11247,7 @@ snapshots: |
| 9202 | '@expo/spawn-async': 1.8.0 | 11247 | '@expo/spawn-async': 1.8.0 |
| 9203 | '@expo/ws-tunnel': 1.0.6 | 11248 | '@expo/ws-tunnel': 1.0.6 |
| 9204 | '@expo/xcpretty': 4.4.4 | 11249 | '@expo/xcpretty': 4.4.4 |
| 9205 | '@react-native/dev-middleware': 0.85.3 | 11250 | '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 9206 | accepts: 1.3.8 | 11251 | accepts: 1.3.8 |
| 9207 | arg: 5.0.2 | 11252 | arg: 5.0.2 |
| 9208 | bplist-creator: 0.1.0 | 11253 | bplist-creator: 0.1.0 |
| ... | @@ -9213,7 +11258,7 @@ snapshots: | ... | @@ -9213,7 +11258,7 @@ snapshots: |
| 9213 | connect: 3.7.0 | 11258 | connect: 3.7.0 |
| 9214 | debug: 4.4.3 | 11259 | debug: 4.4.3 |
| 9215 | dnssd-advertise: 1.1.4 | 11260 | dnssd-advertise: 1.1.4 |
| 9216 | 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) | 11261 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 9217 | expo-server: 56.0.4 | 11262 | expo-server: 56.0.4 |
| 9218 | fetch-nodeshim: 0.4.10 | 11263 | fetch-nodeshim: 0.4.10 |
| 9219 | getenv: 2.0.0 | 11264 | getenv: 2.0.0 |
| ... | @@ -9236,11 +11281,11 @@ snapshots: | ... | @@ -9236,11 +11281,11 @@ snapshots: |
| 9236 | terminal-link: 2.1.1 | 11281 | terminal-link: 2.1.1 |
| 9237 | toqr: 0.1.1 | 11282 | toqr: 0.1.1 |
| 9238 | wrap-ansi: 7.0.0 | 11283 | wrap-ansi: 7.0.0 |
| 9239 | ws: 8.21.0 | 11284 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 9240 | zod: 3.25.76 | 11285 | zod: 3.25.76 |
| 9241 | optionalDependencies: | 11286 | optionalDependencies: |
| 9242 | expo-router: 56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51) | 11287 | expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) |
| 9243 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 11288 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 9244 | transitivePeerDependencies: | 11289 | transitivePeerDependencies: |
| 9245 | - '@expo/dom-webview' | 11290 | - '@expo/dom-webview' |
| 9246 | - '@expo/metro-runtime' | 11291 | - '@expo/metro-runtime' |
| ... | @@ -9302,18 +11347,18 @@ snapshots: | ... | @@ -9302,18 +11347,18 @@ snapshots: |
| 9302 | transitivePeerDependencies: | 11347 | transitivePeerDependencies: |
| 9303 | - supports-color | 11348 | - supports-color |
| 9304 | 11349 | ||
| 9305 | '@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)': | 11350 | '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 9306 | dependencies: | 11351 | dependencies: |
| 9307 | chalk: 4.1.2 | 11352 | chalk: 4.1.2 |
| 9308 | optionalDependencies: | 11353 | optionalDependencies: |
| 9309 | react: 19.2.3 | 11354 | react: 19.2.3 |
| 9310 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 11355 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 9311 | 11356 | ||
| 9312 | '@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)': | 11357 | '@expo/dom-webview@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 9313 | dependencies: | 11358 | dependencies: |
| 9314 | 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) | 11359 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 9315 | react: 19.2.3 | 11360 | react: 19.2.3 |
| 9316 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 11361 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 9317 | 11362 | ||
| 9318 | '@expo/env@2.3.0': | 11363 | '@expo/env@2.3.0': |
| 9319 | dependencies: | 11364 | dependencies: |
| ... | @@ -9374,16 +11419,16 @@ snapshots: | ... | @@ -9374,16 +11419,16 @@ snapshots: |
| 9374 | - supports-color | 11419 | - supports-color |
| 9375 | - typescript | 11420 | - typescript |
| 9376 | 11421 | ||
| 9377 | '@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)': | 11422 | '@expo/log-box@56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 9378 | dependencies: | 11423 | dependencies: |
| 9379 | '@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) | 11424 | '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 9380 | anser: 1.4.10 | 11425 | anser: 1.4.10 |
| 9381 | 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) | 11426 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 9382 | react: 19.2.3 | 11427 | react: 19.2.3 |
| 9383 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 11428 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 9384 | stacktrace-parser: 0.1.11 | 11429 | stacktrace-parser: 0.1.11 |
| 9385 | 11430 | ||
| 9386 | '@expo/metro-config@56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(expo@56.0.8)(typescript@6.0.3)': | 11431 | '@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)': |
| 9387 | dependencies: | 11432 | dependencies: |
| 9388 | '@babel/code-frame': 7.29.7 | 11433 | '@babel/code-frame': 7.29.7 |
| 9389 | '@babel/core': 7.29.7 | 11434 | '@babel/core': 7.29.7 |
| ... | @@ -9391,7 +11436,7 @@ snapshots: | ... | @@ -9391,7 +11436,7 @@ snapshots: |
| 9391 | '@expo/config': 56.0.9(typescript@6.0.3) | 11436 | '@expo/config': 56.0.9(typescript@6.0.3) |
| 9392 | '@expo/env': 2.3.0 | 11437 | '@expo/env': 2.3.0 |
| 9393 | '@expo/json-file': 10.2.0 | 11438 | '@expo/json-file': 10.2.0 |
| 9394 | '@expo/metro': 56.0.0 | 11439 | '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 9395 | '@expo/require-utils': 56.1.3(typescript@6.0.3) | 11440 | '@expo/require-utils': 56.1.3(typescript@6.0.3) |
| 9396 | '@expo/spawn-async': 1.8.0 | 11441 | '@expo/spawn-async': 1.8.0 |
| 9397 | '@jridgewell/gen-mapping': 0.3.13 | 11442 | '@jridgewell/gen-mapping': 0.3.13 |
| ... | @@ -9410,7 +11455,7 @@ snapshots: | ... | @@ -9410,7 +11455,7 @@ snapshots: |
| 9410 | postcss: 8.5.15 | 11455 | postcss: 8.5.15 |
| 9411 | resolve-from: 5.0.0 | 11456 | resolve-from: 5.0.0 |
| 9412 | optionalDependencies: | 11457 | optionalDependencies: |
| 9413 | 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) | 11458 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 9414 | transitivePeerDependencies: | 11459 | transitivePeerDependencies: |
| 9415 | - bufferutil | 11460 | - bufferutil |
| 9416 | - supports-color | 11461 | - supports-color |
| ... | @@ -9428,26 +11473,26 @@ snapshots: | ... | @@ -9428,26 +11473,26 @@ snapshots: |
| 9428 | transitivePeerDependencies: | 11473 | transitivePeerDependencies: |
| 9429 | - supports-color | 11474 | - supports-color |
| 9430 | 11475 | ||
| 9431 | '@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)': | 11476 | '@expo/metro-runtime@56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 9432 | dependencies: | 11477 | dependencies: |
| 9433 | '@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) | 11478 | '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 9434 | anser: 1.4.10 | 11479 | anser: 1.4.10 |
| 9435 | 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) | 11480 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 9436 | pretty-format: 29.7.0 | 11481 | pretty-format: 29.7.0 |
| 9437 | react: 19.2.3 | 11482 | react: 19.2.3 |
| 9438 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 11483 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 9439 | stacktrace-parser: 0.1.11 | 11484 | stacktrace-parser: 0.1.11 |
| 9440 | whatwg-fetch: 3.6.20 | 11485 | whatwg-fetch: 3.6.20 |
| 9441 | optionalDependencies: | 11486 | optionalDependencies: |
| 9442 | react-dom: 19.2.3(react@19.2.3) | 11487 | react-dom: 19.2.3(react@19.2.3) |
| 9443 | 11488 | ||
| 9444 | '@expo/metro@56.0.0': | 11489 | '@expo/metro@56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': |
| 9445 | dependencies: | 11490 | dependencies: |
| 9446 | metro: 0.84.4 | 11491 | metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 9447 | metro-babel-transformer: 0.84.4 | 11492 | metro-babel-transformer: 0.84.4 |
| 9448 | metro-cache: 0.84.4 | 11493 | metro-cache: 0.84.4 |
| 9449 | metro-cache-key: 0.84.4 | 11494 | metro-cache-key: 0.84.4 |
| 9450 | metro-config: 0.84.4 | 11495 | metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 9451 | metro-core: 0.84.4 | 11496 | metro-core: 0.84.4 |
| 9452 | metro-file-map: 0.84.4 | 11497 | metro-file-map: 0.84.4 |
| 9453 | metro-minify-terser: 0.84.4 | 11498 | metro-minify-terser: 0.84.4 |
| ... | @@ -9456,7 +11501,7 @@ snapshots: | ... | @@ -9456,7 +11501,7 @@ snapshots: |
| 9456 | metro-source-map: 0.84.4 | 11501 | metro-source-map: 0.84.4 |
| 9457 | metro-symbolicate: 0.84.4 | 11502 | metro-symbolicate: 0.84.4 |
| 9458 | metro-transform-plugins: 0.84.4 | 11503 | metro-transform-plugins: 0.84.4 |
| 9459 | metro-transform-worker: 0.84.4 | 11504 | metro-transform-worker: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 9460 | transitivePeerDependencies: | 11505 | transitivePeerDependencies: |
| 9461 | - bufferutil | 11506 | - bufferutil |
| 9462 | - supports-color | 11507 | - supports-color |
| ... | @@ -9510,14 +11555,14 @@ snapshots: | ... | @@ -9510,14 +11555,14 @@ snapshots: |
| 9510 | '@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)': | 11555 | '@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)': |
| 9511 | dependencies: | 11556 | dependencies: |
| 9512 | debug: 4.4.3 | 11557 | debug: 4.4.3 |
| 9513 | 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) | 11558 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 9514 | 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)) | 11559 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 9515 | 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) | 11560 | expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 9516 | expo-server: 56.0.4 | 11561 | expo-server: 56.0.4 |
| 9517 | react: 19.2.3 | 11562 | react: 19.2.3 |
| 9518 | optionalDependencies: | 11563 | optionalDependencies: |
| 9519 | '@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) | 11564 | '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 9520 | expo-router: 56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51) | 11565 | expo-router: 56.2.8(c021de11d02907bd585610408f5252e8) |
| 9521 | react-dom: 19.2.3(react@19.2.3) | 11566 | react-dom: 19.2.3(react@19.2.3) |
| 9522 | transitivePeerDependencies: | 11567 | transitivePeerDependencies: |
| 9523 | - supports-color | 11568 | - supports-color |
| ... | @@ -9532,18 +11577,18 @@ snapshots: | ... | @@ -9532,18 +11577,18 @@ snapshots: |
| 9532 | 11577 | ||
| 9533 | '@expo/sudo-prompt@9.3.2': {} | 11578 | '@expo/sudo-prompt@9.3.2': {} |
| 9534 | 11579 | ||
| 9535 | '@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)': | 11580 | '@expo/ui@56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584)': |
| 9536 | dependencies: | 11581 | dependencies: |
| 9537 | 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) | 11582 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 9538 | react: 19.2.3 | 11583 | react: 19.2.3 |
| 9539 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 11584 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 9540 | sf-symbols-typescript: 2.2.0 | 11585 | sf-symbols-typescript: 2.2.0 |
| 9541 | 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) | 11586 | 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) |
| 9542 | optionalDependencies: | 11587 | optionalDependencies: |
| 9543 | '@babel/core': 7.29.7 | 11588 | '@babel/core': 7.29.7 |
| 9544 | react-dom: 19.2.3(react@19.2.3) | 11589 | react-dom: 19.2.3(react@19.2.3) |
| 9545 | 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) | 11590 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 9546 | 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) | 11591 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 9547 | transitivePeerDependencies: | 11592 | transitivePeerDependencies: |
| 9548 | - '@types/react' | 11593 | - '@types/react' |
| 9549 | - '@types/react-dom' | 11594 | - '@types/react-dom' |
| ... | @@ -9757,13 +11802,17 @@ snapshots: | ... | @@ -9757,13 +11802,17 @@ snapshots: |
| 9757 | '@jridgewell/resolve-uri': 3.1.2 | 11802 | '@jridgewell/resolve-uri': 3.1.2 |
| 9758 | '@jridgewell/sourcemap-codec': 1.5.5 | 11803 | '@jridgewell/sourcemap-codec': 1.5.5 |
| 9759 | 11804 | ||
| 9760 | '@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)': | 11805 | '@js-temporal/polyfill@0.5.1': |
| 11806 | dependencies: | ||
| 11807 | jsbi: 4.3.2 | ||
| 11808 | |||
| 11809 | '@legendapp/list@3.0.0-beta.44(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | ||
| 9761 | dependencies: | 11810 | dependencies: |
| 9762 | react: 19.2.3 | 11811 | react: 19.2.3 |
| 9763 | use-sync-external-store: 1.6.0(react@19.2.3) | 11812 | use-sync-external-store: 1.6.0(react@19.2.3) |
| 9764 | optionalDependencies: | 11813 | optionalDependencies: |
| 9765 | react-dom: 19.2.3(react@19.2.3) | 11814 | react-dom: 19.2.3(react@19.2.3) |
| 9766 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 11815 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 9767 | 11816 | ||
| 9768 | '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 11817 | '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 9769 | dependencies: | 11818 | dependencies: |
| ... | @@ -9931,6 +11980,64 @@ snapshots: | ... | @@ -9931,6 +11980,64 @@ snapshots: |
| 9931 | lexical: 0.41.0 | 11980 | lexical: 0.41.0 |
| 9932 | yjs: 13.6.31 | 11981 | yjs: 13.6.31 |
| 9933 | 11982 | ||
| 11983 | '@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': | ||
| 11984 | dependencies: | ||
| 11985 | '@libsql/core': 0.17.3 | ||
| 11986 | '@libsql/hrana-client': 0.10.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | ||
| 11987 | js-base64: 3.7.8 | ||
| 11988 | libsql: 0.5.29 | ||
| 11989 | promise-limit: 2.7.0 | ||
| 11990 | transitivePeerDependencies: | ||
| 11991 | - bufferutil | ||
| 11992 | - utf-8-validate | ||
| 11993 | |||
| 11994 | '@libsql/core@0.17.3': | ||
| 11995 | dependencies: | ||
| 11996 | js-base64: 3.7.8 | ||
| 11997 | |||
| 11998 | '@libsql/darwin-arm64@0.5.29': | ||
| 11999 | optional: true | ||
| 12000 | |||
| 12001 | '@libsql/darwin-x64@0.5.29': | ||
| 12002 | optional: true | ||
| 12003 | |||
| 12004 | '@libsql/hrana-client@0.10.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': | ||
| 12005 | dependencies: | ||
| 12006 | '@libsql/isomorphic-ws': 0.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) | ||
| 12007 | js-base64: 3.7.8 | ||
| 12008 | transitivePeerDependencies: | ||
| 12009 | - bufferutil | ||
| 12010 | - utf-8-validate | ||
| 12011 | |||
| 12012 | '@libsql/isomorphic-ws@0.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)': | ||
| 12013 | dependencies: | ||
| 12014 | '@types/ws': 8.18.1 | ||
| 12015 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | ||
| 12016 | transitivePeerDependencies: | ||
| 12017 | - bufferutil | ||
| 12018 | - utf-8-validate | ||
| 12019 | |||
| 12020 | '@libsql/linux-arm-gnueabihf@0.5.29': | ||
| 12021 | optional: true | ||
| 12022 | |||
| 12023 | '@libsql/linux-arm-musleabihf@0.5.29': | ||
| 12024 | optional: true | ||
| 12025 | |||
| 12026 | '@libsql/linux-arm64-gnu@0.5.29': | ||
| 12027 | optional: true | ||
| 12028 | |||
| 12029 | '@libsql/linux-arm64-musl@0.5.29': | ||
| 12030 | optional: true | ||
| 12031 | |||
| 12032 | '@libsql/linux-x64-gnu@0.5.29': | ||
| 12033 | optional: true | ||
| 12034 | |||
| 12035 | '@libsql/linux-x64-musl@0.5.29': | ||
| 12036 | optional: true | ||
| 12037 | |||
| 12038 | '@libsql/win32-x64-msvc@0.5.29': | ||
| 12039 | optional: true | ||
| 12040 | |||
| 9934 | '@malept/cross-spawn-promise@2.0.0': | 12041 | '@malept/cross-spawn-promise@2.0.0': |
| 9935 | dependencies: | 12042 | dependencies: |
| 9936 | cross-spawn: 7.0.6 | 12043 | cross-spawn: 7.0.6 |
| ... | @@ -10000,6 +12107,91 @@ snapshots: | ... | @@ -10000,6 +12107,91 @@ snapshots: |
| 10000 | '@tybys/wasm-util': 0.10.2 | 12107 | '@tybys/wasm-util': 0.10.2 |
| 10001 | optional: true | 12108 | optional: true |
| 10002 | 12109 | ||
| 12110 | '@neon-rs/load@0.0.4': {} | ||
| 12111 | |||
| 12112 | '@noble/curves@1.9.1': | ||
| 12113 | dependencies: | ||
| 12114 | '@noble/hashes': 1.8.0 | ||
| 12115 | |||
| 12116 | '@noble/hashes@1.8.0': {} | ||
| 12117 | |||
| 12118 | '@nodable/entities@2.1.1': {} | ||
| 12119 | |||
| 12120 | '@nodelib/fs.scandir@2.1.5': | ||
| 12121 | dependencies: | ||
| 12122 | '@nodelib/fs.stat': 2.0.5 | ||
| 12123 | run-parallel: 1.2.0 | ||
| 12124 | |||
| 12125 | '@nodelib/fs.stat@2.0.5': {} | ||
| 12126 | |||
| 12127 | '@nodelib/fs.walk@1.2.8': | ||
| 12128 | dependencies: | ||
| 12129 | '@nodelib/fs.scandir': 2.1.5 | ||
| 12130 | fastq: 1.20.1 | ||
| 12131 | |||
| 12132 | '@octokit/auth-token@6.0.0': {} | ||
| 12133 | |||
| 12134 | '@octokit/core@7.0.6': | ||
| 12135 | dependencies: | ||
| 12136 | '@octokit/auth-token': 6.0.0 | ||
| 12137 | '@octokit/graphql': 9.0.3 | ||
| 12138 | '@octokit/request': 10.0.10 | ||
| 12139 | '@octokit/request-error': 7.1.0 | ||
| 12140 | '@octokit/types': 16.0.0 | ||
| 12141 | before-after-hook: 4.0.0 | ||
| 12142 | universal-user-agent: 7.0.3 | ||
| 12143 | |||
| 12144 | '@octokit/endpoint@11.0.3': | ||
| 12145 | dependencies: | ||
| 12146 | '@octokit/types': 16.0.0 | ||
| 12147 | universal-user-agent: 7.0.3 | ||
| 12148 | |||
| 12149 | '@octokit/graphql@9.0.3': | ||
| 12150 | dependencies: | ||
| 12151 | '@octokit/request': 10.0.10 | ||
| 12152 | '@octokit/types': 16.0.0 | ||
| 12153 | universal-user-agent: 7.0.3 | ||
| 12154 | |||
| 12155 | '@octokit/openapi-types@27.0.0': {} | ||
| 12156 | |||
| 12157 | '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': | ||
| 12158 | dependencies: | ||
| 12159 | '@octokit/core': 7.0.6 | ||
| 12160 | '@octokit/types': 16.0.0 | ||
| 12161 | |||
| 12162 | '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': | ||
| 12163 | dependencies: | ||
| 12164 | '@octokit/core': 7.0.6 | ||
| 12165 | |||
| 12166 | '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': | ||
| 12167 | dependencies: | ||
| 12168 | '@octokit/core': 7.0.6 | ||
| 12169 | '@octokit/types': 16.0.0 | ||
| 12170 | |||
| 12171 | '@octokit/request-error@7.1.0': | ||
| 12172 | dependencies: | ||
| 12173 | '@octokit/types': 16.0.0 | ||
| 12174 | |||
| 12175 | '@octokit/request@10.0.10': | ||
| 12176 | dependencies: | ||
| 12177 | '@octokit/endpoint': 11.0.3 | ||
| 12178 | '@octokit/request-error': 7.1.0 | ||
| 12179 | '@octokit/types': 16.0.0 | ||
| 12180 | content-type: 2.0.0 | ||
| 12181 | json-with-bigint: 3.5.8 | ||
| 12182 | universal-user-agent: 7.0.3 | ||
| 12183 | |||
| 12184 | '@octokit/rest@22.0.1': | ||
| 12185 | dependencies: | ||
| 12186 | '@octokit/core': 7.0.6 | ||
| 12187 | '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) | ||
| 12188 | '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) | ||
| 12189 | '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) | ||
| 12190 | |||
| 12191 | '@octokit/types@16.0.0': | ||
| 12192 | dependencies: | ||
| 12193 | '@octokit/openapi-types': 27.0.0 | ||
| 12194 | |||
| 10003 | '@open-draft/deferred-promise@2.2.0': {} | 12195 | '@open-draft/deferred-promise@2.2.0': {} |
| 10004 | 12196 | ||
| 10005 | '@open-draft/logger@0.3.0': | 12197 | '@open-draft/logger@0.3.0': |
| ... | @@ -10020,6 +12212,8 @@ snapshots: | ... | @@ -10020,6 +12212,8 @@ snapshots: |
| 10020 | '@oxc-project/types@0.127.0': | 12212 | '@oxc-project/types@0.127.0': |
| 10021 | optional: true | 12213 | optional: true |
| 10022 | 12214 | ||
| 12215 | '@oxc-project/types@0.130.0': {} | ||
| 12216 | |||
| 10023 | '@oxc-project/types@0.133.0': {} | 12217 | '@oxc-project/types@0.133.0': {} |
| 10024 | 12218 | ||
| 10025 | '@oxfmt/binding-android-arm-eabi@0.52.0': | 12219 | '@oxfmt/binding-android-arm-eabi@0.52.0': |
| ... | @@ -10387,15 +12581,15 @@ snapshots: | ... | @@ -10387,15 +12581,15 @@ snapshots: |
| 10387 | optionalDependencies: | 12581 | optionalDependencies: |
| 10388 | '@types/react': 19.2.16 | 12582 | '@types/react': 19.2.16 |
| 10389 | 12583 | ||
| 10390 | '@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)': | 12584 | '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 10391 | dependencies: | 12585 | dependencies: |
| 10392 | react: 19.2.3 | 12586 | react: 19.2.3 |
| 10393 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 12587 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 10394 | 12588 | ||
| 10395 | '@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)': | 12589 | '@react-native-menu/menu@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 10396 | dependencies: | 12590 | dependencies: |
| 10397 | react: 19.2.3 | 12591 | react: 19.2.3 |
| 10398 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 12592 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 10399 | 12593 | ||
| 10400 | '@react-native/assets-registry@0.85.3': {} | 12594 | '@react-native/assets-registry@0.85.3': {} |
| 10401 | 12595 | ||
| ... | @@ -10455,13 +12649,13 @@ snapshots: | ... | @@ -10455,13 +12649,13 @@ snapshots: |
| 10455 | tinyglobby: 0.2.17 | 12649 | tinyglobby: 0.2.17 |
| 10456 | yargs: 17.7.2 | 12650 | yargs: 17.7.2 |
| 10457 | 12651 | ||
| 10458 | '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))': | 12652 | '@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)': |
| 10459 | dependencies: | 12653 | dependencies: |
| 10460 | '@react-native/dev-middleware': 0.85.3 | 12654 | '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 10461 | debug: 4.4.3 | 12655 | debug: 4.4.3 |
| 10462 | invariant: 2.2.4 | 12656 | invariant: 2.2.4 |
| 10463 | metro: 0.84.4 | 12657 | metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 10464 | metro-config: 0.84.4 | 12658 | metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 10465 | metro-core: 0.84.4 | 12659 | metro-core: 0.84.4 |
| 10466 | semver: 7.8.1 | 12660 | semver: 7.8.1 |
| 10467 | optionalDependencies: | 12661 | optionalDependencies: |
| ... | @@ -10481,7 +12675,7 @@ snapshots: | ... | @@ -10481,7 +12675,7 @@ snapshots: |
| 10481 | transitivePeerDependencies: | 12675 | transitivePeerDependencies: |
| 10482 | - supports-color | 12676 | - supports-color |
| 10483 | 12677 | ||
| 10484 | '@react-native/dev-middleware@0.85.3': | 12678 | '@react-native/dev-middleware@0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)': |
| 10485 | dependencies: | 12679 | dependencies: |
| 10486 | '@isaacs/ttlcache': 1.4.1 | 12680 | '@isaacs/ttlcache': 1.4.1 |
| 10487 | '@react-native/debugger-frontend': 0.85.3 | 12681 | '@react-native/debugger-frontend': 0.85.3 |
| ... | @@ -10494,7 +12688,7 @@ snapshots: | ... | @@ -10494,7 +12688,7 @@ snapshots: |
| 10494 | nullthrows: 1.1.1 | 12688 | nullthrows: 1.1.1 |
| 10495 | open: 7.4.2 | 12689 | open: 7.4.2 |
| 10496 | serve-static: 1.16.3 | 12690 | serve-static: 1.16.3 |
| 10497 | ws: 7.5.11 | 12691 | ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 10498 | transitivePeerDependencies: | 12692 | transitivePeerDependencies: |
| 10499 | - bufferutil | 12693 | - bufferutil |
| 10500 | - supports-color | 12694 | - supports-color |
| ... | @@ -10517,94 +12711,128 @@ snapshots: | ... | @@ -10517,94 +12711,128 @@ snapshots: |
| 10517 | dependencies: | 12711 | dependencies: |
| 10518 | '@react-native/js-polyfills': 0.85.3 | 12712 | '@react-native/js-polyfills': 0.85.3 |
| 10519 | '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) | 12713 | '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) |
| 10520 | metro-config: 0.84.4 | 12714 | metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 10521 | metro-runtime: 0.84.4 | 12715 | metro-runtime: 0.84.4 |
| 10522 | transitivePeerDependencies: | 12716 | transitivePeerDependencies: |
| 10523 | - '@babel/core' | 12717 | - '@babel/core' |
| 10524 | - bufferutil | ||
| 10525 | - supports-color | 12718 | - supports-color |
| 10526 | - utf-8-validate | ||
| 10527 | 12719 | ||
| 10528 | '@react-native/normalize-colors@0.85.3': {} | 12720 | '@react-native/normalize-colors@0.85.3': {} |
| 10529 | 12721 | ||
| 10530 | '@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)': | 12722 | '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 10531 | dependencies: | 12723 | dependencies: |
| 10532 | invariant: 2.2.4 | 12724 | invariant: 2.2.4 |
| 10533 | nullthrows: 1.1.1 | 12725 | nullthrows: 1.1.1 |
| 10534 | react: 19.2.3 | 12726 | react: 19.2.3 |
| 10535 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 12727 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 10536 | optionalDependencies: | 12728 | optionalDependencies: |
| 10537 | '@types/react': 19.2.16 | 12729 | '@types/react': 19.2.16 |
| 10538 | 12730 | ||
| 10539 | '@rolldown/binding-android-arm64@1.0.0-rc.17': | 12731 | '@rolldown/binding-android-arm64@1.0.0-rc.17': |
| 10540 | optional: true | 12732 | optional: true |
| 10541 | 12733 | ||
| 12734 | '@rolldown/binding-android-arm64@1.0.1': | ||
| 12735 | optional: true | ||
| 12736 | |||
| 10542 | '@rolldown/binding-android-arm64@1.0.3': | 12737 | '@rolldown/binding-android-arm64@1.0.3': |
| 10543 | optional: true | 12738 | optional: true |
| 10544 | 12739 | ||
| 10545 | '@rolldown/binding-darwin-arm64@1.0.0-rc.17': | 12740 | '@rolldown/binding-darwin-arm64@1.0.0-rc.17': |
| 10546 | optional: true | 12741 | optional: true |
| 10547 | 12742 | ||
| 12743 | '@rolldown/binding-darwin-arm64@1.0.1': | ||
| 12744 | optional: true | ||
| 12745 | |||
| 10548 | '@rolldown/binding-darwin-arm64@1.0.3': | 12746 | '@rolldown/binding-darwin-arm64@1.0.3': |
| 10549 | optional: true | 12747 | optional: true |
| 10550 | 12748 | ||
| 10551 | '@rolldown/binding-darwin-x64@1.0.0-rc.17': | 12749 | '@rolldown/binding-darwin-x64@1.0.0-rc.17': |
| 10552 | optional: true | 12750 | optional: true |
| 10553 | 12751 | ||
| 12752 | '@rolldown/binding-darwin-x64@1.0.1': | ||
| 12753 | optional: true | ||
| 12754 | |||
| 10554 | '@rolldown/binding-darwin-x64@1.0.3': | 12755 | '@rolldown/binding-darwin-x64@1.0.3': |
| 10555 | optional: true | 12756 | optional: true |
| 10556 | 12757 | ||
| 10557 | '@rolldown/binding-freebsd-x64@1.0.0-rc.17': | 12758 | '@rolldown/binding-freebsd-x64@1.0.0-rc.17': |
| 10558 | optional: true | 12759 | optional: true |
| 10559 | 12760 | ||
| 12761 | '@rolldown/binding-freebsd-x64@1.0.1': | ||
| 12762 | optional: true | ||
| 12763 | |||
| 10560 | '@rolldown/binding-freebsd-x64@1.0.3': | 12764 | '@rolldown/binding-freebsd-x64@1.0.3': |
| 10561 | optional: true | 12765 | optional: true |
| 10562 | 12766 | ||
| 10563 | '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': | 12767 | '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': |
| 10564 | optional: true | 12768 | optional: true |
| 10565 | 12769 | ||
| 12770 | '@rolldown/binding-linux-arm-gnueabihf@1.0.1': | ||
| 12771 | optional: true | ||
| 12772 | |||
| 10566 | '@rolldown/binding-linux-arm-gnueabihf@1.0.3': | 12773 | '@rolldown/binding-linux-arm-gnueabihf@1.0.3': |
| 10567 | optional: true | 12774 | optional: true |
| 10568 | 12775 | ||
| 10569 | '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': | 12776 | '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': |
| 10570 | optional: true | 12777 | optional: true |
| 10571 | 12778 | ||
| 12779 | '@rolldown/binding-linux-arm64-gnu@1.0.1': | ||
| 12780 | optional: true | ||
| 12781 | |||
| 10572 | '@rolldown/binding-linux-arm64-gnu@1.0.3': | 12782 | '@rolldown/binding-linux-arm64-gnu@1.0.3': |
| 10573 | optional: true | 12783 | optional: true |
| 10574 | 12784 | ||
| 10575 | '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': | 12785 | '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': |
| 10576 | optional: true | 12786 | optional: true |
| 10577 | 12787 | ||
| 12788 | '@rolldown/binding-linux-arm64-musl@1.0.1': | ||
| 12789 | optional: true | ||
| 12790 | |||
| 10578 | '@rolldown/binding-linux-arm64-musl@1.0.3': | 12791 | '@rolldown/binding-linux-arm64-musl@1.0.3': |
| 10579 | optional: true | 12792 | optional: true |
| 10580 | 12793 | ||
| 10581 | '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': | 12794 | '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': |
| 10582 | optional: true | 12795 | optional: true |
| 10583 | 12796 | ||
| 12797 | '@rolldown/binding-linux-ppc64-gnu@1.0.1': | ||
| 12798 | optional: true | ||
| 12799 | |||
| 10584 | '@rolldown/binding-linux-ppc64-gnu@1.0.3': | 12800 | '@rolldown/binding-linux-ppc64-gnu@1.0.3': |
| 10585 | optional: true | 12801 | optional: true |
| 10586 | 12802 | ||
| 10587 | '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': | 12803 | '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': |
| 10588 | optional: true | 12804 | optional: true |
| 10589 | 12805 | ||
| 12806 | '@rolldown/binding-linux-s390x-gnu@1.0.1': | ||
| 12807 | optional: true | ||
| 12808 | |||
| 10590 | '@rolldown/binding-linux-s390x-gnu@1.0.3': | 12809 | '@rolldown/binding-linux-s390x-gnu@1.0.3': |
| 10591 | optional: true | 12810 | optional: true |
| 10592 | 12811 | ||
| 10593 | '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': | 12812 | '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': |
| 10594 | optional: true | 12813 | optional: true |
| 10595 | 12814 | ||
| 12815 | '@rolldown/binding-linux-x64-gnu@1.0.1': | ||
| 12816 | optional: true | ||
| 12817 | |||
| 10596 | '@rolldown/binding-linux-x64-gnu@1.0.3': | 12818 | '@rolldown/binding-linux-x64-gnu@1.0.3': |
| 10597 | optional: true | 12819 | optional: true |
| 10598 | 12820 | ||
| 10599 | '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': | 12821 | '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': |
| 10600 | optional: true | 12822 | optional: true |
| 10601 | 12823 | ||
| 12824 | '@rolldown/binding-linux-x64-musl@1.0.1': | ||
| 12825 | optional: true | ||
| 12826 | |||
| 10602 | '@rolldown/binding-linux-x64-musl@1.0.3': | 12827 | '@rolldown/binding-linux-x64-musl@1.0.3': |
| 10603 | optional: true | 12828 | optional: true |
| 10604 | 12829 | ||
| 10605 | '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': | 12830 | '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': |
| 10606 | optional: true | 12831 | optional: true |
| 10607 | 12832 | ||
| 12833 | '@rolldown/binding-openharmony-arm64@1.0.1': | ||
| 12834 | optional: true | ||
| 12835 | |||
| 10608 | '@rolldown/binding-openharmony-arm64@1.0.3': | 12836 | '@rolldown/binding-openharmony-arm64@1.0.3': |
| 10609 | optional: true | 12837 | optional: true |
| 10610 | 12838 | ||
| ... | @@ -10615,6 +12843,13 @@ snapshots: | ... | @@ -10615,6 +12843,13 @@ snapshots: |
| 10615 | '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) | 12843 | '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) |
| 10616 | optional: true | 12844 | optional: true |
| 10617 | 12845 | ||
| 12846 | '@rolldown/binding-wasm32-wasi@1.0.1': | ||
| 12847 | dependencies: | ||
| 12848 | '@emnapi/core': 1.10.0 | ||
| 12849 | '@emnapi/runtime': 1.10.0 | ||
| 12850 | '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) | ||
| 12851 | optional: true | ||
| 12852 | |||
| 10618 | '@rolldown/binding-wasm32-wasi@1.0.3': | 12853 | '@rolldown/binding-wasm32-wasi@1.0.3': |
| 10619 | dependencies: | 12854 | dependencies: |
| 10620 | '@emnapi/core': 1.10.0 | 12855 | '@emnapi/core': 1.10.0 |
| ... | @@ -10625,12 +12860,18 @@ snapshots: | ... | @@ -10625,12 +12860,18 @@ snapshots: |
| 10625 | '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': | 12860 | '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': |
| 10626 | optional: true | 12861 | optional: true |
| 10627 | 12862 | ||
| 12863 | '@rolldown/binding-win32-arm64-msvc@1.0.1': | ||
| 12864 | optional: true | ||
| 12865 | |||
| 10628 | '@rolldown/binding-win32-arm64-msvc@1.0.3': | 12866 | '@rolldown/binding-win32-arm64-msvc@1.0.3': |
| 10629 | optional: true | 12867 | optional: true |
| 10630 | 12868 | ||
| 10631 | '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': | 12869 | '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': |
| 10632 | optional: true | 12870 | optional: true |
| 10633 | 12871 | ||
| 12872 | '@rolldown/binding-win32-x64-msvc@1.0.1': | ||
| 12873 | optional: true | ||
| 12874 | |||
| 10634 | '@rolldown/binding-win32-x64-msvc@1.0.3': | 12875 | '@rolldown/binding-win32-x64-msvc@1.0.3': |
| 10635 | optional: true | 12876 | optional: true |
| 10636 | 12877 | ||
| ... | @@ -10812,8 +13053,79 @@ snapshots: | ... | @@ -10812,8 +13053,79 @@ snapshots: |
| 10812 | 13053 | ||
| 10813 | '@sindresorhus/is@4.6.0': {} | 13054 | '@sindresorhus/is@4.6.0': {} |
| 10814 | 13055 | ||
| 13056 | '@smithy/core@3.24.6': | ||
| 13057 | dependencies: | ||
| 13058 | '@aws-crypto/crc32': 5.2.0 | ||
| 13059 | '@smithy/types': 4.14.3 | ||
| 13060 | tslib: 2.8.1 | ||
| 13061 | |||
| 13062 | '@smithy/credential-provider-imds@4.3.8': | ||
| 13063 | dependencies: | ||
| 13064 | '@smithy/core': 3.24.6 | ||
| 13065 | '@smithy/types': 4.14.3 | ||
| 13066 | tslib: 2.8.1 | ||
| 13067 | |||
| 13068 | '@smithy/fetch-http-handler@5.4.6': | ||
| 13069 | dependencies: | ||
| 13070 | '@smithy/core': 3.24.6 | ||
| 13071 | '@smithy/types': 4.14.3 | ||
| 13072 | tslib: 2.8.1 | ||
| 13073 | |||
| 13074 | '@smithy/is-array-buffer@2.2.0': | ||
| 13075 | dependencies: | ||
| 13076 | tslib: 2.8.1 | ||
| 13077 | |||
| 13078 | '@smithy/node-config-provider@4.4.6': | ||
| 13079 | dependencies: | ||
| 13080 | '@smithy/core': 3.24.6 | ||
| 13081 | tslib: 2.8.1 | ||
| 13082 | |||
| 13083 | '@smithy/node-http-handler@4.7.7': | ||
| 13084 | dependencies: | ||
| 13085 | '@smithy/core': 3.24.6 | ||
| 13086 | '@smithy/types': 4.14.3 | ||
| 13087 | tslib: 2.8.1 | ||
| 13088 | |||
| 13089 | '@smithy/shared-ini-file-loader@4.5.6': | ||
| 13090 | dependencies: | ||
| 13091 | '@smithy/core': 3.24.6 | ||
| 13092 | tslib: 2.8.1 | ||
| 13093 | |||
| 13094 | '@smithy/signature-v4@5.4.6': | ||
| 13095 | dependencies: | ||
| 13096 | '@smithy/core': 3.24.6 | ||
| 13097 | '@smithy/types': 4.14.3 | ||
| 13098 | tslib: 2.8.1 | ||
| 13099 | |||
| 13100 | '@smithy/types@4.14.3': | ||
| 13101 | dependencies: | ||
| 13102 | tslib: 2.8.1 | ||
| 13103 | |||
| 13104 | '@smithy/util-base64@4.4.6': | ||
| 13105 | dependencies: | ||
| 13106 | '@smithy/core': 3.24.6 | ||
| 13107 | tslib: 2.8.1 | ||
| 13108 | |||
| 13109 | '@smithy/util-buffer-from@2.2.0': | ||
| 13110 | dependencies: | ||
| 13111 | '@smithy/is-array-buffer': 2.2.0 | ||
| 13112 | tslib: 2.8.1 | ||
| 13113 | |||
| 13114 | '@smithy/util-utf8@2.3.0': | ||
| 13115 | dependencies: | ||
| 13116 | '@smithy/util-buffer-from': 2.2.0 | ||
| 13117 | tslib: 2.8.1 | ||
| 13118 | |||
| 13119 | '@stablelib/base64@1.0.1': {} | ||
| 13120 | |||
| 10815 | '@standard-schema/spec@1.1.0': {} | 13121 | '@standard-schema/spec@1.1.0': {} |
| 10816 | 13122 | ||
| 13123 | '@stripe/stripe-js@5.6.0': {} | ||
| 13124 | |||
| 13125 | '@swc/helpers@0.5.21': | ||
| 13126 | dependencies: | ||
| 13127 | tslib: 2.8.1 | ||
| 13128 | |||
| 10817 | '@szmarczak/http-timer@4.0.6': | 13129 | '@szmarczak/http-timer@4.0.6': |
| 10818 | dependencies: | 13130 | dependencies: |
| 10819 | defer-to-connect: 2.0.1 | 13131 | defer-to-connect: 2.0.1 |
| ... | @@ -11089,6 +13401,8 @@ snapshots: | ... | @@ -11089,6 +13401,8 @@ snapshots: |
| 11089 | 13401 | ||
| 11090 | '@types/aria-query@5.0.4': {} | 13402 | '@types/aria-query@5.0.4': {} |
| 11091 | 13403 | ||
| 13404 | '@types/aws-lambda@8.10.161': {} | ||
| 13405 | |||
| 11092 | '@types/babel__core@7.20.5': | 13406 | '@types/babel__core@7.20.5': |
| 11093 | dependencies: | 13407 | dependencies: |
| 11094 | '@babel/parser': 7.29.7 | 13408 | '@babel/parser': 7.29.7 |
| ... | @@ -11329,7 +13643,7 @@ snapshots: | ... | @@ -11329,7 +13643,7 @@ snapshots: |
| 11329 | '@voidzero-dev/vite-plus-linux-x64-musl@0.1.24': | 13643 | '@voidzero-dev/vite-plus-linux-x64-musl@0.1.24': |
| 11330 | optional: true | 13644 | optional: true |
| 11331 | 13645 | ||
| 11332 | '@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)': | 13646 | '@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)': |
| 11333 | dependencies: | 13647 | dependencies: |
| 11334 | '@standard-schema/spec': 1.1.0 | 13648 | '@standard-schema/spec': 1.1.0 |
| 11335 | '@types/chai': 5.2.3 | 13649 | '@types/chai': 5.2.3 |
| ... | @@ -11344,7 +13658,7 @@ snapshots: | ... | @@ -11344,7 +13658,7 @@ snapshots: |
| 11344 | tinyexec: 1.2.4 | 13658 | tinyexec: 1.2.4 |
| 11345 | tinyglobby: 0.2.17 | 13659 | tinyglobby: 0.2.17 |
| 11346 | 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)' | 13660 | 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)' |
| 11347 | ws: 8.21.0 | 13661 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 11348 | optionalDependencies: | 13662 | optionalDependencies: |
| 11349 | '@types/node': 24.12.4 | 13663 | '@types/node': 24.12.4 |
| 11350 | transitivePeerDependencies: | 13664 | transitivePeerDependencies: |
| ... | @@ -11433,6 +13747,12 @@ snapshots: | ... | @@ -11433,6 +13747,12 @@ snapshots: |
| 11433 | 13747 | ||
| 11434 | '@xterm/xterm@6.0.0': {} | 13748 | '@xterm/xterm@6.0.0': {} |
| 11435 | 13749 | ||
| 13750 | '@zxcvbn-ts/core@3.0.4': | ||
| 13751 | dependencies: | ||
| 13752 | fastest-levenshtein: 1.0.16 | ||
| 13753 | |||
| 13754 | '@zxcvbn-ts/language-common@3.0.4': {} | ||
| 13755 | |||
| 11436 | abbrev@4.0.0: {} | 13756 | abbrev@4.0.0: {} |
| 11437 | 13757 | ||
| 11438 | abort-controller@3.0.0: | 13758 | abort-controller@3.0.0: |
| ... | @@ -11479,16 +13799,80 @@ snapshots: | ... | @@ -11479,16 +13799,80 @@ snapshots: |
| 11479 | json-schema-traverse: 1.0.0 | 13799 | json-schema-traverse: 1.0.0 |
| 11480 | require-from-string: 2.0.2 | 13800 | require-from-string: 2.0.2 |
| 11481 | 13801 | ||
| 13802 | alchemy@https://pkg.ing/alchemy/8b9c241(9558be4f034193caa013885b5f553eec): | ||
| 13803 | dependencies: | ||
| 13804 | '@alchemy.run/node-utils': 0.0.4 | ||
| 13805 | '@aws-sdk/credential-providers': 3.1062.0 | ||
| 13806 | '@clack/prompts': 0.11.0 | ||
| 13807 | '@distilled.cloud/aws': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 13808 | '@distilled.cloud/axiom': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 13809 | '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 13810 | '@distilled.cloud/cloudflare-rolldown-plugin': 0.10.5(rolldown@1.0.1)(workerd@1.20260526.1) | ||
| 13811 | '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 13812 | '@distilled.cloud/cloudflare-vite-plugin': 0.10.5(874b2206c1318ea354f257cb5515510e) | ||
| 13813 | '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 13814 | '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 13815 | '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 13816 | '@effect/vitest': 4.0.0-beta.78(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 13817 | '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) | ||
| 13818 | '@octokit/rest': 22.0.1 | ||
| 13819 | '@smithy/node-config-provider': 4.4.6 | ||
| 13820 | '@smithy/shared-ini-file-loader': 4.5.6 | ||
| 13821 | '@smithy/types': 4.14.3 | ||
| 13822 | '@types/aws-lambda': 8.10.161 | ||
| 13823 | aws4fetch: 1.0.20 | ||
| 13824 | capnweb: 0.6.1 | ||
| 13825 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) | ||
| 13826 | fast-glob: 3.3.3 | ||
| 13827 | fast-xml-parser: 5.8.0 | ||
| 13828 | 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) | ||
| 13829 | jszip: 3.10.1 | ||
| 13830 | libsodium-wrappers: 0.8.4 | ||
| 13831 | magic-string: 0.30.21 | ||
| 13832 | mysql2: 3.22.4(@types/node@24.12.4) | ||
| 13833 | pathe: 2.0.3 | ||
| 13834 | pg: 8.21.0 | ||
| 13835 | picomatch: 4.0.4 | ||
| 13836 | react: 19.2.6 | ||
| 13837 | rolldown: 1.0.1 | ||
| 13838 | undici: 7.27.1 | ||
| 13839 | yaml: 2.9.0 | ||
| 13840 | optionalDependencies: | ||
| 13841 | '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) | ||
| 13842 | '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) | ||
| 13843 | '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 13844 | drizzle-kit: 1.0.0-rc.3 | ||
| 13845 | drizzle-orm: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) | ||
| 13846 | 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)' | ||
| 13847 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | ||
| 13848 | transitivePeerDependencies: | ||
| 13849 | - '@types/node' | ||
| 13850 | - '@types/react' | ||
| 13851 | - bufferutil | ||
| 13852 | - pg-native | ||
| 13853 | - react-devtools-core | ||
| 13854 | - utf-8-validate | ||
| 13855 | - vitest | ||
| 13856 | - workerd | ||
| 13857 | |||
| 13858 | alien-signals@2.0.6: {} | ||
| 13859 | |||
| 11482 | anser@1.4.10: {} | 13860 | anser@1.4.10: {} |
| 11483 | 13861 | ||
| 11484 | ansi-escapes@4.3.2: | 13862 | ansi-escapes@4.3.2: |
| 11485 | dependencies: | 13863 | dependencies: |
| 11486 | type-fest: 0.21.3 | 13864 | type-fest: 0.21.3 |
| 11487 | 13865 | ||
| 13866 | ansi-escapes@7.3.0: | ||
| 13867 | dependencies: | ||
| 13868 | environment: 1.1.0 | ||
| 13869 | |||
| 11488 | ansi-regex@4.1.1: {} | 13870 | ansi-regex@4.1.1: {} |
| 11489 | 13871 | ||
| 11490 | ansi-regex@5.0.1: {} | 13872 | ansi-regex@5.0.1: {} |
| 11491 | 13873 | ||
| 13874 | ansi-regex@6.2.2: {} | ||
| 13875 | |||
| 11492 | ansi-styles@3.2.1: | 13876 | ansi-styles@3.2.1: |
| 11493 | dependencies: | 13877 | dependencies: |
| 11494 | color-convert: 1.9.3 | 13878 | color-convert: 1.9.3 |
| ... | @@ -11499,6 +13883,8 @@ snapshots: | ... | @@ -11499,6 +13883,8 @@ snapshots: |
| 11499 | 13883 | ||
| 11500 | ansi-styles@5.2.0: {} | 13884 | ansi-styles@5.2.0: {} |
| 11501 | 13885 | ||
| 13886 | ansi-styles@6.2.3: {} | ||
| 13887 | |||
| 11502 | ansis@4.3.1: {} | 13888 | ansis@4.3.1: {} |
| 11503 | 13889 | ||
| 11504 | anymatch@3.1.3: | 13890 | anymatch@3.1.3: |
| ... | @@ -11579,7 +13965,7 @@ snapshots: | ... | @@ -11579,7 +13965,7 @@ snapshots: |
| 11579 | astral-regex@2.0.0: | 13965 | astral-regex@2.0.0: |
| 11580 | optional: true | 13966 | optional: true |
| 11581 | 13967 | ||
| 11582 | 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): | 13968 | 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): |
| 11583 | dependencies: | 13969 | dependencies: |
| 11584 | '@astrojs/compiler': 4.0.0 | 13970 | '@astrojs/compiler': 4.0.0 |
| 11585 | '@astrojs/internal-helpers': 0.10.0 | 13971 | '@astrojs/internal-helpers': 0.10.0 |
| ... | @@ -11629,7 +14015,7 @@ snapshots: | ... | @@ -11629,7 +14015,7 @@ snapshots: |
| 11629 | ultrahtml: 1.6.0 | 14015 | ultrahtml: 1.6.0 |
| 11630 | unifont: 0.7.4 | 14016 | unifont: 0.7.4 |
| 11631 | unist-util-visit: 5.1.0 | 14017 | unist-util-visit: 5.1.0 |
| 11632 | unstorage: 1.17.5(ioredis@5.11.0) | 14018 | unstorage: 1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0) |
| 11633 | vfile: 6.0.3 | 14019 | vfile: 6.0.3 |
| 11634 | 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)' | 14020 | 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)' |
| 11635 | 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)) | 14021 | 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 +14073,12 @@ snapshots: | ... | @@ -11687,6 +14073,12 @@ snapshots: |
| 11687 | 14073 | ||
| 11688 | at-least-node@1.0.0: {} | 14074 | at-least-node@1.0.0: {} |
| 11689 | 14075 | ||
| 14076 | auto-bind@5.0.1: {} | ||
| 14077 | |||
| 14078 | aws-ssl-profiles@1.1.2: {} | ||
| 14079 | |||
| 14080 | aws4fetch@1.0.20: {} | ||
| 14081 | |||
| 11690 | axobject-query@4.1.0: {} | 14082 | axobject-query@4.1.0: {} |
| 11691 | 14083 | ||
| 11692 | babel-dead-code-elimination@1.0.12: | 14084 | babel-dead-code-elimination@1.0.12: |
| ... | @@ -11738,7 +14130,7 @@ snapshots: | ... | @@ -11738,7 +14130,7 @@ snapshots: |
| 11738 | transitivePeerDependencies: | 14130 | transitivePeerDependencies: |
| 11739 | - '@babel/core' | 14131 | - '@babel/core' |
| 11740 | 14132 | ||
| 11741 | 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): | 14133 | 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): |
| 11742 | dependencies: | 14134 | dependencies: |
| 11743 | '@babel/generator': 7.29.7 | 14135 | '@babel/generator': 7.29.7 |
| 11744 | '@babel/helper-module-imports': 7.29.7 | 14136 | '@babel/helper-module-imports': 7.29.7 |
| ... | @@ -11785,11 +14177,14 @@ snapshots: | ... | @@ -11785,11 +14177,14 @@ snapshots: |
| 11785 | react-refresh: 0.14.2 | 14177 | react-refresh: 0.14.2 |
| 11786 | optionalDependencies: | 14178 | optionalDependencies: |
| 11787 | '@babel/runtime': 7.29.7 | 14179 | '@babel/runtime': 7.29.7 |
| 11788 | 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) | 14180 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 14181 | expo-widgets: 56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584) | ||
| 11789 | transitivePeerDependencies: | 14182 | transitivePeerDependencies: |
| 11790 | - '@babel/core' | 14183 | - '@babel/core' |
| 11791 | - supports-color | 14184 | - supports-color |
| 11792 | 14185 | ||
| 14186 | badgin@1.2.3: {} | ||
| 14187 | |||
| 11793 | bail@2.0.2: {} | 14188 | bail@2.0.2: {} |
| 11794 | 14189 | ||
| 11795 | balanced-match@1.0.2: {} | 14190 | balanced-match@1.0.2: {} |
| ... | @@ -11802,10 +14197,14 @@ snapshots: | ... | @@ -11802,10 +14197,14 @@ snapshots: |
| 11802 | transitivePeerDependencies: | 14197 | transitivePeerDependencies: |
| 11803 | - '@types/emscripten' | 14198 | - '@types/emscripten' |
| 11804 | 14199 | ||
| 14200 | base-64@1.0.0: {} | ||
| 14201 | |||
| 11805 | base64-js@1.5.1: {} | 14202 | base64-js@1.5.1: {} |
| 11806 | 14203 | ||
| 11807 | baseline-browser-mapping@2.10.33: {} | 14204 | baseline-browser-mapping@2.10.33: {} |
| 11808 | 14205 | ||
| 14206 | before-after-hook@4.0.0: {} | ||
| 14207 | |||
| 11809 | big-integer@1.6.52: {} | 14208 | big-integer@1.6.52: {} |
| 11810 | 14209 | ||
| 11811 | body-parser@2.2.2: | 14210 | body-parser@2.2.2: |
| ... | @@ -11827,6 +14226,8 @@ snapshots: | ... | @@ -11827,6 +14226,8 @@ snapshots: |
| 11827 | boolean@3.2.0: | 14226 | boolean@3.2.0: |
| 11828 | optional: true | 14227 | optional: true |
| 11829 | 14228 | ||
| 14229 | bowser@2.14.1: {} | ||
| 14230 | |||
| 11830 | bplist-creator@0.1.0: | 14231 | bplist-creator@0.1.0: |
| 11831 | dependencies: | 14232 | dependencies: |
| 11832 | stream-buffers: 2.2.0 | 14233 | stream-buffers: 2.2.0 |
| ... | @@ -11856,6 +14257,10 @@ snapshots: | ... | @@ -11856,6 +14257,10 @@ snapshots: |
| 11856 | dependencies: | 14257 | dependencies: |
| 11857 | fill-range: 7.1.1 | 14258 | fill-range: 7.1.1 |
| 11858 | 14259 | ||
| 14260 | browser-tabs-lock@1.3.0: | ||
| 14261 | dependencies: | ||
| 14262 | lodash: 4.18.1 | ||
| 14263 | |||
| 11859 | browserslist@4.28.2: | 14264 | browserslist@4.28.2: |
| 11860 | dependencies: | 14265 | dependencies: |
| 11861 | baseline-browser-mapping: 2.10.33 | 14266 | baseline-browser-mapping: 2.10.33 |
| ... | @@ -11876,6 +14281,10 @@ snapshots: | ... | @@ -11876,6 +14281,10 @@ snapshots: |
| 11876 | dependencies: | 14281 | dependencies: |
| 11877 | base64-js: 1.5.1 | 14282 | base64-js: 1.5.1 |
| 11878 | ieee754: 1.2.1 | 14283 | ieee754: 1.2.1 |
| 14284 | |||
| 14285 | bufferutil@4.1.0: | ||
| 14286 | dependencies: | ||
| 14287 | node-gyp-build: 4.8.4 | ||
| 11879 | optional: true | 14288 | optional: true |
| 11880 | 14289 | ||
| 11881 | builder-util-runtime@9.5.1: | 14290 | builder-util-runtime@9.5.1: |
| ... | @@ -11938,6 +14347,10 @@ snapshots: | ... | @@ -11938,6 +14347,10 @@ snapshots: |
| 11938 | 14347 | ||
| 11939 | caniuse-lite@1.0.30001793: {} | 14348 | caniuse-lite@1.0.30001793: {} |
| 11940 | 14349 | ||
| 14350 | capnweb@0.6.1: {} | ||
| 14351 | |||
| 14352 | capnweb@0.7.0: {} | ||
| 14353 | |||
| 11941 | ccount@2.0.1: {} | 14354 | ccount@2.0.1: {} |
| 11942 | 14355 | ||
| 11943 | chalk@2.4.2: | 14356 | chalk@2.4.2: |
| ... | @@ -11951,6 +14364,8 @@ snapshots: | ... | @@ -11951,6 +14364,8 @@ snapshots: |
| 11951 | ansi-styles: 4.3.0 | 14364 | ansi-styles: 4.3.0 |
| 11952 | supports-color: 7.2.0 | 14365 | supports-color: 7.2.0 |
| 11953 | 14366 | ||
| 14367 | chalk@5.6.2: {} | ||
| 14368 | |||
| 11954 | character-entities-html4@2.1.0: {} | 14369 | character-entities-html4@2.1.0: {} |
| 11955 | 14370 | ||
| 11956 | character-entities-legacy@3.0.0: {} | 14371 | character-entities-legacy@3.0.0: {} |
| ... | @@ -12002,10 +14417,16 @@ snapshots: | ... | @@ -12002,10 +14417,16 @@ snapshots: |
| 12002 | dependencies: | 14417 | dependencies: |
| 12003 | clsx: 2.1.1 | 14418 | clsx: 2.1.1 |
| 12004 | 14419 | ||
| 14420 | cli-boxes@3.0.0: {} | ||
| 14421 | |||
| 12005 | cli-cursor@2.1.0: | 14422 | cli-cursor@2.1.0: |
| 12006 | dependencies: | 14423 | dependencies: |
| 12007 | restore-cursor: 2.0.0 | 14424 | restore-cursor: 2.0.0 |
| 12008 | 14425 | ||
| 14426 | cli-cursor@4.0.0: | ||
| 14427 | dependencies: | ||
| 14428 | restore-cursor: 4.0.0 | ||
| 14429 | |||
| 12009 | cli-spinners@2.9.2: {} | 14430 | cli-spinners@2.9.2: {} |
| 12010 | 14431 | ||
| 12011 | cli-truncate@2.1.0: | 14432 | cli-truncate@2.1.0: |
| ... | @@ -12014,6 +14435,11 @@ snapshots: | ... | @@ -12014,6 +14435,11 @@ snapshots: |
| 12014 | string-width: 4.2.3 | 14435 | string-width: 4.2.3 |
| 12015 | optional: true | 14436 | optional: true |
| 12016 | 14437 | ||
| 14438 | cli-truncate@5.2.0: | ||
| 14439 | dependencies: | ||
| 14440 | slice-ansi: 8.0.0 | ||
| 14441 | string-width: 8.2.1 | ||
| 14442 | |||
| 12017 | cli-width@4.1.0: {} | 14443 | cli-width@4.1.0: {} |
| 12018 | 14444 | ||
| 12019 | client-only@0.0.1: {} | 14445 | client-only@0.0.1: {} |
| ... | @@ -12034,6 +14460,10 @@ snapshots: | ... | @@ -12034,6 +14460,10 @@ snapshots: |
| 12034 | 14460 | ||
| 12035 | cluster-key-slot@1.1.1: {} | 14461 | cluster-key-slot@1.1.1: {} |
| 12036 | 14462 | ||
| 14463 | code-excerpt@4.0.0: | ||
| 14464 | dependencies: | ||
| 14465 | convert-to-spaces: 2.0.1 | ||
| 14466 | |||
| 12037 | color-convert@1.9.3: | 14467 | color-convert@1.9.3: |
| 12038 | dependencies: | 14468 | dependencies: |
| 12039 | color-name: 1.1.3 | 14469 | color-name: 1.1.3 |
| ... | @@ -12114,6 +14544,8 @@ snapshots: | ... | @@ -12114,6 +14544,8 @@ snapshots: |
| 12114 | 14544 | ||
| 12115 | convert-source-map@2.0.0: {} | 14545 | convert-source-map@2.0.0: {} |
| 12116 | 14546 | ||
| 14547 | convert-to-spaces@2.0.1: {} | ||
| 14548 | |||
| 12117 | cookie-es@1.2.3: {} | 14549 | cookie-es@1.2.3: {} |
| 12118 | 14550 | ||
| 12119 | cookie-es@3.1.1: {} | 14551 | cookie-es@3.1.1: {} |
| ... | @@ -12128,8 +14560,9 @@ snapshots: | ... | @@ -12128,8 +14560,9 @@ snapshots: |
| 12128 | dependencies: | 14560 | dependencies: |
| 12129 | browserslist: 4.28.2 | 14561 | browserslist: 4.28.2 |
| 12130 | 14562 | ||
| 12131 | core-util-is@1.0.2: | 14563 | core-js@3.47.0: {} |
| 12132 | optional: true | 14564 | |
| 14565 | core-util-is@1.0.2: {} | ||
| 12133 | 14566 | ||
| 12134 | cors@2.8.6: | 14567 | cors@2.8.6: |
| 12135 | dependencies: | 14568 | dependencies: |
| ... | @@ -12159,6 +14592,8 @@ snapshots: | ... | @@ -12159,6 +14592,8 @@ snapshots: |
| 12159 | dependencies: | 14592 | dependencies: |
| 12160 | uncrypto: 0.1.3 | 14593 | uncrypto: 0.1.3 |
| 12161 | 14594 | ||
| 14595 | crypto-js@4.2.0: {} | ||
| 14596 | |||
| 12162 | css-select@5.2.2: | 14597 | css-select@5.2.2: |
| 12163 | dependencies: | 14598 | dependencies: |
| 12164 | boolbase: 1.0.0 | 14599 | boolbase: 1.0.0 |
| ... | @@ -12252,6 +14687,8 @@ snapshots: | ... | @@ -12252,6 +14687,8 @@ snapshots: |
| 12252 | 14687 | ||
| 12253 | destroy@1.2.0: {} | 14688 | destroy@1.2.0: {} |
| 12254 | 14689 | ||
| 14690 | detect-libc@2.0.2: {} | ||
| 14691 | |||
| 12255 | detect-libc@2.1.2: {} | 14692 | detect-libc@2.1.2: {} |
| 12256 | 14693 | ||
| 12257 | detect-node-es@1.1.0: {} | 14694 | detect-node-es@1.1.0: {} |
| ... | @@ -12327,6 +14764,25 @@ snapshots: | ... | @@ -12327,6 +14764,25 @@ snapshots: |
| 12327 | 14764 | ||
| 12328 | dotenv@16.6.1: {} | 14765 | dotenv@16.6.1: {} |
| 12329 | 14766 | ||
| 14767 | drizzle-kit@1.0.0-rc.3: | ||
| 14768 | dependencies: | ||
| 14769 | '@drizzle-team/brocli': 0.11.0 | ||
| 14770 | '@js-temporal/polyfill': 0.5.1 | ||
| 14771 | esbuild: 0.25.12 | ||
| 14772 | get-tsconfig: 4.14.0 | ||
| 14773 | jiti: 2.7.0 | ||
| 14774 | |||
| 14775 | drizzle-orm@1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@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.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): | ||
| 14776 | optionalDependencies: | ||
| 14777 | '@cloudflare/workers-types': 4.20260604.1 | ||
| 14778 | '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) | ||
| 14779 | '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) | ||
| 14780 | bun-types: 1.3.14 | ||
| 14781 | effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) | ||
| 14782 | mysql2: 3.22.4(@types/node@24.12.4) | ||
| 14783 | pg: 8.21.0 | ||
| 14784 | zod: 4.4.3 | ||
| 14785 | |||
| 12330 | dset@3.1.4: {} | 14786 | dset@3.1.4: {} |
| 12331 | 14787 | ||
| 12332 | dunder-proto@1.0.1: | 14788 | dunder-proto@1.0.1: |
| ... | @@ -12337,7 +14793,7 @@ snapshots: | ... | @@ -12337,7 +14793,7 @@ snapshots: |
| 12337 | 14793 | ||
| 12338 | ee-first@1.1.1: {} | 14794 | ee-first@1.1.1: {} |
| 12339 | 14795 | ||
| 12340 | effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01): | 14796 | effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754): |
| 12341 | dependencies: | 14797 | dependencies: |
| 12342 | '@standard-schema/spec': 1.1.0 | 14798 | '@standard-schema/spec': 1.1.0 |
| 12343 | fast-check: 4.8.0 | 14799 | fast-check: 4.8.0 |
| ... | @@ -12432,6 +14888,8 @@ snapshots: | ... | @@ -12432,6 +14888,8 @@ snapshots: |
| 12432 | '@emmetio/abbreviation': 2.3.3 | 14888 | '@emmetio/abbreviation': 2.3.3 |
| 12433 | '@emmetio/css-abbreviation': 2.1.8 | 14889 | '@emmetio/css-abbreviation': 2.1.8 |
| 12434 | 14890 | ||
| 14891 | emoji-regex@10.6.0: {} | ||
| 14892 | |||
| 12435 | emoji-regex@8.0.0: {} | 14893 | emoji-regex@8.0.0: {} |
| 12436 | 14894 | ||
| 12437 | encodeurl@1.0.2: {} | 14895 | encodeurl@1.0.2: {} |
| ... | @@ -12453,6 +14911,8 @@ snapshots: | ... | @@ -12453,6 +14911,8 @@ snapshots: |
| 12453 | 14911 | ||
| 12454 | env-paths@2.2.1: {} | 14912 | env-paths@2.2.1: {} |
| 12455 | 14913 | ||
| 14914 | environment@1.1.0: {} | ||
| 14915 | |||
| 12456 | err-code@2.0.3: {} | 14916 | err-code@2.0.3: {} |
| 12457 | 14917 | ||
| 12458 | error-stack-parser@2.1.4: | 14918 | error-stack-parser@2.1.4: |
| ... | @@ -12478,9 +14938,40 @@ snapshots: | ... | @@ -12478,9 +14938,40 @@ snapshots: |
| 12478 | has-tostringtag: 1.0.2 | 14938 | has-tostringtag: 1.0.2 |
| 12479 | hasown: 2.0.4 | 14939 | hasown: 2.0.4 |
| 12480 | 14940 | ||
| 14941 | es-toolkit@1.47.0: {} | ||
| 14942 | |||
| 12481 | es6-error@4.1.1: | 14943 | es6-error@4.1.1: |
| 12482 | optional: true | 14944 | optional: true |
| 12483 | 14945 | ||
| 14946 | esbuild@0.25.12: | ||
| 14947 | optionalDependencies: | ||
| 14948 | '@esbuild/aix-ppc64': 0.25.12 | ||
| 14949 | '@esbuild/android-arm': 0.25.12 | ||
| 14950 | '@esbuild/android-arm64': 0.25.12 | ||
| 14951 | '@esbuild/android-x64': 0.25.12 | ||
| 14952 | '@esbuild/darwin-arm64': 0.25.12 | ||
| 14953 | '@esbuild/darwin-x64': 0.25.12 | ||
| 14954 | '@esbuild/freebsd-arm64': 0.25.12 | ||
| 14955 | '@esbuild/freebsd-x64': 0.25.12 | ||
| 14956 | '@esbuild/linux-arm': 0.25.12 | ||
| 14957 | '@esbuild/linux-arm64': 0.25.12 | ||
| 14958 | '@esbuild/linux-ia32': 0.25.12 | ||
| 14959 | '@esbuild/linux-loong64': 0.25.12 | ||
| 14960 | '@esbuild/linux-mips64el': 0.25.12 | ||
| 14961 | '@esbuild/linux-ppc64': 0.25.12 | ||
| 14962 | '@esbuild/linux-riscv64': 0.25.12 | ||
| 14963 | '@esbuild/linux-s390x': 0.25.12 | ||
| 14964 | '@esbuild/linux-x64': 0.25.12 | ||
| 14965 | '@esbuild/netbsd-arm64': 0.25.12 | ||
| 14966 | '@esbuild/netbsd-x64': 0.25.12 | ||
| 14967 | '@esbuild/openbsd-arm64': 0.25.12 | ||
| 14968 | '@esbuild/openbsd-x64': 0.25.12 | ||
| 14969 | '@esbuild/openharmony-arm64': 0.25.12 | ||
| 14970 | '@esbuild/sunos-x64': 0.25.12 | ||
| 14971 | '@esbuild/win32-arm64': 0.25.12 | ||
| 14972 | '@esbuild/win32-ia32': 0.25.12 | ||
| 14973 | '@esbuild/win32-x64': 0.25.12 | ||
| 14974 | |||
| 12484 | esbuild@0.27.7: | 14975 | esbuild@0.27.7: |
| 12485 | optionalDependencies: | 14976 | optionalDependencies: |
| 12486 | '@esbuild/aix-ppc64': 0.27.7 | 14977 | '@esbuild/aix-ppc64': 0.27.7 |
| ... | @@ -12516,6 +15007,8 @@ snapshots: | ... | @@ -12516,6 +15007,8 @@ snapshots: |
| 12516 | 15007 | ||
| 12517 | escape-string-regexp@1.0.5: {} | 15008 | escape-string-regexp@1.0.5: {} |
| 12518 | 15009 | ||
| 15010 | escape-string-regexp@2.0.0: {} | ||
| 15011 | |||
| 12519 | escape-string-regexp@4.0.0: {} | 15012 | escape-string-regexp@4.0.0: {} |
| 12520 | 15013 | ||
| 12521 | escape-string-regexp@5.0.0: {} | 15014 | escape-string-regexp@5.0.0: {} |
| ... | @@ -12536,133 +15029,151 @@ snapshots: | ... | @@ -12536,133 +15029,151 @@ snapshots: |
| 12536 | dependencies: | 15029 | dependencies: |
| 12537 | eventsource-parser: 3.1.0 | 15030 | eventsource-parser: 3.1.0 |
| 12538 | 15031 | ||
| 12539 | 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): | 15032 | expo-application@56.0.3(expo@56.0.8): |
| 15033 | dependencies: | ||
| 15034 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) | ||
| 15035 | |||
| 15036 | expo-asset@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): | ||
| 12540 | dependencies: | 15037 | dependencies: |
| 12541 | '@expo/image-utils': 0.10.1(typescript@6.0.3) | 15038 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| 12542 | 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) | 15039 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12543 | 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)) | 15040 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12544 | react: 19.2.3 | 15041 | react: 19.2.3 |
| 12545 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15042 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12546 | transitivePeerDependencies: | 15043 | transitivePeerDependencies: |
| 12547 | - supports-color | 15044 | - supports-color |
| 12548 | - typescript | 15045 | - typescript |
| 12549 | 15046 | ||
| 15047 | expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | ||
| 15048 | dependencies: | ||
| 15049 | expo-application: 56.0.3(expo@56.0.8) | ||
| 15050 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | ||
| 15051 | expo-crypto: 56.0.4(expo@56.0.8) | ||
| 15052 | expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 15053 | expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | ||
| 15054 | invariant: 2.2.4 | ||
| 15055 | react: 19.2.3 | ||
| 15056 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | ||
| 15057 | transitivePeerDependencies: | ||
| 15058 | - expo | ||
| 15059 | - supports-color | ||
| 15060 | |||
| 12550 | expo-build-properties@56.0.16(expo@56.0.8): | 15061 | expo-build-properties@56.0.16(expo@56.0.8): |
| 12551 | dependencies: | 15062 | dependencies: |
| 12552 | '@expo/schema-utils': 56.0.1 | 15063 | '@expo/schema-utils': 56.0.1 |
| 12553 | 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) | 15064 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12554 | resolve-from: 5.0.0 | 15065 | resolve-from: 5.0.0 |
| 12555 | semver: 7.8.1 | 15066 | semver: 7.8.1 |
| 12556 | 15067 | ||
| 12557 | 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): | 15068 | expo-camera@56.0.7(@types/emscripten@1.41.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 12558 | dependencies: | 15069 | dependencies: |
| 12559 | barcode-detector: 3.2.0(@types/emscripten@1.41.5) | 15070 | barcode-detector: 3.2.0(@types/emscripten@1.41.5) |
| 12560 | 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) | 15071 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12561 | react: 19.2.3 | 15072 | react: 19.2.3 |
| 12562 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15073 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12563 | transitivePeerDependencies: | 15074 | transitivePeerDependencies: |
| 12564 | - '@types/emscripten' | 15075 | - '@types/emscripten' |
| 12565 | 15076 | ||
| 12566 | 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): | 15077 | expo-clipboard@56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 12567 | dependencies: | 15078 | dependencies: |
| 12568 | 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) | 15079 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12569 | react: 19.2.3 | 15080 | react: 19.2.3 |
| 12570 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15081 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12571 | 15082 | ||
| 12572 | 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)): | 15083 | expo-constants@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 12573 | dependencies: | 15084 | dependencies: |
| 12574 | '@expo/env': 2.3.0 | 15085 | '@expo/env': 2.3.0 |
| 12575 | 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) | 15086 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12576 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15087 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12577 | transitivePeerDependencies: | 15088 | transitivePeerDependencies: |
| 12578 | - supports-color | 15089 | - supports-color |
| 12579 | 15090 | ||
| 12580 | expo-crypto@56.0.4(expo@56.0.8): | 15091 | expo-crypto@56.0.4(expo@56.0.8): |
| 12581 | dependencies: | 15092 | dependencies: |
| 12582 | 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) | 15093 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12583 | 15094 | ||
| 12584 | 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)): | 15095 | expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 12585 | dependencies: | 15096 | dependencies: |
| 12586 | 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) | 15097 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12587 | 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)) | 15098 | expo-dev-launcher: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12588 | 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)) | 15099 | expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12589 | expo-dev-menu-interface: 56.0.1(expo@56.0.8) | 15100 | expo-dev-menu-interface: 56.0.1(expo@56.0.8) |
| 12590 | expo-manifests: 56.0.4(expo@56.0.8) | 15101 | expo-manifests: 56.0.4(expo@56.0.8) |
| 12591 | expo-updates-interface: 56.0.2(expo@56.0.8) | 15102 | expo-updates-interface: 56.0.2(expo@56.0.8) |
| 12592 | transitivePeerDependencies: | 15103 | transitivePeerDependencies: |
| 12593 | - react-native | 15104 | - react-native |
| 12594 | 15105 | ||
| 12595 | 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)): | 15106 | expo-dev-launcher@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 12596 | dependencies: | 15107 | dependencies: |
| 12597 | '@expo/schema-utils': 56.0.1 | 15108 | '@expo/schema-utils': 56.0.1 |
| 12598 | 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) | 15109 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12599 | 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)) | 15110 | expo-dev-menu: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12600 | expo-manifests: 56.0.4(expo@56.0.8) | 15111 | expo-manifests: 56.0.4(expo@56.0.8) |
| 12601 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15112 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12602 | 15113 | ||
| 12603 | expo-dev-menu-interface@56.0.1(expo@56.0.8): | 15114 | expo-dev-menu-interface@56.0.1(expo@56.0.8): |
| 12604 | dependencies: | 15115 | dependencies: |
| 12605 | 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) | 15116 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12606 | 15117 | ||
| 12607 | 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)): | 15118 | expo-dev-menu@56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 12608 | dependencies: | 15119 | dependencies: |
| 12609 | 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) | 15120 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12610 | expo-dev-menu-interface: 56.0.1(expo@56.0.8) | 15121 | expo-dev-menu-interface: 56.0.1(expo@56.0.8) |
| 12611 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15122 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12612 | 15123 | ||
| 12613 | expo-eas-client@56.0.1: {} | 15124 | expo-eas-client@56.0.1: {} |
| 12614 | 15125 | ||
| 12615 | 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)): | 15126 | expo-file-system@56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 12616 | dependencies: | 15127 | dependencies: |
| 12617 | 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) | 15128 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12618 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15129 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12619 | 15130 | ||
| 12620 | 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): | 15131 | expo-font@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 12621 | dependencies: | 15132 | dependencies: |
| 12622 | 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) | 15133 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12623 | fontfaceobserver: 2.3.0 | 15134 | fontfaceobserver: 2.3.0 |
| 12624 | react: 19.2.3 | 15135 | react: 19.2.3 |
| 12625 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15136 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12626 | 15137 | ||
| 12627 | 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): | 15138 | expo-glass-effect@56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 12628 | dependencies: | 15139 | dependencies: |
| 12629 | 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) | 15140 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12630 | react: 19.2.3 | 15141 | react: 19.2.3 |
| 12631 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15142 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12632 | 15143 | ||
| 12633 | expo-haptics@56.0.3(expo@56.0.8): | 15144 | expo-haptics@56.0.3(expo@56.0.8): |
| 12634 | dependencies: | 15145 | dependencies: |
| 12635 | 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) | 15146 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12636 | 15147 | ||
| 12637 | expo-image-loader@56.0.3(expo@56.0.8): | 15148 | expo-image-loader@56.0.3(expo@56.0.8): |
| 12638 | dependencies: | 15149 | dependencies: |
| 12639 | 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) | 15150 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12640 | 15151 | ||
| 12641 | expo-image-picker@56.0.15(expo@56.0.8): | 15152 | expo-image-picker@56.0.15(expo@56.0.8): |
| 12642 | dependencies: | 15153 | dependencies: |
| 12643 | 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) | 15154 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12644 | expo-image-loader: 56.0.3(expo@56.0.8) | 15155 | expo-image-loader: 56.0.3(expo@56.0.8) |
| 12645 | 15156 | ||
| 12646 | expo-json-utils@56.0.0: {} | 15157 | expo-json-utils@56.0.0: {} |
| 12647 | 15158 | ||
| 12648 | expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): | 15159 | expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3): |
| 12649 | dependencies: | 15160 | dependencies: |
| 12650 | 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) | 15161 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12651 | react: 19.2.3 | 15162 | react: 19.2.3 |
| 12652 | 15163 | ||
| 12653 | 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): | 15164 | expo-linking@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 12654 | dependencies: | 15165 | dependencies: |
| 12655 | 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)) | 15166 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12656 | invariant: 2.2.4 | 15167 | invariant: 2.2.4 |
| 12657 | react: 19.2.3 | 15168 | react: 19.2.3 |
| 12658 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15169 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12659 | transitivePeerDependencies: | 15170 | transitivePeerDependencies: |
| 12660 | - expo | 15171 | - expo |
| 12661 | - supports-color | 15172 | - supports-color |
| 12662 | 15173 | ||
| 12663 | expo-manifests@56.0.4(expo@56.0.8): | 15174 | expo-manifests@56.0.4(expo@56.0.8): |
| 12664 | dependencies: | 15175 | dependencies: |
| 12665 | 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) | 15176 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12666 | expo-json-utils: 56.0.0 | 15177 | expo-json-utils: 56.0.0 |
| 12667 | 15178 | ||
| 12668 | expo-modules-autolinking@56.0.14(typescript@6.0.3): | 15179 | expo-modules-autolinking@56.0.14(typescript@6.0.3): |
| ... | @@ -12675,47 +15186,61 @@ snapshots: | ... | @@ -12675,47 +15186,61 @@ snapshots: |
| 12675 | - supports-color | 15186 | - supports-color |
| 12676 | - typescript | 15187 | - typescript |
| 12677 | 15188 | ||
| 12678 | 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): | 15189 | expo-modules-core@56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 12679 | dependencies: | 15190 | dependencies: |
| 12680 | '@expo/expo-modules-macros-plugin': 0.0.9 | 15191 | '@expo/expo-modules-macros-plugin': 0.0.9 |
| 12681 | 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)) | 15192 | expo-modules-jsi: 56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12682 | invariant: 2.2.4 | 15193 | invariant: 2.2.4 |
| 12683 | react: 19.2.3 | 15194 | react: 19.2.3 |
| 12684 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15195 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12685 | optionalDependencies: | 15196 | optionalDependencies: |
| 12686 | 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) | 15197 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12687 | 15198 | ||
| 12688 | 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)): | 15199 | expo-modules-jsi@56.0.7(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 12689 | dependencies: | 15200 | dependencies: |
| 12690 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15201 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 15202 | |||
| 15203 | expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): | ||
| 15204 | dependencies: | ||
| 15205 | '@expo/image-utils': 0.10.1(typescript@6.0.3) | ||
| 15206 | abort-controller: 3.0.0 | ||
| 15207 | badgin: 1.2.3 | ||
| 15208 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) | ||
| 15209 | expo-application: 56.0.3(expo@56.0.8) | ||
| 15210 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | ||
| 15211 | react: 19.2.3 | ||
| 15212 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | ||
| 15213 | transitivePeerDependencies: | ||
| 15214 | - supports-color | ||
| 15215 | - typescript | ||
| 12691 | 15216 | ||
| 12692 | 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): | 15217 | expo-paste-input@0.1.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 12693 | dependencies: | 15218 | dependencies: |
| 12694 | 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) | 15219 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12695 | react: 19.2.3 | 15220 | react: 19.2.3 |
| 12696 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15221 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12697 | 15222 | ||
| 12698 | expo-router@56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51): | 15223 | expo-router@56.2.8(c021de11d02907bd585610408f5252e8): |
| 12699 | dependencies: | 15224 | dependencies: |
| 12700 | '@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) | 15225 | '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12701 | '@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) | 15226 | '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12702 | '@expo/schema-utils': 56.0.1 | 15227 | '@expo/schema-utils': 56.0.1 |
| 12703 | '@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) | 15228 | '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) |
| 12704 | '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) | 15229 | '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) |
| 12705 | '@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) | 15230 | '@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) |
| 12706 | '@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) | 15231 | '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12707 | '@testing-library/jest-dom': 6.9.1 | 15232 | '@testing-library/jest-dom': 6.9.1 |
| 12708 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) | 15233 | '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) |
| 12709 | client-only: 0.0.1 | 15234 | client-only: 0.0.1 |
| 12710 | color: 4.2.3 | 15235 | color: 4.2.3 |
| 12711 | debug: 4.4.3 | 15236 | debug: 4.4.3 |
| 12712 | escape-string-regexp: 4.0.0 | 15237 | escape-string-regexp: 4.0.0 |
| 12713 | 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) | 15238 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12714 | 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)) | 15239 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12715 | 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) | 15240 | expo-glass-effect: 56.0.4(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12716 | 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) | 15241 | expo-linking: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12717 | expo-server: 56.0.4 | 15242 | expo-server: 56.0.4 |
| 12718 | 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) | 15243 | expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12719 | fast-deep-equal: 3.1.3 | 15244 | fast-deep-equal: 3.1.3 |
| 12720 | invariant: 2.2.4 | 15245 | invariant: 2.2.4 |
| 12721 | nanoid: 3.3.12 | 15246 | nanoid: 3.3.12 |
| ... | @@ -12723,18 +15248,18 @@ snapshots: | ... | @@ -12723,18 +15248,18 @@ snapshots: |
| 12723 | react: 19.2.3 | 15248 | react: 19.2.3 |
| 12724 | react-fast-compare: 3.2.2 | 15249 | react-fast-compare: 3.2.2 |
| 12725 | react-is: 19.2.7 | 15250 | react-is: 19.2.7 |
| 12726 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15251 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12727 | 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) | 15252 | react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12728 | 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) | 15253 | react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12729 | 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) | 15254 | react-native-screens: 4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12730 | server-only: 0.0.1 | 15255 | server-only: 0.0.1 |
| 12731 | sf-symbols-typescript: 2.2.0 | 15256 | sf-symbols-typescript: 2.2.0 |
| 12732 | shallowequal: 1.1.0 | 15257 | shallowequal: 1.1.0 |
| 12733 | 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) | 15258 | 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) |
| 12734 | optionalDependencies: | 15259 | optionalDependencies: |
| 12735 | react-dom: 19.2.3(react@19.2.3) | 15260 | react-dom: 19.2.3(react@19.2.3) |
| 12736 | 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) | 15261 | react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12737 | 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) | 15262 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12738 | transitivePeerDependencies: | 15263 | transitivePeerDependencies: |
| 12739 | - '@babel/core' | 15264 | - '@babel/core' |
| 12740 | - '@testing-library/dom' | 15265 | - '@testing-library/dom' |
| ... | @@ -12746,7 +15271,7 @@ snapshots: | ... | @@ -12746,7 +15271,7 @@ snapshots: |
| 12746 | 15271 | ||
| 12747 | expo-secure-store@56.0.4(expo@56.0.8): | 15272 | expo-secure-store@56.0.4(expo@56.0.8): |
| 12748 | dependencies: | 15273 | dependencies: |
| 12749 | 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) | 15274 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12750 | 15275 | ||
| 12751 | expo-server@56.0.4: {} | 15276 | expo-server@56.0.4: {} |
| 12752 | 15277 | ||
| ... | @@ -12754,7 +15279,7 @@ snapshots: | ... | @@ -12754,7 +15279,7 @@ snapshots: |
| 12754 | dependencies: | 15279 | dependencies: |
| 12755 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) | 15280 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) |
| 12756 | '@expo/image-utils': 0.10.1(typescript@6.0.3) | 15281 | '@expo/image-utils': 0.10.1(typescript@6.0.3) |
| 12757 | 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) | 15282 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12758 | xml2js: 0.6.0 | 15283 | xml2js: 0.6.0 |
| 12759 | transitivePeerDependencies: | 15284 | transitivePeerDependencies: |
| 12760 | - supports-color | 15285 | - supports-color |
| ... | @@ -12762,20 +15287,20 @@ snapshots: | ... | @@ -12762,20 +15287,20 @@ snapshots: |
| 12762 | 15287 | ||
| 12763 | expo-structured-headers@56.0.0: {} | 15288 | expo-structured-headers@56.0.0: {} |
| 12764 | 15289 | ||
| 12765 | 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): | 15290 | expo-symbols@56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 12766 | dependencies: | 15291 | dependencies: |
| 12767 | '@expo-google-fonts/material-symbols': 0.4.38 | 15292 | '@expo-google-fonts/material-symbols': 0.4.38 |
| 12768 | 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) | 15293 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12769 | 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) | 15294 | expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12770 | react: 19.2.3 | 15295 | react: 19.2.3 |
| 12771 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15296 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12772 | sf-symbols-typescript: 2.2.0 | 15297 | sf-symbols-typescript: 2.2.0 |
| 12773 | 15298 | ||
| 12774 | expo-updates-interface@56.0.2(expo@56.0.8): | 15299 | expo-updates-interface@56.0.2(expo@56.0.8): |
| 12775 | dependencies: | 15300 | dependencies: |
| 12776 | 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) | 15301 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12777 | 15302 | ||
| 12778 | 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): | 15303 | expo-updates@56.0.17(expo-dev-client@56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 12779 | dependencies: | 15304 | dependencies: |
| 12780 | '@expo/code-signing-certificates': 0.0.6 | 15305 | '@expo/code-signing-certificates': 0.0.6 |
| 12781 | '@expo/plist': 0.7.0 | 15306 | '@expo/plist': 0.7.0 |
| ... | @@ -12783,7 +15308,7 @@ snapshots: | ... | @@ -12783,7 +15308,7 @@ snapshots: |
| 12783 | arg: 4.1.3 | 15308 | arg: 4.1.3 |
| 12784 | chalk: 4.1.2 | 15309 | chalk: 4.1.2 |
| 12785 | debug: 4.4.3 | 15310 | debug: 4.4.3 |
| 12786 | 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) | 15311 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12787 | expo-eas-client: 56.0.1 | 15312 | expo-eas-client: 56.0.1 |
| 12788 | expo-manifests: 56.0.4(expo@56.0.8) | 15313 | expo-manifests: 56.0.4(expo@56.0.8) |
| 12789 | expo-structured-headers: 56.0.0 | 15314 | expo-structured-headers: 56.0.0 |
| ... | @@ -12793,42 +15318,62 @@ snapshots: | ... | @@ -12793,42 +15318,62 @@ snapshots: |
| 12793 | ignore: 5.3.2 | 15318 | ignore: 5.3.2 |
| 12794 | nullthrows: 1.1.1 | 15319 | nullthrows: 1.1.1 |
| 12795 | react: 19.2.3 | 15320 | react: 19.2.3 |
| 12796 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15321 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12797 | resolve-from: 5.0.0 | 15322 | resolve-from: 5.0.0 |
| 12798 | optionalDependencies: | 15323 | optionalDependencies: |
| 12799 | 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)) | 15324 | expo-dev-client: 56.0.18(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12800 | transitivePeerDependencies: | 15325 | transitivePeerDependencies: |
| 12801 | - supports-color | 15326 | - supports-color |
| 12802 | 15327 | ||
| 12803 | 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): | 15328 | expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 15329 | dependencies: | ||
| 15330 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) | ||
| 15331 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | ||
| 15332 | |||
| 15333 | expo-widgets@56.0.16(ab3f255d102c60ba0fd2bbe4e47ba584): | ||
| 15334 | dependencies: | ||
| 15335 | '@expo/plist': 0.7.0 | ||
| 15336 | '@expo/ui': 56.0.15(ab3f255d102c60ba0fd2bbe4e47ba584) | ||
| 15337 | expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) | ||
| 15338 | react: 19.2.3 | ||
| 15339 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | ||
| 15340 | transitivePeerDependencies: | ||
| 15341 | - '@babel/core' | ||
| 15342 | - '@types/react' | ||
| 15343 | - '@types/react-dom' | ||
| 15344 | - react-dom | ||
| 15345 | - react-native-reanimated | ||
| 15346 | - react-native-worklets | ||
| 15347 | |||
| 15348 | expo@56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): | ||
| 12804 | dependencies: | 15349 | dependencies: |
| 12805 | '@babel/runtime': 7.29.7 | 15350 | '@babel/runtime': 7.29.7 |
| 12806 | '@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) | 15351 | '@expo/cli': 56.1.13(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-constants@56.0.16)(expo-font@56.0.5)(expo-router@56.2.8)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) |
| 12807 | '@expo/config': 56.0.9(typescript@6.0.3) | 15352 | '@expo/config': 56.0.9(typescript@6.0.3) |
| 12808 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) | 15353 | '@expo/config-plugins': 56.0.8(typescript@6.0.3) |
| 12809 | '@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) | 15354 | '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12810 | '@expo/fingerprint': 0.19.3 | 15355 | '@expo/fingerprint': 0.19.3 |
| 12811 | '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) | 15356 | '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) |
| 12812 | '@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) | 15357 | '@expo/log-box': 56.0.12(@expo/dom-webview@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12813 | '@expo/metro': 56.0.0 | 15358 | '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 12814 | '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(expo@56.0.8)(typescript@6.0.3) | 15359 | '@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) |
| 12815 | '@ungap/structured-clone': 1.3.1 | 15360 | '@ungap/structured-clone': 1.3.1 |
| 12816 | 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) | 15361 | 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) |
| 12817 | 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) | 15362 | expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) |
| 12818 | 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)) | 15363 | expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12819 | 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)) | 15364 | expo-file-system: 56.0.7(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| 12820 | 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) | 15365 | expo-font: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12821 | expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3) | 15366 | expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3) |
| 12822 | expo-modules-autolinking: 56.0.14(typescript@6.0.3) | 15367 | expo-modules-autolinking: 56.0.14(typescript@6.0.3) |
| 12823 | 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) | 15368 | expo-modules-core: 56.0.14(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12824 | pretty-format: 29.7.0 | 15369 | pretty-format: 29.7.0 |
| 12825 | react: 19.2.3 | 15370 | react: 19.2.3 |
| 12826 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 15371 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12827 | react-refresh: 0.14.2 | 15372 | react-refresh: 0.14.2 |
| 12828 | whatwg-url-minimum: 0.1.2 | 15373 | whatwg-url-minimum: 0.1.2 |
| 12829 | optionalDependencies: | 15374 | optionalDependencies: |
| 12830 | '@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) | 15375 | '@expo/dom-webview': 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12831 | '@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) | 15376 | '@expo/metro-runtime': 56.0.13(@expo/log-box@56.0.12)(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 12832 | react-dom: 19.2.3(react@19.2.3) | 15377 | react-dom: 19.2.3(react@19.2.3) |
| 12833 | transitivePeerDependencies: | 15378 | transitivePeerDependencies: |
| 12834 | - '@babel/core' | 15379 | - '@babel/core' |
| ... | @@ -12902,8 +15447,18 @@ snapshots: | ... | @@ -12902,8 +15447,18 @@ snapshots: |
| 12902 | 15447 | ||
| 12903 | fast-deep-equal@3.1.3: {} | 15448 | fast-deep-equal@3.1.3: {} |
| 12904 | 15449 | ||
| 15450 | fast-glob@3.3.3: | ||
| 15451 | dependencies: | ||
| 15452 | '@nodelib/fs.stat': 2.0.5 | ||
| 15453 | '@nodelib/fs.walk': 1.2.8 | ||
| 15454 | glob-parent: 5.1.2 | ||
| 15455 | merge2: 1.4.1 | ||
| 15456 | micromatch: 4.0.8 | ||
| 15457 | |||
| 12905 | fast-json-stable-stringify@2.1.0: {} | 15458 | fast-json-stable-stringify@2.1.0: {} |
| 12906 | 15459 | ||
| 15460 | fast-sha256@1.3.0: {} | ||
| 15461 | |||
| 12907 | fast-string-truncated-width@3.0.3: {} | 15462 | fast-string-truncated-width@3.0.3: {} |
| 12908 | 15463 | ||
| 12909 | fast-string-width@3.0.2: | 15464 | fast-string-width@3.0.2: |
| ... | @@ -12916,6 +15471,32 @@ snapshots: | ... | @@ -12916,6 +15471,32 @@ snapshots: |
| 12916 | dependencies: | 15471 | dependencies: |
| 12917 | fast-string-width: 3.0.2 | 15472 | fast-string-width: 3.0.2 |
| 12918 | 15473 | ||
| 15474 | fast-xml-builder@1.2.0: | ||
| 15475 | dependencies: | ||
| 15476 | path-expression-matcher: 1.5.0 | ||
| 15477 | xml-naming: 0.1.0 | ||
| 15478 | |||
| 15479 | fast-xml-parser@5.7.3: | ||
| 15480 | dependencies: | ||
| 15481 | '@nodable/entities': 2.1.1 | ||
| 15482 | fast-xml-builder: 1.2.0 | ||
| 15483 | path-expression-matcher: 1.5.0 | ||
| 15484 | strnum: 2.3.0 | ||
| 15485 | |||
| 15486 | fast-xml-parser@5.8.0: | ||
| 15487 | dependencies: | ||
| 15488 | '@nodable/entities': 2.1.1 | ||
| 15489 | fast-xml-builder: 1.2.0 | ||
| 15490 | path-expression-matcher: 1.5.0 | ||
| 15491 | strnum: 2.3.0 | ||
| 15492 | xml-naming: 0.1.0 | ||
| 15493 | |||
| 15494 | fastest-levenshtein@1.0.16: {} | ||
| 15495 | |||
| 15496 | fastq@1.20.1: | ||
| 15497 | dependencies: | ||
| 15498 | reusify: 1.1.0 | ||
| 15499 | |||
| 12919 | fb-dotslash@0.5.8: {} | 15500 | fb-dotslash@0.5.8: {} |
| 12920 | 15501 | ||
| 12921 | fb-watchman@2.0.2: | 15502 | fb-watchman@2.0.2: |
| ... | @@ -13036,10 +15617,16 @@ snapshots: | ... | @@ -13036,10 +15617,16 @@ snapshots: |
| 13036 | 15617 | ||
| 13037 | function-bind@1.1.2: {} | 15618 | function-bind@1.1.2: {} |
| 13038 | 15619 | ||
| 15620 | generate-function@2.3.1: | ||
| 15621 | dependencies: | ||
| 15622 | is-property: 1.0.2 | ||
| 15623 | |||
| 13039 | gensync@1.0.0-beta.2: {} | 15624 | gensync@1.0.0-beta.2: {} |
| 13040 | 15625 | ||
| 13041 | get-caller-file@2.0.5: {} | 15626 | get-caller-file@2.0.5: {} |
| 13042 | 15627 | ||
| 15628 | get-east-asian-width@1.6.0: {} | ||
| 15629 | |||
| 13043 | get-intrinsic@1.3.0: | 15630 | get-intrinsic@1.3.0: |
| 13044 | dependencies: | 15631 | dependencies: |
| 13045 | call-bind-apply-helpers: 1.0.2 | 15632 | call-bind-apply-helpers: 1.0.2 |
| ... | @@ -13064,6 +15651,10 @@ snapshots: | ... | @@ -13064,6 +15651,10 @@ snapshots: |
| 13064 | dependencies: | 15651 | dependencies: |
| 13065 | pump: 3.0.4 | 15652 | pump: 3.0.4 |
| 13066 | 15653 | ||
| 15654 | get-tsconfig@4.14.0: | ||
| 15655 | dependencies: | ||
| 15656 | resolve-pkg-maps: 1.0.0 | ||
| 15657 | |||
| 13067 | get-tsconfig@5.0.0-beta.4: | 15658 | get-tsconfig@5.0.0-beta.4: |
| 13068 | dependencies: | 15659 | dependencies: |
| 13069 | resolve-pkg-maps: 1.0.0 | 15660 | resolve-pkg-maps: 1.0.0 |
| ... | @@ -13072,6 +15663,12 @@ snapshots: | ... | @@ -13072,6 +15663,12 @@ snapshots: |
| 13072 | 15663 | ||
| 13073 | github-slugger@2.0.0: {} | 15664 | github-slugger@2.0.0: {} |
| 13074 | 15665 | ||
| 15666 | glob-parent@5.1.2: | ||
| 15667 | dependencies: | ||
| 15668 | is-glob: 4.0.3 | ||
| 15669 | |||
| 15670 | glob-to-regexp@0.4.1: {} | ||
| 15671 | |||
| 13075 | glob@13.0.6: | 15672 | glob@13.0.6: |
| 13076 | dependencies: | 15673 | dependencies: |
| 13077 | minimatch: 10.2.5 | 15674 | minimatch: 10.2.5 |
| ... | @@ -13340,17 +15937,23 @@ snapshots: | ... | @@ -13340,17 +15937,23 @@ snapshots: |
| 13340 | dependencies: | 15937 | dependencies: |
| 13341 | safer-buffer: 2.1.2 | 15938 | safer-buffer: 2.1.2 |
| 13342 | 15939 | ||
| 13343 | ieee754@1.2.1: | 15940 | idb-keyval@6.2.1: |
| 13344 | optional: true | 15941 | optional: true |
| 13345 | 15942 | ||
| 15943 | ieee754@1.2.1: {} | ||
| 15944 | |||
| 13346 | ignore@5.3.2: {} | 15945 | ignore@5.3.2: {} |
| 13347 | 15946 | ||
| 13348 | image-size@1.2.1: | 15947 | image-size@1.2.1: |
| 13349 | dependencies: | 15948 | dependencies: |
| 13350 | queue: 6.0.2 | 15949 | queue: 6.0.2 |
| 13351 | 15950 | ||
| 15951 | immediate@3.0.6: {} | ||
| 15952 | |||
| 13352 | indent-string@4.0.0: {} | 15953 | indent-string@4.0.0: {} |
| 13353 | 15954 | ||
| 15955 | indent-string@5.0.0: {} | ||
| 15956 | |||
| 13354 | inflight@1.0.6: | 15957 | inflight@1.0.6: |
| 13355 | dependencies: | 15958 | dependencies: |
| 13356 | once: 1.4.0 | 15959 | once: 1.4.0 |
| ... | @@ -13360,6 +15963,41 @@ snapshots: | ... | @@ -13360,6 +15963,41 @@ snapshots: |
| 13360 | 15963 | ||
| 13361 | ini@7.0.0: {} | 15964 | ini@7.0.0: {} |
| 13362 | 15965 | ||
| 15966 | 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): | ||
| 15967 | dependencies: | ||
| 15968 | '@alcalzone/ansi-tokenize': 0.2.5 | ||
| 15969 | ansi-escapes: 7.3.0 | ||
| 15970 | ansi-styles: 6.2.3 | ||
| 15971 | auto-bind: 5.0.1 | ||
| 15972 | chalk: 5.6.2 | ||
| 15973 | cli-boxes: 3.0.0 | ||
| 15974 | cli-cursor: 4.0.0 | ||
| 15975 | cli-truncate: 5.2.0 | ||
| 15976 | code-excerpt: 4.0.0 | ||
| 15977 | es-toolkit: 1.47.0 | ||
| 15978 | indent-string: 5.0.0 | ||
| 15979 | is-in-ci: 2.0.0 | ||
| 15980 | patch-console: 2.0.0 | ||
| 15981 | react: 19.2.6 | ||
| 15982 | react-reconciler: 0.33.0(react@19.2.6) | ||
| 15983 | scheduler: 0.27.0 | ||
| 15984 | signal-exit: 3.0.7 | ||
| 15985 | slice-ansi: 8.0.0 | ||
| 15986 | stack-utils: 2.0.6 | ||
| 15987 | string-width: 8.2.1 | ||
| 15988 | terminal-size: 4.0.1 | ||
| 15989 | type-fest: 5.7.0 | ||
| 15990 | widest-line: 6.0.0 | ||
| 15991 | wrap-ansi: 9.0.2 | ||
| 15992 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | ||
| 15993 | yoga-layout: 3.2.1 | ||
| 15994 | optionalDependencies: | ||
| 15995 | '@types/react': 19.2.16 | ||
| 15996 | react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) | ||
| 15997 | transitivePeerDependencies: | ||
| 15998 | - bufferutil | ||
| 15999 | - utf-8-validate | ||
| 16000 | |||
| 13363 | inline-style-parser@0.2.7: {} | 16001 | inline-style-parser@0.2.7: {} |
| 13364 | 16002 | ||
| 13365 | invariant@2.2.4: | 16003 | invariant@2.2.4: |
| ... | @@ -13405,10 +16043,22 @@ snapshots: | ... | @@ -13405,10 +16043,22 @@ snapshots: |
| 13405 | 16043 | ||
| 13406 | is-docker@4.0.0: {} | 16044 | is-docker@4.0.0: {} |
| 13407 | 16045 | ||
| 16046 | is-extglob@2.1.1: {} | ||
| 16047 | |||
| 13408 | is-fullwidth-code-point@3.0.0: {} | 16048 | is-fullwidth-code-point@3.0.0: {} |
| 13409 | 16049 | ||
| 16050 | is-fullwidth-code-point@5.1.0: | ||
| 16051 | dependencies: | ||
| 16052 | get-east-asian-width: 1.6.0 | ||
| 16053 | |||
| 16054 | is-glob@4.0.3: | ||
| 16055 | dependencies: | ||
| 16056 | is-extglob: 2.1.1 | ||
| 16057 | |||
| 13410 | is-hexadecimal@2.0.1: {} | 16058 | is-hexadecimal@2.0.1: {} |
| 13411 | 16059 | ||
| 16060 | is-in-ci@2.0.0: {} | ||
| 16061 | |||
| 13412 | is-inside-container@1.0.0: | 16062 | is-inside-container@1.0.0: |
| 13413 | dependencies: | 16063 | dependencies: |
| 13414 | is-docker: 3.0.0 | 16064 | is-docker: 3.0.0 |
| ... | @@ -13421,6 +16071,8 @@ snapshots: | ... | @@ -13421,6 +16071,8 @@ snapshots: |
| 13421 | 16071 | ||
| 13422 | is-promise@4.0.0: {} | 16072 | is-promise@4.0.0: {} |
| 13423 | 16073 | ||
| 16074 | is-property@1.0.2: {} | ||
| 16075 | |||
| 13424 | is-wsl@2.2.0: | 16076 | is-wsl@2.2.0: |
| 13425 | dependencies: | 16077 | dependencies: |
| 13426 | is-docker: 2.2.1 | 16078 | is-docker: 2.2.1 |
| ... | @@ -13429,6 +16081,8 @@ snapshots: | ... | @@ -13429,6 +16081,8 @@ snapshots: |
| 13429 | dependencies: | 16081 | dependencies: |
| 13430 | is-inside-container: 1.0.0 | 16082 | is-inside-container: 1.0.0 |
| 13431 | 16083 | ||
| 16084 | isarray@1.0.0: {} | ||
| 16085 | |||
| 13432 | isbinaryfile@4.0.10: {} | 16086 | isbinaryfile@4.0.10: {} |
| 13433 | 16087 | ||
| 13434 | isbinaryfile@5.0.7: {} | 16088 | isbinaryfile@5.0.7: {} |
| ... | @@ -13480,14 +16134,22 @@ snapshots: | ... | @@ -13480,14 +16134,22 @@ snapshots: |
| 13480 | 16134 | ||
| 13481 | jiti@2.7.0: {} | 16135 | jiti@2.7.0: {} |
| 13482 | 16136 | ||
| 16137 | jose@6.2.2: {} | ||
| 16138 | |||
| 13483 | jose@6.2.3: {} | 16139 | jose@6.2.3: {} |
| 13484 | 16140 | ||
| 16141 | js-base64@3.7.8: {} | ||
| 16142 | |||
| 16143 | js-cookie@3.0.7: {} | ||
| 16144 | |||
| 13485 | js-tokens@4.0.0: {} | 16145 | js-tokens@4.0.0: {} |
| 13486 | 16146 | ||
| 13487 | js-yaml@4.2.0: | 16147 | js-yaml@4.2.0: |
| 13488 | dependencies: | 16148 | dependencies: |
| 13489 | argparse: 2.0.1 | 16149 | argparse: 2.0.1 |
| 13490 | 16150 | ||
| 16151 | jsbi@4.3.2: {} | ||
| 16152 | |||
| 13491 | jsc-safe-url@0.2.4: {} | 16153 | jsc-safe-url@0.2.4: {} |
| 13492 | 16154 | ||
| 13493 | jsesc@3.1.0: {} | 16155 | jsesc@3.1.0: {} |
| ... | @@ -13508,6 +16170,8 @@ snapshots: | ... | @@ -13508,6 +16170,8 @@ snapshots: |
| 13508 | json-stringify-safe@5.0.1: | 16170 | json-stringify-safe@5.0.1: |
| 13509 | optional: true | 16171 | optional: true |
| 13510 | 16172 | ||
| 16173 | json-with-bigint@3.5.8: {} | ||
| 16174 | |||
| 13511 | json5@2.2.3: {} | 16175 | json5@2.2.3: {} |
| 13512 | 16176 | ||
| 13513 | jsonc-parser@2.3.1: {} | 16177 | jsonc-parser@2.3.1: {} |
| ... | @@ -13524,6 +16188,13 @@ snapshots: | ... | @@ -13524,6 +16188,13 @@ snapshots: |
| 13524 | optionalDependencies: | 16188 | optionalDependencies: |
| 13525 | graceful-fs: 4.2.11 | 16189 | graceful-fs: 4.2.11 |
| 13526 | 16190 | ||
| 16191 | jszip@3.10.1: | ||
| 16192 | dependencies: | ||
| 16193 | lie: 3.3.0 | ||
| 16194 | pako: 1.0.11 | ||
| 16195 | readable-stream: 2.3.8 | ||
| 16196 | setimmediate: 1.0.5 | ||
| 16197 | |||
| 13527 | keyv@4.5.4: | 16198 | keyv@4.5.4: |
| 13528 | dependencies: | 16199 | dependencies: |
| 13529 | json-buffer: 3.0.1 | 16200 | json-buffer: 3.0.1 |
| ... | @@ -13546,6 +16217,31 @@ snapshots: | ... | @@ -13546,6 +16217,31 @@ snapshots: |
| 13546 | dependencies: | 16217 | dependencies: |
| 13547 | isomorphic.js: 0.2.5 | 16218 | isomorphic.js: 0.2.5 |
| 13548 | 16219 | ||
| 16220 | libsodium-wrappers@0.8.4: | ||
| 16221 | dependencies: | ||
| 16222 | libsodium: 0.8.4 | ||
| 16223 | |||
| 16224 | libsodium@0.8.4: {} | ||
| 16225 | |||
| 16226 | libsql@0.5.29: | ||
| 16227 | dependencies: | ||
| 16228 | '@neon-rs/load': 0.0.4 | ||
| 16229 | detect-libc: 2.0.2 | ||
| 16230 | optionalDependencies: | ||
| 16231 | '@libsql/darwin-arm64': 0.5.29 | ||
| 16232 | '@libsql/darwin-x64': 0.5.29 | ||
| 16233 | '@libsql/linux-arm-gnueabihf': 0.5.29 | ||
| 16234 | '@libsql/linux-arm-musleabihf': 0.5.29 | ||
| 16235 | '@libsql/linux-arm64-gnu': 0.5.29 | ||
| 16236 | '@libsql/linux-arm64-musl': 0.5.29 | ||
| 16237 | '@libsql/linux-x64-gnu': 0.5.29 | ||
| 16238 | '@libsql/linux-x64-musl': 0.5.29 | ||
| 16239 | '@libsql/win32-x64-msvc': 0.5.29 | ||
| 16240 | |||
| 16241 | lie@3.3.0: | ||
| 16242 | dependencies: | ||
| 16243 | immediate: 3.0.6 | ||
| 16244 | |||
| 13549 | lighthouse-logger@1.4.2: | 16245 | lighthouse-logger@1.4.2: |
| 13550 | dependencies: | 16246 | dependencies: |
| 13551 | debug: 2.6.9 | 16247 | debug: 2.6.9 |
| ... | @@ -13710,6 +16406,8 @@ snapshots: | ... | @@ -13710,6 +16406,8 @@ snapshots: |
| 13710 | dependencies: | 16406 | dependencies: |
| 13711 | chalk: 2.4.2 | 16407 | chalk: 2.4.2 |
| 13712 | 16408 | ||
| 16409 | long@5.3.2: {} | ||
| 16410 | |||
| 13713 | longest-streak@3.1.0: {} | 16411 | longest-streak@3.1.0: {} |
| 13714 | 16412 | ||
| 13715 | loose-envify@1.4.0: | 16413 | loose-envify@1.4.0: |
| ... | @@ -13730,6 +16428,8 @@ snapshots: | ... | @@ -13730,6 +16428,8 @@ snapshots: |
| 13730 | dependencies: | 16428 | dependencies: |
| 13731 | yallist: 4.0.0 | 16429 | yallist: 4.0.0 |
| 13732 | 16430 | ||
| 16431 | lru.min@1.1.4: {} | ||
| 16432 | |||
| 13733 | lru_map@0.4.1: {} | 16433 | lru_map@0.4.1: {} |
| 13734 | 16434 | ||
| 13735 | lucide-react@0.564.0(react@19.2.6): | 16435 | lucide-react@0.564.0(react@19.2.6): |
| ... | @@ -13936,6 +16636,8 @@ snapshots: | ... | @@ -13936,6 +16636,8 @@ snapshots: |
| 13936 | 16636 | ||
| 13937 | merge-stream@2.0.0: {} | 16637 | merge-stream@2.0.0: {} |
| 13938 | 16638 | ||
| 16639 | merge2@1.4.1: {} | ||
| 16640 | |||
| 13939 | metro-babel-transformer@0.84.4: | 16641 | metro-babel-transformer@0.84.4: |
| 13940 | dependencies: | 16642 | dependencies: |
| 13941 | '@babel/core': 7.29.7 | 16643 | '@babel/core': 7.29.7 |
| ... | @@ -13959,12 +16661,12 @@ snapshots: | ... | @@ -13959,12 +16661,12 @@ snapshots: |
| 13959 | transitivePeerDependencies: | 16661 | transitivePeerDependencies: |
| 13960 | - supports-color | 16662 | - supports-color |
| 13961 | 16663 | ||
| 13962 | metro-config@0.84.4: | 16664 | metro-config@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6): |
| 13963 | dependencies: | 16665 | dependencies: |
| 13964 | connect: 3.7.0 | 16666 | connect: 3.7.0 |
| 13965 | flow-enums-runtime: 0.0.6 | 16667 | flow-enums-runtime: 0.0.6 |
| 13966 | jest-validate: 29.7.0 | 16668 | jest-validate: 29.7.0 |
| 13967 | metro: 0.84.4 | 16669 | metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 13968 | metro-cache: 0.84.4 | 16670 | metro-cache: 0.84.4 |
| 13969 | metro-core: 0.84.4 | 16671 | metro-core: 0.84.4 |
| 13970 | metro-runtime: 0.84.4 | 16672 | metro-runtime: 0.84.4 |
| ... | @@ -14044,14 +16746,14 @@ snapshots: | ... | @@ -14044,14 +16746,14 @@ snapshots: |
| 14044 | transitivePeerDependencies: | 16746 | transitivePeerDependencies: |
| 14045 | - supports-color | 16747 | - supports-color |
| 14046 | 16748 | ||
| 14047 | metro-transform-worker@0.84.4: | 16749 | metro-transform-worker@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6): |
| 14048 | dependencies: | 16750 | dependencies: |
| 14049 | '@babel/core': 7.29.7 | 16751 | '@babel/core': 7.29.7 |
| 14050 | '@babel/generator': 7.29.7 | 16752 | '@babel/generator': 7.29.7 |
| 14051 | '@babel/parser': 7.29.7 | 16753 | '@babel/parser': 7.29.7 |
| 14052 | '@babel/types': 7.29.7 | 16754 | '@babel/types': 7.29.7 |
| 14053 | flow-enums-runtime: 0.0.6 | 16755 | flow-enums-runtime: 0.0.6 |
| 14054 | metro: 0.84.4 | 16756 | metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 14055 | metro-babel-transformer: 0.84.4 | 16757 | metro-babel-transformer: 0.84.4 |
| 14056 | metro-cache: 0.84.4 | 16758 | metro-cache: 0.84.4 |
| 14057 | metro-cache-key: 0.84.4 | 16759 | metro-cache-key: 0.84.4 |
| ... | @@ -14064,7 +16766,7 @@ snapshots: | ... | @@ -14064,7 +16766,7 @@ snapshots: |
| 14064 | - supports-color | 16766 | - supports-color |
| 14065 | - utf-8-validate | 16767 | - utf-8-validate |
| 14066 | 16768 | ||
| 14067 | metro@0.84.4: | 16769 | metro@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6): |
| 14068 | dependencies: | 16770 | dependencies: |
| 14069 | '@babel/code-frame': 7.29.7 | 16771 | '@babel/code-frame': 7.29.7 |
| 14070 | '@babel/core': 7.29.7 | 16772 | '@babel/core': 7.29.7 |
| ... | @@ -14089,7 +16791,7 @@ snapshots: | ... | @@ -14089,7 +16791,7 @@ snapshots: |
| 14089 | metro-babel-transformer: 0.84.4 | 16791 | metro-babel-transformer: 0.84.4 |
| 14090 | metro-cache: 0.84.4 | 16792 | metro-cache: 0.84.4 |
| 14091 | metro-cache-key: 0.84.4 | 16793 | metro-cache-key: 0.84.4 |
| 14092 | metro-config: 0.84.4 | 16794 | metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 14093 | metro-core: 0.84.4 | 16795 | metro-core: 0.84.4 |
| 14094 | metro-file-map: 0.84.4 | 16796 | metro-file-map: 0.84.4 |
| 14095 | metro-resolver: 0.84.4 | 16797 | metro-resolver: 0.84.4 |
| ... | @@ -14097,13 +16799,13 @@ snapshots: | ... | @@ -14097,13 +16799,13 @@ snapshots: |
| 14097 | metro-source-map: 0.84.4 | 16799 | metro-source-map: 0.84.4 |
| 14098 | metro-symbolicate: 0.84.4 | 16800 | metro-symbolicate: 0.84.4 |
| 14099 | metro-transform-plugins: 0.84.4 | 16801 | metro-transform-plugins: 0.84.4 |
| 14100 | metro-transform-worker: 0.84.4 | 16802 | metro-transform-worker: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 14101 | mime-types: 3.0.2 | 16803 | mime-types: 3.0.2 |
| 14102 | nullthrows: 1.1.1 | 16804 | nullthrows: 1.1.1 |
| 14103 | serialize-error: 2.1.0 | 16805 | serialize-error: 2.1.0 |
| 14104 | source-map: 0.5.7 | 16806 | source-map: 0.5.7 |
| 14105 | throat: 5.0.0 | 16807 | throat: 5.0.0 |
| 14106 | ws: 7.5.11 | 16808 | ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 14107 | yargs: 17.7.2 | 16809 | yargs: 17.7.2 |
| 14108 | transitivePeerDependencies: | 16810 | transitivePeerDependencies: |
| 14109 | - bufferutil | 16811 | - bufferutil |
| ... | @@ -14326,6 +17028,8 @@ snapshots: | ... | @@ -14326,6 +17028,8 @@ snapshots: |
| 14326 | 17028 | ||
| 14327 | mimic-fn@1.2.0: {} | 17029 | mimic-fn@1.2.0: {} |
| 14328 | 17030 | ||
| 17031 | mimic-fn@2.1.0: {} | ||
| 17032 | |||
| 14329 | mimic-response@1.0.1: {} | 17033 | mimic-response@1.0.1: {} |
| 14330 | 17034 | ||
| 14331 | mimic-response@3.1.0: {} | 17035 | mimic-response@3.1.0: {} |
| ... | @@ -14417,6 +17121,22 @@ snapshots: | ... | @@ -14417,6 +17121,22 @@ snapshots: |
| 14417 | 17121 | ||
| 14418 | mute-stream@2.0.0: {} | 17122 | mute-stream@2.0.0: {} |
| 14419 | 17123 | ||
| 17124 | mysql2@3.22.4(@types/node@24.12.4): | ||
| 17125 | dependencies: | ||
| 17126 | '@types/node': 24.12.4 | ||
| 17127 | aws-ssl-profiles: 1.1.2 | ||
| 17128 | denque: 2.1.0 | ||
| 17129 | generate-function: 2.3.1 | ||
| 17130 | iconv-lite: 0.7.2 | ||
| 17131 | long: 5.3.2 | ||
| 17132 | lru.min: 1.1.4 | ||
| 17133 | named-placeholders: 1.1.6 | ||
| 17134 | sql-escaper: 1.3.3 | ||
| 17135 | |||
| 17136 | named-placeholders@1.1.6: | ||
| 17137 | dependencies: | ||
| 17138 | lru.min: 1.1.4 | ||
| 17139 | |||
| 14420 | nanoid@3.3.12: {} | 17140 | nanoid@3.3.12: {} |
| 14421 | 17141 | ||
| 14422 | negotiator@0.6.3: {} | 17142 | negotiator@0.6.3: {} |
| ... | @@ -14453,6 +17173,9 @@ snapshots: | ... | @@ -14453,6 +17173,9 @@ snapshots: |
| 14453 | detect-libc: 2.1.2 | 17173 | detect-libc: 2.1.2 |
| 14454 | optional: true | 17174 | optional: true |
| 14455 | 17175 | ||
| 17176 | node-gyp-build@4.8.4: | ||
| 17177 | optional: true | ||
| 17178 | |||
| 14456 | node-gyp@12.3.0: | 17179 | node-gyp@12.3.0: |
| 14457 | dependencies: | 17180 | dependencies: |
| 14458 | env-paths: 2.2.1 | 17181 | env-paths: 2.2.1 |
| ... | @@ -14508,6 +17231,8 @@ snapshots: | ... | @@ -14508,6 +17231,8 @@ snapshots: |
| 14508 | object-keys@1.1.1: | 17231 | object-keys@1.1.1: |
| 14509 | optional: true | 17232 | optional: true |
| 14510 | 17233 | ||
| 17234 | obuf@1.1.2: {} | ||
| 17235 | |||
| 14511 | obug@2.1.1: {} | 17236 | obug@2.1.1: {} |
| 14512 | 17237 | ||
| 14513 | ofetch@1.5.1: | 17238 | ofetch@1.5.1: |
| ... | @@ -14536,6 +17261,10 @@ snapshots: | ... | @@ -14536,6 +17261,10 @@ snapshots: |
| 14536 | dependencies: | 17261 | dependencies: |
| 14537 | mimic-fn: 1.2.0 | 17262 | mimic-fn: 1.2.0 |
| 14538 | 17263 | ||
| 17264 | onetime@5.1.2: | ||
| 17265 | dependencies: | ||
| 17266 | mimic-fn: 2.1.0 | ||
| 17267 | |||
| 14539 | oniguruma-parser@0.12.2: {} | 17268 | oniguruma-parser@0.12.2: {} |
| 14540 | 17269 | ||
| 14541 | oniguruma-to-es@4.3.6: | 17270 | oniguruma-to-es@4.3.6: |
| ... | @@ -14558,9 +17287,13 @@ snapshots: | ... | @@ -14558,9 +17287,13 @@ snapshots: |
| 14558 | strip-ansi: 5.2.0 | 17287 | strip-ansi: 5.2.0 |
| 14559 | wcwidth: 1.0.1 | 17288 | wcwidth: 1.0.1 |
| 14560 | 17289 | ||
| 17290 | os-paths@7.4.0: | ||
| 17291 | optionalDependencies: | ||
| 17292 | fsevents: 2.3.3 | ||
| 17293 | |||
| 14561 | outvariant@1.4.3: {} | 17294 | outvariant@1.4.3: {} |
| 14562 | 17295 | ||
| 14563 | 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)): | 17296 | 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)): |
| 14564 | dependencies: | 17297 | dependencies: |
| 14565 | tinypool: 2.1.0 | 17298 | tinypool: 2.1.0 |
| 14566 | optionalDependencies: | 17299 | optionalDependencies: |
| ... | @@ -14583,7 +17316,7 @@ snapshots: | ... | @@ -14583,7 +17316,7 @@ snapshots: |
| 14583 | '@oxfmt/binding-win32-arm64-msvc': 0.52.0 | 17316 | '@oxfmt/binding-win32-arm64-msvc': 0.52.0 |
| 14584 | '@oxfmt/binding-win32-ia32-msvc': 0.52.0 | 17317 | '@oxfmt/binding-win32-ia32-msvc': 0.52.0 |
| 14585 | '@oxfmt/binding-win32-x64-msvc': 0.52.0 | 17318 | '@oxfmt/binding-win32-x64-msvc': 0.52.0 |
| 14586 | 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) | 17319 | 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) |
| 14587 | 17320 | ||
| 14588 | oxlint-tsgolint@0.23.0: | 17321 | oxlint-tsgolint@0.23.0: |
| 14589 | optionalDependencies: | 17322 | optionalDependencies: |
| ... | @@ -14594,7 +17327,7 @@ snapshots: | ... | @@ -14594,7 +17327,7 @@ snapshots: |
| 14594 | '@oxlint-tsgolint/win32-arm64': 0.23.0 | 17327 | '@oxlint-tsgolint/win32-arm64': 0.23.0 |
| 14595 | '@oxlint-tsgolint/win32-x64': 0.23.0 | 17328 | '@oxlint-tsgolint/win32-x64': 0.23.0 |
| 14596 | 17329 | ||
| 14597 | 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)): | 17330 | 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)): |
| 14598 | optionalDependencies: | 17331 | optionalDependencies: |
| 14599 | '@oxlint/binding-android-arm-eabi': 1.67.0 | 17332 | '@oxlint/binding-android-arm-eabi': 1.67.0 |
| 14600 | '@oxlint/binding-android-arm64': 1.67.0 | 17333 | '@oxlint/binding-android-arm64': 1.67.0 |
| ... | @@ -14616,7 +17349,7 @@ snapshots: | ... | @@ -14616,7 +17349,7 @@ snapshots: |
| 14616 | '@oxlint/binding-win32-ia32-msvc': 1.67.0 | 17349 | '@oxlint/binding-win32-ia32-msvc': 1.67.0 |
| 14617 | '@oxlint/binding-win32-x64-msvc': 1.67.0 | 17350 | '@oxlint/binding-win32-x64-msvc': 1.67.0 |
| 14618 | oxlint-tsgolint: 0.23.0 | 17351 | oxlint-tsgolint: 0.23.0 |
| 14619 | 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) | 17352 | 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) |
| 14620 | 17353 | ||
| 14621 | p-cancelable@2.1.1: {} | 17354 | p-cancelable@2.1.1: {} |
| 14622 | 17355 | ||
| ... | @@ -14637,6 +17370,8 @@ snapshots: | ... | @@ -14637,6 +17370,8 @@ snapshots: |
| 14637 | 17370 | ||
| 14638 | package-manager-detector@1.6.0: {} | 17371 | package-manager-detector@1.6.0: {} |
| 14639 | 17372 | ||
| 17373 | pako@1.0.11: {} | ||
| 17374 | |||
| 14640 | parse-entities@4.0.2: | 17375 | parse-entities@4.0.2: |
| 14641 | dependencies: | 17376 | dependencies: |
| 14642 | '@types/unist': 2.0.11 | 17377 | '@types/unist': 2.0.11 |
| ... | @@ -14666,8 +17401,12 @@ snapshots: | ... | @@ -14666,8 +17401,12 @@ snapshots: |
| 14666 | 17401 | ||
| 14667 | parseurl@1.3.3: {} | 17402 | parseurl@1.3.3: {} |
| 14668 | 17403 | ||
| 17404 | patch-console@2.0.0: {} | ||
| 17405 | |||
| 14669 | path-browserify@1.0.1: {} | 17406 | path-browserify@1.0.1: {} |
| 14670 | 17407 | ||
| 17408 | path-expression-matcher@1.5.0: {} | ||
| 17409 | |||
| 14671 | path-is-absolute@1.0.1: {} | 17410 | path-is-absolute@1.0.1: {} |
| 14672 | 17411 | ||
| 14673 | path-key@3.1.1: {} | 17412 | path-key@3.1.1: {} |
| ... | @@ -14691,6 +17430,59 @@ snapshots: | ... | @@ -14691,6 +17430,59 @@ snapshots: |
| 14691 | 17430 | ||
| 14692 | pend@1.2.0: {} | 17431 | pend@1.2.0: {} |
| 14693 | 17432 | ||
| 17433 | pg-cloudflare@1.4.0: | ||
| 17434 | optional: true | ||
| 17435 | |||
| 17436 | pg-connection-string@2.12.0: {} | ||
| 17437 | |||
| 17438 | pg-connection-string@2.13.0: {} | ||
| 17439 | |||
| 17440 | pg-cursor@2.20.0(pg@8.21.0): | ||
| 17441 | dependencies: | ||
| 17442 | pg: 8.21.0 | ||
| 17443 | |||
| 17444 | pg-int8@1.0.1: {} | ||
| 17445 | |||
| 17446 | pg-numeric@1.0.2: {} | ||
| 17447 | |||
| 17448 | pg-pool@3.14.0(pg@8.21.0): | ||
| 17449 | dependencies: | ||
| 17450 | pg: 8.21.0 | ||
| 17451 | |||
| 17452 | pg-protocol@1.14.0: {} | ||
| 17453 | |||
| 17454 | pg-types@2.2.0: | ||
| 17455 | dependencies: | ||
| 17456 | pg-int8: 1.0.1 | ||
| 17457 | postgres-array: 2.0.0 | ||
| 17458 | postgres-bytea: 1.0.1 | ||
| 17459 | postgres-date: 1.0.7 | ||
| 17460 | postgres-interval: 1.2.0 | ||
| 17461 | |||
| 17462 | pg-types@4.1.0: | ||
| 17463 | dependencies: | ||
| 17464 | pg-int8: 1.0.1 | ||
| 17465 | pg-numeric: 1.0.2 | ||
| 17466 | postgres-array: 3.0.4 | ||
| 17467 | postgres-bytea: 3.0.0 | ||
| 17468 | postgres-date: 2.1.0 | ||
| 17469 | postgres-interval: 3.0.0 | ||
| 17470 | postgres-range: 1.1.4 | ||
| 17471 | |||
| 17472 | pg@8.21.0: | ||
| 17473 | dependencies: | ||
| 17474 | pg-connection-string: 2.13.0 | ||
| 17475 | pg-pool: 3.14.0(pg@8.21.0) | ||
| 17476 | pg-protocol: 1.14.0 | ||
| 17477 | pg-types: 2.2.0 | ||
| 17478 | pgpass: 1.0.5 | ||
| 17479 | optionalDependencies: | ||
| 17480 | pg-cloudflare: 1.4.0 | ||
| 17481 | |||
| 17482 | pgpass@1.0.5: | ||
| 17483 | dependencies: | ||
| 17484 | split2: 4.2.0 | ||
| 17485 | |||
| 14694 | piccolore@0.1.3: {} | 17486 | piccolore@0.1.3: {} |
| 14695 | 17487 | ||
| 14696 | picocolors@1.1.1: {} | 17488 | picocolors@1.1.1: {} |
| ... | @@ -14735,6 +17527,28 @@ snapshots: | ... | @@ -14735,6 +17527,28 @@ snapshots: |
| 14735 | picocolors: 1.1.1 | 17527 | picocolors: 1.1.1 |
| 14736 | source-map-js: 1.2.1 | 17528 | source-map-js: 1.2.1 |
| 14737 | 17529 | ||
| 17530 | postgres-array@2.0.0: {} | ||
| 17531 | |||
| 17532 | postgres-array@3.0.4: {} | ||
| 17533 | |||
| 17534 | postgres-bytea@1.0.1: {} | ||
| 17535 | |||
| 17536 | postgres-bytea@3.0.0: | ||
| 17537 | dependencies: | ||
| 17538 | obuf: 1.1.2 | ||
| 17539 | |||
| 17540 | postgres-date@1.0.7: {} | ||
| 17541 | |||
| 17542 | postgres-date@2.1.0: {} | ||
| 17543 | |||
| 17544 | postgres-interval@1.2.0: | ||
| 17545 | dependencies: | ||
| 17546 | xtend: 4.0.2 | ||
| 17547 | |||
| 17548 | postgres-interval@3.0.0: {} | ||
| 17549 | |||
| 17550 | postgres-range@1.1.4: {} | ||
| 17551 | |||
| 14738 | postject@1.0.0-alpha.6: | 17552 | postject@1.0.0-alpha.6: |
| 14739 | dependencies: | 17553 | dependencies: |
| 14740 | commander: 9.5.0 | 17554 | commander: 9.5.0 |
| ... | @@ -14764,8 +17578,12 @@ snapshots: | ... | @@ -14764,8 +17578,12 @@ snapshots: |
| 14764 | 17578 | ||
| 14765 | proc-log@6.1.0: {} | 17579 | proc-log@6.1.0: {} |
| 14766 | 17580 | ||
| 17581 | process-nextick-args@2.0.1: {} | ||
| 17582 | |||
| 14767 | progress@2.0.3: {} | 17583 | progress@2.0.3: {} |
| 14768 | 17584 | ||
| 17585 | promise-limit@2.7.0: {} | ||
| 17586 | |||
| 14769 | promise-retry@2.0.1: | 17587 | promise-retry@2.0.1: |
| 14770 | dependencies: | 17588 | dependencies: |
| 14771 | err-code: 2.0.3 | 17589 | err-code: 2.0.3 |
| ... | @@ -14813,6 +17631,8 @@ snapshots: | ... | @@ -14813,6 +17631,8 @@ snapshots: |
| 14813 | split-on-first: 1.1.0 | 17631 | split-on-first: 1.1.0 |
| 14814 | strict-uri-encode: 2.0.0 | 17632 | strict-uri-encode: 2.0.0 |
| 14815 | 17633 | ||
| 17634 | queue-microtask@1.2.3: {} | ||
| 17635 | |||
| 14816 | queue@6.0.2: | 17636 | queue@6.0.2: |
| 14817 | dependencies: | 17637 | dependencies: |
| 14818 | inherits: 2.0.4 | 17638 | inherits: 2.0.4 |
| ... | @@ -14830,10 +17650,10 @@ snapshots: | ... | @@ -14830,10 +17650,10 @@ snapshots: |
| 14830 | iconv-lite: 0.7.2 | 17650 | iconv-lite: 0.7.2 |
| 14831 | unpipe: 1.0.0 | 17651 | unpipe: 1.0.0 |
| 14832 | 17652 | ||
| 14833 | react-devtools-core@6.1.5: | 17653 | react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6): |
| 14834 | dependencies: | 17654 | dependencies: |
| 14835 | shell-quote: 1.8.4 | 17655 | shell-quote: 1.8.4 |
| 14836 | ws: 7.5.11 | 17656 | ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 14837 | transitivePeerDependencies: | 17657 | transitivePeerDependencies: |
| 14838 | - bufferutil | 17658 | - bufferutil |
| 14839 | - utf-8-validate | 17659 | - utf-8-validate |
| ... | @@ -14884,90 +17704,95 @@ snapshots: | ... | @@ -14884,90 +17704,95 @@ snapshots: |
| 14884 | transitivePeerDependencies: | 17704 | transitivePeerDependencies: |
| 14885 | - supports-color | 17705 | - supports-color |
| 14886 | 17706 | ||
| 14887 | 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): | 17707 | react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14888 | dependencies: | 17708 | dependencies: |
| 14889 | color: 4.2.3 | 17709 | color: 4.2.3 |
| 14890 | react: 19.2.3 | 17710 | react: 19.2.3 |
| 14891 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17711 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14892 | 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) | 17712 | react-native-gesture-handler: 2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14893 | 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) | 17713 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14894 | use-latest-callback: 0.2.6(react@19.2.3) | 17714 | use-latest-callback: 0.2.6(react@19.2.3) |
| 14895 | 17715 | ||
| 14896 | 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): | 17716 | react-native-gesture-handler@2.31.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14897 | dependencies: | 17717 | dependencies: |
| 14898 | '@egjs/hammerjs': 2.0.17 | 17718 | '@egjs/hammerjs': 2.0.17 |
| 14899 | '@types/react-test-renderer': 19.1.0 | 17719 | '@types/react-test-renderer': 19.1.0 |
| 14900 | hoist-non-react-statics: 3.3.2 | 17720 | hoist-non-react-statics: 3.3.2 |
| 14901 | invariant: 2.2.4 | 17721 | invariant: 2.2.4 |
| 14902 | react: 19.2.3 | 17722 | react: 19.2.3 |
| 14903 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17723 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14904 | 17724 | ||
| 14905 | 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): | 17725 | react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14906 | dependencies: | 17726 | dependencies: |
| 14907 | react: 19.2.3 | 17727 | react: 19.2.3 |
| 14908 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17728 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14909 | 17729 | ||
| 14910 | 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): | 17730 | react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14911 | dependencies: | 17731 | dependencies: |
| 14912 | react: 19.2.3 | 17732 | react: 19.2.3 |
| 14913 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17733 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14914 | 17734 | ||
| 14915 | 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): | 17735 | react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14916 | dependencies: | 17736 | dependencies: |
| 14917 | react: 19.2.3 | 17737 | react: 19.2.3 |
| 14918 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17738 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14919 | 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) | 17739 | react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14920 | 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) | 17740 | react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14921 | 17741 | ||
| 14922 | 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): | 17742 | react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14923 | dependencies: | 17743 | dependencies: |
| 14924 | react: 19.2.3 | 17744 | react: 19.2.3 |
| 14925 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17745 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14926 | 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) | 17746 | react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14927 | optionalDependencies: | 17747 | optionalDependencies: |
| 14928 | 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) | 17748 | react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14929 | 17749 | ||
| 14930 | 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): | 17750 | react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14931 | dependencies: | 17751 | dependencies: |
| 14932 | react: 19.2.3 | 17752 | react: 19.2.3 |
| 14933 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17753 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14934 | 17754 | ||
| 14935 | 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): | 17755 | react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14936 | dependencies: | 17756 | dependencies: |
| 14937 | react: 19.2.3 | 17757 | react: 19.2.3 |
| 14938 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17758 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14939 | 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) | 17759 | react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14940 | 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) | 17760 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14941 | semver: 7.8.1 | 17761 | semver: 7.8.1 |
| 14942 | 17762 | ||
| 14943 | 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): | 17763 | react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14944 | dependencies: | 17764 | dependencies: |
| 14945 | react: 19.2.3 | 17765 | react: 19.2.3 |
| 14946 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17766 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14947 | 17767 | ||
| 14948 | 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): | 17768 | react-native-screens@4.25.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14949 | dependencies: | 17769 | dependencies: |
| 14950 | react: 19.2.3 | 17770 | react: 19.2.3 |
| 14951 | react-freeze: 1.0.4(react@19.2.3) | 17771 | react-freeze: 1.0.4(react@19.2.3) |
| 14952 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17772 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14953 | warn-once: 0.1.1 | 17773 | warn-once: 0.1.1 |
| 14954 | 17774 | ||
| 14955 | 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): | 17775 | react-native-shiki-engine@0.3.10(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14956 | dependencies: | 17776 | dependencies: |
| 14957 | '@shikijs/types': 3.23.0 | 17777 | '@shikijs/types': 3.23.0 |
| 14958 | '@shikijs/vscode-textmate': 10.0.2 | 17778 | '@shikijs/vscode-textmate': 10.0.2 |
| 14959 | react: 19.2.3 | 17779 | react: 19.2.3 |
| 14960 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17780 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14961 | 17781 | ||
| 14962 | 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): | 17782 | react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 14963 | dependencies: | 17783 | dependencies: |
| 14964 | css-select: 5.2.2 | 17784 | css-select: 5.2.2 |
| 14965 | css-tree: 1.1.3 | 17785 | css-tree: 1.1.3 |
| 14966 | react: 19.2.3 | 17786 | react: 19.2.3 |
| 14967 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17787 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14968 | warn-once: 0.1.1 | 17788 | warn-once: 0.1.1 |
| 14969 | 17789 | ||
| 14970 | 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): | 17790 | react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 17791 | dependencies: | ||
| 17792 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | ||
| 17793 | whatwg-url-without-unicode: 8.0.0-3 | ||
| 17794 | |||
| 17795 | react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | ||
| 14971 | dependencies: | 17796 | dependencies: |
| 14972 | '@babel/core': 7.29.7 | 17797 | '@babel/core': 7.29.7 |
| 14973 | '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) | 17798 | '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) |
| ... | @@ -14982,20 +17807,20 @@ snapshots: | ... | @@ -14982,20 +17807,20 @@ snapshots: |
| 14982 | '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) | 17807 | '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) |
| 14983 | convert-source-map: 2.0.0 | 17808 | convert-source-map: 2.0.0 |
| 14984 | react: 19.2.3 | 17809 | react: 19.2.3 |
| 14985 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 17810 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 14986 | semver: 7.8.1 | 17811 | semver: 7.8.1 |
| 14987 | transitivePeerDependencies: | 17812 | transitivePeerDependencies: |
| 14988 | - supports-color | 17813 | - supports-color |
| 14989 | 17814 | ||
| 14990 | react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3): | 17815 | react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): |
| 14991 | dependencies: | 17816 | dependencies: |
| 14992 | '@react-native/assets-registry': 0.85.3 | 17817 | '@react-native/assets-registry': 0.85.3 |
| 14993 | '@react-native/codegen': 0.85.3(@babel/core@7.29.7) | 17818 | '@react-native/codegen': 0.85.3(@babel/core@7.29.7) |
| 14994 | '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)) | 17819 | '@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) |
| 14995 | '@react-native/gradle-plugin': 0.85.3 | 17820 | '@react-native/gradle-plugin': 0.85.3 |
| 14996 | '@react-native/js-polyfills': 0.85.3 | 17821 | '@react-native/js-polyfills': 0.85.3 |
| 14997 | '@react-native/normalize-colors': 0.85.3 | 17822 | '@react-native/normalize-colors': 0.85.3 |
| 14998 | '@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) | 17823 | '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 14999 | abort-controller: 3.0.0 | 17824 | abort-controller: 3.0.0 |
| 15000 | anser: 1.4.10 | 17825 | anser: 1.4.10 |
| 15001 | ansi-regex: 5.0.1 | 17826 | ansi-regex: 5.0.1 |
| ... | @@ -15012,7 +17837,7 @@ snapshots: | ... | @@ -15012,7 +17837,7 @@ snapshots: |
| 15012 | pretty-format: 29.7.0 | 17837 | pretty-format: 29.7.0 |
| 15013 | promise: 8.3.0 | 17838 | promise: 8.3.0 |
| 15014 | react: 19.2.3 | 17839 | react: 19.2.3 |
| 15015 | react-devtools-core: 6.1.5 | 17840 | react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 15016 | react-refresh: 0.14.2 | 17841 | react-refresh: 0.14.2 |
| 15017 | regenerator-runtime: 0.13.11 | 17842 | regenerator-runtime: 0.13.11 |
| 15018 | scheduler: 0.27.0 | 17843 | scheduler: 0.27.0 |
| ... | @@ -15020,7 +17845,7 @@ snapshots: | ... | @@ -15020,7 +17845,7 @@ snapshots: |
| 15020 | stacktrace-parser: 0.1.11 | 17845 | stacktrace-parser: 0.1.11 |
| 15021 | tinyglobby: 0.2.17 | 17846 | tinyglobby: 0.2.17 |
| 15022 | whatwg-fetch: 3.6.20 | 17847 | whatwg-fetch: 3.6.20 |
| 15023 | ws: 7.5.11 | 17848 | ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 15024 | yargs: 17.7.2 | 17849 | yargs: 17.7.2 |
| 15025 | optionalDependencies: | 17850 | optionalDependencies: |
| 15026 | '@types/react': 19.2.16 | 17851 | '@types/react': 19.2.16 |
| ... | @@ -15032,6 +17857,11 @@ snapshots: | ... | @@ -15032,6 +17857,11 @@ snapshots: |
| 15032 | - supports-color | 17857 | - supports-color |
| 15033 | - utf-8-validate | 17858 | - utf-8-validate |
| 15034 | 17859 | ||
| 17860 | react-reconciler@0.33.0(react@19.2.6): | ||
| 17861 | dependencies: | ||
| 17862 | react: 19.2.6 | ||
| 17863 | scheduler: 0.27.0 | ||
| 17864 | |||
| 15035 | react-refresh@0.14.2: {} | 17865 | react-refresh@0.14.2: {} |
| 15036 | 17866 | ||
| 15037 | react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.3): | 17867 | react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.3): |
| ... | @@ -15071,6 +17901,16 @@ snapshots: | ... | @@ -15071,6 +17901,16 @@ snapshots: |
| 15071 | transitivePeerDependencies: | 17901 | transitivePeerDependencies: |
| 15072 | - supports-color | 17902 | - supports-color |
| 15073 | 17903 | ||
| 17904 | readable-stream@2.3.8: | ||
| 17905 | dependencies: | ||
| 17906 | core-util-is: 1.0.2 | ||
| 17907 | inherits: 2.0.4 | ||
| 17908 | isarray: 1.0.0 | ||
| 17909 | process-nextick-args: 2.0.1 | ||
| 17910 | safe-buffer: 5.1.2 | ||
| 17911 | string_decoder: 1.1.1 | ||
| 17912 | util-deprecate: 1.0.2 | ||
| 17913 | |||
| 15074 | readdirp@4.1.2: {} | 17914 | readdirp@4.1.2: {} |
| 15075 | 17915 | ||
| 15076 | readdirp@5.0.0: {} | 17916 | readdirp@5.0.0: {} |
| ... | @@ -15223,6 +18063,11 @@ snapshots: | ... | @@ -15223,6 +18063,11 @@ snapshots: |
| 15223 | onetime: 2.0.1 | 18063 | onetime: 2.0.1 |
| 15224 | signal-exit: 3.0.7 | 18064 | signal-exit: 3.0.7 |
| 15225 | 18065 | ||
| 18066 | restore-cursor@4.0.0: | ||
| 18067 | dependencies: | ||
| 18068 | onetime: 5.1.2 | ||
| 18069 | signal-exit: 3.0.7 | ||
| 18070 | |||
| 15226 | retext-latin@4.0.0: | 18071 | retext-latin@4.0.0: |
| 15227 | dependencies: | 18072 | dependencies: |
| 15228 | '@types/nlcst': 2.0.3 | 18073 | '@types/nlcst': 2.0.3 |
| ... | @@ -15252,6 +18097,8 @@ snapshots: | ... | @@ -15252,6 +18097,8 @@ snapshots: |
| 15252 | 18097 | ||
| 15253 | rettime@0.10.1: {} | 18098 | rettime@0.10.1: {} |
| 15254 | 18099 | ||
| 18100 | reusify@1.1.0: {} | ||
| 18101 | |||
| 15255 | rimraf@2.6.3: | 18102 | rimraf@2.6.3: |
| 15256 | dependencies: | 18103 | dependencies: |
| 15257 | glob: 7.2.3 | 18104 | glob: 7.2.3 |
| ... | @@ -15288,6 +18135,27 @@ snapshots: | ... | @@ -15288,6 +18135,27 @@ snapshots: |
| 15288 | '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 | 18135 | '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 |
| 15289 | optional: true | 18136 | optional: true |
| 15290 | 18137 | ||
| 18138 | rolldown@1.0.1: | ||
| 18139 | dependencies: | ||
| 18140 | '@oxc-project/types': 0.130.0 | ||
| 18141 | '@rolldown/pluginutils': 1.0.1 | ||
| 18142 | optionalDependencies: | ||
| 18143 | '@rolldown/binding-android-arm64': 1.0.1 | ||
| 18144 | '@rolldown/binding-darwin-arm64': 1.0.1 | ||
| 18145 | '@rolldown/binding-darwin-x64': 1.0.1 | ||
| 18146 | '@rolldown/binding-freebsd-x64': 1.0.1 | ||
| 18147 | '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 | ||
| 18148 | '@rolldown/binding-linux-arm64-gnu': 1.0.1 | ||
| 18149 | '@rolldown/binding-linux-arm64-musl': 1.0.1 | ||
| 18150 | '@rolldown/binding-linux-ppc64-gnu': 1.0.1 | ||
| 18151 | '@rolldown/binding-linux-s390x-gnu': 1.0.1 | ||
| 18152 | '@rolldown/binding-linux-x64-gnu': 1.0.1 | ||
| 18153 | '@rolldown/binding-linux-x64-musl': 1.0.1 | ||
| 18154 | '@rolldown/binding-openharmony-arm64': 1.0.1 | ||
| 18155 | '@rolldown/binding-wasm32-wasi': 1.0.1 | ||
| 18156 | '@rolldown/binding-win32-arm64-msvc': 1.0.1 | ||
| 18157 | '@rolldown/binding-win32-x64-msvc': 1.0.1 | ||
| 18158 | |||
| 15291 | rolldown@1.0.3: | 18159 | rolldown@1.0.3: |
| 15292 | dependencies: | 18160 | dependencies: |
| 15293 | '@oxc-project/types': 0.133.0 | 18161 | '@oxc-project/types': 0.133.0 |
| ... | @@ -15351,6 +18219,12 @@ snapshots: | ... | @@ -15351,6 +18219,12 @@ snapshots: |
| 15351 | transitivePeerDependencies: | 18219 | transitivePeerDependencies: |
| 15352 | - supports-color | 18220 | - supports-color |
| 15353 | 18221 | ||
| 18222 | run-parallel@1.2.0: | ||
| 18223 | dependencies: | ||
| 18224 | queue-microtask: 1.2.3 | ||
| 18225 | |||
| 18226 | safe-buffer@5.1.2: {} | ||
| 18227 | |||
| 15354 | safe-buffer@5.2.1: {} | 18228 | safe-buffer@5.2.1: {} |
| 15355 | 18229 | ||
| 15356 | safer-buffer@2.1.2: {} | 18230 | safer-buffer@2.1.2: {} |
| ... | @@ -15441,6 +18315,8 @@ snapshots: | ... | @@ -15441,6 +18315,8 @@ snapshots: |
| 15441 | 18315 | ||
| 15442 | server-only@0.0.1: {} | 18316 | server-only@0.0.1: {} |
| 15443 | 18317 | ||
| 18318 | setimmediate@1.0.5: {} | ||
| 18319 | |||
| 15444 | setprototypeof@1.2.0: {} | 18320 | setprototypeof@1.2.0: {} |
| 15445 | 18321 | ||
| 15446 | sf-symbols-typescript@2.2.0: {} | 18322 | sf-symbols-typescript@2.2.0: {} |
| ... | @@ -15570,6 +18446,11 @@ snapshots: | ... | @@ -15570,6 +18446,11 @@ snapshots: |
| 15570 | is-fullwidth-code-point: 3.0.0 | 18446 | is-fullwidth-code-point: 3.0.0 |
| 15571 | optional: true | 18447 | optional: true |
| 15572 | 18448 | ||
| 18449 | slice-ansi@8.0.0: | ||
| 18450 | dependencies: | ||
| 18451 | ansi-styles: 6.2.3 | ||
| 18452 | is-fullwidth-code-point: 5.1.0 | ||
| 18453 | |||
| 15573 | slugify@1.6.9: {} | 18454 | slugify@1.6.9: {} |
| 15574 | 18455 | ||
| 15575 | smart-buffer@4.2.0: | 18456 | smart-buffer@4.2.0: |
| ... | @@ -15592,9 +18473,17 @@ snapshots: | ... | @@ -15592,9 +18473,17 @@ snapshots: |
| 15592 | 18473 | ||
| 15593 | split-on-first@1.1.0: {} | 18474 | split-on-first@1.1.0: {} |
| 15594 | 18475 | ||
| 18476 | split2@4.2.0: {} | ||
| 18477 | |||
| 15595 | sprintf-js@1.1.3: | 18478 | sprintf-js@1.1.3: |
| 15596 | optional: true | 18479 | optional: true |
| 15597 | 18480 | ||
| 18481 | sql-escaper@1.3.3: {} | ||
| 18482 | |||
| 18483 | stack-utils@2.0.6: | ||
| 18484 | dependencies: | ||
| 18485 | escape-string-regexp: 2.0.0 | ||
| 18486 | |||
| 15598 | stackframe@1.3.4: {} | 18487 | stackframe@1.3.4: {} |
| 15599 | 18488 | ||
| 15600 | stacktrace-parser@0.1.11: | 18489 | stacktrace-parser@0.1.11: |
| ... | @@ -15603,6 +18492,11 @@ snapshots: | ... | @@ -15603,6 +18492,11 @@ snapshots: |
| 15603 | 18492 | ||
| 15604 | standard-as-callback@2.1.0: {} | 18493 | standard-as-callback@2.1.0: {} |
| 15605 | 18494 | ||
| 18495 | standardwebhooks@1.0.0: | ||
| 18496 | dependencies: | ||
| 18497 | '@stablelib/base64': 1.0.1 | ||
| 18498 | fast-sha256: 1.3.0 | ||
| 18499 | |||
| 15606 | stat-mode@1.0.0: {} | 18500 | stat-mode@1.0.0: {} |
| 15607 | 18501 | ||
| 15608 | statuses@1.5.0: {} | 18502 | statuses@1.5.0: {} |
| ... | @@ -15623,6 +18517,21 @@ snapshots: | ... | @@ -15623,6 +18517,21 @@ snapshots: |
| 15623 | is-fullwidth-code-point: 3.0.0 | 18517 | is-fullwidth-code-point: 3.0.0 |
| 15624 | strip-ansi: 6.0.1 | 18518 | strip-ansi: 6.0.1 |
| 15625 | 18519 | ||
| 18520 | string-width@7.2.0: | ||
| 18521 | dependencies: | ||
| 18522 | emoji-regex: 10.6.0 | ||
| 18523 | get-east-asian-width: 1.6.0 | ||
| 18524 | strip-ansi: 7.2.0 | ||
| 18525 | |||
| 18526 | string-width@8.2.1: | ||
| 18527 | dependencies: | ||
| 18528 | get-east-asian-width: 1.6.0 | ||
| 18529 | strip-ansi: 7.2.0 | ||
| 18530 | |||
| 18531 | string_decoder@1.1.1: | ||
| 18532 | dependencies: | ||
| 18533 | safe-buffer: 5.1.2 | ||
| 18534 | |||
| 15626 | stringify-entities@4.0.4: | 18535 | stringify-entities@4.0.4: |
| 15627 | dependencies: | 18536 | dependencies: |
| 15628 | character-entities-html4: 2.1.0 | 18537 | character-entities-html4: 2.1.0 |
| ... | @@ -15636,10 +18545,16 @@ snapshots: | ... | @@ -15636,10 +18545,16 @@ snapshots: |
| 15636 | dependencies: | 18545 | dependencies: |
| 15637 | ansi-regex: 5.0.1 | 18546 | ansi-regex: 5.0.1 |
| 15638 | 18547 | ||
| 18548 | strip-ansi@7.2.0: | ||
| 18549 | dependencies: | ||
| 18550 | ansi-regex: 6.2.2 | ||
| 18551 | |||
| 15639 | strip-indent@3.0.0: | 18552 | strip-indent@3.0.0: |
| 15640 | dependencies: | 18553 | dependencies: |
| 15641 | min-indent: 1.0.1 | 18554 | min-indent: 1.0.1 |
| 15642 | 18555 | ||
| 18556 | strnum@2.3.0: {} | ||
| 18557 | |||
| 15643 | structured-headers@0.4.1: {} | 18558 | structured-headers@0.4.1: {} |
| 15644 | 18559 | ||
| 15645 | style-to-js@1.1.21: | 18560 | style-to-js@1.1.21: |
| ... | @@ -15720,6 +18635,8 @@ snapshots: | ... | @@ -15720,6 +18635,8 @@ snapshots: |
| 15720 | ansi-escapes: 4.3.2 | 18635 | ansi-escapes: 4.3.2 |
| 15721 | supports-hyperlinks: 2.3.0 | 18636 | supports-hyperlinks: 2.3.0 |
| 15722 | 18637 | ||
| 18638 | terminal-size@4.0.1: {} | ||
| 18639 | |||
| 15723 | terser@5.48.0: | 18640 | terser@5.48.0: |
| 15724 | dependencies: | 18641 | dependencies: |
| 15725 | '@jridgewell/source-map': 0.3.11 | 18642 | '@jridgewell/source-map': 0.3.11 |
| ... | @@ -15831,8 +18748,14 @@ snapshots: | ... | @@ -15831,8 +18748,14 @@ snapshots: |
| 15831 | 18748 | ||
| 15832 | undici@6.26.0: {} | 18749 | undici@6.26.0: {} |
| 15833 | 18750 | ||
| 18751 | undici@7.27.1: {} | ||
| 18752 | |||
| 15834 | undici@8.3.0: {} | 18753 | undici@8.3.0: {} |
| 15835 | 18754 | ||
| 18755 | unenv@2.0.0-rc.24: | ||
| 18756 | dependencies: | ||
| 18757 | pathe: 2.0.3 | ||
| 18758 | |||
| 15836 | unicode-canonical-property-names-ecmascript@2.0.1: {} | 18759 | unicode-canonical-property-names-ecmascript@2.0.1: {} |
| 15837 | 18760 | ||
| 15838 | unicode-match-property-ecmascript@2.0.0: | 18761 | unicode-match-property-ecmascript@2.0.0: |
| ... | @@ -15902,18 +18825,20 @@ snapshots: | ... | @@ -15902,18 +18825,20 @@ snapshots: |
| 15902 | unist-util-is: 6.0.1 | 18825 | unist-util-is: 6.0.1 |
| 15903 | unist-util-visit-parents: 6.0.2 | 18826 | unist-util-visit-parents: 6.0.2 |
| 15904 | 18827 | ||
| 18828 | universal-user-agent@7.0.3: {} | ||
| 18829 | |||
| 15905 | universalify@0.1.2: {} | 18830 | universalify@0.1.2: {} |
| 15906 | 18831 | ||
| 15907 | universalify@2.0.1: {} | 18832 | universalify@2.0.1: {} |
| 15908 | 18833 | ||
| 15909 | 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): | 18834 | uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): |
| 15910 | dependencies: | 18835 | dependencies: |
| 15911 | '@tailwindcss/node': 4.2.1 | 18836 | '@tailwindcss/node': 4.2.1 |
| 15912 | '@tailwindcss/oxide': 4.2.1 | 18837 | '@tailwindcss/oxide': 4.2.1 |
| 15913 | culori: 4.0.2 | 18838 | culori: 4.0.2 |
| 15914 | lightningcss: 1.30.1 | 18839 | lightningcss: 1.30.1 |
| 15915 | react: 19.2.3 | 18840 | react: 19.2.3 |
| 15916 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3) | 18841 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 15917 | tailwindcss: 4.3.0 | 18842 | tailwindcss: 4.3.0 |
| 15918 | 18843 | ||
| 15919 | unpipe@1.0.0: {} | 18844 | unpipe@1.0.0: {} |
| ... | @@ -15929,7 +18854,7 @@ snapshots: | ... | @@ -15929,7 +18854,7 @@ snapshots: |
| 15929 | rolldown: 1.0.0-rc.17 | 18854 | rolldown: 1.0.0-rc.17 |
| 15930 | optional: true | 18855 | optional: true |
| 15931 | 18856 | ||
| 15932 | unstorage@1.17.5(ioredis@5.11.0): | 18857 | unstorage@1.17.5(aws4fetch@1.0.20)(idb-keyval@6.2.1)(ioredis@5.11.0): |
| 15933 | dependencies: | 18858 | dependencies: |
| 15934 | anymatch: 3.1.3 | 18859 | anymatch: 3.1.3 |
| 15935 | chokidar: 5.0.0 | 18860 | chokidar: 5.0.0 |
| ... | @@ -15940,6 +18865,8 @@ snapshots: | ... | @@ -15940,6 +18865,8 @@ snapshots: |
| 15940 | ofetch: 1.5.1 | 18865 | ofetch: 1.5.1 |
| 15941 | ufo: 1.6.4 | 18866 | ufo: 1.6.4 |
| 15942 | optionalDependencies: | 18867 | optionalDependencies: |
| 18868 | aws4fetch: 1.0.20 | ||
| 18869 | idb-keyval: 6.2.1 | ||
| 15943 | ioredis: 5.11.0 | 18870 | ioredis: 5.11.0 |
| 15944 | 18871 | ||
| 15945 | until-async@3.0.2: {} | 18872 | until-async@3.0.2: {} |
| ... | @@ -15981,8 +18908,15 @@ snapshots: | ... | @@ -15981,8 +18908,15 @@ snapshots: |
| 15981 | dependencies: | 18908 | dependencies: |
| 15982 | react: 19.2.6 | 18909 | react: 19.2.6 |
| 15983 | 18910 | ||
| 18911 | utf-8-validate@6.0.6: | ||
| 18912 | dependencies: | ||
| 18913 | node-gyp-build: 4.8.4 | ||
| 18914 | optional: true | ||
| 18915 | |||
| 15984 | utf8-byte-length@1.0.5: {} | 18916 | utf8-byte-length@1.0.5: {} |
| 15985 | 18917 | ||
| 18918 | util-deprecate@1.0.2: {} | ||
| 18919 | |||
| 15986 | utils-merge@1.0.1: {} | 18920 | utils-merge@1.0.1: {} |
| 15987 | 18921 | ||
| 15988 | uuid@14.0.0: {} | 18922 | uuid@14.0.0: {} |
| ... | @@ -16024,14 +18958,14 @@ snapshots: | ... | @@ -16024,14 +18958,14 @@ snapshots: |
| 16024 | '@types/unist': 3.0.3 | 18958 | '@types/unist': 3.0.3 |
| 16025 | vfile-message: 4.0.3 | 18959 | vfile-message: 4.0.3 |
| 16026 | 18960 | ||
| 16027 | 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): | 18961 | 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): |
| 16028 | dependencies: | 18962 | dependencies: |
| 16029 | '@oxc-project/types': 0.133.0 | 18963 | '@oxc-project/types': 0.133.0 |
| 16030 | '@oxlint/plugins': 1.61.0 | 18964 | '@oxlint/plugins': 1.61.0 |
| 16031 | '@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) | 18965 | '@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) |
| 16032 | '@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) | 18966 | '@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) |
| 16033 | 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)) | 18967 | 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)) |
| 16034 | 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)) | 18968 | 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)) |
| 16035 | oxlint-tsgolint: 0.23.0 | 18969 | oxlint-tsgolint: 0.23.0 |
| 16036 | optionalDependencies: | 18970 | optionalDependencies: |
| 16037 | '@voidzero-dev/vite-plus-darwin-arm64': 0.1.24 | 18971 | '@voidzero-dev/vite-plus-darwin-arm64': 0.1.24 |
| ... | @@ -16078,11 +19012,11 @@ snapshots: | ... | @@ -16078,11 +19012,11 @@ snapshots: |
| 16078 | optionalDependencies: | 19012 | optionalDependencies: |
| 16079 | 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)' | 19013 | 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)' |
| 16080 | 19014 | ||
| 16081 | 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): | 19015 | 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): |
| 16082 | dependencies: | 19016 | dependencies: |
| 16083 | react: 19.2.6 | 19017 | react: 19.2.6 |
| 16084 | react-dom: 19.2.6(react@19.2.6) | 19018 | react-dom: 19.2.6(react@19.2.6) |
| 16085 | 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)' | 19019 | 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)' |
| 16086 | optionalDependencies: | 19020 | optionalDependencies: |
| 16087 | '@types/react': 19.2.16 | 19021 | '@types/react': 19.2.16 |
| 16088 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 19022 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| ... | @@ -16198,12 +19132,20 @@ snapshots: | ... | @@ -16198,12 +19132,20 @@ snapshots: |
| 16198 | 19132 | ||
| 16199 | web-namespaces@2.0.1: {} | 19133 | web-namespaces@2.0.1: {} |
| 16200 | 19134 | ||
| 19135 | webidl-conversions@5.0.0: {} | ||
| 19136 | |||
| 16201 | webpack-virtual-modules@0.6.2: {} | 19137 | webpack-virtual-modules@0.6.2: {} |
| 16202 | 19138 | ||
| 16203 | whatwg-fetch@3.6.20: {} | 19139 | whatwg-fetch@3.6.20: {} |
| 16204 | 19140 | ||
| 16205 | whatwg-url-minimum@0.1.2: {} | 19141 | whatwg-url-minimum@0.1.2: {} |
| 16206 | 19142 | ||
| 19143 | whatwg-url-without-unicode@8.0.0-3: | ||
| 19144 | dependencies: | ||
| 19145 | buffer: 5.7.1 | ||
| 19146 | punycode: 2.3.1 | ||
| 19147 | webidl-conversions: 5.0.0 | ||
| 19148 | |||
| 16207 | which-pm-runs@1.1.0: {} | 19149 | which-pm-runs@1.1.0: {} |
| 16208 | 19150 | ||
| 16209 | which@2.0.2: | 19151 | which@2.0.2: |
| ... | @@ -16218,6 +19160,18 @@ snapshots: | ... | @@ -16218,6 +19160,18 @@ snapshots: |
| 16218 | dependencies: | 19160 | dependencies: |
| 16219 | isexe: 4.0.0 | 19161 | isexe: 4.0.0 |
| 16220 | 19162 | ||
| 19163 | widest-line@6.0.0: | ||
| 19164 | dependencies: | ||
| 19165 | string-width: 8.2.1 | ||
| 19166 | |||
| 19167 | workerd@1.20260526.1: | ||
| 19168 | optionalDependencies: | ||
| 19169 | '@cloudflare/workerd-darwin-64': 1.20260526.1 | ||
| 19170 | '@cloudflare/workerd-darwin-arm64': 1.20260526.1 | ||
| 19171 | '@cloudflare/workerd-linux-64': 1.20260526.1 | ||
| 19172 | '@cloudflare/workerd-linux-arm64': 1.20260526.1 | ||
| 19173 | '@cloudflare/workerd-windows-64': 1.20260526.1 | ||
| 19174 | |||
| 16221 | wrap-ansi@6.2.0: | 19175 | wrap-ansi@6.2.0: |
| 16222 | dependencies: | 19176 | dependencies: |
| 16223 | ansi-styles: 4.3.0 | 19177 | ansi-styles: 4.3.0 |
| ... | @@ -16230,17 +19184,43 @@ snapshots: | ... | @@ -16230,17 +19184,43 @@ snapshots: |
| 16230 | string-width: 4.2.3 | 19184 | string-width: 4.2.3 |
| 16231 | strip-ansi: 6.0.1 | 19185 | strip-ansi: 6.0.1 |
| 16232 | 19186 | ||
| 19187 | wrap-ansi@9.0.2: | ||
| 19188 | dependencies: | ||
| 19189 | ansi-styles: 6.2.3 | ||
| 19190 | string-width: 7.2.0 | ||
| 19191 | strip-ansi: 7.2.0 | ||
| 19192 | |||
| 16233 | wrappy@1.0.2: {} | 19193 | wrappy@1.0.2: {} |
| 16234 | 19194 | ||
| 16235 | ws@7.5.11: {} | 19195 | ws@7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6): |
| 19196 | optionalDependencies: | ||
| 19197 | bufferutil: 4.1.0 | ||
| 19198 | utf-8-validate: 6.0.6 | ||
| 16236 | 19199 | ||
| 16237 | ws@8.21.0: {} | 19200 | ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): |
| 19201 | optionalDependencies: | ||
| 19202 | bufferutil: 4.1.0 | ||
| 19203 | utf-8-validate: 6.0.6 | ||
| 16238 | 19204 | ||
| 16239 | xcode@3.0.1: | 19205 | xcode@3.0.1: |
| 16240 | dependencies: | 19206 | dependencies: |
| 16241 | simple-plist: 1.3.1 | 19207 | simple-plist: 1.3.1 |
| 16242 | uuid: 7.0.3 | 19208 | uuid: 7.0.3 |
| 16243 | 19209 | ||
| 19210 | xdg-app-paths@8.3.0: | ||
| 19211 | dependencies: | ||
| 19212 | xdg-portable: 10.6.0 | ||
| 19213 | optionalDependencies: | ||
| 19214 | fsevents: 2.3.3 | ||
| 19215 | |||
| 19216 | xdg-portable@10.6.0: | ||
| 19217 | dependencies: | ||
| 19218 | os-paths: 7.4.0 | ||
| 19219 | optionalDependencies: | ||
| 19220 | fsevents: 2.3.3 | ||
| 19221 | |||
| 19222 | xml-naming@0.1.0: {} | ||
| 19223 | |||
| 16244 | xml2js@0.6.0: | 19224 | xml2js@0.6.0: |
| 16245 | dependencies: | 19225 | dependencies: |
| 16246 | sax: 1.6.0 | 19226 | sax: 1.6.0 |
| ... | @@ -16250,6 +19230,8 @@ snapshots: | ... | @@ -16250,6 +19230,8 @@ snapshots: |
| 16250 | 19230 | ||
| 16251 | xmlbuilder@15.1.1: {} | 19231 | xmlbuilder@15.1.1: {} |
| 16252 | 19232 | ||
| 19233 | xtend@4.0.2: {} | ||
| 19234 | |||
| 16253 | xxhash-wasm@1.1.0: {} | 19235 | xxhash-wasm@1.1.0: {} |
| 16254 | 19236 | ||
| 16255 | y18n@5.0.8: {} | 19237 | y18n@5.0.8: {} |
| ... | @@ -16305,6 +19287,8 @@ snapshots: | ... | @@ -16305,6 +19287,8 @@ snapshots: |
| 16305 | 19287 | ||
| 16306 | yoctocolors-cjs@2.1.3: {} | 19288 | yoctocolors-cjs@2.1.3: {} |
| 16307 | 19289 | ||
| 19290 | yoga-layout@3.2.1: {} | ||
| 19291 | |||
| 16308 | zod-to-json-schema@3.25.2(zod@4.4.3): | 19292 | zod-to-json-schema@3.25.2(zod@4.4.3): |
| 16309 | dependencies: | 19293 | dependencies: |
| 16310 | zod: 4.4.3 | 19294 | zod: 4.4.3 |
pnpm-workspace.yaml+24-9| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | packages: | 1 | packages: |
| 2 | - "apps/*" | 2 | - "apps/*" |
| 3 | - "infra/*" | ||
| 3 | - "oxlint-plugin-t3code" | 4 | - "oxlint-plugin-t3code" |
| 4 | - "packages/*" | 5 | - "packages/*" |
| 5 | - "scripts" | 6 | - "scripts" |
| ... | @@ -15,30 +16,44 @@ ignoredBuiltDependencies: | ... | @@ -15,30 +16,44 @@ ignoredBuiltDependencies: |
| 15 | - msw | 16 | - msw |
| 16 | 17 | ||
| 17 | catalog: | 18 | catalog: |
| 18 | effect: 4.0.0-beta.73 | 19 | effect: 4.0.0-beta.78 |
| 19 | "@effect/atom-react": 4.0.0-beta.73 | 20 | "@effect/atom-react": 4.0.0-beta.78 |
| 20 | "@effect/openapi-generator": 4.0.0-beta.73 | 21 | "@effect/openapi-generator": 4.0.0-beta.78 |
| 21 | "@effect/platform-bun": 4.0.0-beta.73 | 22 | "@effect/platform-bun": 4.0.0-beta.78 |
| 22 | "@effect/platform-node": 4.0.0-beta.73 | 23 | "@effect/platform-node": 4.0.0-beta.78 |
| 23 | "@effect/platform-node-shared": 4.0.0-beta.73 | 24 | "@effect/platform-node-shared": 4.0.0-beta.78 |
| 24 | "@effect/sql-sqlite-bun": 4.0.0-beta.73 | 25 | "@effect/sql-pg": 4.0.0-beta.78 |
| 25 | "@effect/vitest": 4.0.0-beta.73 | 26 | "@effect/sql-sqlite-bun": 4.0.0-beta.78 |
| 27 | "@effect/vitest": 4.0.0-beta.78 | ||
| 26 | "@effect/tsgo": 0.11.4 | 28 | "@effect/tsgo": 0.11.4 |
| 29 | "@noble/curves": 1.9.1 | ||
| 30 | "@noble/hashes": 1.8.0 | ||
| 27 | "@pierre/diffs": 1.1.20 | 31 | "@pierre/diffs": 1.1.20 |
| 28 | "@vitest/runner": ^4.1.8 | 32 | "@vitest/runner": ^4.1.8 |
| 29 | "@types/bun": ^1.3.11 | 33 | "@types/bun": ^1.3.11 |
| 30 | "@types/node": 24.12.4 | 34 | "@types/node": 24.12.4 |
| 31 | "@typescript/native-preview": 7.0.0-dev.20260527.2 | 35 | "@typescript/native-preview": 7.0.0-dev.20260527.2 |
| 36 | jose: 6.2.2 | ||
| 37 | tsdown: ^0.20.3 | ||
| 32 | typescript: ~6.0.3 | 38 | typescript: ~6.0.3 |
| 33 | vitest: npm:@voidzero-dev/vite-plus-test@latest | 39 | vitest: npm:@voidzero-dev/vite-plus-test@latest |
| 34 | vite: npm:@voidzero-dev/vite-plus-core@latest | 40 | vite: npm:@voidzero-dev/vite-plus-core@latest |
| 35 | vite-plus: latest | 41 | vite-plus: latest |
| 36 | yaml: ^2.9.0 | 42 | yaml: ^2.9.0 |
| 37 | overrides: | 43 | overrides: |
| 44 | # Clerk publishes wallet auth integrations as required dependencies. T3 Code does | ||
| 45 | # not support wallet auth, so keep that unused dependency tree out of installs. | ||
| 46 | "@clerk/clerk-js>@base-org/account": "-" | ||
| 47 | "@clerk/clerk-js>@coinbase/wallet-sdk": "-" | ||
| 48 | "@clerk/clerk-js>@solana/wallet-adapter-base": "-" | ||
| 49 | "@clerk/clerk-js>@solana/wallet-adapter-react": "-" | ||
| 50 | "@clerk/clerk-js>@solana/wallet-standard": "-" | ||
| 51 | "@clerk/clerk-js>@wallet-standard/core": "-" | ||
| 38 | "@effect/atom-react": "catalog:" | 52 | "@effect/atom-react": "catalog:" |
| 39 | "@effect/platform-bun": "catalog:" | 53 | "@effect/platform-bun": "catalog:" |
| 40 | "@effect/platform-node": "catalog:" | 54 | "@effect/platform-node": "catalog:" |
| 41 | "@effect/platform-node-shared": "catalog:" | 55 | "@effect/platform-node-shared": "catalog:" |
| 56 | "@effect/sql-pg": "catalog:" | ||
| 42 | "@effect/sql-sqlite-bun": "catalog:" | 57 | "@effect/sql-sqlite-bun": "catalog:" |
| 43 | "@effect/vitest": "catalog:" | 58 | "@effect/vitest": "catalog:" |
| 44 | "@types/node": "catalog:" | 59 | "@types/node": "catalog:" |
| ... | @@ -60,5 +75,5 @@ packageExtensions: | ... | @@ -60,5 +75,5 @@ packageExtensions: |
| 60 | patchedDependencies: | 75 | patchedDependencies: |
| 61 | "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch | 76 | "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch |
| 62 | "@pierre/diffs@1.1.20": patches/@pierre%2Fdiffs@1.1.20.patch | 77 | "@pierre/diffs@1.1.20": patches/@pierre%2Fdiffs@1.1.20.patch |
| 63 | effect@4.0.0-beta.73: patches/effect@4.0.0-beta.73.patch | 78 | effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch |
| 64 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch | 79 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch |
scripts/build-desktop-artifact.ts+6| ... | @@ -717,6 +717,12 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( | ... | @@ -717,6 +717,12 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( |
| 717 | target: target === "dmg" ? [target, "zip"] : [target], | 717 | target: target === "dmg" ? [target, "zip"] : [target], |
| 718 | icon: "icon.icns", | 718 | icon: "icon.icns", |
| 719 | category: "public.app-category.developer-tools", | 719 | category: "public.app-category.developer-tools", |
| 720 | protocols: [ | ||
| 721 | { | ||
| 722 | name: "T3 Code", | ||
| 723 | schemes: ["t3code"], | ||
| 724 | }, | ||
| 725 | ], | ||
| 720 | }; | 726 | }; |
| 721 | } | 727 | } |
| 722 | 728 |
scripts/dev-runner.ts+4| ... | @@ -17,6 +17,10 @@ import * as Schema from "effect/Schema"; | ... | @@ -17,6 +17,10 @@ import * as Schema from "effect/Schema"; |
| 17 | import { Argument, Command, Flag } from "effect/unstable/cli"; | 17 | import { Argument, Command, Flag } from "effect/unstable/cli"; |
| 18 | import { ChildProcess } from "effect/unstable/process"; | 18 | import { ChildProcess } from "effect/unstable/process"; |
| 19 | 19 | ||
| 20 | import { loadRepoEnv } from "./lib/public-config.ts"; | ||
| 21 | |||
| 22 | Object.assign(process.env, loadRepoEnv()); | ||
| 23 | |||
| 20 | const BASE_SERVER_PORT = 13773; | 24 | const BASE_SERVER_PORT = 13773; |
| 21 | const BASE_WEB_PORT = 5733; | 25 | const BASE_WEB_PORT = 5733; |
| 22 | const MAX_HASH_OFFSET = 3000; | 26 | const MAX_HASH_OFFSET = 3000; |
scripts/lib/public-config.test.ts created+104| ... | @@ -0,0 +1,104 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Tests exercise root env file precedence directly. | ||
| 2 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | ||
| 3 | import { tmpdir } from "node:os"; | ||
| 4 | import { join } from "node:path"; | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 6 | |||
| 7 | import { loadRepoEnv, resolvePublicConfig } from "./public-config.ts"; | ||
| 8 | |||
| 9 | const temporaryDirectories: string[] = []; | ||
| 10 | |||
| 11 | afterEach(() => { | ||
| 12 | for (const directory of temporaryDirectories.splice(0)) { | ||
| 13 | rmSync(directory, { recursive: true, force: true }); | ||
| 14 | } | ||
| 15 | }); | ||
| 16 | |||
| 17 | describe("loadRepoEnv", () => { | ||
| 18 | it("does not project cloud configuration for an unconfigured clone", () => { | ||
| 19 | const env = loadRepoEnv({ baseEnv: {}, repoRoot: makeTemporaryDirectory() }); | ||
| 20 | |||
| 21 | expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).toBeUndefined(); | ||
| 22 | expect(env.T3CODE_CLERK_CLI_OAUTH_CLIENT_ID).toBeUndefined(); | ||
| 23 | expect(env.VITE_CLERK_PUBLISHABLE_KEY).toBeUndefined(); | ||
| 24 | expect(env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).toBeUndefined(); | ||
| 25 | expect(env.T3CODE_CLERK_JWT_TEMPLATE).toBeUndefined(); | ||
| 26 | expect(env.VITE_CLERK_JWT_TEMPLATE).toBeUndefined(); | ||
| 27 | expect(env.EXPO_PUBLIC_CLERK_JWT_TEMPLATE).toBeUndefined(); | ||
| 28 | expect(env.T3CODE_RELAY_URL).toBeUndefined(); | ||
| 29 | expect(env.VITE_T3CODE_RELAY_URL).toBeUndefined(); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("applies process, root local, and root precedence in that order", () => { | ||
| 33 | const repoRoot = makeTemporaryDirectory(); | ||
| 34 | writeFileSync( | ||
| 35 | join(repoRoot, ".env"), | ||
| 36 | "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3CODE_CLERK_JWT_TEMPLATE=template_root\nT3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauth_root\nT3CODE_RELAY_URL=https://root.example.test\n", | ||
| 37 | ); | ||
| 38 | writeFileSync( | ||
| 39 | join(repoRoot, ".env.local"), | ||
| 40 | "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3CODE_CLERK_JWT_TEMPLATE=template_local\nT3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauth_local\nT3CODE_RELAY_URL=https://local.example.test\n", | ||
| 41 | ); | ||
| 42 | |||
| 43 | expect(loadRepoEnv({ baseEnv: {}, repoRoot }).T3CODE_RELAY_URL).toBe( | ||
| 44 | "https://local.example.test", | ||
| 45 | ); | ||
| 46 | expect( | ||
| 47 | loadRepoEnv({ | ||
| 48 | baseEnv: { | ||
| 49 | T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", | ||
| 50 | T3CODE_CLERK_JWT_TEMPLATE: "template_ci", | ||
| 51 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_ci", | ||
| 52 | T3CODE_RELAY_URL: "https://ci.example.test", | ||
| 53 | }, | ||
| 54 | repoRoot, | ||
| 55 | }), | ||
| 56 | ).toMatchObject({ | ||
| 57 | T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci", | ||
| 58 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_ci", | ||
| 59 | VITE_CLERK_PUBLISHABLE_KEY: "pk_ci", | ||
| 60 | EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_ci", | ||
| 61 | T3CODE_CLERK_JWT_TEMPLATE: "template_ci", | ||
| 62 | VITE_CLERK_JWT_TEMPLATE: "template_ci", | ||
| 63 | EXPO_PUBLIC_CLERK_JWT_TEMPLATE: "template_ci", | ||
| 64 | T3CODE_RELAY_URL: "https://ci.example.test", | ||
| 65 | VITE_T3CODE_RELAY_URL: "https://ci.example.test", | ||
| 66 | }); | ||
| 67 | }); | ||
| 68 | |||
| 69 | it("accepts legacy framework aliases as root overrides", () => { | ||
| 70 | expect( | ||
| 71 | resolvePublicConfig({ | ||
| 72 | VITE_CLERK_PUBLISHABLE_KEY: "pk_legacy", | ||
| 73 | VITE_CLERK_JWT_TEMPLATE: "template_legacy", | ||
| 74 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_canonical", | ||
| 75 | VITE_T3CODE_RELAY_URL: "https://legacy.example.test", | ||
| 76 | }), | ||
| 77 | ).toEqual({ | ||
| 78 | clerkPublishableKey: "pk_legacy", | ||
| 79 | clerkJwtTemplate: "template_legacy", | ||
| 80 | clerkCliOAuthClientId: "oauth_canonical", | ||
| 81 | relayUrl: "https://legacy.example.test", | ||
| 82 | }); | ||
| 83 | }); | ||
| 84 | |||
| 85 | it("projects only the configured aliases", () => { | ||
| 86 | expect( | ||
| 87 | loadRepoEnv({ | ||
| 88 | baseEnv: { | ||
| 89 | T3CODE_RELAY_URL: "https://relay.example.test", | ||
| 90 | }, | ||
| 91 | repoRoot: makeTemporaryDirectory(), | ||
| 92 | }), | ||
| 93 | ).toEqual({ | ||
| 94 | T3CODE_RELAY_URL: "https://relay.example.test", | ||
| 95 | VITE_T3CODE_RELAY_URL: "https://relay.example.test", | ||
| 96 | }); | ||
| 97 | }); | ||
| 98 | }); | ||
| 99 | |||
| 100 | function makeTemporaryDirectory() { | ||
| 101 | const directory = mkdtempSync(join(tmpdir(), "t3code-public-config-")); | ||
| 102 | temporaryDirectories.push(directory); | ||
| 103 | return directory; | ||
| 104 | } | ||
scripts/lib/public-config.ts created+96| ... | @@ -0,0 +1,96 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Build bootstrap reads optional root env files before an Effect runtime exists. | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodePath from "node:path"; | ||
| 4 | import * as NodeURL from "node:url"; | ||
| 5 | import * as NodeUtil from "node:util"; | ||
| 6 | |||
| 7 | export interface T3CodePublicConfig { | ||
| 8 | readonly clerkPublishableKey: string | undefined; | ||
| 9 | readonly clerkJwtTemplate: string | undefined; | ||
| 10 | readonly clerkCliOAuthClientId: string | undefined; | ||
| 11 | readonly relayUrl: string | undefined; | ||
| 12 | } | ||
| 13 | |||
| 14 | type Environment = Readonly<Record<string, string | undefined>>; | ||
| 15 | |||
| 16 | const REPO_ROOT = NodePath.dirname( | ||
| 17 | NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))), | ||
| 18 | ); | ||
| 19 | |||
| 20 | export function loadRepoEnv({ | ||
| 21 | baseEnv = process.env, | ||
| 22 | repoRoot = REPO_ROOT, | ||
| 23 | }: { | ||
| 24 | readonly baseEnv?: Environment; | ||
| 25 | readonly repoRoot?: string; | ||
| 26 | } = {}): Record<string, string | undefined> { | ||
| 27 | const rootEnv = readEnvFile(NodePath.join(repoRoot, ".env")); | ||
| 28 | const localEnv = readEnvFile(NodePath.join(repoRoot, ".env.local")); | ||
| 29 | const config = resolvePublicConfig(baseEnv, localEnv, rootEnv); | ||
| 30 | |||
| 31 | return { | ||
| 32 | ...rootEnv, | ||
| 33 | ...localEnv, | ||
| 34 | ...baseEnv, | ||
| 35 | ...(config.clerkPublishableKey | ||
| 36 | ? { | ||
| 37 | T3CODE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, | ||
| 38 | VITE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, | ||
| 39 | EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey, | ||
| 40 | } | ||
| 41 | : {}), | ||
| 42 | ...(config.clerkJwtTemplate | ||
| 43 | ? { | ||
| 44 | T3CODE_CLERK_JWT_TEMPLATE: config.clerkJwtTemplate, | ||
| 45 | VITE_CLERK_JWT_TEMPLATE: config.clerkJwtTemplate, | ||
| 46 | EXPO_PUBLIC_CLERK_JWT_TEMPLATE: config.clerkJwtTemplate, | ||
| 47 | } | ||
| 48 | : {}), | ||
| 49 | ...(config.clerkCliOAuthClientId | ||
| 50 | ? { | ||
| 51 | T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: config.clerkCliOAuthClientId, | ||
| 52 | } | ||
| 53 | : {}), | ||
| 54 | ...(config.relayUrl | ||
| 55 | ? { | ||
| 56 | T3CODE_RELAY_URL: config.relayUrl, | ||
| 57 | VITE_T3CODE_RELAY_URL: config.relayUrl, | ||
| 58 | } | ||
| 59 | : {}), | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | |||
| 63 | export function resolvePublicConfig(...sources: readonly Environment[]): T3CodePublicConfig { | ||
| 64 | return { | ||
| 65 | clerkPublishableKey: firstNonEmpty( | ||
| 66 | sources, | ||
| 67 | "T3CODE_CLERK_PUBLISHABLE_KEY", | ||
| 68 | "VITE_CLERK_PUBLISHABLE_KEY", | ||
| 69 | "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY", | ||
| 70 | ), | ||
| 71 | clerkJwtTemplate: firstNonEmpty( | ||
| 72 | sources, | ||
| 73 | "T3CODE_CLERK_JWT_TEMPLATE", | ||
| 74 | "VITE_CLERK_JWT_TEMPLATE", | ||
| 75 | "EXPO_PUBLIC_CLERK_JWT_TEMPLATE", | ||
| 76 | ), | ||
| 77 | clerkCliOAuthClientId: firstNonEmpty(sources, "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID"), | ||
| 78 | relayUrl: firstNonEmpty(sources, "T3CODE_RELAY_URL", "VITE_T3CODE_RELAY_URL"), | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | function firstNonEmpty(sources: readonly Environment[], ...names: readonly string[]) { | ||
| 83 | for (const source of sources) { | ||
| 84 | for (const name of names) { | ||
| 85 | const value = source[name]?.trim(); | ||
| 86 | if (value) { | ||
| 87 | return value; | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | return undefined; | ||
| 92 | } | ||
| 93 | |||
| 94 | function readEnvFile(path: string): Record<string, string | undefined> { | ||
| 95 | return NodeFS.existsSync(path) ? NodeUtil.parseEnv(NodeFS.readFileSync(path, "utf8")) : {}; | ||
| 96 | } | ||
scripts/release-smoke.ts+1| ... | @@ -29,6 +29,7 @@ const workspaceFiles = [ | ... | @@ -29,6 +29,7 @@ const workspaceFiles = [ |
| 29 | "apps/mobile/modules/t3-review-diff/package.json", | 29 | "apps/mobile/modules/t3-review-diff/package.json", |
| 30 | "apps/mobile/modules/t3-terminal/package.json", | 30 | "apps/mobile/modules/t3-terminal/package.json", |
| 31 | "apps/marketing/package.json", | 31 | "apps/marketing/package.json", |
| 32 | "infra/relay/package.json", | ||
| 32 | "oxlint-plugin-t3code/package.json", | 33 | "oxlint-plugin-t3code/package.json", |
| 33 | "packages/client-runtime/package.json", | 34 | "packages/client-runtime/package.json", |
| 34 | "packages/contracts/package.json", | 35 | "packages/contracts/package.json", |
vite.config.ts+7| ... | @@ -4,6 +4,13 @@ import { defineConfig } from "vite-plus"; | ... | @@ -4,6 +4,13 @@ import { defineConfig } from "vite-plus"; |
| 4 | export default defineConfig({ | 4 | export default defineConfig({ |
| 5 | test: { | 5 | test: { |
| 6 | environment: "node", | 6 | environment: "node", |
| 7 | exclude: [ | ||
| 8 | "**/.repos/**", | ||
| 9 | "**/node_modules/**", | ||
| 10 | "**/dist/**", | ||
| 11 | "**/dist-electron/**", | ||
| 12 | "**/.{idea,git,cache,output,temp}/**", | ||
| 13 | ], | ||
| 7 | hookTimeout: 60_000, | 14 | hookTimeout: 60_000, |
| 8 | testTimeout: 60_000, | 15 | testTimeout: 60_000, |
| 9 | }, | 16 | }, |