authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-04 19:53:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-04 19:53:56-07:00
log5ae77c0d63ece1a3e3fa5a70bc7717aa7c342756
tree4896f566fd801e6f350aa1ba010e10c0b6584a2c
parent300f7fd11d61704617119634a99563e16c0e6913
signature Signed by PGP key B5690EEEBB952194

feat(relay): Add managed relay tunnels and APN service (#2837)

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 @@
1name: Deploy T3 Cloud relay
2
3on:
4 push:
5 branches:
6 - main
7
8permissions:
9 contents: read
10 id-token: none
11
12concurrency:
13 group: relay-production
14 cancel-in-progress: false
15
16jobs:
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-output167 --github-output
168168
169 build:169 relay_public_config:
170 name: Build ${{ matrix.label }}170 name: Resolve T3 Cloud public config
171 needs: preflight171 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: 30226 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: false233 fail-fast: false
177 matrix:234 matrix:
...@@ -422,13 +479,18 @@ jobs:...@@ -422,13 +479,18 @@ jobs:
422479
423 publish_cli:480 publish_cli:
424 name: Publish CLI to npm481 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-2404484 runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
428 timeout-minutes: 10485 timeout-minutes: 10
429 permissions:486 permissions:
430 contents: read487 contents: read
431 id-token: write488 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: Checkout495 - name: Checkout
434 uses: actions/checkout@v6496 uses: actions/checkout@v6
...@@ -577,11 +639,14 @@ jobs:...@@ -577,11 +639,14 @@ jobs:
577639
578 deploy_web:640 deploy_web:
579 name: Deploy hosted web app641 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-2404644 runs-on: blacksmith-8vcpu-ubuntu-2404
583 timeout-minutes: 10645 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-2404820 runs-on: blacksmith-8vcpu-ubuntu-2404
752 timeout-minutes: 10821 timeout-minutes: 10
753 steps:822 steps:
.gitignore+6
...@@ -5,6 +5,7 @@ node_modules...@@ -5,6 +5,7 @@ node_modules
5*.log5*.log
6*.tsbuildinfo6*.tsbuildinfo
7apps/*/dist7apps/*/dist
8infra/*/dist
8.astro9.astro
9packages/*/dist10packages/*/dist
10.env11.env
...@@ -26,3 +27,8 @@ squashfs-root/...@@ -26,3 +27,8 @@ squashfs-root/
26.gstack/27.gstack/
27dist-electron/28dist-electron/
28.electron-runtime/29.electron-runtime/
30node_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.
3333- `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile.
34## Codex App Server (Important)
35
36T3 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
38How 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
45Docs:
46
47- Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server
4834
49## Reference Repos35## Reference Repos
5036
README.md+8
...@@ -48,6 +48,10 @@ We are not accepting contributions yet....@@ -48,6 +48,10 @@ We are not accepting contributions yet.
4848
49Observability guide: [docs/observability.md](./docs/observability.md)49Observability guide: [docs/observability.md](./docs/observability.md)
5050
51Relay observability: [docs/relay-observability.md](./docs/relay-observability.md)
52
53T3 Cloud Clerk setup: [docs/t3-cloud-clerk.md](./docs/t3-cloud-clerk.md)
54
51## If you REALLY want to contribute still.... read this first55## If you REALLY want to contribute still.... read this first
5256
53Before local development, prepare the environment and install dependencies:57Before local development, prepare the environment and install dependencies:
...@@ -58,6 +62,10 @@ mise install...@@ -58,6 +62,10 @@ mise install
58vp install62vp install
59```63```
6064
65T3 Cloud is optional and disabled in a fresh clone. To enable it for web, desktop, and mobile source
66builds, copy [`.env.example`](./.env.example) to `.env` at the repository root and set the canonical
67public configuration there.
68
61Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.69Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.
6270
63Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).71Need 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";
2import { watch } from "node:fs";2import { watch } from "node:fs";
3import { join } from "node:path";3import { join } from "node:path";
44
5import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";5import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs";
6import { waitForResources } from "./wait-for-resources.mjs";6import { waitForResources } from "./wait-for-resources.mjs";
77
8const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();8const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
...@@ -28,6 +28,7 @@ const watchedDirectories = [...@@ -28,6 +28,7 @@ const watchedDirectories = [
28const forcedShutdownTimeoutMs = 1_500;28const forcedShutdownTimeoutMs = 1_500;
29const restartDebounceMs = 120;29const restartDebounceMs = 120;
30const childTreeGracePeriodMs = 1_200;30const childTreeGracePeriodMs = 1_200;
31const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim();
3132
32await waitForResources({33await waitForResources({
33 baseDir: desktopDir,34 baseDir: desktopDir,
...@@ -38,6 +39,11 @@ await waitForResources({...@@ -38,6 +39,11 @@ await waitForResources({
3839
39const childEnv = { ...process.env };40const childEnv = { ...process.env };
40delete childEnv.ELECTRON_RUN_AS_NODE;41delete childEnv.ELECTRON_RUN_AS_NODE;
42const devProtocolClient = resolveDevProtocolClient();
43if (devProtocolClient) {
44 childEnv.T3CODE_DESKTOP_APP_USER_MODEL_ID = devProtocolClient.appBundleId;
45 childEnv.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED = "1";
46}
4147
42let shuttingDown = false;48let shuttingDown = false;
43let restartTimer = null;49let restartTimer = null;
...@@ -67,15 +73,17 @@ function startApp() {...@@ -67,15 +73,17 @@ function startApp() {
67 return;73 return;
68 }74 }
6975
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 });
7987
80 currentApp = app;88 currentApp = app;
8189
...@@ -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();
128137
129 setTimeout(() => {138 setTimeout(() => {
130 if (settled) {139 if (settled) {
...@@ -133,6 +142,7 @@ async function stopApp() {...@@ -133,6 +142,7 @@ async function stopApp() {
133142
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 @@
3import { spawnSync } from "node:child_process";3import { spawnSync } from "node:child_process";
4import {4import {
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";
15import { createRequire } from "node:module";16import { createRequire } from "node:module";
16import { dirname, join, resolve } from "node:path";17import { basename, dirname, join, resolve } from "node:path";
17import { fileURLToPath } from "node:url";18import { fileURLToPath } from "node:url";
18import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";19import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";
1920
20const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);21const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
21const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
22const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
23const LAUNCHER_VERSION = 2;
24
25const __dirname = dirname(fileURLToPath(import.meta.url));22const __dirname = dirname(fileURLToPath(import.meta.url));
26export const desktopDir = resolve(__dirname, "..");23export const desktopDir = resolve(__dirname, "..");
27const repoRoot = resolve(desktopDir, "..", "..");24const repoRoot = resolve(desktopDir, "..", "..");
25const devBundleIdSuffix = basename(repoRoot)
26 .toLowerCase()
27 .replaceAll(/[^a-z0-9]+/g, "");
28export const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
29export const APP_BUNDLE_ID = isDevelopment
30 ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}`
31 : "com.t3tools.t3code";
32const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
33const LAUNCHER_VERSION = 10;
28const defaultIconPath = join(desktopDir, "resources", "icon.icns");34const defaultIconPath = join(desktopDir, "resources", "icon.icns");
29const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");35const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");
3036
37function 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
31function setPlistString(plistPath, key, value) {45function 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}
4963
64function 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
50function runChecked(command, args) {84function 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}
5993
94function shellSingleQuote(value) {
95 return `'${value.replaceAll("'", "'\\''")}'`;
96}
97
98function 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
133function 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
60function ensureDevelopmentIconIcns(runtimeDir) {159function 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 ]);
118223
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}
123228
229function 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
124function readJson(path) {260function 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 };
148286
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 }
157296
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);
162306
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 }
175319
176 // Dev launches do not need a renamed app bundle badly enough to risk breaking320 return buildMacLauncher(electronBinaryPath);
177 // Electron helper resource lookup on macOS.321}
178 if (isDevelopment) {322
179 return electronBinaryPath;323export function resolveDevProtocolClient() {
324 if (process.platform !== "darwin" || !isDevelopment) {
325 return null;
180 }326 }
181327
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 @@
1import * as Cause from "effect/Cause";1import * as Cause from "effect/Cause";
2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";2import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";4import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";5import * as Ref from "effect/Ref";
76
8import * as NetService from "@t3tools/shared/Net";7import * as NetService from "@t3tools/shared/Net";
8import * as Crypto from "effect/Crypto";
9import * as ElectronApp from "../electron/ElectronApp.ts";9import * as ElectronApp from "../electron/ElectronApp.ts";
10import * as ElectronDialog from "../electron/ElectronDialog.ts";10import * as ElectronDialog from "../electron/ElectronDialog.ts";
11import * as ElectronProtocol from "../electron/ElectronProtocol.ts";11import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
12import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";12import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
13import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";13import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
14import * as DesktopCloudAuth from "./DesktopCloudAuth.ts";
14import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";15import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
15import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";16import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
16import * as DesktopEnvironment from "./DesktopEnvironment.ts";17import * 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* () {
207209
208 yield* appIdentity.configure;210 yield* appIdentity.configure;
209 yield* lifecycle.register;211 yield* lifecycle.register;
212 yield* cloudAuth.configure;
210213
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 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import * as ElectronApp from "../electron/ElectronApp.ts";
8import * as ElectronWindow from "../electron/ElectronWindow.ts";
9import * as IpcChannels from "../ipc/channels.ts";
10import * as DesktopCloudAuth from "./DesktopCloudAuth.ts";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12
13interface 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
32function 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
119function 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
129const flushCloudAuthDispatch = Effect.promise(() => Promise.resolve());
130
131describe("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 @@
1import * as Context from "effect/Context";
2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Scope from "effect/Scope";
8import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
9
10import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
11import * as ElectronApp from "../electron/ElectronApp.ts";
12import * as ElectronWindow from "../electron/ElectronWindow.ts";
13import * as IpcChannels from "../ipc/channels.ts";
14import * as DesktopEnvironment from "./DesktopEnvironment.ts";
15import type * as Electron from "electron";
16
17export const CLOUD_AUTH_CALLBACK_HOST = "auth";
18export const CLOUD_AUTH_CALLBACK_PATHNAME = "/callback";
19export const CLOUD_AUTH_CALLBACK_STATE_PARAM = "t3_state";
20export const CLOUD_AUTH_CALLBACK_SCHEME = "t3code";
21export const DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME = "t3code-dev";
22
23const CLOUD_AUTH_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
24
25export 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
35interface PendingCloudAuthRequest {
36 readonly state: string;
37 readonly redirectUrl: string;
38 readonly close: () => void;
39}
40
41export 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
50export class DesktopCloudAuth extends Context.Service<DesktopCloudAuth, DesktopCloudAuthShape>()(
51 "@t3tools/desktop/app/DesktopCloudAuth",
52) {}
53
54export function resolveCloudAuthCallbackScheme(input: { readonly isDevelopment: boolean }): string {
55 return input.isDevelopment ? DEVELOPMENT_CLOUD_AUTH_CALLBACK_SCHEME : CLOUD_AUTH_CALLBACK_SCHEME;
56}
57
58export 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
69export 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
90export 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
106export function resolveProtocolClientLaunchArgs(input: {
107 readonly argv: readonly string[];
108}): readonly string[] {
109 return input.argv.slice(1);
110}
111
112function 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
128function isProtocolRegistrationManagedExternally(): boolean {
129 return process.env.T3CODE_DESKTOP_PROTOCOL_REGISTRATION_MANAGED?.trim() === "1";
130}
131
132function 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
148const closeCloudAuthRequest = (request: PendingCloudAuthRequest | null): null => {
149 request?.close();
150 return null;
151};
152
153function 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
158function ignoreCloudAuthCallback(_rawUrl: string) {}
159
160function 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
190const 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
330export const layer = Layer.effect(DesktopCloudAuth, make);
apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts created+96
...@@ -0,0 +1,96 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7
8import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
9import * as DesktopConfig from "./DesktopConfig.ts";
10import * as DesktopEnvironment from "./DesktopEnvironment.ts";
11import * as DesktopCloudAuthTokenStore from "./DesktopCloudAuthTokenStore.ts";
12
13const textDecoder = new TextDecoder();
14const textEncoder = new TextEncoder();
15
16function 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
34function 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
58const 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
70describe("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 @@
1import { fromLenientJson } from "@t3tools/shared/schemaJson";
2import * as Context from "effect/Context";
3import * as Crypto from "effect/Crypto";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as PlatformError from "effect/PlatformError";
12import * as Schema from "effect/Schema";
13
14import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
15import * as DesktopEnvironment from "./DesktopEnvironment.ts";
16
17interface CloudAuthTokenDocument {
18 readonly version: number;
19 readonly encryptedClientJwt: string;
20}
21
22const CloudAuthTokenDocumentSchema = Schema.Struct({
23 version: Schema.Number,
24 encryptedClientJwt: Schema.String,
25});
26
27const CloudAuthTokenDocumentJson = fromLenientJson(CloudAuthTokenDocumentSchema);
28const decodeCloudAuthTokenDocumentJson = Schema.decodeEffect(CloudAuthTokenDocumentJson);
29const encodeCloudAuthTokenDocumentJson = Schema.encodeEffect(CloudAuthTokenDocumentJson);
30
31export 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
41export 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
51export 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
69export class DesktopCloudAuthTokenStore extends Context.Service<
70 DesktopCloudAuthTokenStore,
71 DesktopCloudAuthTokenStoreShape
72>()("@t3tools/desktop/app/DesktopCloudAuthTokenStore") {}
73
74function 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
82const 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
96const 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
111export 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 );
9595
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 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
22
3import * as DesktopIpc from "./DesktopIpc.ts";3import * as DesktopIpc from "./DesktopIpc.ts";
4import {
5 clearCloudAuthToken,
6 createCloudAuthRequest,
7 fetchCloudAuth,
8 getCloudAuthToken,
9 setCloudAuthToken,
10} from "./methods/cloudAuth.ts";
4import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";11import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
5import {12import {
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);
7885 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";
3export const SET_THEME_CHANNEL = "desktop:set-theme";3export const SET_THEME_CHANNEL = "desktop:set-theme";
4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
6export const CREATE_CLOUD_AUTH_REQUEST_CHANNEL = "desktop:create-cloud-auth-request";
7export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token";
8export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token";
9export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token";
10export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth";
11export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback";
6export const MENU_ACTION_CHANNEL = "desktop:menu-action";12export const MENU_ACTION_CHANNEL = "desktop:menu-action";
7export const UPDATE_STATE_CHANNEL = "desktop:update-state";13export const UPDATE_STATE_CHANNEL = "desktop:update-state";
8export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";14export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
apps/desktop/src/ipc/methods/cloudAuth.test.ts created+105
...@@ -0,0 +1,105 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
5import { afterEach } from "vitest";
6
7import { fetchCloudAuth, validateClerkFrontendApiUrl } from "./cloudAuth.ts";
8
9const originalClerkPublishableKey = process.env.T3CODE_CLERK_PUBLISHABLE_KEY;
10
11const clerkPublishableKey = (hostname: string): string =>
12 `pk_test_${Buffer.from(`${hostname}$`).toString("base64")}`;
13
14function 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
25describe("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 @@
1import {
2 DesktopCloudAuthFetchInputSchema,
3 DesktopCloudAuthFetchResultSchema,
4} from "@t3tools/contracts";
5import {
6 clerkFrontendApiHostnameFromPublishableKey,
7 isAllowedClerkFrontendApiHostname,
8} from "@t3tools/shared/relayAuth";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";
13import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http";
14
15import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts";
16import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts";
17import * as IpcChannels from "../channels.ts";
18import { makeIpcMethod } from "../DesktopIpc.ts";
19
20declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
21
22export 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
31function 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
42const allowedClerkFrontendApiHosts = (hostname: string): boolean =>
43 isAllowedClerkFrontendApiHostname(hostname, configuredClerkFrontendApiHostname());
44
45export 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
55export 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
65export 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
75export 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
85export 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
95export 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";
26import * as ElectronWindow from "./electron/ElectronWindow.ts";26import * as ElectronWindow from "./electron/ElectronWindow.ts";
27import * as DesktopApp from "./app/DesktopApp.ts";27import * as DesktopApp from "./app/DesktopApp.ts";
28import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";28import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
29import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts";
30import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts";
29import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";31import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
30import * as DesktopAssets from "./app/DesktopAssets.ts";32import * as DesktopAssets from "./app/DesktopAssets.ts";
31import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";33import * 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(
135const desktopApplicationLayer = Layer.mergeAll(138const 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});
5859
...@@ -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}
139144
140function toSavedEnvironmentStorageRecord(145function 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}
168166
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;
3333
34function makeFakeBrowserWindow() {34function 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}
6872
...@@ -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);
98102
99const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, {
100 openExternal: () => Effect.succeed(true),
101 copyText: () => Effect.void,
102} satisfies ElectronShell.ElectronShellShape);
103
104const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {103const 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}
155162
156describe("DesktopWindow", () => {163describe("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}
9494
95export 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
95function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions {106function 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 });
238267
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 });
277306
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 }
285313
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 @@
1import { defineConfig } from "vite-plus";1import { defineConfig } from "vite-plus";
22
3import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
4
5const repoEnv = loadRepoEnv();
3const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1";6const shouldLaunchElectronAfterPack = process.env.T3CODE_DESKTOP_DEV === "1";
7const publicConfigDefine = {
8 __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: JSON.stringify(
9 repoEnv.T3CODE_CLERK_PUBLISHABLE_KEY?.trim() ?? "",
10 ),
11};
412
5export default defineConfig({13export 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:
1616
17Run commands from `apps/mobile`.17Run commands from `apps/mobile`.
1818
19T3 Cloud is optional and disabled in a fresh clone. Public configuration belongs in the
20repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See
21[`../../.env.example`](../../.env.example).
22
19## Development23## Development
2024
21Start Metro for the dev client:25Start 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.
6367
64CI 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.68CI 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.
6569
70For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY`,
71`T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL`
72as EAS environment variables. Expo config maps the canonical values into the mobile build.
73
66Create a PR preview dev-client build manually:74Create a PR preview dev-client build manually:
6775
68```bash76```bash
apps/mobile/app.config.ts+35-5
...@@ -1,8 +1,13 @@...@@ -1,8 +1,13 @@
1import type { ExpoConfig } from "expo/config";1import type { ExpoConfig } from "expo/config";
22
3import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
4
3type AppVariant = "development" | "preview" | "production";5type AppVariant = "development" | "preview" | "production";
46
5const APP_VARIANT = resolveAppVariant(process.env.APP_VARIANT);7const repoEnv = loadRepoEnv();
8Object.assign(process.env, repoEnv);
9
10const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT);
611
7const VARIANT_CONFIG: Record<12const 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;
4142
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;
128130
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";
10import { GestureHandlerRootView } from "react-native-gesture-handler";10import { GestureHandlerRootView } from "react-native-gesture-handler";
11import { KeyboardProvider } from "react-native-keyboard-controller";11import { KeyboardProvider } from "react-native-keyboard-controller";
12import { SafeAreaProvider } from "react-native-safe-area-context";12import { SafeAreaProvider } from "react-native-safe-area-context";
13import { useCSSVariable, useResolveClassNames } from "uniwind";13import { useResolveClassNames } from "uniwind";
1414
15import { LoadingScreen } from "../components/LoadingScreen";15import { LoadingScreen } from "../components/LoadingScreen";
1616
...@@ -20,12 +20,15 @@ import {...@@ -20,12 +20,15 @@ import {
20} from "../state/use-remote-environment-registry";20} from "../state/use-remote-environment-registry";
21import { RegistryContext } from "@effect/atom-react";21import { RegistryContext } from "@effect/atom-react";
22import { appAtomRegistry } from "../state/atom-registry";22import { appAtomRegistry } from "../state/atom-registry";
23import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";
24import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation";
2325
24function AppNavigator() {26function 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();
2932
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 };
4750
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.Screen80 <Stack.Screen
...@@ -91,14 +100,20 @@ export default function RootLayout() {...@@ -91,14 +100,20 @@ export default function RootLayout() {
91 useRemoteEnvironmentBootstrap();100 useRemoteEnvironmentBootstrap();
92101
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 @@
1import Stack from "expo-router/stack";1import Stack from "expo-router/stack";
2import { useColorScheme } from "react-native";
2import { useResolveClassNames } from "uniwind";3import { useResolveClassNames } from "uniwind";
34
4import { useThemeColor } from "../../lib/useThemeColor";
5
6export const unstable_settings = {5export const unstable_settings = {
7 anchor: "index",6 anchor: "index",
8};7};
98
10export default function ConnectionsLayout() {9export 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";
1414
15 return (15 return (
16 <Stack16 <Stack
apps/mobile/src/app/connections/index.tsx+10-16
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import { Link, Stack } from "expo-router";1import { Stack, useRouter } from "expo-router";
2import { SymbolView } from "expo-symbols";2import { SymbolView } from "expo-symbols";
3import type { EnvironmentId } from "@t3tools/contracts";3import type { EnvironmentId } from "@t3tools/contracts";
4import { useCallback, useState } from "react";4import { useCallback, useState } from "react";
5import { Pressable, ScrollView, View } from "react-native";5import { ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";6import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";7import { useThemeColor } from "../../lib/useThemeColor";
88
...@@ -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);
2425
25 const primaryFg = useThemeColor("--color-primary-foreground");
26 const accentColor = useThemeColor("--color-icon-muted");26 const accentColor = useThemeColor("--color-icon-muted");
2727
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.Screen34 <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 <ScrollView46 <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 @@
1import { CameraView, useCameraPermissions } from "expo-camera";1import { CameraView, useCameraPermissions } from "expo-camera";
2import { Stack, useLocalSearchParams, useRouter } from "expo-router";2import { Stack, useLocalSearchParams, useRouter } from "expo-router";
3import { SymbolView } from "expo-symbols";
4import { useCallback, useEffect, useState } from "react";3import { useCallback, useEffect, useState } from "react";
5import { Alert, Pressable, ScrollView, View } from "react-native";4import { Alert, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";5import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";6import { useThemeColor } from "../../lib/useThemeColor";
87
...@@ -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);
3332
34 const textColor = useThemeColor("--color-icon");
35 const placeholderColor = useThemeColor("--color-placeholder");33 const placeholderColor = useThemeColor("--color-placeholder");
3634
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.Screen126 <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>
153144
154 <ScrollView145 <ScrollView
155 contentInsetAdjustmentBehavior="automatic"146 contentInsetAdjustmentBehavior="automatic"
apps/mobile/src/app/index.tsx+6-12
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import { Stack, useRouter } from "expo-router";1import { Stack, useRouter } from "expo-router";
2import { useState } from "react";2import { useState } from "react";
3import { Text as RNText, View, useColorScheme } from "react-native";3import { Text as RNText, View, useColorScheme } from "react-native";
4import { useThemeColor } from "../lib/useThemeColor";
54
6import { buildThreadRoutePath } from "../lib/routes";5import { buildThreadRoutePath } from "../lib/routes";
7import { useRemoteCatalog } from "../state/use-remote-catalog";6import { 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("");
1817
19 const isDark = useColorScheme() === "dark";18 const isDark = useColorScheme() === "dark";
20 const iconColor = String(useThemeColor("--color-icon"));19 const iconColor = isDark ? "#f5f5f5" : "#262626";
2120
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>
7978
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.MenuAction82 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>
9286
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 @@
1import Stack from "expo-router/stack";1import Stack from "expo-router/stack";
2import { useColorScheme } from "react-native";
2import { useResolveClassNames } from "uniwind";3import { useResolveClassNames } from "uniwind";
34
4import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";5import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";
...@@ -9,20 +10,41 @@ export const unstable_settings = {...@@ -9,20 +10,41 @@ export const unstable_settings = {
910
10export default function NewTaskLayout() {11export 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";
1216
13 return (17 return (
14 <NewTaskFlowProvider>18 <NewTaskFlowProvider>
15 <Stack19 <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.Screen31 <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 @@
1import { Stack } from "expo-router";
2import { useThemeColor } from "../../../lib/useThemeColor";
3
4export 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 @@
1import { Stack } from "expo-router";
2
3import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen";1import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen";
42
5export default function AddProjectDestinationRoute() {3export 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 @@
1import { Stack } from "expo-router";
2
3import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen";1import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen";
42
5export default function AddProjectRoute() {3export 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 @@
1import { Stack } from "expo-router";
2
3import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen";1import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen";
42
5export default function AddProjectLocalRoute() {3export 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 @@
1import { Stack } from "expo-router";1import { Stack, useLocalSearchParams } from "expo-router";
2import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime";
23
3import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen";4import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen";
45
5export default function AddProjectRepositoryRoute() {6export 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 }>();
1011
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 <NewTaskDraftScreen19 <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";
88
9import { AppText as Text } from "../../components/AppText";9import { AppText as Text } from "../../components/AppText";
10import { ProjectFavicon } from "../../components/ProjectFavicon";10import { ProjectFavicon } from "../../components/ProjectFavicon";
11import { NewTaskSheetHeader } from "../../features/threads/NewTaskSheetHeader";
12import { groupProjectsByRepository } from "../../lib/repositoryGroups";11import { groupProjectsByRepository } from "../../lib/repositoryGroups";
13import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog";12import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog";
14import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";13import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
...@@ -102,9 +101,14 @@ export default function NewTaskRoute() {...@@ -102,9 +101,14 @@ export default function NewTaskRoute() {
102101
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" }} />
106105 <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>
108112
109 <ScrollView113 <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;
151156
152 return (157 return (
153 <Link158 <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 <ProjectFavicon184 <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 <SymbolView202 <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 @@
1import Stack from "expo-router/stack";
2import { useColorScheme } from "react-native";
3import { useResolveClassNames } from "uniwind";
4
5export const unstable_settings = {
6 anchor: "index",
7};
8
9export 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 @@
1export { default } from "../connections/new";
apps/mobile/src/app/settings/environments.tsx created+275
...@@ -0,0 +1,275 @@
1import { useAuth } from "@clerk/expo";
2import { Stack, useRouter } from "expo-router";
3import { SymbolView } from "expo-symbols";
4import type { EnvironmentId } from "@t3tools/contracts";
5import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
6import * as Effect from "effect/Effect";
7import { useCallback, useMemo, useState } from "react";
8import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10
11import { AppText as Text } from "../../components/AppText";
12import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment";
13import {
14 hasCloudPublicConfig,
15 resolveRelayClerkTokenOptions,
16} from "../../features/cloud/publicConfig";
17import {
18 useManagedRelayEnvironments,
19 useManagedRelayEnvironmentStatus,
20} from "../../features/cloud/managedRelayState";
21import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
22import { cn } from "../../lib/cn";
23import { mobileRuntime } from "../../lib/runtime";
24import { useThemeColor } from "../../lib/useThemeColor";
25import {
26 connectSavedEnvironment,
27 useRemoteConnections,
28 useRemoteEnvironmentState,
29} from "../../state/use-remote-environment-registry";
30
31export 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
117function 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
218function 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 @@
1import { useAuth, useUser, useUserProfileModal } from "@clerk/expo";
2import * as Notifications from "expo-notifications";
3import { Link, Stack, useRouter } from "expo-router";
4import { SymbolView } from "expo-symbols";
5import * as Effect from "effect/Effect";
6import { useCallback, useEffect, useMemo, useState } from "react";
7import type { ComponentProps, ReactNode } from "react";
8import { Alert, Linking, Pressable, ScrollView, Switch, View } from "react-native";
9import { useSafeAreaInsets } from "react-native-safe-area-context";
10
11import { AppText as Text } from "../../components/AppText";
12import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/liveActivityPreferences";
13import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions";
14import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration";
15import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState";
16import {
17 hasCloudPublicConfig,
18 resolveRelayClerkTokenOptions,
19} from "../../features/cloud/publicConfig";
20import { mobileRuntime } from "../../lib/runtime";
21import { loadPreferences } from "../../lib/storage";
22import { useThemeColor } from "../../lib/useThemeColor";
23import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
24
25type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
26type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
27
28export default function SettingsRouteScreen() {
29 return hasCloudPublicConfig() ? <ConfiguredSettingsRouteScreen /> : <LocalSettingsRouteScreen />;
30}
31
32function 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
66function 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
338type SymbolName = ComponentProps<typeof SymbolView>["name"];
339
340function 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
354function 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
374function 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
424function 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 @@
1import { Redirect, Stack } from "expo-router";
2import { ScrollView } from "react-native";
3
4import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment";
5import { hasCloudPublicConfig } from "../../features/cloud/publicConfig";
6import { useNativeClerkAuthModal } from "../../features/cloud/useNativeClerkAuthModal";
7
8export default function SettingsWaitlistRouteScreen() {
9 return hasCloudPublicConfig() ? (
10 <ConfiguredSettingsWaitlistRouteScreen />
11 ) : (
12 <Redirect href="/settings" />
13 );
14}
15
16function 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.Screen23 <Stack.Screen
apps/mobile/src/components/ComposerToolbarTrigger.tsx created+247
...@@ -0,0 +1,247 @@
1import { SymbolView } from "expo-symbols";
2import type { ComponentProps, ReactNode } from "react";
3import { useCallback, useMemo, useState } from "react";
4import {
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
16import { useThemeColor } from "../lib/useThemeColor";
17import { cn } from "../lib/cn";
18import { AppText as Text } from "./AppText";
19
20export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44;
21export const COMPOSER_TOOLBAR_GAP = 8;
22export const COMPOSER_TOOLBAR_FADE_WIDTH = 18;
23const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4;
24
25export 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
51export 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
140export 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
247export 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";
5import { AppText as Text } from "./AppText";5import { AppText as Text } from "./AppText";
6import { BrandMark } from "./BrandMark";6import { BrandMark } from "./BrandMark";
77
8export function LoadingScreen(props: { readonly message: string }) {8export 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";
1216
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 @@
1import { beforeEach, vi } from "vitest";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import type { EnvironmentId } from "@t3tools/contracts";
5import { ManagedRelayClient } from "@t3tools/client-runtime";
6import { HttpClient } from "effect/unstable/http";
7
8import type { SavedRemoteConnection } from "../../lib/connection";
9import { savePreferencesPatch } from "../../lib/storage";
10import { linkEnvironmentToCloud } from "../cloud/linkEnvironment";
11import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences";
12import { refreshAgentAwarenessRegistration } from "./remoteRegistration";
13
14vi.mock("../../lib/storage", () => ({
15 savePreferencesPatch: vi.fn(() => Promise.resolve()),
16}));
17
18vi.mock("../cloud/linkEnvironment", () => ({
19 linkEnvironmentToCloud: vi.fn(() => Effect.void),
20}));
21
22vi.mock("./remoteRegistration", () => ({
23 refreshAgentAwarenessRegistration: vi.fn(() => Effect.void),
24}));
25
26const 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
36const 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
49describe("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 @@
1import * as Effect from "effect/Effect";
2import { HttpClient } from "effect/unstable/http";
3import { ManagedRelayClient } from "@t3tools/client-runtime";
4
5import type { SavedRemoteConnection } from "../../lib/connection";
6import { savePreferencesPatch } from "../../lib/storage";
7import { linkEnvironmentToCloud } from "../cloud/linkEnvironment";
8import { refreshAgentAwarenessRegistration } from "./remoteRegistration";
9
10export 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 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 extractAgentNotificationDeepLink,
5 routeAgentNotificationResponseOnce,
6} from "./notificationPayload";
7
8function 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
21describe("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
84describe("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 @@
1import { useEffect, useRef } from "react";
2import * as Notifications from "expo-notifications";
3import { useRouter } from "expo-router";
4
5import { routeAgentNotificationResponseOnce } from "./notificationPayload";
6
7export 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 @@
1function 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
21function 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
37function 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
47function 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
72export 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
90export 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 @@
1import * as Notifications from "expo-notifications";
2import * as Effect from "effect/Effect";
3import { Platform } from "react-native";
4
5export type NotificationPermissionResult =
6 | { readonly type: "unsupported" }
7 | { readonly type: "granted" }
8 | { readonly type: "denied"; readonly canAskAgain: boolean };
9
10export 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 @@
1import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
2
3import type { MobilePreferences } from "../../lib/storage";
4
5export 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
3import * as NodeCrypto from "node:crypto";
4
5import { beforeEach, vi } from "vitest";
6import { describe, expect, it } from "@effect/vitest";
7import Constants from "expo-constants";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import { FetchHttpClient } from "effect/unstable/http";
11import type { ManagedRelayClient } from "@t3tools/client-runtime";
12
13import type { EnvironmentId } from "@t3tools/contracts";
14import { verifyDpopProof } from "@t3tools/shared/dpop";
15import type { SavedRemoteConnection } from "../../lib/connection";
16import { mobileCryptoLayer } from "../cloud/dpop";
17import { mobileManagedRelayClientLayer } from "../cloud/managedRelayLayer";
18import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
19import {
20 __resetAgentAwarenessRemoteRegistrationForTest,
21 refreshActiveLiveActivityRemoteRegistration,
22 refreshAgentAwarenessRegistration,
23 normalizeAgentAwarenessRelayBaseUrl,
24 registerAgentAwarenessConnection,
25 registerLiveActivityPushToken,
26 setAgentAwarenessRelayTokenProvider,
27 shouldRegisterAgentAwarenessDeviceForProvider,
28 unregisterAgentAwarenessConnection,
29} from "./remoteRegistration";
30import * as Notifications from "expo-notifications";
31
32const secureStore = vi.hoisted(() => new Map<string, string>());
33const widgetMocks = vi.hoisted(() => ({
34 getInstances: vi.fn(() => []),
35}));
36
37vi.mock("expo-constants", () => ({
38 default: {
39 expoConfig: {
40 version: "1.0.0",
41 extra: {},
42 },
43 },
44}));
45
46vi.mock("expo-widgets", () => ({
47 addPushToStartTokenListener: vi.fn(() => ({ remove: vi.fn() })),
48}));
49
50vi.mock("../../widgets/AgentActivity", () => ({
51 default: {
52 getInstances: widgetMocks.getInstances,
53 },
54}));
55
56vi.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
62vi.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
82vi.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
90vi.mock("react-native", () => ({
91 Platform: {
92 OS: "ios",
93 Version: "18.0",
94 },
95}));
96
97vi.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
112vi.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
118function 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
129function 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
141const 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
152async 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
164describe("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 @@
1import { addPushToStartTokenListener, type LiveActivity } from "expo-widgets";
2import Constants from "expo-constants";
3import * as Notifications from "expo-notifications";
4import * as Effect from "effect/Effect";
5import { Platform } from "react-native";
6import type { EnvironmentId } from "@t3tools/contracts";
7import {
8 type RelayDeviceRegistrationRequest,
9 type RelayLiveActivityRegistrationRequest,
10} from "@t3tools/contracts/relay";
11import { ManagedRelayClient } from "@t3tools/client-runtime";
12
13import type { SavedRemoteConnection } from "../../lib/connection";
14import { mobileRuntime } from "../../lib/runtime";
15import {
16 loadAgentAwarenessDeviceId,
17 loadOrCreateAgentAwarenessDeviceId,
18 loadPreferences,
19} from "../../lib/storage";
20import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
21import { resolveCloudPublicConfig } from "../cloud/publicConfig";
22import { makeRelayDeviceRegistrationRequest } from "./registrationPayload";
23
24const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000;
25const environmentConnections = new Map<EnvironmentId, SavedRemoteConnection>();
26const activityPushTokenListeners = new WeakSet<LiveActivity<AgentActivityProps>>();
27let pushToStartSubscription: { remove: () => void } | null = null;
28let pushTokenSubscription: { remove: () => void } | null = null;
29let activeLiveActivityRegistrationRetry: ReturnType<typeof setTimeout> | null = null;
30let relayTokenProvider: (() => Promise<string | null>) | null = null;
31let relayTokenProviderIdentity: string | null = null;
32
33export 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
43function 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
53function canRegisterRemoteLiveActivities(): boolean {
54 return Platform.OS === "ios";
55}
56
57export function shouldRegisterAgentAwarenessDeviceForProvider(
58 previousIdentity: string | null,
59 identity: string | undefined,
60): boolean {
61 return identity === undefined || identity !== previousIdentity;
62}
63
64export 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
96function 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
105function 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
139const 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
150function 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
169function 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
192function 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
212function 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
222function logRegistrationDebug(context: string, details?: unknown): void {
223 if (!__DEV__) {
224 return;
225 }
226 console.log(`[agent-awareness] ${context}`, details ?? "");
227}
228
229function 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
238function 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
273function registerDeviceForCurrentUser(
274 pushToStartToken?: string,
275): Effect.Effect<void, unknown, ManagedRelayClient> {
276 return registerDevice(pushToStartToken ? { pushToStartToken } : undefined);
277}
278
279function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void {
280 runRegistrationInBackground(
281 registerDeviceForCurrentUser(pushToStartToken),
282 "push-to-start token registration failed",
283 );
284}
285
286function 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
299function 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
314export 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
329function removeAgentAwarenessConnection(environmentId: EnvironmentId): void {
330 environmentConnections.delete(environmentId);
331}
332
333export function unregisterAgentAwarenessConnection(environmentId: EnvironmentId): void {
334 removeAgentAwarenessConnection(environmentId);
335}
336
337export 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
349export 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
363export 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
377export 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
398export 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
450function 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
471function 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
485export 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 @@
1import { ClerkProvider, useAuth } from "@clerk/expo";
2import { tokenCache } from "@clerk/expo/token-cache";
3import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime";
4import { type ReactNode, useEffect, useRef } from "react";
5
6import { mobileRuntime } from "../../lib/runtime";
7import { appAtomRegistry } from "../../state/atom-registry";
8import {
9 setAgentAwarenessRelayTokenProvider,
10 unregisterAgentAwarenessDeviceForCurrentUser,
11} from "../agent-awareness/remoteRegistration";
12import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig";
13
14function 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
68export 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 @@
1import { useWaitlist } from "@clerk/expo";
2import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
3import { useState } from "react";
4
5import { useThemeColor } from "../../lib/useThemeColor";
6
7export 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
116function 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
128function 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
141const 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
3import * as NodeCrypto from "node:crypto";
4
5import { vi } from "vitest";
6import { describe, expect, it } from "@effect/vitest";
7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";
9import { verifyDpopProof } from "@t3tools/shared/dpop";
10
11import {
12 createDpopProof,
13 generateDpopProofKeyPair,
14 loadOrCreateDpopProofKeyPair,
15 mobileCryptoLayer,
16} from "./dpop";
17
18vi.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
38const secureStore = new Map<string, string>();
39vi.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
47function 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
58function 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
69describe("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 @@
1import * as Clock from "effect/Clock";
2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Encoding from "effect/Encoding";
6import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";
8import * as ExpoCrypto from "expo-crypto";
9import * as SecureStore from "expo-secure-store";
10import { p256 } from "@noble/curves/nist";
11import {
12 computeDpopAccessTokenHash,
13 computeDpopJwkThumbprint,
14 DpopPublicJwk,
15 normalizeDpopHtu,
16} from "@t3tools/shared/dpop";
17import * as Layer from "effect/Layer";
18
19export class CloudDpopError extends Data.TaggedError("CloudDpopError")<{
20 readonly message: string;
21 readonly cause?: unknown;
22}> {}
23
24function cloudDpopError(message: string) {
25 return (cause: unknown) => new CloudDpopError({ message, cause });
26}
27
28const DpopPrivateJwkSchema = Schema.Struct({
29 ...DpopPublicJwk.fields,
30 d: Schema.String,
31});
32
33const DpopPrivateJwkJson = Schema.fromJsonString(DpopPrivateJwkSchema);
34const decodeDpopPrivateJwkJson = Schema.decodeUnknownEffect(DpopPrivateJwkJson);
35const encodeDpopPrivateJwkJson = Schema.encodeEffect(DpopPrivateJwkJson);
36
37const DpopJwtHeaderJson = Schema.fromJsonString(
38 Schema.Struct({
39 typ: Schema.Literal("dpop+jwt"),
40 alg: Schema.Literal("ES256"),
41 jwk: DpopPublicJwk,
42 }),
43);
44
45const 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
55const encodeDpopJwtHeaderJson = Schema.encodeEffect(DpopJwtHeaderJson);
56const encodeDpopJwtPayloadJson = Schema.encodeEffect(DpopJwtPayloadJson);
57
58function 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
73export 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
86type DpopPrivateJwk = typeof DpopPrivateJwkSchema.Type;
87
88export interface DpopProofKeyPair {
89 readonly privateJwk: DpopPrivateJwk;
90 readonly publicJwk: DpopPublicJwk;
91 readonly thumbprint: string;
92}
93
94const DPOP_PROOF_KEY_STORAGE_KEY = "t3code.cloud.dpop-proof-key";
95
96function base64UrlToBytes(value: string): Uint8Array {
97 return Result.getOrThrow(Encoding.decodeBase64Url(value));
98}
99
100function 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
110function 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
120function 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
132function privateJwkFromPrivateKey(
133 privateKey: Uint8Array,
134 publicJwk: DpopPublicJwk,
135): DpopPrivateJwk {
136 return { ...publicJwk, d: Encoding.encodeBase64Url(privateKey) };
137}
138
139export 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
165export 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
209function 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
216export 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 @@
1import { beforeEach, vi } from "vitest";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { EnvironmentId } from "@t3tools/contracts";
6import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import {
8 managedRelayClientLayer,
9 ManagedRelayClient,
10 ManagedRelayDpopSigner,
11 remoteHttpClientLayer,
12} from "@t3tools/client-runtime";
13import { HttpClient } from "effect/unstable/http";
14
15import {
16 cloudEnvironmentsPendingStatus,
17 linkEnvironmentToCloud,
18 connectCloudEnvironment,
19 listCloudEnvironments,
20 listCloudEnvironmentsWithStatus,
21 normalizeRelayBaseUrl,
22 refreshCloudEnvironmentConnection,
23} from "./linkEnvironment";
24
25vi.mock("expo-constants", () => ({
26 default: {
27 expoConfig: {
28 extra: {
29 relay: {
30 url: "https://relay.example.test",
31 },
32 },
33 },
34 },
35}));
36
37vi.mock("react-native", () => ({
38 Platform: {
39 OS: "ios",
40 },
41}));
42
43vi.mock("../../lib/storage", () => ({
44 loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
45 loadPreferences: vi.fn(() => Promise.resolve({})),
46}));
47
48const 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
58const createProofMock = vi.fn(
59 (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
60 Effect.succeed(`dpop:${input.method}:${input.url}`),
61);
62const testDpopSignerLayer = Layer.succeed(
63 ManagedRelayDpopSigner,
64 ManagedRelayDpopSigner.of({
65 thumbprint: Effect.succeed("client-proof-key-thumbprint"),
66 createProof: (input) => createProofMock(input),
67 }),
68);
69
70function 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
81const withCloudServices = <A, E>(
82 effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>,
83) => effect.pipe(Effect.provide(cloudClientLayer()));
84
85function validLinkProof() {
86 return "signed-environment-link-jwt";
87}
88
89function 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
109function validLinkChallengeResponse() {
110 return {
111 challenge: "link-challenge",
112 expiresAt: "2026-05-25T00:05:00.000Z",
113 };
114}
115
116function requestBodyText(body: BodyInit | null | undefined): string {
117 return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
118}
119
120function 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
130function 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
143describe("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 @@
1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import { HttpClient } from "effect/unstable/http";
5import {
6 EnvironmentCloudEndpointUnavailableError,
7 EnvironmentHttpBadRequestError,
8 EnvironmentHttpConflictError,
9 EnvironmentHttpForbiddenError,
10 EnvironmentHttpInternalServerError,
11 EnvironmentHttpUnauthorizedError,
12} from "@t3tools/contracts";
13import { stripPairingTokenFromUrl } from "@t3tools/shared/remote";
14import {
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";
26import {
27 exchangeRemoteDpopAccessToken,
28 fetchRemoteEnvironmentDescriptor,
29 makeEnvironmentHttpApiClient,
30 ManagedRelayClient,
31 ManagedRelayDpopSigner,
32} from "@t3tools/client-runtime";
33
34import { mobileAuthClientMetadata } from "../../lib/authClientMetadata";
35import type { SavedRemoteConnection } from "../../lib/connection";
36import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage";
37import { resolveCloudPublicConfig } from "./publicConfig";
38
39const RELAY_STATUS_AND_CONNECT_SCOPES = [
40 RelayEnvironmentStatusScope,
41 RelayEnvironmentConnectScope,
42] satisfies ReadonlyArray<RelayDpopAccessTokenScope>;
43
44export 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
52function readRelayUrl(): string | null {
53 return resolveCloudPublicConfig().relayUrl;
54}
55
56export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
57 readonly message: string;
58 readonly cause?: unknown;
59}> {}
60
61export interface CloudEnvironmentRecordWithStatus {
62 readonly environment: RelayClientEnvironmentRecord;
63 readonly status: RelayEnvironmentStatusResponseType | null;
64 readonly statusError: string | null;
65}
66
67const isRelayProtectedError = Schema.is(RelayProtectedError);
68const isEnvironmentCloudApiError = Schema.is(
69 Schema.Union([
70 EnvironmentHttpBadRequestError,
71 EnvironmentHttpUnauthorizedError,
72 EnvironmentHttpForbiddenError,
73 EnvironmentHttpConflictError,
74 EnvironmentHttpInternalServerError,
75 EnvironmentCloudEndpointUnavailableError,
76 ]),
77);
78
79const MANAGED_ENDPOINT_PROVIDER_KIND =
80 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
81
82function 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
94function isDevRuntime(): boolean {
95 return typeof __DEV__ !== "undefined" && __DEV__;
96}
97
98function 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
112function 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
120function 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
155function 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
166function 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
176function 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
186function 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
193function 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
201function 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
219function 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
230function 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
255function 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
267export 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
357export 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
376export 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
406export 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
416export 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
449export 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
465function 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
555export 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
570export 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 @@
1import {
2 managedRelayClientLayer,
3 ManagedRelayDpopSigner,
4 ManagedRelayDpopSignerError,
5} from "@t3tools/client-runtime";
6import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10
11import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop";
12
13const 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
39export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 createManagedRelayQueryManager,
4 ManagedRelayClient,
5 managedRelaySessionAtom,
6 readManagedRelaySnapshotState,
7} from "@t3tools/client-runtime";
8import type {
9 RelayClientEnvironmentRecord,
10 RelayEnvironmentStatusResponse,
11} from "@t3tools/contracts/relay";
12import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import { AsyncResult, Atom } from "effect/unstable/reactivity";
16import { useCallback } from "react";
17
18import { mobileRuntime } from "../../lib/runtime";
19import { appAtomRegistry } from "../../state/atom-registry";
20
21const managedRelayAtomRuntime = Atom.runtime(
22 Layer.effect(
23 ManagedRelayClient,
24 mobileRuntime.contextEffect.pipe(
25 Effect.map((context) => Context.get(context, ManagedRelayClient)),
26 ),
27 ),
28);
29
30export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime);
31
32const EMPTY_ENVIRONMENTS_ATOM = Atom.make(
33 AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]),
34).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:mobile:environments:null"));
35
36const 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
40export 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
60export 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
83export 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 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { resolveCloudPublicConfig } from "./publicConfig";
4
5vi.mock("expo-constants", () => ({
6 default: {
7 expoConfig: {
8 extra: {},
9 },
10 },
11}));
12
13describe("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 @@
1import Constants from "expo-constants";
2import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";
3import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
4
5type ExpoExtra = Readonly<Record<string, unknown>> | undefined;
6
7export interface CloudPublicConfig {
8 readonly clerkPublishableKey: string | null;
9 readonly clerkJwtTemplate: string | null;
10 readonly relayUrl: string | null;
11}
12
13function trimNonEmpty(value: unknown): string | null {
14 return typeof value === "string" && value.trim() ? value.trim() : null;
15}
16
17export 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
30export function hasCloudPublicConfig(): boolean {
31 const config = resolveCloudPublicConfig();
32 return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl);
33}
34
35export 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 @@
1import { getClerkInstance } from "@clerk/expo";
2import { tokenCache } from "@clerk/expo/token-cache";
3import * as Data from "effect/Data";
4import { useCallback, useRef } from "react";
5import type { TurboModule } from "react-native";
6import { TurboModuleRegistry } from "react-native";
7
8const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt";
9
10interface 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
18interface NativeAuthResult {
19 readonly cancelled?: boolean;
20 readonly session?: {
21 readonly id?: string;
22 };
23 readonly sessionId?: string;
24}
25
26interface ClerkWithNativeSync {
27 readonly __internal_reloadInitialResources?: () => Promise<void>;
28 readonly setActive?: (params: { readonly session: string }) => Promise<void>;
29}
30
31const NativeClerk = TurboModuleRegistry.get<NativeClerkModule>("ClerkExpo");
32
33class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{
34 readonly message: string;
35 readonly cause?: unknown;
36}> {}
37
38async 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
91export 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 <Text117 <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 <TextInput120 ) : (
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>
133139
134 <View className="gap-1.5">140 <View className="gap-1.5">
135 <Text141 <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 URL145 URL
140 </Text>146 </Text>
141 <TextInput147 <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 )}
152160
153 <View className="flex-row gap-2">161 <View className="flex-row justify-end gap-2">
154 <Pressable162 {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 Save167 <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 )}
166176
167 <Pressable177 <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";
44import { NewTaskSheetHeader } from "../threads/NewTaskSheetHeader";
4544
46interface EnvironmentOption {45interface 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}
107106
108function AddProjectShell(props: {107function 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();
115109
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 <ScrollView112 <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]);
364349
365 return (350 return (
366 <AddProjectShell title="Select source" closeToNew>351 <AddProjectShell>
367 {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null}352 {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null}
368353
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]);
562547
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 <TextInput551 <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]);
712697
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]);
791776
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 <TextInput243 <TextInput
244 autoFocus244 autoFocus
245 multiline245 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";
8import { SymbolView } from "expo-symbols";8import { SymbolView } from "expo-symbols";
9import { Stack, useLocalSearchParams, useRouter } from "expo-router";9import { Stack, useLocalSearchParams, useRouter } from "expo-router";
10import { useCallback, useEffect, useMemo, useRef, useState } from "react";10import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11import {11import { 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";
21import {12import {
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";
2718
19import {
20 ComposerToolbarButton,
21 ComposerToolbarRow,
22 ComposerToolbarScroller,
23} from "../../components/ComposerToolbarTrigger";
28import { EmptyState } from "../../components/EmptyState";24import { EmptyState } from "../../components/EmptyState";
29import { GlassSurface } from "../../components/GlassSurface";25import { GlassSurface } from "../../components/GlassSurface";
30import { LoadingScreen } from "../../components/LoadingScreen";26import { LoadingScreen } from "../../components/LoadingScreen";
...@@ -79,7 +75,6 @@ import {...@@ -79,7 +75,6 @@ import {
79const DEFAULT_TERMINAL_COLS = 80;75const DEFAULT_TERMINAL_COLS = 80;
80const DEFAULT_TERMINAL_ROWS = 24;76const DEFAULT_TERMINAL_ROWS = 24;
81const TERMINAL_ACCESSORY_HEIGHT = 52;77const TERMINAL_ACCESSORY_HEIGHT = 52;
82const TERMINAL_ACCESSORY_FADE_WIDTH = 18;
8378
84type PendingModifier = "ctrl" | "meta";79type PendingModifier = "ctrl" | "meta";
85type HostPlatform = "mac" | "linux" | "windows" | "unknown";80type 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 };
9691
97function 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
138function firstRouteParam(value: string | string[] | undefined): string | null {92function 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}
188142
189function withAlpha(hexColor: string, alpha: string): string {
190 return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor;
191}
192
193function pickRunningTerminalSessionForBootstrap(143function 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 };
372313
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 );
966899
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 />
1090982
1091 <Stack.Toolbar placement="right">983 <Stack.Toolbar placement="right">
1092 <Stack.Toolbar.Menu984 <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 <ScrollView1059 contentPaddingRight={2}
1181 horizontal1060 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}
11921071 label={action.label}
1193 return (1072 maxWidth={120}
1194 <Pressable1073 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: active1078 ? "uppercase"
1200 ? withAlpha(1079 : "none"
1201 terminalTheme.palette[10] ?? terminalTheme.foreground,1080 }
1202 "2e",1081 />
1203 )1082 );
1204 : pressed1083 })}
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: pressed1091 </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 @@
1import { useRouter } from "expo-router";1import { Stack, useRouter } from "expo-router";
2import { TextInputWrapper } from "expo-paste-input";2import { TextInputWrapper } from "expo-paste-input";
3import { useCallback, useEffect, useMemo } from "react";3import { useCallback, useEffect, useMemo, useRef } from "react";
4import { View } from "react-native";4import {
5import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller";5 InteractionManager,
6 View,
7 useColorScheme,
8 type TextInput as RNTextInput,
9} from "react-native";
10import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
6import { useSafeAreaInsets } from "react-native-safe-area-context";11import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";12import { useThemeColor } from "../../lib/useThemeColor";
813
9import { EnvironmentId, type ModelSelection } from "@t3tools/contracts";14import { EnvironmentId, type ModelSelection } from "@t3tools/contracts";
1015
11import { AppTextInput as TextInput } from "../../components/AppText";16import { AppTextInput as TextInput } from "../../components/AppText";
17import {
18 ComposerToolbarButton,
19 ComposerToolbarRow,
20 ComposerToolbarScroller,
21 ComposerToolbarTrigger,
22} from "../../components/ComposerToolbarTrigger";
12import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";23import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
13import { ControlPill, ControlPillMenu } from "../../components/ControlPill";24import { ControlPillMenu } from "../../components/ControlPill";
14import { ProviderIcon } from "../../components/ProviderIcon";25import { ProviderIcon } from "../../components/ProviderIcon";
1526
16import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";27import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
...@@ -18,7 +29,6 @@ import { buildThreadRoutePath } from "../../lib/routes";...@@ -18,7 +29,6 @@ import { buildThreadRoutePath } from "../../lib/routes";
18import { useRemoteCatalog } from "../../state/use-remote-catalog";29import { useRemoteCatalog } from "../../state/use-remote-catalog";
19import { useNativePaste } from "../../lib/useNativePaste";30import { useNativePaste } from "../../lib/useNativePaste";
20import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";31import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";
21import { NewTaskSheetHeader } from "./NewTaskSheetHeader";
22import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";32import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
23import { useProjectActions } from "./use-project-actions";33import { useProjectActions } from "./use-project-actions";
2434
...@@ -34,6 +44,22 @@ function withModelSelectionOption(...@@ -34,6 +44,22 @@ function withModelSelectionOption(
34 };44 };
35}45}
3646
47function formatTitleCase(value: string): string {
48 return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
49}
50
51function 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
37export function NewTaskDraftScreen(props: {63export 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);
5179
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)";
5383
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]);
95125
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 ]);
255303
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 });
383456
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 }
399474
400 return (475 return (
401 <View className="flex-1 bg-sheet">476 <View className="flex-1 bg-sheet">
402 <NewTaskSheetHeader477 <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 > 1480 <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>
406 ? { icon: "chevron.left", onPress: () => router.back() }481 <TextInputWrapper
407 : undefined482 onPaste={(payload) => void handleNativePaste(payload)}
408 }483 style={{ flex: 1, minHeight: 0 }}
409 />484 >
410485 <TextInput
411 <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}>486 ref={promptInputRef}
412 <TextInputWrapper487 autoFocus
413 onPaste={(payload) => void handleNativePaste(payload)}488 multiline
414 style={{ flex: 1 }}489 scrollEnabled
415 >490 value={flow.prompt}
416 <TextInput491 onChangeText={flow.setPrompt}
417 multiline492 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>
427499
428 <KeyboardStickyView>
429 <View500 <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 <ControlPillMenu519 fadeOpaque={sheetFadeOpaque}
449 actions={modelMenuActions}520 fadeTransparent={sheetFadeTransparent}
450 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
451 >521 >
452 <ControlPill522 <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 <ControlPillMenu528 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 <ControlPillMenu534 <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 <ControlPillMenu540 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 <ControlPill546 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 @@
1import { SymbolView } from "expo-symbols";
2import { Pressable, View } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { useThemeColor } from "../../lib/useThemeColor";
6
7export 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";
3131
32import { AppText as Text } from "../../components/AppText";32import { AppText as Text } from "../../components/AppText";
33import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";33import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
34import {
35 ComposerToolbarButton,
36 ComposerToolbarRow,
37 ComposerToolbarScroller,
38 ComposerToolbarTrigger,
39} from "../../components/ComposerToolbarTrigger";
34import { ControlPill, ControlPillMenu } from "../../components/ControlPill";40import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
35import { ProviderIcon } from "../../components/ProviderIcon";41import { ProviderIcon } from "../../components/ProviderIcon";
36import type { DraftComposerImageAttachment } from "../../lib/composerImages";42import 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 for73 * 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 */
70export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 54;76export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 60;
7177
72export interface ThreadComposerProps {78export 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}
147152
153function formatTitleCase(value: string): string {
154 return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
155}
156
148export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {157export 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;
185194
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 );
432442
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 );
438448 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
753776
754 {/* Toolbar row — matches draft page layout (expanded only) */}777 {/* Toolbar row — matches draft page layout (expanded only) */}
755 {isExpanded ? (778 {isExpanded ? (
756 <View779 <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 <ControlPill790 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}
795830
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});
199198
200export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {199export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
201 const { onOpenDrawer, onRefresh } = props;200 const { onOpenDrawer } = props;
202201
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]);
225223
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";
7import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";7import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
8import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";8import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
9import { useThemeColor } from "../../lib/useThemeColor";9import { useThemeColor } from "../../lib/useThemeColor";
10import { useVcsStatus, vcsStatusManager } from "../../state/use-vcs-status";10import { useVcsStatus } from "../../state/use-vcs-status";
11import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";11import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
1212
13import { EmptyState } from "../../components/EmptyState";13import { 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}
5555
56function OpeningThreadLoadingScreen() {
57 return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;
58}
59
56export function ThreadRouteScreen() {60export 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);
124127
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 );
246235
247 if (!environmentId || !threadId) {236 if (!environmentId || !threadId) {
248 return <LoadingScreen message="Opening thread…" />;237 return <OpeningThreadLoadingScreen />;
249 }238 }
250239
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";
256245
257 if (stillHydrating) {246 if (stillHydrating) {
258 return <LoadingScreen message="Opening thread…" />;247 return <OpeningThreadLoadingScreen />;
259 }248 }
260249
261 return (250 return (
...@@ -278,7 +267,7 @@ export function ThreadRouteScreen() {...@@ -278,7 +267,7 @@ export function ThreadRouteScreen() {
278 }267 }
279268
280 if (!selectedThreadDetail) {269 if (!selectedThreadDetail) {
281 return <LoadingScreen message="Opening thread…" />;270 return <OpeningThreadLoadingScreen />;
282 }271 }
283272
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";
15import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";15import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
16import { groupProjectsByRepository } from "../../lib/repositoryGroups";16import { groupProjectsByRepository } from "../../lib/repositoryGroups";
17import { scopedProjectKey } from "../../lib/scopedEntities";17import { scopedProjectKey } from "../../lib/scopedEntities";
18import {
19 appendComposerDraftAttachments,
20 removeComposerDraftAttachment,
21 replaceComposerDraftAttachments,
22 setComposerDraftText,
23 useComposerDraft,
24} from "../../state/use-composer-drafts";
18import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs";25import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs";
19import { useRemoteCatalog } from "../../state/use-remote-catalog";26import { useRemoteCatalog } from "../../state/use-remote-catalog";
20import {27import {
...@@ -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);
168173
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]);
213194
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;
274261
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;
299286
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 @@
1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
2import { Platform } from "react-native";
3
4export 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 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";
23
3import { mobileAuthClientMetadata, redactPairingCredential } from "./connection";4import {
5 isRelayManagedConnection,
6 mobileAuthClientMetadata,
7 redactPairingCredential,
8 toStableSavedRemoteConnection,
9} from "./connection";
410
5vi.mock("./runtime", () => ({11vi.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 @@
1import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import {2import {
3 bootstrapRemoteBearerSession,3 bootstrapRemoteBearerSession,
4 fetchRemoteEnvironmentDescriptor,4 fetchRemoteEnvironmentDescriptor,
5} from "@t3tools/client-runtime";5} from "@t3tools/client-runtime";
6import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";6import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";
7import { Platform } from "react-native";7import * as Effect from "effect/Effect";
8import { mobileRemoteHttpRuntime } from "./runtime";8import { mobileAuthClientMetadata } from "./authClientMetadata";
9import { mobileRuntime } from "./runtime";
10
11export { mobileAuthClientMetadata } from "./authClientMetadata";
912
10export interface RemoteConnectionInput {13export 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}
2329
24export type RemoteClientConnectionState =30export 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}
3945
40export function mobileAuthClientMetadata(): AuthClientPresentationMetadata {46export 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
52export 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}
4762
48export async function bootstrapRemoteConnection(63export 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 });
5469
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,
6075 }),
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 );
6885
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 @@
1import * as Layer from "effect/Layer";
1import * as ManagedRuntime from "effect/ManagedRuntime";2import * as ManagedRuntime from "effect/ManagedRuntime";
23
3import { remoteHttpClientLayer } from "@t3tools/client-runtime";4import { remoteHttpClientLayer } from "@t3tools/client-runtime";
45
5export const mobileRemoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(fetch));6import { mobileCryptoLayer } from "../features/cloud/dpop";
7import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer";
8import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
9
10function configuredRelayUrl(): string {
11 return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid";
12}
13
14const mobileHttpClientLayer = remoteHttpClientLayer(fetch);
15
16export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vitest";
3
4const 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
16vi.mock("expo-secure-store", () => ({
17 getItemAsync: mocks.getItemAsync,
18 setItemAsync: mocks.setItemAsync,
19}));
20
21vi.mock("react-native", () => ({
22 Platform: {
23 OS: "ios",
24 },
25}));
26
27vi.mock("./runtime", () => ({
28 mobileRuntime: {
29 runPromise: vi.fn(),
30 },
31}));
32
33import { loadSavedConnections, saveConnection } from "./storage";
34import { toStableSavedRemoteConnection } from "./connection";
35
36const 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
49describe("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";
5import * as SecureStore from "expo-secure-store";5import * as SecureStore from "expo-secure-store";
6import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts";6import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts";
77
8import type { SavedRemoteConnection } from "./connection";8import {
9 isRelayManagedConnection,
10 type SavedRemoteConnection,
11 toStableSavedRemoteConnection,
12} from "./connection";
913
10const CONNECTIONS_KEY = "t3code.connections";14const CONNECTIONS_KEY = "t3code.connections";
11const PREFERENCES_KEY = "t3code.preferences";15const PREFERENCES_KEY = "t3code.preferences";
16const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
12const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;17const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
13const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";18const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
1419
...@@ -20,6 +25,7 @@ export interface CachedShellSnapshot {...@@ -20,6 +25,7 @@ export interface CachedShellSnapshot {
20}25}
2126
22export interface MobilePreferences {27export interface MobilePreferences {
28 readonly liveActivitiesEnabled?: boolean;
23 readonly terminalFontSize?: number;29 readonly terminalFontSize?: number;
24}30}
2531
...@@ -133,18 +139,23 @@ export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteC...@@ -133,18 +139,23 @@ export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteC
133139
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}
139147
140export async function saveConnection(connection: SavedRemoteConnection): Promise<void> {148export 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));
148159
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 }
166177
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 }
170189
171 return {};190 return preferences;
172}191}
173192
174export async function savePreferencesPatch(193export 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
205export 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
217export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useEffect } from "react";
3import { Atom } from "effect/unstable/reactivity";
4
5import type { DraftComposerImageAttachment } from "../lib/composerImages";
6import { appAtomRegistry } from "./atom-registry";
7
8const COMPOSER_DRAFTS_SCHEMA_VERSION = 1;
9const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts";
10const COMPOSER_DRAFTS_FILE = "drafts.json";
11const PERSIST_DEBOUNCE_MS = 200;
12
13export interface ComposerDraft {
14 readonly text: string;
15 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
16}
17
18interface PersistedComposerDrafts {
19 readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION;
20 readonly drafts: Record<string, ComposerDraft>;
21}
22
23const EMPTY_DRAFT: ComposerDraft = {
24 text: "",
25 attachments: [],
26};
27
28export const composerDraftsAtom = Atom.make<Record<string, ComposerDraft>>({}).pipe(
29 Atom.keepAlive,
30 Atom.withLabel("mobile:composer-drafts"),
31);
32
33let loadStarted = false;
34let persistTimer: ReturnType<typeof setTimeout> | null = null;
35
36function 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
46function isEmptyDraft(draft: ComposerDraft): boolean {
47 return draft.text.length === 0 && draft.attachments.length === 0;
48}
49
50async 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
57async 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
82async 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
101function 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
111export 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
128function 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
136export 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
154export 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
167export 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
186export 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
207export 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
226export 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
237export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId } from "@t3tools/contracts";
3import {
4 createManagedRelaySession,
5 ManagedRelayDpopSigner,
6 setManagedRelaySession,
7} from "@t3tools/client-runtime";
8import * as Effect from "effect/Effect";
9import { beforeEach, vi } from "vitest";
10
11const 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
65vi.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
75vi.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
89vi.mock("../lib/connection", async (importOriginal) => ({
90 ...(await importOriginal<typeof import("../lib/connection")>()),
91 bootstrapRemoteConnection: mocks.bootstrapRemoteConnection,
92}));
93
94vi.mock("../features/cloud/linkEnvironment", () => ({
95 refreshCloudEnvironmentConnection: mocks.refreshCloudEnvironmentConnection,
96}));
97
98vi.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
107vi.mock("../lib/runtime", () => ({
108 mobileRuntime: {
109 runPromise: mocks.mobileRunPromise,
110 },
111}));
112
113vi.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
121vi.mock("../features/agent-awareness/remoteRegistration", () => ({
122 registerAgentAwarenessConnection: mocks.registerAgentAwarenessConnection,
123 unregisterAgentAwarenessConnection: mocks.unregisterAgentAwarenessConnection,
124 unregisterAllAgentAwarenessConnections: vi.fn(),
125}));
126
127vi.mock("../features/terminal/terminalDebugLog", () => ({
128 terminalDebugLog: mocks.terminalDebugLog,
129}));
130
131vi.mock("./use-environment-runtime", () => ({
132 environmentRuntimeManager: {
133 invalidate: mocks.environmentRuntimeInvalidate,
134 patch: mocks.environmentRuntimePatch,
135 },
136 useEnvironmentRuntimeStates: vi.fn(() => ({})),
137}));
138
139vi.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
151vi.mock("./use-source-control-discovery", () => ({
152 invalidateSourceControlDiscoveryForEnvironment:
153 mocks.invalidateSourceControlDiscoveryForEnvironment,
154 resetSourceControlDiscoveryState: vi.fn(),
155}));
156
157vi.mock("./use-terminal-session", () => ({
158 subscribeTerminalMetadata: mocks.subscribeTerminalMetadata,
159 terminalSessionManager: {
160 invalidate: vi.fn(),
161 invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment,
162 },
163}));
164
165import {
166 connectSavedEnvironment,
167 disconnectEnvironment,
168 reconnectEnvironmentConnectionsAfterAppResume,
169} from "./use-remote-environment-registry";
170import { appAtomRegistry } from "./atom-registry";
171
172const environmentId = EnvironmentId.make("env-mobile-test");
173
174const 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
184describe("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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useEffect, useMemo } from "react";2import { useCallback, useEffect, useMemo } from "react";
3import { Alert } from "react-native";3import { Alert, AppState } from "react-native";
44
5import {5import {
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";
15import type { EnvironmentId } from "@t3tools/contracts";19import type { EnvironmentId } from "@t3tools/contracts";
16import * as Arr from "effect/Array";20import * as Arr from "effect/Array";
21import * as Duration from "effect/Duration";
22import * as Effect from "effect/Effect";
17import * as Order from "effect/Order";23import * as Order from "effect/Order";
18import * as Option from "effect/Option";24import * as Option from "effect/Option";
19import { pipe } from "effect/Function";25import { pipe } from "effect/Function";
20import { Atom } from "effect/unstable/reactivity";26import { Atom } from "effect/unstable/reactivity";
21import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection";27import {
28 type SavedRemoteConnection,
29 bootstrapRemoteConnection,
30 isRelayManagedConnection,
31 toStableSavedRemoteConnection,
32} from "../lib/connection";
33import { refreshCloudEnvironmentConnection } from "../features/cloud/linkEnvironment";
22import { terminalDebugLog } from "../features/terminal/terminalDebugLog";34import { terminalDebugLog } from "../features/terminal/terminalDebugLog";
23import {35import {
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";
31import { appAtomRegistry } from "./atom-registry";43import { appAtomRegistry } from "./atom-registry";
32import { mobileRemoteHttpRuntime } from "../lib/runtime";44import { mobileRuntime } from "../lib/runtime";
33import {45import {
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";
57import {
58 registerAgentAwarenessConnection,
59 unregisterAgentAwarenessConnection,
60 unregisterAllAgentAwarenessConnections,
61} from "../features/agent-awareness/remoteRegistration";
44import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";62import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";
45import {63import {
46 clearCachedShellSnapshotMetadata,64 clearCachedShellSnapshotMetadata,
...@@ -53,6 +71,8 @@ import { subscribeTerminalMetadata, terminalSessionManager } from "./use-termina...@@ -53,6 +71,8 @@ import { subscribeTerminalMetadata, terminalSessionManager } from "./use-termina
53const terminalMetadataUnsubscribers = new Map<EnvironmentId, () => void>();71const terminalMetadataUnsubscribers = new Map<EnvironmentId, () => void>();
54const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry();72const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry();
55const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000;73const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000;
74const APP_RESUME_RECONNECT_COOLDOWN_MS = 2_000;
75let lastAppResumeReconnectAt = Number.NEGATIVE_INFINITY;
5676
57interface RemoteEnvironmentLocalState {77interface 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}
155175
156function withTimeout<T>(176function 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}
178182
179export async function disconnectEnvironment(183export 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 }
190195
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 }
202207 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}
210224
211export async function connectSavedEnvironment(225export 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);
217231 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,
225239 });
226 if (options?.persist !== false) {
227 await saveConnection(connection);
228 if (!isCurrentAttempt()) {240 if (!isCurrentAttempt()) {
229 return;241 return;
230 }242 }
231 }
232243
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 }
236308
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 }
251372
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 );
286399
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}
336461
337 if (!isCurrentAttempt()) {462export 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 }
341467
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();
357473
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}
374513
514function 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
375const environmentsSortOrder = Order.mapInput(527const 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(
403export function useRemoteEnvironmentBootstrap() {556export function useRemoteEnvironmentBootstrap() {
404 useEffect(() => {557 useEffect(() => {
405 let cancelled = false;558 let cancelled = false;
559 const unsubscribeAppResumeReconnects = subscribeAppResumeReconnects();
406560
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() {
418573
419 setIsLoadingSavedConnection(false);574 setIsLoadingSavedConnection(false);
420575
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 );
430584
431 if (cancelled) {585 if (cancelled) {
432 return;586 return;
433 }587 }
434588
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 })();
449606
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 }
562721
...@@ -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 }, []);
582748
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";
16import { scopedThreadKey } from "../lib/scopedEntities";16import { scopedThreadKey } from "../lib/scopedEntities";
17import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity";17import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity";
18import { appAtomRegistry } from "../state/atom-registry";18import { appAtomRegistry } from "../state/atom-registry";
19import {
20 appendComposerDraftAttachments,
21 appendComposerDraftText,
22 clearComposerDraft,
23 composerDraftsAtom,
24 ensureComposerDraftsLoaded,
25 removeComposerDraftAttachment,
26 setComposerDraftText,
27 useComposerDraft,
28} from "./use-composer-drafts";
19import { getEnvironmentClient } from "./environment-session-registry";29import { getEnvironmentClient } from "./environment-session-registry";
20import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types";30import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types";
21import {31import {
...@@ -26,15 +36,6 @@ import { useRemoteCatalog } from "../state/use-remote-catalog";...@@ -26,15 +36,6 @@ import { useRemoteCatalog } from "../state/use-remote-catalog";
26import { useSelectedThreadDetail } from "../state/use-thread-detail";36import { useSelectedThreadDetail } from "../state/use-thread-detail";
27import { useThreadSelection } from "../state/use-thread-selection";37import { useThreadSelection } from "../state/use-thread-selection";
2838
29const draftMessageByThreadKeyAtom = Atom.make<Record<string, string>>({}).pipe(
30 Atom.keepAlive,
31 Atom.withLabel("mobile:thread-composer:draft-message"),
32);
33
34const draftAttachmentsByThreadKeyAtom = Atom.make<
35 Record<string, ReadonlyArray<DraftComposerImageAttachment>>
36>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:draft-attachments"));
37
38const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(39const 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"));
4647
47function setDraftMessage(threadKey: string, value: string): void {
48 const current = appAtomRegistry.get(draftMessageByThreadKeyAtom);
49 appAtomRegistry.set(draftMessageByThreadKeyAtom, {
50 ...current,
51 [threadKey]: value,
52 });
53}
54
55function 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
66function 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
74export function appendReviewCommentToDraft(input: {48export 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}
9262
...@@ -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.threadId68 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);
10372
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}
10978
110function 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
123function 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
131function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {79function 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);
238185
186 useEffect(() => {
187 ensureComposerDraftsLoaded();
188 }, []);
189
239 const selectedThreadKey = selectedThreadShell190 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 );
254205
255 const draftMessage = selectedThreadKey ? (draftMessageByThreadKey[selectedThreadKey] ?? "") : "";206 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
256 const draftAttachments = selectedThreadKey207 const draftMessage = selectedDraft?.text ?? "";
257 ? (draftAttachmentsByThreadKey[selectedThreadKey] ?? [])208 const draftAttachments = selectedDraft?.attachments ?? [];
258 : [];
259 const selectedThreadQueueCount = selectedThreadQueuedMessages.length;209 const selectedThreadQueueCount = selectedThreadQueuedMessages.length;
260210
261 const selectedThreadSessionActivity = useMemo(() => {211 const selectedThreadSessionActivity = useMemo(() => {
...@@ -350,8 +300,9 @@ export function useThreadComposerState() {...@@ -350,8 +300,9 @@ export function useThreadComposerState() {
350 }300 }
351301
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]);
371322
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 }
377328
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() {
388339
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]);
400351
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() {
405356
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]);
420371
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 );
442393
443 const onRemoveDraftImage = useCallback(394 const onRemoveDraftImage = useCallback(
...@@ -447,7 +398,7 @@ export function useThreadComposerState() {...@@ -447,7 +398,7 @@ export function useThreadComposerState() {
447 }398 }
448399
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 @@
1import { describe, expect, it, vi } from "vitest";
2
3vi.mock("@expo/ui/swift-ui", () => ({
4 HStack: "HStack",
5 Spacer: "Spacer",
6 Text: "Text",
7 VStack: "VStack",
8}));
9
10vi.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
17vi.mock("expo-widgets", () => ({
18 createLiveActivity: vi.fn((name: string, layout: unknown) => ({ layout, name })),
19}));
20
21import { AgentActivity, type AgentActivityProps } from "./AgentActivity";
22
23const 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
31const environment = {
32 colorScheme: "dark",
33 isLuminanceReduced: false,
34} as const;
35
36describe("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 @@
1import { HStack, Spacer, Text, VStack } from "@expo/ui/swift-ui";
2import { font, foregroundStyle, lineLimit, padding } from "@expo/ui/swift-ui/modifiers";
3import {
4 createLiveActivity,
5 type LiveActivityComponent,
6 type LiveActivityLayout,
7} from "expo-widgets";
8
9type LiveActivityEnvironment = Parameters<LiveActivityComponent<AgentActivityProps>>[1];
10
11export type AgentActivityPhase =
12 | "starting"
13 | "running"
14 | "waiting_for_approval"
15 | "waiting_for_input"
16 | "completed"
17 | "failed"
18 | "stale";
19
20export 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
32export 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
40export 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
321export 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";
77import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";77import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
78import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";78import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";
79import * as VcsProcess from "../src/vcs/VcsProcess.ts";79import * as VcsProcess from "../src/vcs/VcsProcess.ts";
80import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts";
8081
81const decodeCodexSettings = Schema.decodeEffect(CodexSettings);82const decodeCodexSettings = Schema.decodeEffect(CodexSettings);
8283
...@@ -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) {}
166167
...@@ -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";
20import { encodeOAuthScope } from "@t3tools/shared/oauthScope";20import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
21import * as Context from "effect/Context";21import * as Context from "effect/Context";
22import * as Crypto from "effect/Crypto";
22import * as Data from "effect/Data";23import * as Data from "effect/Data";
23import * as DateTime from "effect/DateTime";24import * as DateTime from "effect/DateTime";
24import * as Duration from "effect/Duration";25import * 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";
31import * as PairingGrantStore from "./PairingGrantStore.ts";32import * as PairingGrantStore from "./PairingGrantStore.ts";
32import * as ServerSecretStore from "./ServerSecretStore.ts";33import * as ServerSecretStore from "./ServerSecretStore.ts";
33import * as SessionStore from "./SessionStore.ts";34import * as SessionStore from "./SessionStore.ts";
35import { verifyRequestDpopProof } from "./dpop.ts";
34import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";36import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
3537
36export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";38export 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}
6669
...@@ -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 | ServerAuthInternalError118 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};
185192
186const AUTHORIZATION_PREFIX = "Bearer ";193const AUTHORIZATION_PREFIX = "Bearer ";
194const DPOP_AUTHORIZATION_PREFIX = "DPoP ";
187const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket";195const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket";
188196
189const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {197const 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}
247255
256function 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
248export const make = Effect.fn("makeEnvironmentAuth")(function* () {265export 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();
253272
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 };
287339
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 );
350402
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* sessions415 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 );
9494
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}
3132
...@@ -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}
5557
...@@ -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}
8189
82export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()(90export 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 );
260270
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 }
291301
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* () {
326350
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 }
362390
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}> {}
1717
18const isPlatformError = (value: unknown): value is PlatformError.PlatformError =>
19 Predicate.isTagged(value, "PlatformError");
20
21export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean =>
22 isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists";
23
18export interface ServerSecretStoreShape {24export 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* () {
4855
49 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);56 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
5057
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 };
106110
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 !== null156 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}
4445
45export interface VerifiedSession {46export 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}
5456
55export type SessionCredentialChange =57export 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 @@
1import { describe, expect, it } from "vitest";
2import * as PlatformError from "effect/PlatformError";
3
4import * as ServerSecretStore from "./ServerSecretStore.ts";
5import { mapDpopReplayStoreError } from "./dpop.ts";
6
7const 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
18describe("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 @@
1import { verifyDpopProof } from "@t3tools/shared/dpop";
2import * as Crypto from "effect/Crypto";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Encoding from "effect/Encoding";
6import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
7
8import * as EnvironmentAuth from "./EnvironmentAuth.ts";
9import * as ServerSecretStore from "./ServerSecretStore.ts";
10
11function firstHeaderValue(value: string | undefined): string | undefined {
12 const first = value?.split(",")[0]?.trim();
13 return first && first.length > 0 ? first : undefined;
14}
15
16export 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
27export 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
40export 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";
34import * as EnvironmentAuth from "./EnvironmentAuth.ts";34import * as EnvironmentAuth from "./EnvironmentAuth.ts";
35import * as SessionStore from "./SessionStore.ts";35import * as SessionStore from "./SessionStore.ts";
36import { deriveAuthClientMetadata } from "./utils.ts";36import { deriveAuthClientMetadata } from "./utils.ts";
37import { verifyRequestDpopProof } from "./dpop.ts";
38
39const CREDENTIAL_RESPONSE_HEADERS = {
40 "cache-control": "no-store",
41 pragma: "no-cache",
42} as const;
43
44const appendCredentialResponseHeaders = HttpEffect.appendPreResponseHandler((_request, response) =>
45 Effect.succeed(HttpServerResponse.setHeaders(response, CREDENTIAL_RESPONSE_HEADERS)),
46);
47
48const appendDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) =>
49 Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", "DPoP")),
50);
51
52const 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 });
3763
38export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe(64export 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);
158184
...@@ -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";
6import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";6import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
7import * as Crypto from "node:crypto";7import * as Crypto from "node:crypto";
8import * as Encoding from "effect/Encoding";
9import * as Result from "effect/Result";
810
9const SESSION_COOKIE_NAME = "t3_session";11const SESSION_COOKIE_NAME = "t3_session";
1012
...@@ -20,12 +22,13 @@ export function resolveSessionCookieName(input: {...@@ -20,12 +22,13 @@ export function resolveSessionCookieName(input: {
20}22}
2123
22export function base64UrlEncode(input: string | Uint8Array): string {24export 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}
2629
27export function base64UrlDecodeUtf8(input: string): string {30export function base64UrlDecodeUtf8(input: string): string {
28 return Buffer.from(input, "base64url").toString("utf8");31 return Result.getOrThrow(Encoding.decodeBase64UrlString(input));
29}32}
3033
31export function signPayload(payload: string, secret: Uint8Array): string {34export 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.
2import * as NodeHttp from "node:http";2import * as NodeHttp from "node:http";
3import { mkdtempSync } from "node:fs";3import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
4import { tmpdir } from "node:os";4import { tmpdir } from "node:os";
5import { join } from "node:path";5import { join } from "node:path";
66
...@@ -19,7 +19,7 @@ import * as CliError from "effect/unstable/cli/CliError";...@@ -19,7 +19,7 @@ import * as CliError from "effect/unstable/cli/CliError";
19import * as TestConsole from "effect/testing/TestConsole";19import * as TestConsole from "effect/testing/TestConsole";
20import { Command } from "effect/unstable/cli";20import { Command } from "effect/unstable/cli";
2121
22import { cli } from "./bin.ts";22import { cli, makeCli } from "./bin.ts";
23import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";23import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
24import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";24import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
25import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";25import { 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";
38const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);38const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
39class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}39class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}
4040
41const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);41const cloudCli = makeCli({ cloudEnabled: true });
42const noCloudCli = makeCli({ cloudEnabled: false });
43const runCli = (args: ReadonlyArray<string>, command = cli) =>
44 Command.runWith(command, { version: "0.0.0" })(args);
45const runCloudCli = (args: ReadonlyArray<string>) => runCli(args, cloudCli);
42const runCliWithRuntime = (args: ReadonlyArray<string>) =>46const runCliWithRuntime = (args: ReadonlyArray<string>) =>
43 runCli(args).pipe(Effect.provide(CliRuntimeLayer));47 runCli(args).pipe(Effect.provide(CliRuntimeLayer));
4448
...@@ -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 );
177181
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";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import { Command } from "effect/unstable/cli";5import { Command } from "effect/unstable/cli";
6import * as CliError from "effect/unstable/cli/CliError";
67
7import * as NetService from "@t3tools/shared/Net";8import * as NetService from "@t3tools/shared/Net";
8import packageJson from "../package.json" with { type: "json" };9import packageJson from "../package.json" with { type: "json" };
9import { authCommand } from "./cli/auth.ts";10import { authCommand } from "./cli/auth.ts";
11import { cloudCommand } from "./cli/cloud.ts";
12import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
10import { sharedServerCommandFlags } from "./cli/config.ts";13import { sharedServerCommandFlags } from "./cli/config.ts";
11import { projectCommand } from "./cli/project.ts";14import { projectCommand } from "./cli/project.ts";
12import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";15import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1316
14const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);17const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1518
16export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(19const 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]),22class CloudPublicConfigMissingError extends CliError.UserError {
23 override get message() {
24 return cloudPublicConfigMissingMessage;
25 }
26}
27
28const 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);
2140
41export 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
54export const cli = makeCli();
55
22if (import.meta.main) {56if (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 @@
1import * as RelayClient from "@t3tools/shared/relayClient";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5
6import { acquireRelayClientForLink } from "./cloud.ts";
7
8const managedExecutable = {
9 status: "available",
10 executablePath: "/tmp/cloudflared",
11 source: "managed",
12 version: RelayClient.CLOUDFLARED_VERSION,
13} as const;
14
15it.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
43it.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
82it.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 @@
1import {
2 AuthRelayWriteScope,
3 EnvironmentHttpApi,
4 type RelayClientInstallProgressEvent,
5 type RelayClientInstallProgressStage,
6} from "@t3tools/contracts";
7import { RelayOkResponse } from "@t3tools/contracts/relay";
8import * as RelayClient from "@t3tools/shared/relayClient";
9import * as Console from "effect/Console";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as References from "effect/References";
17import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
18import {
19 FetchHttpClient,
20 HttpClient,
21 HttpClientRequest,
22 HttpClientResponse,
23} from "effect/unstable/http";
24import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
25
26import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
28import * as CliState from "../cloud/CliState.ts";
29import * as CliTokenManager from "../cloud/CliTokenManager.ts";
30import { CLOUD_LINKED_USER_ID, RELAY_URL_SECRET } from "../cloud/config.ts";
31import { relayUrlConfig } from "../cloud/publicConfig.ts";
32import { ServerConfig } from "../config.ts";
33import { ServerEnvironmentLive } from "../environment/Layers/ServerEnvironment.ts";
34import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
35import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
36import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
37
38const jsonFlag = Flag.boolean("json").pipe(
39 Flag.withDescription("Emit JSON instead of human-readable output."),
40 Flag.withDefault(false),
41);
42
43function bytesToString(value: Uint8Array): string {
44 return new TextDecoder().decode(value);
45}
46
47interface 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
56function 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
81function 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
110const CLOUD_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(5);
111
112const 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
120function 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
139const reportRelayClientInstallProgress = (event: RelayClientInstallProgressEvent) =>
140 event.type === "progress"
141 ? Console.log(`Relay client: ${relayClientInstallProgressMessage(event.stage)}...`)
142 : Effect.void;
143
144export 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
164const 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
178type LiveCloudActionResult =
179 | { readonly status: "not-running" }
180 | { readonly status: "succeeded" }
181 | { readonly status: "failed"; readonly cause: unknown };
182
183const 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
208type RelayUnlinkResult =
209 | { readonly status: "not-authenticated" }
210 | { readonly status: "revoked" }
211 | { readonly status: "not-linked" };
212
213const 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
237const 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
273const 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
310const 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
326const 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
359const 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
398const 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
407const 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
416export 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};
117117
118const makeLiveServerClient = (origin: string) =>118const 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 });
126122
127const normalizeWorkspaceRootForProjectCommand = Effect.fn(123const normalizeWorkspaceRootForProjectCommand = Effect.fn(
apps/server/src/cloud/CliState.test.ts created+58
...@@ -0,0 +1,58 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
7import { ServerConfig } from "../config.ts";
8import * as CliState from "./CliState.ts";
9import {
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
19const 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
29const makeTestLayer = () =>
30 ServerSecretStore.layer.pipe(
31 Layer.provide(
32 ServerConfig.layerTest(process.cwd(), {
33 prefix: "t3-cloud-cli-state-test-",
34 }),
35 ),
36 );
37
38it.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 @@
1import * as Effect from "effect/Effect";
2
3import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
4import {
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
14export const CLOUD_CLI_DESIRED_LINK_SECRET = "cloud-cli-desired-link";
15
16const TRUE_BYTES = new TextEncoder().encode("true");
17
18export const readCliDesiredCloudLink = Effect.gen(function* () {
19 const secrets = yield* ServerSecretStore.ServerSecretStore;
20 return (yield* secrets.get(CLOUD_CLI_DESIRED_LINK_SECRET)) !== null;
21});
22
23export 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
34export 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.
2import { createServer } from "node:http";
3
4import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
5import * as Clock from "effect/Clock";
6import * as Console from "effect/Console";
7import * as Context from "effect/Context";
8import * as Crypto from "effect/Crypto";
9import * as Data from "effect/Data";
10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Encoding from "effect/Encoding";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Schema from "effect/Schema";
17import * as Semaphore from "effect/Semaphore";
18import * as HttpRouter from "effect/unstable/http/HttpRouter";
19import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
20import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
21import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
22
23import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
24import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts";
25
26const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";
27const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);
28const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));
29
30const PersistedToken = Schema.Struct({
31 accessToken: Schema.String,
32 refreshToken: Schema.String,
33 expiresAtEpochMs: Schema.Number,
34});
35type PersistedToken = typeof PersistedToken.Type;
36
37const PersistedTokenJson = Schema.fromJsonString(PersistedToken);
38const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson);
39const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson);
40
41const 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
48export class CloudCliTokenManagerError extends Data.TaggedError("CloudCliTokenManagerError")<{
49 readonly message: string;
50 readonly cause?: unknown;
51}> {}
52
53export 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
60export class CloudCliTokenManager extends Context.Service<
61 CloudCliTokenManager,
62 CloudCliTokenManagerShape
63>()("t3/cloud/CliTokenManager/CloudCliTokenManager") {}
64
65const 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
78function stringToBytes(value: string): Uint8Array {
79 return new TextEncoder().encode(value);
80}
81
82function bytesToString(value: Uint8Array): string {
83 return new TextDecoder().decode(value);
84}
85
86const 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
236export const layer = Layer.effect(CloudCliTokenManager, make);
apps/server/src/cloud/ManagedEndpointRuntime.test.ts created+362
...@@ -0,0 +1,362 @@
1import { describe, expect, it } from "@effect/vitest";
2import { vi } from "vitest";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as PlatformError from "effect/PlatformError";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import * as RelayClient from "@t3tools/shared/relayClient";
12
13import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts";
14
15const 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
29const runtimeDependencies = (spawner: ReturnType<typeof ChildProcessSpawner.make>) =>
30 Layer.mergeAll(
31 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
32 relayClientAvailableLayer,
33 );
34
35function 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
59describe("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 @@
1import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2import * as RelayClient from "@t3tools/shared/relayClient";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Ref from "effect/Ref";
9import * as Result from "effect/Result";
10import * as Semaphore from "effect/Semaphore";
11import * as Scope from "effect/Scope";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13
14import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
15import { CLOUD_ENDPOINT_RUNTIME_CONFIG, decodeRuntimeConfig } from "./config.ts";
16
17function bytesToString(bytes: Uint8Array): string {
18 return new TextDecoder().decode(bytes);
19}
20
21const 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
30export interface CloudManagedEndpointRuntimeShape {
31 readonly applyConfig: (
32 config: RelayManagedEndpointRuntimeConfig | null,
33 ) => Effect.Effect<CloudManagedEndpointRuntimeStatus>;
34}
35
36export class CloudManagedEndpointRuntime extends Context.Service<
37 CloudManagedEndpointRuntime,
38 CloudManagedEndpointRuntimeShape
39>()("t3/cloud/ManagedEndpointRuntime/CloudManagedEndpointRuntime") {}
40
41export 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
64interface ActiveConnector {
65 readonly child: ChildProcessSpawner.ChildProcessHandle;
66 readonly scope: Scope.Closeable;
67 readonly configKey: string;
68 readonly config: RelayManagedEndpointRuntimeConfig;
69}
70
71function 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
80const 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
92export 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
268export 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 @@
1import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2import * as Schema from "effect/Schema";
3
4export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";
5export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";
6export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id";
7export const RELAY_URL_SECRET = "cloud-relay-url";
8export const RELAY_ISSUER_SECRET = "cloud-relay-issuer";
9export const RELAY_ENVIRONMENT_CREDENTIAL_SECRET = "cloud-relay-environment-credential";
10export const PUBLISH_AGENT_ACTIVITY_SECRET = "cloud-publish-agent-activity";
11
12export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect(
13 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
14);
15
16export const decodeRuntimeConfig = Schema.decodeUnknownOption(
17 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
18);
apps/server/src/cloud/environmentKeys.test.ts created+87
...@@ -0,0 +1,87 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as PlatformError from "effect/PlatformError";
6
7import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
8import { ServerConfig } from "../config.ts";
9import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
10
11const makeServerSecretStoreLayer = () =>
12 ServerSecretStore.layer.pipe(
13 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-environment-keys-test-" })),
14 );
15
16const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation");
17
18it.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 @@
1import * as NodeCrypto from "node:crypto";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
6
7const CLOUD_LINK_KEY_PAIR = "cloud-link-ed25519-key-pair";
8const CLOUD_LINK_PRIVATE_KEY = "cloud-link-ed25519-private-key";
9const CLOUD_LINK_PUBLIC_KEY = "cloud-link-ed25519-public-key";
10
11const EnvironmentKeyPair = Schema.Struct({
12 privateKey: Schema.String,
13 publicKey: Schema.String,
14});
15type EnvironmentKeyPair = typeof EnvironmentKeyPair.Type;
16
17const EnvironmentKeyPairJson = Schema.fromJsonString(EnvironmentKeyPair);
18const decodeEnvironmentKeyPair = Schema.decodeUnknownEffect(EnvironmentKeyPairJson);
19const encodeEnvironmentKeyPair = Schema.encodeEffect(EnvironmentKeyPairJson);
20
21function bytesToString(bytes: Uint8Array): string {
22 return new TextDecoder().decode(bytes);
23}
24
25function stringToBytes(value: string): Uint8Array {
26 return new TextEncoder().encode(value);
27}
28
29const keyPairPersistenceError = (message: string, cause?: unknown) =>
30 new ServerSecretStore.SecretStoreError({ message, cause });
31
32const 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
46const 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
75export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as PlatformError from "effect/PlatformError";
6import { HttpClient } from "effect/unstable/http";
7
8import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
9import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
10import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
11import * as CliTokenManager from "./CliTokenManager.ts";
12import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts";
13import {
14 CloudManagedEndpointRuntime,
15 type CloudManagedEndpointRuntimeShape,
16} from "./ManagedEndpointRuntime.ts";
17
18const 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
29const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation");
30
31function 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
43describe("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
72describe("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 @@
1import * as NodeCrypto from "node:crypto";
2import {
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";
15import {
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";
32import {
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";
42import { isSecureRelayUrl } from "@t3tools/shared/relayUrl";
43import * as DateTime from "effect/DateTime";
44import * as Crypto from "effect/Crypto";
45import * as Duration from "effect/Duration";
46import * as Effect from "effect/Effect";
47import * as Option from "effect/Option";
48import * as Schema from "effect/Schema";
49import * as HttpEffect from "effect/unstable/http/HttpEffect";
50import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
51import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
52import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
53
54import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
55import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
56import { requireEnvironmentScope } from "../auth/http.ts";
57import {
58 ServerEnvironment,
59 type ServerEnvironmentShape,
60} from "../environment/Services/ServerEnvironment.ts";
61import {
62 CloudManagedEndpointRuntime,
63 type CloudManagedEndpointRuntimeShape,
64} from "./ManagedEndpointRuntime.ts";
65import {
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";
75import { relayUrlConfig } from "./publicConfig.ts";
76import * as CliState from "./CliState.ts";
77import * as CliTokenManager from "./CliTokenManager.ts";
78import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
79
80const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-";
81const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-";
82const CLOUD_HEALTH_NONCE_PREFIX = "cloud-health-nonce-";
83const CLOUD_HEALTH_JTI_PREFIX = "cloud-health-jti-";
84const CLOUD_PROOF_MAX_LIFETIME_SECONDS = 5 * 60;
85const CLOUD_PROOF_CLOCK_SKEW_SECONDS = 60;
86const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
87const CLOUD_CREDENTIAL_RESPONSE_HEADERS = {
88 "cache-control": "no-store",
89 pragma: "no-cache",
90} as const;
91
92const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler(
93 (_request, response) =>
94 Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)),
95);
96
97const 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
104const 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
113function bytesToString(bytes: Uint8Array): string {
114 return new TextDecoder().decode(bytes);
115}
116
117function stringToBytes(value: string): Uint8Array {
118 return new TextEncoder().encode(value);
119}
120
121export 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
141function normalizePemForSignedPayload(value: string): string {
142 return value.trim();
143}
144
145function normalizeHostname(hostname: string): string {
146 return hostname
147 .trim()
148 .toLowerCase()
149 .replace(/^\[(.*)\]$/, "$1");
150}
151
152function 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
174function 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
208function 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
237function 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
260function isLoopbackHostname(hostname: string): boolean {
261 return LOOPBACK_HOSTNAMES.has(normalizeHostname(hostname));
262}
263
264function firstForwardedHeaderValue(value: string | undefined): string | undefined {
265 const first = value?.split(",")[0]?.trim();
266 return first && first.length > 0 ? first : undefined;
267}
268
269function 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
282function 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
289function endpointRequestPort(url: URL): number {
290 return Number(url.port || (url.protocol === "https:" ? 443 : 80));
291}
292
293function 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
309function providerKindMatchesRequestedLinkScopes(request: RelayLinkProofRequest): boolean {
310 return request.endpoint.providerKind === "cloudflare_tunnel";
311}
312
313function 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
320function 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
332const decodeCloudHealthProof = Schema.decodeUnknownEffect(RelayCloudEnvironmentHealthProofPayload);
333const decodeCloudMintProof = Schema.decodeUnknownEffect(RelayCloudMintCredentialProofPayload);
334
335interface 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
344const 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
355const 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
406const 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
431const 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
476const 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
494const 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
517const 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
603export const reconcileDesiredCloudLink = Effect.fn("environment.cloud.reconcileDesiredLink")(
604 function* (localOrigin: string) {
605 return yield* reconcileDesiredCloudLinkWith(yield* cloudHttpDependencies, localOrigin);
606 },
607);
608
609const 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
632const 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
643const 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
668const 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
686const 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
803const 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
927export 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 @@
1import { assert, it } from "@effect/vitest";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Effect from "effect/Effect";
4
5import { makeCloudCliOAuthConfig, makeRelayUrlConfig } from "./publicConfig.ts";
6
7const provideEnv = (env: Readonly<Record<string, string>>) =>
8 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })));
9
10it.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
20it.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
30it.effect("requires a relay URL when the server bundle has no injected value", () =>
31 makeRelayUrlConfig("").pipe(provideEnv({}), Effect.flip),
32);
33
34it.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
41it.effect("rejects an injected relay URL with a non-origin path", () =>
42 makeRelayUrlConfig("https://embedded.example.test/path").pipe(provideEnv({}), Effect.flip),
43);
44
45it.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
62it.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
80it.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 @@
1import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
2import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
3import * as Config from "effect/Config";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as SchemaIssue from "effect/SchemaIssue";
8
9declare const __T3CODE_BUILD_RELAY_URL__: string | undefined;
10declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined;
11declare const __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__: string | undefined;
12
13const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback";
14const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const;
15
16function 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
31function readBuildTimeValue(value: string | undefined): string {
32 return typeof value === "undefined" ? "" : value.trim();
33}
34
35export const buildTimeRelayUrl =
36 typeof __T3CODE_BUILD_RELAY_URL__ === "undefined"
37 ? ""
38 : (normalizeSecureRelayUrl(__T3CODE_BUILD_RELAY_URL__) ?? "");
39export const buildTimeClerkPublishableKey = readBuildTimeValue(
40 typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined"
41 ? undefined
42 : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__,
43);
44export 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
50export 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
57export const relayUrlConfig = makeRelayUrlConfig();
58
59function 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
66export 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
74export 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
104export const cloudCliOAuthConfig = makeCloudCliOAuthConfig();
105
106export 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) {}
53const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);53const 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";
8import * as Data from "effect/Data";8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";12import * as Option from "effect/Option";
12import * as Path from "effect/Path";13import * as Path from "effect/Path";
13import { cast } from "effect/Function";14import { 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
47const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";48const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
48const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);49const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
4950
50export const browserApiCorsLayer = HttpRouter.cors({51export 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);
5563
56export function isLoopbackHostname(hostname: string): boolean {64export function isLoopbackHostname(hostname: string): boolean {
57 const normalizedHostname = hostname65 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;
89
9export const browserApiCorsHeaders = {10export 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
11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";12import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
13import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";13import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
14import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1415
15describe("OrchestrationReactor", () => {16describe("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 );
6878
...@@ -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 ]);
7990
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";
9import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";9import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";10import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";11import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1213
13export const makeOrchestrationReactor = Effect.gen(function* () {14export 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;
1820
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 });
2528
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_links71 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 NULL74 AND revoked_at IS NULL
73 AND consumed_at IS NULL75 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 RETURNING81 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
44import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";44import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";
45import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";45import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";
46import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";46import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
47import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
4748
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;
9193
92export const makeMigrationLoader = (throughId?: number) =>94export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/032_AuthPairingProofKeyThumbprint.ts created+16
...@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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;
3436
35export const ConsumeAuthPairingLinkInput = Schema.Struct({37export 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 @@
1import * as NodeCrypto from "node:crypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3
4import type {
5 EnvironmentId,
6 ExecutionEnvironmentDescriptor,
7 OrchestrationEvent,
8 OrchestrationProjectShell,
9 OrchestrationShellSnapshot,
10 OrchestrationThreadShell,
11 ProjectId,
12 ThreadId,
13 TurnId,
14} from "@t3tools/contracts";
15import type {
16 RelayAgentActivityPublishProofPayload,
17 RelayAgentActivityState,
18} from "@t3tools/contracts/relay";
19import { CommandId, ProviderInstanceId } from "@t3tools/contracts";
20import { RELAY_ACTIVITY_PUBLISH_TYP, verifyRelayJwt } from "@t3tools/shared/relayJwt";
21import { describe, expect, it } from "@effect/vitest";
22import * as Deferred from "effect/Deferred";
23import * as Effect from "effect/Effect";
24import * as Layer from "effect/Layer";
25import * as Option from "effect/Option";
26import * as Queue from "effect/Queue";
27import * as Stream from "effect/Stream";
28
29import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
30import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
31import {
32 OrchestrationEngineService,
33 type OrchestrationEngineShape,
34} from "../orchestration/Services/OrchestrationEngine.ts";
35import {
36 ProjectionSnapshotQuery,
37 type ProjectionSnapshotQueryShape,
38} from "../orchestration/Services/ProjectionSnapshotQuery.ts";
39import {
40 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
41 RELAY_ISSUER_SECRET,
42 RELAY_URL_SECRET,
43 PUBLISH_AGENT_ACTIVITY_SECRET,
44} from "../cloud/config.ts";
45import * as AgentAwarenessRelay from "./AgentAwarenessRelay.ts";
46
47const 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
59const encodeSecret = (value: string): Uint8Array => new TextEncoder().encode(value);
60
61function 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
97describe.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 @@
1import {
2 RelayApi,
3 type RelayAgentActivityPublishProofPayload,
4 type RelayAgentActivityState,
5} from "@t3tools/contracts/relay";
6import type {
7 EnvironmentId,
8 OrchestrationEvent,
9 OrchestrationProjectShell,
10 OrchestrationThreadShell,
11 ThreadId,
12} from "@t3tools/contracts";
13import { projectThreadAwareness } from "@t3tools/shared/agentAwareness";
14import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
15import {
16 RELAY_ACTIVITY_PUBLISH_TYP,
17 signRelayJwt,
18 normalizeRelayIssuer,
19} from "@t3tools/shared/relayJwt";
20import * as Cause from "effect/Cause";
21import * as Context from "effect/Context";
22import * as Crypto from "effect/Crypto";
23import * as DateTime from "effect/DateTime";
24import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";
27import * as Ref from "effect/Ref";
28import type * as Scope from "effect/Scope";
29import * as Stream from "effect/Stream";
30import { FetchHttpClient } from "effect/unstable/http";
31import * as HttpClient from "effect/unstable/http/HttpClient";
32import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
33import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
34
35import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
36import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts";
37import {
38 PUBLISH_AGENT_ACTIVITY_SECRET,
39 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
40 RELAY_ISSUER_SECRET,
41 RELAY_URL_SECRET,
42} from "../cloud/config.ts";
43import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
44import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
45import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
46
47export interface AgentAwarenessRelayShape {
48 readonly publishThread: (threadId: ThreadId) => Effect.Effect<void>;
49 readonly start: () => Effect.Effect<void, never, Scope.Scope>;
50}
51
52export class AgentAwarenessRelay extends Context.Service<
53 AgentAwarenessRelay,
54 AgentAwarenessRelayShape
55>()("t3/relay/AgentAwarenessRelay") {}
56
57export 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
68export 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
90export 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
98export function isAgentActivityPublishingEnabled(value: string | null): boolean {
99 return value === "true";
100}
101
102const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160;
103const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed.";
104
105export 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
119function relayEnvironmentClient(token: string) {
120 return HttpClient.mapRequest(HttpClientRequest.setHeader("authorization", `Bearer ${token}`));
121}
122
123function 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
162export 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
173const 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
197export 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
234export 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
257const 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
503export const layer = Layer.effect(AgentAwarenessRelay, make);
apps/server/src/server.test.ts+2187-271
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeSocket from "@effect/platform-node/NodeSocket";2import * as NodeSocket from "@effect/platform-node/NodeSocket";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as NodeCrypto from "node:crypto";
45
5import {6import {
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";
32import {
33 computeDpopAccessTokenHash,
34 computeDpopJwkThumbprint,
35 type DpopPublicJwk,
36} from "@t3tools/shared/dpop";
37import { RELAY_HEALTH_REQUEST_TYP, RELAY_MINT_REQUEST_TYP } from "@t3tools/shared/relayJwt";
38import * as RelayClient from "@t3tools/shared/relayClient";
31import { assert, it } from "@effect/vitest";39import { assert, it } from "@effect/vitest";
32import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";40import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
33import * as Clock from "effect/Clock";41import * 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";
43import * as Stream from "effect/Stream";51import * as Stream from "effect/Stream";
44import { ChildProcessSpawner } from "effect/unstable/process";52import { ChildProcessSpawner } from "effect/unstable/process";
45import {53import {
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";
54import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";62import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
55import { RpcClient, RpcSerialization } from "effect/unstable/rpc";63import { 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";
120import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";128import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
121import * as ServerSecretStore from "./auth/ServerSecretStore.ts";129import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
122import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";130import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
131import {
132 CloudManagedEndpointRuntime,
133 type CloudManagedEndpointRuntimeShape,
134} from "./cloud/ManagedEndpointRuntime.ts";
135import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
123import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";136import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
124import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";137import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
125import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";138import * 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};
801850
851const 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
802const bootstrapBrowserSession = (858const 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 });
824887
...@@ -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?.deviceType913 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 });
871939
940const 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
986const 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
1024const 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
1057const 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
872class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{1065class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{
873 readonly message: string;1066 readonly message: string;
874}> {}1067}> {}
8751068
1069class TestHttpRequestError extends Data.TaggedError("TestHttpRequestError")<{
1070 readonly cause: unknown;
1071}> {}
1072
1073const 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
1082const 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
1105const jsonRequestBody = (value: unknown): string => {
1106 return JSON.stringify(value);
1107};
1108
1109const 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
1115const responseOk = (response: HttpClientResponse.HttpClientResponse) =>
1116 response.status >= 200 && response.status < 300;
1117
876const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) =>1118const 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
894const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) =>1136const 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};
9201162
921const splitHeaderTokens = (value: string | null) =>1163const 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) =>
9271169
928const assertBrowserApiCorsResponseHeaders = (1170const 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};
9331183
934const assertBrowserApiCorsPreflightHeaders = (1184const 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 });
9911246
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 );
9951249
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();
10591313
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);
10621317
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();
10711326
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);
10781334
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();
10881344
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);
10991356
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);
11241385
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);
11341396
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();
11441406
1145 const { response: bootstrapResponse, body: tokenBody } = yield* exchangeAccessToken();1407 const { response: tokenResponse, body: tokenBody } = yield* exchangeAccessToken();
11461408
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);
11561417
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);
11671429
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 );
11831445
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 );
14251506
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();
14291512
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 };
14403342
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);
15453448
...@@ -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 });
16023505
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 );
18913800
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();
18973806
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)}`;
19083819
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 });
21084020
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:off4063 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) => {
21704081
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 );
21834098
...@@ -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 });
22194135
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
44import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";44import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
45import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";45import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
46import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";46import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
47import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
48import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
47import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";49import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
48import { ServerSettingsLive } from "./serverSettings.ts";50import { ServerSettingsLive } from "./serverSettings.ts";
49import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";51import { 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
67import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";69import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
68import * as ServerSecretStore from "./auth/ServerSecretStore.ts";70import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
69import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";71import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
72import { cloudHttpApiLayer, reconcileDesiredCloudLink } from "./cloud/http.ts";
73import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts";
74import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
75import * as CloudCliState from "./cloud/CliState.ts";
70import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";76import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
71import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";77import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
72import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";78import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
...@@ -78,6 +84,7 @@ import {...@@ -78,6 +84,7 @@ import {
78} from "./serverRuntimeState.ts";84} from "./serverRuntimeState.ts";
79import { orchestrationHttpApiLayer } from "./orchestration/http.ts";85import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
80import * as NetService from "@t3tools/shared/Net";86import * as NetService from "@t3tools/shared/Net";
87import * as RelayClient from "@t3tools/shared/relayClient";
81import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";88import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
8289
83const PtyAdapterLive = Layer.unwrap(90const PtyAdapterLive = Layer.unwrap(
...@@ -92,6 +99,13 @@ const PtyAdapterLive = Layer.unwrap(...@@ -92,6 +99,13 @@ const PtyAdapterLive = Layer.unwrap(
92 }),99 }),
93);100);
94101
102const RelayClientLive = Layer.unwrap(
103 Effect.gen(function* () {
104 const config = yield* ServerConfig;
105 return RelayClient.layerCloudflared({ baseDir: config.baseDir });
106 }),
107);
108
95const HttpServerLive = Layer.unwrap(109const 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);
139154
...@@ -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);
233248
249const CloudManagedEndpointRuntimeLive = Layer.mergeAll(
250 RelayClientLive,
251 CloudManagedEndpointRuntime.layer.pipe(
252 Layer.provide(ServerSecretStore.layer),
253 Layer.provide(RelayClientLive),
254 ),
255);
256
234const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(257const 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);
275305
276const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(306const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
...@@ -291,6 +321,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(...@@ -291,6 +321,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
291export const makeRoutesLayer = Layer.mergeAll(321export 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 );
390442
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 );
399452
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";
10import * as Data from "effect/Data";10import * as Data from "effect/Data";
11import * as Crypto from "effect/Crypto";
11import * as Deferred from "effect/Deferred";12import * as Deferred from "effect/Deferred";
12import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";14import * 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";
19import * as Scope from "effect/Scope";20import * as Scope from "effect/Scope";
20import * as Context from "effect/Context";21import * as Context from "effect/Context";
21import * as Console from "effect/Console";22import * as Console from "effect/Console";
22import * as Crypto from "effect/Crypto";
23import * as DateTime from "effect/DateTime";23import * as DateTime from "effect/DateTime";
2424
25import { ServerConfig } from "./config.ts";25import { 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;
292293
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;
19export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", {19export 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;
18export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", {18export 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({
1919
20class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {20class 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}) {}
2424
25const hash = (value: string) =>25const 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";
16export class PtySpawnError extends Schema.TaggedErrorClass<PtySpawnError>()("PtySpawnError", {16export 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}) {}
2121
22export interface PtyExitEvent {22export 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) {}
2929
...@@ -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) {}
4040
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) {}
2626
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";
96import * as PairingGrantStore from "./auth/PairingGrantStore.ts";99import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
97import * as SessionStore from "./auth/SessionStore.ts";100import * as SessionStore from "./auth/SessionStore.ts";
98import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";101import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
102import * as RelayClient from "@t3tools/shared/relayClient";
99const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);103const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
100const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError);104const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError);
101105
...@@ -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";
2import { defineConfig, mergeConfig } from "vite-plus";2import { defineConfig, mergeConfig } from "vite-plus";
33
4import baseConfig from "../../vite.config.ts";4import baseConfig from "../../vite.config.ts";
5import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
56
6const bundledPackagePrefixes = [7const 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}
1617
18const repoEnv = loadRepoEnv();
19
17export default mergeConfig(20export 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 orchestration55 // 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});
32type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type;33type 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}
5863
59export function readBrowserClientSettings(): ClientSettings | null {64export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveDesktopCloudAuthOAuthOptions } from "./desktopAuth";
4
5describe("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 @@
1export type DesktopCloudAuthOAuthStrategy = `oauth_${string}`;
2
3export interface DesktopCloudAuthOAuthOption {
4 readonly strategy: DesktopCloudAuthOAuthStrategy;
5 readonly label: string;
6 readonly providerId: string;
7 readonly iconUrl: string | null;
8}
9
10interface ClerkOAuthProviderSetting {
11 readonly enabled?: unknown;
12 readonly authenticatable?: unknown;
13 readonly strategy?: unknown;
14 readonly name?: unknown;
15 readonly logo_url?: unknown;
16}
17
18interface ClerkUserSettingsLike {
19 readonly authenticatableSocialStrategies?: unknown;
20 readonly social?: unknown;
21}
22
23interface ClerkEnvironmentLike {
24 readonly userSettings?: ClerkUserSettingsLike;
25}
26
27interface ClerkLike {
28 readonly __internal_environment?: ClerkEnvironmentLike;
29 readonly environment?: ClerkEnvironmentLike;
30}
31
32const isClerkOAuthProviderSetting = (value: unknown): value is ClerkOAuthProviderSetting =>
33 typeof value === "object" && value !== null;
34
35const 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
49export function isDesktopCloudAuthOAuthStrategy(
50 value: unknown,
51): value is DesktopCloudAuthOAuthStrategy {
52 return typeof value === "string" && value.startsWith("oauth_");
53}
54
55export 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
69export 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
107function 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
119function 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
135function 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 @@
1import { Clerk } from "@clerk/clerk-js";
2import {
3 buildClerkUIScriptAttributes,
4 clerkUIScriptUrl,
5 InternalClerkProvider,
6} from "@clerk/react/internal";
7import type { ClerkProviderProps } from "@clerk/react";
8import {
9 clerkFrontendApiHostnameFromPublishableKey,
10 isAllowedClerkFrontendApiHostname,
11} from "@t3tools/shared/relayAuth";
12import React, { useEffect, useState } from "react";
13
14import {
15 makeDesktopClerkExternalAccountAdapter,
16 type DesktopClerkUser,
17} from "./desktopClerkExternalAccounts";
18
19type DesktopClerkUiCtor = NonNullable<Window["__internal_ClerkUICtor"]>;
20
21interface ClerkFrontendApiRequest {
22 credentials?: RequestCredentials;
23 headers?: Headers;
24 url?: URL;
25}
26
27interface ClerkFrontendApiResponse {
28 headers: Headers;
29 payload?: {
30 errors?: readonly {
31 code?: string;
32 }[];
33 };
34}
35
36interface 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
58interface DesktopClerkProviderProps {
59 readonly children: React.ReactNode;
60 readonly publishableKey: string;
61}
62
63let desktopClerk: Clerk | null = null;
64let desktopClerkFetchInstalled = false;
65let desktopClerkUiLoad: Promise<DesktopClerkUiCtor> | null = null;
66let desktopClerkFrontendApiHostname: string | null = null;
67let desktopClerkExternalAccountCleanup: (() => void) | null = null;
68
69const 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
85const getStoredClientJwt = (): Promise<string | null> =>
86 window.desktopBridge?.getCloudAuthToken() ?? Promise.resolve(null);
87
88const setStoredClientJwt = (token: string): Promise<boolean> =>
89 window.desktopBridge?.setCloudAuthToken(token) ?? Promise.resolve(false);
90
91const clearStoredClientJwt = (): Promise<void> =>
92 window.desktopBridge?.clearCloudAuthToken() ?? Promise.resolve();
93
94const isClerkFrontendApiUrl = (url: URL): boolean =>
95 url.protocol === "https:" &&
96 isAllowedClerkFrontendApiHostname(url.hostname, desktopClerkFrontendApiHostname);
97
98const 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
106function 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
140function 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
159function 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
220function 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
278export 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 @@
1import { describe, expect, it, vi } from "vitest";
2
3import {
4 makeDesktopClerkExternalAccountAdapter,
5 type DesktopClerkUser,
6} from "./desktopClerkExternalAccounts";
7
8describe("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 @@
1interface DesktopClerkExternalAccountParams {
2 readonly redirectUrl?: string;
3 readonly [key: string]: unknown;
4}
5
6interface DesktopClerkExternalAccount {
7 reauthorize: (params: DesktopClerkExternalAccountParams) => Promise<DesktopClerkExternalAccount>;
8}
9
10interface DesktopClerkUser {
11 readonly externalAccounts: readonly DesktopClerkExternalAccount[];
12 createExternalAccount: (
13 params: DesktopClerkExternalAccountParams,
14 ) => Promise<DesktopClerkExternalAccount>;
15 reload: () => Promise<unknown>;
16}
17
18interface DesktopClerkExternalAccountBridge {
19 readonly createCloudAuthRequest: () => Promise<string>;
20 readonly onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void;
21}
22
23interface DesktopClerkExternalAccountAdapter {
24 readonly dispose: () => void;
25 readonly installUser: (user: DesktopClerkUser) => void;
26}
27
28interface 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
38export 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
112export type { DesktopClerkExternalAccountAdapter, DesktopClerkUser };
apps/web/src/cloud/dpop.test.ts created+32
...@@ -0,0 +1,32 @@
1import { verifyDpopProof } from "@t3tools/shared/dpop";
2import * as Effect from "effect/Effect";
3import { describe, expect, it, vi } from "vitest";
4
5import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop";
6
7describe("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 @@
1import {
2 computeDpopAccessTokenHash,
3 computeDpopJwkThumbprint,
4 DpopPublicJwk,
5} from "@t3tools/shared/dpop";
6import * as Crypto from "effect/Crypto";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Schema from "effect/Schema";
11import { importJWK, SignJWT, type JWK } from "jose";
12
13export interface BrowserDpopKey {
14 readonly privateKey: CryptoKey;
15 readonly publicJwk: DpopPublicJwk;
16 readonly thumbprint: string;
17}
18
19export class BrowserDpopError extends Data.TaggedError("BrowserDpopError")<{
20 readonly message: string;
21 readonly cause?: unknown;
22}> {}
23
24const DPOP_DATABASE_NAME = "t3code:cloud-auth";
25const DPOP_DATABASE_VERSION = 1;
26const DPOP_KEY_STORE_NAME = "keys";
27const DPOP_KEY_ID = "relay-dpop-proof-key";
28const decodeDpopPublicJwk = Schema.decodeUnknownEffect(DpopPublicJwk);
29
30export 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
43function dpopError(message: string, cause?: unknown) {
44 return new BrowserDpopError({ message, ...(cause === undefined ? {} : { cause }) });
45}
46
47function 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
64export 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
87export 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
110export 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
148export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { RelayWebClientId } from "@t3tools/contracts/relay";
3import { afterEach, beforeEach, vi } from "vitest";
4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import { HttpClient } from "effect/unstable/http";
8import {
9 managedRelayClientLayer,
10 ManagedRelayClient,
11 ManagedRelayDpopSigner,
12 remoteHttpClientLayer,
13} from "@t3tools/client-runtime";
14
15import type { SavedEnvironmentRecord } from "../environments/runtime";
16import {
17 connectManagedCloudEnvironment,
18 linkEnvironmentToCloud,
19 linkPrimaryEnvironmentToCloud,
20 listManagedCloudEnvironments,
21 normalizeRelayBaseUrl,
22 readPrimaryCloudLinkState,
23 unlinkPrimaryEnvironmentFromCloud,
24} from "./linkEnvironment";
25import {
26 readPrimaryEnvironmentDescriptor,
27 readPrimaryEnvironmentTarget,
28 resolvePrimaryEnvironmentHttpUrl,
29} from "../environments/primary";
30
31const getSavedEnvironmentSecretMock = vi.fn();
32const relayClientInstallDialogHarness = vi.hoisted(() => ({
33 requestConfirmation: vi.fn(),
34 reportProgress: vi.fn(),
35 finish: vi.fn(),
36}));
37const getRelayClientStatusMock = vi.fn();
38const installRelayClientMock = vi.fn();
39const environmentConnectionMock = {
40 client: {
41 cloud: {
42 getRelayClientStatus: getRelayClientStatusMock,
43 installRelayClient: installRelayClientMock,
44 },
45 },
46};
47
48const createProofMock = vi.fn(
49 (_input: { readonly method: string; readonly url: string; readonly accessToken?: string }) =>
50 Effect.succeed("web-dpop-proof"),
51);
52const testDpopSignerLayer = Layer.succeed(
53 ManagedRelayDpopSigner,
54 ManagedRelayDpopSigner.of({
55 thumbprint: Effect.succeed("web-thumbprint"),
56 createProof: (input) => createProofMock(input),
57 }),
58);
59
60function 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
71const withCloudServices = <A, E>(
72 effect: Effect.Effect<A, E, HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner>,
73) => effect.pipe(Effect.provide(cloudClientLayer()));
74
75vi.mock("../localApi", () => ({
76 ensureLocalApi: () => ({
77 persistence: {
78 getSavedEnvironmentSecret: getSavedEnvironmentSecretMock,
79 },
80 }),
81}));
82
83vi.mock("./relayClientInstallDialog", () => ({
84 requestRelayClientInstallConfirmation: relayClientInstallDialogHarness.requestConfirmation,
85 reportRelayClientInstallProgress: relayClientInstallDialogHarness.reportProgress,
86 finishRelayClientInstall: relayClientInstallDialogHarness.finish,
87}));
88
89vi.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
95vi.mock("../environments/runtime", () => ({
96 getPrimaryEnvironmentConnection: () => environmentConnectionMock,
97 readEnvironmentConnection: () => environmentConnectionMock,
98}));
99
100const 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
109function validProof() {
110 return "signed-environment-link-jwt";
111}
112
113function validChallenge() {
114 return {
115 challenge: "link-challenge",
116 expiresAt: "2026-05-25T00:05:00.000Z",
117 };
118}
119
120function 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
129function requestBodyText(body: BodyInit | null | undefined): string {
130 return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? "");
131}
132
133describe("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 @@
1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import { HttpClient } from "effect/unstable/http";
5import {
6 EnvironmentCloudEndpointUnavailableError,
7 type EnvironmentCloudLinkStateResult,
8 EnvironmentHttpBadRequestError,
9 EnvironmentHttpConflictError,
10 EnvironmentHttpForbiddenError,
11 EnvironmentHttpInternalServerError,
12 EnvironmentHttpUnauthorizedError,
13 EnvironmentId,
14} from "@t3tools/contracts";
15import {
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";
24import {
25 exchangeRemoteDpopAccessToken,
26 fetchRemoteEnvironmentDescriptor,
27 makeEnvironmentHttpApiClient,
28 ManagedRelayClient,
29 ManagedRelayDpopSigner,
30 type WsRpcClient,
31} from "@t3tools/client-runtime";
32
33import { ensureLocalApi } from "../localApi";
34import {
35 getPrimaryEnvironmentConnection,
36 readEnvironmentConnection,
37 type SavedEnvironmentRecord,
38} from "../environments/runtime";
39import {
40 readPrimaryEnvironmentDescriptor,
41 readPrimaryEnvironmentTarget,
42 resolvePrimaryEnvironmentHttpUrl,
43} from "../environments/primary";
44import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit";
45import { resolveCloudPublicConfig } from "./publicConfig";
46import {
47 finishRelayClientInstall,
48 reportRelayClientInstallProgress,
49 requestRelayClientInstallConfirmation,
50} from "./relayClientInstallDialog";
51
52export 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
60function relayUrl(): string | null {
61 return resolveCloudPublicConfig().relayUrl;
62}
63
64export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
65 readonly message: string;
66 readonly cause?: unknown;
67}> {}
68
69const relayClientRpcError = (message: string) => (cause: unknown) =>
70 new CloudEnvironmentLinkError({
71 message,
72 cause,
73 });
74
75function 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
115const isRelayProtectedError = Schema.is(RelayProtectedError);
116const isEnvironmentCloudApiError = Schema.is(
117 Schema.Union([
118 EnvironmentHttpBadRequestError,
119 EnvironmentHttpUnauthorizedError,
120 EnvironmentHttpForbiddenError,
121 EnvironmentHttpConflictError,
122 EnvironmentHttpInternalServerError,
123 EnvironmentCloudEndpointUnavailableError,
124 ]),
125);
126
127function 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
162function 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
173function 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
183function 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
193const 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
203function 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
211const MANAGED_ENDPOINT_PROVIDER_KIND =
212 "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
213
214function 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
232export interface CloudLinkTarget {
233 readonly environmentId: string;
234 readonly label: string;
235 readonly httpBaseUrl: string;
236 readonly wsBaseUrl: string;
237}
238
239export type CloudLinkState = EnvironmentCloudLinkStateResult;
240
241export 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
250export 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
266export 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
280export 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
311export 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
337export 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
444export 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
463export 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
480export 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
517export 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
623export 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 @@
1import { useAuth } from "@clerk/react";
2import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime";
3import { useEffect, type ReactNode } from "react";
4
5import { appAtomRegistry } from "../rpc/atomRegistry";
6import { resolveRelayClerkTokenOptions } from "./publicConfig";
7
8let relayTokenProvider: (() => Promise<string | null>) | null = null;
9
10export async function readManagedRelayClerkToken(): Promise<string | null> {
11 return relayTokenProvider?.() ?? null;
12}
13
14export 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 @@
1import {
2 managedRelayClientLayer,
3 ManagedRelayDpopSigner,
4 ManagedRelayDpopSignerError,
5} from "@t3tools/client-runtime";
6import { RelayWebClientId } from "@t3tools/contracts/relay";
7import * as Crypto from "effect/Crypto";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Semaphore from "effect/Semaphore";
11
12import {
13 createBrowserDpopProof,
14 generateBrowserDpopKey,
15 readStoredBrowserDpopKey,
16 writeStoredBrowserDpopKey,
17 type BrowserDpopKey,
18} from "./dpop";
19
20export 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
59export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 createManagedRelayQueryManager,
4 ManagedRelayClient,
5 managedRelaySessionAtom,
6 readManagedRelaySnapshotState,
7} from "@t3tools/client-runtime";
8import type {
9 RelayClientDeviceRecord,
10 RelayClientEnvironmentRecord,
11} from "@t3tools/contracts/relay";
12import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import { AsyncResult, Atom } from "effect/unstable/reactivity";
16import { useCallback } from "react";
17
18import { webRuntime } from "../lib/runtime";
19import { appAtomRegistry } from "../rpc/atomRegistry";
20
21const managedRelayAtomRuntime = Atom.runtime(
22 Layer.effect(
23 ManagedRelayClient,
24 webRuntime.contextEffect.pipe(
25 Effect.map((context) => Context.get(context, ManagedRelayClient)),
26 ),
27 ),
28);
29
30export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime);
31
32const EMPTY_ENVIRONMENTS_ATOM = Atom.make(
33 AsyncResult.success<ReadonlyArray<RelayClientEnvironmentRecord>>([]),
34).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null"));
35
36const EMPTY_DEVICES_ATOM = Atom.make(
37 AsyncResult.success<ReadonlyArray<RelayClientDeviceRecord>>([]),
38).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:devices:null"));
39
40export 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
60export 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
78export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentCloudLinkStateResult, EnvironmentId } from "@t3tools/contracts";
3import * as Cause from "effect/Cause";
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import { AsyncResult, Atom } from "effect/unstable/reactivity";
9import { HttpClient } from "effect/unstable/http";
10import { useCallback } from "react";
11
12import { usePrimaryEnvironmentId } from "../environments/primary";
13import { webRuntime } from "../lib/runtime";
14import { appAtomRegistry } from "../rpc/atomRegistry";
15import { readPrimaryCloudLinkState } from "./linkEnvironment";
16
17const 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
26const 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
36const 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
40export function refreshPrimaryCloudLinkState(environmentId: EnvironmentId | null): void {
41 if (environmentId) {
42 appAtomRegistry.refresh(primaryCloudLinkStateAtom(environmentId));
43 }
44}
45
46export 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 @@
1import { afterEach, describe, expect, it, vi } from "vitest";
2
3import { hasCloudPublicConfig } from "./publicConfig.ts";
4
5afterEach(() => {
6 vi.unstubAllEnvs();
7});
8
9describe("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 @@
1import { relayClerkTokenOptions } from "@t3tools/shared/relayAuth";
2import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
3
4export interface CloudPublicConfig {
5 readonly clerkPublishableKey: string | null;
6 readonly clerkJwtTemplate: string | null;
7 readonly relayUrl: string | null;
8}
9
10function trimNonEmpty(value: string | undefined): string | null {
11 return value?.trim() || null;
12}
13
14export 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
26export function hasCloudPublicConfig(): boolean {
27 const config = resolveCloudPublicConfig();
28 return Boolean(config.clerkPublishableKey && config.clerkJwtTemplate && config.relayUrl);
29}
30
31export 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 @@
1import { beforeEach, describe, expect, it } from "vitest";
2
3import {
4 completeRelayClientInstallDialogClose,
5 finishRelayClientInstall,
6 readRelayClientInstallDialogState,
7 reportRelayClientInstallProgress,
8 requestRelayClientInstallConfirmation,
9 resetRelayClientInstallDialogForTests,
10 respondToRelayClientInstallConfirmation,
11} from "./relayClientInstallDialog";
12
13describe("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 @@
1import type {
2 RelayClientInstallProgressEvent,
3 RelayClientInstallProgressStage,
4} from "@t3tools/contracts";
5
6export 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
25const idleState: RelayClientInstallDialogState = { status: "idle" };
26let state: RelayClientInstallDialogState = idleState;
27let resolveConfirmation: ((confirmed: boolean) => void) | null = null;
28const listeners = new Set<() => void>();
29
30function publish(next: RelayClientInstallDialogState) {
31 state = next;
32 for (const listener of listeners) {
33 listener();
34 }
35}
36
37export function readRelayClientInstallDialogState(): RelayClientInstallDialogState {
38 return state;
39}
40
41export function subscribeRelayClientInstallDialog(listener: () => void): () => void {
42 listeners.add(listener);
43 return () => {
44 listeners.delete(listener);
45 };
46}
47
48export 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
59export 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
74export function reportRelayClientInstallProgress(event: RelayClientInstallProgressEvent): void {
75 if (state.status !== "installing" || event.type !== "progress") {
76 return;
77 }
78 publish({ ...state, stage: event.stage });
79}
80
81export 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
89export function completeRelayClientInstallDialogClose(): void {
90 if (state.status === "closing") {
91 publish(idleState);
92 }
93}
94
95export 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 @@
1import type { ReactNode } from "react";
2
3import { 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
10export 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
32export 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
41export 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
64export 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
77export 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
92export 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
110function 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 @@
1import "../../index.css";
2
3import { page, userEvent } from "vitest/browser";
4import { afterEach, describe, expect, it, vi } from "vitest";
5import { render } from "vitest-browser-react";
6
7import type { DesktopCloudAuthOAuthOption } from "../../cloud/desktopAuth";
8import { DesktopClerkSignInCard } from "./DesktopClerkSignIn";
9
10const GOOGLE: DesktopCloudAuthOAuthOption = {
11 strategy: "oauth_google",
12 label: "Google",
13 providerId: "google",
14 iconUrl: null,
15};
16
17const 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
33describe("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 @@
1import { LoaderCircleIcon } from "lucide-react";
2
3import type {
4 DesktopCloudAuthOAuthOption,
5 DesktopCloudAuthOAuthStrategy,
6} from "../../cloud/desktopAuth";
7import { cn } from "../../lib/utils";
8import {
9 DesktopClerkAlert,
10 DesktopClerkCard,
11 DesktopClerkFooterAction,
12 DesktopClerkHeader,
13} from "./DesktopClerkCard";
14import { 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
18export 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
32export 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
71function 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
124function 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 @@
1import { useClerk } from "@clerk/react";
2import { useState } from "react";
3
4import {
5 DesktopClerkAlert,
6 DesktopClerkCard,
7 DesktopClerkFooterAction,
8 DesktopClerkHeader,
9 DesktopClerkInput,
10 DesktopClerkPrimaryButton,
11} from "./DesktopClerkCard";
12import { DesktopClerkSignIn } from "./DesktopClerkSignIn";
13
14type 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
19export 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
29function 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
96function 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 @@
1import { useClerk } from "@clerk/react";
2import { useSignIn, useSignUp } from "@clerk/react/legacy";
3import { useCallback, useEffect, useRef, useState } from "react";
4
5import {
6 type DesktopCloudAuthOAuthStrategy,
7 resolveDesktopCloudAuthOAuthOptions,
8} from "../../cloud/desktopAuth";
9import { 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
14class 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
24async 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
35function 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
46export 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 @@
1import "../../index.css";
2
3import { page } from "vitest/browser";
4import { beforeEach, describe, expect, it } from "vitest";
5import { render } from "vitest-browser-react";
6
7import {
8 finishRelayClientInstall,
9 readRelayClientInstallDialogState,
10 reportRelayClientInstallProgress,
11 requestRelayClientInstallConfirmation,
12 resetRelayClientInstallDialogForTests,
13} from "../../cloud/relayClientInstallDialog";
14import { RelayClientInstallDialog } from "./RelayClientInstallDialog";
15
16describe("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 @@
1import { DownloadIcon } from "lucide-react";
2import { useSyncExternalStore } from "react";
3import type { RelayClientInstallProgressStage } from "@t3tools/contracts";
4
5import {
6 completeRelayClientInstallDialogClose,
7 readRelayClientInstallDialogState,
8 respondToRelayClientInstallConfirmation,
9 subscribeRelayClientInstallDialog,
10} from "../../cloud/relayClientInstallDialog";
11import { Button } from "../ui/button";
12import {
13 Dialog,
14 DialogDescription,
15 DialogFooter,
16 DialogHeader,
17 DialogPanel,
18 DialogPopup,
19 DialogTitle,
20} from "../ui/dialog";
21const 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
34export 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 @@
1import { UserButton, Waitlist, useAuth } from "@clerk/react";
2import { AuthRelayWriteScope } from "@t3tools/contracts";
3import { CloudIcon, RefreshCwIcon, SmartphoneIcon } from "lucide-react";
4import { useEffect, useState } from "react";
5
6import { updatePrimaryCloudPreferences } from "../../cloud/linkEnvironment";
7import { hasCloudPublicConfig } from "../../cloud/publicConfig";
8import { useManagedRelayDevices } from "../../cloud/managedRelayState";
9import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState";
10import { isElectron } from "../../env";
11import { usePrimarySessionState } from "../../environments/primary";
12import { webRuntime } from "../../lib/runtime";
13import { cn } from "../../lib/utils";
14import { DesktopClerkWaitlist } from "../clerk/DesktopClerkWaitlist";
15import { Button } from "../ui/button";
16import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
17import { Skeleton } from "../ui/skeleton";
18import { Switch } from "../ui/switch";
19import { toastManager } from "../ui/toast";
20import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
21import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
22
23const NOTIFICATION_DEVICE_SKELETON_ROWS = ["primary", "secondary"] as const;
24
25function 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
37function 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
53function cloudErrorMessage(error: unknown, fallback: string): string {
54 return error instanceof Error ? error.message : fallback;
55}
56
57export function CloudSettingsPanel() {
58 if (!hasCloudPublicConfig()) return null;
59
60 return <ConfiguredCloudSettingsPanel />;
61}
62
63function ConfiguredCloudSettingsPanel() {
64 const { isLoaded, isSignedIn } = useAuth();
65
66 if (!isLoaded) {
67 return null;
68 }
69
70 return isSignedIn ? <CloudSettingsPanelInner /> : <CloudWaitlistPanel />;
71}
72
73function CloudWaitlistPanel() {
74 return (
75 <SettingsPageContainer className="min-h-full items-center justify-center">
76 {isElectron ? <DesktopClerkWaitlist /> : <Waitlist />}
77 </SettingsPageContainer>
78 );
79}
80
81function 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";
10import { useAuth } from "@clerk/react";
10import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react";11import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react";
11import {12import {
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";
31import { WsRpcClient } from "@t3tools/client-runtime";32import { WsRpcClient } from "@t3tools/client-runtime";
33import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
32import * as DateTime from "effect/DateTime";34import * as DateTime from "effect/DateTime";
3335
34import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";36import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
35import { cn } from "../../lib/utils";37import { cn } from "../../lib/utils";
36import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";38import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
37import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";39import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
40import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig";
38import {41import {
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";
67import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";70import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
68import { QRCodeSvg } from "../ui/qr-code";71import { QRCodeSvg } from "../ui/qr-code";
72import { Skeleton } from "../ui/skeleton";
69import { Spinner } from "../ui/spinner";73import { Spinner } from "../ui/spinner";
70import { Switch } from "../ui/switch";74import { Switch } from "../ui/switch";
71import { stackedThreadToast, toastManager } from "../ui/toast";75import { stackedThreadToast, toastManager } from "../ui/toast";
72import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";76import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
73import { Button } from "../ui/button";77import { Button } from "../ui/button";
78import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
74import { Group, GroupSeparator } from "../ui/group";79import { Group, GroupSeparator } from "../ui/group";
75import { AnimatedHeight } from "../AnimatedHeight";80import { AnimatedHeight } from "../AnimatedHeight";
76import {81import {
...@@ -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 {
110import { useUiStateStore } from "~/uiStateStore";117import { useUiStateStore } from "~/uiStateStore";
111import { resolveServerConfigVersionMismatch } from "~/versionSkew";118import { resolveServerConfigVersionMismatch } from "~/versionSkew";
112import { useServerConfig } from "~/rpc/serverState";119import { useServerConfig } from "~/rpc/serverState";
120import {
121 connectManagedCloudEnvironment,
122 linkPrimaryEnvironmentToCloud,
123 unlinkPrimaryEnvironmentFromCloud,
124} from "~/cloud/linkEnvironment";
125import {
126 refreshManagedRelayEnvironments,
127 useManagedRelayEnvironments,
128} from "~/cloud/managedRelayState";
129import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState";
130import { webRuntime } from "~/lib/runtime";
131import { hasCloudPublicConfig } from "~/cloud/publicConfig";
113132
114const DEFAULT_TAILSCALE_SERVE_PORT = 443;133const DEFAULT_TAILSCALE_SERVE_PORT = 443;
115134
...@@ -1582,8 +1601,242 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({...@@ -1582,8 +1601,242 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({
1582 );1601 );
1583});1602});
15841603
1604function 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
1633function 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
1703function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) {
1704 return hasCloudPublicConfig() ? <ConfiguredCloudLinkRow canManageRelay={canManageRelay} /> : null;
1705}
1706
1707function 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
1725function 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
1739function 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
1820function 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
1585export function ConnectionsSettings() {1837export 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 = desktopBridge1841 const currentSessionScopes = desktopBridge
1589 ? AuthAdministrativeScopes1842 ? 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 = desktopBridge1958 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 <SettingsRow2866 <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>
26342892
...@@ -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 <SettingsRow3067 <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 )}
28153074
...@@ -2889,15 +3148,10 @@ export function ConnectionsSettings() {...@@ -2889,15 +3148,10 @@ export function ConnectionsSettings() {
2889 onRemove={handleRemoveSavedBackend}3148 onRemove={handleRemoveSavedBackend}
2890 />3149 />
2891 ))}3150 ))}
28923151 <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});
149149
150const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn());150const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn());
151const mockGetClerkToken = vi.hoisted(() => vi.fn(async () => null));
152
153vi.mock("@clerk/react", () => ({
154 useAuth: () => ({
155 getToken: mockGetClerkToken,
156 isSignedIn: false,
157 }),
158}));
151159
152vi.mock("../../environments/runtime", () => {160vi.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 );
555576
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 expect581 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";
24import { Badge } from "../ui/badge";
25import { hasCloudPublicConfig } from "../../cloud/publicConfig";
2326
24export type SettingsSectionPath =27export 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";
3135
...@@ -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 {
3434
35export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState";35export { refreshPrimarySessionState, usePrimarySessionState } from "./sessionState";
3636
37export { resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname } from "./target";37export {
38 readPrimaryEnvironmentTarget,
39 resolvePrimaryEnvironmentHttpUrl,
40 isLoopbackHostname,
41} from "./target";
apps/web/src/environments/primary/requestInit.ts created+7
...@@ -0,0 +1,7 @@
1import * as Effect from "effect/Effect";
2import { FetchHttpClient } from "effect/unstable/http";
3
4export const primaryEnvironmentRequestInit = { credentials: "include" } as const;
5
6export 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 {
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
77
8import {8import {
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";
1517
16describe("environment runtime catalog stores", () => {18describe("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 });
7678
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";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
9import { create } from "zustand";11import { create } from "zustand";
1012
11import { ensureLocalApi } from "../../localApi";13import { 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}
2326
27export 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]);
39export type SavedEnvironmentCredential = typeof SavedEnvironmentCredential.Type;
40
41const SavedEnvironmentCredentialJson = Schema.fromJsonString(SavedEnvironmentCredential);
42const decodeSavedEnvironmentCredentialJson = Schema.decodeUnknownOption(
43 SavedEnvironmentCredentialJson,
44);
45const encodeSavedEnvironmentCredentialJson = Schema.encodeSync(SavedEnvironmentCredentialJson);
46
24interface SavedEnvironmentRegistryState {47interface 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}
5276
...@@ -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}
252276
277export 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
292export 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
253export async function writeSavedEnvironmentBearerToken(302export 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 {
1616
17export {17export {
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 @@
1import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";1import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
45
...@@ -10,17 +11,33 @@ const mockResolveRemotePairingTarget = vi.fn();...@@ -10,17 +11,33 @@ const mockResolveRemotePairingTarget = vi.fn();
10const mockFetchRemoteEnvironmentDescriptor = vi.fn();11const mockFetchRemoteEnvironmentDescriptor = vi.fn();
11const mockBootstrapRemoteBearerSession = vi.fn();12const mockBootstrapRemoteBearerSession = vi.fn();
12const mockFetchRemoteSessionState = vi.fn();13const mockFetchRemoteSessionState = vi.fn();
14const mockFetchRemoteDpopSessionState = vi.fn();
13const mockResolveRemoteWebSocketConnectionUrl = vi.fn();15const mockResolveRemoteWebSocketConnectionUrl = vi.fn();
14const mockRemoteHttpRunPromise = vi.fn((effect: Promise<unknown>) => effect);16let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner;
17const 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);
15const mockBootstrapSshBearerSession = vi.fn();30const mockBootstrapSshBearerSession = vi.fn();
16const mockFetchSshSessionState = vi.fn();31const mockFetchSshSessionState = vi.fn();
17const mockPersistSavedEnvironmentRecord = vi.fn();32const mockPersistSavedEnvironmentRecord = vi.fn();
18const mockWriteSavedEnvironmentBearerToken = vi.fn();33const mockWriteSavedEnvironmentBearerToken = vi.fn();
34const mockWriteSavedEnvironmentCredential = vi.fn();
19const mockSetSavedEnvironmentRegistry = vi.fn();35const mockSetSavedEnvironmentRegistry = vi.fn();
20const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => {36const 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});
23const mockReadSavedEnvironmentBearerToken = vi.fn();39const mockReadSavedEnvironmentBearerToken = vi.fn();
40const mockReadSavedEnvironmentCredential = vi.fn();
24const mockRemoveSavedEnvironmentBearerToken = vi.fn();41const mockRemoveSavedEnvironmentBearerToken = vi.fn();
25const mockPatchRuntime = vi.fn();42const mockPatchRuntime = vi.fn();
26const mockClearRuntime = vi.fn();43const 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}));
78const mockConnectManagedCloudEnvironment = vi.fn();
79const mockReadManagedRelayClerkToken = vi.fn();
6180
62vi.mock("@t3tools/shared/remote", async (importOriginal) => ({81vi.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}));
6685
67vi.mock("../../lib/runtime", () => ({86vi.mock("../../lib/runtime", () => ({
68 remoteHttpRuntime: {87 webRuntime: {
69 runPromise: mockRemoteHttpRunPromise,88 runPromise: mockRemoteHttpRunPromise,
70 },89 },
71}));90}));
7291
92vi.mock("../../cloud/linkEnvironment", () => ({
93 connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment,
94}));
95
96vi.mock("../../cloud/managedAuth", () => ({
97 readManagedRelayClerkToken: mockReadManagedRelayClerkToken,
98}));
99
73vi.mock("~/localApi", () => ({100vi.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}));
109138
110vi.mock("./connection", async (importOriginal) => ({139vi.mock("./connection", async (importOriginal) => ({
...@@ -114,6 +143,7 @@ vi.mock("./connection", async (importOriginal) => ({...@@ -114,6 +143,7 @@ vi.mock("./connection", async (importOriginal) => ({
114143
115vi.mock("@t3tools/client-runtime", async (importOriginal) => {144vi.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 });
325374
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));
428565
429 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");566 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
430567
...@@ -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 );
754894
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();
11const mockListSavedEnvironmentRecords = vi.fn();11const mockListSavedEnvironmentRecords = vi.fn();
12const mockSavedEnvironmentRegistrySubscribe = vi.fn();12const mockSavedEnvironmentRegistrySubscribe = vi.fn();
13const mockReadSavedEnvironmentBearerToken = vi.fn();13const mockReadSavedEnvironmentBearerToken = vi.fn();
14const mockReadSavedEnvironmentCredential = vi.fn();
14const mockGetSavedEnvironmentRecord = vi.fn();15const mockGetSavedEnvironmentRecord = vi.fn();
1516
16function MockWsTransport() {17function MockWsTransport() {
...@@ -31,7 +32,7 @@ vi.mock("../primary", () => ({...@@ -31,7 +32,7 @@ vi.mock("../primary", () => ({
31}));32}));
3233
33vi.mock("../../lib/runtime", () => ({34vi.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}));
6568
66vi.mock("./connection", async (importOriginal) => ({69vi.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();
18const mockListSavedEnvironmentRecords = vi.fn();18const mockListSavedEnvironmentRecords = vi.fn();
19const mockGetSavedEnvironmentRecord = vi.fn();19const mockGetSavedEnvironmentRecord = vi.fn();
20const mockReadSavedEnvironmentBearerToken = vi.fn();20const mockReadSavedEnvironmentBearerToken = vi.fn();
21const mockReadSavedEnvironmentCredential = vi.fn();
21const mockSavedEnvironmentRegistrySubscribe = vi.fn();22const mockSavedEnvironmentRegistrySubscribe = vi.fn();
22const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn());23const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn());
23const mockFetchRemoteSessionState = vi.fn();24const mockFetchRemoteSessionState = vi.fn();
...@@ -35,7 +36,7 @@ vi.mock("../primary", () => ({...@@ -35,7 +36,7 @@ vi.mock("../primary", () => ({
35}));36}));
3637
37vi.mock("../../lib/runtime", () => ({38vi.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}));
6972
70vi.mock("./connection", async (importOriginal) => ({73vi.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 @@
1import {1import {
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";
2225
23import { type QueryClient } from "@tanstack/react-query";26import { type QueryClient } from "@tanstack/react-query";
24import { Throttler } from "@tanstack/react-pacer";27import { Throttler } from "@tanstack/react-pacer";
28import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";29import * as Schema from "effect/Schema";
26import {30import {
27 createKnownEnvironment,31 createKnownEnvironment,
...@@ -40,21 +44,25 @@ import { ensureLocalApi } from "~/localApi";...@@ -40,21 +44,25 @@ import { ensureLocalApi } from "~/localApi";
40import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";44import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";
41import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";45import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
42import { getPrimaryKnownEnvironment } from "../primary";46import { getPrimaryKnownEnvironment } from "../primary";
43import { remoteHttpRuntime } from "../../lib/runtime";47import { webRuntime } from "../../lib/runtime";
48import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment";
49import { readManagedRelayClerkToken } from "../../cloud/managedAuth";
4450
45import {51import {
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";
59import {67import {
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;
159const NOOP = () => undefined;167const NOOP = () => undefined;
160const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;168const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u;
161169
170const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) =>
171 Effect.gen(function* () {
172 const signer = yield* ManagedRelayDpopSigner;
173 return yield* signer.createProof(input);
174 });
175
162function createDeferredPromise<T>() {176function 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(
11561170
1157function createSavedEnvironmentClient(1171function 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);
11621176
...@@ -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.desktopSsh1184 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(
12211286
1222async function refreshSavedEnvironmentMetadata(1287async 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.desktopSsh1301 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 ]);
12451328
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}
12561339
1340async 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
1257function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {1386function 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 }
13651499
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 }
14371592
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.desktopSsh1842 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: {
17011856
1702 const bearerSession = input.desktopSsh1857 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}
17431898
1899export 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
1744export async function connectDesktopSshEnvironment(1933export 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) {
109function syncDesktopTheme(theme: Theme) {109function 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 }
115115
apps/web/src/hostedPairing.ts+1-1
...@@ -10,7 +10,7 @@ export interface HostedPairingRequest {...@@ -10,7 +10,7 @@ export interface HostedPairingRequest {
1010
11export type HostedAppChannel = "latest" | "nightly";11export type HostedAppChannel = "latest" | "nightly";
1212
13function configuredHostedAppUrl(): string {13export 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}
1616
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";
11import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit";
1112
12export const remoteHttpRuntime = ManagedRuntime.make(remoteHttpClientLayer(globalThis.fetch));13import { browserCryptoLayer } from "../cloud/dpop";
14import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer";
15import { resolveCloudPublicConfig } from "../cloud/publicConfig";
16
17function configuredRelayUrl(): string {
18 return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid";
19}
20
21const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch);
22
23export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer);
1324
14const primaryHttpRuntime = ManagedRuntime.make(25const 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
37export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void {48export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void {
38 primaryHttpRunner = runner ?? livePrimaryHttpRunner;49 primaryHttpRunner = runner ?? livePrimaryHttpRunner;
39}50}
51
52export 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 @@
1import React from "react";1import React from "react";
2import ReactDOM from "react-dom/client";2import ReactDOM from "react-dom/client";
3import { ClerkProvider } from "@clerk/react";
3import { RouterProvider } from "@tanstack/react-router";4import { RouterProvider } from "@tanstack/react-router";
4import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";5import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
56
...@@ -7,6 +8,9 @@ import "@xterm/xterm/css/xterm.css";...@@ -7,6 +8,9 @@ import "@xterm/xterm/css/xterm.css";
7import "./index.css";8import "./index.css";
89
9import { isElectron } from "./env";10import { isElectron } from "./env";
11import { DesktopClerkProvider } from "./cloud/desktopClerk";
12import { ManagedRelayAuthProvider } from "./cloud/managedAuth";
13import { hasCloudPublicConfig } from "./cloud/publicConfig";
10import { getRouter } from "./router";14import { getRouter } from "./router";
11import { APP_DISPLAY_NAME } from "./branding";15import { APP_DISPLAY_NAME } from "./branding";
12import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";16import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
...@@ -22,8 +26,25 @@ if (isElectron) {...@@ -22,8 +26,25 @@ if (isElectron) {
2226
23document.title = APP_DISPLAY_NAME;27document.title = APP_DISPLAY_NAME;
2428
29const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
30const cloudWaitlistUrl = "/settings/cloud";
31
32const app = <RouterProvider router={router} />;
33
25ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(34ReactDOM.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
19import { Route as SettingsGeneralRouteImport } from './routes/settings.general'19import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
20import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'20import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
21import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'21import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
22import { Route as SettingsCloudRouteImport } from './routes/settings.cloud'
22import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'23import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
23import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'24import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
24import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'25import { 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)
76const SettingsCloudRoute = SettingsCloudRouteImport.update({
77 id: '/cloud',
78 path: '/cloud',
79 getParentRoute: () => SettingsRoute,
80} as any)
75const SettingsArchivedRoute = SettingsArchivedRouteImport.update({81const 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 PairRoute100 '/pair': typeof PairRoute
95 '/settings': typeof SettingsRouteWithChildren101 '/settings': typeof SettingsRouteWithChildren
96 '/settings/archived': typeof SettingsArchivedRoute102 '/settings/archived': typeof SettingsArchivedRoute
103 '/settings/cloud': typeof SettingsCloudRoute
97 '/settings/connections': typeof SettingsConnectionsRoute104 '/settings/connections': typeof SettingsConnectionsRoute
98 '/settings/diagnostics': typeof SettingsDiagnosticsRoute105 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
99 '/settings/general': typeof SettingsGeneralRoute106 '/settings/general': typeof SettingsGeneralRoute
...@@ -107,6 +114,7 @@ export interface FileRoutesByTo {...@@ -107,6 +114,7 @@ export interface FileRoutesByTo {
107 '/pair': typeof PairRoute114 '/pair': typeof PairRoute
108 '/settings': typeof SettingsRouteWithChildren115 '/settings': typeof SettingsRouteWithChildren
109 '/settings/archived': typeof SettingsArchivedRoute116 '/settings/archived': typeof SettingsArchivedRoute
117 '/settings/cloud': typeof SettingsCloudRoute
110 '/settings/connections': typeof SettingsConnectionsRoute118 '/settings/connections': typeof SettingsConnectionsRoute
111 '/settings/diagnostics': typeof SettingsDiagnosticsRoute119 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
112 '/settings/general': typeof SettingsGeneralRoute120 '/settings/general': typeof SettingsGeneralRoute
...@@ -123,6 +131,7 @@ export interface FileRoutesById {...@@ -123,6 +131,7 @@ export interface FileRoutesById {
123 '/pair': typeof PairRoute131 '/pair': typeof PairRoute
124 '/settings': typeof SettingsRouteWithChildren132 '/settings': typeof SettingsRouteWithChildren
125 '/settings/archived': typeof SettingsArchivedRoute133 '/settings/archived': typeof SettingsArchivedRoute
134 '/settings/cloud': typeof SettingsCloudRoute
126 '/settings/connections': typeof SettingsConnectionsRoute135 '/settings/connections': typeof SettingsConnectionsRoute
127 '/settings/diagnostics': typeof SettingsDiagnosticsRoute136 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
128 '/settings/general': typeof SettingsGeneralRoute137 '/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 SettingsConnectionsRouteImport269 preLoaderRoute: typeof SettingsConnectionsRouteImport
258 parentRoute: typeof SettingsRoute270 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)
297316
298interface SettingsRouteChildren {317interface SettingsRouteChildren {
299 SettingsArchivedRoute: typeof SettingsArchivedRoute318 SettingsArchivedRoute: typeof SettingsArchivedRoute
319 SettingsCloudRoute: typeof SettingsCloudRoute
300 SettingsConnectionsRoute: typeof SettingsConnectionsRoute320 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
301 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute321 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
302 SettingsGeneralRoute: typeof SettingsGeneralRoute322 SettingsGeneralRoute: typeof SettingsGeneralRoute
...@@ -307,6 +327,7 @@ interface SettingsRouteChildren {...@@ -307,6 +327,7 @@ interface SettingsRouteChildren {
307327
308const SettingsRouteChildren: SettingsRouteChildren = {328const 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";
13import { APP_DISPLAY_NAME } from "../branding";13import { APP_DISPLAY_NAME } from "../branding";
14import { AppSidebarLayout } from "../components/AppSidebarLayout";14import { AppSidebarLayout } from "../components/AppSidebarLayout";
15import { CommandPalette } from "../components/CommandPalette";15import { CommandPalette } from "../components/CommandPalette";
16import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
16import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";17import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
17import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";18import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
18import {19import {
...@@ -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/
7import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";7import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";
8import { useSavedEnvironmentRegistryStore } from "../environments/runtime";8import { useSavedEnvironmentRegistryStore } from "../environments/runtime";
9import { APP_DISPLAY_NAME } from "~/branding";9import { APP_DISPLAY_NAME } from "~/branding";
10import { hasCloudPublicConfig } from "~/cloud/publicConfig";
1011
11function ChatIndexRouteView() {12function 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});
2728
28function HostedStaticOnboardingState() {29function 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 started51 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 backend54 {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 environment64 {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 @@
1import { createFileRoute, redirect } from "@tanstack/react-router";
2
3import { CloudSettingsPanel } from "../components/settings/CloudSettings";
4import { hasCloudPublicConfig } from "../cloud/publicConfig";
5
6export 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}
1214
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";
8import { defineConfig } from "vite-plus";8import { defineConfig } from "vite-plus";
9import pkg from "./package.json" with { type: "json" };9import pkg from "./package.json" with { type: "json" };
1010
11import { loadRepoEnv } from "../../scripts/lib/public-config";
12
13const repoEnv = loadRepoEnv();
14Object.assign(process.env, repoEnv);
15
11const port = Number(process.env.PORT ?? 5733);16const port = Number(process.env.PORT ?? 5733);
12const host = process.env.HOST?.trim() || "localhost";17const host = process.env.HOST?.trim() || "localhost";
13const configuredWsUrl = process.env.VITE_WS_URL?.trim();18const configuredWsUrl = process.env.VITE_WS_URL?.trim();
19const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
20const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
21const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
14const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";22const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
15const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;23const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
16const configuredHostedAppUrl = (() => {24const 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 lives132 // 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
3The 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
9Alchemy stages append their sanitized stage name to isolate resources, for example
10`t3-code-relay-traces-dev-julius` for a personal stage.
11
12Deploy from `infra/relay` with the normal Alchemy workflow:
13
14```sh
15vp run deploy
16```
17
18Alchemy resolves Axiom deployment credentials through its provider. At runtime, the Worker
19receives only the scoped ingest token; it does not receive the diagnostics query token.
20
21The Worker emits Effect's built-in HTTP server spans plus endpoint and database child spans.
22Effect's OpenTelemetry exporter stores semantic HTTP attributes below the `attributes.` prefix.
23For 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
36Endpoint 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
40Agents should prefer the provisioned view or APL queries for completed incidents instead of
41tailing the Cloudflare Worker. Use the read-only query token when scripted access is needed;
42keep 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 releases9 - 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 channel11 - 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` DMG15 - macOS `arm64` DMG
15 - macOS `x64` DMG16 - 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 channel30 - 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.
3132
33## T3 Cloud relay deployment
34
35The relay is a shared control plane versioned separately from client releases. Stable and nightly
36client builds must point at the same relay so users see the same linked environments when switching
37release channels.
38
39`.github/workflows/deploy-relay.yml` deploys Alchemy stage `prod` on every push to `main`. The
40release workflow reads the relay URL and Clerk client configuration from the existing `production`
41GitHub Actions environment before building desktop, CLI, or hosted web artifacts.
42
43Required repository variables shared by relay deployments:
44
45- `CLOUDFLARE_ACCOUNT_ID`
46- `PLANETSCALE_ORGANIZATION`
47- `AXIOM_ORG_ID`
48
49Required repository secrets shared by relay deployments:
50
51- `CLOUDFLARE_API_TOKEN`
52- `PLANETSCALE_API_TOKEN_ID`
53- `PLANETSCALE_API_TOKEN`
54- `AXIOM_TOKEN`
55
56Required `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
69Optional `production` environment variables:
70
71- `RELAY_DOMAIN` when overriding the derived `relay.<RELAY_API_ZONE_NAME>` domain
72
73Required `production` environment secrets:
74
75- `CLERK_SECRET_KEY`
76- `APNS_PRIVATE_KEY`
77
78The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they
79are not bound into the relay Worker. The production deployment uses an Axiom personal access token,
80so `AXIOM_ORG_ID` must accompany `AXIOM_TOKEN`. The `prod` stage owns the retained PlanetScale
81database. Local personal stages provision isolated branches from it and are never deployed by CI.
82Production adopts the configured relay API and tunnel DNS zones as retained Cloudflare resources.
83Personal stages reference the production-owned zones.
84
85Developers deploy personal stages locally rather than through pull-request automation:
86
87```sh
88vp run --filter t3code-relay deploy -- --stage "$USER" --env-file .env.local
89```
90
32## Hosted web app release deployment91## Hosted web app release deployment
3392
34The hosted app is intentionally not deployed by Vercel's Git integration. The93The 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:
85144
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
3T3 Cloud uses one Clerk application for web, desktop, and mobile authentication. The relay accepts
4Clerk JWTs only when they are generated from the `t3-relay` template with the shared
5`t3-code-relay` audience.
6
7## Application Keys
8
9T3 Cloud is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env`
10or `.env.local` file:
11
12```dotenv
13T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key>
14T3CODE_CLERK_JWT_TEMPLATE=<JWT template name>
15T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=<public OAuth application client ID>
16T3CODE_RELAY_URL=https://relay.example.com
17```
18
19The 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
21client configuration should use the canonical names.
22
23Configuration precedence is:
24
251. Process or CI environment variables.
262. Repository-root `.env.local`.
273. Repository-root `.env`.
28
29The Clerk publishable key, JWT template name, CLI OAuth client ID, and relay URL are public
30identifiers, not secrets.
31Web, desktop, mobile, and bundled server builds statically inject the values they consume during
32their build step. A built artifact does not need an environment file at runtime. CI release builds
33should 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
35production builds only need the Clerk publishable key, JWT template name, and relay URL in their EAS
36environment.
37
38When any client-facing public value is absent, cloud UI is omitted. When the CLI public values are
39absent, the `t3 cloud` CLI command group is omitted. The bundled server still accepts runtime
40overrides for self-hosted or operator-managed
41deployments.
42
43For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay
44deployment 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
46deployment 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`
49with 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
51environment or commit it to the repository.
52
53The `prod` Alchemy stage owns the retained PlanetScale database. Non-production stages reference
54that database and provision isolated PlanetScale branches, so deploy `prod` before creating a
55personal developer stage.
56
57## Headless CLI OAuth Application
58
59The `t3 cloud` commands authorize a headless environment with a separate Clerk OAuth application.
60This uses an OAuth public client with PKCE, so the CLI stores no client secret.
61
62In **Clerk Dashboard > OAuth applications**:
63
641. Create an OAuth application for the T3 CLI.
652. Enable the **Public** option so authorization-code exchange uses PKCE.
663. Add `http://127.0.0.1:34338/callback` as an allowed redirect URI.
674. Enable the `openid`, `profile`, and `email` scopes.
685. 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
71The 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
73handshake; it only validates the issued Clerk bearer token when the CLI manages an environment link.
74
75The CLI supports these headless operations:
76
77```sh
78t3 cloud login
79t3 cloud link
80t3 cloud status
81t3 cloud unlink
82t3 cloud logout
83t3 serve
84```
85
86`t3 cloud login` opens the Clerk authorization flow and stores the CLI credential without enabling
87cloud exposure. `t3 cloud link` installs the pinned managed `cloudflared` binary when needed,
88authorizes when needed, and records durable intent to expose the environment. It works without a
89running T3 server. The next `t3 serve` or `t3 start` reconciles the relay link and launches the
90managed tunnel. `t3 cloud unlink` records disabled intent immediately, stops a reachable running
91connector, and attempts to revoke the relay-side environment record. It retains the stored CLI
92authorization so `t3 cloud link` can re-enable exposure without another browser flow. `t3 cloud
93logout` performs the same cleanup and removes the stored CLI authorization.
94
95The current OAuth callback listener binds to loopback port `34338`. When running the CLI over SSH,
96forward that port before running `t3 cloud login` or `t3 cloud link`:
97
98```sh
99ssh -L 34338:127.0.0.1:34338 <host>
100```
101
102A relay-hosted callback broker can remove this port-forward requirement later without changing the
103stored PKCE token model.
104
105## JWT Template
106
107In **Clerk Dashboard > JWT templates**, create a template with:
108
109| Setting | Value |
110| ------- | ---------------------------- |
111| Name | `t3-relay` |
112| Claims | `{ "aud": "t3-code-relay" }` |
113
114Set `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
117is shared by production and non-production relay stages. The client-facing `T3CODE_RELAY_URL` still
118selects the concrete relay deployment, but changing that URL does not require a JWT template change.
119
120## Desktop OAuth Redirect Allowlist
121
122The desktop app opens OAuth in the system browser and returns to the app with a custom URL scheme.
123In **Clerk Dashboard > Native applications**, enable native application support and add these
124entries under the mobile SSO redirect allowlist:
125
126```text
127t3code-dev://auth/callback
128t3code://auth/callback
129```
130
131The first entry is for local desktop development. The second is for packaged desktop builds.
132The app also adds a request-scoped `t3_state` query parameter and validates it on callback. Initial
133sign-in and linked-account OAuth flows both return through this bridge. The desktop provider keeps
134Clerk's stock profile component, replaces its renderer-page callback with the custom-scheme callback,
135and opens the provider URL in the system browser. Do not add the local renderer URL as an OAuth
136redirect: an external browser cannot use it to reopen the packaged app.
137
138The current mobile UI uses Clerk's native authentication view. If a future mobile browser OAuth
139flow uses a custom redirect URI, add that exact URI to the same allowlist.
140
141## Enable Waitlist Access
142
143For a private beta where people should request access, use **Clerk Dashboard > Waitlist**:
144
1451. Toggle on **Enable waitlist** and save.
1462. Review requests on the same page and select **Invite** or **Deny**.
147
148Signed-out web and desktop users see Clerk's waitlist enrollment as the T3 Cloud page content,
149while approved signed-in users see cloud settings. The browser app also uses `/settings/cloud` as
150its Clerk waitlist URL.
151
152On mobile, signed-out users open **Settings > T3 Account** to reach `/settings/waitlist` within the
153Settings 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
155screen.
156
157## Alternative: Known-User Allowlist
158
159For a closed beta where all permitted users are known in advance, use an allowlist instead of a
160request-and-approval waitlist:
161
162To restrict the beta to permitted email addresses or domains:
163
1641. In **Clerk Dashboard > Restrictions > Allowlist**, add each permitted email address or email
165 domain.
1662. Enable the allowlist and save.
1673. Alternatively, enable **Restricted mode** when all new users must be explicitly invited or
168 manually created without a waitlist request flow.
169
170Do not enable an empty allowlist: it blocks all new sign-ups.
171
172Clerk allowlists control who can sign up. They do not revoke an existing user's active cloud
173access. To remove an already-created user's access, ban that user in Clerk so their active
174sessions 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">
347flowchart 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">
542sequenceDiagram
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">
586sequenceDiagram
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">
619sequenceDiagram
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">
650sequenceDiagram
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.
5RELAY_API_ZONE_NAME=example.com
6RELAY_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.
15CLERK_PUBLISHABLE_KEY=pk_test_...
16CLERK_SECRET_KEY=sk_test_...
17CLERK_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.
22APNS_ENVIRONMENT=sandbox
23APNS_TEAM_ID=...
24APNS_KEY_ID=...
25APNS_BUNDLE_ID=...
26APNS_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
6The relay is the hosted control plane for T3 Code Cloud. It helps clients discover and connect to
7remote environments, manages the cloud-side records needed for those connections, and delivers
8optional mobile notifications and Live Activities.
9
10The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects,
11regular API and WebSocket traffic goes directly between that client and the selected environment.
12See the [cloud architecture overview](../../docs/t3-code-cloud-auth-flow.html) for the larger system
13design.
14
15## Responsibilities
16
17The 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
27The environment server and relay have separate credentials and trust boundaries. Read
28[Environment Authentication Profile](../../docs/environment-auth.md) before changing token,
29credential, 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
45Shared request and response schemas live in
46[`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay
47calls live in
48[`packages/client-runtime/src/managedRelay.ts`](../../packages/client-runtime/src/managedRelay.ts).
49
50## Working Locally
51
52Install dependencies from the repository root, then run relay-focused checks from this directory:
53
54```sh
55vp install
56cd infra/relay
57vp test run
58vp run typecheck
59```
60
61To run a smaller test set while iterating:
62
63```sh
64vp test run src/environments/EnvironmentLinker.test.ts
65```
66
67Before considering a change complete, run the repository-wide checks from the root:
68
69```sh
70vp check
71vp run typecheck
72```
73
74Backend changes should include tests. Prefer testing the real business logic with external
75dependencies represented at their boundary rather than mocking internal behavior.
76
77## Deployment
78
79The relay deploys through Alchemy:
80
81```sh
82vp run --filter t3code-relay deploy
83```
84
85The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database
86connectivity, 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
88file from the relay directory. Runtime secrets include Clerk and APNs credentials. Production adopts
89the configured API and tunnel DNS zones as retained Cloudflare resources. Personal stages reference
90the production-owned zones.
91
92The `prod` Alchemy stage owns the retained PlanetScale database and is the shared hosted relay for
93stable and nightly clients. Every other stage references that database and provisions an isolated
94PlanetScale branch and runtime role for local development, so deploy `prod` before creating
95developer stages:
96
97```sh
98vp run --filter t3code-relay deploy -- --stage prod
99vp run --filter t3code-relay deploy -- --env-file .env.local
100```
101
102Alchemy defaults personal deployments to the `dev_$USER` stage. Relay custom domains apply the same
103DNS-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
109domain override.
110
111After a successful deploy, the wrapper updates the repository-root `.env` file with the derived relay
112URL. That makes subsequent source builds point at the relay that was just deployed without copying
113the URL manually.
114
115### Deployment CI
116
117The relay is versioned separately from client releases. `.github/workflows/deploy-relay.yml` deploys
118the shared Alchemy `prod` stage on every push to `main`. Stable and nightly release builds both
119resolve their static public config from the same
120`production` GitHub environment. Pull requests do not deploy relay stages. Developers can
121deploy personal non-production stages locally with any stage name other than `prod`.
122
123The repository must define these Actions variables shared by relay deployments:
124
125- `CLOUDFLARE_ACCOUNT_ID`
126- `PLANETSCALE_ORGANIZATION`
127- `AXIOM_ORG_ID`
128
129The 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
136The `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
149The `production` GitHub environment must define these Actions secrets:
150
151- `CLERK_SECRET_KEY`
152- `APNS_PRIVATE_KEY`
153
154The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they
155are not bound into the relay Worker. The production deployment uses an Axiom personal access token,
156so `AXIOM_ORG_ID` must accompany `AXIOM_TOKEN`. The release workflow reads the production relay's
157derived public URL and Clerk publishable key from the same environment for downstream desktop, CLI,
158and hosted web builds.
159
160See:
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.
2import * as Alchemy from "alchemy";
3import * as Axiom from "alchemy/Axiom";
4import * as Cloudflare from "alchemy/Cloudflare";
5import * as Drizzle from "alchemy/Drizzle";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Planetscale from "alchemy/Planetscale";
9
10import { PlanetscaleDatabase, RelayHyperdrive } from "./src/db.ts";
11import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts";
12import Api from "./src/worker.ts";
13
14export 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 @@
1CREATE 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
11CREATE 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
27CREATE 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
36CREATE 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
46CREATE 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
64CREATE 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
78CREATE 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
92CREATE INDEX "idx_relay_agent_activity_rows_updated" ON "relay_agent_activity_rows" ("updated_at");--> statement-breakpoint
93CREATE INDEX "idx_relay_delivery_attempts_environment" ON "relay_delivery_attempts" ("environment_id","thread_id","created_at");--> statement-breakpoint
94CREATE UNIQUE INDEX "idx_relay_delivery_attempts_source_job" ON "relay_delivery_attempts" ("source_job_id");--> statement-breakpoint
95CREATE INDEX "idx_relay_dpop_proofs_expires_at" ON "relay_dpop_proofs" ("expires_at");--> statement-breakpoint
96CREATE UNIQUE INDEX "idx_relay_environment_credentials_hash" ON "relay_environment_credentials" ("credential_hash");--> statement-breakpoint
97CREATE INDEX "idx_relay_environment_credentials_environment" ON "relay_environment_credentials" ("environment_id","revoked_at");--> statement-breakpoint
98CREATE INDEX "idx_relay_environment_credentials_environment_key" ON "relay_environment_credentials" ("environment_id","environment_public_key","revoked_at");--> statement-breakpoint
99CREATE INDEX "idx_relay_environment_links_environment" ON "relay_environment_links" ("environment_id","revoked_at");--> statement-breakpoint
100CREATE INDEX "idx_relay_live_activities_user" ON "relay_live_activities" ("user_id");--> statement-breakpoint
101CREATE UNIQUE INDEX "idx_relay_live_activities_activity_push_token" ON "relay_live_activities" ("activity_push_token");--> statement-breakpoint
102CREATE INDEX "idx_relay_mobile_devices_user" ON "relay_mobile_devices" ("user_id");--> statement-breakpoint
103CREATE UNIQUE INDEX "idx_relay_mobile_devices_push_token" ON "relay_mobile_devices" ("push_token");--> statement-breakpoint
104CREATE 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 @@
1ALTER 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 @@
1CREATE 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
14CREATE UNIQUE INDEX "idx_relay_managed_endpoint_allocations_hostname" ON "relay_managed_endpoint_allocations" ("hostname");--> statement-breakpoint
15CREATE 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 @@
1import { describe, expect, it } from "vitest";
2
3import { hasDeployChanges, reconcileRootEnvRelayUrl } from "./deploy.ts";
4
5describe("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
35describe("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
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import { AdoptPolicy } from "alchemy/AdoptPolicy";
5import { AlchemyContext, AlchemyContextLive } from "alchemy/AlchemyContext";
6import * as Apply from "alchemy/Apply";
7import { provideFreshArtifactStore } from "alchemy/Artifacts";
8import { AuthProviders } from "alchemy/Auth/AuthProvider";
9import { CredentialsStoreLive } from "alchemy/Auth/Credentials";
10import { ProfileLive } from "alchemy/Auth/Profile";
11import { Cli } from "alchemy/Cli/Cli";
12import { LoggingCli } from "alchemy/Cli/LoggingCli";
13import * as Plan from "alchemy/Plan";
14import * as Stage from "alchemy/Stage";
15import { TelemetryLive } from "alchemy/Telemetry/Layer";
16import { PlatformServices } from "alchemy/Util/PlatformServices";
17import * as Config from "effect/Config";
18import * as ConfigProvider from "effect/ConfigProvider";
19import * as Console from "effect/Console";
20import * as Data from "effect/Data";
21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
25import * as Path from "effect/Path";
26import { Command, Flag, Prompt } from "effect/unstable/cli";
27import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
28
29import RelayStack from "../alchemy.run.ts";
30
31export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{
32 readonly message: string;
33}> {}
34
35export 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
44export 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
55export 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
65const relayRoot = Effect.service(Path.Path).pipe(
66 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
67);
68const repoRoot = Effect.service(Path.Path).pipe(
69 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
70);
71
72const 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
82const 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
89const 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
100const 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
109const 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
163export 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
175export 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
206if (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 @@
1import * as Context from "effect/Context";
2import * as Redacted from "effect/Redacted";
3import * as Schema from "effect/Schema";
4
5export const ApnsEnvironment = Schema.Literals(["sandbox", "production"]);
6export type ApnsEnvironment = typeof ApnsEnvironment.Type;
7
8export 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
16export 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
29export 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 @@
1import type { RelayAgentActivityState, RelayDeliveryResult } from "@t3tools/contracts/relay";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import * as AgentActivityRows from "./AgentActivityRows.ts";
7import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
8import * as LiveActivities from "./LiveActivities.ts";
9import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
10import * as ApnsDeliveries from "./ApnsDeliveries.ts";
11
12const 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
24function 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
43function 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
57function 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
68function 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
90function 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
127describe("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 @@
1import type {
2 RelayAgentActivityAggregateState,
3 RelayAgentActivityState,
4 RelayDeliveryResult,
5 RelayPublishResponse,
6} from "@t3tools/contracts/relay";
7import * as Context from "effect/Context";
8import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11
12import { sanitizeAgentActivityAggregateState } from "./agentActivityPayloads.ts";
13import * as AgentActivityRows from "./AgentActivityRows.ts";
14import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
15import * as LiveActivities from "./LiveActivities.ts";
16import * as ApnsDeliveries from "./ApnsDeliveries.ts";
17
18export type AgentActivityPublishError =
19 | AgentActivityRows.AgentActivityRowUpsertPersistenceError
20 | AgentActivityRows.AgentActivityRowDeletePersistenceError
21 | AgentActivityRows.AgentActivityRowListPersistenceError
22 | EnvironmentLinks.EnvironmentLinkUserListPersistenceError
23 | LiveActivities.LiveActivityTargetListPersistenceError
24 | ApnsDeliveries.ApnsDeliveryError;
25
26export 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
39export class AgentActivityPublisher extends Context.Service<
40 AgentActivityPublisher,
41 AgentActivityPublisherShape
42>()("t3code-relay/agentActivity/AgentActivityPublisher") {}
43
44const 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
163function 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
182function isTerminalPhase(state: RelayAgentActivityState): boolean {
183 return state.phase === "completed" || state.phase === "failed";
184}
185
186function 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
200function 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
210function 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
230export const layer = Layer.effect(AgentActivityPublisher, make);
infra/relay/src/agentActivity/AgentActivityRows.ts created+158
...@@ -0,0 +1,158 @@
1import type { RelayAgentActivityState } from "@t3tools/contracts/relay";
2import { RelayAgentActivityState as RelayAgentActivityStateSchema } from "@t3tools/contracts/relay";
3import * as Context from "effect/Context";
4import * as Data from "effect/Data";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import { cast } from "effect/Function";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11import { and, desc, eq, isNull } from "drizzle-orm";
12
13import { RelayDb } from "../db.ts";
14import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts";
15
16export class AgentActivityRowUpsertPersistenceError extends Data.TaggedError(
17 "AgentActivityRowUpsertPersistenceError",
18)<{
19 readonly cause: unknown;
20}> {}
21
22export class AgentActivityRowDeletePersistenceError extends Data.TaggedError(
23 "AgentActivityRowDeletePersistenceError",
24)<{
25 readonly cause: unknown;
26}> {}
27
28export class AgentActivityRowListPersistenceError extends Data.TaggedError(
29 "AgentActivityRowListPersistenceError",
30)<{
31 readonly cause: unknown;
32}> {}
33
34export 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
49export class AgentActivityRows extends Context.Service<AgentActivityRows, AgentActivityRowsShape>()(
50 "t3code-relay/agentActivity/AgentActivityRows",
51) {}
52
53const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString);
54const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString);
55
56const encodeRelayAgentActivityStateJson = Schema.encodeEffect(
57 Schema.fromJsonString(RelayAgentActivityStateSchema),
58);
59
60const decodeRelayAgentActivityStateJson = Schema.decodeUnknownOption(
61 Schema.fromJsonString(RelayAgentActivityStateSchema),
62);
63
64const 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
158export const layer = Layer.effect(AgentActivityRows, make);
infra/relay/src/agentActivity/ApnsClient.test.ts created+140
...@@ -0,0 +1,140 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpClient } from "effect/unstable/http";
6
7import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
8import { EnvironmentId, ThreadId } from "@t3tools/contracts";
9import * as ApnsClient from "./ApnsClient.ts";
10
11const 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
20describe("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 @@
1import * as NodeCrypto from "node:crypto";
2
3import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
4import * as Context from "effect/Context";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Encoding from "effect/Encoding";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Redacted from "effect/Redacted";
11import * as Schema from "effect/Schema";
12import {
13 Headers,
14 HttpClient,
15 HttpClientRequest,
16 type HttpBody,
17 type HttpClientError,
18} from "effect/unstable/http";
19import type { ApnsCredentials } from "../Config.ts";
20import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
21
22const LIVE_ACTIVITY_NAME = "AgentActivity";
23const STALE_AFTER_SECONDS = 2 * 60;
24const DISMISS_AFTER_SECONDS = 5 * 60;
25
26export type ApnsLiveActivityEvent = "start" | "update" | "end";
27
28interface ApnsLiveActivityRequest {
29 readonly token: string;
30 readonly event: ApnsLiveActivityEvent;
31 readonly priority: "5" | "10";
32 readonly payload: unknown;
33}
34
35interface ApnsPushNotificationRequest {
36 readonly token: string;
37 readonly priority: "10";
38 readonly payload: unknown;
39}
40
41export interface ApnsDeliveryResult {
42 readonly ok: boolean;
43 readonly status: number;
44 readonly reason?: string;
45 readonly apnsId: string | null;
46}
47
48export class ApnsSigningError extends Data.TaggedError("ApnsSigningError")<{
49 readonly phase: "encoding" | "signing";
50 readonly cause: unknown;
51}> {}
52
53export class ApnsHttpRequestError extends Data.TaggedError("ApnsHttpRequestError")<{
54 readonly cause: HttpClientError.HttpClientError | HttpBody.HttpBodyError;
55}> {}
56
57export class ApnsInvalidResponseError extends Data.TaggedError("ApnsInvalidResponseError")<{
58 readonly cause: unknown;
59}> {}
60
61export type ApnsError = ApnsSigningError | ApnsHttpRequestError | ApnsInvalidResponseError;
62
63const decodeApnsErrorResponseJson = Schema.decodeUnknownOption(
64 Schema.fromJsonString(
65 Schema.Struct({
66 reason: Schema.optional(Schema.String),
67 }),
68 ),
69);
70const encodeApnsJwtHeaderJson = Schema.encodeEffect(
71 Schema.fromJsonString(
72 Schema.Struct({
73 alg: Schema.Literal("ES256"),
74 kid: Schema.String,
75 }),
76 ),
77);
78const encodeApnsJwtPayloadJson = Schema.encodeEffect(
79 Schema.fromJsonString(
80 Schema.Struct({
81 iss: Schema.String,
82 iat: Schema.Number,
83 }),
84 ),
85);
86
87const 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
120function contentState(state: RelayAgentActivityAggregateState) {
121 return {
122 name: LIVE_ACTIVITY_NAME,
123 props: JSON.stringify(state),
124 };
125}
126
127interface LiveActivityRequestBase {
128 readonly token: string;
129 readonly nowEpochSeconds: number;
130 readonly nowIso: string;
131}
132
133type 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
143function 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
188function 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
210function 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
220export 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
235export class ApnsClient extends Context.Service<ApnsClient, ApnsClientShape>()(
236 "t3code-relay/agentActivity/ApnsClient",
237) {}
238
239const 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
320export const layer = Layer.effect(ApnsClient, make);
infra/relay/src/agentActivity/ApnsDeliveries.test.ts created+1128
...@@ -0,0 +1,1128 @@
1import type {
2 RelayAgentActivityAggregateState,
3 RelayAgentActivityState,
4} from "@t3tools/contracts/relay";
5import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
6import { describe, expect, it } from "@effect/vitest";
7import * as NodeCrypto from "node:crypto";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Redacted from "effect/Redacted";
11import {
12 FetchHttpClient,
13 HttpClient,
14 HttpClientRequest,
15 HttpClientResponse,
16} from "effect/unstable/http";
17
18import {
19 makeApnsDeliveryJobPayload,
20 signApnsDeliveryJob,
21 type SignedApnsDeliveryJob,
22} from "./apnsDeliveryJobs.ts";
23import * as DeliveryAttempts from "./DeliveryAttempts.ts";
24import * as LiveActivities from "./LiveActivities.ts";
25import * as RelayConfiguration from "../Config.ts";
26import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
27import * as ApnsDeliveries from "./ApnsDeliveries.ts";
28import * as ApnsClient from "./ApnsClient.ts";
29
30const 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
49const apnsSigningKeyPair = NodeCrypto.generateKeyPairSync("ec", {
50 namedCurve: "P-256",
51 privateKeyEncoding: { format: "pem", type: "pkcs8" },
52 publicKeyEncoding: { format: "pem", type: "spki" },
53});
54
55const signingConfig = RelayConfiguration.RelayConfiguration.of({
56 ...config,
57 apns: {
58 ...config.apns,
59 privateKey: Redacted.make(apnsSigningKeyPair.privateKey),
60 },
61});
62
63const 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
75const 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
95const enabledPreferences = JSON.stringify({
96 liveActivitiesEnabled: true,
97 notificationsEnabled: true,
98 notifyOnApproval: true,
99 notifyOnInput: true,
100 notifyOnCompletion: true,
101 notifyOnFailure: true,
102});
103
104const disabledPreferences = JSON.stringify({
105 liveActivitiesEnabled: false,
106 notificationsEnabled: true,
107 notifyOnApproval: true,
108 notifyOnInput: true,
109 notifyOnCompletion: true,
110 notifyOnFailure: true,
111});
112
113const notificationsDisabledPreferences = JSON.stringify({
114 liveActivitiesEnabled: false,
115 notificationsEnabled: false,
116 notifyOnApproval: true,
117 notifyOnInput: true,
118 notifyOnCompletion: true,
119 notifyOnFailure: true,
120});
121
122const 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
139function 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
225describe("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 @@
1import type {
2 RelayAgentActivityAggregateState,
3 RelayAgentAwarenessPreferences,
4 RelayDeliveryKind,
5 RelayDeliveryResult,
6} from "@t3tools/contracts/relay";
7import {
8 RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema,
9 RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema,
10} from "@t3tools/contracts/relay";
11import * as Context from "effect/Context";
12import * as Data from "effect/Data";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Schema from "effect/Schema";
18
19import {
20 sanitizeAgentActivityAggregateState,
21 sanitizeApnsNotificationPayload,
22} from "./agentActivityPayloads.ts";
23import * as Apns from "./ApnsClient.ts";
24import {
25 ApnsDeliveryJobInvalid,
26 type ApnsNotificationPayload,
27 SignedApnsDeliveryJob,
28 verifySignedApnsDeliveryJob,
29 type ApnsDeliveryJobVerificationError,
30} from "./apnsDeliveryJobs.ts";
31import * as DeliveryAttempts from "./DeliveryAttempts.ts";
32import * as LiveActivities from "./LiveActivities.ts";
33import * as RelayConfiguration from "../Config.ts";
34import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
35import { withSpanAttributes } from "../observability.ts";
36
37const MIN_LIVE_ACTIVITY_UPDATE_INTERVAL_MS = 15_000;
38const PERMANENT_APNS_TOKEN_REASONS = new Set([
39 "BadDeviceToken",
40 "DeviceTokenNotForTopic",
41 "Unregistered",
42]);
43
44type LiveActivityDeliveryKind = Extract<
45 RelayDeliveryKind,
46 "live_activity_start" | "live_activity_update" | "live_activity_end"
47>;
48
49type 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
61type ChosenPushNotificationDelivery = {
62 readonly kind: "push_notification";
63 readonly token: string;
64 readonly notification: ApnsNotificationPayload;
65};
66
67type ChosenDelivery = ChosenLiveActivityDelivery | ChosenPushNotificationDelivery;
68
69export type ApnsDeliveryError =
70 | ApnsDeliveryQueue.ApnsDeliveryQueueError
71 | ApnsDeliveryJobVerificationError
72 | ApnsDeliveryJobClaimInFlight
73 | DeliveryAttempts.DeliveryAttemptRecordPersistenceError
74 | LiveActivities.LiveActivityTargetListPersistenceError
75 | LiveActivities.LiveActivityDeliveryMarkPersistenceError;
76
77export class ApnsDeliveryJobClaimInFlight extends Data.TaggedError("ApnsDeliveryJobClaimInFlight")<{
78 readonly sourceJobId: string;
79}> {}
80
81const decodeRelayAgentActivityAggregateStateJson = Schema.decodeUnknownOption(
82 Schema.fromJsonString(RelayAgentActivityAggregateStateSchema),
83);
84const decodeRelayAgentAwarenessPreferencesJson = Schema.decodeUnknownOption(
85 Schema.fromJsonString(RelayAgentAwarenessPreferencesSchema),
86);
87const decodeSignedApnsDeliveryJob = Schema.decodeUnknownEffect(SignedApnsDeliveryJob);
88
89function 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
100function parseAggregate(value: string | null): RelayAgentActivityAggregateState | null {
101 if (!value) {
102 return null;
103 }
104 return Option.getOrNull(decodeRelayAgentActivityAggregateStateJson(value));
105}
106
107function parsePreferences(value: string): RelayAgentAwarenessPreferences | null {
108 return Option.getOrNull(decodeRelayAgentAwarenessPreferencesJson(value));
109}
110
111function 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
140function 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
172function 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
230function 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
249function 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
260function 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
270function 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
279function 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
293function 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
307function 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
316interface LiveActivityDeliveryTarget {
317 readonly user_id: string;
318 readonly device_id: string;
319}
320
321function 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
336interface SendLiveActivityDeliveryInputBase {
337 readonly target: LiveActivityDeliveryTarget;
338 readonly token: string;
339 readonly sourceJobId?: string | null;
340}
341
342export 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
352function 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
388export 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
412export class ApnsDeliveries extends Context.Service<ApnsDeliveries, ApnsDeliveriesShape>()(
413 "t3code-relay/agentActivity/ApnsDeliveries",
414) {}
415
416const 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
795export const layer = Layer.effect(ApnsDeliveries, make);
infra/relay/src/agentActivity/ApnsDeliveryQueue.ts created+163
...@@ -0,0 +1,163 @@
1import * as Alchemy from "alchemy";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Crypto from "effect/Crypto";
4import * as Context from "effect/Context";
5import * as Data from "effect/Data";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9
10import type { RelayDeliveryResult } from "@t3tools/contracts/relay";
11
12import {
13 sanitizeAgentActivityAggregateState,
14 sanitizeApnsNotificationPayload,
15} from "./agentActivityPayloads.ts";
16import {
17 expiresAtForJob,
18 makeApnsDeliveryJobPayload,
19 signApnsDeliveryJob,
20 type ApnsDeliveryJobPayload,
21 type SignedApnsDeliveryJob,
22} from "./apnsDeliveryJobs.ts";
23import * as RelayConfiguration from "../Config.ts";
24
25export class ApnsDeliveryQueueSendError extends Data.TaggedError("ApnsDeliveryQueueSendError")<{
26 readonly cause: unknown;
27}> {}
28
29export type ApnsDeliveryQueueError = ApnsDeliveryQueueSendError;
30
31export interface ApnsDeliveryQueueSenderShape {
32 readonly send: (body: SignedApnsDeliveryJob) => Effect.Effect<void, ApnsDeliveryQueueSendError>;
33}
34
35export class ApnsDeliveryQueueSender extends Context.Service<
36 ApnsDeliveryQueueSender,
37 ApnsDeliveryQueueSenderShape
38>()("t3code-relay/agentActivity/ApnsDeliveryQueue/ApnsDeliveryQueueSender") {}
39
40export 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
56export class ApnsDeliveryQueue extends Context.Service<ApnsDeliveryQueue, ApnsDeliveryQueueShape>()(
57 "t3code-relay/agentActivity/ApnsDeliveryQueue",
58) {}
59
60const 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
144export const layer = Layer.effect(ApnsDeliveryQueue, make);
145
146export 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 @@
1import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { RelayDb, type RelayDatabase } from "../db.ts";
7import { relayDeliveryAttempts } from "../persistence/schema.ts";
8import * as DeliveryAttempts from "./DeliveryAttempts.ts";
9
10describe("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 @@
1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import { and, eq, isNull } from "drizzle-orm";
8import * as Crypto from "effect/Crypto";
9
10import { RelayDb } from "../db.ts";
11import { relayDeliveryAttempts } from "../persistence/schema.ts";
12
13export class DeliveryAttemptRecordPersistenceError extends Data.TaggedError(
14 "DeliveryAttemptRecordPersistenceError",
15)<{
16 readonly cause: unknown;
17}> {}
18
19export 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
33export 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
41export type DeliverySourceJobClaimResult = "claimed" | "completed" | "in_flight";
42
43export 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
55export class DeliveryAttempts extends Context.Service<DeliveryAttempts, DeliveryAttemptsShape>()(
56 "t3code-relay/agentActivity/DeliveryAttempts",
57) {}
58
59const SOURCE_JOB_CLAIM_LEASE_MINUTES = 10;
60
61function 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
83const 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
203export const layer = Layer.effect(DeliveryAttempts, make);
infra/relay/src/agentActivity/Devices.test.ts created+220
...@@ -0,0 +1,220 @@
1import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
2import { describe, expect, it } from "@effect/vitest";
3import type { SQL } from "drizzle-orm";
4import { PgDialect } from "drizzle-orm/pg-core";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7
8import { RelayDb, type RelayDatabase } from "../db.ts";
9import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
10import * as Devices from "./Devices.ts";
11
12const 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
30describe("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 @@
1import type {
2 RelayClientDeviceRecord,
3 RelayDeviceRegistrationRequest,
4} from "@t3tools/contracts/relay";
5import * as Context from "effect/Context";
6import * as Data from "effect/Data";
7import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import { and, eq } from "drizzle-orm";
11import { sql } from "drizzle-orm";
12
13import { RelayDb } from "../db.ts";
14import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
15
16export class DeviceRegistrationPersistenceError extends Data.TaggedError(
17 "DeviceRegistrationPersistenceError",
18)<{
19 readonly cause: unknown;
20}> {}
21
22export class DeviceUnregistrationPersistenceError extends Data.TaggedError(
23 "DeviceUnregistrationPersistenceError",
24)<{
25 readonly cause: unknown;
26}> {}
27
28export class DeviceListPersistenceError extends Data.TaggedError("DeviceListPersistenceError")<{
29 readonly cause: unknown;
30}> {}
31
32export 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
46export class Devices extends Context.Service<Devices, DevicesShape>()(
47 "t3code-relay/agentActivity/Devices",
48) {}
49
50const 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
181export const layer = Layer.effect(Devices, make);
infra/relay/src/agentActivity/LiveActivities.test.ts created+196
...@@ -0,0 +1,196 @@
1import type {
2 RelayAgentActivityAggregateState,
3 RelayLiveActivityRegistrationRequest,
4} from "@t3tools/contracts/relay";
5import { describe, expect, it } from "@effect/vitest";
6import type { SQL } from "drizzle-orm";
7import { PgDialect } from "drizzle-orm/pg-core";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10
11import { RelayDb, type RelayDatabase } from "../db.ts";
12import { relayLiveActivities } from "../persistence/schema.ts";
13import * as LiveActivities from "./LiveActivities.ts";
14
15const 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
36describe("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 @@
1import type {
2 RelayAgentActivityAggregateState,
3 RelayDeliveryKind,
4 RelayLiveActivityRegistrationRequest,
5} from "@t3tools/contracts/relay";
6import { RelayAgentActivityAggregateState as RelayAgentActivityAggregateStateSchema } from "@t3tools/contracts/relay";
7import * as Context from "effect/Context";
8import * as Data from "effect/Data";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import { cast } from "effect/Function";
12import * as Layer from "effect/Layer";
13import * as Schema from "effect/Schema";
14import { and, eq, sql } from "drizzle-orm";
15
16import { RelayDb } from "../db.ts";
17import { relayLiveActivities, relayMobileDevices } from "../persistence/schema.ts";
18
19export class LiveActivityRegistrationPersistenceError extends Data.TaggedError(
20 "LiveActivityRegistrationPersistenceError",
21)<{
22 readonly cause: unknown;
23}> {}
24
25export class LiveActivityTargetListPersistenceError extends Data.TaggedError(
26 "LiveActivityTargetListPersistenceError",
27)<{
28 readonly cause: unknown;
29}> {}
30
31export class LiveActivityDeliveryMarkPersistenceError extends Data.TaggedError(
32 "LiveActivityDeliveryMarkPersistenceError",
33)<{
34 readonly cause: unknown;
35}> {}
36
37export 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
48export 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
57export type TargetRow = DeviceRow & LiveActivityRow;
58
59export 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
91export class LiveActivities extends Context.Service<LiveActivities, LiveActivitiesShape>()(
92 "t3code-relay/agentActivity/LiveActivities",
93) {}
94
95const decodeJsonString = Schema.decodeEffect(Schema.UnknownFromJsonString);
96const encodeJsonValue = Schema.encodeEffect(Schema.UnknownFromJsonString);
97
98const encodeRelayAgentActivityAggregateStateJson = Schema.encodeEffect(
99 Schema.fromJsonString(RelayAgentActivityAggregateStateSchema),
100);
101
102const 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
373export const layer = Layer.effect(LiveActivities, make);
infra/relay/src/agentActivity/MobileRegistrations.test.ts created+464
...@@ -0,0 +1,464 @@
1import type {
2 RelayAgentActivityState,
3 RelayDeviceRegistrationRequest,
4} from "@t3tools/contracts/relay";
5import type { SignedApnsDeliveryJob } from "./apnsDeliveryJobs.ts";
6import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
7import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Redacted from "effect/Redacted";
11import { FetchHttpClient } from "effect/unstable/http";
12
13import * as Devices from "./Devices.ts";
14import * as AgentActivityRows from "./AgentActivityRows.ts";
15import * as DeliveryAttempts from "./DeliveryAttempts.ts";
16import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
17import * as LiveActivities from "./LiveActivities.ts";
18import * as RelayConfiguration from "../Config.ts";
19import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
20import * as ApnsDeliveries from "./ApnsDeliveries.ts";
21import * as ApnsClient from "./ApnsClient.ts";
22import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
23import * as MobileRegistrations from "./MobileRegistrations.ts";
24
25const 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
41function 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
50function 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
64function 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
88function 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
110function 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
121const 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
140function 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
169function 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
179describe("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 @@
1import type {
2 RelayDeviceRegistrationRequest,
3 RelayLiveActivityRegistrationRequest,
4} from "@t3tools/contracts/relay";
5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8
9import * as Devices from "./Devices.ts";
10import * as LiveActivities from "./LiveActivities.ts";
11import * as AgentActivityPublisher from "./AgentActivityPublisher.ts";
12
13export type MobileRegistrationError =
14 | Devices.DeviceRegistrationPersistenceError
15 | Devices.DeviceUnregistrationPersistenceError
16 | LiveActivities.LiveActivityRegistrationPersistenceError;
17
18export 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
33export class MobileRegistrations extends Context.Service<
34 MobileRegistrations,
35 MobileRegistrationsShape
36>()("t3code-relay/agentActivity/MobileRegistrations") {}
37
38const 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
93export const layer = Layer.effect(MobileRegistrations, make);
infra/relay/src/agentActivity/agentActivityPayloads.ts created+63
...@@ -0,0 +1,63 @@
1import type {
2 RelayAgentActivityAggregateRow,
3 RelayAgentActivityAggregateState,
4} from "@t3tools/contracts/relay";
5import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
6
7const MAX_SUMMARY_TEXT_LENGTH = 120;
8const MAX_STATUS_TEXT_LENGTH = 40;
9const MAX_DEEP_LINK_LENGTH = 512;
10const MAX_ACTIVITY_ROWS = 3;
11
12function 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
20function 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
28export 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
41export 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
54export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
4import * as Redacted from "effect/Redacted";
5
6import {
7 makeApnsDeliveryJobPayload,
8 signApnsDeliveryJob,
9 verifySignedApnsDeliveryJob,
10} from "./apnsDeliveryJobs.ts";
11
12const secret = Redacted.make("queue-signing-secret");
13const 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
33const notification = {
34 title: "Thread",
35 body: "Input: Project",
36 environmentId: "env",
37 threadId: "thread",
38 deepLink: "/threads/env/thread",
39};
40
41describe("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 @@
1import * as NodeCrypto from "node:crypto";
2
3import { RelayAgentActivityAggregateState, type RelayDeliveryKind } from "@t3tools/contracts/relay";
4import { stableStringify } from "@t3tools/shared/relaySigning";
5import * as Data from "effect/Data";
6import * as DateTime from "effect/DateTime";
7import * as Option from "effect/Option";
8import * as Redacted from "effect/Redacted";
9import * as Schema from "effect/Schema";
10
11const MAX_JOB_AGE_MS = 10 * 60 * 1_000;
12export const APNS_DELIVERY_JOB_SIGNING_ALGORITHM = "hmac-sha256";
13
14const ApnsDeliveryKind = Schema.Literals([
15 "live_activity_start",
16 "live_activity_update",
17 "live_activity_end",
18 "push_notification",
19]);
20
21export 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});
28export type ApnsNotificationPayload = typeof ApnsNotificationPayload.Type;
29
30export 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});
44export type ApnsDeliveryJobPayload = typeof ApnsDeliveryJobPayload.Type;
45
46export const SignedApnsDeliveryJob = Schema.Struct({
47 algorithm: Schema.Literal(APNS_DELIVERY_JOB_SIGNING_ALGORITHM),
48 payload: ApnsDeliveryJobPayload,
49 signature: Schema.String,
50});
51export type SignedApnsDeliveryJob = typeof SignedApnsDeliveryJob.Type;
52
53export class ApnsDeliveryJobInvalid extends Data.TaggedError("ApnsDeliveryJobInvalid")<{
54 readonly message: string;
55}> {}
56
57export class ApnsDeliveryJobExpired extends Data.TaggedError("ApnsDeliveryJobExpired")<{
58 readonly expiresAt: string;
59}> {}
60
61export type ApnsDeliveryJobVerificationError = ApnsDeliveryJobInvalid | ApnsDeliveryJobExpired;
62
63export 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
90export function expiresAtForJob(createdAtMs: number): string {
91 return DateTime.formatIso(Option.getOrThrow(DateTime.make(createdAtMs + MAX_JOB_AGE_MS)));
92}
93
94function 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
131function 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
140function 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
149export 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
160export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6
7import { RelayDb, type RelayDatabase } from "../db.ts";
8import { relayDpopProofs } from "../persistence/schema.ts";
9import * as DpopProofs from "./DpopProofs.ts";
10
11describe("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 @@
1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as HttpApiError from "effect/unstable/httpapi/HttpApiError";
7import { lt } from "drizzle-orm";
8
9import { verifyDpopProof } from "@t3tools/shared/dpop";
10import { RelayDb } from "../db.ts";
11import { relayDpopProofs } from "../persistence/schema.ts";
12
13export class DpopProofReplayPersistenceError extends Data.TaggedError(
14 "DpopProofReplayPersistenceError",
15)<{
16 readonly cause: unknown;
17}> {}
18
19export 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
39export class DpopProofReplay extends Context.Service<DpopProofReplay, DpopProofReplayShape>()(
40 "t3code-relay/auth/DpopProofs/DpopProofReplay",
41) {}
42
43const 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
128export const layer = Layer.effect(DpopProofReplay, make);
infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts created+217
...@@ -0,0 +1,217 @@
1import * as NodeCrypto from "node:crypto";
2
3import { describe, expect, it } from "@effect/vitest";
4import {
5 computeDpopAccessTokenHash,
6 computeDpopJwkThumbprint,
7 type DpopPublicJwk,
8} from "@t3tools/shared/dpop";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12
13import { RelayDb, type RelayDatabase } from "../db.ts";
14import { relayDpopProofs } from "../persistence/schema.ts";
15import * as DpopProofs from "./DpopProofs.ts";
16
17interface DpopProofInsertValues {
18 readonly thumbprint: string;
19 readonly jti: string;
20 readonly iat: number;
21 readonly expiresAt: string;
22 readonly createdAt: string;
23}
24
25function 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
62function 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
85function 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
98describe("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 @@
1import * as NodeCrypto from "node:crypto";
2
3import { describe, expect, it } from "@effect/vitest";
4import { signRelayJwt } from "@t3tools/shared/relayJwt";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8
9import * as RelayConfiguration from "../Config.ts";
10import * as RelayTokens from "./RelayTokens.ts";
11
12const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
13 privateKeyEncoding: { format: "pem", type: "pkcs8" },
14 publicKeyEncoding: { format: "pem", type: "spki" },
15});
16
17const 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
36const layer = RelayTokens.layer.pipe(
37 Layer.provide(Layer.succeed(RelayConfiguration.RelayConfiguration, config)),
38);
39
40describe("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 @@
1import {
2 RelayDpopAccessTokenScope,
3 RelayEnvironmentConnectScope,
4 RelayEnvironmentStatusScope,
5 RelayMobileClientId,
6 RelayMobileRegistrationScope,
7 RelayWebClientId,
8 type RelayPublicClientId,
9 type RelayEnvironmentLinkChallengeRequest,
10} from "@t3tools/contracts/relay";
11import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
12import {
13 normalizeRelayIssuer,
14 signRelayJwt,
15 verifyRelayJwt,
16 type RelayJwtError,
17} from "@t3tools/shared/relayJwt";
18import * as Context from "effect/Context";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as Redacted from "effect/Redacted";
22import * as Schema from "effect/Schema";
23
24import * as RelayConfiguration from "../Config.ts";
25
26const LINK_CHALLENGE_TYP = "t3-link-challenge+jwt";
27const ACCESS_TOKEN_TYP = "t3-relay-dpop-access+jwt";
28const LINK_CHALLENGE_KIND = "environment_link_challenge";
29
30const 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});
42export type LinkChallengeClaims = typeof LinkChallengeClaims.Type;
43
44const 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});
55export type RelayDpopAccessTokenClaims = Omit<typeof RelayDpopAccessTokenClaims.Type, "scope"> & {
56 readonly scope: ReadonlyArray<RelayDpopAccessTokenScope>;
57};
58
59const decodeLinkChallengeClaims = Schema.decodeUnknownEffect(LinkChallengeClaims);
60const decodeDpopAccessTokenClaims = Schema.decodeUnknownEffect(RelayDpopAccessTokenClaims);
61
62const 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
74function 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
84export 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
114export class RelayTokens extends Context.Service<RelayTokens, RelayTokensShape>()(
115 "t3code-relay/auth/RelayTokens",
116) {}
117
118const 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
220export const layer = Layer.effect(RelayTokens, make);
infra/relay/src/db.ts created+68
...@@ -0,0 +1,68 @@
1import type { PgClient } from "@effect/sql-pg/PgClient";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Drizzle from "alchemy/Drizzle";
4import * as Planetscale from "alchemy/Planetscale";
5import * as Alchemy from "alchemy";
6import * as RemovalPolicy from "alchemy/RemovalPolicy";
7import type { EffectPgDatabase } from "drizzle-orm/effect-postgres";
8import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";
10
11import { relayDatabaseMode } from "./dbConfig.ts";
12
13export interface RelayDatabase extends EffectPgDatabase {
14 readonly $client: PgClient;
15}
16
17export class RelayDb extends Context.Service<RelayDb, RelayDatabase>()("t3code-relay/db/RelayDb") {}
18
19export 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
59export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { relayDatabaseMode } from "./dbConfig.ts";
4
5describe("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 @@
1export type RelayDatabaseMode = "shared-database" | "stage-branch";
2
3export 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 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 managedEndpointDigestInput,
5 managedEndpointForHostname,
6 managedEndpointHostname,
7 isManagedEndpointHostname,
8 managedEndpointTunnelName,
9 relayOwnsManagedEndpointZone,
10 relayPublicDomainForStage,
11 relayResourceNameForStage,
12 relayStageSlug,
13} from "./deploymentConfig.ts";
14
15describe("relayStageSlug", () => {
16 it("matches Alchemy physical-name sanitization for default developer stages", () => {
17 expect(relayStageSlug("dev_julius")).toBe("dev-julius");
18 });
19});
20
21describe("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
33describe("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
40describe("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
51describe("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 @@
1import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";
2
3const DNS_LABEL_MAX_LENGTH = 63;
4const MANAGED_ENDPOINT_HASH_LENGTH = 16;
5const MANAGED_ENDPOINT_TUNNEL_PREFIX = "t3coderelay-managedendpoint";
6export const MANAGED_ENDPOINT_ZONE_OWNER_STAGE = "prod";
7
8function normalizeZoneName(zoneName: string): string {
9 return zoneName
10 .trim()
11 .toLowerCase()
12 .replace(/^\.+|\.+$/g, "");
13}
14
15function 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
30function stableSuffix(hash: string): string {
31 return hash.toLowerCase().slice(0, MANAGED_ENDPOINT_HASH_LENGTH);
32}
33
34function 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 */
45export function relayStageSlug(stage: string): string {
46 return stage
47 .toLowerCase()
48 .replaceAll(/[^a-z0-9-]/g, "-")
49 .replaceAll(/-+/g, "-")
50 .replace(/^-+|-+$/g, "");
51}
52
53export function relayResourceNameForStage(name: string, stage: string): string {
54 return `${name}-${relayStageSlug(stage)}`;
55}
56
57export function relayOwnsManagedEndpointZone(stage: string): boolean {
58 return stage === MANAGED_ENDPOINT_ZONE_OWNER_STAGE;
59}
60
61export 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
70export function managedEndpointDigestInput(
71 stage: string,
72 userId: string,
73 environmentId: string,
74): string {
75 return `${stage}:${userId}:${environmentId}`;
76}
77
78export function managedEndpointHostname(stage: string, baseDomain: string, hash: string): string {
79 const label = appendDnsSafeSuffix(relayStageSlug(stage), stableSuffix(hash));
80 return `${label}.${normalizeZoneName(baseDomain)}`;
81}
82
83export 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
94export function managedEndpointForHostname(hostname: string): RelayManagedEndpoint {
95 return {
96 httpBaseUrl: `https://${hostname}/`,
97 wsBaseUrl: `wss://${hostname}/ws`,
98 providerKind: "cloudflare_tunnel",
99 };
100}
101
102export 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 @@
1import * as NodeCrypto from "node:crypto";
2import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
3
4import {
5 RelayCloudEnvironmentHealthRequest,
6 RelayCloudMintCredentialRequest,
7 RelayCloudEnvironmentHealthProofPayload,
8 RelayCloudMintCredentialProofPayload,
9 RelayEnvironmentHealthResponse,
10 RelayEnvironmentHealthResponseProofPayload,
11 RelayEnvironmentMintResponse,
12 RelayEnvironmentMintResponseProofPayload,
13} from "@t3tools/contracts/relay";
14import { describe, expect, it } from "@effect/vitest";
15import * as DateTime from "effect/DateTime";
16import { RELAY_HEALTH_RESPONSE_TYP, RELAY_MINT_RESPONSE_TYP } from "@t3tools/shared/relayJwt";
17import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";
19import * as Fiber from "effect/Fiber";
20import * as Layer from "effect/Layer";
21import * as Redacted from "effect/Redacted";
22import * as Result from "effect/Result";
23import * as Schema from "effect/Schema";
24import * as TestClock from "effect/testing/TestClock";
25import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
26
27import * as EnvironmentLinks from "./EnvironmentLinks.ts";
28import * as RelayConfiguration from "../Config.ts";
29import * as EnvironmentConnector from "./EnvironmentConnector.ts";
30import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
31
32const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
33 privateKeyEncoding: { format: "pem", type: "pkcs8" },
34 publicKeyEncoding: { format: "pem", type: "spki" },
35});
36
37const environmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
38 privateKeyEncoding: { format: "pem", type: "pkcs8" },
39 publicKeyEncoding: { format: "pem", type: "spki" },
40});
41
42const otherEnvironmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
43 privateKeyEncoding: { format: "pem", type: "pkcs8" },
44 publicKeyEncoding: { format: "pem", type: "spki" },
45});
46
47const decodeHealthRequestBody = Schema.decodeUnknownSync(
48 Schema.fromJsonString(RelayCloudEnvironmentHealthRequest),
49);
50const decodeMintRequestBody = Schema.decodeUnknownSync(
51 Schema.fromJsonString(RelayCloudMintCredentialRequest),
52);
53
54function requestBodyText(request: HttpClientRequest.HttpClientRequest): string {
55 return request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "{}";
56}
57
58const 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
77function 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
84function 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
90function 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
116function 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
153function 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
176function 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
197function 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
223describe("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 @@
1import {
2 EnvironmentHttpBadRequestError,
3 EnvironmentHttpConflictError,
4 EnvironmentHttpForbiddenError,
5 EnvironmentHttpInternalServerError,
6 EnvironmentHttpUnauthorizedError,
7} from "@t3tools/contracts";
8import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime";
9import {
10 RelayCloudEnvironmentHealthProofPayload,
11 RelayEnvironmentHealthResponse,
12 RelayEnvironmentHealthResponseProofPayload,
13 RelayEnvironmentMintResponse,
14 RelayEnvironmentMintResponseProofPayload,
15 RelayCloudMintCredentialProofPayload,
16 type RelayEnvironmentConnectResponse,
17 type RelayEnvironmentStatusResponse,
18} from "@t3tools/contracts/relay";
19import {
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";
28import { stableStringify } from "@t3tools/shared/relaySigning";
29import * as Context from "effect/Context";
30import * as Crypto from "effect/Crypto";
31import * as Data from "effect/Data";
32import * as DateTime from "effect/DateTime";
33import * as Duration from "effect/Duration";
34import * as Effect from "effect/Effect";
35import * as Layer from "effect/Layer";
36import * as Option from "effect/Option";
37import * as Redacted from "effect/Redacted";
38import * as Schema from "effect/Schema";
39import { FetchHttpClient, HttpClient } from "effect/unstable/http";
40
41import * as EnvironmentLinks from "./EnvironmentLinks.ts";
42import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
43import * as RelayConfiguration from "../Config.ts";
44
45export class EnvironmentConnectNotAuthorized extends Data.TaggedError(
46 "EnvironmentConnectNotAuthorized",
47)<{
48 readonly environmentId: string;
49}> {}
50
51export class EnvironmentMintRequestFailed extends Data.TaggedError("EnvironmentMintRequestFailed")<{
52 readonly cause: unknown;
53}> {}
54
55export class EnvironmentMintRequestTimedOut extends Data.TaggedError(
56 "EnvironmentMintRequestTimedOut",
57)<{
58 readonly environmentId: string;
59 readonly timeoutMs: number;
60}> {}
61
62export class EnvironmentMintResponseInvalid extends Data.TaggedError(
63 "EnvironmentMintResponseInvalid",
64)<{
65 readonly environmentId: string;
66}> {}
67
68export type EnvironmentConnectorError =
69 | EnvironmentConnectNotAuthorized
70 | EnvironmentMintRequestFailed
71 | EnvironmentMintRequestTimedOut
72 | EnvironmentMintResponseInvalid
73 | EnvironmentLinks.EnvironmentLinkLookupPersistenceError
74 | ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError;
75
76export const ENVIRONMENT_MINT_REQUEST_TIMEOUT_MS = 10_000;
77const ENVIRONMENT_HEALTH_CLOCK_SKEW_MILLIS = 60 * 1_000;
78
79export 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
92export class EnvironmentConnector extends Context.Service<
93 EnvironmentConnector,
94 EnvironmentConnectorShape
95>()("t3code-relay/environments/EnvironmentConnector") {}
96
97const decodeMintResponseProof = Schema.decodeUnknownEffect(
98 RelayEnvironmentMintResponseProofPayload,
99);
100const decodeHealthResponseProof = Schema.decodeUnknownEffect(
101 RelayEnvironmentHealthResponseProofPayload,
102);
103const isEnvironmentHealthError = Schema.is(
104 Schema.Union([
105 EnvironmentHttpBadRequestError,
106 EnvironmentHttpUnauthorizedError,
107 EnvironmentHttpForbiddenError,
108 EnvironmentHttpConflictError,
109 EnvironmentHttpInternalServerError,
110 ]),
111);
112
113function environmentHealthRequestFailureMessage(cause: unknown): string {
114 return isEnvironmentHealthError(cause)
115 ? `Managed endpoint health request failed: ${cause.message}`
116 : "Managed endpoint health request failed.";
117}
118
119const withoutRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
120 effect.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }));
121
122const 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
145function 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
178function 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
222const 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
430export const layer = Layer.effect(EnvironmentConnector, make);
infra/relay/src/environments/EnvironmentCredentials.test.ts created+158
...@@ -0,0 +1,158 @@
1import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
2import { describe, expect, it } from "@effect/vitest";
3import { PgDialect, QueryBuilder } from "drizzle-orm/pg-core";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6
7import { RelayDb, type RelayDatabase } from "../db.ts";
8import { relayEnvironmentCredentials } from "../persistence/schema.ts";
9import * as EnvironmentCredentials from "./EnvironmentCredentials.ts";
10
11describe("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 @@
1import * as Context from "effect/Context";
2import * as Crypto from "effect/Crypto";
3import * as Data from "effect/Data";
4import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import { and, eq, isNull, ne, notExists } from "drizzle-orm";
10
11import { RelayDb } from "../db.ts";
12import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts";
13
14export class EnvironmentCredentialCreatePersistenceError extends Data.TaggedError(
15 "EnvironmentCredentialCreatePersistenceError",
16)<{
17 readonly cause: unknown;
18}> {}
19
20export class EnvironmentCredentialAuthenticatePersistenceError extends Data.TaggedError(
21 "EnvironmentCredentialAuthenticatePersistenceError",
22)<{
23 readonly cause: unknown;
24}> {}
25
26export class EnvironmentCredentialRevokePersistenceError extends Data.TaggedError(
27 "EnvironmentCredentialRevokePersistenceError",
28)<{
29 readonly cause: unknown;
30}> {}
31
32export interface EnvironmentCredentialPrincipal {
33 readonly credentialId: string;
34 readonly environmentId: string;
35 readonly environmentPublicKey: string;
36}
37
38export 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
55export class EnvironmentCredentials extends Context.Service<
56 EnvironmentCredentials,
57 EnvironmentCredentialsShape
58>()("t3code-relay/environments/EnvironmentCredentials") {}
59
60const 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
188export const layer = Layer.effect(EnvironmentCredentials, make);
infra/relay/src/environments/EnvironmentLinker.test.ts created+206
...@@ -0,0 +1,206 @@
1import * as NodeCrypto from "node:crypto";
2import type {
3 RelayEnvironmentLinkProofPayload,
4 RelayEnvironmentLinkRequest,
5} from "@t3tools/contracts/relay";
6import { RELAY_LINK_PROOF_TYP } from "@t3tools/shared/relayJwt";
7import { describe, expect, it } from "@effect/vitest";
8import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Redacted from "effect/Redacted";
12import * as Result from "effect/Result";
13
14import * as DpopProofs from "../auth/DpopProofs.ts";
15import * as RelayTokens from "../auth/RelayTokens.ts";
16import * as EnvironmentCredentials from "./EnvironmentCredentials.ts";
17import * as EnvironmentLinks from "./EnvironmentLinks.ts";
18import * as RelayConfiguration from "../Config.ts";
19import * as EnvironmentLinker from "./EnvironmentLinker.ts";
20import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts";
21
22const relayKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
23 privateKeyEncoding: { format: "pem", type: "pkcs8" },
24 publicKeyEncoding: { format: "pem", type: "spki" },
25});
26const environmentKeyPair = NodeCrypto.generateKeyPairSync("ed25519", {
27 privateKeyEncoding: { format: "pem", type: "pkcs8" },
28 publicKeyEncoding: { format: "pem", type: "spki" },
29});
30const 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
49function 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
56const 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
107function 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
152describe("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 @@
1import {
2 RelayEnvironmentLinkProofPayload,
3 type RelayEnvironmentLinkProofInvalidReason,
4 type RelayEnvironmentLinkRequest,
5} from "@t3tools/contracts/relay";
6import {
7 decodeRelayJwt,
8 normalizeRelayIssuer,
9 RELAY_LINK_PROOF_TYP,
10 verifyRelayJwt,
11} from "@t3tools/shared/relayJwt";
12import * as Context from "effect/Context";
13import * as Data from "effect/Data";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Schema from "effect/Schema";
18
19import * as DpopProofs from "../auth/DpopProofs.ts";
20import * as RelayTokens from "../auth/RelayTokens.ts";
21import * as EnvironmentCredentials from "./EnvironmentCredentials.ts";
22import * as EnvironmentLinks from "./EnvironmentLinks.ts";
23import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts";
24import * as RelayConfiguration from "../Config.ts";
25
26export class EnvironmentLinkProofExpired extends Data.TaggedError("EnvironmentLinkProofExpired")<{
27 readonly expiresAt: string;
28}> {}
29
30export class EnvironmentLinkProofInvalid extends Data.TaggedError("EnvironmentLinkProofInvalid")<{
31 readonly environmentId: string;
32 readonly reason: RelayEnvironmentLinkProofInvalidReason;
33}> {}
34
35export type EnvironmentLinkError =
36 | EnvironmentLinkProofExpired
37 | EnvironmentLinkProofInvalid
38 | DpopProofs.DpopProofReplayPersistenceError
39 | EnvironmentLinks.EnvironmentLinkUpsertPersistenceError
40 | EnvironmentCredentials.EnvironmentCredentialCreatePersistenceError
41 | ManagedEndpointProvider.ManagedEndpointProviderError;
42
43export 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
60export class EnvironmentLinker extends Context.Service<EnvironmentLinker, EnvironmentLinkerShape>()(
61 "t3code-relay/environments/EnvironmentLinker",
62) {}
63
64const decodeProof = Schema.decodeUnknownEffect(RelayEnvironmentLinkProofPayload);
65
66function 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
80function 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
90function normalizeHostname(hostname: string): string {
91 return hostname
92 .trim()
93 .toLowerCase()
94 .replace(/^\[(.*)\]$/u, "$1");
95}
96
97function 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
109const 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
264export const layer = Layer.effect(EnvironmentLinker, make);
infra/relay/src/environments/EnvironmentLinks.test.ts created+91
...@@ -0,0 +1,91 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { PgDialect } from "drizzle-orm/pg-core";
5
6import { RelayDb, type RelayDatabase } from "../db.ts";
7import { relayEnvironmentLinks } from "../persistence/schema.ts";
8import { EnvironmentLinks, layer } from "./EnvironmentLinks.ts";
9
10describe("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 @@
1import type {
2 RelayClientEnvironmentRecord,
3 RelayEnvironmentLinkProofPayload,
4 RelayEnvironmentLinkRequest,
5 RelayManagedEndpoint,
6} from "@t3tools/contracts/relay";
7import * as Context from "effect/Context";
8import * as Data from "effect/Data";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import { and, eq, isNull, or } from "drizzle-orm";
13
14import { RelayDb } from "../db.ts";
15import { relayEnvironmentLinks } from "../persistence/schema.ts";
16
17export interface RelayLinkedEnvironmentRecord extends RelayClientEnvironmentRecord {
18 readonly environmentPublicKey: string;
19}
20
21export interface AgentAwarenessDeliveryUserRecord {
22 readonly userId: string;
23 readonly notificationsEnabled: boolean;
24 readonly liveActivitiesEnabled: boolean;
25}
26
27export class EnvironmentLinkUpsertPersistenceError extends Data.TaggedError(
28 "EnvironmentLinkUpsertPersistenceError",
29)<{
30 readonly cause: unknown;
31}> {}
32
33export class EnvironmentLinkUserListPersistenceError extends Data.TaggedError(
34 "EnvironmentLinkUserListPersistenceError",
35)<{
36 readonly cause: unknown;
37}> {}
38
39export class EnvironmentPublicKeyListPersistenceError extends Data.TaggedError(
40 "EnvironmentPublicKeyListPersistenceError",
41)<{
42 readonly cause: unknown;
43}> {}
44
45export class EnvironmentLinkListPersistenceError extends Data.TaggedError(
46 "EnvironmentLinkListPersistenceError",
47)<{
48 readonly cause: unknown;
49}> {}
50
51export class EnvironmentLinkLookupPersistenceError extends Data.TaggedError(
52 "EnvironmentLinkLookupPersistenceError",
53)<{
54 readonly cause: unknown;
55}> {}
56
57export class EnvironmentLinkRevokePersistenceError extends Data.TaggedError(
58 "EnvironmentLinkRevokePersistenceError",
59)<{
60 readonly cause: unknown;
61}> {}
62
63export 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
99export class EnvironmentLinks extends Context.Service<EnvironmentLinks, EnvironmentLinksShape>()(
100 "t3code-relay/environments/EnvironmentLinks",
101) {}
102
103function 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
114function 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
124const 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
345export const layer = Layer.effect(EnvironmentLinks, make);
infra/relay/src/environments/EnvironmentPublishSignatures.test.ts created+166
...@@ -0,0 +1,166 @@
1import * as NodeCrypto from "node:crypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import type {
4 RelayAgentActivityPublishProofPayload,
5 RelayAgentActivityPublishRequest,
6 RelayAgentActivityState,
7} from "@t3tools/contracts/relay";
8import { RELAY_ACTIVITY_PUBLISH_TYP } from "@t3tools/shared/relayJwt";
9import { stableStringify } from "@t3tools/shared/relaySigning";
10import { describe, expect, it } from "@effect/vitest";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Redacted from "effect/Redacted";
15import * as Result from "effect/Result";
16
17import * as DpopProofs from "../auth/DpopProofs.ts";
18import * as RelayConfiguration from "../Config.ts";
19import * as EnvironmentPublishSignatures from "./EnvironmentPublishSignatures.ts";
20
21const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
22 privateKeyEncoding: { format: "pem", type: "pkcs8" },
23 publicKeyEncoding: { format: "pem", type: "spki" },
24});
25const 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});
43const 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
55function 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
64const 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
83function 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
100describe("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 @@
1import {
2 RelayAgentActivityPublishProofPayload,
3 type RelayAgentActivityPublishRequest,
4} from "@t3tools/contracts/relay";
5import {
6 decodeRelayJwt,
7 normalizeRelayIssuer,
8 RELAY_ACTIVITY_PUBLISH_TYP,
9 verifyRelayJwt,
10} from "@t3tools/shared/relayJwt";
11import { stableStringify } from "@t3tools/shared/relaySigning";
12import * as Context from "effect/Context";
13import * as Crypto from "effect/Crypto";
14import * as Data from "effect/Data";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as Encoding from "effect/Encoding";
18import * as Layer from "effect/Layer";
19import * as Schema from "effect/Schema";
20
21import * as DpopProofs from "../auth/DpopProofs.ts";
22import * as RelayConfiguration from "../Config.ts";
23
24export class EnvironmentPublishSignatureExpired extends Data.TaggedError(
25 "EnvironmentPublishSignatureExpired",
26)<{
27 readonly expiresAt: string;
28}> {}
29
30export class EnvironmentPublishSignatureInvalid extends Data.TaggedError(
31 "EnvironmentPublishSignatureInvalid",
32)<{
33 readonly environmentId: string;
34}> {}
35
36export class EnvironmentPublishPublicKeyMissing extends Data.TaggedError(
37 "EnvironmentPublishPublicKeyMissing",
38)<{
39 readonly environmentId: string;
40}> {}
41
42export type EnvironmentPublishSignatureError =
43 | EnvironmentPublishSignatureExpired
44 | EnvironmentPublishSignatureInvalid
45 | EnvironmentPublishPublicKeyMissing
46 | DpopProofs.DpopProofReplayPersistenceError;
47
48export 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
57export class EnvironmentPublishSignatures extends Context.Service<
58 EnvironmentPublishSignatures,
59 EnvironmentPublishSignaturesShape
60>()("t3code-relay/environments/EnvironmentPublishSignatures") {}
61
62const decodeProof = Schema.decodeUnknownEffect(RelayAgentActivityPublishProofPayload);
63
64function 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
76const formatEnvironmentPublishReplayThumbprint = (digest: Uint8Array) =>
77 `env-publish:${Encoding.encodeBase64Url(digest)}`;
78
79const 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
164export const layer = Layer.effect(EnvironmentPublishSignatures, make);
infra/relay/src/environments/ManagedEndpointAllocations.ts created+200
...@@ -0,0 +1,200 @@
1import type { RelayManagedEndpoint } from "@t3tools/contracts/relay";
2import { and, eq } from "drizzle-orm";
3import * as Context from "effect/Context";
4import * as Data from "effect/Data";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8
9import { RelayDb } from "../db.ts";
10import { isManagedEndpointHostname, managedEndpointForHostname } from "../deploymentConfig.ts";
11import { relayManagedEndpointAllocations } from "../persistence/schema.ts";
12
13export 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
23export 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
39export class ManagedEndpointAllocationPersistenceError extends Data.TaggedError(
40 "ManagedEndpointAllocationPersistenceError",
41)<{
42 readonly cause: unknown;
43}> {}
44
45interface ManagedEndpointAllocationKey {
46 readonly userId: string;
47 readonly environmentId: string;
48}
49
50interface ReserveManagedEndpointAllocationInput extends ManagedEndpointAllocationKey {
51 readonly hostname: string;
52 readonly tunnelName: string;
53}
54
55interface RecordManagedEndpointTunnelInput extends ManagedEndpointAllocationKey {
56 readonly tunnelId: string;
57}
58
59interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey {
60 readonly dnsRecordId: string;
61}
62
63export 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
84const 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
94const whereAllocation = (input: ManagedEndpointAllocationKey) =>
95 and(
96 eq(relayManagedEndpointAllocations.userId, input.userId),
97 eq(relayManagedEndpointAllocations.environmentId, input.environmentId),
98 );
99
100const persistenceError = (cause: unknown) =>
101 cause instanceof ManagedEndpointAllocationPersistenceError
102 ? cause
103 : new ManagedEndpointAllocationPersistenceError({ cause });
104
105const 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
195export 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 @@
1import * as NodeCrypto from "node:crypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3
4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8
9import * as RelayConfiguration from "../Config.ts";
10import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
11import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts";
12
13const 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
32interface TunnelCall {
33 readonly operation: "list" | "create" | "putConfiguration" | "getToken" | "delete";
34 readonly input: unknown;
35}
36
37interface DnsCall {
38 readonly operation: "listRecords" | "createRecord" | "updateRecord" | "deleteRecord";
39 readonly input: unknown;
40}
41
42interface AllocationCall {
43 readonly operation: "get" | "reserve" | "recordTunnel" | "recordDns" | "markReady" | "remove";
44 readonly input: unknown;
45}
46
47function allocationKey(input: { readonly userId: string; readonly environmentId: string }) {
48 return `${input.userId}:${input.environmentId}`;
49}
50
51function 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
79function 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
110function 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
145function 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
200function 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
216function 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
224function 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
232describe("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 @@
1import * as Alchemy from "alchemy";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Arr from "effect/Array";
4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Encoding from "effect/Encoding";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Result from "effect/Result";
12
13import type {
14 RelayManagedEndpoint,
15 RelayManagedEndpointOrigin,
16 RelayManagedEndpointRuntimeConfig,
17} from "@t3tools/contracts/relay";
18
19import * as RelayConfiguration from "../Config.ts";
20import {
21 managedEndpointDigestInput,
22 managedEndpointForHostname,
23 managedEndpointHostname,
24 managedEndpointTunnelName,
25} from "../deploymentConfig.ts";
26import { ManagedEndpointAllocations } from "./ManagedEndpointAllocations.ts";
27
28export class ManagedEndpointProvisioningNotConfigured extends Data.TaggedError(
29 "ManagedEndpointProvisioningNotConfigured",
30)<{}> {}
31
32export class ManagedEndpointProvisioningFailed extends Data.TaggedError(
33 "ManagedEndpointProvisioningFailed",
34)<{
35 readonly cause: unknown;
36}> {}
37
38export class ManagedEndpointDeprovisioningFailed extends Data.TaggedError(
39 "ManagedEndpointDeprovisioningFailed",
40)<{
41 readonly cause: unknown;
42}> {}
43
44export class ManagedEndpointOriginNotAllowed extends Data.TaggedError(
45 "ManagedEndpointOriginNotAllowed",
46)<{
47 readonly host: string;
48 readonly port: number;
49}> {}
50
51export type ManagedEndpointProviderError =
52 | ManagedEndpointProvisioningNotConfigured
53 | ManagedEndpointProvisioningFailed
54 | ManagedEndpointOriginNotAllowed;
55
56export interface ManagedEndpointProvisioningResult {
57 readonly endpoint: RelayManagedEndpoint;
58 readonly runtime: RelayManagedEndpointRuntimeConfig;
59}
60
61export 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
73export class ManagedEndpointProvider extends Context.Service<
74 ManagedEndpointProvider,
75 ManagedEndpointProviderShape
76>()("t3code-relay/environments/ManagedEndpointProvider") {}
77
78interface ManagedEndpointTunnel {
79 readonly id?: string | null;
80 readonly name?: string | null;
81}
82
83export class ManagedEndpointTunnelClientError extends Data.TaggedError(
84 "ManagedEndpointTunnelClientError",
85)<{
86 readonly cause: unknown;
87}> {}
88
89export 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
114export class ManagedEndpointTunnelClient extends Context.Service<
115 ManagedEndpointTunnelClient,
116 ManagedEndpointTunnelClientShape
117>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointTunnelClient") {}
118
119interface ManagedEndpointCnameRecordInput {
120 readonly type: "CNAME";
121 readonly name: string;
122 readonly content: string;
123 readonly ttl: 1;
124 readonly proxied: true;
125}
126
127export class ManagedEndpointDnsClientError extends Data.TaggedError(
128 "ManagedEndpointDnsClientError",
129)<{
130 readonly cause: unknown;
131}> {}
132
133export 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
149export class ManagedEndpointDnsClient extends Context.Service<
150 ManagedEndpointDnsClient,
151 ManagedEndpointDnsClientShape
152>()("t3code-relay/environments/ManagedEndpointProvider/ManagedEndpointDnsClient") {}
153
154const 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
166function 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
173function normalizeHostname(hostname: string): string {
174 return hostname
175 .trim()
176 .toLowerCase()
177 .replace(/\.$/u, "")
178 .replace(/^\[(.*)\]$/u, "$1");
179}
180
181function 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
191function 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
204const 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
210const 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
423export const layer = Layer.effect(ManagedEndpointProvider, make);
424
425export 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 @@
1import { createClerkClient, verifyToken } from "@clerk/backend";
2import { describe, expect, it } from "@effect/vitest";
3import { vi } from "vitest";
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Predicate from "effect/Predicate";
9import * as Redacted from "effect/Redacted";
10import * as Tracer from "effect/Tracer";
11import * as HttpRouter from "effect/unstable/http/HttpRouter";
12import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
13import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
14import { RelayEnvironmentAuth } from "@t3tools/contracts/relay";
15
16import {
17 relayCors,
18 relayDocsRedirectRoute,
19 relayEnvironmentAuthLayer,
20 relayNotFoundRoute,
21 traceRelayHttpRequestWith,
22 verifyRelayClientBearerToken,
23 withoutCapturedParentSpan,
24} from "./Api.ts";
25import * as RelayConfiguration from "../Config.ts";
26import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
27
28vi.mock("@clerk/backend", () => ({
29 createClerkClient: vi.fn(),
30 verifyToken: vi.fn(),
31}));
32
33const 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
52describe("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
108describe("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
153describe("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
201describe("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 @@
1import { createClerkClient, verifyToken } from "@clerk/backend";
2import { sql as drizzleSql } from "drizzle-orm";
3import * as Data from "effect/Data";
4import * as Crypto from "effect/Crypto";
5import * as Context from "effect/Context";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Record from "effect/Record";
10import * as Redacted from "effect/Redacted";
11import * as Schema from "effect/Schema";
12import * as Tracer from "effect/Tracer";
13import * as HttpEffect from "effect/unstable/http/HttpEffect";
14import * as HttpMiddleware from "effect/unstable/http/HttpMiddleware";
15import * as HttpRouter from "effect/unstable/http/HttpRouter";
16import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
17import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
18import * as HttpTraceContext from "effect/unstable/http/HttpTraceContext";
19import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
20import * as HttpApiError from "effect/unstable/httpapi/HttpApiError";
21import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
22
23import {
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";
49import { normalizeRelayIssuer } from "@t3tools/shared/relayJwt";
50
51import * as DeliveryAttempts from "../agentActivity/DeliveryAttempts.ts";
52import * as AgentActivityRows from "../agentActivity/AgentActivityRows.ts";
53import * as Devices from "../agentActivity/Devices.ts";
54import * as DpopProofs from "../auth/DpopProofs.ts";
55import * as RelayTokens from "../auth/RelayTokens.ts";
56import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
57import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
58import * as LiveActivities from "../agentActivity/LiveActivities.ts";
59import * as RelayConfiguration from "../Config.ts";
60import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts";
61import * as EnvironmentConnector from "../environments/EnvironmentConnector.ts";
62import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts";
63import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts";
64import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts";
65import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts";
66import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts";
67import { withSpanAttributes } from "../observability.ts";
68import { RelayDb } from "../db.ts";
69
70const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const;
71const relayCorsAllowedHeaders = [
72 "authorization",
73 "b3",
74 "traceparent",
75 "content-type",
76 "dpop",
77] as const;
78const relayCorsExposedHeaders = [
79 "traceparent",
80 "x-t3-relay-auth-failure",
81 "www-authenticate",
82] as const;
83
84const relayCorsHeaders = {
85 "access-control-allow-origin": "*",
86 "access-control-expose-headers": relayCorsExposedHeaders.join(","),
87} as const;
88
89const 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
96const 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
106const appendRelayDpopChallengeHeader = HttpEffect.appendPreResponseHandler((_request, response) =>
107 Effect.succeed(
108 response.status === 401
109 ? HttpServerResponse.setHeader(response, "www-authenticate", "DPoP")
110 : response,
111 ),
112);
113
114const 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
128export 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
149export const relayNotFoundRoute = HttpRouter.add(
150 "*",
151 "/*",
152 HttpServerResponse.empty({ status: 404 }),
153);
154
155export const relayDocsRedirectRoute = HttpRouter.add(
156 "GET",
157 "/",
158 HttpServerResponse.redirect("/docs"),
159);
160
161export 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
173export 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
182export 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
192export 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
231export 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
265export 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
303function isDpopAuthorizationHeader(value: string | undefined): boolean {
304 return /^DPoP +/iu.test(value ?? "");
305}
306
307function 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
312export 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
346export 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
369export 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
415export 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
570export 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
632export 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
733export 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
816class ClerkTokenVerificationFailed extends Data.TaggedError("ClerkTokenVerificationFailed")<{
817 readonly cause: unknown;
818}> {}
819
820const isHttpUnauthorized = Schema.is(HttpApiError.Unauthorized);
821
822const currentTraceId = Effect.currentParentSpan.pipe(
823 Effect.map((span) => span.traceId),
824 Effect.orElseSucceed(() => "unavailable"),
825);
826
827const 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;
848type RelayCommonPersistenceError = InstanceType<(typeof COMMON_AUTH_INVALID_REASONS)[number]>;
849
850type 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
855function isRelayCommonPersistenceError(error: unknown): error is RelayCommonPersistenceError {
856 return COMMON_AUTH_INVALID_REASONS.some((ErrorType) => error instanceof ErrorType);
857}
858
859function 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
867function 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
897type TaggedErrorTag<E> = Extract<E, { readonly _tag: string }>["_tag"];
898
899type MapErrorTagCases<E> = {
900 readonly [K in TaggedErrorTag<E>]+?: (
901 error: Extract<E, { readonly _tag: K }>,
902 traceId: string,
903 ) => unknown;
904};
905
906type MappedTagError<Cases> = Cases[keyof Cases] extends (
907 ...args: ReadonlyArray<never>
908) => infer Error
909 ? Error
910 : never;
911
912type 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
918function 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
943function 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
958function safeAuthFailureReason(value: string): string {
959 return /^[a-z0-9._-]+$/i.test(value) ? value : "unknown";
960}
961
962function 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
982function 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
989function 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
1004function 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
1030export 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
1048const 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
1059const 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
1082const 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
1101const 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 @@
1import * as Alchemy from "alchemy";
2import * as Axiom from "alchemy/Axiom";
3import * as Output from "alchemy/Output";
4import * as Layer from "effect/Layer";
5import * as Effect from "effect/Effect";
6import * as Redacted from "effect/Redacted";
7import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
8
9import { relayResourceNameForStage } from "./deploymentConfig.ts";
10
11const 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
22export 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
50export 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
57export 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 @@
1import type {
2 RelayAgentActivityAggregateState,
3 RelayAgentActivityState,
4 RelayAgentAwarenessPreferences,
5} from "@t3tools/contracts/relay";
6import {
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
18export 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
41export 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
62export 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
86export 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
106export 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
128export 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
144export 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
171export 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 @@
1import * as Cloudflare from "alchemy/Cloudflare";
2
3export const RelayApnsDeliveryDeadLetterQueue = Cloudflare.Queue(
4 "RelayApnsDeliveryDeadLetterQueue",
5);
6
7export const RelayApnsDeliveryQueue = Cloudflare.Queue("RelayApnsDeliveryQueue");
infra/relay/src/worker.ts created+283
...@@ -0,0 +1,283 @@
1import * as Alchemy from "alchemy";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Drizzle from "alchemy/Drizzle";
4import * as Config from "effect/Config";
5import * as Crypto from "effect/Crypto";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Stream from "effect/Stream";
9import * as Etag from "effect/unstable/http/Etag";
10import * as HttpPlatform from "effect/unstable/http/HttpPlatform";
11import * as HttpRouter from "effect/unstable/http/HttpRouter";
12import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
13import * as HttpApiScalar from "effect/unstable/httpapi/HttpApiScalar";
14
15import { RelayApi } from "@t3tools/contracts/relay";
16
17import {
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";
34import { ManagedEndpointZone, RelayApiZone, RelayDeploymentConfig } from "./zone.ts";
35import { makeRelayTraceLayer, RelayObservability } from "./observability.ts";
36import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts";
37import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts";
38import * as Devices from "./agentActivity/Devices.ts";
39import * as DpopProofs from "./auth/DpopProofs.ts";
40import * as RelayTokens from "./auth/RelayTokens.ts";
41import * as EnvironmentCredentials from "./environments/EnvironmentCredentials.ts";
42import * as EnvironmentLinks from "./environments/EnvironmentLinks.ts";
43import * as ManagedEndpointAllocations from "./environments/ManagedEndpointAllocations.ts";
44import * as LiveActivities from "./agentActivity/LiveActivities.ts";
45import { RelayDb, RelayHyperdrive } from "./db.ts";
46import { RelayApnsDeliveryDeadLetterQueue, RelayApnsDeliveryQueue } from "./queues.ts";
47import * as RelayConfiguration from "./Config.ts";
48import * as AgentActivityPublisher from "./agentActivity/AgentActivityPublisher.ts";
49import * as ApnsClient from "./agentActivity/ApnsClient.ts";
50import * as ApnsDeliveryQueue from "./agentActivity/ApnsDeliveryQueue.ts";
51import * as ApnsDeliveries from "./agentActivity/ApnsDeliveries.ts";
52import * as EnvironmentConnector from "./environments/EnvironmentConnector.ts";
53import * as EnvironmentLinker from "./environments/EnvironmentLinker.ts";
54import * as EnvironmentPublishSignatures from "./environments/EnvironmentPublishSignatures.ts";
55import * as ManagedEndpointProvider from "./environments/ManagedEndpointProvider.ts";
56import * as MobileRegistrations from "./agentActivity/MobileRegistrations.ts";
57
58const 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
71const 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
76const relayApiLayer = Layer.mergeAll(
77 healthApi,
78 metadataApi,
79 mobileApi,
80 clientApi,
81 tokenApi,
82 dpopClientApi,
83 serverApi,
84);
85
86const CloudMintKeyPair = Alchemy.KeyPair("CloudMintKeyPair");
87const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningSecret", {
88 bytes: 32,
89});
90
91export 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 @@
1import * as Alchemy from "alchemy";
2import { adopt } from "alchemy/AdoptPolicy";
3import * as Cloudflare from "alchemy/Cloudflare";
4import * as Config from "effect/Config";
5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";
7
8import {
9 MANAGED_ENDPOINT_ZONE_OWNER_STAGE,
10 relayOwnsManagedEndpointZone,
11 relayPublicDomainForStage,
12} from "./deploymentConfig.ts";
13
14function 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
22export 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
42export 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
56export 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";
26export * from "./archivedThreadsState.ts";26export * from "./archivedThreadsState.ts";
27export * from "./checkpointDiffState.ts";27export * from "./checkpointDiffState.ts";
28export * from "./remote.ts";28export * from "./remote.ts";
29export * from "./managedRelay.ts";
30export * from "./managedRelayState.ts";
packages/client-runtime/src/managedRelay.test.ts created+185
...@@ -0,0 +1,185 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay";
3import { describe, expect, it } from "@effect/vitest";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as TestClock from "effect/testing/TestClock";
9
10import {
11 MANAGED_RELAY_REQUEST_TIMEOUT_MS,
12 ManagedRelayClient,
13 ManagedRelayDpopSigner,
14 managedRelayClientLayer,
15 type ManagedRelayDpopProofInput,
16} from "./managedRelay.ts";
17import { remoteHttpClientLayer } from "./remote.ts";
18
19function 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
37describe("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 @@
1import {
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";
28import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope";
29import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
30import * as Clock from "effect/Clock";
31import * as Context from "effect/Context";
32import * as Data from "effect/Data";
33import * as Duration from "effect/Duration";
34import * as Effect from "effect/Effect";
35import * as Layer from "effect/Layer";
36import * as Option from "effect/Option";
37import * as SynchronizedRef from "effect/SynchronizedRef";
38import type { HttpMethod } from "effect/unstable/http/HttpMethod";
39import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
40
41export interface ManagedRelayDpopProofInput {
42 readonly method: HttpMethod;
43 readonly url: string;
44 readonly accessToken?: string;
45}
46
47export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{
48 readonly cause: unknown;
49}> {}
50
51export interface ManagedRelayDpopSignerShape {
52 readonly thumbprint: Effect.Effect<string, ManagedRelayDpopSignerError>;
53 readonly createProof: (
54 input: ManagedRelayDpopProofInput,
55 ) => Effect.Effect<string, ManagedRelayDpopSignerError>;
56}
57
58export class ManagedRelayDpopSigner extends Context.Service<
59 ManagedRelayDpopSigner,
60 ManagedRelayDpopSignerShape
61>()("@t3tools/client-runtime/managedRelay/ManagedRelayDpopSigner") {}
62
63export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{
64 readonly message: string;
65 readonly cause?: unknown;
66}> {}
67
68export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000;
69
70interface CachedRelayAccessToken {
71 readonly clerkToken: string;
72 readonly thumbprint: string;
73 readonly scopes: ReadonlyArray<RelayDpopAccessTokenScope>;
74 readonly accessToken: string;
75 readonly expiresAtMillis: number;
76}
77
78export interface ManagedRelayAuthorization {
79 readonly accessToken: string;
80 readonly proof: string;
81 readonly thumbprint: string;
82}
83
84export interface ManagedRelayClientLayerOptions {
85 readonly relayUrl: string;
86 readonly clientId: RelayPublicClientId;
87}
88
89export 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
135export class ManagedRelayClient extends Context.Service<
136 ManagedRelayClient,
137 ManagedRelayClientShape
138>()("@t3tools/client-runtime/managedRelay/ManagedRelayClient") {}
139
140function relayClientError(message: string, cause?: unknown): ManagedRelayClientError {
141 return new ManagedRelayClientError({ message, ...(cause === undefined ? {} : { cause }) });
142}
143
144function 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
159function 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
176function bearerHeaders(clerkToken: string) {
177 return { authorization: `Bearer ${clerkToken}` };
178}
179
180function dpopHeaders(authorization: ManagedRelayAuthorization) {
181 return {
182 authorization: `DPoP ${authorization.accessToken}`,
183 dpop: authorization.proof,
184 };
185}
186
187function 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
206export 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 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import type {
3 RelayClientDeviceRecord,
4 RelayClientEnvironmentRecord,
5 RelayEnvironmentStatusResponse,
6} from "@t3tools/contracts/relay";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import { Atom, AtomRegistry } from "effect/unstable/reactivity";
10import { afterEach, describe, expect, it, vi } from "vitest";
11
12import { ManagedRelayClient, type ManagedRelayClientShape } from "./managedRelay.ts";
13import {
14 createManagedRelayQueryManager,
15 createManagedRelaySession,
16 managedRelaySessionAtom,
17 readManagedRelaySnapshotState,
18 setManagedRelaySession,
19 waitForManagedRelayClerkToken,
20} from "./managedRelayState.ts";
21
22let registry = AtomRegistry.make();
23
24const 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
35const 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
54function resetRegistry() {
55 registry.dispose();
56 registry = AtomRegistry.make();
57}
58
59function 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
85function setSession() {
86 setManagedRelaySession(
87 registry,
88 createManagedRelaySession({
89 accountId: "account-1",
90 readClerkToken: () => Promise.resolve("clerk-token"),
91 }),
92 );
93}
94
95describe("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 @@
1import type {
2 RelayClientEnvironmentRecord,
3 RelayEnvironmentStatusResponse,
4} from "@t3tools/contracts/relay";
5import {
6 RelayEnvironmentConnectScope,
7 RelayEnvironmentStatusScope,
8} from "@t3tools/contracts/relay";
9import * as Cause from "effect/Cause";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
13import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity";
14
15import { ManagedRelayClient } from "./managedRelay.ts";
16
17const DEFAULT_STALE_TIME_MS = 15_000;
18const DEFAULT_IDLE_TTL_MS = 5 * 60_000;
19
20export interface ManagedRelaySession {
21 readonly accountId: string;
22 readonly readClerkToken: () => Effect.Effect<string | null, ManagedRelaySessionError>;
23}
24
25export interface ManagedRelaySnapshotState<A> {
26 readonly data: A | null;
27 readonly error: string | null;
28 readonly isPending: boolean;
29}
30
31export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{
32 readonly message: string;
33 readonly cause?: unknown;
34}> {}
35
36export class ManagedRelaySnapshotError extends Data.TaggedError("ManagedRelaySnapshotError")<{
37 readonly message: string;
38}> {}
39
40export const managedRelaySessionAtom = Atom.make<ManagedRelaySession | null>(null).pipe(
41 Atom.keepAlive,
42 Atom.withLabel("managed-relay:session"),
43);
44
45export 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
63export function setManagedRelaySession(
64 registry: AtomRegistry.AtomRegistry,
65 session: ManagedRelaySession | null,
66): void {
67 registry.set(managedRelaySessionAtom, session);
68}
69
70function 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
86export 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
116function 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
131function statusKey(input: {
132 readonly accountId: string;
133 readonly environment: RelayClientEnvironmentRecord;
134}): string {
135 return JSON.stringify(input);
136}
137
138function 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
148function 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
159function 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
187export 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
202export 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";
8import { EnvironmentAuthInvalidError } from "@t3tools/contracts";8import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
9import {9import {
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 );
125128
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 );
188240
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 });
259311
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 });
267319
...@@ -286,6 +338,45 @@ describe("remote", () => {...@@ -286,6 +338,45 @@ describe("remote", () => {
286 }),338 }),
287 );339 );
288340
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 );
362453
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({
376467 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");
382473 }),
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";
10import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
11import type {10import 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";
17import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
18import * as Data from "effect/Data";18import * as Data from "effect/Data";
19import * as Duration from "effect/Duration";19import * as Duration from "effect/Duration";
20import * as Effect from "effect/Effect";20import * 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";
27const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;27const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;
28const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);28const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
2929
30const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => {30export 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};
4545
46const 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
46export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(54export 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 );
163171
164export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) =>172export 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 });
172176
177export 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
173export const bootstrapRemoteBearerSession = Effect.fn(206export 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);
221251
252export 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
222export const fetchRemoteEnvironmentDescriptor = Effect.fn(273export 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});
251302
303export 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
252export const resolveRemoteWebSocketConnectionUrl = Effect.fn(324export 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
346export 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 });
1313
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 @@
1const TRANSPORT_ERROR_PATTERNS = [1const 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 });
806806
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 });
810810
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/pairing63 * app after bootstrap/pairing
64 * - `bearer-access-token`: scoped token suitable for non-cookie or64 * - `bearer-access-token`: scoped token suitable for non-cookie or
65 * non-browser clients65 * non-browser clients
66 * - `dpop-access-token`: scoped proof-of-possession token used by managed
67 * relay connections
66 */68 */
67export const ServerAuthSessionMethod = Schema.Literals([69export 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]);
71export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;74export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;
7275
...@@ -184,7 +187,7 @@ export type AuthTokenExchangeRequest = typeof AuthTokenExchangeRequest.Type;...@@ -184,7 +187,7 @@ export type AuthTokenExchangeRequest = typeof AuthTokenExchangeRequest.Type;
184export const AuthAccessTokenResult = Schema.Struct({187export 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";
34import {
35 RelayCloudEnvironmentHealthRequest,
36 RelayCloudMintCredentialRequest,
37 RelayEnvironmentConfigRequest,
38 RelayEnvironmentHealthResponse,
39 RelayEnvironmentLinkProof,
40 RelayEnvironmentMintResponse,
41 RelayLinkProofRequest,
42} from "./relay.ts";
3443
35const OptionalBearerHeaders = Schema.Struct({44const OptionalBearerHeaders = Schema.Struct({
36 authorization: Schema.optionalKey(Schema.String),45 authorization: Schema.optionalKey(Schema.String),
46 dpop: Schema.optionalKey(Schema.String),
47});
48
49const OptionalDpopProofHeaders = Schema.Struct({
50 dpop: Schema.optionalKey(Schema.String),
37});51});
3852
39export const EnvironmentRequestInvalidReason = Schema.Literals([53export 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
172export 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
184export 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
196export 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
208export 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
220export 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
232export 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}
157const EnvironmentSessionCreationErrors = [246const 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}
196286
...@@ -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}) {}
208298
299const EnvironmentHttpCloudErrors = [
300 EnvironmentHttpBadRequestError,
301 EnvironmentHttpUnauthorizedError,
302 EnvironmentHttpForbiddenError,
303 EnvironmentHttpConflictError,
304 EnvironmentHttpInternalServerError,
305 EnvironmentScopeRequiredError,
306] as const;
307
308export const EnvironmentCloudRelayConfigResult = Schema.Struct({
309 ok: Schema.Boolean,
310 endpointRuntimeStatus: Schema.Unknown,
311});
312export type EnvironmentCloudRelayConfigResult = typeof EnvironmentCloudRelayConfigResult.Type;
313
314export 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});
321export type EnvironmentCloudLinkStateResult = typeof EnvironmentCloudLinkStateResult.Type;
322
323export const EnvironmentCloudPreferencesRequest = Schema.Struct({
324 publishAgentActivity: Schema.Boolean,
325});
326export type EnvironmentCloudPreferencesRequest = typeof EnvironmentCloudPreferencesRequest.Type;
327
209export const AuthPairingLinkRevokeResult = Schema.Struct({328export 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 ) {}
321441
442export 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
322export class EnvironmentHttpApi extends HttpApi.make("environment")503export 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
345export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", {345export 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";
2export * from "./auth.ts";2export * from "./auth.ts";
3export * from "./environment.ts";3export * from "./environment.ts";
4export * from "./environmentHttp.ts";4export * from "./environmentHttp.ts";
5export * from "./relayClient.ts";
5export * from "./desktopBootstrap.ts";6export * from "./desktopBootstrap.ts";
6export * from "./remoteAccess.ts";7export * from "./remoteAccess.ts";
7export * from "./ipc.ts";8export * 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});
342export type PersistedSavedEnvironmentRecord = typeof PersistedSavedEnvironmentRecordSchema.Type;347export type PersistedSavedEnvironmentRecord = typeof PersistedSavedEnvironmentRecordSchema.Type;
343348
...@@ -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});
374379
380export 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});
386export type DesktopCloudAuthFetchInput = typeof DesktopCloudAuthFetchInputSchema.Type;
387
388export 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});
395export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type;
396
375export interface DesktopBridge {397export 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) {}
12471247
...@@ -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) {}
12551255
...@@ -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) {}
12631263
...@@ -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) {}
12711271
...@@ -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) {}
3636
...@@ -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 @@
1import { describe, expect, it } from "vitest";
2import * as OpenApi from "effect/unstable/httpapi/OpenApi";
3
4import { RelayApi } from "./relay.ts";
5
6describe("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 @@
1import * as Schema from "effect/Schema";
2import * as Context from "effect/Context";
3import * as HttpApi from "effect/unstable/httpapi/HttpApi";
4import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";
5import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";
6import * as HttpApiMiddleware from "effect/unstable/httpapi/HttpApiMiddleware";
7import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";
8import * as HttpApiSecurity from "effect/unstable/httpapi/HttpApiSecurity";
9import * as OpenApi from "effect/unstable/httpapi/OpenApi";
10
11import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
12import { ExecutionEnvironmentDescriptor } from "./environment.ts";
13
14export const RelayAgentAwarenessPlatform = Schema.Literal("ios");
15export type RelayAgentAwarenessPlatform = typeof RelayAgentAwarenessPlatform.Type;
16
17export const RelayAgentAwarenessPhase = Schema.Literals([
18 "starting",
19 "running",
20 "waiting_for_approval",
21 "waiting_for_input",
22 "completed",
23 "failed",
24 "stale",
25]);
26export type RelayAgentAwarenessPhase = typeof RelayAgentAwarenessPhase.Type;
27
28export 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});
36export type RelayAgentAwarenessPreferences = typeof RelayAgentAwarenessPreferences.Type;
37
38export 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});
48export type RelayDeviceRegistrationRequest = typeof RelayDeviceRegistrationRequest.Type;
49
50export 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});
68export type RelayClientDeviceRecord = typeof RelayClientDeviceRecord.Type;
69
70export const RelayListDevicesResponse = Schema.Struct({
71 devices: Schema.Array(RelayClientDeviceRecord),
72});
73export type RelayListDevicesResponse = typeof RelayListDevicesResponse.Type;
74
75export const RelayLiveActivityRegistrationRequest = Schema.Struct({
76 deviceId: TrimmedNonEmptyString,
77 activityPushToken: TrimmedNonEmptyString,
78});
79export type RelayLiveActivityRegistrationRequest = typeof RelayLiveActivityRegistrationRequest.Type;
80
81export const RelayDeviceUnregistrationParams = Schema.Struct({
82 deviceId: TrimmedNonEmptyString,
83});
84export type RelayDeviceUnregistrationParams = typeof RelayDeviceUnregistrationParams.Type;
85
86export 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});
98export type RelayAgentActivityState = typeof RelayAgentActivityState.Type;
99
100export 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});
111export type RelayAgentActivityAggregateRow = typeof RelayAgentActivityAggregateRow.Type;
112
113export 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});
120export type RelayAgentActivityAggregateState = typeof RelayAgentActivityAggregateState.Type;
121
122export const RelayManagedEndpointProviderKind = Schema.Literals([
123 "manual",
124 "cloudflare_tunnel",
125 "t3_relay",
126]);
127export type RelayManagedEndpointProviderKind = typeof RelayManagedEndpointProviderKind.Type;
128
129export const RelayManagedEndpoint = Schema.Struct({
130 httpBaseUrl: TrimmedNonEmptyString,
131 wsBaseUrl: TrimmedNonEmptyString,
132 providerKind: RelayManagedEndpointProviderKind,
133});
134export type RelayManagedEndpoint = typeof RelayManagedEndpoint.Type;
135
136export const RelayManagedEndpointOrigin = Schema.Struct({
137 localHttpHost: TrimmedNonEmptyString,
138 localHttpPort: Schema.Int.check(
139 Schema.isGreaterThanOrEqualTo(1),
140 Schema.isLessThanOrEqualTo(65_535),
141 ),
142});
143export type RelayManagedEndpointOrigin = typeof RelayManagedEndpointOrigin.Type;
144
145export const RelayManagedEndpointRuntimeConfig = Schema.Struct({
146 providerKind: RelayManagedEndpointProviderKind,
147 connectorToken: TrimmedNonEmptyString,
148 tunnelId: Schema.optional(TrimmedNonEmptyString),
149 tunnelName: Schema.optional(TrimmedNonEmptyString),
150});
151export type RelayManagedEndpointRuntimeConfig = typeof RelayManagedEndpointRuntimeConfig.Type;
152
153export const RelayLinkProofRequest = Schema.Struct({
154 challenge: Schema.String,
155 relayIssuer: Schema.String,
156 endpoint: RelayManagedEndpoint,
157 origin: RelayManagedEndpointOrigin,
158});
159export type RelayLinkProofRequest = typeof RelayLinkProofRequest.Type;
160
161export 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});
169export type RelayEnvironmentConfigRequest = typeof RelayEnvironmentConfigRequest.Type;
170
171const 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
180export const RelayAgentActivityPublishProofPayload = Schema.Struct({
181 ...RelaySignedJwtRegisteredClaims,
182 environmentId: EnvironmentId,
183 threadId: ThreadId,
184 state: Schema.NullOr(RelayAgentActivityState),
185});
186export type RelayAgentActivityPublishProofPayload =
187 typeof RelayAgentActivityPublishProofPayload.Type;
188export type RelayAgentActivityPublishProof = string;
189
190export 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." });
198export type RelayAgentActivityPublishRequest = typeof RelayAgentActivityPublishRequest.Type;
199
200export const RelayEnvironmentLinkScope = Schema.Literals([
201 "agent_activity_notifications",
202 "managed_tunnels",
203]);
204export type RelayEnvironmentLinkScope = typeof RelayEnvironmentLinkScope.Type;
205
206export 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});
216export type RelayEnvironmentLinkProofPayload = typeof RelayEnvironmentLinkProofPayload.Type;
217
218export const RelayEnvironmentLinkProof = TrimmedNonEmptyString;
219export type RelayEnvironmentLinkProof = typeof RelayEnvironmentLinkProof.Type;
220
221export 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." });
232export type RelayEnvironmentLinkChallengeRequest = typeof RelayEnvironmentLinkChallengeRequest.Type;
233
234export const RelayEnvironmentLinkChallengeResponse = Schema.Struct({
235 challenge: TrimmedNonEmptyString,
236 expiresAt: TrimmedNonEmptyString,
237});
238export type RelayEnvironmentLinkChallengeResponse =
239 typeof RelayEnvironmentLinkChallengeResponse.Type;
240
241export 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." });
254export type RelayEnvironmentLinkRequest = typeof RelayEnvironmentLinkRequest.Type;
255
256export 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});
266export type RelayEnvironmentLinkResponse = typeof RelayEnvironmentLinkResponse.Type;
267
268export 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]);
276export type RelayEnvironmentLinkProofInvalidReason =
277 typeof RelayEnvironmentLinkProofInvalidReason.Type;
278
279export const RelayEnvironmentLinkFailedReason = Schema.Literals([
280 "link_persistence_failed",
281 "credential_persistence_failed",
282 "replay_persistence_failed",
283 "internal_error",
284]);
285export type RelayEnvironmentLinkFailedReason = typeof RelayEnvironmentLinkFailedReason.Type;
286
287export const RelayEnvironmentLinkUnavailableReason = Schema.Literals([
288 "managed_endpoint_not_configured",
289 "managed_endpoint_provisioning_failed",
290]);
291export type RelayEnvironmentLinkUnavailableReason =
292 typeof RelayEnvironmentLinkUnavailableReason.Type;
293
294export const RelayEnvironmentEndpointUnavailableReason = Schema.Literals([
295 "endpoint_request_failed",
296 "endpoint_response_invalid",
297]);
298export type RelayEnvironmentEndpointUnavailableReason =
299 typeof RelayEnvironmentEndpointUnavailableReason.Type;
300
301export const RelayAgentActivityPublishProofInvalidReason = Schema.Literals([
302 "invalid_signature_or_payload",
303 "replayed_nonce",
304]);
305export type RelayAgentActivityPublishProofInvalidReason =
306 typeof RelayAgentActivityPublishProofInvalidReason.Type;
307
308export const RelayAuthInvalidReason = Schema.Literals([
309 "missing_bearer",
310 "invalid_bearer",
311 "invalid_dpop",
312 "not_authorized",
313]);
314export type RelayAuthInvalidReason = typeof RelayAuthInvalidReason.Type;
315
316export const RelayInternalErrorReason = Schema.Literals([
317 "database_unavailable",
318 "persistence_failed",
319 "upstream_unavailable",
320 "internal_error",
321]);
322export type RelayInternalErrorReason = typeof RelayInternalErrorReason.Type;
323
324export 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
334export 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
343export 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
353export 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
362export 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
372export 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
381export 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
391export 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
401export 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
410export 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
420export 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
430export 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]);
443export type RelayProtectedError = typeof RelayProtectedError.Type;
444
445const RelayAuthAndInternalErrors = [RelayAuthInvalidError, RelayInternalError] as const;
446
447const RelayEnvironmentLinkErrors = [
448 RelayAuthInvalidError,
449 RelayEnvironmentLinkProofExpiredError,
450 RelayEnvironmentLinkProofInvalidError,
451 RelayEnvironmentLinkUnavailableError,
452 RelayEnvironmentLinkFailedError,
453 RelayInternalError,
454] as const;
455
456const RelayEnvironmentConnectErrors = [
457 RelayAuthInvalidError,
458 RelayEnvironmentConnectNotAuthorizedError,
459 RelayEnvironmentEndpointUnavailableError,
460 RelayEnvironmentEndpointTimedOutError,
461 RelayInternalError,
462] as const;
463
464const RelayAgentActivityPublishErrors = [
465 RelayAuthInvalidError,
466 RelayAgentActivityPublishProofExpiredError,
467 RelayAgentActivityPublishProofInvalidError,
468 RelayInternalError,
469] as const;
470
471export interface RelayClientPrincipalShape {
472 readonly userId: string;
473 readonly token: string;
474 readonly proofKeyThumbprint?: string;
475 readonly dpopScopes?: ReadonlyArray<RelayDpopAccessTokenScope>;
476}
477
478export class RelayClientPrincipal extends Context.Service<
479 RelayClientPrincipal,
480 RelayClientPrincipalShape
481>()("@t3tools/contracts/relay/RelayClientPrincipal") {}
482
483export interface RelayEnvironmentPrincipalShape {
484 readonly environmentId: string;
485 readonly environmentPublicKey: string;
486}
487
488export class RelayEnvironmentPrincipal extends Context.Service<
489 RelayEnvironmentPrincipal,
490 RelayEnvironmentPrincipalShape
491>()("@t3tools/contracts/relay/RelayEnvironmentPrincipal") {}
492
493const 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
500export class RelayClientAuth extends HttpApiMiddleware.Service<
501 RelayClientAuth,
502 { provides: RelayClientPrincipal }
503>()("RelayClientAuth", {
504 error: RelayAuthInvalidError,
505 security: { clientBearer: RelayClientBearerAuthorization },
506}) {}
507
508const 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
515export class RelayEnvironmentAuth extends HttpApiMiddleware.Service<
516 RelayEnvironmentAuth,
517 { provides: RelayEnvironmentPrincipal }
518>()("RelayEnvironmentAuth", {
519 error: [RelayAuthInvalidError, RelayInternalError],
520 security: { environmentBearer: RelayEnvironmentBearerAuthorization },
521}) {}
522
523const 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
530export class RelayDpopClientAuth extends HttpApiMiddleware.Service<
531 RelayDpopClientAuth,
532 { provides: RelayClientPrincipal }
533>()("RelayDpopClientAuth", {
534 error: RelayAuthInvalidError,
535 security: { relayDpop: RelayDpopAuthorization },
536}) {}
537
538export const RelayClientEnvironmentRecord = Schema.Struct({
539 environmentId: EnvironmentId,
540 label: TrimmedNonEmptyString,
541 endpoint: RelayManagedEndpoint,
542 linkedAt: TrimmedNonEmptyString,
543});
544export type RelayClientEnvironmentRecord = typeof RelayClientEnvironmentRecord.Type;
545
546export const RelayListEnvironmentsResponse = Schema.Struct({
547 environments: Schema.Array(RelayClientEnvironmentRecord),
548});
549export type RelayListEnvironmentsResponse = typeof RelayListEnvironmentsResponse.Type;
550
551export 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." });
568export type RelayEnvironmentConnectRequest = typeof RelayEnvironmentConnectRequest.Type;
569
570export const RelayEnvironmentConnectScope = "environment:connect" as const;
571export const RelayEnvironmentStatusScope = "environment:status" as const;
572export const RelayMobileRegistrationScope = "mobile:registration" as const;
573export const RelayDpopAccessTokenScope = Schema.Literals([
574 RelayEnvironmentConnectScope,
575 RelayEnvironmentStatusScope,
576 RelayMobileRegistrationScope,
577]);
578export type RelayDpopAccessTokenScope = typeof RelayDpopAccessTokenScope.Type;
579
580export const RelayDpopTokenExchangeGrantType =
581 "urn:ietf:params:oauth:grant-type:token-exchange" as const;
582export const RelayJwtSubjectTokenType = "urn:ietf:params:oauth:token-type:jwt" as const;
583export const RelayAccessTokenType = "urn:ietf:params:oauth:token-type:access_token" as const;
584export const RelayPublicClientId = Schema.Literals(["t3-mobile", "t3-web"]);
585export type RelayPublicClientId = typeof RelayPublicClientId.Type;
586export const RelayMobileClientId = "t3-mobile" as const;
587export const RelayWebClientId = "t3-web" as const;
588
589export 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());
606export type RelayDpopAccessTokenRequest = typeof RelayDpopAccessTokenRequest.Type;
607
608export 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});
615export type RelayDpopAccessTokenResponse = typeof RelayDpopAccessTokenResponse.Type;
616
617export const RelayBearerRequestHeaders = Schema.Struct({
618 authorization: TrimmedNonEmptyString,
619});
620
621export const RelayDpopProofRequestHeaders = Schema.Struct({
622 dpop: TrimmedNonEmptyString,
623});
624
625export const RelayDpopRequestHeaders = Schema.Struct({
626 authorization: TrimmedNonEmptyString,
627 dpop: TrimmedNonEmptyString,
628});
629
630export 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
639export 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
647export const RelayEnvironmentUnlinkParams = Schema.Struct({
648 environmentId: EnvironmentId,
649});
650export type RelayEnvironmentUnlinkParams = typeof RelayEnvironmentUnlinkParams.Type;
651
652export const RelayEnvironmentConnectResponse = Schema.Struct({
653 environmentId: EnvironmentId,
654 endpoint: RelayManagedEndpoint,
655 credential: TrimmedNonEmptyString,
656 expiresAt: TrimmedNonEmptyString,
657});
658export type RelayEnvironmentConnectResponse = typeof RelayEnvironmentConnectResponse.Type;
659
660export const RelayEnvironmentStatusValue = Schema.Literals(["online", "offline"]);
661export type RelayEnvironmentStatusValue = typeof RelayEnvironmentStatusValue.Type;
662
663export 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});
671export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type;
672
673export 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});
684export type RelayCloudMintCredentialProofPayload = typeof RelayCloudMintCredentialProofPayload.Type;
685
686export const RelayCloudMintCredentialProof = TrimmedNonEmptyString;
687export type RelayCloudMintCredentialProof = typeof RelayCloudMintCredentialProof.Type;
688
689export const RelayCloudMintCredentialRequest = Schema.Struct({
690 proof: RelayCloudMintCredentialProof,
691});
692export type RelayCloudMintCredentialRequest = typeof RelayCloudMintCredentialRequest.Type;
693
694export const RelayCloudEnvironmentHealthProofPayload = Schema.Struct({
695 ...RelaySignedJwtRegisteredClaims,
696 environmentId: EnvironmentId,
697 nonce: TrimmedNonEmptyString,
698 scope: Schema.Array(Schema.Literal("environment:status")),
699});
700export type RelayCloudEnvironmentHealthProofPayload =
701 typeof RelayCloudEnvironmentHealthProofPayload.Type;
702
703export const RelayCloudEnvironmentHealthProof = TrimmedNonEmptyString;
704export type RelayCloudEnvironmentHealthProof = typeof RelayCloudEnvironmentHealthProof.Type;
705
706export const RelayCloudEnvironmentHealthRequest = Schema.Struct({
707 proof: RelayCloudEnvironmentHealthProof,
708});
709export type RelayCloudEnvironmentHealthRequest = typeof RelayCloudEnvironmentHealthRequest.Type;
710
711export const RelayEnvironmentHealthResponseProofPayload = Schema.Struct({
712 ...RelaySignedJwtRegisteredClaims,
713 environmentId: EnvironmentId,
714 requestNonce: TrimmedNonEmptyString,
715 status: Schema.Literal("online"),
716 descriptor: ExecutionEnvironmentDescriptor,
717 checkedAt: TrimmedNonEmptyString,
718});
719export type RelayEnvironmentHealthResponseProofPayload =
720 typeof RelayEnvironmentHealthResponseProofPayload.Type;
721
722export const RelayEnvironmentHealthResponse = Schema.Struct({
723 environmentId: EnvironmentId,
724 status: Schema.Literal("online"),
725 descriptor: ExecutionEnvironmentDescriptor,
726 checkedAt: TrimmedNonEmptyString,
727 proof: TrimmedNonEmptyString,
728});
729export type RelayEnvironmentHealthResponse = typeof RelayEnvironmentHealthResponse.Type;
730
731export const RelayEnvironmentMintResponseProofPayload = Schema.Struct({
732 ...RelaySignedJwtRegisteredClaims,
733 environmentId: EnvironmentId,
734 clientProofKeyThumbprint: TrimmedNonEmptyString,
735 requestNonce: TrimmedNonEmptyString,
736 credential: TrimmedNonEmptyString,
737});
738export type RelayEnvironmentMintResponseProofPayload =
739 typeof RelayEnvironmentMintResponseProofPayload.Type;
740
741export const RelayEnvironmentMintResponse = Schema.Struct({
742 credential: TrimmedNonEmptyString,
743 expiresAt: TrimmedNonEmptyString,
744 proof: TrimmedNonEmptyString,
745});
746export type RelayEnvironmentMintResponse = typeof RelayEnvironmentMintResponse.Type;
747
748export const RelayDeliveryKind = Schema.Literals([
749 "live_activity_start",
750 "live_activity_update",
751 "live_activity_end",
752 "push_notification",
753]);
754export type RelayDeliveryKind = typeof RelayDeliveryKind.Type;
755
756export 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});
765export type RelayDeliveryResult = typeof RelayDeliveryResult.Type;
766
767export const RelayOkResponse = Schema.Struct({
768 ok: Schema.Boolean,
769});
770export type RelayOkResponse = typeof RelayOkResponse.Type;
771
772export const RelayPublishResponse = Schema.Struct({
773 ok: Schema.Boolean,
774 deliveries: Schema.Array(RelayDeliveryResult),
775});
776export type RelayPublishResponse = typeof RelayPublishResponse.Type;
777
778export const RelayHealthResponse = Schema.Struct({
779 ok: Schema.Boolean,
780 service: Schema.Literal("relay"),
781});
782export type RelayHealthResponse = typeof RelayHealthResponse.Type;
783
784export 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
793export 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
804export 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
815export 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
826export 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
837export 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
846export 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
884export 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
900export const RelayTokenGroup = HttpApiGroup.make("token")
901 .add(RelayExchangeDpopAccessTokenEndpoint)
902 .annotate(OpenApi.Description, "OAuth token exchange for DPoP-bound client access.");
903
904export 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
918export 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
931export const RelayDpopClientGroup = HttpApiGroup.make("dpopClient")
932 .add(RelayConnectEnvironmentEndpoint, RelayGetEnvironmentStatusEndpoint)
933 .annotate(OpenApi.Description, "DPoP-authenticated client access to linked environments.")
934 .middleware(RelayDpopClientAuth);
935
936export 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
955export 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 );
971export type RelayApi = typeof RelayApi;
packages/contracts/src/relayClient.ts created+63
...@@ -0,0 +1,63 @@
1import * as Schema from "effect/Schema";
2
3export 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]);
21export type RelayClientStatus = typeof RelayClientStatusSchema.Type;
22
23export const RelayClientInstallProgressStageSchema = Schema.Literals([
24 "checking",
25 "waiting_for_lock",
26 "downloading",
27 "verifying",
28 "installing",
29 "validating",
30 "activating",
31]);
32export type RelayClientInstallProgressStage = typeof RelayClientInstallProgressStageSchema.Type;
33
34export 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]);
44export type RelayClientInstallProgressEvent = typeof RelayClientInstallProgressEventSchema.Type;
45
46export 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]);
55export type RelayClientInstallFailureReason = typeof RelayClientInstallFailureReasonSchema.Type;
56
57export 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";
60import { ProviderInstanceId } from "./providerInstance.ts";60import { ProviderInstanceId } from "./providerInstance.ts";
61import {
62 RelayClientInstallFailedError,
63 RelayClientInstallProgressEventSchema,
64 RelayClientStatusSchema,
65} from "./relayClient.ts";
61import {66import {
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",
168173
174 // Cloud environment methods
175 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
176 cloudInstallRelayClient: "cloud.installRelayClient",
177
169 // Source control methods178 // 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});
265274
275export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, {
276 payload: Schema.Struct({}),
277 success: RelayClientStatusSchema,
278 error: EnvironmentAuthorizationError,
279});
280
281export 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
266export const WsSourceControlLookupRepositoryRpc = Rpc.make(288export 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";
44
5export class AcpSpawnError extends Schema.TaggedErrorClass<AcpSpawnError>()("AcpSpawnError", {5export 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.command10 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) {}
4949
packages/effect-codex-app-server/scripts/generate.ts+1-1
...@@ -63,7 +63,7 @@ interface JsonSchemaFile {...@@ -63,7 +63,7 @@ interface JsonSchemaFile {
6363
64class GeneratorError extends Schema.TaggedErrorClass<GeneratorError>()("GeneratorError", {64class 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import type {
4 EnvironmentId,
5 OrchestrationProjectShell,
6 OrchestrationThreadShell,
7 ThreadId,
8 TurnId,
9} from "@t3tools/contracts";
10import { ProviderInstanceId } from "@t3tools/contracts";
11
12import { projectThreadAwareness } from "./agentAwareness.ts";
13
14const NOW = "2026-05-22T12:00:00.000Z";
15
16const project = {
17 title: "t3code",
18} satisfies Pick<OrchestrationProjectShell, "title">;
19
20function 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
46describe("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 @@
1import type {
2 EnvironmentId,
3 OrchestrationProjectShell,
4 OrchestrationThreadShell,
5 ThreadId,
6} from "@t3tools/contracts";
7
8export type AgentAwarenessPhase =
9 | "starting"
10 | "running"
11 | "waiting_for_approval"
12 | "waiting_for_input"
13 | "completed"
14 | "failed"
15 | "stale";
16
17export 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
30export 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
46export function buildAgentAwarenessDeepLink(input: {
47 readonly environmentId: EnvironmentId;
48 readonly threadId: ThreadId;
49}): string {
50 return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`;
51}
52
53export function isTerminalAgentAwarenessPhase(phase: AgentAwarenessPhase): boolean {
54 return phase === "completed" || phase === "failed";
55}
56
57export function isInterruptiveAgentAwarenessPhase(phase: AgentAwarenessPhase): boolean {
58 return phase === "waiting_for_approval" || phase === "waiting_for_input" || phase === "failed";
59}
60
61export 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
85function 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
109function 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
128function 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 @@
1import * as NodeCrypto from "node:crypto";
2
3import { describe, expect, it } from "@effect/vitest";
4
5import {
6 computeDpopAccessTokenHash,
7 computeDpopJwkThumbprint,
8 normalizeDpopHtu,
9 type DpopPublicJwk,
10 verifyDpopProof,
11} from "./dpop.ts";
12
13function 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
44describe("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 @@
1import { p256 } from "@noble/curves/nist";
2import { sha256 } from "@noble/hashes/sha2";
3import * as Encoding from "effect/Encoding";
4import * as Result from "effect/Result";
5import * as Schema from "effect/Schema";
6
7import { stableStringify } from "./relaySigning.ts";
8
9const DPOP_TYP = "dpop+jwt";
10const DPOP_ALG = "ES256";
11const DEFAULT_MAX_AGE_SECONDS = 300;
12
13export 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});
19export type DpopPublicJwk = typeof DpopPublicJwk.Type;
20const isDpopPublicJwk = Schema.is(DpopPublicJwk);
21
22interface DpopJwtHeader {
23 readonly typ: string;
24 readonly alg: string;
25 readonly jwk: DpopPublicJwk;
26}
27
28interface DpopJwtPayload {
29 readonly htm: string;
30 readonly htu: string;
31 readonly jti: string;
32 readonly iat: number;
33 readonly ath?: string;
34}
35
36export 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
48function base64UrlToBytes(value: string): Uint8Array {
49 return Result.getOrThrow(Encoding.decodeBase64Url(value));
50}
51
52function decodeBase64UrlJson(value: string): unknown {
53 return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown;
54}
55
56function 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
71function 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
88function 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
97export 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
108export function computeDpopJwkThumbprint(jwk: DpopPublicJwk): string {
109 return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk))));
110}
111
112export function computeDpopAccessTokenHash(accessToken: string): string {
113 return Encoding.encodeBase64Url(sha256(new TextEncoder().encode(accessToken)));
114}
115
116function 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
129export 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 @@
1import * as Schema from "effect/Schema";
2
3export 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});
9export type DpopPublicJwk = typeof DpopPublicJwk.Type;
10
11export 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 @@
1const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u;1const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u;
22
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 */
6export function parseOAuthScope(value: string): ReadonlyArray<string> | null {7export 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}
2728
29export 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
28export function parseAllowedOAuthScope<Scope extends string>(input: {38export 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 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 clerkFrontendApiHostnameFromPublishableKey,
5 isAllowedClerkFrontendApiHostname,
6} from "./relayAuth.ts";
7
8const clerkPublishableKey = (hostname: string): string => `pk_test_${btoa(`${hostname}$`)}`;
9
10describe("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 @@
1export 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
10export function clerkFrontendApiHostnameFromPublishableKey(publishableKey: string): string {
11 return new URL(clerkFrontendApiUrlFromPublishableKey(publishableKey)).hostname;
12}
13
14export 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
25export 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 @@
1import { sha256 } from "@noble/hashes/sha2";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, expect, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";
11import { HttpClient, HttpClientResponse } from "effect/unstable/http";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13
14import {
15 RelayClientInstallError,
16 CLOUDFLARED_VERSION,
17 makeCloudflaredRelayClient,
18 resolveManagedCloudflaredPath,
19} from "./relayClient.ts";
20
21const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} });
22
23function 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
39const 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
49const 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
60describe("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 @@
1import * as Clock from "effect/Clock";
2import type {
3 RelayClientInstallProgressEvent,
4 RelayClientInstallProgressStage,
5} from "@t3tools/contracts";
6import * as Config from "effect/Config";
7import * as ConfigProvider from "effect/ConfigProvider";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Encoding from "effect/Encoding";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as PlatformError from "effect/PlatformError";
18import * as Semaphore from "effect/Semaphore";
19import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
20import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
21
22export const CLOUDFLARED_VERSION = "2026.5.2";
23export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";
24
25export type RelayClientExecutableSource = "override" | "managed" | "path";
26
27export 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
45export type AvailableRelayClient = Extract<RelayClientStatus, { readonly status: "available" }>;
46
47export 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
60class CloudflaredCommandError extends Data.TaggedError("CloudflaredCommandError")<{
61 readonly command: string;
62 readonly exitCode: number;
63}> {}
64
65export interface CloudflaredReleaseAsset {
66 readonly url: string;
67 readonly sha256: string;
68 readonly archive: "binary" | "tgz";
69}
70
71const 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
101const INSTALL_LOCK_RETRY_COUNT = 100;
102const INSTALL_LOCK_RETRY_DELAY = "100 millis";
103const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;
104
105const 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
116const CloudflaredConfig = Config.all({
117 executableOverride: trimmedString(CLOUDFLARED_PATH_ENV_NAME),
118 path: trimmedString("PATH"),
119});
120
121export 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
129export 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
137export class RelayClient extends Context.Service<RelayClient, RelayClientShape>()(
138 "@t3tools/shared/relayClient",
139) {}
140
141function executableFileName(platform: NodeJS.Platform): string {
142 return platform === "win32" ? "cloudflared.exe" : "cloudflared";
143}
144
145export 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
161function resolveReleaseAsset(
162 platform: NodeJS.Platform,
163 arch: string,
164): CloudflaredReleaseAsset | null {
165 return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null;
166}
167
168function isAlreadyExists(error: PlatformError.PlatformError): boolean {
169 return error.reason._tag === "AlreadyExists";
170}
171
172const 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
191export 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
501export const layerCloudflared = (options: CloudflaredRelayClientOptions) =>
502 Layer.effect(RelayClient, makeCloudflaredRelayClient(options));
packages/shared/src/relayJwt.ts created+69
...@@ -0,0 +1,69 @@
1import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose";
2import * as Data from "effect/Data";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5
6export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt";
7export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt";
8export const RELAY_HEALTH_REQUEST_TYP = "t3-cloud-health+jwt";
9export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt";
10export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt";
11export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt";
12
13export class RelayJwtError extends Data.TaggedError("RelayJwtError")<{
14 readonly cause: unknown;
15}> {}
16
17export function normalizeRelayIssuer(value: string): string {
18 return value.trim().replace(/\/+$/gu, "");
19}
20
21export function decodeRelayJwt(token: string): JWTPayload {
22 return decodeJwt(token);
23}
24
25function normalizePem(value: string): string {
26 return value.replace(/\\n/gu, "\n").trim();
27}
28
29export 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
45export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { stableStringify } from "./relaySigning.ts";
4
5describe("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 @@
1function isRecord(value: unknown): value is Record<string, unknown> {
2 return typeof value === "object" && value !== null && !Array.isArray(value);
3}
4
5export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { isSecureRelayUrl, normalizeSecureRelayUrl } from "./relayUrl.ts";
4
5describe("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 @@
1export 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
20export 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";
8import * as Result from "effect/Result";8import * as Result from "effect/Result";
9import * as Sink from "effect/Sink";9import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";10import * as Stream from "effect/Stream";
11import * as TestClock from "effect/testing/TestClock";11import { TestClock } from "effect/testing";
12import { HttpClient, HttpClientResponse } from "effect/unstable/http";12import { HttpClient, HttpClientResponse } from "effect/unstable/http";
13import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";13import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1414
patches/effect@4.0.0-beta.73.patch deleted-291
...@@ -1,291 +0,0 @@
1diff --git a/dist/unstable/rpc/RpcClient.d.ts b/dist/unstable/rpc/RpcClient.d.ts
2index 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
61diff --git a/dist/unstable/rpc/RpcClient.js b/dist/unstable/rpc/RpcClient.js
62index 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 @@
1diff --git a/dist/unstable/rpc/RpcClient.d.ts b/dist/unstable/rpc/RpcClient.d.ts
2index 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
61diff --git a/dist/unstable/rpc/RpcClient.js b/dist/unstable/rpc/RpcClient.js
62index 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:
7catalogs:7catalogs:
8 default:8 default:
9 '@effect/openapi-generator':9 '@effect/openapi-generator':
10 specifier: 4.0.0-beta.7310 specifier: 4.0.0-beta.78
11 version: 4.0.0-beta.7311 version: 4.0.0-beta.78
12 '@effect/tsgo':12 '@effect/tsgo':
13 specifier: 0.11.413 specifier: 0.11.4
14 version: 0.11.414 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.2022 specifier: 1.1.20
17 version: 1.1.2023 version: 1.1.20
...@@ -24,6 +30,9 @@ catalogs:...@@ -24,6 +30,9 @@ catalogs:
24 '@vitest/runner':30 '@vitest/runner':
25 specifier: ^4.1.831 specifier: ^4.1.8
26 version: 4.1.832 version: 4.1.8
33 jose:
34 specifier: 6.2.2
35 version: 6.2.2
27 typescript:36 typescript:
28 specifier: ~6.0.337 specifier: ~6.0.3
29 version: 6.0.338 version: 6.0.3
...@@ -32,14 +41,21 @@ catalogs:...@@ -32,14 +41,21 @@ catalogs:
32 version: 0.1.2441 version: 0.1.24
3342
34overrides:43overrides:
35 '@effect/atom-react': 4.0.0-beta.7344 '@clerk/clerk-js>@base-org/account': '-'
36 '@effect/platform-bun': 4.0.0-beta.7345 '@clerk/clerk-js>@coinbase/wallet-sdk': '-'
37 '@effect/platform-node': 4.0.0-beta.7346 '@clerk/clerk-js>@solana/wallet-adapter-base': '-'
38 '@effect/platform-node-shared': 4.0.0-beta.7347 '@clerk/clerk-js>@solana/wallet-adapter-react': '-'
39 '@effect/sql-sqlite-bun': 4.0.0-beta.7348 '@clerk/clerk-js>@solana/wallet-standard': '-'
40 '@effect/vitest': 4.0.0-beta.7349 '@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.457 '@types/node': 24.12.4
42 effect: 4.0.0-beta.7358 effect: 4.0.0-beta.78
43 vite: npm:@voidzero-dev/vite-plus-core@latest59 vite: npm:@voidzero-dev/vite-plus-core@latest
44 vitest: npm:@voidzero-dev/vite-plus-test@latest60 vitest: npm:@voidzero-dev/vite-plus-test@latest
45 yaml: ^2.9.061 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: e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e070 hash: e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0
55 path: patches/@pierre%2Fdiffs@1.1.20.patch71 path: patches/@pierre%2Fdiffs@1.1.20.patch
56 effect@4.0.0-beta.73:72 effect@4.0.0-beta.78:
57 hash: a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb0173 hash: 883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754
58 path: patches/effect@4.0.0-beta.73.patch74 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: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b608842267576 hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
61 path: patches/react-native-nitro-modules@0.35.9.patch77 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@latest108 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)'
94110
95 apps/desktop:111 apps/desktop:
96 dependencies:112 dependencies:
97 '@effect/platform-node':113 '@effect/platform-node':
98 specifier: 4.0.0-beta.73114 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-runtime118 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/tailscale130 version: link:../../packages/tailscale
115 effect:131 effect:
116 specifier: 4.0.0-beta.73132 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.0135 specifier: 41.5.0
120 version: 41.5.0136 version: 41.5.0
...@@ -123,8 +139,8 @@ importers:...@@ -123,8 +139,8 @@ importers:
123 version: 6.8.3139 version: 6.8.3
124 devDependencies:140 devDependencies:
125 '@effect/vitest':141 '@effect/vitest':
126 specifier: 4.0.0-beta.73142 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.4145 specifier: 24.12.4
130 version: 24.12.4146 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@latest157 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)'
143159
144 apps/marketing:160 apps/marketing:
145 dependencies:161 dependencies:
146 astro:162 astro:
147 specifier: ^6.0.4163 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.7167 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.1179 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.73185 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.2188 specifier: ^0.4.2
170 version: 0.4.2189 version: 0.4.2
171 '@expo/ui':190 '@expo/ui':
172 specifier: ~56.0.8191 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.44194 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.0206 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.0209 specifier: 3.23.0
185 version: 3.23.0210 version: 3.23.0
...@@ -214,38 +239,41 @@ importers:...@@ -214,38 +239,41 @@ importers:
214 specifier: 8.0.3239 specifier: 8.0.3
215 version: 8.0.3240 version: 8.0.3
216 effect:241 effect:
217 specifier: 4.0.0-beta.73242 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.0245 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.15251 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.7254 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.3257 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.16260 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.4263 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.16266 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.7269 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.5272 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.4275 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.3278 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.12284 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.15290 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.7293 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.4296 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.5302 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.17305 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.1314 specifier: ^2.3.1
278 version: 2.3.1315 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.3323 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.1326 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.2329 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.6332 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.0335 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.4338 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.1341 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.0344 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.2347 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.9350 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.4353 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.3356 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.0359 specifier: 3.23.0
323 version: 3.23.0360 version: 3.23.0
...@@ -326,14 +363,17 @@ importers:...@@ -326,14 +363,17 @@ importers:
326 version: 3.6.0363 version: 3.6.0
327 uniwind:364 uniwind:
328 specifier: ^1.6.2365 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.0372 specifier: ~19.2.0
333 version: 19.2.16373 version: 19.2.16
334 babel-preset-expo:374 babel-preset-expo:
335 specifier: ~56.0.0375 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.0378 specifier: ^4.0.0
339 version: 4.3.0379 version: 4.3.0
...@@ -347,17 +387,17 @@ importers:...@@ -347,17 +387,17 @@ importers:
347 specifier: ^0.3.154387 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.73390 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.73393 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.73396 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.73399 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.15402 specifier: ^1.3.15
363 version: 1.15.13403 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.73408 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.0411 specifier: ^1.1.0
372 version: 1.1.0412 version: 1.1.0
373 devDependencies:413 devDependencies:
374 '@effect/vitest':414 '@effect/vitest':
375 specifier: 4.0.0-beta.73415 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/contracts419 version: link:../../packages/contracts
...@@ -400,16 +440,22 @@ importers:...@@ -400,16 +440,22 @@ importers:
400 version: link:../../packages/effect-codex-app-server440 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@latest445 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)'
407447
408 apps/web:448 apps/web:
409 dependencies:449 dependencies:
410 '@base-ui/react':450 '@base-ui/react':
411 specifier: ^1.4.1451 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.1460 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.2469 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.73472 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.0475 specifier: ^0.9.0
430 version: 0.9.0476 version: 0.9.0
...@@ -465,8 +511,11 @@ importers:...@@ -465,8 +511,11 @@ importers:
465 specifier: ^0.7.1511 specifier: ^0.7.1
466 version: 0.7.1512 version: 0.7.1
467 effect:513 effect:
468 specifier: 4.0.0-beta.73514 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.0520 specifier: ^0.41.0
472 version: 0.41.0521 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.73545 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.0551 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@latest593 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.5596 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)'
546644
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.73648 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.0651 specifier: ^1.63.0
554 version: 1.68.0652 version: 1.68.0
555 effect:653 effect:
556 specifier: 4.0.0-beta.73654 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.73658 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.14662 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@latest667 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)'
571669
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:../shared677 version: link:../shared
580 effect:678 effect:
581 specifier: 4.0.0-beta.73679 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.73683 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@latest689 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)'
593691
594 packages/contracts:692 packages/contracts:
595 dependencies:693 dependencies:
596 effect:694 effect:
597 specifier: 4.0.0-beta.73695 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.73699 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@latest705 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)'
609707
610 packages/effect-acp:708 packages/effect-acp:
611 dependencies:709 dependencies:
612 effect:710 effect:
613 specifier: 4.0.0-beta.73711 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.73718 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.73721 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.4724 specifier: 24.12.4
627 version: 24.12.4725 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@latest730 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)'
634732
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.73736 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.73743 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.73746 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.4749 specifier: 24.12.4
652 version: 24.12.4750 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@latest755 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)'
659757
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:../contracts768 version: link:../contracts
665 effect:769 effect:
666 specifier: 4.0.0-beta.73770 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.0776 specifier: ^2.9.0
670 version: 2.9.0777 version: 2.9.0
671 devDependencies:778 devDependencies:
672 '@effect/platform-node':779 '@effect/platform-node':
673 specifier: 4.0.0-beta.73780 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.73783 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.4786 specifier: 24.12.4
680 version: 24.12.4787 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@latest792 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)'
687794
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:../shared802 version: link:../shared
696 effect:803 effect:
697 specifier: 4.0.0-beta.73804 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.73808 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.73811 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.4814 specifier: 24.12.4
708 version: 24.12.4815 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@latest820 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)'
715822
716 packages/tailscale:823 packages/tailscale:
717 dependencies:824 dependencies:
718 '@effect/platform-node':825 '@effect/platform-node':
719 specifier: 4.0.0-beta.73826 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.73829 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.73833 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.4836 specifier: 24.12.4
730 version: 24.12.4837 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@latest842 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)'
737844
738 scripts:845 scripts:
739 dependencies:846 dependencies:
...@@ -741,8 +848,8 @@ importers:...@@ -741,8 +848,8 @@ importers:
741 specifier: ^0.2.77848 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.73851 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/contracts855 version: link:../packages/contracts
...@@ -750,24 +857,24 @@ importers:...@@ -750,24 +857,24 @@ importers:
750 specifier: workspace:*857 specifier: workspace:*
751 version: link:../packages/shared858 version: link:../packages/shared
752 effect:859 effect:
753 specifier: 4.0.0-beta.73860 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.0863 specifier: ^2.9.0
757 version: 2.9.0864 version: 2.9.0
758 devDependencies:865 devDependencies:
759 '@effect/vitest':866 '@effect/vitest':
760 specifier: 4.0.0-beta.73867 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.14871 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@latest876 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)'
771878
772packages:879packages:
773880
...@@ -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==}
779886
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==}
9231037
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'}
13511558
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'}
13551565
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'}
13591572
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'}
13631679
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'
13911773
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.731780 effect: 4.0.0-beta.78
1396 react: ^19.2.41781 react: ^19.2.4
1397 scheduler: '*'1782 scheduler: '*'
13981783
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: true1786 hasBin: true
1402 peerDependencies:1787 peerDependencies:
1403 '@effect/platform-node': 4.0.0-beta.731788 '@effect/platform-node': 4.0.0-beta.78
1404 effect: 4.0.0-beta.731789 effect: 4.0.0-beta.78
14051790
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.731794 effect: 4.0.0-beta.78
14101795
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.731800 effect: 4.0.0-beta.78
14161801
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.731806 effect: 4.0.0-beta.78
1422 ioredis: ^5.7.01807 ioredis: ^5.7.0
14231808
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.731817 effect: 4.0.0-beta.78
14281818
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: true1856 hasBin: true
14671857
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.731861 effect: 4.0.0-beta.78
1472 vitest: ^3.0.0 || ^4.0.01862 vitest: ^3.0.0 || ^4.0.0
14731863
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==}
15501940
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]
15561952
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]
15621964
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]
15681976
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]
15741988
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]
15802000
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]
15862012
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]
15922024
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]
15982036
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]
16042048
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]
16102060
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]
16162072
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]
16222084
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]
16282096
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]
16342108
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]
16402120
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]
16462132
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]
16522144
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]
16582156
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]
16642168
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]
16702180
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]
16762192
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]
16822204
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]
16882216
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]
16942228
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]
17002240
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==}
21382684
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'
22242774
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.12887 '@emnapi/core': ^1.7.1
2281 '@emnapi/runtime': ^1.7.12888 '@emnapi/runtime': ^1.7.1
22822889
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==}
22852970
...@@ -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==}
23042989
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==}
23072995
...@@ -2909,6 +3597,12 @@ packages:...@@ -2909,6 +3597,12 @@ packages:
2909 cpu: [arm64]3597 cpu: [arm64]
2910 os: [android]3598 os: [android]
29113599
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]
29233617
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]
29353635
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]
29473653
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]
29593671
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]
29713689
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]
29833707
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]
29953725
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]
30073743
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]
30193761
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]
30313779
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]
30433797
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]
30543814
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]
30653830
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]
30773848
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'}
32994076
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==}
33024130
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==}
35974432
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==}
36004438
...@@ -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==}
39714809
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==}
40234867
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==}
40264902
...@@ -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'}
40304906
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'}
40384918
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'}
40504934
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'}
41255013
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: true5072 optional: true
41745073
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==}
41775079
...@@ -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==}
41875089
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==}
41905095
...@@ -4193,6 +5098,9 @@ packages:...@@ -4193,6 +5098,9 @@ packages:
4193 engines: {node: '>=6.0.0'}5098 engines: {node: '>=6.0.0'}
4194 hasBin: true5099 hasBin: true
41955100
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.
42105118
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==}
42135124
...@@ -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'}
42355146
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==}
42525166
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==}
42895207
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==}
42925216
...@@ -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'}
43005224
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==}
43035231
...@@ -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==}
43535281
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'}
43575289
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'}
43655301
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'}
43915331
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==}
43945338
...@@ -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==}
44795423
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==}
44825430
...@@ -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==}
45005448
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==}
45035454
...@@ -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==}
45255476
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==}
45285482
...@@ -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}
46395593
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'}
47015659
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==}
47125790
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==}
47155793
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==}
47495827
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==}
47525833
...@@ -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'}
47795860
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==}
47825867
...@@ -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'}
48075892
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==}
48105898
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'}
48265919
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'}
48595956
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: '*'
48665968
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: '*'
49996107
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: true6194 optional: true
50806195
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: true6211 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==}
51356263
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==}
51386270
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==}
51416276
...@@ -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==}
51506285
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==}
52646417
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.*}
52726428
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'}
52886448
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==}
52996462
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'}
54636633
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==}
54666639
...@@ -5473,10 +6646,17 @@ packages:...@@ -5473,10 +6646,17 @@ packages:
5473 engines: {node: '>=16.x'}6646 engines: {node: '>=16.x'}
5474 hasBin: true6647 hasBin: true
54756648
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'}
54796655
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}
54906670
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==}
54936686
...@@ -5540,13 +6733,30 @@ packages:...@@ -5540,13 +6733,30 @@ packages:
5540 engines: {node: '>=20'}6733 engines: {node: '>=20'}
5541 hasBin: true6734 hasBin: true
55426735
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'}
55466743
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==}
55496754
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==}
55686778
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'}
55766789
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: true6845 hasBin: true
56306846
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==}
56336852
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==}
56366862
...@@ -5638,6 +6864,9 @@ packages:...@@ -5638,6 +6864,9 @@ packages:
5638 resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}6864 resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
5639 hasBin: true6865 hasBin: true
56406866
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==}
56436872
...@@ -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==}
56676896
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==}
56846916
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==}
56876922
...@@ -5715,6 +6950,20 @@ packages:...@@ -5715,6 +6950,20 @@ packages:
5715 engines: {node: '>=16'}6950 engines: {node: '>=16'}
5716 hasBin: true6951 hasBin: true
57176952
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==}
57206969
...@@ -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'}
59447193
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==}
59477199
...@@ -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'}
59697221
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==}
59727228
...@@ -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==}
60757331
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'}
62567516
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}
63437607
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: true7664 hasBin: true
63917665
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'}
64487726
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==}
64517732
...@@ -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'}
64767757
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==}
64797764
...@@ -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'}
64907775
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==}
64937782
...@@ -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==}
65467835
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==}
65497841
...@@ -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'}
65637855
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==}
65667862
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'}
65977897
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==}
66007950
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}
66528002
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}
66868071
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'}
66908078
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'}
67298120
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==}
67328126
...@@ -6874,6 +8268,11 @@ packages:...@@ -6874,6 +8268,11 @@ packages:
6874 react: '*'8268 react: '*'
6875 react-native: '*'8269 react-native: '*'
68768270
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: true8296 optional: true
68988297
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: true8348 hasBin: true
69448349
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'}
70688476
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==}
70718483
...@@ -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==}
70878499
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 supported8506 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: true8516 hasBin: true
71018517
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'}
71158536
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==}
71188545
...@@ -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==}
71898616
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==}
71928622
...@@ -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'}
72668696
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'}
73008734
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==}
73038741
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==}
73068752
...@@ -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==}
73138759
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'}
73438792
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==}
73468806
...@@ -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'}
73548814
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'}
73588822
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==}
73618828
...@@ -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'}
74318898
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'}
75779048
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'}
75819056
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==}
76309108
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.09247 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
77679248
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==}
77709255
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==}
79449432
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==}
79479439
...@@ -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==}
79539445
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: true9467 hasBin: true
79729468
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'}
79809485
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==}
79839492
...@@ -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'}
80119520
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'}
80239544
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==}
80269551
...@@ -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'}
80809605
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:
81219649
8122 '@adobe/css-tools@4.5.0': {}9650 '@adobe/css-tools@4.5.0': {}
81239651
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: true9660 optional: true
81269661
...@@ -8297,6 +9832,220 @@ snapshots:...@@ -8297,6 +9832,220 @@ snapshots:
8297 dependencies:9832 dependencies:
8298 yaml: 2.9.09833 yaml: 2.9.0
82999834
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.710051 '@babel/helper-validator-identifier': 7.29.7
...@@ -8813,33 +10562,238 @@ snapshots:...@@ -8813,33 +10562,238 @@ snapshots:
8813 reselect: 5.2.010562 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.1610565 '@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)
881710764
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.310767 '@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
882210778
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.310781 '@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)
882610783
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.210786 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
8830 sisteransi: 1.0.5
883110787
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.010790 '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
8835 fast-string-width: 3.0.210791 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
8836 fast-wrap-ansi: 0.2.2
8837 sisteransi: 1.0.5
883810792
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.010795 '@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)
884310797
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.610827 react: 19.2.6
8874 tslib: 2.8.110828 tslib: 2.8.1
887510829
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.310835 react: 19.2.3
8880 scheduler: 0.27.010836 scheduler: 0.27.0
888110837
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.610841 react: 19.2.6
8886 scheduler: 0.27.010842 scheduler: 0.27.0
888710843
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)
889210848
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 - bufferutil10854 - bufferutil
8899 - utf-8-validate10855 - utf-8-validate
890010856
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.110859 '@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.010861 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
8906 transitivePeerDependencies:10862 transitivePeerDependencies:
8907 - bufferutil10863 - bufferutil
8908 - utf-8-validate10864 - utf-8-validate
890910865
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.010870 ioredis: 5.11.0
8915 mime: 4.1.010871 mime: 4.1.0
8916 undici: 8.3.010872 undici: 8.3.0
...@@ -8918,9 +10874,20 @@ snapshots:...@@ -8918,9 +10874,20 @@ snapshots:
8918 - bufferutil10874 - bufferutil
8919 - utf-8-validate10875 - utf-8-validate
892010876
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)
892410891
8925 '@effect/tsgo-darwin-arm64@0.11.4':10892 '@effect/tsgo-darwin-arm64@0.11.4':
8926 optional: true10893 optional: true
...@@ -8953,10 +10920,10 @@ snapshots:...@@ -8953,10 +10920,10 @@ snapshots:
8953 '@effect/tsgo-win32-arm64': 0.11.410920 '@effect/tsgo-win32-arm64': 0.11.4
8954 '@effect/tsgo-win32-x64': 0.11.410921 '@effect/tsgo-win32-x64': 0.11.4
895510922
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)'
896010927
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:
909611063
9097 '@epic-web/invariant@1.0.0': {}11064 '@epic-web/invariant@1.0.0': {}
909811065
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: true11070 optional: true
910111071
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: true11076 optional: true
910411077
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: true11082 optional: true
910711083
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: true11088 optional: true
911011089
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: true11094 optional: true
911311095
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: true11100 optional: true
911611101
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: true11106 optional: true
911911107
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: true11112 optional: true
912211113
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: true11118 optional: true
912511119
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: true11124 optional: true
912811125
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: true11130 optional: true
913111131
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: true11136 optional: true
913411137
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: true11142 optional: true
913711143
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: true11148 optional: true
914011149
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: true11154 optional: true
914311155
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: true11160 optional: true
914611161
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: true11166 optional: true
914911167
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: true11172 optional: true
915211173
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: true11178 optional: true
915511179
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: true11184 optional: true
915811185
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: true11190 optional: true
916111191
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: true11196 optional: true
916411197
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: true11202 optional: true
916711203
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: true11208 optional: true
917011209
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: true11214 optional: true
917311215
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: true11220 optional: true
917611221
...@@ -9178,7 +11223,7 @@ snapshots:...@@ -9178,7 +11223,7 @@ snapshots:
917811223
9179 '@expo-google-fonts/material-symbols@0.4.38': {}11224 '@expo-google-fonts/material-symbols@0.4.38': {}
918011225
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.611228 '@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.011235 '@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.011237 '@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.311239 '@expo/metro-file-map': 56.0.3
9195 '@expo/osascript': 2.6.011240 '@expo/osascript': 2.6.0
9196 '@expo/package-manager': 1.12.111241 '@expo/package-manager': 1.12.1
...@@ -9202,7 +11247,7 @@ snapshots:...@@ -9202,7 +11247,7 @@ snapshots:
9202 '@expo/spawn-async': 1.8.011247 '@expo/spawn-async': 1.8.0
9203 '@expo/ws-tunnel': 1.0.611248 '@expo/ws-tunnel': 1.0.6
9204 '@expo/xcpretty': 4.4.411249 '@expo/xcpretty': 4.4.4
9205 '@react-native/dev-middleware': 0.85.311250 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
9206 accepts: 1.3.811251 accepts: 1.3.8
9207 arg: 5.0.211252 arg: 5.0.2
9208 bplist-creator: 0.1.011253 bplist-creator: 0.1.0
...@@ -9213,7 +11258,7 @@ snapshots:...@@ -9213,7 +11258,7 @@ snapshots:
9213 connect: 3.7.011258 connect: 3.7.0
9214 debug: 4.4.311259 debug: 4.4.3
9215 dnssd-advertise: 1.1.411260 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.411262 expo-server: 56.0.4
9218 fetch-nodeshim: 0.4.1011263 fetch-nodeshim: 0.4.10
9219 getenv: 2.0.011264 getenv: 2.0.0
...@@ -9236,11 +11281,11 @@ snapshots:...@@ -9236,11 +11281,11 @@ snapshots:
9236 terminal-link: 2.1.111281 terminal-link: 2.1.1
9237 toqr: 0.1.111282 toqr: 0.1.1
9238 wrap-ansi: 7.0.011283 wrap-ansi: 7.0.0
9239 ws: 8.21.011284 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
9240 zod: 3.25.7611285 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-color11348 - supports-color
930411349
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.211352 chalk: 4.1.2
9308 optionalDependencies:11353 optionalDependencies:
9309 react: 19.2.311354 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)
931111356
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.311360 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)
931711362
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-color11419 - supports-color
9375 - typescript11420 - typescript
937611421
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.1011425 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.311427 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.1111429 stacktrace-parser: 0.1.11
938511430
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.711433 '@babel/code-frame': 7.29.7
9389 '@babel/core': 7.29.711434 '@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.011437 '@expo/env': 2.3.0
9393 '@expo/json-file': 10.2.011438 '@expo/json-file': 10.2.0
9394 '@expo/metro': 56.0.011439 '@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.011441 '@expo/spawn-async': 1.8.0
9397 '@jridgewell/gen-mapping': 0.3.1311442 '@jridgewell/gen-mapping': 0.3.13
...@@ -9410,7 +11455,7 @@ snapshots:...@@ -9410,7 +11455,7 @@ snapshots:
9410 postcss: 8.5.1511455 postcss: 8.5.15
9411 resolve-from: 5.0.011456 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 - bufferutil11460 - bufferutil
9416 - supports-color11461 - supports-color
...@@ -9428,26 +11473,26 @@ snapshots:...@@ -9428,26 +11473,26 @@ snapshots:
9428 transitivePeerDependencies:11473 transitivePeerDependencies:
9429 - supports-color11474 - supports-color
943011475
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.1011479 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.011481 pretty-format: 29.7.0
9437 react: 19.2.311482 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.1111484 stacktrace-parser: 0.1.11
9440 whatwg-fetch: 3.6.2011485 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)
944311488
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.411491 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
9447 metro-babel-transformer: 0.84.411492 metro-babel-transformer: 0.84.4
9448 metro-cache: 0.84.411493 metro-cache: 0.84.4
9449 metro-cache-key: 0.84.411494 metro-cache-key: 0.84.4
9450 metro-config: 0.84.411495 metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
9451 metro-core: 0.84.411496 metro-core: 0.84.4
9452 metro-file-map: 0.84.411497 metro-file-map: 0.84.4
9453 metro-minify-terser: 0.84.411498 metro-minify-terser: 0.84.4
...@@ -9456,7 +11501,7 @@ snapshots:...@@ -9456,7 +11501,7 @@ snapshots:
9456 metro-source-map: 0.84.411501 metro-source-map: 0.84.4
9457 metro-symbolicate: 0.84.411502 metro-symbolicate: 0.84.4
9458 metro-transform-plugins: 0.84.411503 metro-transform-plugins: 0.84.4
9459 metro-transform-worker: 0.84.411504 metro-transform-worker: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
9460 transitivePeerDependencies:11505 transitivePeerDependencies:
9461 - bufferutil11506 - bufferutil
9462 - supports-color11507 - 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.311557 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.411561 expo-server: 56.0.4
9517 react: 19.2.311562 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-color11568 - supports-color
...@@ -9532,18 +11577,18 @@ snapshots:...@@ -9532,18 +11577,18 @@ snapshots:
953211577
9533 '@expo/sudo-prompt@9.3.2': {}11578 '@expo/sudo-prompt@9.3.2': {}
953411579
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.311583 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.011585 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.711588 '@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.211802 '@jridgewell/resolve-uri': 3.1.2
9758 '@jridgewell/sourcemap-codec': 1.5.511803 '@jridgewell/sourcemap-codec': 1.5.5
975911804
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.311811 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)
976711816
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.011980 lexical: 0.41.0
9932 yjs: 13.6.3111981 yjs: 13.6.31
993311982
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.612043 cross-spawn: 7.0.6
...@@ -10000,6 +12107,91 @@ snapshots:...@@ -10000,6 +12107,91 @@ snapshots:
10000 '@tybys/wasm-util': 0.10.212107 '@tybys/wasm-util': 0.10.2
10001 optional: true12108 optional: true
1000212109
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': {}
1000412196
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: true12213 optional: true
1002212214
12215 '@oxc-project/types@0.130.0': {}
12216
10023 '@oxc-project/types@0.133.0': {}12217 '@oxc-project/types@0.133.0': {}
1002412218
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.1612582 '@types/react': 19.2.16
1038912583
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.312586 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)
1039412588
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.312591 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)
1039912593
10400 '@react-native/assets-registry@0.85.3': {}12594 '@react-native/assets-registry@0.85.3': {}
1040112595
...@@ -10455,13 +12649,13 @@ snapshots:...@@ -10455,13 +12649,13 @@ snapshots:
10455 tinyglobby: 0.2.1712649 tinyglobby: 0.2.17
10456 yargs: 17.7.212650 yargs: 17.7.2
1045712651
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.312654 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
10461 debug: 4.4.312655 debug: 4.4.3
10462 invariant: 2.2.412656 invariant: 2.2.4
10463 metro: 0.84.412657 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
10464 metro-config: 0.84.412658 metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
10465 metro-core: 0.84.412659 metro-core: 0.84.4
10466 semver: 7.8.112660 semver: 7.8.1
10467 optionalDependencies:12661 optionalDependencies:
...@@ -10481,7 +12675,7 @@ snapshots:...@@ -10481,7 +12675,7 @@ snapshots:
10481 transitivePeerDependencies:12675 transitivePeerDependencies:
10482 - supports-color12676 - supports-color
1048312677
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.112680 '@isaacs/ttlcache': 1.4.1
10487 '@react-native/debugger-frontend': 0.85.312681 '@react-native/debugger-frontend': 0.85.3
...@@ -10494,7 +12688,7 @@ snapshots:...@@ -10494,7 +12688,7 @@ snapshots:
10494 nullthrows: 1.1.112688 nullthrows: 1.1.1
10495 open: 7.4.212689 open: 7.4.2
10496 serve-static: 1.16.312690 serve-static: 1.16.3
10497 ws: 7.5.1112691 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)
10498 transitivePeerDependencies:12692 transitivePeerDependencies:
10499 - bufferutil12693 - bufferutil
10500 - supports-color12694 - supports-color
...@@ -10517,94 +12711,128 @@ snapshots:...@@ -10517,94 +12711,128 @@ snapshots:
10517 dependencies:12711 dependencies:
10518 '@react-native/js-polyfills': 0.85.312712 '@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.412714 metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
10521 metro-runtime: 0.84.412715 metro-runtime: 0.84.4
10522 transitivePeerDependencies:12716 transitivePeerDependencies:
10523 - '@babel/core'12717 - '@babel/core'
10524 - bufferutil
10525 - supports-color12718 - supports-color
10526 - utf-8-validate
1052712719
10528 '@react-native/normalize-colors@0.85.3': {}12720 '@react-native/normalize-colors@0.85.3': {}
1052912721
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.412724 invariant: 2.2.4
10533 nullthrows: 1.1.112725 nullthrows: 1.1.1
10534 react: 19.2.312726 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.1612729 '@types/react': 19.2.16
1053812730
10539 '@rolldown/binding-android-arm64@1.0.0-rc.17':12731 '@rolldown/binding-android-arm64@1.0.0-rc.17':
10540 optional: true12732 optional: true
1054112733
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: true12738 optional: true
1054412739
10545 '@rolldown/binding-darwin-arm64@1.0.0-rc.17':12740 '@rolldown/binding-darwin-arm64@1.0.0-rc.17':
10546 optional: true12741 optional: true
1054712742
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: true12747 optional: true
1055012748
10551 '@rolldown/binding-darwin-x64@1.0.0-rc.17':12749 '@rolldown/binding-darwin-x64@1.0.0-rc.17':
10552 optional: true12750 optional: true
1055312751
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: true12756 optional: true
1055612757
10557 '@rolldown/binding-freebsd-x64@1.0.0-rc.17':12758 '@rolldown/binding-freebsd-x64@1.0.0-rc.17':
10558 optional: true12759 optional: true
1055912760
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: true12765 optional: true
1056212766
10563 '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':12767 '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
10564 optional: true12768 optional: true
1056512769
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: true12774 optional: true
1056812775
10569 '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':12776 '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
10570 optional: true12777 optional: true
1057112778
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: true12783 optional: true
1057412784
10575 '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':12785 '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
10576 optional: true12786 optional: true
1057712787
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: true12792 optional: true
1058012793
10581 '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':12794 '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
10582 optional: true12795 optional: true
1058312796
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: true12801 optional: true
1058612802
10587 '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':12803 '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
10588 optional: true12804 optional: true
1058912805
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: true12810 optional: true
1059212811
10593 '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':12812 '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
10594 optional: true12813 optional: true
1059512814
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: true12819 optional: true
1059812820
10599 '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':12821 '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
10600 optional: true12822 optional: true
1060112823
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: true12828 optional: true
1060412829
10605 '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':12830 '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
10606 optional: true12831 optional: true
1060712832
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: true12837 optional: true
1061012838
...@@ -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: true12844 optional: true
1061712845
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.012855 '@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: true12861 optional: true
1062712862
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: true12867 optional: true
1063012868
10631 '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':12869 '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
10632 optional: true12870 optional: true
1063312871
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: true12876 optional: true
1063612877
...@@ -10812,8 +13053,79 @@ snapshots:...@@ -10812,8 +13053,79 @@ snapshots:
1081213053
10813 '@sindresorhus/is@4.6.0': {}13054 '@sindresorhus/is@4.6.0': {}
1081413055
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': {}
1081613122
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.113131 defer-to-connect: 2.0.1
...@@ -11089,6 +13401,8 @@ snapshots:...@@ -11089,6 +13401,8 @@ snapshots:
1108913401
11090 '@types/aria-query@5.0.4': {}13402 '@types/aria-query@5.0.4': {}
1109113403
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.713408 '@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: true13644 optional: true
1133113645
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.013648 '@standard-schema/spec': 1.1.0
11335 '@types/chai': 5.2.313649 '@types/chai': 5.2.3
...@@ -11344,7 +13658,7 @@ snapshots:...@@ -11344,7 +13658,7 @@ snapshots:
11344 tinyexec: 1.2.413658 tinyexec: 1.2.4
11345 tinyglobby: 0.2.1713659 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.013661 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
11348 optionalDependencies:13662 optionalDependencies:
11349 '@types/node': 24.12.413663 '@types/node': 24.12.4
11350 transitivePeerDependencies:13664 transitivePeerDependencies:
...@@ -11433,6 +13747,12 @@ snapshots:...@@ -11433,6 +13747,12 @@ snapshots:
1143313747
11434 '@xterm/xterm@6.0.0': {}13748 '@xterm/xterm@6.0.0': {}
1143513749
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: {}
1143713757
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.013799 json-schema-traverse: 1.0.0
11480 require-from-string: 2.0.213800 require-from-string: 2.0.2
1148113801
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: {}
1148313861
11484 ansi-escapes@4.3.2:13862 ansi-escapes@4.3.2:
11485 dependencies:13863 dependencies:
11486 type-fest: 0.21.313864 type-fest: 0.21.3
1148713865
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: {}
1148913871
11490 ansi-regex@5.0.1: {}13872 ansi-regex@5.0.1: {}
1149113873
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.313878 color-convert: 1.9.3
...@@ -11499,6 +13883,8 @@ snapshots:...@@ -11499,6 +13883,8 @@ snapshots:
1149913883
11500 ansi-styles@5.2.0: {}13884 ansi-styles@5.2.0: {}
1150113885
13886 ansi-styles@6.2.3: {}
13887
11502 ansis@4.3.1: {}13888 ansis@4.3.1: {}
1150313889
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: true13966 optional: true
1158113967
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.013970 '@astrojs/compiler': 4.0.0
11585 '@astrojs/internal-helpers': 0.10.013971 '@astrojs/internal-helpers': 0.10.0
...@@ -11629,7 +14015,7 @@ snapshots:...@@ -11629,7 +14015,7 @@ snapshots:
11629 ultrahtml: 1.6.014015 ultrahtml: 1.6.0
11630 unifont: 0.7.414016 unifont: 0.7.4
11631 unist-util-visit: 5.1.014017 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.314019 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:
1168714073
11688 at-least-node@1.0.0: {}14074 at-least-node@1.0.0: {}
1168914075
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: {}
1169114083
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'
1174014132
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.714135 '@babel/generator': 7.29.7
11744 '@babel/helper-module-imports': 7.29.714136 '@babel/helper-module-imports': 7.29.7
...@@ -11785,11 +14177,14 @@ snapshots:...@@ -11785,11 +14177,14 @@ snapshots:
11785 react-refresh: 0.14.214177 react-refresh: 0.14.2
11786 optionalDependencies:14178 optionalDependencies:
11787 '@babel/runtime': 7.29.714179 '@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-color14184 - supports-color
1179214185
14186 badgin@1.2.3: {}
14187
11793 bail@2.0.2: {}14188 bail@2.0.2: {}
1179414189
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'
1180414199
14200 base-64@1.0.0: {}
14201
11805 base64-js@1.5.1: {}14202 base64-js@1.5.1: {}
1180614203
11807 baseline-browser-mapping@2.10.33: {}14204 baseline-browser-mapping@2.10.33: {}
1180814205
14206 before-after-hook@4.0.0: {}
14207
11809 big-integer@1.6.52: {}14208 big-integer@1.6.52: {}
1181014209
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: true14227 optional: true
1182914228
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.014233 stream-buffers: 2.2.0
...@@ -11856,6 +14257,10 @@ snapshots:...@@ -11856,6 +14257,10 @@ snapshots:
11856 dependencies:14257 dependencies:
11857 fill-range: 7.1.114258 fill-range: 7.1.1
1185814259
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.3314266 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.114282 base64-js: 1.5.1
11878 ieee754: 1.2.114283 ieee754: 1.2.1
14284
14285 bufferutil@4.1.0:
14286 dependencies:
14287 node-gyp-build: 4.8.4
11879 optional: true14288 optional: true
1188014289
11881 builder-util-runtime@9.5.1:14290 builder-util-runtime@9.5.1:
...@@ -11938,6 +14347,10 @@ snapshots:...@@ -11938,6 +14347,10 @@ snapshots:
1193814347
11939 caniuse-lite@1.0.30001793: {}14348 caniuse-lite@1.0.30001793: {}
1194014349
14350 capnweb@0.6.1: {}
14351
14352 capnweb@0.7.0: {}
14353
11941 ccount@2.0.1: {}14354 ccount@2.0.1: {}
1194214355
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.014364 ansi-styles: 4.3.0
11952 supports-color: 7.2.014365 supports-color: 7.2.0
1195314366
14367 chalk@5.6.2: {}
14368
11954 character-entities-html4@2.1.0: {}14369 character-entities-html4@2.1.0: {}
1195514370
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.114418 clsx: 2.1.1
1200414419
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.014424 restore-cursor: 2.0.0
1200814425
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: {}
1201014431
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.314435 string-width: 4.2.3
12015 optional: true14436 optional: true
1201614437
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: {}
1201814444
12019 client-only@0.0.1: {}14445 client-only@0.0.1: {}
...@@ -12034,6 +14460,10 @@ snapshots:...@@ -12034,6 +14460,10 @@ snapshots:
1203414460
12035 cluster-key-slot@1.1.1: {}14461 cluster-key-slot@1.1.1: {}
1203614462
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.314469 color-name: 1.1.3
...@@ -12114,6 +14544,8 @@ snapshots:...@@ -12114,6 +14544,8 @@ snapshots:
1211414544
12115 convert-source-map@2.0.0: {}14545 convert-source-map@2.0.0: {}
1211614546
14547 convert-to-spaces@2.0.1: {}
14548
12117 cookie-es@1.2.3: {}14549 cookie-es@1.2.3: {}
1211814550
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.214561 browserslist: 4.28.2
1213014562
12131 core-util-is@1.0.2:14563 core-js@3.47.0: {}
12132 optional: true14564
14565 core-util-is@1.0.2: {}
1213314566
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.314593 uncrypto: 0.1.3
1216114594
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.014599 boolbase: 1.0.0
...@@ -12252,6 +14687,8 @@ snapshots:...@@ -12252,6 +14687,8 @@ snapshots:
1225214687
12253 destroy@1.2.0: {}14688 destroy@1.2.0: {}
1225414689
14690 detect-libc@2.0.2: {}
14691
12255 detect-libc@2.1.2: {}14692 detect-libc@2.1.2: {}
1225614693
12257 detect-node-es@1.1.0: {}14694 detect-node-es@1.1.0: {}
...@@ -12327,6 +14764,25 @@ snapshots:...@@ -12327,6 +14764,25 @@ snapshots:
1232714764
12328 dotenv@16.6.1: {}14765 dotenv@16.6.1: {}
1232914766
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: {}
1233114787
12332 dunder-proto@1.0.1:14788 dunder-proto@1.0.1:
...@@ -12337,7 +14793,7 @@ snapshots:...@@ -12337,7 +14793,7 @@ snapshots:
1233714793
12338 ee-first@1.1.1: {}14794 ee-first@1.1.1: {}
1233914795
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.014798 '@standard-schema/spec': 1.1.0
12343 fast-check: 4.8.014799 fast-check: 4.8.0
...@@ -12432,6 +14888,8 @@ snapshots:...@@ -12432,6 +14888,8 @@ snapshots:
12432 '@emmetio/abbreviation': 2.3.314888 '@emmetio/abbreviation': 2.3.3
12433 '@emmetio/css-abbreviation': 2.1.814889 '@emmetio/css-abbreviation': 2.1.8
1243414890
14891 emoji-regex@10.6.0: {}
14892
12435 emoji-regex@8.0.0: {}14893 emoji-regex@8.0.0: {}
1243614894
12437 encodeurl@1.0.2: {}14895 encodeurl@1.0.2: {}
...@@ -12453,6 +14911,8 @@ snapshots:...@@ -12453,6 +14911,8 @@ snapshots:
1245314911
12454 env-paths@2.2.1: {}14912 env-paths@2.2.1: {}
1245514913
14914 environment@1.1.0: {}
14915
12456 err-code@2.0.3: {}14916 err-code@2.0.3: {}
1245714917
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.214938 has-tostringtag: 1.0.2
12479 hasown: 2.0.414939 hasown: 2.0.4
1248014940
14941 es-toolkit@1.47.0: {}
14942
12481 es6-error@4.1.1:14943 es6-error@4.1.1:
12482 optional: true14944 optional: true
1248314945
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.714977 '@esbuild/aix-ppc64': 0.27.7
...@@ -12516,6 +15007,8 @@ snapshots:...@@ -12516,6 +15007,8 @@ snapshots:
1251615007
12517 escape-string-regexp@1.0.5: {}15008 escape-string-regexp@1.0.5: {}
1251815009
15010 escape-string-regexp@2.0.0: {}
15011
12519 escape-string-regexp@4.0.0: {}15012 escape-string-regexp@4.0.0: {}
1252015013
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.015030 eventsource-parser: 3.1.0
1253815031
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.315041 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-color15044 - supports-color
12548 - typescript15045 - typescript
1254915046
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.115063 '@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.015065 resolve-from: 5.0.0
12555 semver: 7.8.115066 semver: 7.8.1
1255615067
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.315072 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'
1256515076
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.315080 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)
1257115082
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.015085 '@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-color15089 - supports-color
1257915090
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)
1258315094
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-native15104 - react-native
1259415105
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.115108 '@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)
1260215113
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)
1260615117
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)
1261215123
12613 expo-eas-client@56.0.1: {}15124 expo-eas-client@56.0.1: {}
1261415125
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)
1261915130
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.015134 fontfaceobserver: 2.3.0
12624 react: 19.2.315135 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)
1262615137
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.315141 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)
1263215143
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)
1263615147
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)
1264015151
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)
1264515156
12646 expo-json-utils@56.0.0: {}15157 expo-json-utils@56.0.0: {}
1264715158
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.315162 react: 19.2.3
1265215163
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.415167 invariant: 2.2.4
12657 react: 19.2.315168 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 - expo15171 - expo
12661 - supports-color15172 - supports-color
1266215173
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.015177 expo-json-utils: 56.0.0
1266715178
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-color15186 - supports-color
12676 - typescript15187 - typescript
1267715188
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.915191 '@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.415193 invariant: 2.2.4
12683 react: 19.2.315194 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)
1268715198
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
1269115216
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.315220 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)
1269715222
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.115227 '@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.115232 '@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.115234 client-only: 0.0.1
12710 color: 4.2.315235 color: 4.2.3
12711 debug: 4.4.315236 debug: 4.4.3
12712 escape-string-regexp: 4.0.015237 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.415242 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.315244 fast-deep-equal: 3.1.3
12720 invariant: 2.2.415245 invariant: 2.2.4
12721 nanoid: 3.3.1215246 nanoid: 3.3.12
...@@ -12723,18 +15248,18 @@ snapshots:...@@ -12723,18 +15248,18 @@ snapshots:
12723 react: 19.2.315248 react: 19.2.3
12724 react-fast-compare: 3.2.215249 react-fast-compare: 3.2.2
12725 react-is: 19.2.715250 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.115255 server-only: 0.0.1
12731 sf-symbols-typescript: 2.2.015256 sf-symbols-typescript: 2.2.0
12732 shallowequal: 1.1.015257 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:
1274615271
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)
1275015275
12751 expo-server@56.0.4: {}15276 expo-server@56.0.4: {}
1275215277
...@@ -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.015283 xml2js: 0.6.0
12759 transitivePeerDependencies:15284 transitivePeerDependencies:
12760 - supports-color15285 - supports-color
...@@ -12762,20 +15287,20 @@ snapshots:...@@ -12762,20 +15287,20 @@ snapshots:
1276215287
12763 expo-structured-headers@56.0.0: {}15288 expo-structured-headers@56.0.0: {}
1276415289
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.3815292 '@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.315295 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.015297 sf-symbols-typescript: 2.2.0
1277315298
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)
1277715302
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.615305 '@expo/code-signing-certificates': 0.0.6
12781 '@expo/plist': 0.7.015306 '@expo/plist': 0.7.0
...@@ -12783,7 +15308,7 @@ snapshots:...@@ -12783,7 +15308,7 @@ snapshots:
12783 arg: 4.1.315308 arg: 4.1.3
12784 chalk: 4.1.215309 chalk: 4.1.2
12785 debug: 4.4.315310 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.115312 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.015314 expo-structured-headers: 56.0.0
...@@ -12793,42 +15318,62 @@ snapshots:...@@ -12793,42 +15318,62 @@ snapshots:
12793 ignore: 5.3.215318 ignore: 5.3.2
12794 nullthrows: 1.1.115319 nullthrows: 1.1.1
12795 react: 19.2.315320 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.015322 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-color15326 - supports-color
1280215327
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.715350 '@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.315355 '@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.015358 '@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.115360 '@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.015369 pretty-format: 29.7.0
12825 react: 19.2.315370 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.215372 react-refresh: 0.14.2
12828 whatwg-url-minimum: 0.1.215373 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:
1290215447
12903 fast-deep-equal@3.1.3: {}15448 fast-deep-equal@3.1.3: {}
1290415449
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: {}
1290615459
15460 fast-sha256@1.3.0: {}
15461
12907 fast-string-truncated-width@3.0.3: {}15462 fast-string-truncated-width@3.0.3: {}
1290815463
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.215472 fast-string-width: 3.0.2
1291815473
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: {}
1292015501
12921 fb-watchman@2.0.2:15502 fb-watchman@2.0.2:
...@@ -13036,10 +15617,16 @@ snapshots:...@@ -13036,10 +15617,16 @@ snapshots:
1303615617
13037 function-bind@1.1.2: {}15618 function-bind@1.1.2: {}
1303815619
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: {}
1304015625
13041 get-caller-file@2.0.5: {}15626 get-caller-file@2.0.5: {}
1304215627
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.215632 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.415652 pump: 3.0.4
1306615653
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.015660 resolve-pkg-maps: 1.0.0
...@@ -13072,6 +15663,12 @@ snapshots:...@@ -13072,6 +15663,12 @@ snapshots:
1307215663
13073 github-slugger@2.0.0: {}15664 github-slugger@2.0.0: {}
1307415665
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.515674 minimatch: 10.2.5
...@@ -13340,17 +15937,23 @@ snapshots:...@@ -13340,17 +15937,23 @@ snapshots:
13340 dependencies:15937 dependencies:
13341 safer-buffer: 2.1.215938 safer-buffer: 2.1.2
1334215939
13343 ieee754@1.2.1:15940 idb-keyval@6.2.1:
13344 optional: true15941 optional: true
1334515942
15943 ieee754@1.2.1: {}
15944
13346 ignore@5.3.2: {}15945 ignore@5.3.2: {}
1334715946
13348 image-size@1.2.1:15947 image-size@1.2.1:
13349 dependencies:15948 dependencies:
13350 queue: 6.0.215949 queue: 6.0.2
1335115950
15951 immediate@3.0.6: {}
15952
13352 indent-string@4.0.0: {}15953 indent-string@4.0.0: {}
1335315954
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.015959 once: 1.4.0
...@@ -13360,6 +15963,41 @@ snapshots:...@@ -13360,6 +15963,41 @@ snapshots:
1336015963
13361 ini@7.0.0: {}15964 ini@7.0.0: {}
1336215965
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: {}
1336416002
13365 invariant@2.2.4:16003 invariant@2.2.4:
...@@ -13405,10 +16043,22 @@ snapshots:...@@ -13405,10 +16043,22 @@ snapshots:
1340516043
13406 is-docker@4.0.0: {}16044 is-docker@4.0.0: {}
1340716045
16046 is-extglob@2.1.1: {}
16047
13408 is-fullwidth-code-point@3.0.0: {}16048 is-fullwidth-code-point@3.0.0: {}
1340916049
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: {}
1341116059
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.016064 is-docker: 3.0.0
...@@ -13421,6 +16071,8 @@ snapshots:...@@ -13421,6 +16071,8 @@ snapshots:
1342116071
13422 is-promise@4.0.0: {}16072 is-promise@4.0.0: {}
1342316073
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.116078 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.016082 is-inside-container: 1.0.0
1343116083
16084 isarray@1.0.0: {}
16085
13432 isbinaryfile@4.0.10: {}16086 isbinaryfile@4.0.10: {}
1343316087
13434 isbinaryfile@5.0.7: {}16088 isbinaryfile@5.0.7: {}
...@@ -13480,14 +16134,22 @@ snapshots:...@@ -13480,14 +16134,22 @@ snapshots:
1348016134
13481 jiti@2.7.0: {}16135 jiti@2.7.0: {}
1348216136
16137 jose@6.2.2: {}
16138
13483 jose@6.2.3: {}16139 jose@6.2.3: {}
1348416140
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: {}
1348616146
13487 js-yaml@4.2.0:16147 js-yaml@4.2.0:
13488 dependencies:16148 dependencies:
13489 argparse: 2.0.116149 argparse: 2.0.1
1349016150
16151 jsbi@4.3.2: {}
16152
13491 jsc-safe-url@0.2.4: {}16153 jsc-safe-url@0.2.4: {}
1349216154
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: true16171 optional: true
1351016172
16173 json-with-bigint@3.5.8: {}
16174
13511 json5@2.2.3: {}16175 json5@2.2.3: {}
1351216176
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.1116189 graceful-fs: 4.2.11
1352616190
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.116200 json-buffer: 3.0.1
...@@ -13546,6 +16217,31 @@ snapshots:...@@ -13546,6 +16217,31 @@ snapshots:
13546 dependencies:16217 dependencies:
13547 isomorphic.js: 0.2.516218 isomorphic.js: 0.2.5
1354816219
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.916247 debug: 2.6.9
...@@ -13710,6 +16406,8 @@ snapshots:...@@ -13710,6 +16406,8 @@ snapshots:
13710 dependencies:16406 dependencies:
13711 chalk: 2.4.216407 chalk: 2.4.2
1371216408
16409 long@5.3.2: {}
16410
13713 longest-streak@3.1.0: {}16411 longest-streak@3.1.0: {}
1371416412
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.016429 yallist: 4.0.0
1373216430
16431 lru.min@1.1.4: {}
16432
13733 lru_map@0.4.1: {}16433 lru_map@0.4.1: {}
1373416434
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:
1393616636
13937 merge-stream@2.0.0: {}16637 merge-stream@2.0.0: {}
1393816638
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.716643 '@babel/core': 7.29.7
...@@ -13959,12 +16661,12 @@ snapshots:...@@ -13959,12 +16661,12 @@ snapshots:
13959 transitivePeerDependencies:16661 transitivePeerDependencies:
13960 - supports-color16662 - supports-color
1396116663
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.016666 connect: 3.7.0
13965 flow-enums-runtime: 0.0.616667 flow-enums-runtime: 0.0.6
13966 jest-validate: 29.7.016668 jest-validate: 29.7.0
13967 metro: 0.84.416669 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
13968 metro-cache: 0.84.416670 metro-cache: 0.84.4
13969 metro-core: 0.84.416671 metro-core: 0.84.4
13970 metro-runtime: 0.84.416672 metro-runtime: 0.84.4
...@@ -14044,14 +16746,14 @@ snapshots:...@@ -14044,14 +16746,14 @@ snapshots:
14044 transitivePeerDependencies:16746 transitivePeerDependencies:
14045 - supports-color16747 - supports-color
1404616748
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.716751 '@babel/core': 7.29.7
14050 '@babel/generator': 7.29.716752 '@babel/generator': 7.29.7
14051 '@babel/parser': 7.29.716753 '@babel/parser': 7.29.7
14052 '@babel/types': 7.29.716754 '@babel/types': 7.29.7
14053 flow-enums-runtime: 0.0.616755 flow-enums-runtime: 0.0.6
14054 metro: 0.84.416756 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
14055 metro-babel-transformer: 0.84.416757 metro-babel-transformer: 0.84.4
14056 metro-cache: 0.84.416758 metro-cache: 0.84.4
14057 metro-cache-key: 0.84.416759 metro-cache-key: 0.84.4
...@@ -14064,7 +16766,7 @@ snapshots:...@@ -14064,7 +16766,7 @@ snapshots:
14064 - supports-color16766 - supports-color
14065 - utf-8-validate16767 - utf-8-validate
1406616768
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.716771 '@babel/code-frame': 7.29.7
14070 '@babel/core': 7.29.716772 '@babel/core': 7.29.7
...@@ -14089,7 +16791,7 @@ snapshots:...@@ -14089,7 +16791,7 @@ snapshots:
14089 metro-babel-transformer: 0.84.416791 metro-babel-transformer: 0.84.4
14090 metro-cache: 0.84.416792 metro-cache: 0.84.4
14091 metro-cache-key: 0.84.416793 metro-cache-key: 0.84.4
14092 metro-config: 0.84.416794 metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
14093 metro-core: 0.84.416795 metro-core: 0.84.4
14094 metro-file-map: 0.84.416796 metro-file-map: 0.84.4
14095 metro-resolver: 0.84.416797 metro-resolver: 0.84.4
...@@ -14097,13 +16799,13 @@ snapshots:...@@ -14097,13 +16799,13 @@ snapshots:
14097 metro-source-map: 0.84.416799 metro-source-map: 0.84.4
14098 metro-symbolicate: 0.84.416800 metro-symbolicate: 0.84.4
14099 metro-transform-plugins: 0.84.416801 metro-transform-plugins: 0.84.4
14100 metro-transform-worker: 0.84.416802 metro-transform-worker: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
14101 mime-types: 3.0.216803 mime-types: 3.0.2
14102 nullthrows: 1.1.116804 nullthrows: 1.1.1
14103 serialize-error: 2.1.016805 serialize-error: 2.1.0
14104 source-map: 0.5.716806 source-map: 0.5.7
14105 throat: 5.0.016807 throat: 5.0.0
14106 ws: 7.5.1116808 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)
14107 yargs: 17.7.216809 yargs: 17.7.2
14108 transitivePeerDependencies:16810 transitivePeerDependencies:
14109 - bufferutil16811 - bufferutil
...@@ -14326,6 +17028,8 @@ snapshots:...@@ -14326,6 +17028,8 @@ snapshots:
1432617028
14327 mimic-fn@1.2.0: {}17029 mimic-fn@1.2.0: {}
1432817030
17031 mimic-fn@2.1.0: {}
17032
14329 mimic-response@1.0.1: {}17033 mimic-response@1.0.1: {}
1433017034
14331 mimic-response@3.1.0: {}17035 mimic-response@3.1.0: {}
...@@ -14417,6 +17121,22 @@ snapshots:...@@ -14417,6 +17121,22 @@ snapshots:
1441717121
14418 mute-stream@2.0.0: {}17122 mute-stream@2.0.0: {}
1441917123
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: {}
1442117141
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.217173 detect-libc: 2.1.2
14454 optional: true17174 optional: true
1445517175
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.117181 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: true17232 optional: true
1451017233
17234 obuf@1.1.2: {}
17235
14511 obug@2.1.1: {}17236 obug@2.1.1: {}
1451217237
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.017262 mimic-fn: 1.2.0
1453817263
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: {}
1454017269
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.017287 strip-ansi: 5.2.0
14559 wcwidth: 1.0.117288 wcwidth: 1.0.1
1456017289
17290 os-paths@7.4.0:
17291 optionalDependencies:
17292 fsevents: 2.3.3
17293
14561 outvariant@1.4.3: {}17294 outvariant@1.4.3: {}
1456217295
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.017298 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.017316 '@oxfmt/binding-win32-arm64-msvc': 0.52.0
14584 '@oxfmt/binding-win32-ia32-msvc': 0.52.017317 '@oxfmt/binding-win32-ia32-msvc': 0.52.0
14585 '@oxfmt/binding-win32-x64-msvc': 0.52.017318 '@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)
1458717320
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.017327 '@oxlint-tsgolint/win32-arm64': 0.23.0
14595 '@oxlint-tsgolint/win32-x64': 0.23.017328 '@oxlint-tsgolint/win32-x64': 0.23.0
1459617329
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.017332 '@oxlint/binding-android-arm-eabi': 1.67.0
14600 '@oxlint/binding-android-arm64': 1.67.017333 '@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.017349 '@oxlint/binding-win32-ia32-msvc': 1.67.0
14617 '@oxlint/binding-win32-x64-msvc': 1.67.017350 '@oxlint/binding-win32-x64-msvc': 1.67.0
14618 oxlint-tsgolint: 0.23.017351 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)
1462017353
14621 p-cancelable@2.1.1: {}17354 p-cancelable@2.1.1: {}
1462217355
...@@ -14637,6 +17370,8 @@ snapshots:...@@ -14637,6 +17370,8 @@ snapshots:
1463717370
14638 package-manager-detector@1.6.0: {}17371 package-manager-detector@1.6.0: {}
1463917372
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.1117377 '@types/unist': 2.0.11
...@@ -14666,8 +17401,12 @@ snapshots:...@@ -14666,8 +17401,12 @@ snapshots:
1466617401
14667 parseurl@1.3.3: {}17402 parseurl@1.3.3: {}
1466817403
17404 patch-console@2.0.0: {}
17405
14669 path-browserify@1.0.1: {}17406 path-browserify@1.0.1: {}
1467017407
17408 path-expression-matcher@1.5.0: {}
17409
14671 path-is-absolute@1.0.1: {}17410 path-is-absolute@1.0.1: {}
1467217411
14673 path-key@3.1.1: {}17412 path-key@3.1.1: {}
...@@ -14691,6 +17430,59 @@ snapshots:...@@ -14691,6 +17430,59 @@ snapshots:
1469117430
14692 pend@1.2.0: {}17431 pend@1.2.0: {}
1469317432
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: {}
1469517487
14696 picocolors@1.1.1: {}17488 picocolors@1.1.1: {}
...@@ -14735,6 +17527,28 @@ snapshots:...@@ -14735,6 +17527,28 @@ snapshots:
14735 picocolors: 1.1.117527 picocolors: 1.1.1
14736 source-map-js: 1.2.117528 source-map-js: 1.2.1
1473717529
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.017554 commander: 9.5.0
...@@ -14764,8 +17578,12 @@ snapshots:...@@ -14764,8 +17578,12 @@ snapshots:
1476417578
14765 proc-log@6.1.0: {}17579 proc-log@6.1.0: {}
1476617580
17581 process-nextick-args@2.0.1: {}
17582
14767 progress@2.0.3: {}17583 progress@2.0.3: {}
1476817584
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.317589 err-code: 2.0.3
...@@ -14813,6 +17631,8 @@ snapshots:...@@ -14813,6 +17631,8 @@ snapshots:
14813 split-on-first: 1.1.017631 split-on-first: 1.1.0
14814 strict-uri-encode: 2.0.017632 strict-uri-encode: 2.0.0
1481517633
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.417638 inherits: 2.0.4
...@@ -14830,10 +17650,10 @@ snapshots:...@@ -14830,10 +17650,10 @@ snapshots:
14830 iconv-lite: 0.7.217650 iconv-lite: 0.7.2
14831 unpipe: 1.0.017651 unpipe: 1.0.0
1483217652
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.417655 shell-quote: 1.8.4
14836 ws: 7.5.1117656 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)
14837 transitivePeerDependencies:17657 transitivePeerDependencies:
14838 - bufferutil17658 - bufferutil
14839 - utf-8-validate17659 - utf-8-validate
...@@ -14884,90 +17704,95 @@ snapshots:...@@ -14884,90 +17704,95 @@ snapshots:
14884 transitivePeerDependencies:17704 transitivePeerDependencies:
14885 - supports-color17705 - supports-color
1488617706
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.317709 color: 4.2.3
14890 react: 19.2.317710 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)
1489517715
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.1717718 '@egjs/hammerjs': 2.0.17
14899 '@types/react-test-renderer': 19.1.017719 '@types/react-test-renderer': 19.1.0
14900 hoist-non-react-statics: 3.3.217720 hoist-non-react-statics: 3.3.2
14901 invariant: 2.2.417721 invariant: 2.2.4
14902 react: 19.2.317722 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)
1490417724
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.317727 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)
1490917729
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.317732 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)
1491417734
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.317737 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)
1492117741
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.317744 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)
1492917749
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.317752 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)
1493417754
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.317757 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.117761 semver: 7.8.1
1494217762
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.317765 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)
1494717767
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.317770 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.117773 warn-once: 0.1.1
1495417774
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.017777 '@shikijs/types': 3.23.0
14958 '@shikijs/vscode-textmate': 10.0.217778 '@shikijs/vscode-textmate': 10.0.2
14959 react: 19.2.317779 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)
1496117781
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.217784 css-select: 5.2.2
14965 css-tree: 1.1.317785 css-tree: 1.1.3
14966 react: 19.2.317786 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.117788 warn-once: 0.1.1
1496917789
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.717797 '@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.017808 convert-source-map: 2.0.0
14984 react: 19.2.317809 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.117811 semver: 7.8.1
14987 transitivePeerDependencies:17812 transitivePeerDependencies:
14988 - supports-color17813 - supports-color
1498917814
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.317817 '@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.317820 '@react-native/gradle-plugin': 0.85.3
14996 '@react-native/js-polyfills': 0.85.317821 '@react-native/js-polyfills': 0.85.3
14997 '@react-native/normalize-colors': 0.85.317822 '@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.017824 abort-controller: 3.0.0
15000 anser: 1.4.1017825 anser: 1.4.10
15001 ansi-regex: 5.0.117826 ansi-regex: 5.0.1
...@@ -15012,7 +17837,7 @@ snapshots:...@@ -15012,7 +17837,7 @@ snapshots:
15012 pretty-format: 29.7.017837 pretty-format: 29.7.0
15013 promise: 8.3.017838 promise: 8.3.0
15014 react: 19.2.317839 react: 19.2.3
15015 react-devtools-core: 6.1.517840 react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)
15016 react-refresh: 0.14.217841 react-refresh: 0.14.2
15017 regenerator-runtime: 0.13.1117842 regenerator-runtime: 0.13.11
15018 scheduler: 0.27.017843 scheduler: 0.27.0
...@@ -15020,7 +17845,7 @@ snapshots:...@@ -15020,7 +17845,7 @@ snapshots:
15020 stacktrace-parser: 0.1.1117845 stacktrace-parser: 0.1.11
15021 tinyglobby: 0.2.1717846 tinyglobby: 0.2.17
15022 whatwg-fetch: 3.6.2017847 whatwg-fetch: 3.6.20
15023 ws: 7.5.1117848 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)
15024 yargs: 17.7.217849 yargs: 17.7.2
15025 optionalDependencies:17850 optionalDependencies:
15026 '@types/react': 19.2.1617851 '@types/react': 19.2.16
...@@ -15032,6 +17857,11 @@ snapshots:...@@ -15032,6 +17857,11 @@ snapshots:
15032 - supports-color17857 - supports-color
15033 - utf-8-validate17858 - utf-8-validate
1503417859
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: {}
1503617866
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-color17902 - supports-color
1507317903
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: {}
1507517915
15076 readdirp@5.0.0: {}17916 readdirp@5.0.0: {}
...@@ -15223,6 +18063,11 @@ snapshots:...@@ -15223,6 +18063,11 @@ snapshots:
15223 onetime: 2.0.118063 onetime: 2.0.1
15224 signal-exit: 3.0.718064 signal-exit: 3.0.7
1522518065
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.318073 '@types/nlcst': 2.0.3
...@@ -15252,6 +18097,8 @@ snapshots:...@@ -15252,6 +18097,8 @@ snapshots:
1525218097
15253 rettime@0.10.1: {}18098 rettime@0.10.1: {}
1525418099
18100 reusify@1.1.0: {}
18101
15255 rimraf@2.6.3:18102 rimraf@2.6.3:
15256 dependencies:18103 dependencies:
15257 glob: 7.2.318104 glob: 7.2.3
...@@ -15288,6 +18135,27 @@ snapshots:...@@ -15288,6 +18135,27 @@ snapshots:
15288 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.1718135 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
15289 optional: true18136 optional: true
1529018137
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.018161 '@oxc-project/types': 0.133.0
...@@ -15351,6 +18219,12 @@ snapshots:...@@ -15351,6 +18219,12 @@ snapshots:
15351 transitivePeerDependencies:18219 transitivePeerDependencies:
15352 - supports-color18220 - supports-color
1535318221
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: {}
1535518229
15356 safer-buffer@2.1.2: {}18230 safer-buffer@2.1.2: {}
...@@ -15441,6 +18315,8 @@ snapshots:...@@ -15441,6 +18315,8 @@ snapshots:
1544118315
15442 server-only@0.0.1: {}18316 server-only@0.0.1: {}
1544318317
18318 setimmediate@1.0.5: {}
18319
15444 setprototypeof@1.2.0: {}18320 setprototypeof@1.2.0: {}
1544518321
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.018446 is-fullwidth-code-point: 3.0.0
15571 optional: true18447 optional: true
1557218448
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: {}
1557418455
15575 smart-buffer@4.2.0:18456 smart-buffer@4.2.0:
...@@ -15592,9 +18473,17 @@ snapshots:...@@ -15592,9 +18473,17 @@ snapshots:
1559218473
15593 split-on-first@1.1.0: {}18474 split-on-first@1.1.0: {}
1559418475
18476 split2@4.2.0: {}
18477
15595 sprintf-js@1.1.3:18478 sprintf-js@1.1.3:
15596 optional: true18479 optional: true
1559718480
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: {}
1559918488
15600 stacktrace-parser@0.1.11:18489 stacktrace-parser@0.1.11:
...@@ -15603,6 +18492,11 @@ snapshots:...@@ -15603,6 +18492,11 @@ snapshots:
1560318492
15604 standard-as-callback@2.1.0: {}18493 standard-as-callback@2.1.0: {}
1560518494
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: {}
1560718501
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.018517 is-fullwidth-code-point: 3.0.0
15624 strip-ansi: 6.0.118518 strip-ansi: 6.0.1
1562518519
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.018537 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.118546 ansi-regex: 5.0.1
1563818547
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.118554 min-indent: 1.0.1
1564218555
18556 strnum@2.3.0: {}
18557
15643 structured-headers@0.4.1: {}18558 structured-headers@0.4.1: {}
1564418559
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.218635 ansi-escapes: 4.3.2
15721 supports-hyperlinks: 2.3.018636 supports-hyperlinks: 2.3.0
1572218637
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.1118642 '@jridgewell/source-map': 0.3.11
...@@ -15831,8 +18748,14 @@ snapshots:...@@ -15831,8 +18748,14 @@ snapshots:
1583118748
15832 undici@6.26.0: {}18749 undici@6.26.0: {}
1583318750
18751 undici@7.27.1: {}
18752
15834 undici@8.3.0: {}18753 undici@8.3.0: {}
1583518754
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: {}
1583718760
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.118825 unist-util-is: 6.0.1
15903 unist-util-visit-parents: 6.0.218826 unist-util-visit-parents: 6.0.2
1590418827
18828 universal-user-agent@7.0.3: {}
18829
15905 universalify@0.1.2: {}18830 universalify@0.1.2: {}
1590618831
15907 universalify@2.0.1: {}18832 universalify@2.0.1: {}
1590818833
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.118836 '@tailwindcss/node': 4.2.1
15912 '@tailwindcss/oxide': 4.2.118837 '@tailwindcss/oxide': 4.2.1
15913 culori: 4.0.218838 culori: 4.0.2
15914 lightningcss: 1.30.118839 lightningcss: 1.30.1
15915 react: 19.2.318840 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.018842 tailwindcss: 4.3.0
1591818843
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.1718854 rolldown: 1.0.0-rc.17
15930 optional: true18855 optional: true
1593118856
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.318859 anymatch: 3.1.3
15935 chokidar: 5.0.018860 chokidar: 5.0.0
...@@ -15940,6 +18865,8 @@ snapshots:...@@ -15940,6 +18865,8 @@ snapshots:
15940 ofetch: 1.5.118865 ofetch: 1.5.1
15941 ufo: 1.6.418866 ufo: 1.6.4
15942 optionalDependencies:18867 optionalDependencies:
18868 aws4fetch: 1.0.20
18869 idb-keyval: 6.2.1
15943 ioredis: 5.11.018870 ioredis: 5.11.0
1594418871
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.618909 react: 19.2.6
1598318910
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: {}
1598518917
18918 util-deprecate@1.0.2: {}
18919
15986 utils-merge@1.0.1: {}18920 utils-merge@1.0.1: {}
1598718921
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.318958 '@types/unist': 3.0.3
16025 vfile-message: 4.0.318959 vfile-message: 4.0.3
1602618960
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.018963 '@oxc-project/types': 0.133.0
16030 '@oxlint/plugins': 1.61.018964 '@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.018969 oxlint-tsgolint: 0.23.0
16036 optionalDependencies:18970 optionalDependencies:
16037 '@voidzero-dev/vite-plus-darwin-arm64': 0.1.2418971 '@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)'
1608019014
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.619017 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.1619021 '@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:
1619819132
16199 web-namespaces@2.0.1: {}19133 web-namespaces@2.0.1: {}
1620019134
19135 webidl-conversions@5.0.0: {}
19136
16201 webpack-virtual-modules@0.6.2: {}19137 webpack-virtual-modules@0.6.2: {}
1620219138
16203 whatwg-fetch@3.6.20: {}19139 whatwg-fetch@3.6.20: {}
1620419140
16205 whatwg-url-minimum@0.1.2: {}19141 whatwg-url-minimum@0.1.2: {}
1620619142
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: {}
1620819150
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.019161 isexe: 4.0.0
1622019162
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.019177 ansi-styles: 4.3.0
...@@ -16230,17 +19184,43 @@ snapshots:...@@ -16230,17 +19184,43 @@ snapshots:
16230 string-width: 4.2.319184 string-width: 4.2.3
16231 strip-ansi: 6.0.119185 strip-ansi: 6.0.1
1623219186
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: {}
1623419194
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
1623619199
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
1623819204
16239 xcode@3.0.1:19205 xcode@3.0.1:
16240 dependencies:19206 dependencies:
16241 simple-plist: 1.3.119207 simple-plist: 1.3.1
16242 uuid: 7.0.319208 uuid: 7.0.3
1624319209
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.019226 sax: 1.6.0
...@@ -16250,6 +19230,8 @@ snapshots:...@@ -16250,6 +19230,8 @@ snapshots:
1625019230
16251 xmlbuilder@15.1.1: {}19231 xmlbuilder@15.1.1: {}
1625219232
19233 xtend@4.0.2: {}
19234
16253 xxhash-wasm@1.1.0: {}19235 xxhash-wasm@1.1.0: {}
1625419236
16255 y18n@5.0.8: {}19237 y18n@5.0.8: {}
...@@ -16305,6 +19287,8 @@ snapshots:...@@ -16305,6 +19287,8 @@ snapshots:
1630519287
16306 yoctocolors-cjs@2.1.3: {}19288 yoctocolors-cjs@2.1.3: {}
1630719289
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.319294 zod: 4.4.3
pnpm-workspace.yaml+24-9
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1packages:1packages:
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 - msw16 - msw
1617
17catalog:18catalog:
18 effect: 4.0.0-beta.7319 effect: 4.0.0-beta.78
19 "@effect/atom-react": 4.0.0-beta.7320 "@effect/atom-react": 4.0.0-beta.78
20 "@effect/openapi-generator": 4.0.0-beta.7321 "@effect/openapi-generator": 4.0.0-beta.78
21 "@effect/platform-bun": 4.0.0-beta.7322 "@effect/platform-bun": 4.0.0-beta.78
22 "@effect/platform-node": 4.0.0-beta.7323 "@effect/platform-node": 4.0.0-beta.78
23 "@effect/platform-node-shared": 4.0.0-beta.7324 "@effect/platform-node-shared": 4.0.0-beta.78
24 "@effect/sql-sqlite-bun": 4.0.0-beta.7325 "@effect/sql-pg": 4.0.0-beta.78
25 "@effect/vitest": 4.0.0-beta.7326 "@effect/sql-sqlite-bun": 4.0.0-beta.78
27 "@effect/vitest": 4.0.0-beta.78
26 "@effect/tsgo": 0.11.428 "@effect/tsgo": 0.11.4
29 "@noble/curves": 1.9.1
30 "@noble/hashes": 1.8.0
27 "@pierre/diffs": 1.1.2031 "@pierre/diffs": 1.1.20
28 "@vitest/runner": ^4.1.832 "@vitest/runner": ^4.1.8
29 "@types/bun": ^1.3.1133 "@types/bun": ^1.3.11
30 "@types/node": 24.12.434 "@types/node": 24.12.4
31 "@typescript/native-preview": 7.0.0-dev.20260527.235 "@typescript/native-preview": 7.0.0-dev.20260527.2
36 jose: 6.2.2
37 tsdown: ^0.20.3
32 typescript: ~6.0.338 typescript: ~6.0.3
33 vitest: npm:@voidzero-dev/vite-plus-test@latest39 vitest: npm:@voidzero-dev/vite-plus-test@latest
34 vite: npm:@voidzero-dev/vite-plus-core@latest40 vite: npm:@voidzero-dev/vite-plus-core@latest
35 vite-plus: latest41 vite-plus: latest
36 yaml: ^2.9.042 yaml: ^2.9.0
37overrides:43overrides:
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:
60patchedDependencies:75patchedDependencies:
61 "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch76 "@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.patch77 "@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.patch78 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.patch79 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 }
722728
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";
17import { Argument, Command, Flag } from "effect/unstable/cli";17import { Argument, Command, Flag } from "effect/unstable/cli";
18import { ChildProcess } from "effect/unstable/process";18import { ChildProcess } from "effect/unstable/process";
1919
20import { loadRepoEnv } from "./lib/public-config.ts";
21
22Object.assign(process.env, loadRepoEnv());
23
20const BASE_SERVER_PORT = 13773;24const BASE_SERVER_PORT = 13773;
21const BASE_WEB_PORT = 5733;25const BASE_WEB_PORT = 5733;
22const MAX_HASH_OFFSET = 3000;26const 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.
2import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import { afterEach, describe, expect, it } from "vitest";
6
7import { loadRepoEnv, resolvePublicConfig } from "./public-config.ts";
8
9const temporaryDirectories: string[] = [];
10
11afterEach(() => {
12 for (const directory of temporaryDirectories.splice(0)) {
13 rmSync(directory, { recursive: true, force: true });
14 }
15});
16
17describe("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
100function 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.
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4import * as NodeURL from "node:url";
5import * as NodeUtil from "node:util";
6
7export interface T3CodePublicConfig {
8 readonly clerkPublishableKey: string | undefined;
9 readonly clerkJwtTemplate: string | undefined;
10 readonly clerkCliOAuthClientId: string | undefined;
11 readonly relayUrl: string | undefined;
12}
13
14type Environment = Readonly<Record<string, string | undefined>>;
15
16const REPO_ROOT = NodePath.dirname(
17 NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))),
18);
19
20export 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
63export 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
82function 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
94function 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";
4export default defineConfig({4export 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 },