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:
166166 --current-tag "${{ steps.release_meta.outputs.tag }}" \
167167 --github-output
168168
169 build:
170 name: Build ${{ matrix.label }}
169 relay_public_config:
170 name: Resolve T3 Cloud public config
171171 needs: preflight
172172 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' }}
173225 runs-on: ${{ matrix.runner }}
174226 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 }}
175232 strategy:
176233 fail-fast: false
177234 matrix:
......@@ -422,13 +479,18 @@ jobs:
422479
423480 publish_cli:
424481 name: Publish CLI to npm
425 needs: [preflight, build]
426 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' }}
482 needs: [preflight, relay_public_config, build]
483 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }}
427484 runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
428485 timeout-minutes: 10
429486 permissions:
430487 contents: read
431488 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 }}
432494 steps:
433495 - name: Checkout
434496 uses: actions/checkout@v6
......@@ -577,11 +639,14 @@ jobs:
577639
578640 deploy_web:
579641 name: Deploy hosted web app
580 needs: [preflight, release]
581 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }}
642 needs: [preflight, relay_public_config, release]
643 if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }}
582644 runs-on: blacksmith-8vcpu-ubuntu-2404
583645 timeout-minutes: 10
584646 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 }}
585650 VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
586651 VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
587652 VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
......@@ -648,6 +713,9 @@ jobs:
648713 --token "$VERCEL_TOKEN" \
649714 "${vercel_scope_args[@]}" \
650715 --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:-}" \
651719 --build-env "VITE_HOSTED_APP_URL=$router_url" \
652720 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
653721 )"
......@@ -744,10 +812,11 @@ jobs:
744812 if: |
745813 always() && !cancelled() &&
746814 needs.preflight.result == 'success' &&
815 needs.relay_public_config.result == 'success' &&
747816 needs.release.result == 'success' &&
748817 needs.deploy_web.result == 'success' &&
749818 (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]
751820 runs-on: blacksmith-8vcpu-ubuntu-2404
752821 timeout-minutes: 10
753822 steps:
.gitignore+6
......@@ -5,6 +5,7 @@ node_modules
55*.log
66*.tsbuildinfo
77apps/*/dist
8infra/*/dist
89.astro
910packages/*/dist
1011.env
......@@ -26,3 +27,8 @@ squashfs-root/
2627.gstack/
2728dist-electron/
2829.electron-runtime/
30node_modules/
31.alchemy/
32*.log
33.env*
34!.env.example
.vscode/settings.json+1
......@@ -6,6 +6,7 @@
66 },
77 "oxc.unusedDisableDirectives": "warn",
88 "js/ts.tsdk.path": "node_modules/typescript/lib",
9 "typescript.native-preview.tsdk": "node_modules/@typescript/native-preview",
910 "typescript.preferences.autoImportFileExcludePatterns": [".repos/**"],
1011 "javascript.preferences.autoImportFileExcludePatterns": [".repos/**"],
1112 "files.watcherExclude": {
AGENTS.md+2-16
......@@ -29,22 +29,8 @@ Long term maintainability is a core priority. If you add new functionality, firs
2929- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
3030- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket.
3131- `packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. Keep this package schema-only — no runtime logic.
32- `packages/shared`: Shared runtime utilities consumed by both server and web. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index.
33
34## Codex App Server (Important)
35
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
32- `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index.
33- `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile.
4834
4935## Reference Repos
5036
README.md+8
......@@ -48,6 +48,10 @@ We are not accepting contributions yet.
4848
4949Observability 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
5155## If you REALLY want to contribute still.... read this first
5256
5357Before local development, prepare the environment and install dependencies:
......@@ -58,6 +62,10 @@ mise install
5862vp install
5963```
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
6169Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.
6270
6371Need 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";
22import { watch } from "node:fs";
33import { join } from "node:path";
44
5import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
5import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs";
66import { waitForResources } from "./wait-for-resources.mjs";
77
88const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
......@@ -28,6 +28,7 @@ const watchedDirectories = [
2828const forcedShutdownTimeoutMs = 1_500;
2929const restartDebounceMs = 120;
3030const childTreeGracePeriodMs = 1_200;
31const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim();
3132
3233await waitForResources({
3334 baseDir: desktopDir,
......@@ -38,6 +39,11 @@ await waitForResources({
3839
3940const childEnv = { ...process.env };
4041delete 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
4248let shuttingDown = false;
4349let restartTimer = null;
......@@ -67,15 +73,17 @@ function startApp() {
6773 return;
6874 }
6975
70 const app = spawn(
71 resolveElectronPath(),
72 [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"],
73 {
74 cwd: desktopDir,
75 env: childEnv,
76 stdio: "inherit",
77 },
78 );
76 const electronArgs = remoteDebuggingPort
77 ? [`--remote-debugging-port=${remoteDebuggingPort}`]
78 : [];
79 const launchArgs = devProtocolClient
80 ? electronArgs
81 : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"];
82 const app = spawn(resolveElectronPath(), launchArgs, {
83 cwd: desktopDir,
84 env: childEnv,
85 stdio: "inherit",
86 });
7987
8088 currentApp = app;
8189
......@@ -125,6 +133,7 @@ async function stopApp() {
125133 app.once("exit", finish);
126134 app.kill("SIGTERM");
127135 killChildTreeByPid(app.pid, "TERM");
136 cleanupStaleDevApps();
128137
129138 setTimeout(() => {
130139 if (settled) {
......@@ -133,6 +142,7 @@ async function stopApp() {
133142
134143 app.kill("SIGKILL");
135144 killChildTreeByPid(app.pid, "KILL");
145 cleanupStaleDevApps();
136146 finish();
137147 }, forcedShutdownTimeoutMs).unref();
138148 });
apps/desktop/scripts/electron-launcher.mjs+162-10
......@@ -3,6 +3,7 @@
33import { spawnSync } from "node:child_process";
44import {
55 copyFileSync,
6 chmodSync,
67 cpSync,
78 existsSync,
89 mkdirSync,
......@@ -13,21 +14,34 @@ import {
1314 writeFileSync,
1415} from "node:fs";
1516import { createRequire } from "node:module";
16import { dirname, join, resolve } from "node:path";
17import { basename, dirname, join, resolve } from "node:path";
1718import { fileURLToPath } from "node:url";
1819import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";
1920
2021const 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
2522const __dirname = dirname(fileURLToPath(import.meta.url));
2623export const desktopDir = resolve(__dirname, "..");
2724const 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;
2834const defaultIconPath = join(desktopDir, "resources", "icon.icns");
2935const 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
3145function setPlistString(plistPath, key, value) {
3246 const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], {
3347 encoding: "utf8",
......@@ -47,6 +61,26 @@ function setPlistString(plistPath, key, value) {
4761 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());
4862}
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
5084function runChecked(command, args) {
5185 const result = spawnSync(command, args, { encoding: "utf8" });
5286 if (result.status === 0) {
......@@ -57,6 +91,71 @@ function runChecked(command, args) {
5791 throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim());
5892}
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
60159function ensureDevelopmentIconIcns(runtimeDir) {
61160 const generatedIconPath = join(runtimeDir, "icon-dev.icns");
62161 mkdirSync(runtimeDir, { recursive: true });
......@@ -115,12 +214,49 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) {
115214 setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME);
116215 setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID);
117216 setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns");
217 setPlistJson(infoPlistPath, "CFBundleURLTypes", [
218 {
219 CFBundleURLName: APP_BUNDLE_ID,
220 CFBundleURLSchemes: APP_PROTOCOL_SCHEMES,
221 },
222 ]);
118223
119224 const resourcesDir = join(appBundlePath, "Contents", "Resources");
120225 copyFileSync(iconPath, join(resourcesDir, "icon.icns"));
121226 copyFileSync(iconPath, join(resourcesDir, "electron.icns"));
122227}
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
124260function readJson(path) {
125261 try {
126262 return JSON.parse(readFileSync(path, "utf8"));
......@@ -144,6 +280,8 @@ function buildMacLauncher(electronBinaryPath) {
144280 sourceAppBundlePath,
145281 sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs,
146282 iconMtimeMs: statSync(iconPath).mtimeMs,
283 appBundleId: APP_BUNDLE_ID,
284 appProtocolSchemes: APP_PROTOCOL_SCHEMES,
147285 };
148286
149287 const currentMetadata = readJson(metadataPath);
......@@ -152,13 +290,19 @@ function buildMacLauncher(electronBinaryPath) {
152290 currentMetadata &&
153291 JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata)
154292 ) {
293 registerMacLauncherBundle(targetAppBundlePath);
155294 return targetBinaryPath;
156295 }
157296
158297 rmSync(targetAppBundlePath, { recursive: true, force: true });
159298 cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true });
160299 patchMainBundleInfoPlist(targetAppBundlePath, iconPath);
300 patchHelperBundleInfoPlists(targetAppBundlePath);
301 if (isDevelopment) {
302 writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath);
303 }
161304 writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`);
305 registerMacLauncherBundle(targetAppBundlePath);
162306
163307 return targetBinaryPath;
164308}
......@@ -173,11 +317,19 @@ export function resolveElectronPath() {
173317 return electronBinaryPath;
174318 }
175319
176 // Dev launches do not need a renamed app bundle badly enough to risk breaking
177 // Electron helper resource lookup on macOS.
178 if (isDevelopment) {
179 return electronBinaryPath;
320 return buildMacLauncher(electronBinaryPath);
321}
322
323export function resolveDevProtocolClient() {
324 if (process.platform !== "darwin" || !isDevelopment) {
325 return null;
180326 }
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 };
183335}
apps/desktop/src/app/DesktopApp.ts+4-1
......@@ -1,16 +1,17 @@
11import * as Cause from "effect/Cause";
2import * as Crypto from "effect/Crypto";
32import * as Data from "effect/Data";
43import * as Effect from "effect/Effect";
54import * as Option from "effect/Option";
65import * as Ref from "effect/Ref";
76
87import * as NetService from "@t3tools/shared/Net";
8import * as Crypto from "effect/Crypto";
99import * as ElectronApp from "../electron/ElectronApp.ts";
1010import * as ElectronDialog from "../electron/ElectronDialog.ts";
1111import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
1212import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
1313import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
14import * as DesktopCloudAuth from "./DesktopCloudAuth.ts";
1415import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
1516import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
1617import * as DesktopEnvironment from "./DesktopEnvironment.ts";
......@@ -190,6 +191,7 @@ const startup = Effect.gen(function* () {
190191 const electronApp = yield* ElectronApp.ElectronApp;
191192 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
192193 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
194 const cloudAuth = yield* DesktopCloudAuth.DesktopCloudAuth;
193195 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
194196 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
195197 const updates = yield* DesktopUpdates.DesktopUpdates;
......@@ -207,6 +209,7 @@ const startup = Effect.gen(function* () {
207209
208210 yield* appIdentity.configure;
209211 yield* lifecycle.register;
212 yield* cloudAuth.configure;
210213
211214 yield* electronApp.whenReady.pipe(
212215 Effect.withSpan("desktop.electron.whenReady"),
apps/desktop/src/app/DesktopAppIdentity.test.ts+3
......@@ -53,6 +53,9 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
5353 calls.setAboutPanelOptions.push(options);
5454 }),
5555 setAppUserModelId: () => Effect.void,
56 requestSingleInstanceLock: Effect.succeed(true),
57 isDefaultProtocolClient: () => Effect.succeed(false),
58 setAsDefaultProtocolClient: () => Effect.succeed(true),
5659 setDesktopName: () => Effect.void,
5760 setDockIcon: (iconPath) =>
5861 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({
3737 xdgConfigHome: trimmedString("XDG_CONFIG_HOME"),
3838 t3Home: trimmedString("T3CODE_HOME"),
3939 devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option),
40 appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"),
4041 devRemoteT3ServerEntryPath: trimmedString("T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH"),
4142 configuredBackendPort: Config.port("T3CODE_PORT").pipe(Config.option),
4243 commitHashOverride: trimmedString("T3CODE_COMMIT_HASH"),
apps/desktop/src/app/DesktopEnvironment.test.ts+14
......@@ -93,6 +93,20 @@ describe("DesktopEnvironment", () => {
9393 }),
9494 );
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
96110 it.effect("resolves picker defaults without nullish sentinels", () =>
97111 Effect.gen(function* () {
98112 const environment = yield* makeEnvironment();
apps/desktop/src/app/DesktopEnvironment.ts+3-1
......@@ -199,7 +199,9 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
199199 otlpExportIntervalMs: config.otlpExportIntervalMs,
200200 branding,
201201 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 ),
203205 linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop",
204206 linuxWmClass: isDevelopment ? "t3code-dev" : "t3code",
205207 userDataDirName,
apps/desktop/src/electron/ElectronApp.test.ts+9
......@@ -7,12 +7,15 @@ const {
77 exitMock,
88 getAppPathMock,
99 getVersionMock,
10 isDefaultProtocolClientMock,
1011 onMock,
1112 quitMock,
1213 relaunchMock,
1314 removeListenerMock,
15 requestSingleInstanceLockMock,
1416 setAboutPanelOptionsMock,
1517 setAppUserModelIdMock,
18 setAsDefaultProtocolClientMock,
1619 setDesktopNameMock,
1720 setDockIconMock,
1821 setNameMock,
......@@ -23,12 +26,15 @@ const {
2326 exitMock: vi.fn(),
2427 getAppPathMock: vi.fn(() => "/app"),
2528 getVersionMock: vi.fn(() => "1.2.3"),
29 isDefaultProtocolClientMock: vi.fn(() => false),
2630 onMock: vi.fn(),
2731 quitMock: vi.fn(),
2832 relaunchMock: vi.fn(),
2933 removeListenerMock: vi.fn(),
34 requestSingleInstanceLockMock: vi.fn(() => true),
3035 setAboutPanelOptionsMock: vi.fn(),
3136 setAppUserModelIdMock: vi.fn(),
37 setAsDefaultProtocolClientMock: vi.fn(() => true),
3238 setDesktopNameMock: vi.fn(),
3339 setDockIconMock: vi.fn(),
3440 setNameMock: vi.fn(),
......@@ -46,14 +52,17 @@ vi.mock("electron", () => ({
4652 },
4753 getAppPath: getAppPathMock,
4854 getVersion: getVersionMock,
55 isDefaultProtocolClient: isDefaultProtocolClientMock,
4956 isPackaged: true,
5057 name: "T3 Code",
5158 on: onMock,
5259 quit: quitMock,
5360 relaunch: relaunchMock,
5461 removeListener: removeListenerMock,
62 requestSingleInstanceLock: requestSingleInstanceLockMock,
5563 runningUnderARM64Translation: false,
5664 setAboutPanelOptions: setAboutPanelOptionsMock,
65 setAsDefaultProtocolClient: setAsDefaultProtocolClientMock,
5766 setAppUserModelId: setAppUserModelIdMock,
5867 setDesktopName: setDesktopNameMock,
5968 setName: setNameMock,
apps/desktop/src/electron/ElectronApp.ts+17
......@@ -29,6 +29,13 @@ export interface ElectronAppShape {
2929 options: Electron.AboutPanelOptionsOptions,
3030 ) => Effect.Effect<void>;
3131 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>;
3239 readonly setDesktopName: (desktopName: string) => Effect.Effect<void>;
3340 readonly setDockIcon: (iconPath: string) => Effect.Effect<void>;
3441 readonly appendCommandLineSwitch: (switchName: string, value?: string) => Effect.Effect<void>;
......@@ -93,6 +100,16 @@ const make = ElectronApp.of({
93100 Effect.sync(() => {
94101 Electron.app.setAppUserModelId(id);
95102 }),
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 }),
96113 setDesktopName: (desktopName) =>
97114 Effect.sync(() => {
98115 const linuxApp = Electron.app as Electron.App & {
apps/desktop/src/ipc/DesktopIpcHandlers.ts+12-1
......@@ -1,6 +1,13 @@
11import * as Effect from "effect/Effect";
22
33import * as DesktopIpc from "./DesktopIpc.ts";
4import {
5 clearCloudAuthToken,
6 createCloudAuthRequest,
7 fetchCloudAuth,
8 getCloudAuthToken,
9 setCloudAuthToken,
10} from "./methods/cloudAuth.ts";
411import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
512import {
613 getSavedEnvironmentRegistry,
......@@ -75,7 +82,11 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
7582 yield* ipc.handle(setTheme);
7683 yield* ipc.handle(showContextMenu);
7784 yield* ipc.handle(openExternal);
78
85 yield* ipc.handle(createCloudAuthRequest);
86 yield* ipc.handle(getCloudAuthToken);
87 yield* ipc.handle(setCloudAuthToken);
88 yield* ipc.handle(clearCloudAuthToken);
89 yield* ipc.handle(fetchCloudAuth);
7990 yield* ipc.handle(getUpdateState);
8091 yield* ipc.handle(setUpdateChannel);
8192 yield* ipc.handle(downloadUpdate);
apps/desktop/src/ipc/channels.ts+6
......@@ -3,6 +3,12 @@ export const CONFIRM_CHANNEL = "desktop:confirm";
33export const SET_THEME_CHANNEL = "desktop:set-theme";
44export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
55export 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";
612export const MENU_ACTION_CHANNEL = "desktop:menu-action";
713export const UPDATE_STATE_CHANNEL = "desktop:update-state";
814export 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";
2626import * as ElectronWindow from "./electron/ElectronWindow.ts";
2727import * as DesktopApp from "./app/DesktopApp.ts";
2828import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
29import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts";
30import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts";
2931import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts";
3032import * as DesktopAssets from "./app/DesktopAssets.ts";
3133import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts";
......@@ -111,6 +113,7 @@ const desktopFoundationLayer = Layer.mergeAll(
111113 DesktopAppSettings.layer,
112114 DesktopClientSettings.layer,
113115 DesktopSavedEnvironments.layer,
116 DesktopCloudAuthTokenStore.layer,
114117 DesktopAssets.layer,
115118 DesktopObservability.layer,
116119).pipe(Layer.provideMerge(desktopEnvironmentLayer));
......@@ -135,6 +138,7 @@ const desktopBackendLayer = DesktopBackendManager.layer.pipe(
135138const desktopApplicationLayer = Layer.mergeAll(
136139 DesktopLifecycle.layer,
137140 DesktopApplicationMenu.layer,
141 DesktopCloudAuth.layer,
138142 DesktopShellEnvironment.layer,
139143 desktopSshLayer,
140144).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer));
apps/desktop/src/preload.ts+17
......@@ -96,6 +96,23 @@ contextBridge.exposeInMainWorld("desktopBridge", {
9696 ...(position === undefined ? {} : { position }),
9797 }),
9898 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 },
99116 onMenuAction: (listener) => {
100117 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
101118 if (typeof action !== "string") return;
apps/desktop/src/settings/DesktopSavedEnvironments.ts+12-14
......@@ -53,6 +53,7 @@ const PersistedSavedEnvironmentStorageRecordSchema = Schema.Struct({
5353 createdAt: Schema.String,
5454 lastConnectedAt: Schema.NullOr(Schema.String),
5555 desktopSsh: Schema.optionalKey(DesktopSshTargetSchema),
56 relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })),
5657 encryptedBearerToken: Schema.optionalKey(Schema.String),
5758});
5859
......@@ -134,7 +135,11 @@ function toPersistedSavedEnvironmentRecord(
134135 createdAt: record.createdAt,
135136 lastConnectedAt: record.lastConnectedAt,
136137 };
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 };
138143}
139144
140145function toSavedEnvironmentStorageRecord(
......@@ -149,20 +154,13 @@ function toSavedEnvironmentStorageRecord(
149154 createdAt: record.createdAt,
150155 lastConnectedAt: record.lastConnectedAt,
151156 };
152 const desktopSsh = record.desktopSsh;
153 if (desktopSsh) {
154 return Option.match(encryptedBearerToken, {
155 onNone: () => ({ ...nextRecord, desktopSsh }),
156 onSome: (value) => ({
157 ...nextRecord,
158 desktopSsh,
159 encryptedBearerToken: value,
160 }),
161 });
162 }
157 const metadata = {
158 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
159 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
160 };
163161 return Option.match(encryptedBearerToken, {
164 onNone: () => nextRecord,
165 onSome: (value) => ({ ...nextRecord, encryptedBearerToken: value }),
162 onNone: () => ({ ...nextRecord, ...metadata }),
163 onSome: (value) => ({ ...nextRecord, ...metadata, encryptedBearerToken: value }),
166164 });
167165}
168166
apps/desktop/src/window/DesktopApplicationMenu.test.ts+3
......@@ -39,6 +39,9 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
3939 setName: () => Effect.void,
4040 setAboutPanelOptions: () => Effect.void,
4141 setAppUserModelId: () => Effect.void,
42 requestSingleInstanceLock: Effect.succeed(true),
43 isDefaultProtocolClient: () => Effect.succeed(false),
44 setAsDefaultProtocolClient: () => Effect.succeed(true),
4245 setDesktopName: () => Effect.void,
4346 setDockIcon: () => Effect.void,
4447 appendCommandLineSwitch: () => Effect.void,
apps/desktop/src/window/DesktopWindow.test.ts+73-7
......@@ -32,10 +32,13 @@ const environmentInput = {
3232} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
3333
3434function makeFakeBrowserWindow() {
35 const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>();
3536 const webContents = {
3637 copyImageAt: vi.fn(),
3738 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 }),
3942 once: vi.fn(),
4043 openDevTools: vi.fn(),
4144 replaceMisspelling: vi.fn(),
......@@ -63,6 +66,7 @@ function makeFakeBrowserWindow() {
6366 window: window as unknown as Electron.BrowserWindow,
6467 loadURL: window.loadURL,
6568 openDevTools: webContents.openDevTools,
69 webContentsListeners,
6670 };
6771}
6872
......@@ -96,11 +100,6 @@ const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, {
96100 showContextMenu: () => Effect.succeed(Option.none()),
97101} satisfies ElectronMenu.ElectronMenuShape);
98102
99const electronShellLayer = Layer.succeed(ElectronShell.ElectronShell, {
100 openExternal: () => Effect.succeed(true),
101 copyText: () => Effect.void,
102} satisfies ElectronShell.ElectronShellShape);
103
104103const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
105104 shouldUseDarkColors: Effect.succeed(false),
106105 setSource: () => Effect.void,
......@@ -123,6 +122,7 @@ function makeTestLayer(input: {
123122 readonly window: Electron.BrowserWindow;
124123 readonly createCount: Ref.Ref<number>;
125124 readonly mainWindow: Ref.Ref<Option.Option<Electron.BrowserWindow>>;
125 readonly openedExternalUrls?: unknown[];
126126}) {
127127 const electronWindowLayer = Layer.succeed(ElectronWindow.ElectronWindow, {
128128 create: () => Ref.update(input.createCount, (count) => count + 1).pipe(Effect.as(input.window)),
......@@ -145,7 +145,14 @@ function makeTestLayer(input: {
145145 desktopServerExposureLayer,
146146 DesktopState.layer,
147147 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),
149156 electronThemeLayer,
150157 electronWindowLayer,
151158 ),
......@@ -154,6 +161,27 @@ function makeTestLayer(input: {
154161}
155162
156163describe("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
157185 it.effect("does not open a development window until the backend is ready", () =>
158186 Effect.gen(function* () {
159187 const fakeWindow = makeFakeBrowserWindow();
......@@ -177,4 +205,42 @@ describe("DesktopWindow", () => {
177205 }).pipe(Effect.provide(layer));
178206 }),
179207 );
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 );
180246});
apps/desktop/src/window/DesktopWindow.ts+31-3
......@@ -92,6 +92,17 @@ function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string {
9292 return shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
9393}
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
95106function getWindowTitleBarOptions(shouldUseDarkColors: boolean): WindowTitleBarOptions {
96107 if (process.platform === "darwin") {
97108 return {
......@@ -159,6 +170,9 @@ const make = Effect.gen(function* () {
159170 const createWindow = Effect.fn("desktop.window.createWindow")(function* (
160171 backendHttpUrl: URL,
161172 ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> {
173 const applicationUrl = environment.isDevelopment
174 ? yield* resolveDesktopDevServerUrl(environment)
175 : backendHttpUrl.href;
162176 const iconPaths = yield* assets.iconPaths;
163177 const iconOption = getIconOption(iconPaths);
164178 const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors;
......@@ -235,6 +249,21 @@ const make = Effect.gen(function* () {
235249 }
236250 return { action: "deny" };
237251 });
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
239268 window.on("page-title-updated", (event) => {
240269 event.preventDefault();
......@@ -276,11 +305,10 @@ const make = Effect.gen(function* () {
276305 });
277306
278307 if (environment.isDevelopment) {
279 const devServerUrl = yield* resolveDesktopDevServerUrl(environment);
280 void window.loadURL(devServerUrl);
308 void window.loadURL(applicationUrl);
281309 window.webContents.openDevTools({ mode: "detach" });
282310 } else {
283 void window.loadURL(backendHttpUrl.href);
311 void window.loadURL(applicationUrl);
284312 }
285313
286314 window.on("closed", () => {
apps/desktop/tsconfig.json+1-1
......@@ -5,5 +5,5 @@
55 "types": ["node", "electron"],
66 "lib": ["ESNext", "DOM", "esnext.disposable"]
77 },
8 "include": ["src", "vite.config.ts"]
8 "include": ["src", "vite.config.ts", "../../scripts/lib"]
99}
apps/desktop/vite.config.ts+10
......@@ -1,6 +1,14 @@
11import { defineConfig } from "vite-plus";
22
3import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
4
5const repoEnv = loadRepoEnv();
36const 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
513export default defineConfig({
614 run: {
......@@ -32,6 +40,7 @@ export default defineConfig({
3240 outDir: "dist-electron",
3341 sourcemap: true,
3442 outExtensions: () => ({ js: ".cjs" }),
43 define: publicConfigDefine,
3544 entry: ["src/main.ts"],
3645 clean: true,
3746 deps: {
......@@ -44,6 +53,7 @@ export default defineConfig({
4453 outDir: "dist-electron",
4554 sourcemap: true,
4655 outExtensions: () => ({ js: ".cjs" }),
56 define: publicConfigDefine,
4757 entry: ["src/preload.ts"],
4858 },
4959 ],
apps/mobile/README.md+8
......@@ -16,6 +16,10 @@ This app has three variants:
1616
1717Run 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
1923## Development
2024
2125Start Metro for the dev client:
......@@ -63,6 +67,10 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin.
6367
6468CI 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
6674Create a PR preview dev-client build manually:
6775
6876```bash
apps/mobile/app.config.ts+35-5
......@@ -1,8 +1,13 @@
11import type { ExpoConfig } from "expo/config";
22
3import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
4
35type 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
712const VARIANT_CONFIG: Record<
813 AppVariant,
......@@ -96,6 +101,11 @@ const config: ExpoConfig = {
96101 favicon: "./assets/favicon.png",
97102 },
98103 plugins: [
104 "expo-router",
105 "expo-font",
106 "expo-secure-store",
107 ["@clerk/expo", { theme: "./clerk-theme.json" }],
108 "expo-web-browser",
99109 [
100110 "expo-camera",
101111 {
......@@ -120,17 +130,37 @@ const config: ExpoConfig = {
120130 "expo-build-properties",
121131 {
122132 ios: {
123 deploymentTarget: "16.4",
133 deploymentTarget: "18.0",
124134 },
125135 },
126136 ],
127 "expo-secure-store",
128 "expo-router",
129 "expo-font",
137 [
138 "expo-widgets",
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 ],
130153 "./plugins/withAndroidCleartextTraffic.cjs",
131154 ],
132155 extra: {
133156 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 },
134164 eas: {
135165 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
136166 },
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 @@
3838 --color-secondary: #ffffff;
3939 --color-secondary-foreground: #262626;
4040 --color-secondary-border: rgba(0, 0, 0, 0.08);
41 --color-switch-active: #34c759;
4142
4243 /* Danger */
4344 --color-danger: #fef2f2;
......@@ -125,6 +126,7 @@
125126 --color-secondary: rgba(255, 255, 255, 0.04);
126127 --color-secondary-foreground: #f5f5f5;
127128 --color-secondary-border: rgba(255, 255, 255, 0.06);
129 --color-switch-active: #30d158;
128130
129131 /* Danger */
130132 --color-danger: rgba(239, 68, 68, 0.14);
apps/mobile/package.json+8
......@@ -39,10 +39,13 @@
3939 },
4040 "dependencies": {
4141 "@callstack/liquid-glass": "^0.7.1",
42 "@clerk/expo": "^3.3.0",
4243 "@effect/atom-react": "catalog:",
4344 "@expo-google-fonts/dm-sans": "^0.4.2",
4445 "@expo/ui": "~56.0.8",
4546 "@legendapp/list": "3.0.0-beta.44",
47 "@noble/curves": "catalog:",
48 "@noble/hashes": "catalog:",
4649 "@pierre/diffs": "catalog:",
4750 "@react-native-menu/menu": "^2.0.0",
4851 "@shikijs/core": "3.23.0",
......@@ -58,6 +61,7 @@
5861 "diff": "8.0.3",
5962 "effect": "catalog:",
6063 "expo": "^56.0.0",
64 "expo-auth-session": "~56.0.12",
6165 "expo-build-properties": "~56.0.15",
6266 "expo-camera": "~56.0.7",
6367 "expo-clipboard": "~56.0.3",
......@@ -70,12 +74,15 @@
7074 "expo-haptics": "~56.0.3",
7175 "expo-image-picker": "~56.0.14",
7276 "expo-linking": "~56.0.12",
77 "expo-notifications": "~56.0.14",
7378 "expo-paste-input": "^0.1.15",
7479 "expo-router": "~56.2.7",
7580 "expo-secure-store": "~56.0.4",
7681 "expo-splash-screen": "~56.0.10",
7782 "expo-symbols": "~56.0.5",
7883 "expo-updates": "~56.0.17",
84 "expo-web-browser": "~56.0.5",
85 "expo-widgets": "~56.0.15",
7986 "punycode": "^2.3.1",
8087 "react": "19.2.3",
8188 "react-dom": "19.2.3",
......@@ -96,6 +103,7 @@
96103 "uniwind": "^1.6.2"
97104 },
98105 "devDependencies": {
106 "@effect/vitest": "catalog:",
99107 "@types/react": "~19.2.0",
100108 "babel-preset-expo": "~56.0.0",
101109 "tailwindcss": "^4.0.0",
apps/mobile/src/app/_layout.tsx+26-11
......@@ -10,7 +10,7 @@ import { StatusBar, useColorScheme } from "react-native";
1010import { GestureHandlerRootView } from "react-native-gesture-handler";
1111import { KeyboardProvider } from "react-native-keyboard-controller";
1212import { SafeAreaProvider } from "react-native-safe-area-context";
13import { useCSSVariable, useResolveClassNames } from "uniwind";
13import { useResolveClassNames } from "uniwind";
1414
1515import { LoadingScreen } from "../components/LoadingScreen";
1616
......@@ -20,12 +20,15 @@ import {
2020} from "../state/use-remote-environment-registry";
2121import { RegistryContext } from "@effect/atom-react";
2222import { appAtomRegistry } from "../state/atom-registry";
23import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider";
24import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation";
2325
2426function AppNavigator() {
2527 const { isLoadingSavedConnection } = useRemoteEnvironmentState();
2628 const colorScheme = useColorScheme();
27 const statusBarBg = useCSSVariable("--color-status-bar");
29 const statusBarBg = colorScheme === "dark" ? "#0a0a0a" : "#f2f2f7";
2830 const sheetStyle = useResolveClassNames("bg-sheet");
31 useAgentNotificationNavigation();
2932
3033 const newTaskScreenOptions = {
3134 contentStyle: sheetStyle,
......@@ -45,6 +48,11 @@ function AppNavigator() {
4548 sheetGrabberVisible: true,
4649 };
4750
51 const settingsSheetScreenOptions = {
52 ...connectionSheetScreenOptions,
53 sheetAllowedDetents: [0.7],
54 };
55
4856 if (isLoadingSavedConnection) {
4957 return <LoadingScreen message="Loading remote workspace…" />;
5058 }
......@@ -66,6 +74,7 @@ function AppNavigator() {
6674 headerShadowVisible: false,
6775 }}
6876 />
77 <Stack.Screen name="settings" options={settingsSheetScreenOptions} />
6978 <Stack.Screen name="connections" options={connectionSheetScreenOptions} />
7079 <Stack.Screen name="new" options={newTaskScreenOptions} />
7180 <Stack.Screen
......@@ -91,14 +100,20 @@ export default function RootLayout() {
91100 useRemoteEnvironmentBootstrap();
92101
93102 return (
94 <RegistryContext value={appAtomRegistry}>
95 <GestureHandlerRootView style={{ flex: 1 }}>
96 <KeyboardProvider statusBarTranslucent>
97 <SafeAreaProvider>
98 {fontsLoaded ? <AppNavigator /> : <LoadingScreen message="Loading remote workspace…" />}
99 </SafeAreaProvider>
100 </KeyboardProvider>
101 </GestureHandlerRootView>
102 </RegistryContext>
103 <RegistryContext.Provider value={appAtomRegistry}>
104 <CloudAuthProvider>
105 <GestureHandlerRootView style={{ flex: 1 }}>
106 <KeyboardProvider statusBarTranslucent>
107 <SafeAreaProvider>
108 {fontsLoaded ? (
109 <AppNavigator />
110 ) : (
111 <LoadingScreen message="Loading remote workspace…" />
112 )}
113 </SafeAreaProvider>
114 </KeyboardProvider>
115 </GestureHandlerRootView>
116 </CloudAuthProvider>
117 </RegistryContext.Provider>
103118 );
104119}
apps/mobile/src/app/connections/_layout.tsx+4-4
......@@ -1,16 +1,16 @@
11import Stack from "expo-router/stack";
2import { useColorScheme } from "react-native";
23import { useResolveClassNames } from "uniwind";
34
4import { useThemeColor } from "../../lib/useThemeColor";
5
65export const unstable_settings = {
76 anchor: "index",
87};
98
109export default function ConnectionsLayout() {
1110 const contentStyle = useResolveClassNames("bg-sheet");
12 const connSheetBg = String(useThemeColor("--color-sheet"));
13 const headerTint = String(useThemeColor("--color-icon"));
11 const isDark = useColorScheme() === "dark";
12 const connSheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)";
13 const headerTint = isDark ? "#f5f5f5" : "#262626";
1414
1515 return (
1616 <Stack
apps/mobile/src/app/connections/index.tsx+10-16
......@@ -1,8 +1,8 @@
1import { Link, Stack } from "expo-router";
1import { Stack, useRouter } from "expo-router";
22import { SymbolView } from "expo-symbols";
33import type { EnvironmentId } from "@t3tools/contracts";
44import { useCallback, useState } from "react";
5import { Pressable, ScrollView, View } from "react-native";
5import { ScrollView, View } from "react-native";
66import { useSafeAreaInsets } from "react-native-safe-area-context";
77import { useThemeColor } from "../../lib/useThemeColor";
88
......@@ -18,11 +18,11 @@ export default function ConnectionsRouteScreen() {
1818 onRemoveEnvironmentPress,
1919 onUpdateEnvironment,
2020 } = useRemoteConnections();
21 const router = useRouter();
2122 const insets = useSafeAreaInsets();
2223 const hasEnvironments = connectedEnvironments.length > 0;
2324 const [expandedId, setExpandedId] = useState<EnvironmentId | null>(null);
2425
25 const primaryFg = useThemeColor("--color-primary-foreground");
2626 const accentColor = useThemeColor("--color-icon-muted");
2727
2828 const handleToggle = useCallback((environmentId: EnvironmentId) => {
......@@ -34,21 +34,15 @@ export default function ConnectionsRouteScreen() {
3434 <Stack.Screen
3535 options={{
3636 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 ),
5037 }}
5138 />
39 <Stack.Toolbar placement="right">
40 <Stack.Toolbar.Button
41 icon="plus"
42 onPress={() => router.push("/connections/new")}
43 separateBackground
44 />
45 </Stack.Toolbar>
5246 <ScrollView
5347 contentInsetAdjustmentBehavior="automatic"
5448 showsVerticalScrollIndicator={false}
apps/mobile/src/app/connections/new.tsx+14-23
......@@ -1,8 +1,7 @@
11import { CameraView, useCameraPermissions } from "expo-camera";
22import { Stack, useLocalSearchParams, useRouter } from "expo-router";
3import { SymbolView } from "expo-symbols";
43import { useCallback, useEffect, useState } from "react";
5import { Alert, Pressable, ScrollView, View } from "react-native";
4import { Alert, ScrollView, View } from "react-native";
65import { useSafeAreaInsets } from "react-native-safe-area-context";
76import { useThemeColor } from "../../lib/useThemeColor";
87
......@@ -31,7 +30,6 @@ export default function ConnectionsNewRouteScreen() {
3130 const [cameraPermission, requestCameraPermission] = useCameraPermissions();
3231 const [scannerLocked, setScannerLocked] = useState(false);
3332
34 const textColor = useThemeColor("--color-icon");
3533 const placeholderColor = useThemeColor("--color-placeholder");
3634
3735 const connectDisabled = isSubmitting || hostInput.trim().length === 0;
......@@ -128,28 +126,21 @@ export default function ConnectionsNewRouteScreen() {
128126 <Stack.Screen
129127 options={{
130128 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 ),
151129 }}
152130 />
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
154145 <ScrollView
155146 contentInsetAdjustmentBehavior="automatic"
apps/mobile/src/app/index.tsx+6-12
......@@ -1,7 +1,6 @@
11import { Stack, useRouter } from "expo-router";
22import { useState } from "react";
33import { Text as RNText, View, useColorScheme } from "react-native";
4import { useThemeColor } from "../lib/useThemeColor";
54
65import { buildThreadRoutePath } from "../lib/routes";
76import { useRemoteCatalog } from "../state/use-remote-catalog";
......@@ -17,7 +16,7 @@ export default function HomeRouteScreen() {
1716 const [searchQuery, setSearchQuery] = useState("");
1817
1918 const isDark = useColorScheme() === "dark";
20 const iconColor = String(useThemeColor("--color-icon"));
19 const iconColor = isDark ? "#f5f5f5" : "#262626";
2120
2221 return (
2322 <>
......@@ -78,16 +77,11 @@ export default function HomeRouteScreen() {
7877 </Stack.Toolbar>
7978
8079 <Stack.Toolbar placement="right">
81 <Stack.Toolbar.Menu icon="ellipsis.circle" separateBackground>
82 <Stack.Toolbar.Label>More</Stack.Toolbar.Label>
83 <Stack.Toolbar.MenuAction
84 icon="desktopcomputer"
85 onPress={() => router.push("/connections")}
86 subtitle="Manage connected hosts"
87 >
88 <Stack.Toolbar.Label>Environments</Stack.Toolbar.Label>
89 </Stack.Toolbar.MenuAction>
90 </Stack.Toolbar.Menu>
80 <Stack.Toolbar.Button
81 icon="gearshape"
82 onPress={() => router.push("/settings")}
83 separateBackground
84 />
9185 </Stack.Toolbar>
9286
9387 {/* Bottom toolbar: search + compose, visually split like iMessage */}
apps/mobile/src/app/new/_layout.tsx+26-4
......@@ -1,4 +1,5 @@
11import Stack from "expo-router/stack";
2import { useColorScheme } from "react-native";
23import { useResolveClassNames } from "uniwind";
34
45import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";
......@@ -9,20 +10,41 @@ export const unstable_settings = {
910
1011export default function NewTaskLayout() {
1112 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
1317 return (
1418 <NewTaskFlowProvider>
1519 <Stack
1620 screenOptions={{
1721 contentStyle: sheetStyle,
22 headerBackButtonDisplayMode: "minimal",
23 headerLargeTitle: false,
24 headerShadowVisible: false,
25 headerStyle: { backgroundColor: sheetBg },
26 headerTintColor: headerTint,
27 headerTitleStyle: { fontFamily: "DMSans_700Bold" },
1828 }}
1929 >
20 <Stack.Screen name="index" options={{ animation: "none" }} />
30 <Stack.Screen name="index" options={{ animation: "none", title: "Choose project" }} />
2131 <Stack.Screen
22 name="add-project"
23 options={{ animation: "slide_from_right", headerShown: false }}
32 name="add-project/index"
33 options={{ animation: "slide_from_right", title: "New project" }}
2434 />
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" }} />
2648 </Stack>
2749 </NewTaskFlowProvider>
2850 );
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 @@
1import { Stack } from "expo-router";
2
31import { AddProjectDestinationScreen } from "../../../features/projects/AddProjectScreen";
42
53export default function AddProjectDestinationRoute() {
6 return (
7 <>
8 <Stack.Screen options={{ headerShown: false }} />
9 <AddProjectDestinationScreen />
10 </>
11 );
4 return <AddProjectDestinationScreen />;
125}
apps/mobile/src/app/new/add-project/index.tsx+1-8
......@@ -1,12 +1,5 @@
1import { Stack } from "expo-router";
2
31import { AddProjectSourceScreen } from "../../../features/projects/AddProjectScreen";
42
53export default function AddProjectRoute() {
6 return (
7 <>
8 <Stack.Screen options={{ headerShown: false }} />
9 <AddProjectSourceScreen />
10 </>
11 );
4 return <AddProjectSourceScreen />;
125}
apps/mobile/src/app/new/add-project/local.tsx+1-8
......@@ -1,12 +1,5 @@
1import { Stack } from "expo-router";
2
31import { AddProjectLocalFolderScreen } from "../../../features/projects/AddProjectScreen";
42
53export default function AddProjectLocalRoute() {
6 return (
7 <>
8 <Stack.Screen options={{ headerShown: false }} />
9 <AddProjectLocalFolderScreen />
10 </>
11 );
4 return <AddProjectLocalFolderScreen />;
125}
apps/mobile/src/app/new/add-project/repository.tsx+13-2
......@@ -1,11 +1,22 @@
1import { Stack } from "expo-router";
1import { Stack, useLocalSearchParams } from "expo-router";
2import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime";
23
34import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen";
45
56export 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
617 return (
718 <>
8 <Stack.Screen options={{ headerShown: false }} />
19 <Stack.Screen options={{ title }} />
920 <AddProjectRepositoryScreen />
1021 </>
1122 );
apps/mobile/src/app/new/draft.tsx+6-1
......@@ -6,11 +6,16 @@ export default function NewTaskDraftRoute() {
66 const params = useLocalSearchParams<{
77 environmentId?: string | string[];
88 projectId?: string | string[];
9 title?: string | string[];
910 }>();
1011
1112 return (
1213 <>
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 />
1419 <NewTaskDraftScreen
1520 initialProjectRef={{
1621 environmentId: Array.isArray(params.environmentId)
apps/mobile/src/app/new/index.tsx+28-42
......@@ -8,7 +8,6 @@ import { useThemeColor } from "../../lib/useThemeColor";
88
99import { AppText as Text } from "../../components/AppText";
1010import { ProjectFavicon } from "../../components/ProjectFavicon";
11import { NewTaskSheetHeader } from "../../features/threads/NewTaskSheetHeader";
1211import { groupProjectsByRepository } from "../../lib/repositoryGroups";
1312import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog";
1413import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
......@@ -102,9 +101,14 @@ export default function NewTaskRoute() {
102101
103102 return (
104103 <View collapsable={false} className="flex-1 bg-sheet">
105 <Stack.Screen options={{ headerShown: false }} />
106
107 <NewTaskSheetHeader title="Choose project" />
104 <Stack.Screen options={{ title: "Choose project" }} />
105 <Stack.Toolbar placement="right">
106 <Stack.Toolbar.Button
107 icon="plus"
108 onPress={() => router.push("/new/add-project")}
109 separateBackground
110 />
111 </Stack.Toolbar>
108112
109113 <ScrollView
110114 showsVerticalScrollIndicator={false}
......@@ -148,6 +152,7 @@ export default function NewTaskRoute() {
148152 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
149153 {items.map((item, index) => {
150154 const isFirst = index === 0;
155 const isLast = index === items.length - 1;
151156
152157 return (
153158 <Link
......@@ -166,25 +171,33 @@ export default function NewTaskRoute() {
166171 className="bg-card"
167172 style={{
168173 paddingHorizontal: 16,
169 paddingVertical: 18,
174 paddingVertical: 14,
170175 borderTopWidth: isFirst ? 0 : 1,
171176 borderTopColor: borderSubtleColor,
172177 borderTopLeftRadius: isFirst ? 24 : 0,
173178 borderTopRightRadius: isFirst ? 24 : 0,
174 borderBottomLeftRadius: 0,
175 borderBottomRightRadius: 0,
179 borderBottomLeftRadius: isLast ? 24 : 0,
180 borderBottomRightRadius: isLast ? 24 : 0,
176181 }}
177182 >
178183 <View className="flex-row items-center justify-between gap-3">
179 <ProjectFavicon
180 size={22}
181 projectTitle={item.title}
182 httpBaseUrl={savedConnectionsById[item.environmentId]?.httpBaseUrl ?? null}
183 workspaceRoot={item.workspaceRoot}
184 bearerToken={savedConnectionsById[item.environmentId]?.bearerToken ?? null}
185 />
184 <View className="h-7 w-7 items-center justify-center">
185 <ProjectFavicon
186 size={20}
187 projectTitle={item.title}
188 httpBaseUrl={
189 savedConnectionsById[item.environmentId]?.httpBaseUrl ?? null
190 }
191 workspaceRoot={item.workspaceRoot}
192 bearerToken={
193 savedConnectionsById[item.environmentId]?.bearerToken ?? null
194 }
195 />
196 </View>
186197 <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>
188201 </View>
189202 <SymbolView
190203 name="chevron.right"
......@@ -197,33 +210,6 @@ export default function NewTaskRoute() {
197210 </Link>
198211 );
199212 })}
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>
227213 </View>
228214 )}
229215 </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() {
1717 headerShown: true,
1818 headerTransparent: true,
1919 headerShadowVisible: false,
20 headerTitle: "",
2021 }}
2122 />
2223 <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";
55import { AppText as Text } from "./AppText";
66import { 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}) {
912 const colorScheme = useColorScheme();
1013 const screenBg = useThemeColor("--color-screen");
1114 const insets = useSafeAreaInsets();
15 const messagePlacement = props.messagePlacement ?? "below-spinner";
1216
1317 return (
1418 <View className="flex-1 bg-screen" style={{ paddingTop: insets.top }}>
......@@ -19,8 +23,13 @@ export function LoadingScreen(props: { readonly message: string }) {
1923 />
2024 <View className="flex-1 items-center justify-center gap-5 px-6">
2125 <BrandMark compact />
26 {messagePlacement === "above-spinner" ? (
27 <Text className="font-t3-bold text-lg text-foreground">{props.message}</Text>
28 ) : null}
2229 <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}
2433 </View>
2534 </View>
2635 );
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: {
113113 exiting={FadeOut.duration(150)}
114114 className="gap-3 px-4 pb-4"
115115 >
116 <View className="gap-1.5">
117 <Text
118 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
119 style={{ letterSpacing: 0.8 }}
120 >
121 Label
116 {props.environment.isRelayManaged ? (
117 <Text className="text-[13px] leading-[18px] text-foreground-muted">
118 Managed by T3 Cloud. Tunnel details update automatically.
122119 </Text>
123 <TextInput
124 autoCapitalize="words"
125 autoCorrect={false}
126 placeholder="My MacBook"
127 placeholderTextColor={placeholderColor}
128 value={label}
129 onChangeText={setLabel}
130 className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground"
131 />
132 </View>
120 ) : (
121 <>
122 <View className="gap-1.5">
123 <Text
124 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
125 style={{ letterSpacing: 0.8 }}
126 >
127 Label
128 </Text>
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">
135 <Text
136 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
137 style={{ letterSpacing: 0.8 }}
138 >
139 URL
140 </Text>
141 <TextInput
142 autoCapitalize="none"
143 autoCorrect={false}
144 keyboardType="url"
145 placeholder="192.168.1.100:8080"
146 placeholderTextColor={placeholderColor}
147 value={url}
148 onChangeText={setUrl}
149 className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground"
150 />
151 </View>
140 <View className="gap-1.5">
141 <Text
142 className="text-[11px] font-t3-bold uppercase text-foreground-muted"
143 style={{ letterSpacing: 0.8 }}
144 >
145 URL
146 </Text>
147 <TextInput
148 autoCapitalize="none"
149 autoCorrect={false}
150 keyboardType="url"
151 placeholder="192.168.1.100:8080"
152 placeholderTextColor={placeholderColor}
153 value={url}
154 onChangeText={setUrl}
155 className="rounded-[14px] border border-input-border bg-input px-4 py-3 text-[15px] text-foreground"
156 />
157 </View>
158 </>
159 )}
152160
153 <View className="flex-row gap-2">
154 <Pressable
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"
156 onPress={handleSave}
157 >
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 }}
161 <View className="flex-row justify-end gap-2">
162 {props.environment.isRelayManaged ? null : (
163 <Pressable
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"
165 onPress={handleSave}
162166 >
163 Save
164 </Text>
165 </Pressable>
167 <SymbolView name="checkmark" size={13} tintColor={primaryFg} type="monochrome" />
168 <Text
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
167177 <Pressable
168178 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 {
4141 refreshSourceControlDiscoveryForEnvironment,
4242 useSourceControlDiscovery,
4343} from "../../state/use-source-control-discovery";
44import { NewTaskSheetHeader } from "../threads/NewTaskSheetHeader";
4544
4645interface EnvironmentOption {
4746 readonly environmentId: EnvironmentId;
......@@ -105,25 +104,11 @@ function SectionTitle(props: { readonly children: string }) {
105104 );
106105}
107106
108function AddProjectShell(props: {
109 readonly title: string;
110 readonly closeToNew?: boolean;
111 readonly children: ReactNode;
112}) {
113 const router = useRouter();
107function AddProjectShell(props: { readonly children: ReactNode }) {
114108 const insets = useSafeAreaInsets();
115109
116110 return (
117111 <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
127112 <ScrollView
128113 keyboardShouldPersistTaps="handled"
129114 contentInsetAdjustmentBehavior="automatic"
......@@ -179,9 +164,9 @@ function ListRow(props: {
179164 {props.icon}
180165 </View>
181166 <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>
183168 {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}>
185170 {props.subtitle}
186171 </Text>
187172 ) : null}
......@@ -363,7 +348,7 @@ export function AddProjectSourceScreen() {
363348 }, [selectedEnvironment]);
364349
365350 return (
366 <AddProjectShell title="Select source" closeToNew>
351 <AddProjectShell>
367352 {environmentOptions.length === 0 ? <EmptyEnvironmentState /> : null}
368353
369354 {environmentOptions.length > 1 ? (
......@@ -561,7 +546,7 @@ export function AddProjectRepositoryScreen() {
561546 }, [environment, isSubmitting, repositoryInput, router, source]);
562547
563548 return (
564 <AddProjectShell title={source === "url" ? "Git URL" : addProjectRemoteSourceLabel(source)}>
549 <AddProjectShell>
565550 {error ? <ErrorBanner message={error} /> : null}
566551 <TextInput
567552 className="h-12 min-h-12 rounded-[24px] px-4 py-0 text-[15px] leading-[20px]"
......@@ -711,7 +696,7 @@ export function AddProjectLocalFolderScreen() {
711696 }, [createProject, environment, isSubmitting, pathInput]);
712697
713698 return (
714 <AddProjectShell title="Local folder">
699 <AddProjectShell>
715700 {error ? <ErrorBanner message={error} /> : null}
716701 {environment ? (
717702 <>
......@@ -790,7 +775,7 @@ export function AddProjectDestinationScreen() {
790775 }, [createProject, environment, isSubmitting, pathInput, remoteUrl]);
791776
792777 return (
793 <AddProjectShell title="Clone destination">
778 <AddProjectShell>
794779 {error ? <ErrorBanner message={error} /> : null}
795780 {repositoryTitle ? (
796781 <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() {
238238 <View className="min-h-0 flex-1 gap-2">
239239 <Text className="text-[13px] font-t3-bold text-foreground">Comment</Text>
240240 <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">
242 <TextInputWrapper onPaste={handleNativePaste} style={{ flex: 1 }}>
241 <View className="min-h-0 flex-1 px-4 pt-3.5">
242 <TextInputWrapper onPaste={handleNativePaste} style={{ flex: 1, minHeight: 0 }}>
243243 <TextInput
244244 autoFocus
245245 multiline
246 scrollEnabled
246247 placeholder="Leave a comment..."
247248 textAlignVertical="top"
248249 value={commentText}
249250 onChangeText={setCommentText}
250251 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 }}
252253 />
253254 </TextInputWrapper>
254255 </View>
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+40-242
......@@ -8,16 +8,7 @@ import type { KnownTerminalSession } from "@t3tools/client-runtime";
88import { SymbolView } from "expo-symbols";
99import { Stack, useLocalSearchParams, useRouter } from "expo-router";
1010import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11import {
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";
11import { Pressable, Text as RNText, View, useColorScheme } from "react-native";
2112import {
2213 KeyboardController,
2314 KeyboardEvents,
......@@ -25,6 +16,11 @@ import {
2516 useKeyboardState,
2617} from "react-native-keyboard-controller";
2718
19import {
20 ComposerToolbarButton,
21 ComposerToolbarRow,
22 ComposerToolbarScroller,
23} from "../../components/ComposerToolbarTrigger";
2824import { EmptyState } from "../../components/EmptyState";
2925import { GlassSurface } from "../../components/GlassSurface";
3026import { LoadingScreen } from "../../components/LoadingScreen";
......@@ -79,7 +75,6 @@ import {
7975const DEFAULT_TERMINAL_COLS = 80;
8076const DEFAULT_TERMINAL_ROWS = 24;
8177const TERMINAL_ACCESSORY_HEIGHT = 52;
82const TERMINAL_ACCESSORY_FADE_WIDTH = 18;
8378
8479type PendingModifier = "ctrl" | "meta";
8580type HostPlatform = "mac" | "linux" | "windows" | "unknown";
......@@ -94,47 +89,6 @@ type TerminalToolbarAction =
9489 readonly modifier: PendingModifier;
9590 };
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
13892function firstRouteParam(value: string | string[] | undefined): string | null {
13993 if (Array.isArray(value)) {
14094 return value[0] ?? null;
......@@ -186,10 +140,6 @@ function applyCtrlModifier(input: string): string {
186140 return input;
187141}
188142
189function withAlpha(hexColor: string, alpha: string): string {
190 return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor;
191}
192
193143function pickRunningTerminalSessionForBootstrap(
194144 sessions: ReadonlyArray<KnownTerminalSession>,
195145): KnownTerminalSession | null {
......@@ -248,15 +198,6 @@ export function ThreadTerminalRouteScreen() {
248198 const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE);
249199 const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0);
250200 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 });
260201 const hasOpenedRef = useRef(false);
261202 const bufferReplayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
262203 const attachStreamLogCountRef = useRef(0);
......@@ -370,14 +311,6 @@ export function ThreadTerminalRouteScreen() {
370311 activeKnownSummary: activeKnownSession?.state.summary ?? null,
371312 };
372313
373 const terminalStatusTone = useMemo(
374 () =>
375 getTerminalStatusTone({
376 status: terminal.status,
377 hasRunningSubprocess: terminal.hasRunningSubprocess,
378 }),
379 [terminal.hasRunningSubprocess, terminal.status],
380 );
381314 const terminalTheme = getPierreTerminalTheme(appearanceScheme);
382315 const pendingModifier =
383316 pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null;
......@@ -964,47 +897,6 @@ export function ThreadTerminalRouteScreen() {
964897 [handleClearTerminal, pendingModifier, terminalId, writeInput],
965898 );
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
1008900 const handleDismissKeyboard = useCallback(() => {
1009901 setIsAccessoryDismissed(true);
1010902 void KeyboardController.dismiss();
......@@ -1089,17 +981,7 @@ export function ThreadTerminalRouteScreen() {
1089981 />
1090982
1091983 <Stack.Toolbar placement="right">
1092 <Stack.Toolbar.Menu
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 >
984 <Stack.Toolbar.Menu icon="terminal" title="Terminal options" separateBackground>
1103985 <Stack.Toolbar.Label>
1104986 {getTerminalStatusLabel({
1105987 status: terminal.status,
......@@ -1170,127 +1052,43 @@ export function ThreadTerminalRouteScreen() {
11701052 borderTopColor: terminalTheme.border,
11711053 borderTopWidth: 1,
11721054 minHeight: TERMINAL_ACCESSORY_HEIGHT,
1173 paddingBottom: 4,
1174 paddingHorizontal: 8,
1175 paddingTop: 4,
11761055 }}
11771056 >
1178 <View style={{ alignItems: "center", flexDirection: "row", gap: 6 }}>
1179 <View style={{ flex: 1, position: "relative" }}>
1180 <ScrollView
1181 horizontal
1182 contentContainerStyle={{ alignItems: "center", gap: 6, paddingRight: 2 }}
1183 onContentSizeChange={handleToolbarContentSizeChange}
1184 onLayout={handleToolbarLayout}
1185 onScroll={handleToolbarScroll}
1186 scrollEventThrottle={16}
1187 showsHorizontalScrollIndicator={false}
1188 >
1189 {terminalToolbarActions.map((action) => {
1190 const active =
1191 action.kind === "modifier" && pendingModifier === action.modifier;
1192
1193 return (
1194 <Pressable
1195 key={action.key}
1196 onPress={() => handleToolbarActionPress(action)}
1197 style={({ pressed }) => ({
1198 alignItems: "center",
1199 backgroundColor: active
1200 ? withAlpha(
1201 terminalTheme.palette[10] ?? terminalTheme.foreground,
1202 "2e",
1203 )
1204 : pressed
1205 ? withAlpha(terminalTheme.foreground, "1f")
1206 : withAlpha(terminalTheme.foreground, "12"),
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
1057 <ComposerToolbarRow paddingBottom={4} paddingHorizontal={8} paddingTop={4}>
1058 <ComposerToolbarScroller
1059 contentPaddingRight={2}
1060 fadeOpaque={terminalTheme.background}
1061 fadeTransparent={`${terminalTheme.background}00`}
1062 >
1063 {terminalToolbarActions.map((action) => {
1064 const active =
1065 action.kind === "modifier" && pendingModifier === action.modifier;
1066
1067 return (
1068 <ComposerToolbarButton
1069 key={action.key}
1070 active={active}
1071 label={action.label}
1072 maxWidth={120}
1073 minWidth={action.label.length > 1 ? 56 : 44}
1074 onPress={() => handleToolbarActionPress(action)}
1075 showChevron={false}
1076 textTransform={
1077 action.kind === "modifier" || action.kind === "clear"
1078 ? "uppercase"
1079 : "none"
1080 }
1081 />
1082 );
1083 })}
1084 </ComposerToolbarScroller>
1085 <ComposerToolbarButton
12691086 accessibilityLabel="Dismiss keyboard"
1270 accessibilityRole="button"
1087 icon={{ ios: "keyboard.chevron.compact.down", android: "keyboard_hide" }}
12711088 onPress={handleDismissKeyboard}
1272 style={({ pressed }) => ({
1273 alignItems: "center",
1274 backgroundColor: pressed
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>
1089 showChevron={false}
1090 />
1091 </ComposerToolbarRow>
12941092 </View>
12951093 </KeyboardStickyView>
12961094 ) : !keyboardState.isVisible ? (
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+162-67
......@@ -1,16 +1,27 @@
1import { useRouter } from "expo-router";
1import { Stack, useRouter } from "expo-router";
22import { TextInputWrapper } from "expo-paste-input";
3import { useCallback, useEffect, useMemo } from "react";
4import { View } from "react-native";
5import { KeyboardStickyView, useKeyboardState } from "react-native-keyboard-controller";
3import { useCallback, useEffect, useMemo, useRef } from "react";
4import {
5 InteractionManager,
6 View,
7 useColorScheme,
8 type TextInput as RNTextInput,
9} from "react-native";
10import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";
611import { useSafeAreaInsets } from "react-native-safe-area-context";
712import { useThemeColor } from "../../lib/useThemeColor";
813
914import { EnvironmentId, type ModelSelection } from "@t3tools/contracts";
1015
1116import { AppTextInput as TextInput } from "../../components/AppText";
17import {
18 ComposerToolbarButton,
19 ComposerToolbarRow,
20 ComposerToolbarScroller,
21 ComposerToolbarTrigger,
22} from "../../components/ComposerToolbarTrigger";
1223import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
13import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
24import { ControlPillMenu } from "../../components/ControlPill";
1425import { ProviderIcon } from "../../components/ProviderIcon";
1526
1627import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
......@@ -18,7 +29,6 @@ import { buildThreadRoutePath } from "../../lib/routes";
1829import { useRemoteCatalog } from "../../state/use-remote-catalog";
1930import { useNativePaste } from "../../lib/useNativePaste";
2031import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions";
21import { NewTaskSheetHeader } from "./NewTaskSheetHeader";
2232import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
2333import { useProjectActions } from "./use-project-actions";
2434
......@@ -34,6 +44,22 @@ function withModelSelectionOption(
3444 };
3545}
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
3763export function NewTaskDraftScreen(props: {
3864 readonly initialProjectRef?: {
3965 readonly environmentId?: string;
......@@ -45,11 +71,15 @@ export function NewTaskDraftScreen(props: {
4571 const flow = useNewTaskFlow();
4672 const router = useRouter();
4773 const insets = useSafeAreaInsets();
74 const colorScheme = useColorScheme();
4875 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
4976 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
5077 const { logicalProjects, selectedProject, setProject } = flow;
78 const promptInputRef = useRef<RNTextInput>(null);
5179
5280 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
5484 useEffect(() => {
5585 if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {
......@@ -93,6 +123,24 @@ export function NewTaskDraftScreen(props: {
93123 void flow.loadBranches();
94124 }, [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
96144 const environmentMenuActions = useMemo(
97145 () =>
98146 flow.environments.map((environment) => ({
......@@ -231,10 +279,10 @@ export function NewTaskDraftScreen(props: {
231279 {
232280 id: "workspace:mode",
233281 title: "Mode",
234 subtitle: flow.workspaceMode === "local" ? "Local" : "Worktree",
282 subtitle: flow.workspaceMode === "local" ? "Current checkout" : "New worktree",
235283 subactions: (["local", "worktree"] as const).map((value) => ({
236284 id: `workspace:mode:${value}`,
237 title: value === "local" ? "Local" : "Worktree",
285 title: value === "local" ? "Current checkout" : "New worktree",
238286 state: flow.workspaceMode === value ? ("on" as const) : undefined,
239287 })),
240288 },
......@@ -253,6 +301,31 @@ export function NewTaskDraftScreen(props: {
253301 flow.workspaceMode,
254302 ]);
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 );
256329 function handleModelMenuAction(event: string) {
257330 if (!event.startsWith("model:")) {
258331 return;
......@@ -382,6 +455,8 @@ export function NewTaskDraftScreen(props: {
382455 });
383456
384457 if (createdThread) {
458 flow.setPrompt("");
459 flow.clearAttachments();
385460 router.replace(buildThreadRoutePath(createdThread));
386461 }
387462 } finally {
......@@ -392,40 +467,36 @@ export function NewTaskDraftScreen(props: {
392467 if (!selectedProject) {
393468 return (
394469 <View className="flex-1 bg-sheet">
395 <NewTaskSheetHeader title="Loading task" />
470 <Stack.Screen options={{ title: "Loading task" }} />
396471 </View>
397472 );
398473 }
399474
400475 return (
401476 <View className="flex-1 bg-sheet">
402 <NewTaskSheetHeader
403 title={selectedProject.title}
404 control={
405 flow.logicalProjects.length > 1
406 ? { icon: "chevron.left", onPress: () => router.back() }
407 : undefined
408 }
409 />
410
411 <View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 8 }}>
412 <TextInputWrapper
413 onPaste={(payload) => void handleNativePaste(payload)}
414 style={{ flex: 1 }}
415 >
416 <TextInput
417 multiline
418 value={flow.prompt}
419 onChangeText={flow.setPrompt}
420 placeholder={`Describe a coding task in ${selectedProject.title}`}
421 textAlignVertical="top"
422 className="h-full flex-1 border-0 bg-transparent text-[18px] leading-[28px]"
423 style={{ flex: 1 }}
424 />
425 </TextInputWrapper>
426 </View>
477 <Stack.Screen options={{ title: selectedProject.title }} />
478
479 <KeyboardAvoidingView automaticOffset behavior="padding" style={{ flex: 1 }}>
480 <View style={{ flex: 1, minHeight: 0, paddingHorizontal: 20, paddingTop: 8 }}>
481 <TextInputWrapper
482 onPaste={(payload) => void handleNativePaste(payload)}
483 style={{ flex: 1, minHeight: 0 }}
484 >
485 <TextInput
486 ref={promptInputRef}
487 autoFocus
488 multiline
489 scrollEnabled
490 value={flow.prompt}
491 onChangeText={flow.setPrompt}
492 placeholder={`Describe a coding task in ${selectedProject.title}`}
493 textAlignVertical="top"
494 className="h-full flex-1 border-0 bg-transparent text-[18px] leading-[28px]"
495 style={{ flex: 1, minHeight: 0 }}
496 />
497 </TextInputWrapper>
498 </View>
427499
428 <KeyboardStickyView>
429500 <View
430501 style={{
431502 borderTopWidth: 1,
......@@ -443,41 +514,65 @@ export function NewTaskDraftScreen(props: {
443514 />
444515 </View>
445516 ) : null}
446 <View className="flex-row items-center justify-between gap-2 px-4 pt-2">
447 <ControlPill icon="plus" onPress={() => void handlePickImages()} />
448 <ControlPillMenu
449 actions={modelMenuActions}
450 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
517 <ComposerToolbarRow paddingBottom={controlsBottomPadding} paddingHorizontal={6}>
518 <ComposerToolbarScroller
519 fadeOpaque={sheetFadeOpaque}
520 fadeTransparent={sheetFadeTransparent}
451521 >
452 <ControlPill
453 iconNode={
454 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />
455 }
522 <ComposerToolbarButton
523 icon="plus"
524 onPress={() => void handlePickImages()}
525 showChevron={false}
456526 />
457 </ControlPillMenu>
458 <ControlPillMenu
459 actions={optionsMenuActions}
460 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
461 >
462 <ControlPill icon="slider.horizontal.3" />
463 </ControlPillMenu>
464 <ControlPillMenu
465 actions={environmentMenuActions}
466 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}
467 >
468 <ControlPill icon="desktopcomputer" />
469 </ControlPillMenu>
470 <ControlPillMenu
471 actions={workspaceMenuActions}
472 onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}
473 >
474 <ControlPill icon="point.topleft.down.curvedto.point.bottomright.up" />
475 </ControlPillMenu>
476 <ControlPill
527 <ControlPillMenu
528 actions={modelMenuActions}
529 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
530 >
531 <ComposerToolbarTrigger
532 accessibilityLabel="Model"
533 iconNode={
534 <ProviderIcon provider={flow.selectedModelOption?.providerDriver} size={16} />
535 }
536 label={flow.selectedModelOption?.label ?? "Model"}
537 />
538 </ControlPillMenu>
539 <ControlPillMenu
540 actions={optionsMenuActions}
541 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
542 >
543 <ComposerToolbarTrigger
544 accessibilityLabel="Configuration"
545 icon="slider.horizontal.3"
546 label={configurationLabel}
547 />
548 </ControlPillMenu>
549 <ControlPillMenu
550 actions={environmentMenuActions}
551 onPressAction={({ nativeEvent }) => handleEnvironmentMenuAction(nativeEvent.event)}
552 >
553 <ComposerToolbarTrigger
554 accessibilityLabel="Environment"
555 icon="desktopcomputer"
556 label={selectedEnvironmentLabel}
557 />
558 </ControlPillMenu>
559 <ControlPillMenu
560 actions={workspaceMenuActions}
561 onPressAction={({ nativeEvent }) => handleWorkspaceMenuAction(nativeEvent.event)}
562 >
563 <ComposerToolbarTrigger
564 accessibilityLabel="Workspace"
565 icon="point.topleft.down.curvedto.point.bottomright.up"
566 label={workspaceLabel}
567 />
568 </ControlPillMenu>
569 </ComposerToolbarScroller>
570 <ComposerToolbarButton
571 accessibilityLabel={flow.submitting ? "Starting task" : "Start task"}
477572 icon="arrow.up"
478 label={flow.submitting ? "Starting" : "Start"}
479573 onPress={() => void handleStart()}
480574 variant="primary"
575 showChevron={false}
481576 disabled={
482577 !flow.selectedProject ||
483578 !flow.selectedModel ||
......@@ -486,9 +581,9 @@ export function NewTaskDraftScreen(props: {
486581 (flow.workspaceMode === "worktree" && !flow.selectedBranchName)
487582 }
488583 />
489 </View>
584 </ComposerToolbarRow>
490585 </View>
491 </KeyboardStickyView>
586 </KeyboardAvoidingView>
492587 </View>
493588 );
494589}
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";
3131
3232import { AppText as Text } from "../../components/AppText";
3333import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
34import {
35 ComposerToolbarButton,
36 ComposerToolbarRow,
37 ComposerToolbarScroller,
38 ComposerToolbarTrigger,
39} from "../../components/ComposerToolbarTrigger";
3440import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
3541import { ProviderIcon } from "../../components/ProviderIcon";
3642import type { DraftComposerImageAttachment } from "../../lib/composerImages";
......@@ -67,7 +73,7 @@ export const COMPOSER_EXPANDED_CHROME = 174;
6773 * Used by the feed inset because KeyboardAvoidingLegendList only accounts for
6874 * keyboard height; the floating toolbar remains an additional overlay.
6975 */
70export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 54;
76export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 60;
7177
7278export interface ThreadComposerProps {
7379 readonly draftMessage: string;
......@@ -85,7 +91,6 @@ export interface ThreadComposerProps {
8591 readonly onPickDraftImages: () => Promise<void>;
8692 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
8793 readonly onRemoveDraftImage: (imageId: string) => void;
88 readonly onRefresh: () => Promise<void>;
8994 readonly onStopThread: () => Promise<void>;
9095 readonly onSendMessage: () => void;
9196 readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise<void>;
......@@ -145,6 +150,10 @@ function withModelSelectionOption(
145150 };
146151}
147152
153function formatTitleCase(value: string): string {
154 return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
155}
156
148157export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
149158 const isDarkMode = useColorScheme() === "dark";
150159 const themePlaceholderColor = useThemeColor("--color-placeholder");
......@@ -184,10 +193,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
184193 props.queueCount > 0;
185194
186195 const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send";
187 const modelProvider = props.selectedThread.modelSelection?.instanceId ?? null;
188196 const currentModelSelection = props.selectedThread.modelSelection;
189197 const currentRuntimeMode = props.selectedThread.runtimeMode;
190198 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)";
191201 const selectedProviderStatus = useMemo(() => {
192202 if (!props.serverConfig) return null;
193203 return (
......@@ -431,11 +441,25 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
431441 );
432442
433443 // ── Model menu ───────────────────────────────────────────
434 const providerGroups = useMemo(() => {
435 const options = buildModelOptions(props.serverConfig, currentModelSelection);
436 return groupByProvider(options);
437 }, [props.serverConfig, currentModelSelection]);
438
444 const modelOptions = useMemo(
445 () => buildModelOptions(props.serverConfig, currentModelSelection),
446 [props.serverConfig, currentModelSelection],
447 );
448 const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
449 const currentModelOption =
450 modelOptions.find(
451 (option) =>
452 option.selection.instanceId === currentModelSelection.instanceId &&
453 option.selection.model === currentModelSelection.model,
454 ) ?? null;
455 const configurationLabel = useMemo(() => {
456 const parts = [
457 formatTitleCase(currentEffort),
458 currentFastMode ? "Fast" : null,
459 currentContextWindow !== "1M" ? currentContextWindow : null,
460 ].filter((part): part is string => Boolean(part));
461 return parts.length > 0 ? parts.join(" · ") : "Configuration";
462 }, [currentContextWindow, currentEffort, currentFastMode]);
439463 const modelMenuActions = useMemo(
440464 () =>
441465 providerGroups.map((group) => ({
......@@ -546,8 +570,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
546570 return;
547571 }
548572 const modelKey = event.slice("model:".length);
549 const options = buildModelOptions(props.serverConfig, currentModelSelection);
550 const option = options.find((o) => o.key === modelKey);
573 const option = modelOptions.find((o) => o.key === modelKey);
551574 if (option) {
552575 void props.onUpdateModelSelection(option.selection);
553576 }
......@@ -753,44 +776,56 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
753776
754777 {/* Toolbar row — matches draft page layout (expanded only) */}
755778 {isExpanded ? (
756 <View
757 style={{
758 flexDirection: "row",
759 alignItems: "center",
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)}
779 <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}>
780 <ComposerToolbarScroller
781 fadeOpaque={toolbarFadeOpaque}
782 fadeTransparent={toolbarFadeTransparent}
775783 >
776 <ControlPill icon="slider.horizontal.3" />
777 </ControlPillMenu>
778 <ControlPill icon="arrow.clockwise" onPress={() => void props.onRefresh()} />
779 {showStopAction ? (
780 <ControlPill
781 icon="stop.fill"
782 variant="danger"
783 onPress={() => void props.onStopThread()}
784 <ComposerToolbarButton
785 icon="plus"
786 onPress={() => void props.onPickDraftImages()}
787 showChevron={false}
784788 />
785 ) : null}
786 <ControlPill
789 <ControlPillMenu
790 actions={modelMenuActions}
791 onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)}
792 >
793 <ComposerToolbarTrigger
794 accessibilityLabel="Model"
795 iconNode={
796 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
797 }
798 label={currentModelOption?.label ?? currentModelSelection.model}
799 />
800 </ControlPillMenu>
801 <ControlPillMenu
802 actions={optionsMenuActions}
803 onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)}
804 >
805 <ComposerToolbarTrigger
806 accessibilityLabel="Configuration"
807 icon="slider.horizontal.3"
808 label={configurationLabel}
809 />
810 </ControlPillMenu>
811 {showStopAction ? (
812 <ComposerToolbarButton
813 icon="stop.fill"
814 variant="danger"
815 onPress={() => void props.onStopThread()}
816 showChevron={false}
817 />
818 ) : null}
819 </ComposerToolbarScroller>
820 <ComposerToolbarButton
821 accessibilityLabel={sendLabel}
787822 icon="arrow.up"
788 label={sendLabel}
789823 variant="primary"
790824 disabled={!canSend}
791825 onPress={handleSend}
826 showChevron={false}
792827 />
793 </View>
828 </ComposerToolbarRow>
794829 ) : null}
795830
796831 {/* Queue count */}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+2-20
......@@ -67,7 +67,6 @@ export interface ThreadDetailScreenProps {
6767 readonly onPickDraftImages: () => Promise<void>;
6868 readonly onNativePasteImages: (uris: ReadonlyArray<string>) => Promise<void>;
6969 readonly onRemoveDraftImage: (imageId: string) => void;
70 readonly onRefresh: () => Promise<void>;
7170 readonly onStopThread: () => Promise<void>;
7271 readonly onSendMessage: () => void;
7372 readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise<void>;
......@@ -198,18 +197,17 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: {
198197});
199198
200199export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
201 const { onOpenDrawer, onRefresh } = props;
200 const { onOpenDrawer } = props;
202201
203202 const insets = useSafeAreaInsets();
204203 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
205 const composerBottomInset = Math.max(insets.bottom, 12);
206204 const [composerExpanded, setComposerExpanded] = useState(false);
205 const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);
207206 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
208207 const composerOverlapHeight = composerChrome + composerBottomInset;
209208 const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
210209 const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
211210 const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0);
212 const [refreshing, setRefreshing] = useState(false);
213211 const showContent = props.showContent ?? true;
214212 const layoutVariant = props.layoutVariant ?? "compact";
215213 const isSplitLayout = layoutVariant === "split";
......@@ -223,19 +221,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
223221 onOpenDrawer();
224222 }, [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
239224 const drawerGestureThreshold = 80;
240225 const headerDrawerGesture = useMemo(
241226 () =>
......@@ -273,8 +258,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
273258 contentBottomInset={feedBottomInset}
274259 layoutVariant={layoutVariant}
275260 composerExpanded={composerExpanded}
276 refreshing={refreshing}
277 onRefresh={handleRefresh}
278261 />
279262 ) : (
280263 <View style={{ flex: 1 }} />
......@@ -330,7 +313,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
330313 onPickDraftImages={props.onPickDraftImages}
331314 onNativePasteImages={props.onNativePasteImages}
332315 onRemoveDraftImage={props.onRemoveDraftImage}
333 onRefresh={props.onRefresh}
334316 onStopThread={props.onStopThread}
335317 onSendMessage={props.onSendMessage}
336318 onUpdateModelSelection={props.onUpdateThreadModelSelection}
apps/mobile/src/features/threads/ThreadFeed.tsx-4
......@@ -55,8 +55,6 @@ export interface ThreadFeedProps {
5555 readonly bearerToken: string | null;
5656 readonly agentLabel: string;
5757 readonly contentBottomInset?: number;
58 readonly refreshing?: boolean;
59 readonly onRefresh?: () => void;
6058 readonly layoutVariant?: MobileLayoutVariant;
6159 readonly composerExpanded?: boolean;
6260}
......@@ -958,8 +956,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
958956 on: { layout: true, itemLayout: true, dataChange: true },
959957 }}
960958 maintainScrollAtEndThreshold={0.1}
961 refreshing={props.refreshing ?? false}
962 onRefresh={props.onRefresh}
963959 safeAreaInsetBottom={insets.bottom}
964960 contentContainerStyle={{
965961 paddingTop: 12,
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+8-20
......@@ -7,7 +7,7 @@ import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";
77import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
88import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
99import { useThemeColor } from "../../lib/useThemeColor";
10import { useVcsStatus, vcsStatusManager } from "../../state/use-vcs-status";
10import { useVcsStatus } from "../../state/use-vcs-status";
1111import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state";
1212
1313import { EmptyState } from "../../components/EmptyState";
......@@ -53,6 +53,10 @@ function firstRouteParam(value: string | string[] | undefined): string | null {
5353 return value ?? null;
5454}
5555
56function OpeningThreadLoadingScreen() {
57 return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;
58}
59
5660export function ThreadRouteScreen() {
5761 const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } =
5862 useRemoteEnvironmentState();
......@@ -70,7 +74,6 @@ export function ThreadRouteScreen() {
7074 const commands = useSelectedThreadCommands({
7175 refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus,
7276 });
73 const refreshSelectedThread = commands.onRefresh;
7477 const router = useRouter();
7578 const params = useLocalSearchParams<{
7679 environmentId?: string | string[];
......@@ -122,20 +125,6 @@ export function ThreadRouteScreen() {
122125 );
123126 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
139128 const handleOpenDrawer = useCallback(() => {
140129 setDrawerVisible(true);
141130 }, []);
......@@ -245,7 +234,7 @@ export function ThreadRouteScreen() {
245234 );
246235
247236 if (!environmentId || !threadId) {
248 return <LoadingScreen message="Opening thread…" />;
237 return <OpeningThreadLoadingScreen />;
249238 }
250239
251240 if (!selectedThread) {
......@@ -255,7 +244,7 @@ export function ThreadRouteScreen() {
255244 routeConnectionState === "reconnecting";
256245
257246 if (stillHydrating) {
258 return <LoadingScreen message="Opening thread…" />;
247 return <OpeningThreadLoadingScreen />;
259248 }
260249
261250 return (
......@@ -278,7 +267,7 @@ export function ThreadRouteScreen() {
278267 }
279268
280269 if (!selectedThreadDetail) {
281 return <LoadingScreen message="Opening thread…" />;
270 return <OpeningThreadLoadingScreen />;
282271 }
283272
284273 const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id);
......@@ -388,7 +377,6 @@ export function ThreadRouteScreen() {
388377 onPickDraftImages={composer.onPickDraftImages}
389378 onNativePasteImages={composer.onNativePasteImages}
390379 onRemoveDraftImage={composer.onRemoveDraftImage}
391 onRefresh={handleRefreshAll}
392380 serverConfig={serverConfig}
393381 onStopThread={commands.onStopThread}
394382 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";
1515import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
1616import { groupProjectsByRepository } from "../../lib/repositoryGroups";
1717import { scopedProjectKey } from "../../lib/scopedEntities";
18import {
19 appendComposerDraftAttachments,
20 removeComposerDraftAttachment,
21 replaceComposerDraftAttachments,
22 setComposerDraftText,
23 useComposerDraft,
24} from "../../state/use-composer-drafts";
1825import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs";
1926import { useRemoteCatalog } from "../../state/use-remote-catalog";
2027import {
......@@ -153,8 +160,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
153160 const [selectedBranchName, setSelectedBranchName] = useState<string | null>(null);
154161 const [selectedWorktreePath, setSelectedWorktreePath] = useState<string | null>(null);
155162 const branchLoadVersionRef = useRef(0);
156 const [prompt, setPrompt] = useState("");
157 const [attachments, setAttachments] = useState<ReadonlyArray<DraftComposerImageAttachment>>([]);
158163 const [submitting, setSubmitting] = useState(false);
159164 const [branchQuery, setBranchQuery] = useState("");
160165 const [runtimeMode, setRuntimeMode] = useState<RuntimeMode>(DEFAULT_RUNTIME_MODE);
......@@ -166,28 +171,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
166171 const [contextWindow, setContextWindow] = useState("1M");
167172 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
191174 const reset = useCallback(() => {
192175 console.log("[new task flow] reset", {
193176 defaultEnvironmentId: projects[0]?.environmentId ?? null,
......@@ -199,8 +182,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
199182 setWorkspaceMode("local");
200183 setSelectedBranchName(null);
201184 setSelectedWorktreePath(null);
202 setPrompt("");
203 clearAttachments();
204185 setSubmitting(false);
205186 setBranchQuery("");
206187 setRuntimeMode(DEFAULT_RUNTIME_MODE);
......@@ -209,7 +190,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
209190 setFastMode(false);
210191 setContextWindow("1M");
211192 setExpandedProvider(null);
212 }, [clearAttachments, projects]);
193 }, [projects]);
213194
214195 useEffect(() => {
215196 if (selectedEnvironmentId !== null || projects.length === 0) {
......@@ -271,6 +252,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
271252 ) ??
272253 projectsForEnvironment[0] ??
273254 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
275262 const modelOptions = useMemo(
276263 () =>
......@@ -298,6 +285,48 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
298285 ) ?? null;
299286
300287 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]);
301330 const branchTarget = useMemo(
302331 () => ({
303332 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 @@
11import { 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
511vi.mock("./runtime", () => ({
6 mobileRemoteHttpRuntime: {
12 mobileRuntime: {
713 runPromise: vi.fn(),
814 },
915}));
......@@ -39,4 +45,30 @@ describe("mobile remote connection records", () => {
3945 ),
4046 ).toBe("https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&label=Desktop");
4147 });
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 });
4274});
apps/mobile/src/lib/connection.ts+40-22
......@@ -1,11 +1,14 @@
1import { type AuthClientPresentationMetadata, EnvironmentId } from "@t3tools/contracts";
1import { EnvironmentId } from "@t3tools/contracts";
22import {
33 bootstrapRemoteBearerSession,
44 fetchRemoteEnvironmentDescriptor,
55} from "@t3tools/client-runtime";
66import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote";
7import { Platform } from "react-native";
8import { mobileRemoteHttpRuntime } from "./runtime";
7import * as Effect from "effect/Effect";
8import { mobileAuthClientMetadata } from "./authClientMetadata";
9import { mobileRuntime } from "./runtime";
10
11export { mobileAuthClientMetadata } from "./authClientMetadata";
912
1013export interface RemoteConnectionInput {
1114 readonly pairingUrl: string;
......@@ -18,7 +21,10 @@ export interface SavedRemoteConnection {
1821 readonly displayUrl: string;
1922 readonly httpBaseUrl: string;
2023 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;
2228}
2329
2430export type RemoteClientConnectionState =
......@@ -37,12 +43,21 @@ export function redactPairingCredential(pairingUrl: string): string {
3743 }
3844}
3945
40export function mobileAuthClientMetadata(): AuthClientPresentationMetadata {
41 return {
42 label: "T3 Code Mobile",
43 deviceType: "mobile",
44 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
45 };
46export function isRelayManagedConnection(
47 connection: Pick<SavedRemoteConnection, "authenticationMethod" | "relayManaged">,
48): boolean {
49 return connection.relayManaged === true || connection.authenticationMethod === "dpop";
50}
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;
4661}
4762
4863export async function bootstrapRemoteConnection(
......@@ -52,18 +67,20 @@ export async function bootstrapRemoteConnection(
5267 pairingUrl: input.pairingUrl,
5368 });
5469
55 const descriptor = await mobileRemoteHttpRuntime.runPromise(
56 fetchRemoteEnvironmentDescriptor({
57 httpBaseUrl: target.httpBaseUrl,
58 }),
59 );
60
61 const bootstrap = await mobileRemoteHttpRuntime.runPromise(
62 bootstrapRemoteBearerSession({
63 httpBaseUrl: target.httpBaseUrl,
64 credential: target.credential,
65 clientMetadata: mobileAuthClientMetadata(),
66 }),
70 const { descriptor, bootstrap } = await mobileRuntime.runPromise(
71 Effect.all(
72 {
73 descriptor: fetchRemoteEnvironmentDescriptor({
74 httpBaseUrl: target.httpBaseUrl,
75 }),
76 bootstrap: bootstrapRemoteBearerSession({
77 httpBaseUrl: target.httpBaseUrl,
78 credential: target.credential,
79 clientMetadata: mobileAuthClientMetadata(),
80 }),
81 },
82 { concurrency: "unbounded" },
83 ),
6784 );
6885
6986 return {
......@@ -74,5 +91,6 @@ export async function bootstrapRemoteConnection(
7491 httpBaseUrl: target.httpBaseUrl,
7592 wsBaseUrl: target.wsBaseUrl,
7693 bearerToken: bootstrap.access_token,
94 authenticationMethod: "bearer",
7795 };
7896}
apps/mobile/src/lib/runtime.ts+20-1
......@@ -1,5 +1,24 @@
1import * as Layer from "effect/Layer";
12import * as ManagedRuntime from "effect/ManagedRuntime";
23
34import { 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";
55import * as SecureStore from "expo-secure-store";
66import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts";
77
8import type { SavedRemoteConnection } from "./connection";
8import {
9 isRelayManagedConnection,
10 type SavedRemoteConnection,
11 toStableSavedRemoteConnection,
12} from "./connection";
913
1014const CONNECTIONS_KEY = "t3code.connections";
1115const PREFERENCES_KEY = "t3code.preferences";
16const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
1217const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
1318const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
1419
......@@ -20,6 +25,7 @@ export interface CachedShellSnapshot {
2025}
2126
2227export interface MobilePreferences {
28 readonly liveActivitiesEnabled?: boolean;
2329 readonly terminalFontSize?: number;
2430}
2531
......@@ -133,18 +139,23 @@ export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteC
133139
134140 return pipe(
135141 parsed.connections ?? [],
136 Arr.filter((c) => !!c.environmentId && !!c.bearerToken?.trim()),
142 Arr.filter(
143 (c) => !!c.environmentId && (!!c.bearerToken?.trim() || isRelayManagedConnection(c)),
144 ),
137145 );
138146}
139147
140148export async function saveConnection(connection: SavedRemoteConnection): Promise<void> {
141149 const current = await loadSavedConnections();
150 const stableConnection = toStableSavedRemoteConnection(connection);
142151 const next = current.some((entry) => entry.environmentId === connection.environmentId)
143152 ? pipe(
144153 current,
145 Arr.map((entry) => (entry.environmentId === connection.environmentId ? connection : entry)),
154 Arr.map((entry) =>
155 entry.environmentId === connection.environmentId ? stableConnection : entry,
156 ),
146157 )
147 : pipe(current, Arr.append(connection));
158 : pipe(current, Arr.append(stableConnection));
148159
149160 await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next }));
150161}
......@@ -164,11 +175,19 @@ export async function loadPreferences(): Promise<MobilePreferences> {
164175 return {};
165176 }
166177
178 const preferences: {
179 liveActivitiesEnabled?: boolean;
180 terminalFontSize?: number;
181 } = {};
182
183 if (typeof parsed.liveActivitiesEnabled === "boolean") {
184 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
185 }
167186 if (typeof parsed.terminalFontSize === "number") {
168 return { terminalFontSize: parsed.terminalFontSize };
187 preferences.terminalFontSize = parsed.terminalFontSize;
169188 }
170189
171 return {};
190 return preferences;
172191}
173192
174193export async function savePreferencesPatch(
......@@ -182,3 +201,20 @@ export async function savePreferencesPatch(
182201 await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next));
183202 return next;
184203}
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 {
1111 readonly environmentId: EnvironmentId;
1212 readonly environmentLabel: string;
1313 readonly displayUrl: string;
14 readonly isRelayManaged: boolean;
1415 readonly connectionState: EnvironmentConnectionState;
1516 readonly connectionError: string | null;
1617}
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 @@
11import { useAtomValue } from "@effect/atom-react";
22import { useCallback, useEffect, useMemo } from "react";
3import { Alert } from "react-native";
3import { Alert, AppState } from "react-native";
44
55import {
66 type EnvironmentRuntimeState,
......@@ -9,16 +9,28 @@ import {
99 createKnownEnvironment,
1010 createWsRpcClient,
1111 EnvironmentConnectionState,
12 ManagedRelayDpopSigner,
1213 WsTransport,
14 remoteEndpointUrl,
15 resolveRemoteDpopWebSocketConnectionUrl,
1316 resolveRemoteWebSocketConnectionUrl,
17 waitForManagedRelayClerkToken,
1418} from "@t3tools/client-runtime";
1519import type { EnvironmentId } from "@t3tools/contracts";
1620import * as Arr from "effect/Array";
21import * as Duration from "effect/Duration";
22import * as Effect from "effect/Effect";
1723import * as Order from "effect/Order";
1824import * as Option from "effect/Option";
1925import { pipe } from "effect/Function";
2026import { 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";
2234import { terminalDebugLog } from "../features/terminal/terminalDebugLog";
2335import {
2436 clearCachedShellSnapshot,
......@@ -29,9 +41,10 @@ import {
2941 saveConnection,
3042} from "../lib/storage";
3143import { appAtomRegistry } from "./atom-registry";
32import { mobileRemoteHttpRuntime } from "../lib/runtime";
44import { mobileRuntime } from "../lib/runtime";
3345import {
3446 drainEnvironmentSessions,
47 getEnvironmentSession,
3548 notifyEnvironmentConnectionListeners,
3649 removeEnvironmentSession,
3750 setEnvironmentSession,
......@@ -41,6 +54,11 @@ import {
4154 invalidateSourceControlDiscoveryForEnvironment,
4255 resetSourceControlDiscoveryState,
4356} from "./use-source-control-discovery";
57import {
58 registerAgentAwarenessConnection,
59 unregisterAgentAwarenessConnection,
60 unregisterAllAgentAwarenessConnections,
61} from "../features/agent-awareness/remoteRegistration";
4462import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime";
4563import {
4664 clearCachedShellSnapshotMetadata,
......@@ -53,6 +71,8 @@ import { subscribeTerminalMetadata, terminalSessionManager } from "./use-termina
5371const terminalMetadataUnsubscribers = new Map<EnvironmentId, () => void>();
5472const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry();
5573const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000;
74const APP_RESUME_RECONNECT_COOLDOWN_MS = 2_000;
75let lastAppResumeReconnectAt = Number.NEGATIVE_INFINITY;
5676
5777interface RemoteEnvironmentLocalState {
5878 readonly isLoadingSavedConnection: boolean;
......@@ -153,225 +173,357 @@ function setEnvironmentConnectionStatus(
153173 }));
154174}
155175
156function withTimeout<T>(
157 promise: Promise<T>,
158 timeoutMs: number,
159 timeoutMessage: string,
160): Promise<T> {
161 return new Promise<T>((resolve, reject) => {
162 const timeoutId = setTimeout(() => {
163 reject(new Error(timeoutMessage));
164 }, timeoutMs);
165
166 promise.then(
167 (value) => {
168 clearTimeout(timeoutId);
169 resolve(value);
170 },
171 (error: unknown) => {
172 clearTimeout(timeoutId);
173 reject(error);
174 },
175 );
176function fromPromise<T>(tryPromise: () => Promise<T>): Effect.Effect<T, unknown> {
177 return Effect.tryPromise({
178 try: tryPromise,
179 catch: (cause) => cause,
176180 });
177181}
178182
179export async function disconnectEnvironment(
183export function disconnectEnvironment(
180184 environmentId: EnvironmentId,
181185 options?: {
182186 readonly preserveShellSnapshot?: boolean;
183187 readonly removeSaved?: boolean;
184188 readonly preserveConnectionAttempt?: boolean;
185189 },
186) {
187 if (!options?.preserveConnectionAttempt) {
188 environmentConnectionAttempts.cancel(environmentId);
189 }
190): Effect.Effect<void, unknown> {
191 return Effect.gen(function* () {
192 if (!options?.preserveConnectionAttempt) {
193 environmentConnectionAttempts.cancel(environmentId);
194 }
190195
191 const session = removeEnvironmentSession(environmentId);
192 notifyEnvironmentConnectionListeners();
193 await session?.connection.dispose();
194 terminalMetadataUnsubscribers.get(environmentId)?.();
195 terminalMetadataUnsubscribers.delete(environmentId);
196 if (!options?.preserveShellSnapshot) {
197 shellSnapshotManager.invalidate({ environmentId });
198 }
199 invalidateSourceControlDiscoveryForEnvironment(environmentId);
200 terminalSessionManager.invalidateEnvironment(environmentId);
201 environmentRuntimeManager.invalidate({ environmentId });
202
203 if (options?.removeSaved) {
204 await clearSavedConnection(environmentId);
205 await clearCachedShellSnapshot(environmentId);
206 clearCachedShellSnapshotMetadata(environmentId);
207 removeSavedConnection(environmentId);
208 }
196 const session = removeEnvironmentSession(environmentId);
197 notifyEnvironmentConnectionListeners();
198 if (session) {
199 yield* fromPromise(() => session.connection.dispose());
200 }
201 terminalMetadataUnsubscribers.get(environmentId)?.();
202 terminalMetadataUnsubscribers.delete(environmentId);
203 unregisterAgentAwarenessConnection(environmentId);
204 if (!options?.preserveShellSnapshot) {
205 shellSnapshotManager.invalidate({ environmentId });
206 }
207 invalidateSourceControlDiscoveryForEnvironment(environmentId);
208 terminalSessionManager.invalidateEnvironment(environmentId);
209 environmentRuntimeManager.invalidate({ environmentId });
210
211 if (options?.removeSaved) {
212 yield* Effect.all(
213 [
214 fromPromise(() => clearSavedConnection(environmentId)),
215 fromPromise(() => clearCachedShellSnapshot(environmentId)),
216 ],
217 { concurrency: 2 },
218 );
219 clearCachedShellSnapshotMetadata(environmentId);
220 removeSavedConnection(environmentId);
221 }
222 });
209223}
210224
211export async function connectSavedEnvironment(
225export function connectSavedEnvironment(
212226 connection: SavedRemoteConnection,
213 options?: { readonly persist?: boolean },
214) {
215 const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId);
216 const isCurrentAttempt = connectionAttempt.isCurrent;
217
218 await disconnectEnvironment(connection.environmentId, {
219 preserveShellSnapshot: true,
220 preserveConnectionAttempt: true,
221 });
222 if (!isCurrentAttempt()) {
223 return;
224 }
225
226 if (options?.persist !== false) {
227 await saveConnection(connection);
227 options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean },
228): Effect.Effect<void, unknown> {
229 return Effect.gen(function* () {
230 const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId);
231 const isCurrentAttempt = connectionAttempt.isCurrent;
232 let activeConnection = connection;
233 let initialDpopAccessToken =
234 options?.persist === false ? undefined : connection.dpopAccessToken;
235
236 yield* disconnectEnvironment(connection.environmentId, {
237 preserveShellSnapshot: true,
238 preserveConnectionAttempt: true,
239 });
228240 if (!isCurrentAttempt()) {
229241 return;
230242 }
231 }
232243
233 upsertSavedConnection(connection);
234 setEnvironmentConnectionStatus(connection.environmentId, "connecting", null);
235 shellSnapshotManager.markPending({ environmentId: connection.environmentId });
244 if (options?.persist !== false) {
245 yield* fromPromise(() => saveConnection(toStableSavedRemoteConnection(connection)));
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(
238 () =>
239 mobileRemoteHttpRuntime.runPromise(
240 resolveRemoteWebSocketConnectionUrl({
241 wsBaseUrl: connection.wsBaseUrl,
242 httpBaseUrl: connection.httpBaseUrl,
243 bearerToken: connection.bearerToken,
309 environmentRuntimeManager.patch(
310 { environmentId: connection.environmentId },
311 (previous) => {
312 const nextState =
313 previous.connectionState === "ready" || previous.connectionState === "reconnecting"
314 ? "reconnecting"
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 },
244359 }),
245 ),
246 {
247 onAttempt: () => {
360 environmentId: connection.environmentId,
361 },
362 client,
363 applyShellEvent: (event, environmentId) => {
364 if (isCurrentAttempt()) {
365 shellSnapshotManager.applyEvent({ environmentId }, event);
366 }
367 },
368 syncShellSnapshot: (snapshot, environmentId) => {
248369 if (!isCurrentAttempt()) {
249370 return;
250371 }
251372
252 environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => {
253 const nextState =
254 previous.connectionState === "ready" || previous.connectionState === "reconnecting"
255 ? "reconnecting"
256 : "connecting";
257 const keepSettledFailure =
258 previous.connectionState === "disconnected" && previous.connectionError !== null;
259 return {
260 ...previous,
261 connectionState: keepSettledFailure ? "disconnected" : nextState,
262 connectionError: keepSettledFailure ? previous.connectionError : null,
263 };
264 });
373 shellSnapshotManager.syncSnapshot({ environmentId }, snapshot);
374 markShellSnapshotLive(environmentId);
375 void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined);
376 environmentRuntimeManager.patch({ environmentId }, (runtime) => ({
377 ...runtime,
378 connectionState: "ready",
379 connectionError: null,
380 }));
265381 },
266 onError: (message) => {
382 onShellResubscribe: (environmentId) => {
267383 if (isCurrentAttempt()) {
268 setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message);
384 shellSnapshotManager.markPending({ environmentId });
269385 }
270386 },
271 onClose: (details) => {
272 if (!isCurrentAttempt()) {
273 return;
387 onConfigSnapshot: (serverConfig) => {
388 if (isCurrentAttempt()) {
389 environmentRuntimeManager.patch(
390 { environmentId: connection.environmentId },
391 (runtime) => ({
392 ...runtime,
393 serverConfig,
394 }),
395 );
274396 }
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);
283397 },
284 },
285 );
398 });
286399
287 const client = createWsRpcClient(transport);
288 const environmentConnection = createEnvironmentConnection({
289 kind: "saved",
290 knownEnvironment: {
291 ...createKnownEnvironment({
292 id: connection.environmentId,
293 label: connection.environmentLabel,
294 source: "manual",
295 target: {
296 httpBaseUrl: connection.httpBaseUrl,
297 wsBaseUrl: connection.wsBaseUrl,
298 },
400 if (!isCurrentAttempt()) {
401 yield* fromPromise(() => environmentConnection.dispose());
402 return;
403 }
404
405 setEnvironmentSession(connection.environmentId, {
406 client,
407 connection: environmentConnection,
408 });
409
410 const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe(
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,
299452 }),
453 );
454 terminalDebugLog("registry:terminal-metadata-subscribed", {
300455 environmentId: connection.environmentId,
301 },
302 client,
303 applyShellEvent: (event, environmentId) => {
304 if (isCurrentAttempt()) {
305 shellSnapshotManager.applyEvent({ environmentId }, event);
306 }
307 },
308 syncShellSnapshot: (snapshot, environmentId) => {
309 if (!isCurrentAttempt()) {
310 return;
311 }
312
313 shellSnapshotManager.syncSnapshot({ environmentId }, snapshot);
314 markShellSnapshotLive(environmentId);
315 void saveCachedShellSnapshot(environmentId, snapshot);
316 environmentRuntimeManager.patch({ environmentId }, (runtime) => ({
317 ...runtime,
318 connectionState: "ready",
319 connectionError: null,
320 }));
321 },
322 onShellResubscribe: (environmentId) => {
323 if (isCurrentAttempt()) {
324 shellSnapshotManager.markPending({ environmentId });
325 }
326 },
327 onConfigSnapshot: (serverConfig) => {
328 if (isCurrentAttempt()) {
329 environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (runtime) => ({
330 ...runtime,
331 serverConfig,
332 }));
333 }
334 },
456 });
457 registerAgentAwarenessConnection(toStableSavedRemoteConnection(activeConnection));
458 notifyEnvironmentConnectionListeners();
335459 });
460}
336461
337 if (!isCurrentAttempt()) {
338 await environmentConnection.dispose();
462export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void {
463 const now = Date.now();
464 if (now - lastAppResumeReconnectAt < APP_RESUME_RECONNECT_COOLDOWN_MS) {
339465 return;
340466 }
341467
342 setEnvironmentSession(connection.environmentId, {
343 client,
344 connection: environmentConnection,
345 });
346 terminalMetadataUnsubscribers.set(
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();
468 for (const connection of Object.values(getSavedConnectionsById())) {
469 const session = getEnvironmentSession(connection.environmentId);
470 if (session?.client.isHeartbeatFresh()) {
471 continue;
472 }
357473
358 try {
359 await withTimeout(
360 environmentConnection.ensureBootstrapped(),
361 SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS,
362 "Environment did not respond before the connection timeout.",
363 );
364 } catch (error) {
365 if (isCurrentAttempt()) {
366 setEnvironmentConnectionStatus(
367 connection.environmentId,
368 "disconnected",
369 error instanceof Error ? error.message : "Failed to bootstrap remote connection.",
370 );
474 lastAppResumeReconnectAt = now;
475 terminalDebugLog("registry:app-resume-reconnect", {
476 environmentId: connection.environmentId,
477 reason,
478 hasSession: session !== null,
479 });
480
481 if (!session) {
482 void mobileRuntime
483 .runPromise(
484 connectSavedEnvironment(connection, {
485 persist: false,
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;
371497 }
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 });
372511 }
373512}
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
375527const environmentsSortOrder = Order.mapInput(
376528 Order.Struct({
377529 environmentLabel: Order.String,
......@@ -392,6 +544,7 @@ function deriveConnectedEnvironments(
392544 environmentId: connection.environmentId,
393545 environmentLabel: connection.environmentLabel,
394546 displayUrl: connection.displayUrl,
547 isRelayManaged: isRelayManagedConnection(connection),
395548 connectionState: runtime?.connectionState ?? "idle",
396549 connectionError: runtime?.connectionError ?? null,
397550 };
......@@ -403,9 +556,11 @@ function deriveConnectedEnvironments(
403556export function useRemoteEnvironmentBootstrap() {
404557 useEffect(() => {
405558 let cancelled = false;
559 const unsubscribeAppResumeReconnects = subscribeAppResumeReconnects();
406560
407 void loadSavedConnections()
408 .then((connections) => {
561 void (async () => {
562 try {
563 const connections = await loadSavedConnections();
409564 if (cancelled) {
410565 return;
411566 }
......@@ -418,37 +573,40 @@ export function useRemoteEnvironmentBootstrap() {
418573
419574 setIsLoadingSavedConnection(false);
420575
421 void (async () => {
422 await Promise.all(
423 connections.map(async (connection) => {
424 const cached = await loadCachedShellSnapshot(connection.environmentId);
425 if (!cancelled && cached) {
426 hydrateCachedShellSnapshot(cached);
427 }
428 }),
429 );
576 await Promise.all(
577 connections.map(async (connection) => {
578 const cached = await loadCachedShellSnapshot(connection.environmentId);
579 if (!cancelled && cached) {
580 hydrateCachedShellSnapshot(cached);
581 }
582 }),
583 );
430584
431 if (cancelled) {
432 return;
433 }
585 if (cancelled) {
586 return;
587 }
434588
435 await Promise.all(
589 await mobileRuntime.runPromise(
590 Effect.all(
436591 connections.map((connection) =>
437592 connectSavedEnvironment(connection, {
438593 persist: false,
594 suppressBootstrapError: true,
439595 }),
440596 ),
441 );
442 })();
443 })
444 .catch(() => {
597 { concurrency: "unbounded" },
598 ),
599 );
600 } catch {
445601 if (!cancelled) {
446602 setIsLoadingSavedConnection(false);
447603 }
448 });
604 }
605 })();
449606
450607 return () => {
451608 cancelled = true;
609 unsubscribeAppResumeReconnects();
452610 for (const session of drainEnvironmentSessions()) {
453611 void session.connection.dispose();
454612 }
......@@ -457,6 +615,7 @@ export function useRemoteEnvironmentBootstrap() {
457615 }
458616 terminalMetadataUnsubscribers.clear();
459617 environmentConnectionAttempts.clear();
618 unregisterAllAgentAwarenessConnections();
460619 environmentRuntimeManager.invalidate();
461620 shellSnapshotManager.invalidate();
462621 resetSourceControlDiscoveryState();
......@@ -538,7 +697,7 @@ export function useRemoteConnections() {
538697 const nextPairingUrl = pairingUrl ?? connectionPairingUrl;
539698 const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl });
540699 clearPendingConnectionError();
541 await connectSavedEnvironment(connection);
700 await mobileRuntime.runPromise(connectSavedEnvironment(connection));
542701 clearConnectionPairingUrl();
543702 } catch (error) {
544703 setPendingConnectionError(
......@@ -556,7 +715,7 @@ export function useRemoteConnections() {
556715 updates: { readonly label: string; readonly displayUrl: string },
557716 ) => {
558717 const connection = getSavedConnectionsById()[environmentId];
559 if (!connection) {
718 if (!connection || isRelayManagedConnection(connection)) {
560719 return;
561720 }
562721
......@@ -577,7 +736,14 @@ export function useRemoteConnections() {
577736 if (!connection) {
578737 return;
579738 }
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);
581747 }, []);
582748
583749 const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => {
......@@ -595,7 +761,9 @@ export function useRemoteConnections() {
595761 text: "Remove",
596762 style: "destructive",
597763 onPress: () => {
598 void disconnectEnvironment(environmentId, { removeSaved: true });
764 void mobileRuntime
765 .runPromise(disconnectEnvironment(environmentId, { removeSaved: true }))
766 .catch(() => undefined);
599767 },
600768 },
601769 ],
apps/mobile/src/state/use-thread-composer-state.ts+41-90
......@@ -16,6 +16,16 @@ import type { DraftComposerImageAttachment } from "../lib/composerImages";
1616import { scopedThreadKey } from "../lib/scopedEntities";
1717import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity";
1818import { 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";
1929import { getEnvironmentClient } from "./environment-session-registry";
2030import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types";
2131import {
......@@ -26,15 +36,6 @@ import { useRemoteCatalog } from "../state/use-remote-catalog";
2636import { useSelectedThreadDetail } from "../state/use-thread-detail";
2737import { 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
3839const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
3940 Atom.keepAlive,
4041 Atom.withLabel("mobile:thread-composer:dispatching-message-id"),
......@@ -44,33 +45,6 @@ const queuedMessagesByThreadKeyAtom = Atom.make<Record<string, ReadonlyArray<Que
4445 {},
4546).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
7448export function appendReviewCommentToDraft(input: {
7549 readonly environmentId: EnvironmentId;
7650 readonly threadId: ThreadId;
......@@ -78,15 +52,11 @@ export function appendReviewCommentToDraft(input: {
7852 readonly attachments?: ReadonlyArray<DraftComposerImageAttachment>;
7953}): void {
8054 const threadKey = scopedThreadKey(input.environmentId, input.threadId);
81 const current = appAtomRegistry.get(draftMessageByThreadKeyAtom);
82 const existing = current[threadKey] ?? "";
55 const existing = appAtomRegistry.get(composerDraftsAtom)[threadKey]?.text ?? "";
8356 const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : "";
84 appAtomRegistry.set(draftMessageByThreadKeyAtom, {
85 ...current,
86 [threadKey]: `${existing}${separator}${input.text}`,
87 });
57 setComposerDraftText(threadKey, `${existing}${separator}${input.text}`);
8858 if (input.attachments && input.attachments.length > 0) {
89 appendDraftAttachments(threadKey, input.attachments);
59 appendComposerDraftAttachments(threadKey, input.attachments);
9060 }
9161}
9262
......@@ -94,40 +64,18 @@ export function useThreadDraftForThread(input: {
9464 readonly environmentId?: EnvironmentId;
9565 readonly threadId?: ThreadId;
9666}) {
97 const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom);
98 const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom);
9967 const threadKey =
10068 input.environmentId && input.threadId
10169 ? scopedThreadKey(input.environmentId, input.threadId)
10270 : null;
71 const draft = useComposerDraft(threadKey);
10372
10473 return {
105 draftMessage: threadKey ? (draftMessageByThreadKey[threadKey] ?? "") : "",
106 draftAttachments: threadKey ? (draftAttachmentsByThreadKey[threadKey] ?? []) : [],
74 draftMessage: draft.text,
75 draftAttachments: draft.attachments,
10776 };
10877}
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
13179function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {
13280 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);
13381}
......@@ -231,11 +179,14 @@ export function useThreadComposerState() {
231179 const { threads } = useRemoteCatalog();
232180 const { selectedThread: selectedThreadShell } = useThreadSelection();
233181 const selectedThread = useSelectedThreadDetail();
234 const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom);
235 const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom);
182 const composerDrafts = useAtomValue(composerDraftsAtom);
236183 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
237184 const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom);
238185
186 useEffect(() => {
187 ensureComposerDraftsLoaded();
188 }, []);
189
239190 const selectedThreadKey = selectedThreadShell
240191 ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id)
241192 : null;
......@@ -252,10 +203,9 @@ export function useThreadComposerState() {
252203 [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages],
253204 );
254205
255 const draftMessage = selectedThreadKey ? (draftMessageByThreadKey[selectedThreadKey] ?? "") : "";
256 const draftAttachments = selectedThreadKey
257 ? (draftAttachmentsByThreadKey[selectedThreadKey] ?? [])
258 : [];
206 const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null;
207 const draftMessage = selectedDraft?.text ?? "";
208 const draftAttachments = selectedDraft?.attachments ?? [];
259209 const selectedThreadQueueCount = selectedThreadQueuedMessages.length;
260210
261211 const selectedThreadSessionActivity = useMemo(() => {
......@@ -350,8 +300,9 @@ export function useThreadComposerState() {
350300 }
351301
352302 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
353 const text = (draftMessageByThreadKey[threadKey] ?? "").trim();
354 const attachments = draftAttachmentsByThreadKey[threadKey] ?? [];
303 const draft = composerDrafts[threadKey];
304 const text = (draft?.text ?? "").trim();
305 const attachments = draft?.attachments ?? [];
355306 if (text.length === 0 && attachments.length === 0) {
356307 return;
357308 }
......@@ -366,8 +317,8 @@ export function useThreadComposerState() {
366317 attachments,
367318 createdAt: metadata.createdAt,
368319 });
369 clearDraft(threadKey);
370 }, [draftAttachmentsByThreadKey, draftMessageByThreadKey, selectedThreadShell]);
320 clearComposerDraft(threadKey);
321 }, [composerDrafts, selectedThreadShell]);
371322
372323 const onChangeDraftMessage = useCallback(
373324 (value: string) => {
......@@ -376,7 +327,7 @@ export function useThreadComposerState() {
376327 }
377328
378329 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
379 setDraftMessage(threadKey, value);
330 setComposerDraftText(threadKey, value);
380331 },
381332 [selectedThreadShell],
382333 );
......@@ -388,15 +339,15 @@ export function useThreadComposerState() {
388339
389340 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
390341 const result = await pickComposerImages({
391 existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0,
342 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
392343 });
393344 if (result.images.length > 0) {
394 appendDraftAttachments(threadKey, result.images);
345 appendComposerDraftAttachments(threadKey, result.images);
395346 }
396347 if (result.error) {
397348 setPendingConnectionError(result.error);
398349 }
399 }, [draftAttachmentsByThreadKey, selectedThreadShell]);
350 }, [composerDrafts, selectedThreadShell]);
400351
401352 const onPasteIntoDraft = useCallback(async () => {
402353 if (!selectedThreadShell) {
......@@ -405,18 +356,18 @@ export function useThreadComposerState() {
405356
406357 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
407358 const result = await pasteComposerClipboard({
408 existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0,
359 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
409360 });
410361 if (result.images.length > 0) {
411 appendDraftAttachments(threadKey, result.images);
362 appendComposerDraftAttachments(threadKey, result.images);
412363 }
413364 if (result.text) {
414 appendDraftMessage(threadKey, result.text);
365 appendComposerDraftText(threadKey, result.text);
415366 }
416367 if (result.error) {
417368 setPendingConnectionError(result.error);
418369 }
419 }, [draftAttachmentsByThreadKey, selectedThreadShell]);
370 }, [composerDrafts, selectedThreadShell]);
420371
421372 const onNativePasteImages = useCallback(
422373 async (uris: ReadonlyArray<string>) => {
......@@ -428,16 +379,16 @@ export function useThreadComposerState() {
428379 try {
429380 const images = await convertPastedImagesToAttachments({
430381 uris,
431 existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0,
382 existingCount: composerDrafts[threadKey]?.attachments.length ?? 0,
432383 });
433384 if (images.length > 0) {
434 appendDraftAttachments(threadKey, images);
385 appendComposerDraftAttachments(threadKey, images);
435386 }
436387 } catch (error) {
437388 console.error("[native paste] error converting images", error);
438389 }
439390 },
440 [draftAttachmentsByThreadKey, selectedThreadShell],
391 [composerDrafts, selectedThreadShell],
441392 );
442393
443394 const onRemoveDraftImage = useCallback(
......@@ -447,7 +398,7 @@ export function useThreadComposerState() {
447398 }
448399
449400 const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id);
450 removeDraftImage(threadKey, imageId);
401 removeComposerDraftAttachment(threadKey, imageId);
451402 },
452403 [selectedThreadShell],
453404 );
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";
7777import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
7878import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";
7979import * as VcsProcess from "../src/vcs/VcsProcess.ts";
80import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts";
8081
8182const decodeCodexSettings = Schema.decodeEffect(CodexSettings);
8283
......@@ -160,7 +161,7 @@ class OrchestrationHarnessRuntimeError extends Schema.TaggedErrorClass<Orchestra
160161 "OrchestrationHarnessRuntimeError",
161162 {
162163 operation: Schema.String,
163 cause: Schema.optional(Schema.Defect),
164 cause: Schema.optional(Schema.Defect()),
164165 },
165166) {}
166167
......@@ -364,6 +365,12 @@ export const makeOrchestrationIntegrationHarness = (
364365 drain: Effect.void,
365366 }),
366367 ),
368 Layer.provideMerge(
369 Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, {
370 publishThread: () => Effect.void,
371 start: () => Effect.void,
372 }),
373 ),
367374 );
368375 const layer = Layer.empty.pipe(
369376 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/auth/EnvironmentAuth.ts+65-6
......@@ -19,6 +19,7 @@ import {
1919} from "@t3tools/contracts";
2020import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
2121import * as Context from "effect/Context";
22import * as Crypto from "effect/Crypto";
2223import * as Data from "effect/Data";
2324import * as DateTime from "effect/DateTime";
2425import * as Duration from "effect/Duration";
......@@ -31,6 +32,7 @@ import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
3132import * as PairingGrantStore from "./PairingGrantStore.ts";
3233import * as ServerSecretStore from "./ServerSecretStore.ts";
3334import * as SessionStore from "./SessionStore.ts";
35import { verifyRequestDpopProof } from "./dpop.ts";
3436import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";
3537
3638export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
......@@ -61,6 +63,7 @@ export interface AuthenticatedSession {
6163 readonly subject: string;
6264 readonly method: ServerAuthSessionMethod;
6365 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
66 readonly proofKeyThumbprint?: string;
6467 readonly expiresAt?: DateTime.DateTime;
6568}
6669
......@@ -107,6 +110,9 @@ export interface EnvironmentAuthShape {
107110 credential: string,
108111 requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,
109112 requestMetadata: AuthClientMetadata,
113 input?: {
114 readonly proofKeyThumbprint?: string;
115 },
110116 ) => Effect.Effect<
111117 AuthAccessTokenResult,
112118 ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError
......@@ -116,6 +122,7 @@ export interface EnvironmentAuthShape {
116122 readonly label?: string;
117123 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
118124 readonly subject?: string;
125 readonly proofKeyThumbprint?: string;
119126 }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
120127 readonly issuePairingCredential: (
121128 input?: AuthCreatePairingCredentialInput,
......@@ -184,6 +191,7 @@ type BootstrapExchangeResult = {
184191};
185192
186193const AUTHORIZATION_PREFIX = "Bearer ";
194const DPOP_AUTHORIZATION_PREFIX = "DPoP ";
187195const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket";
188196
189197const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
......@@ -245,10 +253,21 @@ function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string
245253 return token.length > 0 ? token : null;
246254}
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
248265export const make = Effect.fn("makeEnvironmentAuth")(function* () {
249266 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
250267 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
251268 const sessions = yield* SessionStore.SessionStore;
269 const secretStore = yield* ServerSecretStore.ServerSecretStore;
270 const crypto = yield* Crypto.Crypto;
252271 const descriptor = yield* policy.getDescriptor();
253272
254273 const authenticateToken = (
......@@ -270,6 +289,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
270289 subject: session.subject,
271290 method: session.method,
272291 scopes: session.scopes,
292 ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}),
273293 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
274294 })),
275295 mapSessionVerificationErrors,
......@@ -278,11 +298,43 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
278298 const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
279299 const cookieToken = request.cookies[sessions.cookieName];
280300 const bearerToken = parseBearerToken(request);
281 const credential = cookieToken ?? bearerToken;
301 const dpopToken = parseDpopToken(request);
302 const credential = cookieToken ?? bearerToken ?? dpopToken;
282303 if (!credential) {
283304 return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" }));
284305 }
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 );
286338 };
287339
288340 const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) =>
......@@ -349,8 +401,8 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
349401 );
350402
351403 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] =
352 (credential, requestedScopes, requestMetadata) =>
353 bootstrapCredentials.consume(credential).pipe(
404 (credential, requestedScopes, requestMetadata, input) =>
405 bootstrapCredentials.consume(credential, input).pipe(
354406 Effect.mapError(toBootstrapExchangeError),
355407 Effect.flatMap((grant) =>
356408 Effect.gen(function* () {
......@@ -362,9 +414,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
362414 }
363415 return yield* sessions
364416 .issue({
365 method: "bearer-access-token",
417 method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",
366418 subject: grant.subject,
367419 scopes: grantedScopes,
420 ...(input?.proofKeyThumbprint
421 ? {
422 proofKeyThumbprint: input.proofKeyThumbprint,
423 ttl: Duration.hours(1),
424 }
425 : {}),
368426 client: {
369427 ...requestMetadata,
370428 ...(grant.label ? { label: grant.label } : {}),
......@@ -388,7 +446,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
388446 ({
389447 access_token: session.token,
390448 issued_token_type: AuthAccessTokenType,
391 token_type: "Bearer",
449 token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer",
392450 expires_in: Math.max(
393451 0,
394452 Math.floor(
......@@ -434,6 +492,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
434492 subject: input?.subject ?? "one-time-token",
435493 ...(input?.ttl ? { ttl: input.ttl } : {}),
436494 ...(input?.label ? { label: input.label } : {}),
495 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
437496 });
438497 return {
439498 id: issued.id,
apps/server/src/auth/EnvironmentAuthPolicy.ts+1-1
......@@ -39,7 +39,7 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () {
3939 const descriptor: ServerAuthDescriptor = {
4040 policy,
4141 bootstrapMethods,
42 sessionMethods: ["browser-session-cookie", "bearer-access-token"],
42 sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"],
4343 sessionCookieName: resolveSessionCookieName({
4444 mode: config.mode,
4545 port: config.port,
apps/server/src/auth/PairingGrantStore.test.ts+23
......@@ -92,6 +92,29 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
9292 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
9393 );
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
95118 it.effect("seeds the desktop bootstrap credential as a one-time grant", () =>
96119 Effect.gen(function* () {
97120 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
apps/server/src/auth/PairingGrantStore.ts+37-2
......@@ -26,6 +26,7 @@ export interface BootstrapGrant {
2626 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
2727 readonly subject: string;
2828 readonly label?: string;
29 readonly proofKeyThumbprint?: string;
2930 readonly expiresAt: DateTime.DateTime;
3031}
3132
......@@ -50,6 +51,7 @@ export interface IssuedBootstrapCredential {
5051 readonly id: string;
5152 readonly credential: string;
5253 readonly label?: string;
54 readonly proofKeyThumbprint?: string;
5355 readonly expiresAt: DateTime.Utc;
5456}
5557
......@@ -69,6 +71,7 @@ export interface PairingGrantStoreShape {
6971 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
7072 readonly subject?: string;
7173 readonly label?: string;
74 readonly proofKeyThumbprint?: string;
7275 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
7376 readonly listActive: () => Effect.Effect<
7477 ReadonlyArray<AuthPairingLink>,
......@@ -76,7 +79,12 @@ export interface PairingGrantStoreShape {
7679 >;
7780 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
7881 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>;
8088}
8189
8290export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()(
......@@ -232,6 +240,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
232240 id,
233241 credential,
234242 ...(input?.label ? { label: input.label } : {}),
243 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
235244 expiresAt,
236245 };
237246 yield* pairingLinks.create({
......@@ -241,6 +250,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
241250 scopes: input?.scopes ?? AuthStandardClientScopes,
242251 subject: input?.subject ?? "one-time-token",
243252 label: input?.label ?? null,
253 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
244254 createdAt: now,
245255 expiresAt: expiresAt,
246256 });
......@@ -259,7 +269,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
259269 );
260270
261271 const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")(
262 function* (credential) {
272 function* (credential, input) {
263273 const now = yield* DateTime.now;
264274 const seededResult: ConsumeResult = yield* Ref.modify(
265275 seededGrantsRef,
......@@ -289,6 +299,17 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
289299 ];
290300 }
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
292313 const remainingUses = grant.remainingUses;
293314 if (typeof remainingUses === "number") {
294315 if (remainingUses <= 1) {
......@@ -309,6 +330,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
309330 scopes: grant.scopes,
310331 subject: grant.subject,
311332 ...(grant.label ? { label: grant.label } : {}),
333 ...(grant.proofKeyThumbprint
334 ? { proofKeyThumbprint: grant.proofKeyThumbprint }
335 : {}),
312336 expiresAt: grant.expiresAt,
313337 } satisfies BootstrapGrant,
314338 },
......@@ -326,6 +350,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
326350
327351 const consumed = yield* pairingLinks.consumeAvailable({
328352 credential,
353 proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
329354 consumedAt: now,
330355 now,
331356 });
......@@ -337,6 +362,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
337362 scopes: consumed.value.scopes,
338363 subject: consumed.value.subject,
339364 ...(consumed.value.label ? { label: consumed.value.label } : {}),
365 ...(consumed.value.proofKeyThumbprint
366 ? { proofKeyThumbprint: consumed.value.proofKeyThumbprint }
367 : {}),
340368 expiresAt: consumed.value.expiresAt,
341369 } satisfies BootstrapGrant;
342370 }
......@@ -360,6 +388,13 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
360388 return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
361389 }
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
363398 return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
364399 },
365400 Effect.mapError((cause) =>
apps/server/src/auth/ServerSecretStore.ts+10-5
......@@ -15,9 +15,16 @@ export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{
1515 readonly cause?: unknown;
1616}> {}
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
1824export interface ServerSecretStoreShape {
1925 readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>;
2026 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
27 readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
2128 readonly getOrCreateRandom: (
2229 name: string,
2330 bytes: number,
......@@ -48,9 +55,6 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
4855
4956 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
5458 const get: ServerSecretStoreShape["get"] = (name) =>
5559 fileSystem.readFile(resolveSecretPath(name)).pipe(
5660 Effect.map((bytes) => Uint8Array.from(bytes)),
......@@ -104,7 +108,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
104108 );
105109 };
106110
107 const create: ServerSecretStoreShape["set"] = (name, value) => {
111 const create: ServerSecretStoreShape["create"] = (name, value) => {
108112 const secretPath = resolveSecretPath(name);
109113 return Effect.scoped(
110114 Effect.gen(function* () {
......@@ -146,7 +150,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
146150 create(name, generated).pipe(
147151 Effect.as(Uint8Array.from(generated)),
148152 Effect.catchTag("SecretStoreError", (error) =>
149 isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"
153 isSecretAlreadyExistsError(error)
150154 ? get(name).pipe(
151155 Effect.flatMap((created) =>
152156 created !== null
......@@ -185,6 +189,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
185189 return {
186190 get,
187191 set,
192 create,
188193 getOrCreateRandom,
189194 remove,
190195 } satisfies ServerSecretStoreShape;
apps/server/src/auth/SessionStore.ts+8-1
......@@ -40,6 +40,7 @@ export interface IssuedSession {
4040 readonly client: AuthClientMetadata;
4141 readonly expiresAt: DateTime.DateTime;
4242 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
43 readonly proofKeyThumbprint?: string;
4344}
4445
4546export interface VerifiedSession {
......@@ -50,6 +51,7 @@ export interface VerifiedSession {
5051 readonly expiresAt?: DateTime.DateTime;
5152 readonly subject: string;
5253 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
54 readonly proofKeyThumbprint?: string;
5355}
5456
5557export type SessionCredentialChange =
......@@ -86,6 +88,7 @@ export interface SessionStoreShape {
8688 readonly method?: ServerAuthSessionMethod;
8789 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
8890 readonly client?: AuthClientMetadata;
91 readonly proofKeyThumbprint?: string;
8992 }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
9093 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
9194 readonly issueWebSocketToken: (
......@@ -132,7 +135,8 @@ const SessionClaims = Schema.Struct({
132135 sid: AuthSessionId,
133136 sub: Schema.String,
134137 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),
136140 iat: Schema.Number,
137141 exp: Schema.Number,
138142});
......@@ -310,6 +314,7 @@ export const make = Effect.fn("makeSessionStore")(function* () {
310314 sub: input?.subject ?? "browser",
311315 scopes: input?.scopes ?? AuthStandardClientScopes,
312316 method: input?.method ?? "browser-session-cookie",
317 ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}),
313318 iat: issuedAt.epochMilliseconds,
314319 exp: expiresAt.epochMilliseconds,
315320 };
......@@ -360,6 +365,7 @@ export const make = Effect.fn("makeSessionStore")(function* () {
360365 client,
361366 expiresAt: expiresAt,
362367 scopes: claims.scopes,
368 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
363369 } satisfies IssuedSession;
364370 },
365371 Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")),
......@@ -425,6 +431,7 @@ export const make = Effect.fn("makeSessionStore")(function* () {
425431 expiresAt: expiresAt.value,
426432 subject: claims.sub,
427433 scopes: claims.scopes,
434 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
428435 } satisfies VerifiedSession;
429436 },
430437 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";
3434import * as EnvironmentAuth from "./EnvironmentAuth.ts";
3535import * as SessionStore from "./SessionStore.ts";
3636import { 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
3864export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe(
3965 Effect.map((span) => span.traceId),
......@@ -152,7 +178,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect(
152178 scopes: new Set(session.scopes),
153179 }),
154180 );
155 });
181 }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized));
156182 }),
157183);
158184
......@@ -199,6 +225,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
199225 yield* HttpEffect.appendPreResponseHandler((_request, response) =>
200226 Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)),
201227 );
228 yield* appendCredentialResponseHeaders;
202229 return result.response;
203230 },
204231 Effect.catchTags({
......@@ -230,8 +257,22 @@ export const authHttpApiLayer = HttpApiBuilder.group(
230257 AuthRelayWriteScope,
231258 ]),
232259 });
233 if (requestedScopes === null)
260 if (requestedScopes === null) {
234261 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;
235276 return yield* serverAuth.exchangeBootstrapCredentialForAccessToken(
236277 args.payload.subject_token,
237278 requestedScopes,
......@@ -245,6 +286,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
245286 ...(args.payload.client_os ? { os: args.payload.client_os } : {}),
246287 },
247288 }),
289 proofKeyThumbprint ? { proofKeyThumbprint } : undefined,
248290 );
249291 },
250292 Effect.catchTags({
......@@ -261,6 +303,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
261303 function* (args) {
262304 yield* annotateEnvironmentRequest(args.endpoint.name);
263305 const session = yield* EnvironmentAuthenticatedPrincipal;
306 yield* appendCredentialResponseHeaders;
264307 return yield* serverAuth.issueWebSocketTicket(session);
265308 },
266309 Effect.catchTag("ServerAuthInternalError", (error) =>
apps/server/src/auth/utils.ts+6-3
......@@ -5,6 +5,8 @@ import type {
55} from "@t3tools/contracts";
66import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
77import * as Crypto from "node:crypto";
8import * as Encoding from "effect/Encoding";
9import * as Result from "effect/Result";
810
911const SESSION_COOKIE_NAME = "t3_session";
1012
......@@ -20,12 +22,13 @@ export function resolveSessionCookieName(input: {
2022}
2123
2224export function base64UrlEncode(input: string | Uint8Array): string {
23 const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input);
24 return buffer.toString("base64url");
25 return typeof input === "string"
26 ? Encoding.encodeBase64Url(new TextEncoder().encode(input))
27 : Encoding.encodeBase64Url(input);
2528}
2629
2730export function base64UrlDecodeUtf8(input: string): string {
28 return Buffer.from(input, "base64url").toString("utf8");
31 return Result.getOrThrow(Encoding.decodeBase64UrlString(input));
2932}
3033
3134export function signPayload(payload: string, secret: Uint8Array): string {
apps/server/src/bin.test.ts+122-3
......@@ -1,6 +1,6 @@
11// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries.
22import * as NodeHttp from "node:http";
3import { mkdtempSync } from "node:fs";
3import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
44import { tmpdir } from "node:os";
55import { join } from "node:path";
66
......@@ -19,7 +19,7 @@ import * as CliError from "effect/unstable/cli/CliError";
1919import * as TestConsole from "effect/testing/TestConsole";
2020import { Command } from "effect/unstable/cli";
2121
22import { cli } from "./bin.ts";
22import { cli, makeCli } from "./bin.ts";
2323import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
2424import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
2525import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
......@@ -38,7 +38,11 @@ import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";
3838const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
3939class 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);
4246const runCliWithRuntime = (args: ReadonlyArray<string>) =>
4347 runCli(args).pipe(Effect.provide(CliRuntimeLayer));
4448
......@@ -175,6 +179,121 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => {
175179 }),
176180 );
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
178297 it.effect("executes auth pairing subcommands and redacts secrets from list output", () =>
179298 Effect.gen(function* () {
180299 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";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import { Command } from "effect/unstable/cli";
6import * as CliError from "effect/unstable/cli/CliError";
67
78import * as NetService from "@t3tools/shared/Net";
89import packageJson from "../package.json" with { type: "json" };
910import { authCommand } from "./cli/auth.ts";
11import { cloudCommand } from "./cli/cloud.ts";
12import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
1013import { sharedServerCommandFlags } from "./cli/config.ts";
1114import { projectCommand } from "./cli/project.ts";
1215import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1316
1417const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
1518
16export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(
17 Command.withDescription("Run the T3 Code server."),
18 Command.withHandler((flags) => runServerCommand(flags)),
19 Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]),
19const cloudPublicConfigMissingMessage =
20 "T3 Cloud commands are unavailable: this build is missing T3 Cloud public configuration.";
21
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 ),
2039);
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
2256if (import.meta.main) {
2357 Command.run(cli, { version: packageJson.version }).pipe(
2458 Effect.scoped,
apps/server/src/checkpointing/Errors.ts+2-2
......@@ -11,7 +11,7 @@ export class CheckpointUnavailableError extends Schema.TaggedErrorClass<Checkpoi
1111 threadId: Schema.String,
1212 turnCount: Schema.Number,
1313 detail: Schema.String,
14 cause: Schema.optional(Schema.Defect),
14 cause: Schema.optional(Schema.Defect()),
1515 },
1616) {
1717 override get message(): string {
......@@ -27,7 +27,7 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint
2727 {
2828 operation: Schema.String,
2929 detail: Schema.String,
30 cause: Schema.optional(Schema.Defect),
30 cause: Schema.optional(Schema.Defect()),
3131 },
3232) {
3333 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) => {
116116};
117117
118118const makeLiveServerClient = (origin: string) =>
119 Effect.gen(function* () {
120 const httpClient = yield* HttpClient.HttpClient;
121 return yield* HttpApiClient.makeWith(EnvironmentHttpApi, {
122 baseUrl: origin,
123 httpClient,
124 });
119 HttpApiClient.make(EnvironmentHttpApi, {
120 baseUrl: origin,
125121 });
126122
127123const 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
4747 "ProcessDiagnosticsError",
4848 {
4949 message: Schema.String,
50 cause: Schema.optional(Schema.Defect),
50 cause: Schema.optional(Schema.Defect()),
5151 },
5252) {}
5353const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
apps/server/src/http.ts+13-5
......@@ -8,6 +8,7 @@ import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
88import * as Data from "effect/Data";
99import * as Effect from "effect/Effect";
1010import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
1112import * as Option from "effect/Option";
1213import * as Path from "effect/Path";
1314import { cast } from "effect/Function";
......@@ -47,11 +48,18 @@ const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" vi
4748const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
4849const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
4950
50export const browserApiCorsLayer = HttpRouter.cors({
51 allowedMethods: browserApiCorsAllowedMethods,
52 allowedHeaders: browserApiCorsAllowedHeaders,
53 maxAge: 600,
54});
51export const browserApiCorsLayer = Layer.unwrap(
52 Effect.gen(function* () {
53 const config = yield* ServerConfig;
54 const devOrigin = config.devUrl?.origin;
55 return HttpRouter.cors({
56 ...(devOrigin ? { allowedOrigins: [devOrigin], credentials: true } : {}),
57 allowedMethods: browserApiCorsAllowedMethods,
58 allowedHeaders: browserApiCorsAllowedHeaders,
59 maxAge: 600,
60 });
61 }),
62);
5563
5664export function isLoopbackHostname(hostname: string): boolean {
5765 const normalizedHostname = hostname
apps/server/src/httpCors.ts+1
......@@ -4,6 +4,7 @@ export const browserApiCorsAllowedHeaders = [
44 "b3",
55 "traceparent",
66 "content-type",
7 "dpop",
78] as const;
89
910export const browserApiCorsHeaders = {
apps/server/src/orchestration/Errors.ts+6-6
......@@ -7,7 +7,7 @@ export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass<
77 "OrchestrationCommandJsonParseError",
88 {
99 detail: Schema.String,
10 cause: Schema.optional(Schema.Defect),
10 cause: Schema.optional(Schema.Defect()),
1111 },
1212) {
1313 override get message(): string {
......@@ -19,7 +19,7 @@ export class OrchestrationCommandDecodeError extends Schema.TaggedErrorClass<Orc
1919 "OrchestrationCommandDecodeError",
2020 {
2121 issue: Schema.String,
22 cause: Schema.optional(Schema.Defect),
22 cause: Schema.optional(Schema.Defect()),
2323 },
2424) {
2525 override get message(): string {
......@@ -32,7 +32,7 @@ export class OrchestrationCommandInvariantError extends Schema.TaggedErrorClass<
3232 {
3333 commandType: Schema.String,
3434 detail: Schema.String,
35 cause: Schema.optional(Schema.Defect),
35 cause: Schema.optional(Schema.Defect()),
3636 },
3737) {
3838 override get message(): string {
......@@ -45,7 +45,7 @@ export class OrchestrationCommandPreviouslyRejectedError extends Schema.TaggedEr
4545 {
4646 commandId: Schema.String,
4747 detail: Schema.String,
48 cause: Schema.optional(Schema.Defect),
48 cause: Schema.optional(Schema.Defect()),
4949 },
5050) {
5151 override get message(): string {
......@@ -58,7 +58,7 @@ export class OrchestrationProjectorDecodeError extends Schema.TaggedErrorClass<O
5858 {
5959 eventType: Schema.String,
6060 issue: Schema.String,
61 cause: Schema.optional(Schema.Defect),
61 cause: Schema.optional(Schema.Defect()),
6262 },
6363) {
6464 override get message(): string {
......@@ -71,7 +71,7 @@ export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass<
7171 {
7272 listener: Schema.Literals(["read-model", "domain-event"]),
7373 detail: Schema.String,
74 cause: Schema.optional(Schema.Defect),
74 cause: Schema.optional(Schema.Defect()),
7575 },
7676) {
7777 override get message(): string {
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+11
......@@ -11,6 +11,7 @@ import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeInge
1111import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
1212import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
1313import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
14import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1415
1516describe("OrchestrationReactor", () => {
1617 let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null;
......@@ -63,6 +64,15 @@ describe("OrchestrationReactor", () => {
6364 drain: Effect.void,
6465 }),
6566 ),
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 ),
6676 ),
6777 );
6878
......@@ -75,6 +85,7 @@ describe("OrchestrationReactor", () => {
7585 "provider-command-reactor",
7686 "checkpoint-reactor",
7787 "thread-deletion-reactor",
88 "agent-awareness-relay",
7889 ]);
7990
8091 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";
99import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
1010import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
1111import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts";
12import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
1213
1314export const makeOrchestrationReactor = Effect.gen(function* () {
1415 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;
1516 const providerCommandReactor = yield* ProviderCommandReactor;
1617 const checkpointReactor = yield* CheckpointReactor;
1718 const threadDeletionReactor = yield* ThreadDeletionReactor;
19 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
1820
1921 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
2022 yield* providerRuntimeIngestion.start();
2123 yield* providerCommandReactor.start();
2224 yield* checkpointReactor.start();
2325 yield* threadDeletionReactor.start();
26 yield* agentAwarenessRelay.start();
2427 });
2528
2629 return {
apps/server/src/persistence/Errors.ts+4-4
......@@ -10,7 +10,7 @@ export class PersistenceSqlError extends Schema.TaggedErrorClass<PersistenceSqlE
1010 {
1111 operation: Schema.String,
1212 detail: Schema.String,
13 cause: Schema.optional(Schema.Defect),
13 cause: Schema.optional(Schema.Defect()),
1414 },
1515) {
1616 override get message(): string {
......@@ -23,7 +23,7 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass<PersistenceD
2323 {
2424 operation: Schema.String,
2525 issue: Schema.String,
26 cause: Schema.optional(Schema.Defect),
26 cause: Schema.optional(Schema.Defect()),
2727 },
2828) {
2929 override get message(): string {
......@@ -72,7 +72,7 @@ export class ProviderSessionRepositoryValidationError extends Schema.TaggedError
7272 {
7373 operation: Schema.String,
7474 issue: Schema.String,
75 cause: Schema.optional(Schema.Defect),
75 cause: Schema.optional(Schema.Defect()),
7676 },
7777) {
7878 override get message(): string {
......@@ -85,7 +85,7 @@ export class ProviderSessionRepositoryPersistenceError extends Schema.TaggedErro
8585 {
8686 operation: Schema.String,
8787 detail: Schema.String,
88 cause: Schema.optional(Schema.Defect),
88 cause: Schema.optional(Schema.Defect()),
8989 },
9090) {
9191 override get message(): string {
apps/server/src/persistence/Layers/AuthPairingLinks.ts+10-1
......@@ -41,6 +41,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
4141 scopes,
4242 subject,
4343 label,
44 proof_key_thumbprint,
4445 created_at,
4546 expires_at,
4647 consumed_at,
......@@ -53,6 +54,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
5354 ${JSON.stringify(input.scopes)},
5455 ${input.subject},
5556 ${input.label},
57 ${input.proofKeyThumbprint},
5658 ${input.createdAt},
5759 ${input.expiresAt},
5860 NULL,
......@@ -64,7 +66,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
6466 const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({
6567 Request: ConsumeAuthPairingLinkInput,
6668 Result: AuthPairingLinkRecord,
67 execute: ({ credential, consumedAt, now }) =>
69 execute: ({ credential, proofKeyThumbprint, consumedAt, now }) =>
6870 sql`
6971 UPDATE auth_pairing_links
7072 SET consumed_at = ${consumedAt}
......@@ -72,6 +74,10 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
7274 AND revoked_at IS NULL
7375 AND consumed_at IS NULL
7476 AND expires_at > ${now}
77 AND (
78 proof_key_thumbprint IS NULL
79 OR proof_key_thumbprint = ${proofKeyThumbprint}
80 )
7581 RETURNING
7682 id AS "id",
7783 credential AS "credential",
......@@ -79,6 +85,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
7985 scopes AS "scopes",
8086 subject AS "subject",
8187 label AS "label",
88 proof_key_thumbprint AS "proofKeyThumbprint",
8289 created_at AS "createdAt",
8390 expires_at AS "expiresAt",
8491 consumed_at AS "consumedAt",
......@@ -98,6 +105,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
98105 scopes AS "scopes",
99106 subject AS "subject",
100107 label AS "label",
108 proof_key_thumbprint AS "proofKeyThumbprint",
101109 created_at AS "createdAt",
102110 expires_at AS "expiresAt",
103111 consumed_at AS "consumedAt",
......@@ -136,6 +144,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () {
136144 scopes AS "scopes",
137145 subject AS "subject",
138146 label AS "label",
147 proof_key_thumbprint AS "proofKeyThumbprint",
139148 created_at AS "createdAt",
140149 expires_at AS "expiresAt",
141150 consumed_at AS "consumedAt",
apps/server/src/persistence/Migrations.ts+2
......@@ -44,6 +44,7 @@ import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts
4444import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts";
4545import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts";
4646import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
47import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
4748
4849/**
4950 * Migration loader with all migrations defined inline.
......@@ -87,6 +88,7 @@ export const migrationEntries = [
8788 [29, "ProjectionThreadDetailOrderingIndexes", Migration0029],
8889 [30, "ProjectionThreadShellArchiveIndexes", Migration0030],
8990 [31, "AuthAuthorizationScopes", Migration0031],
91 [32, "AuthPairingProofKeyThumbprint", Migration0032],
9092] as const;
9193
9294export 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({
1313 scopes: Schema.fromJsonString(AuthEnvironmentScopes),
1414 subject: Schema.String,
1515 label: Schema.NullOr(Schema.String),
16 proofKeyThumbprint: Schema.NullOr(Schema.String),
1617 createdAt: Schema.DateTimeUtcFromString,
1718 expiresAt: Schema.DateTimeUtcFromString,
1819 consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
......@@ -27,6 +28,7 @@ export const CreateAuthPairingLinkInput = Schema.Struct({
2728 scopes: AuthEnvironmentScopes,
2829 subject: Schema.String,
2930 label: Schema.NullOr(Schema.String),
31 proofKeyThumbprint: Schema.NullOr(Schema.String),
3032 createdAt: Schema.DateTimeUtcFromString,
3133 expiresAt: Schema.DateTimeUtcFromString,
3234});
......@@ -34,6 +36,7 @@ export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type;
3436
3537export const ConsumeAuthPairingLinkInput = Schema.Struct({
3638 credential: Schema.String,
39 proofKeyThumbprint: Schema.NullOr(Schema.String),
3740 consumedAt: Schema.DateTimeUtcFromString,
3841 now: Schema.DateTimeUtcFromString,
3942});
apps/server/src/provider/Errors.ts+11-11
......@@ -11,7 +11,7 @@ export class ProviderAdapterValidationError extends Schema.TaggedErrorClass<Prov
1111 provider: Schema.String,
1212 operation: Schema.String,
1313 issue: Schema.String,
14 cause: Schema.optional(Schema.Defect),
14 cause: Schema.optional(Schema.Defect()),
1515 },
1616) {
1717 override get message(): string {
......@@ -27,7 +27,7 @@ export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass
2727 {
2828 provider: Schema.String,
2929 threadId: Schema.String,
30 cause: Schema.optional(Schema.Defect),
30 cause: Schema.optional(Schema.Defect()),
3131 },
3232) {
3333 override get message(): string {
......@@ -43,7 +43,7 @@ export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass<P
4343 {
4444 provider: Schema.String,
4545 threadId: Schema.String,
46 cause: Schema.optional(Schema.Defect),
46 cause: Schema.optional(Schema.Defect()),
4747 },
4848) {
4949 override get message(): string {
......@@ -60,7 +60,7 @@ export class ProviderAdapterRequestError extends Schema.TaggedErrorClass<Provide
6060 provider: Schema.String,
6161 method: Schema.String,
6262 detail: Schema.String,
63 cause: Schema.optional(Schema.Defect),
63 cause: Schema.optional(Schema.Defect()),
6464 },
6565) {
6666 override get message(): string {
......@@ -77,7 +77,7 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass<Provide
7777 provider: Schema.String,
7878 threadId: Schema.String,
7979 detail: Schema.String,
80 cause: Schema.optional(Schema.Defect),
80 cause: Schema.optional(Schema.Defect()),
8181 },
8282) {
8383 override get message(): string {
......@@ -93,7 +93,7 @@ export class ProviderValidationError extends Schema.TaggedErrorClass<ProviderVal
9393 {
9494 operation: Schema.String,
9595 issue: Schema.String,
96 cause: Schema.optional(Schema.Defect),
96 cause: Schema.optional(Schema.Defect()),
9797 },
9898) {
9999 override get message(): string {
......@@ -108,7 +108,7 @@ export class ProviderUnsupportedError extends Schema.TaggedErrorClass<ProviderUn
108108 "ProviderUnsupportedError",
109109 {
110110 provider: Schema.String,
111 cause: Schema.optional(Schema.Defect),
111 cause: Schema.optional(Schema.Defect()),
112112 },
113113) {
114114 override get message(): string {
......@@ -129,7 +129,7 @@ export class ProviderInstanceNotFoundError extends Schema.TaggedErrorClass<Provi
129129 "ProviderInstanceNotFoundError",
130130 {
131131 instanceId: Schema.String,
132 cause: Schema.optional(Schema.Defect),
132 cause: Schema.optional(Schema.Defect()),
133133 },
134134) {
135135 override get message(): string {
......@@ -148,7 +148,7 @@ export class ProviderDriverError extends Schema.TaggedErrorClass<ProviderDriverE
148148 driver: Schema.String,
149149 instanceId: Schema.String,
150150 detail: Schema.String,
151 cause: Schema.optional(Schema.Defect),
151 cause: Schema.optional(Schema.Defect()),
152152 },
153153) {
154154 override get message(): string {
......@@ -163,7 +163,7 @@ export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass<Provid
163163 "ProviderSessionNotFoundError",
164164 {
165165 threadId: Schema.String,
166 cause: Schema.optional(Schema.Defect),
166 cause: Schema.optional(Schema.Defect()),
167167 },
168168) {
169169 override get message(): string {
......@@ -179,7 +179,7 @@ export class ProviderSessionDirectoryPersistenceError extends Schema.TaggedError
179179 {
180180 operation: Schema.String,
181181 detail: Schema.String,
182 cause: Schema.optional(Schema.Defect),
182 cause: Schema.optional(Schema.Defect()),
183183 },
184184) {
185185 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 @@
11import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
22import * as NodeSocket from "@effect/platform-node/NodeSocket";
33import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as NodeCrypto from "node:crypto";
45
56import {
67 AuthAccessTokenType,
......@@ -28,6 +29,13 @@ import {
2829 WsRpcGroup,
2930 EditorId,
3031} 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";
3139import { assert, it } from "@effect/vitest";
3240import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
3341import * as Clock from "effect/Clock";
......@@ -43,13 +51,13 @@ import * as Path from "effect/Path";
4351import * as Stream from "effect/Stream";
4452import { ChildProcessSpawner } from "effect/unstable/process";
4553import {
46 Cookies,
4754 FetchHttpClient,
4855 HttpBody,
4956 HttpClient,
57 HttpClientRequest,
58 HttpClientResponse,
5059 HttpRouter,
5160 HttpServer,
52 UrlParams,
5361} from "effect/unstable/http";
5462import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
5563import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
......@@ -120,6 +128,11 @@ import * as ReviewService from "./review/ReviewService.ts";
120128import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
121129import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
122130import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
131import {
132 CloudManagedEndpointRuntime,
133 type CloudManagedEndpointRuntimeShape,
134} from "./cloud/ManagedEndpointRuntime.ts";
135import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts";
123136import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
124137import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
125138import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
......@@ -347,6 +360,9 @@ const buildAppUnderTest = (options?: {
347360 serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>;
348361 serverEnvironment?: Partial<ServerEnvironmentShape>;
349362 repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>;
363 cloudManagedEndpointRuntime?: Partial<CloudManagedEndpointRuntimeShape>;
364 relayClient?: Partial<RelayClient.RelayClientShape>;
365 cloudCliTokenManager?: Partial<CloudCliTokenManager.CloudCliTokenManagerShape>;
350366 };
351367}) =>
352368 Effect.gen(function* () {
......@@ -741,7 +757,40 @@ const buildAppUnderTest = (options?: {
741757 ...options?.layers?.repositoryIdentityResolver,
742758 }),
743759 ),
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 ),
744792 Layer.provideMerge(makeAuthTestLayer()),
793 Layer.provideMerge(ServerSecretStore.layer),
745794 Layer.provide(workspaceAndProjectServicesLayer),
746795 Layer.provideMerge(FetchHttpClient.layer),
747796 Layer.provide(layerConfig),
......@@ -799,6 +848,13 @@ const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) =>
799848 return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`;
800849};
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
802858const bootstrapBrowserSession = (
803859 credential = defaultDesktopBootstrapToken,
804860 options?: {
......@@ -806,19 +862,26 @@ const bootstrapBrowserSession = (
806862 },
807863) =>
808864 Effect.gen(function* () {
809 const response = yield* HttpClient.post("/api/auth/browser-session", {
810 headers: options?.headers,
811 body: yield* HttpBody.json({ credential }),
865 const bootstrapUrl = yield* getHttpServerUrl("/api/auth/browser-session");
866 const response = yield* fetchEffect(bootstrapUrl, {
867 method: "POST",
868 headers: {
869 "content-type": "application/json",
870 ...options?.headers,
871 },
872 body: jsonRequestBody({
873 credential,
874 }),
812875 });
813 const body = (yield* response.json) as {
876 const body = yield* responseJsonEffect<{
814877 readonly authenticated: boolean;
815878 readonly sessionMethod: string;
816879 readonly expiresAt: string;
817 };
880 }>(response);
818881 return {
819882 response,
820883 body,
821 cookie: Cookies.toSetCookieHeaders(response.cookies)[0] ?? null,
884 cookie: response.headers["set-cookie"],
822885 };
823886 });
824887
......@@ -835,48 +898,227 @@ const exchangeAccessToken = (
835898 },
836899) =>
837900 Effect.gen(function* () {
838 const response = yield* HttpClient.post("/oauth/token", {
839 headers: options?.headers,
840 body: HttpBody.urlParams(
841 UrlParams.fromInput({
842 grant_type: AuthTokenExchangeGrantType,
843 subject_token: credential,
844 subject_token_type: AuthEnvironmentBootstrapTokenType,
845 requested_token_type: AuthAccessTokenType,
846 scope:
847 options?.scope ??
848 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
849 ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}),
850 ...(options?.clientMetadata?.deviceType
851 ? { client_device_type: options.clientMetadata.deviceType }
852 : {}),
853 ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}),
854 }),
855 ),
901 const tokenUrl = yield* getHttpServerUrl("/oauth/token");
902 const response = yield* fetchEffect(tokenUrl, {
903 method: "POST",
904 headers: {
905 "content-type": "application/x-www-form-urlencoded",
906 ...options?.headers,
907 },
908 body: new URLSearchParams({
909 grant_type: AuthTokenExchangeGrantType,
910 subject_token: credential,
911 subject_token_type: AuthEnvironmentBootstrapTokenType,
912 requested_token_type: AuthAccessTokenType,
913 scope:
914 options?.scope ??
915 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
916 ...(options?.clientMetadata?.label ? { client_label: options.clientMetadata.label } : {}),
917 ...(options?.clientMetadata?.deviceType
918 ? { client_device_type: options.clientMetadata.deviceType }
919 : {}),
920 ...(options?.clientMetadata?.os ? { client_os: options.clientMetadata.os } : {}),
921 }).toString(),
856922 });
857 const body = (yield* response.json) as {
923 const body = yield* responseJsonEffect<{
858924 readonly access_token?: string;
859925 readonly issued_token_type?: string;
860926 readonly token_type?: string;
861927 readonly expires_in?: number;
862928 readonly scope?: string;
863929 readonly _tag?: string;
864 readonly message?: string;
865 };
930 readonly code?: string;
931 readonly reason?: string;
932 readonly traceId?: string;
933 }>(response);
866934 return {
867935 response,
868936 body,
869937 };
870938 });
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
8721065class AuthenticationGetterError extends Data.TaggedError("AuthenticationGetterError")<{
8731066 readonly message: string;
8741067}> {}
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
8761118const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) =>
8771119 Effect.gen(function* () {
8781120 const { response, cookie } = yield* bootstrapBrowserSession(credential);
879 if (response.status !== 200) {
1121 if (!responseOk(response)) {
8801122 return yield* new AuthenticationGetterError({
8811123 message: `Expected bootstrap session response to succeed, got ${response.status}`,
8821124 });
......@@ -894,7 +1136,7 @@ const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstra
8941136const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) =>
8951137 Effect.gen(function* () {
8961138 const { response, body } = yield* exchangeAccessToken(credential);
897 if (response.status !== 200) {
1139 if (!responseOk(response)) {
8981140 return yield* new AuthenticationGetterError({
8991141 message: `Expected bearer bootstrap response to succeed, got ${response.status}`,
9001142 });
......@@ -918,7 +1160,7 @@ const extractSessionTokenFromSetCookie = (cookieHeader: string): string => {
9181160 return token;
9191161};
9201162
921const splitHeaderTokens = (value: string | null) =>
1163const splitHeaderTokens = (value: string | null | undefined) =>
9221164 (value ?? "")
9231165 .split(",")
9241166 .map((token) => token.trim())
......@@ -927,23 +1169,36 @@ const splitHeaderTokens = (value: string | null) =>
9271169
9281170const assertBrowserApiCorsResponseHeaders = (
9291171 headers: Readonly<Record<string, string | undefined>>,
1172 options?: {
1173 readonly origin?: string;
1174 readonly credentials?: boolean;
1175 },
9301176) => {
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 );
9321182};
9331183
9341184const assertBrowserApiCorsPreflightHeaders = (
9351185 headers: Readonly<Record<string, string | undefined>>,
1186 options?: {
1187 readonly origin?: string;
1188 readonly credentials?: boolean;
1189 },
9361190) => {
937 assertBrowserApiCorsResponseHeaders(headers);
1191 assertBrowserApiCorsResponseHeaders(headers, options);
9381192 assert.deepEqual(splitHeaderTokens(headers["access-control-allow-methods"] ?? null), [
9391193 "GET",
9401194 "OPTIONS",
9411195 "POST",
9421196 ]);
943 assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"] ?? null), [
1197 assert.deepEqual(splitHeaderTokens(headers["access-control-allow-headers"]), [
9441198 "authorization",
9451199 "b3",
9461200 "content-type",
1201 "dpop",
9471202 "traceparent",
9481203 ]);
9491204};
......@@ -989,9 +1244,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
9891244 config: { devUrl: new URL("http://127.0.0.1:5173") },
9901245 });
9911246
992 const response = yield* HttpClient.get("/foo/bar?token=test-token").pipe(
993 Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
994 );
1247 const url = yield* getHttpServerUrl("/foo/bar?token=test-token");
1248 const response = yield* fetchEffect(url, { redirect: "manual" });
9951249
9961250 assert.equal(response.status, 302);
9971251 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) => {
10571311 Effect.gen(function* () {
10581312 yield* buildAppUnderTest();
10591313
1060 const response = yield* HttpClient.get("/.well-known/t3/environment");
1061 const body = (yield* response.json) as typeof testEnvironmentDescriptor;
1314 const url = yield* getHttpServerUrl("/.well-known/t3/environment");
1315 const response = yield* fetchEffect(url);
1316 const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response);
10621317
10631318 assert.equal(response.status, 200);
10641319 assert.deepEqual(body, testEnvironmentDescriptor);
......@@ -1069,12 +1324,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10691324 Effect.gen(function* () {
10701325 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, {
10731329 headers: {
10741330 origin: crossOriginClientOrigin,
10751331 },
10761332 });
1077 const body = (yield* response.json) as typeof testEnvironmentDescriptor;
1333 const body = yield* responseJsonEffect<typeof testEnvironmentDescriptor>(response);
10781334
10791335 assert.equal(response.status, 200);
10801336 assertBrowserApiCorsResponseHeaders(response.headers);
......@@ -1086,8 +1342,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10861342 Effect.gen(function* () {
10871343 yield* buildAppUnderTest();
10881344
1089 const response = yield* HttpClient.get("/api/auth/session");
1090 const body = (yield* response.json) as {
1345 const url = yield* getHttpServerUrl("/api/auth/session");
1346 const response = yield* fetchEffect(url);
1347 const body = yield* responseJsonEffect<{
10911348 readonly authenticated: boolean;
10921349 readonly auth: {
10931350 readonly policy: string;
......@@ -1095,13 +1352,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
10951352 readonly sessionMethods: ReadonlyArray<string>;
10961353 readonly sessionCookieName: string;
10971354 };
1098 };
1355 }>(response);
10991356
11001357 assert.equal(response.status, 200);
11011358 assert.equal(body.authenticated, false);
11021359 assert.equal(body.auth.policy, "desktop-managed-local");
11031360 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 ]);
11051366 assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_"));
11061367 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11071368 );
......@@ -1122,15 +1383,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11221383 assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken);
11231384 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, {
11261388 headers: {
11271389 cookie: setCookie?.split(";")[0] ?? "",
11281390 },
11291391 });
1130 const sessionBody = (yield* sessionResponse.json) as {
1392 const sessionBody = yield* responseJsonEffect<{
11311393 readonly authenticated: boolean;
11321394 readonly sessionMethod?: string;
1133 };
1395 }>(sessionResponse);
11341396
11351397 assert.equal(sessionResponse.status, 200);
11361398 assert.equal(sessionBody.authenticated, true);
......@@ -1142,9 +1404,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11421404 Effect.gen(function* () {
11431405 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);
11481410 assert.equal(tokenBody.issued_token_type, AuthAccessTokenType);
11491411 assert.equal(tokenBody.token_type, "Bearer");
11501412 assert.equal(
......@@ -1152,18 +1414,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11521414 "orchestration:read orchestration:operate terminal:operate review:write relay:read access:read access:write relay:write",
11531415 );
11541416 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, {
11581420 headers: {
11591421 authorization: `Bearer ${tokenBody.access_token ?? ""}`,
11601422 },
11611423 });
1162 const sessionBody = (yield* sessionResponse.json) as {
1424 const sessionBody = yield* responseJsonEffect<{
11631425 readonly authenticated: boolean;
11641426 readonly sessionMethod?: string;
11651427 readonly scopes?: ReadonlyArray<string>;
1166 };
1428 }>(sessionResponse);
11671429
11681430 assert.equal(sessionResponse.status, 200);
11691431 assert.equal(sessionBody.authenticated, true);
......@@ -1181,187 +1443,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
11811443 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
11821444 );
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
13651446 it.effect("persists token exchange client display metadata for authorized-client listings", () =>
13661447 Effect.gen(function* () {
13671448 yield* buildAppUnderTest({
......@@ -1385,7 +1466,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
13851466 headers: {
13861467 "user-agent": "undici",
13871468 },
1388 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
1469 scope: "orchestration:read orchestration:operate terminal:operate review:write",
13891470 clientMetadata: {
13901471 label: "T3 Code Mobile",
13911472 deviceType: "mobile",
......@@ -1423,19 +1504,1840 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
14231504 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
14241505 );
14251506
1426 it.effect("issues authenticated one-time pairing credentials for additional clients", () =>
1427 Effect.gen(function* () {
1428 yield* buildAppUnderTest();
1507 it.effect(
1508 "exchanges a bootstrap credential for a DPoP-bound access token without bearer downgrade",
1509 () =>
1510 Effect.gen(function* () {
1511 yield* buildAppUnderTest();
14291512
1430 const response = yield* HttpClient.post("/api/auth/pairing-token", {
1431 headers: {
1432 cookie: yield* getAuthenticatedSessionCookieHeader(),
1433 },
1434 body: yield* HttpBody.json({}),
1435 });
1436 const body = (yield* response.json) as {
1437 readonly credential: string;
1438 readonly expiresAt: string;
1513 const ownerCookie = yield* getAuthenticatedSessionCookieHeader();
1514 const credentialResponse = yield* HttpClient.post("/api/auth/pairing-token", {
1515 headers: { cookie: ownerCookie },
1516 body: yield* HttpBody.json({}),
1517 });
1518 const credential = (yield* credentialResponse.json) as { readonly credential: string };
1519 const tokenUrl = yield* getHttpServerUrl("/oauth/token");
1520 const now = yield* DateTime.now;
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;
14393341 };
14403342
14413343 assert.equal(response.status, 200);
......@@ -1538,8 +3440,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
15383440 const revokeResponse = yield* HttpClient.post("/api/auth/pairing-links/revoke", {
15393441 headers: {
15403442 cookie: ownerCookie,
3443 "content-type": "application/json",
15413444 },
1542 body: yield* HttpBody.json({ id: createdBody.id }),
3445 body: HttpBody.text(jsonRequestBody({ id: createdBody.id }), "application/json"),
15433446 });
15443447 const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential);
15453448
......@@ -1601,16 +3504,21 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
16013504 });
16023505
16033506 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",
16053510 headers: {
16063511 cookie: ownerCookie,
3512 "content-type": "application/json",
16073513 },
1608 body: yield* HttpBody.json({ label: "Julius iPhone" }),
3514 body: jsonRequestBody({
3515 label: "Julius iPhone",
3516 }),
16093517 });
1610 const ownerPairingBody = (yield* ownerPairingResponse.json) as {
3518 const ownerPairingBody = yield* responseJsonEffect<{
16113519 readonly credential: string;
16123520 readonly label?: string;
1613 };
3521 }>(ownerPairingResponse);
16143522 assert.equal(ownerPairingResponse.status, 200);
16153523 const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, {
16163524 headers: {
......@@ -1794,8 +3702,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17943702 const revokeResponse = yield* HttpClient.post("/api/auth/clients/revoke", {
17953703 headers: {
17963704 cookie: ownerCookie,
3705 "content-type": "application/json",
17973706 },
1798 body: yield* HttpBody.json({ sessionId: pairedSessionId }),
3707 body: HttpBody.text(jsonRequestBody({ sessionId: pairedSessionId }), "application/json"),
17993708 });
18003709 const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", {
18013710 headers: {
......@@ -1890,20 +3799,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
18903799 );
18913800
18923801 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",
18943803 () =>
18953804 Effect.gen(function* () {
18963805 yield* buildAppUnderTest();
18973806
18983807 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",
19003811 headers: {
19013812 authorization: `Bearer ${bearerToken}`,
19023813 },
19033814 });
1904 const wsTicketBody = (yield* wsTicketResponse.json) as {
3815 const wsTicketBody = yield* responseJsonEffect<{
19053816 readonly ticket: string;
1906 };
3817 }>(wsTicketResponse);
19073818 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(wsTicketBody.ticket)}`;
19083819
19093820 const response = yield* Effect.scoped(
......@@ -2103,7 +4014,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
21034014 "content-type": "application/json",
21044015 origin: "http://localhost:5733",
21054016 },
2106 body: yield* HttpBody.json(payload),
4017 // @effect-diagnostics-next-line preferSchemaOverJson:off
4018 body: HttpBody.text(JSON.stringify(payload), "application/json"),
21074019 });
21084020
21094021 assert.equal(response.status, 204);
......@@ -2148,8 +4060,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
21484060 ]);
21494061 assert.deepEqual(upstreamRequests, [
21504062 {
2151 // @effect-diagnostics-next-line preferSchemaOverJson:off
2152 body: JSON.stringify(payload),
4063 body: jsonRequestBody(payload),
21534064 contentType: "application/json",
21544065 },
21554066 ]);
......@@ -2170,14 +4081,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
21704081
21714082 assert.equal(response.status, 204);
21724083 assert.equal(response.headers["access-control-allow-origin"], "*");
2173 assert.deepEqual(
2174 splitHeaderTokens(response.headers["access-control-allow-methods"] ?? null),
2175 ["GET", "OPTIONS", "POST"],
2176 );
2177 assert.deepEqual(
2178 splitHeaderTokens(response.headers["access-control-allow-headers"] ?? null),
2179 ["authorization", "b3", "content-type", "traceparent"],
2180 );
4084 assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-methods"]), [
4085 "GET",
4086 "OPTIONS",
4087 "POST",
4088 ]);
4089 assert.deepEqual(splitHeaderTokens(response.headers["access-control-allow-headers"]), [
4090 "authorization",
4091 "b3",
4092 "content-type",
4093 "dpop",
4094 "traceparent",
4095 ]);
21814096 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
21824097 );
21834098
......@@ -2214,7 +4129,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
22144129 cookie: yield* getAuthenticatedSessionCookieHeader(),
22154130 "content-type": "application/json",
22164131 },
2217 body: yield* HttpBody.json(payload),
4132 // @effect-diagnostics-next-line preferSchemaOverJson:off
4133 body: HttpBody.text(JSON.stringify(payload), "application/json"),
22184134 });
22194135
22204136 assert.equal(response.status, 204);
apps/server/src/server.ts+53
......@@ -44,6 +44,8 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun
4444import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts";
4545import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
4646import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
47import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
48import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
4749import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts";
4850import { ServerSettingsLive } from "./serverSettings.ts";
4951import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts";
......@@ -67,6 +69,10 @@ import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts
6769import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts";
6870import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
6971import * 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";
7076import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
7177import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
7278import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
......@@ -78,6 +84,7 @@ import {
7884} from "./serverRuntimeState.ts";
7985import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
8086import * as NetService from "@t3tools/shared/Net";
87import * as RelayClient from "@t3tools/shared/relayClient";
8188import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
8289
8390const PtyAdapterLive = Layer.unwrap(
......@@ -92,6 +99,13 @@ const PtyAdapterLive = Layer.unwrap(
9299 }),
93100);
94101
102const RelayClientLive = Layer.unwrap(
103 Effect.gen(function* () {
104 const config = yield* ServerConfig;
105 return RelayClient.layerCloudflared({ baseDir: config.baseDir });
106 }),
107);
108
95109const HttpServerLive = Layer.unwrap(
96110 Effect.gen(function* () {
97111 const config = yield* ServerConfig;
......@@ -134,6 +148,7 @@ const ReactorLayerLive = Layer.empty.pipe(
134148 Layer.provideMerge(ProviderCommandReactorLive),
135149 Layer.provideMerge(CheckpointReactorLive),
136150 Layer.provideMerge(ThreadDeletionReactorLive),
151 Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))),
137152 Layer.provideMerge(RuntimeReceiptBusLive),
138153);
139154
......@@ -231,6 +246,14 @@ const AuthLayerLive = EnvironmentAuth.layer.pipe(
231246 Layer.provide(ServerSecretStore.layer),
232247);
233248
249const CloudManagedEndpointRuntimeLive = Layer.mergeAll(
250 RelayClientLive,
251 CloudManagedEndpointRuntime.layer.pipe(
252 Layer.provide(ServerSecretStore.layer),
253 Layer.provide(RelayClientLive),
254 ),
255);
256
234257const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
235258 Layer.provideMerge(ProviderLayerLive),
236259 Layer.provideMerge(OrchestrationLayerLive),
......@@ -271,6 +294,13 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
271294 Layer.provideMerge(RepositoryIdentityResolverLive),
272295 Layer.provideMerge(ServerEnvironmentLive),
273296 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 ),
274304);
275305
276306const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
......@@ -291,6 +321,7 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe(
291321export const makeRoutesLayer = Layer.mergeAll(
292322 HttpApiBuilder.layer(EnvironmentHttpApi).pipe(
293323 Layer.provide(authHttpApiLayer),
324 Layer.provide(cloudHttpApiLayer),
294325 Layer.provide(orchestrationHttpApiLayer),
295326 Layer.provide(serverEnvironmentHttpApiLayer),
296327 Layer.provide(environmentAuthenticatedAuthLayer),
......@@ -387,6 +418,27 @@ export const makeServerLayer = Layer.unwrap(
387418 ),
388419 )
389420 : 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
391443 const serverApplicationLayer = Layer.mergeAll(
392444 HttpRouter.serve(makeRoutesLayer, {
......@@ -395,6 +447,7 @@ export const makeServerLayer = Layer.unwrap(
395447 httpListeningLayer,
396448 runtimeStateLayer,
397449 tailscaleServeLayer,
450 cloudDesiredLinkReconcileLayer,
398451 );
399452
400453 return serverApplicationLayer.pipe(
apps/server/src/serverRuntimeStartup.ts+5-2
......@@ -8,6 +8,7 @@ import {
88 ThreadId,
99} from "@t3tools/contracts";
1010import * as Data from "effect/Data";
11import * as Crypto from "effect/Crypto";
1112import * as Deferred from "effect/Deferred";
1213import * as Effect from "effect/Effect";
1314import * as Exit from "effect/Exit";
......@@ -19,7 +20,6 @@ import * as Ref from "effect/Ref";
1920import * as Scope from "effect/Scope";
2021import * as Context from "effect/Context";
2122import * as Console from "effect/Console";
22import * as Crypto from "effect/Crypto";
2323import * as DateTime from "effect/DateTime";
2424
2525import { ServerConfig } from "./config.ts";
......@@ -289,6 +289,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () {
289289 const lifecycleEvents = yield* ServerLifecycleEvents;
290290 const serverSettings = yield* ServerSettingsService;
291291 const serverEnvironment = yield* ServerEnvironment;
292 const crypto = yield* Crypto.Crypto;
292293
293294 const commandGate = yield* makeCommandGate;
294295 const httpListening = yield* Deferred.make<void>();
......@@ -361,7 +362,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () {
361362 runStartupPhase(
362363 "welcome.autobootstrap",
363364 Effect.gen(function* () {
364 const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets;
365 const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe(
366 Effect.provideService(Crypto.Crypto, crypto),
367 );
365368 if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) {
366369 return;
367370 }
apps/server/src/sourceControl/AzureDevOpsCli.ts+1-1
......@@ -21,7 +21,7 @@ export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliE
2121 {
2222 operation: Schema.String,
2323 detail: Schema.String,
24 cause: Schema.optional(Schema.Defect),
24 cause: Schema.optional(Schema.Defect()),
2525 },
2626) {
2727 override get message(): string {
apps/server/src/sourceControl/BitbucketApi.ts+1-1
......@@ -37,7 +37,7 @@ export class BitbucketApiError extends Schema.TaggedErrorClass<BitbucketApiError
3737 operation: Schema.String,
3838 detail: Schema.String,
3939 status: Schema.optional(Schema.Number),
40 cause: Schema.optional(Schema.Defect),
40 cause: Schema.optional(Schema.Defect()),
4141 },
4242) {
4343 override get message(): string {
apps/server/src/sourceControl/GitHubCli.ts+1-1
......@@ -19,7 +19,7 @@ const DEFAULT_TIMEOUT_MS = 30_000;
1919export class GitHubCliError extends Schema.TaggedErrorClass<GitHubCliError>()("GitHubCliError", {
2020 operation: Schema.String,
2121 detail: Schema.String,
22 cause: Schema.optional(Schema.Defect),
22 cause: Schema.optional(Schema.Defect()),
2323}) {
2424 override get message(): string {
2525 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;
1818export class GitLabCliError extends Schema.TaggedErrorClass<GitLabCliError>()("GitLabCliError", {
1919 operation: Schema.String,
2020 detail: Schema.String,
21 cause: Schema.optional(Schema.Defect),
21 cause: Schema.optional(Schema.Defect()),
2222}) {
2323 override get message(): string {
2424 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({
1919
2020class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {
2121 message: Schema.String,
22 cause: Schema.optional(Schema.Defect),
22 cause: Schema.optional(Schema.Defect()),
2323}) {}
2424
2525const hash = (value: string) =>
apps/server/src/terminal/Layers/Manager.ts+2-2
......@@ -64,7 +64,7 @@ class TerminalSubprocessCheckError extends Schema.TaggedErrorClass<TerminalSubpr
6464 "TerminalSubprocessCheckError",
6565 {
6666 message: Schema.String,
67 cause: Schema.optional(Schema.Defect),
67 cause: Schema.optional(Schema.Defect()),
6868 terminalPid: Schema.Number,
6969 command: Schema.Literals(["powershell", "pgrep", "ps"]),
7070 },
......@@ -74,7 +74,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass<TerminalProcess
7474 "TerminalProcessSignalError",
7575 {
7676 message: Schema.String,
77 cause: Schema.optional(Schema.Defect),
77 cause: Schema.optional(Schema.Defect()),
7878 signal: Schema.Literals(["SIGTERM", "SIGKILL"]),
7979 },
8080) {}
apps/server/src/terminal/Services/PTY.ts+1-1
......@@ -16,7 +16,7 @@ import * as Context from "effect/Context";
1616export class PtySpawnError extends Schema.TaggedErrorClass<PtySpawnError>()("PtySpawnError", {
1717 adapter: Schema.String,
1818 message: Schema.String,
19 cause: Schema.optional(Schema.Defect),
19 cause: Schema.optional(Schema.Defect()),
2020}) {}
2121
2222export interface PtyExitEvent {
apps/server/src/workspace/Services/WorkspaceEntries.ts+2-2
......@@ -23,7 +23,7 @@ export class WorkspaceEntriesError extends Schema.TaggedErrorClass<WorkspaceEntr
2323 cwd: Schema.String,
2424 operation: Schema.String,
2525 detail: Schema.String,
26 cause: Schema.optional(Schema.Defect),
26 cause: Schema.optional(Schema.Defect()),
2727 },
2828) {}
2929
......@@ -34,7 +34,7 @@ export class WorkspaceEntriesBrowseError extends Schema.TaggedErrorClass<Workspa
3434 partialPath: Schema.String,
3535 operation: Schema.String,
3636 detail: Schema.String,
37 cause: Schema.optional(Schema.Defect),
37 cause: Schema.optional(Schema.Defect()),
3838 },
3939) {}
4040
apps/server/src/workspace/Services/WorkspaceFileSystem.ts+1-1
......@@ -20,7 +20,7 @@ export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceF
2020 relativePath: Schema.optional(Schema.String),
2121 operation: Schema.String,
2222 detail: Schema.String,
23 cause: Schema.optional(Schema.Defect),
23 cause: Schema.optional(Schema.Defect()),
2424 },
2525) {}
2626
apps/server/src/ws.ts+40
......@@ -14,6 +14,7 @@ import {
1414 AuthOrchestrationOperateScope,
1515 AuthOrchestrationReadScope,
1616 AuthReviewWriteScope,
17 AuthRelayWriteScope,
1718 AuthTerminalOperateScope,
1819 AuthAccessReadScope,
1920 AuthAccessStreamError,
......@@ -34,6 +35,8 @@ import {
3435 ORCHESTRATION_WS_METHODS,
3536 ProjectSearchEntriesError,
3637 ProjectWriteFileError,
38 RelayClientInstallFailedError,
39 type RelayClientInstallProgressEvent,
3740 OrchestrationReplayEventsError,
3841 FilesystemBrowseError,
3942 EnvironmentAuthorizationError,
......@@ -96,6 +99,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts";
9699import * as PairingGrantStore from "./auth/PairingGrantStore.ts";
97100import * as SessionStore from "./auth/SessionStore.ts";
98101import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts";
102import * as RelayClient from "@t3tools/shared/relayClient";
99103const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError);
100104const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError);
101105
......@@ -145,6 +149,8 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
145149 [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],
146150 [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],
147151 [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],
152 [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope],
153 [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope],
148154 [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope],
149155 [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope],
150156 [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope],
......@@ -260,6 +266,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
260266 const sessions = yield* SessionStore.SessionStore;
261267 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
262268 const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor;
269 const relayClient = yield* RelayClient.RelayClient;
263270 const authorizationError = (requiredScope: AuthEnvironmentScope) =>
264271 new EnvironmentAuthorizationError({
265272 message: `The authenticated token is missing required scope: ${requiredScope}.`,
......@@ -1070,6 +1077,39 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
10701077 observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), {
10711078 "rpc.aggregate": "server",
10721079 }),
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 ),
10731113 [WS_METHODS.sourceControlLookupRepository]: (input) =>
10741114 observeRpcEffect(
10751115 WS_METHODS.sourceControlLookupRepository,
apps/server/vite.config.ts+12
......@@ -2,6 +2,7 @@ import "vite-plus/test/config";
22import { defineConfig, mergeConfig } from "vite-plus";
33
44import baseConfig from "../../vite.config.ts";
5import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
56
67const bundledPackagePrefixes = [
78 "@pierre/diffs",
......@@ -14,6 +15,8 @@ export function shouldBundleCliDependency(id: string): boolean {
1415 return bundledPackagePrefixes.some((prefix) => id.startsWith(prefix));
1516}
1617
18const repoEnv = loadRepoEnv();
19
1720export default mergeConfig(
1821 baseConfig,
1922 defineConfig({
......@@ -38,6 +41,15 @@ export default mergeConfig(
3841 banner: {
3942 js: "#!/usr/bin/env node\n",
4043 },
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 },
4153 },
4254 test: {
4355 // The server suite exercises sqlite, git, temp worktrees, and orchestration
apps/web/package.json+4
......@@ -14,6 +14,8 @@
1414 },
1515 "dependencies": {
1616 "@base-ui/react": "^1.4.1",
17 "@clerk/clerk-js": "^6.13.0",
18 "@clerk/react": "^6.7.2",
1719 "@dnd-kit/core": "^6.3.1",
1820 "@dnd-kit/modifiers": "^9.0.0",
1921 "@dnd-kit/sortable": "^10.0.0",
......@@ -33,6 +35,7 @@
3335 "@xterm/xterm": "^6.0.0",
3436 "class-variance-authority": "^0.7.1",
3537 "effect": "catalog:",
38 "jose": "catalog:",
3639 "lexical": "^0.41.0",
3740 "lucide-react": "^0.564.0",
3841 "react": "19.2.6",
......@@ -44,6 +47,7 @@
4447 },
4548 "devDependencies": {
4649 "@effect/platform-node": "catalog:",
50 "@effect/vitest": "catalog:",
4751 "@rolldown/plugin-babel": "^0.2.0",
4852 "@tailwindcss/vite": "^4.0.0",
4953 "@tanstack/router-plugin": "^1.161.0",
apps/web/src/clientPersistenceStorage.ts+14-2
......@@ -27,6 +27,7 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({
2727 port: Schema.NullOr(Schema.Number),
2828 }),
2929 ),
30 relayManaged: Schema.optionalKey(Schema.Struct({ relayUrl: Schema.String })),
3031 bearerToken: Schema.optionalKey(Schema.String),
3132});
3233type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type;
......@@ -53,7 +54,11 @@ function toPersistedSavedEnvironmentRecord(
5354 createdAt: record.createdAt,
5455 lastConnectedAt: record.lastConnectedAt,
5556 };
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 };
5762}
5863
5964export function readBrowserClientSettings(): ClientSettings | null {
......@@ -145,6 +150,7 @@ export function writeBrowserSavedEnvironmentRegistry(
145150 createdAt: record.createdAt,
146151 lastConnectedAt: record.lastConnectedAt,
147152 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
153 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
148154 bearerToken,
149155 }
150156 : toPersistedSavedEnvironmentRecord(record);
......@@ -171,6 +177,8 @@ export function writeBrowserSavedEnvironmentSecret(
171177 let found = false;
172178 writeBrowserSavedEnvironmentRegistryDocument({
173179 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
174182 records: records.map((record) => {
175183 if (record.environmentId !== environmentId) {
176184 return record;
......@@ -185,7 +193,11 @@ export function writeBrowserSavedEnvironmentSecret(
185193 lastConnectedAt: record.lastConnectedAt,
186194 bearerToken: secret,
187195 };
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 };
189201 }),
190202 });
191203 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 {
77 TerminalIcon,
88 TriangleAlertIcon,
99} from "lucide-react";
10import { useAuth } from "@clerk/react";
1011import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react";
1112import {
1213 AuthAccessReadScope,
......@@ -29,12 +30,14 @@ import {
2930 type EnvironmentId,
3031} from "@t3tools/contracts";
3132import { WsRpcClient } from "@t3tools/client-runtime";
33import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
3234import * as DateTime from "effect/DateTime";
3335
3436import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
3537import { cn } from "../../lib/utils";
3638import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
3739import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
40import { resolveRelayClerkTokenOptions } from "../../cloud/publicConfig";
3841import {
3942 SettingsPageContainer,
4043 SettingsRow,
......@@ -66,11 +69,13 @@ import {
6669} from "../ui/alert-dialog";
6770import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
6871import { QRCodeSvg } from "../ui/qr-code";
72import { Skeleton } from "../ui/skeleton";
6973import { Spinner } from "../ui/spinner";
7074import { Switch } from "../ui/switch";
7175import { stackedThreadToast, toastManager } from "../ui/toast";
7276import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7377import { Button } from "../ui/button";
78import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
7479import { Group, GroupSeparator } from "../ui/group";
7580import { AnimatedHeight } from "../AnimatedHeight";
7681import {
......@@ -91,6 +96,7 @@ import {
9196 revokeServerClientSession,
9297 revokeServerPairingLink,
9398 isLoopbackHostname,
99 usePrimaryEnvironmentId,
94100 usePrimarySessionState,
95101 type ServerClientSessionRecord,
96102 type ServerPairingLinkRecord,
......@@ -101,6 +107,7 @@ import {
101107 useSavedEnvironmentRegistryStore,
102108 useSavedEnvironmentRuntimeStore,
103109 addSavedEnvironment,
110 addManagedRelayEnvironment,
104111 connectDesktopSshEnvironment,
105112 disconnectSavedEnvironment,
106113 getPrimaryEnvironmentConnection,
......@@ -110,6 +117,18 @@ import {
110117import { useUiStateStore } from "~/uiStateStore";
111118import { resolveServerConfigVersionMismatch } from "~/versionSkew";
112119import { 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
114133const DEFAULT_TAILSCALE_SERVE_PORT = 443;
115134
......@@ -1582,8 +1601,242 @@ const DesktopSshHostRow = memo(function DesktopSshHostRow({
15821601 );
15831602});
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
15851837export function ConnectionsSettings() {
15861838 const desktopBridge = window.desktopBridge;
1839 const primaryEnvironmentId = usePrimaryEnvironmentId();
15871840 const primarySessionState = usePrimarySessionState();
15881841 const currentSessionScopes = desktopBridge
15891842 ? AuthAdministrativeScopes
......@@ -1701,6 +1954,7 @@ export function ConnectionsSettings() {
17011954 (state) => state.setDefaultAdvertisedEndpointKey,
17021955 );
17031956 const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false;
1957 const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;
17041958 const isLocalBackendNetworkAccessible = desktopBridge
17051959 ? desktopServerExposureState?.mode === "network-accessible"
17061960 : currentAuthPolicy === "remote-reachable";
......@@ -2607,7 +2861,7 @@ export function ConnectionsSettings() {
26072861 <SettingsPageContainer>
26082862 {canManageLocalBackend ? (
26092863 <>
2610 <SettingsSection title="Manage local backend">
2864 <SettingsSection title="This environment">
26112865 {primaryVersionMismatch ? (
26122866 <SettingsRow
26132867 title="Version drift"
......@@ -2626,9 +2880,13 @@ export function ConnectionsSettings() {
26262880 {renderNetworkAccessRow()}
26272881 {renderEndpointRows("endpoint-rail")}
26282882 {renderTailscaleRow()}
2883 <CloudLinkRow canManageRelay={canManageRelay} />
26292884 </>
26302885 ) : (
2631 renderDisabledNetworkAccessRow()
2886 <>
2887 {renderDisabledNetworkAccessRow()}
2888 <CloudLinkRow canManageRelay={canManageRelay} />
2889 </>
26322890 )}
26332891 </SettingsSection>
26342892
......@@ -2805,11 +3063,12 @@ export function ConnectionsSettings() {
28053063 </Dialog>
28063064 </>
28073065 ) : (
2808 <SettingsSection title="Local backend access">
3066 <SettingsSection title="This environment">
28093067 <SettingsRow
28103068 title="Administrative access"
28113069 description="Pairing links and client-session management require the access:write scope for this backend."
28123070 />
3071 <CloudLinkRow canManageRelay={canManageRelay} />
28133072 </SettingsSection>
28143073 )}
28153074
......@@ -2889,15 +3148,10 @@ export function ConnectionsSettings() {
28893148 onRemove={handleRemoveSavedBackend}
28903149 />
28913150 ))}
2892
2893 {savedEnvironmentIds.length === 0 ? (
2894 <div className={ITEM_ROW_CLASSNAME}>
2895 <p className="text-xs text-muted-foreground">
2896 No remote environments yet. Click &ldquo;Add environment&rdquo; to pair another
2897 environment.
2898 </p>
2899 </div>
2900 ) : null}
3151 <CloudRemoteEnvironmentRows
3152 primaryEnvironmentId={primaryEnvironmentId}
3153 savedEnvironmentIds={savedEnvironmentIds}
3154 />
29013155 </SettingsSection>
29023156 </SettingsPageContainer>
29033157 );
apps/web/src/components/settings/SettingsPanels.browser.tsx+24-1
......@@ -148,6 +148,14 @@ const authAccessHarness = vi.hoisted(() => {
148148});
149149
150150const 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
152160vi.mock("../../environments/runtime", () => {
153161 const primaryConnection = {
......@@ -185,6 +193,7 @@ vi.mock("../../environments/runtime", () => {
185193 resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) =>
186194 new URL(path, "http://localhost:3000").toString(),
187195 waitForSavedEnvironmentRegistryHydration: async () => undefined,
196 addManagedRelayEnvironment: vi.fn(),
188197 addSavedEnvironment: vi.fn(),
189198 connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment,
190199 disconnectSavedEnvironment: vi.fn(),
......@@ -450,6 +459,18 @@ const createDesktopBridgeStub = (overrides?: {
450459 setTheme: vi.fn().mockResolvedValue(undefined),
451460 showContextMenu: vi.fn().mockResolvedValue(null),
452461 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: () => () => {},
453474 onMenuAction: () => () => {},
454475 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),
455476 setUpdateChannel:
......@@ -553,7 +574,9 @@ describe("GeneralSettingsPanel observability", () => {
553574 </AppAtomRegistryProvider>,
554575 );
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();
557580 await expect.element(page.getByLabelText("Enable network access")).toBeDisabled();
558581 await expect
559582 .element(
apps/web/src/components/settings/SettingsSidebarNav.tsx+14-1
......@@ -3,6 +3,7 @@ import {
33 ArchiveIcon,
44 ArrowLeftIcon,
55 BotIcon,
6 CloudIcon,
67 GitBranchIcon,
78 KeyboardIcon,
89 Link2Icon,
......@@ -20,12 +21,15 @@ import {
2021 SidebarSeparator,
2122 useSidebar,
2223} from "../ui/sidebar";
24import { Badge } from "../ui/badge";
25import { hasCloudPublicConfig } from "../../cloud/publicConfig";
2326
2427export type SettingsSectionPath =
2528 | "/settings/general"
2629 | "/settings/keybindings"
2730 | "/settings/providers"
2831 | "/settings/source-control"
32 | "/settings/cloud"
2933 | "/settings/connections"
3034 | "/settings/archived";
3135
......@@ -33,11 +37,13 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
3337 label: string;
3438 to: SettingsSectionPath;
3539 icon: ComponentType<{ className?: string }>;
40 badgeLabel?: string;
3641}> = [
3742 { label: "General", to: "/settings/general", icon: Settings2Icon },
3843 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },
3944 { label: "Providers", to: "/settings/providers", icon: BotIcon },
4045 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
46 { label: "T3 Cloud", to: "/settings/cloud", icon: CloudIcon, badgeLabel: "Private Beta" },
4147 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
4248 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
4349];
......@@ -71,7 +77,9 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
7177 <SidebarContent className="overflow-x-hidden">
7278 <SidebarGroup className="px-2 py-3">
7379 <SidebarMenu>
74 {SETTINGS_NAV_ITEMS.map((item) => {
80 {SETTINGS_NAV_ITEMS.filter(
81 (item) => item.to !== "/settings/cloud" || hasCloudPublicConfig(),
82 ).map((item) => {
7583 const Icon = item.icon;
7684 const isActive = pathname === item.to;
7785 return (
......@@ -94,6 +102,11 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
94102 }
95103 />
96104 <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}
97110 </SidebarMenuButton>
98111 </SidebarMenuItem>
99112 );
apps/web/src/environments/primary/auth.ts+2-1
......@@ -3,6 +3,7 @@ import type {
33 AuthClientMetadata,
44 AuthEnvironmentScope,
55 AuthPairingCredentialResult,
6 ServerAuthSessionMethod,
67 AuthSessionId,
78 AuthSessionState,
89} from "@t3tools/contracts";
......@@ -45,7 +46,7 @@ export interface ServerClientSessionRecord {
4546 readonly sessionId: AuthSessionId;
4647 readonly subject: string;
4748 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
48 readonly method: "browser-session-cookie" | "bearer-access-token";
49 readonly method: ServerAuthSessionMethod;
4950 readonly client: AuthClientMetadata;
5051 readonly issuedAt: string;
5152 readonly expiresAt: string;
apps/web/src/environments/primary/index.ts+5-1
......@@ -34,4 +34,8 @@ export {
3434
3535export { 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 {
66import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
77
88import {
9 readSavedEnvironmentCredential,
910 resetSavedEnvironmentRegistryStoreForTests,
1011 resetSavedEnvironmentRuntimeStoreForTests,
1112 useSavedEnvironmentRegistryStore,
1213 useSavedEnvironmentRuntimeStore,
1314 waitForSavedEnvironmentRegistryHydration,
15 writeSavedEnvironmentCredential,
1416} from "./catalog";
1517
1618describe("environment runtime catalog stores", () => {
......@@ -74,6 +76,47 @@ describe("environment runtime catalog stores", () => {
7476 expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({});
7577 });
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
77120 it("does not throw when local api lookup fails during registry persistence", async () => {
78121 vi.unstubAllGlobals();
79122 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
apps/web/src/environments/runtime/catalog.ts+49
......@@ -6,6 +6,8 @@ import type {
66 PersistedSavedEnvironmentRecord,
77 ServerConfig,
88} from "@t3tools/contracts";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
911import { create } from "zustand";
1012
1113import { ensureLocalApi } from "../../localApi";
......@@ -19,8 +21,29 @@ export interface SavedEnvironmentRecord {
1921 readonly createdAt: string;
2022 readonly lastConnectedAt: string | null;
2123 readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"];
24 readonly relayManaged?: PersistedSavedEnvironmentRecord["relayManaged"];
2225}
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
2447interface SavedEnvironmentRegistryState {
2548 readonly byId: Record<EnvironmentId, SavedEnvironmentRecord>;
2649}
......@@ -47,6 +70,7 @@ export function toPersistedSavedEnvironmentRecord(
4770 createdAt: record.createdAt,
4871 lastConnectedAt: record.lastConnectedAt,
4972 ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}),
73 ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}),
5074 };
5175}
5276
......@@ -250,6 +274,31 @@ export async function readSavedEnvironmentBearerToken(
250274 return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId);
251275}
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
253302export async function writeSavedEnvironmentBearerToken(
254303 environmentId: EnvironmentId,
255304 bearerToken: string,
apps/web/src/environments/runtime/index.ts+1
......@@ -16,6 +16,7 @@ export {
1616
1717export {
1818 addSavedEnvironment,
19 addManagedRelayEnvironment,
1920 connectDesktopSshEnvironment,
2021 disconnectSavedEnvironment,
2122 ensureEnvironmentConnectionBootstrapped,
apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+159-19
......@@ -1,4 +1,5 @@
11import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
23import * as Schema from "effect/Schema";
34import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
45
......@@ -10,17 +11,33 @@ const mockResolveRemotePairingTarget = vi.fn();
1011const mockFetchRemoteEnvironmentDescriptor = vi.fn();
1112const mockBootstrapRemoteBearerSession = vi.fn();
1213const mockFetchRemoteSessionState = vi.fn();
14const mockFetchRemoteDpopSessionState = vi.fn();
1315const 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);
1530const mockBootstrapSshBearerSession = vi.fn();
1631const mockFetchSshSessionState = vi.fn();
1732const mockPersistSavedEnvironmentRecord = vi.fn();
1833const mockWriteSavedEnvironmentBearerToken = vi.fn();
34const mockWriteSavedEnvironmentCredential = vi.fn();
1935const mockSetSavedEnvironmentRegistry = vi.fn();
2036const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => {
2137 return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null;
2238});
2339const mockReadSavedEnvironmentBearerToken = vi.fn();
40const mockReadSavedEnvironmentCredential = vi.fn();
2441const mockRemoveSavedEnvironmentBearerToken = vi.fn();
2542const mockPatchRuntime = vi.fn();
2643const mockClearRuntime = vi.fn();
......@@ -58,6 +75,8 @@ const mockClientGetConfig = vi.fn(async () => ({
5875 label: "Remote environment",
5976 },
6077}));
78const mockConnectManagedCloudEnvironment = vi.fn();
79const mockReadManagedRelayClerkToken = vi.fn();
6180
6281vi.mock("@t3tools/shared/remote", async (importOriginal) => ({
6382 ...(await importOriginal<typeof import("@t3tools/shared/remote")>()),
......@@ -65,11 +84,19 @@ vi.mock("@t3tools/shared/remote", async (importOriginal) => ({
6584}));
6685
6786vi.mock("../../lib/runtime", () => ({
68 remoteHttpRuntime: {
87 webRuntime: {
6988 runPromise: mockRemoteHttpRunPromise,
7089 },
7190}));
7291
92vi.mock("../../cloud/linkEnvironment", () => ({
93 connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment,
94}));
95
96vi.mock("../../cloud/managedAuth", () => ({
97 readManagedRelayClerkToken: mockReadManagedRelayClerkToken,
98}));
99
73100vi.mock("~/localApi", () => ({
74101 ensureLocalApi: () => ({
75102 persistence: {
......@@ -84,6 +111,7 @@ vi.mock("./catalog", () => ({
84111 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
85112 persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord,
86113 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
114 readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential,
87115 removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken,
88116 toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord,
89117 useSavedEnvironmentRegistryStore: {
......@@ -105,6 +133,7 @@ vi.mock("./catalog", () => ({
105133 },
106134 waitForSavedEnvironmentRegistryHydration: vi.fn(),
107135 writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken,
136 writeSavedEnvironmentCredential: mockWriteSavedEnvironmentCredential,
108137}));
109138
110139vi.mock("./connection", async (importOriginal) => ({
......@@ -114,6 +143,7 @@ vi.mock("./connection", async (importOriginal) => ({
114143
115144vi.mock("@t3tools/client-runtime", async (importOriginal) => {
116145 const actual = await importOriginal<typeof import("@t3tools/client-runtime")>();
146 managedRelayDpopSigner = actual.ManagedRelayDpopSigner;
117147 return {
118148 ...actual,
119149 bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession,
......@@ -130,6 +160,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
130160 })),
131161 fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor,
132162 fetchRemoteSessionState: mockFetchRemoteSessionState,
163 fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState,
133164 resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl,
134165 };
135166});
......@@ -172,20 +203,36 @@ describe("addSavedEnvironment", () => {
172203 credential: input.pairingCode ?? "pairing-code",
173204 }),
174205 );
175 mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({
176 environmentId: EnvironmentId.make("environment-1"),
177 label: "Remote environment",
178 });
179 mockBootstrapRemoteBearerSession.mockResolvedValue({
180 access_token: "bearer-token",
181 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
206 mockReadSavedEnvironmentCredential.mockImplementation(async () => {
207 const token = await mockReadSavedEnvironmentBearerToken();
208 return token ? { version: 1, method: "bearer", token } : null;
182209 });
183 mockFetchRemoteSessionState.mockResolvedValue({
184 authenticated: true,
185 scopes: ["orchestration:read", "access:write"],
186 });
187 mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue(
188 "wss://remote.example.com/?wsTicket=remote-token",
210 mockFetchRemoteEnvironmentDescriptor.mockReturnValue(
211 Effect.succeed({
212 environmentId: EnvironmentId.make("environment-1"),
213 label: "Remote environment",
214 }),
215 );
216 mockBootstrapRemoteBearerSession.mockReturnValue(
217 Effect.succeed({
218 access_token: "bearer-token",
219 scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read",
220 }),
221 );
222 mockFetchRemoteSessionState.mockReturnValue(
223 Effect.succeed({
224 authenticated: true,
225 scopes: ["orchestration:read", "access:write"],
226 }),
227 );
228 mockFetchRemoteDpopSessionState.mockReturnValue(
229 Effect.succeed({
230 authenticated: true,
231 scopes: ["orchestration:read", "access:write"],
232 }),
233 );
234 mockResolveRemoteWebSocketConnectionUrl.mockReturnValue(
235 Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"),
189236 );
190237 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
191238 environmentId: EnvironmentId.make("environment-1"),
......@@ -197,6 +244,8 @@ describe("addSavedEnvironment", () => {
197244 });
198245 mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined);
199246 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false);
247 mockWriteSavedEnvironmentCredential.mockResolvedValue(true);
248 mockReadManagedRelayClerkToken.mockResolvedValue(null);
200249 mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined);
201250 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
202251 mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined);
......@@ -323,6 +372,94 @@ describe("addSavedEnvironment", () => {
323372 await resetEnvironmentServiceForTests();
324373 });
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
326463 it("removes an older ssh record when the same target returns a new environment id", async () => {
327464 mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true);
328465 mockFetchSshEnvironmentDescriptor.mockResolvedValue({
......@@ -424,7 +561,7 @@ describe("addSavedEnvironment", () => {
424561 reason: "invalid_credential",
425562 traceId: "trace-auth-test",
426563 });
427 mockFetchRemoteSessionState.mockRejectedValueOnce(authError);
564 mockFetchRemoteSessionState.mockReturnValueOnce(Effect.fail(authError));
428565
429566 const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service");
430567
......@@ -747,9 +884,12 @@ describe("addSavedEnvironment", () => {
747884 readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">;
748885 }) => void;
749886 mockFetchRemoteSessionState.mockReturnValue(
750 new Promise((resolve) => {
751 resolveSessionState = resolve;
752 }),
887 Effect.promise(
888 () =>
889 new Promise((resolve) => {
890 resolveSessionState = resolve;
891 }),
892 ),
753893 );
754894
755895 const {
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+8-1
......@@ -11,6 +11,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
1111const mockListSavedEnvironmentRecords = vi.fn();
1212const mockSavedEnvironmentRegistrySubscribe = vi.fn();
1313const mockReadSavedEnvironmentBearerToken = vi.fn();
14const mockReadSavedEnvironmentCredential = vi.fn();
1415const mockGetSavedEnvironmentRecord = vi.fn();
1516
1617function MockWsTransport() {
......@@ -31,7 +32,7 @@ vi.mock("../primary", () => ({
3132}));
3233
3334vi.mock("../../lib/runtime", () => ({
34 remoteHttpRuntime: {
35 webRuntime: {
3536 runPromise: mockRemoteHttpRunPromise,
3637 },
3738}));
......@@ -42,6 +43,7 @@ vi.mock("./catalog", () => ({
4243 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
4344 persistSavedEnvironmentRecord: vi.fn(),
4445 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
46 readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential,
4547 removeSavedEnvironmentBearerToken: vi.fn(),
4648 useSavedEnvironmentRegistryStore: {
4749 subscribe: mockSavedEnvironmentRegistrySubscribe,
......@@ -61,6 +63,7 @@ vi.mock("./catalog", () => ({
6163 },
6264 waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,
6365 writeSavedEnvironmentBearerToken: vi.fn(),
66 writeSavedEnvironmentCredential: vi.fn(),
6467}));
6568
6669vi.mock("./connection", async (importOriginal) => ({
......@@ -231,6 +234,10 @@ describe("saved environment startup", () => {
231234 mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined);
232235 mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined);
233236 mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token");
237 mockReadSavedEnvironmentCredential.mockImplementation(async () => {
238 const token = await mockReadSavedEnvironmentBearerToken();
239 return token ? { version: 1, method: "bearer", token } : null;
240 });
234241 mockCreateWsRpcClient.mockImplementation(() => createClient());
235242 mockCreateEnvironmentConnection.mockImplementation((input) => {
236243 if (input.kind === "saved") {
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+12-1
......@@ -18,6 +18,7 @@ const mockWaitForSavedEnvironmentRegistryHydration = vi.fn();
1818const mockListSavedEnvironmentRecords = vi.fn();
1919const mockGetSavedEnvironmentRecord = vi.fn();
2020const mockReadSavedEnvironmentBearerToken = vi.fn();
21const mockReadSavedEnvironmentCredential = vi.fn();
2122const mockSavedEnvironmentRegistrySubscribe = vi.fn();
2223const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn());
2324const mockFetchRemoteSessionState = vi.fn();
......@@ -35,7 +36,7 @@ vi.mock("../primary", () => ({
3536}));
3637
3738vi.mock("../../lib/runtime", () => ({
38 remoteHttpRuntime: {
39 webRuntime: {
3940 runPromise: mockRemoteHttpRunPromise,
4041 },
4142}));
......@@ -46,6 +47,7 @@ vi.mock("./catalog", () => ({
4647 listSavedEnvironmentRecords: mockListSavedEnvironmentRecords,
4748 persistSavedEnvironmentRecord: vi.fn(),
4849 readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken,
50 readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential,
4951 removeSavedEnvironmentBearerToken: vi.fn(),
5052 useSavedEnvironmentRegistryStore: {
5153 subscribe: mockSavedEnvironmentRegistrySubscribe,
......@@ -65,6 +67,7 @@ vi.mock("./catalog", () => ({
6567 },
6668 waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration,
6769 writeSavedEnvironmentBearerToken: vi.fn(),
70 writeSavedEnvironmentCredential: vi.fn(),
6871}));
6972
7073vi.mock("./connection", async (importOriginal) => ({
......@@ -78,6 +81,10 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => {
7881 dispose: async () => undefined,
7982 reconnect: async () => undefined,
8083 isHeartbeatFresh: () => false,
84 cloud: {
85 getRelayClientStatus: vi.fn(),
86 installRelayClient: vi.fn(),
87 },
8188 orchestration: {
8289 dispatchCommand: vi.fn(),
8390 getTurnDiff: vi.fn(),
......@@ -301,6 +308,10 @@ describe("retainThreadDetailSubscription", () => {
301308 mockListSavedEnvironmentRecords.mockReturnValue([]);
302309 mockGetSavedEnvironmentRecord.mockReturnValue(null);
303310 mockReadSavedEnvironmentBearerToken.mockResolvedValue(null);
311 mockReadSavedEnvironmentCredential.mockImplementation(async () => {
312 const token = await mockReadSavedEnvironmentBearerToken();
313 return token ? { version: 1, method: "bearer", token } : null;
314 });
304315 mockFetchRemoteSessionState.mockResolvedValue({
305316 authenticated: true,
306317 scopes: ["orchestration:read"],
apps/web/src/environments/runtime/service.ts+231-42
......@@ -1,6 +1,5 @@
11import {
22 AuthEnvironmentScope,
3 AuthStandardClientScopes,
43 type DesktopSshEnvironmentBootstrap,
54 type DesktopSshEnvironmentTarget,
65 type EnvironmentId,
......@@ -16,12 +15,17 @@ import {
1615 type WsRpcClient,
1716 bootstrapRemoteBearerSession,
1817 fetchRemoteEnvironmentDescriptor,
18 fetchRemoteDpopSessionState,
1919 fetchRemoteSessionState,
20 type ManagedRelayDpopProofInput,
21 ManagedRelayDpopSigner,
22 resolveRemoteDpopWebSocketConnectionUrl,
2023 resolveRemoteWebSocketConnectionUrl,
2124} from "@t3tools/client-runtime";
2225
2326import { type QueryClient } from "@tanstack/react-query";
2427import { Throttler } from "@tanstack/react-pacer";
28import * as Effect from "effect/Effect";
2529import * as Schema from "effect/Schema";
2630import {
2731 createKnownEnvironment,
......@@ -40,21 +44,25 @@ import { ensureLocalApi } from "~/localApi";
4044import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";
4145import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
4246import { getPrimaryKnownEnvironment } from "../primary";
43import { remoteHttpRuntime } from "../../lib/runtime";
47import { webRuntime } from "../../lib/runtime";
48import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment";
49import { readManagedRelayClerkToken } from "../../cloud/managedAuth";
4450
4551import {
4652 getSavedEnvironmentRecord,
4753 hasSavedEnvironmentRegistryHydrated,
4854 listSavedEnvironmentRecords,
4955 persistSavedEnvironmentRecord,
50 readSavedEnvironmentBearerToken,
56 readSavedEnvironmentCredential,
5157 removeSavedEnvironmentBearerToken,
5258 type SavedEnvironmentRecord,
59 type SavedEnvironmentCredential,
5360 toPersistedSavedEnvironmentRecord,
5461 useSavedEnvironmentRegistryStore,
5562 useSavedEnvironmentRuntimeStore,
5663 waitForSavedEnvironmentRegistryHydration,
5764 writeSavedEnvironmentBearerToken,
65 writeSavedEnvironmentCredential,
5866} from "./catalog";
5967import {
6068 createEnvironmentConnection,
......@@ -159,6 +167,12 @@ const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150;
159167const NOOP = () => undefined;
160168const 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
162176function createDeferredPromise<T>() {
163177 let resolve: ((value: T) => void) | null = null;
164178 const promise = new Promise<T>((nextResolve) => {
......@@ -1156,7 +1170,7 @@ function createPrimaryEnvironmentClient(
11561170
11571171function createSavedEnvironmentClient(
11581172 environmentId: EnvironmentId,
1159 bearerToken: string,
1173 credentialRef: { current: SavedEnvironmentCredential },
11601174): WsRpcClient {
11611175 useSavedEnvironmentRuntimeStore.getState().ensure(environmentId);
11621176
......@@ -1167,19 +1181,70 @@ function createSavedEnvironmentClient(
11671181 if (!record) {
11681182 throw new Error(`Saved environment ${environmentId} not found.`);
11691183 }
1170 return record.desktopSsh
1171 ? await resolveDesktopSshWebSocketConnectionUrl(
1172 record.wsBaseUrl,
1173 record.httpBaseUrl,
1174 bearerToken,
1175 )
1176 : await remoteHttpRuntime.runPromise(
1177 resolveRemoteWebSocketConnectionUrl({
1178 wsBaseUrl: record.wsBaseUrl,
1179 httpBaseUrl: record.httpBaseUrl,
1180 bearerToken,
1181 }),
1184 const credential = credentialRef.current;
1185 if (record.desktopSsh) {
1186 if (credential.method !== "bearer") {
1187 throw new Error("SSH environments require bearer credentials.");
1188 }
1189 return await resolveDesktopSshWebSocketConnectionUrl(
1190 record.wsBaseUrl,
1191 record.httpBaseUrl,
1192 credential.token,
1193 );
1194 }
1195 if (credential.method === "dpop") {
1196 try {
1197 return await webRuntime.runPromise(
1198 createManagedRelayDpopProof({
1199 method: "POST",
1200 url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(),
1201 accessToken: credential.accessToken,
1202 }).pipe(
1203 Effect.flatMap((proof) =>
1204 resolveRemoteDpopWebSocketConnectionUrl({
1205 wsBaseUrl: record.wsBaseUrl,
1206 httpBaseUrl: record.httpBaseUrl,
1207 accessToken: credential.accessToken,
1208 dpopProof: proof,
1209 }),
1210 ),
1211 ),
1212 );
1213 } catch (error) {
1214 if (!isEnvironmentAuthInvalidError(error)) {
1215 throw error;
1216 }
1217 const renewed = await renewManagedRelayCredential(record);
1218 if (!renewed || renewed.credential.method !== "dpop") {
1219 throw error;
1220 }
1221 const renewedCredential = renewed.credential;
1222 credentialRef.current = renewedCredential;
1223 return await webRuntime.runPromise(
1224 createManagedRelayDpopProof({
1225 method: "POST",
1226 url: new URL("/api/auth/websocket-ticket", renewed.record.httpBaseUrl).toString(),
1227 accessToken: renewedCredential.accessToken,
1228 }).pipe(
1229 Effect.flatMap((proof) =>
1230 resolveRemoteDpopWebSocketConnectionUrl({
1231 wsBaseUrl: renewed.record.wsBaseUrl,
1232 httpBaseUrl: renewed.record.httpBaseUrl,
1233 accessToken: renewedCredential.accessToken,
1234 dpopProof: proof,
1235 }),
1236 ),
1237 ),
11821238 );
1239 }
1240 }
1241 return await webRuntime.runPromise(
1242 resolveRemoteWebSocketConnectionUrl({
1243 wsBaseUrl: record.wsBaseUrl,
1244 httpBaseUrl: record.httpBaseUrl,
1245 bearerToken: credential.token,
1246 }),
1247 );
11831248 },
11841249 {
11851250 getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null,
......@@ -1221,7 +1286,7 @@ function createSavedEnvironmentClient(
12211286
12221287async function refreshSavedEnvironmentMetadata(
12231288 environmentId: EnvironmentId,
1224 bearerToken: string,
1289 credential: SavedEnvironmentCredential,
12251290 client: WsRpcClient,
12261291 scopeHint?: ReadonlyArray<AuthEnvironmentScope> | null,
12271292 configHint?: ServerConfig | null,
......@@ -1234,13 +1299,31 @@ async function refreshSavedEnvironmentMetadata(
12341299 const [serverConfig, sessionState] = await Promise.all([
12351300 configHint ? Promise.resolve(configHint) : client.server.getConfig(),
12361301 record.desktopSsh
1237 ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken)
1238 : remoteHttpRuntime.runPromise(
1239 fetchRemoteSessionState({
1240 httpBaseUrl: record.httpBaseUrl,
1241 bearerToken,
1242 }),
1243 ),
1302 ? credential.method === "bearer"
1303 ? fetchDesktopSshSessionState(record.httpBaseUrl, credential.token)
1304 : Promise.reject(new Error("SSH environments require bearer credentials."))
1305 : credential.method === "dpop"
1306 ? webRuntime.runPromise(
1307 createManagedRelayDpopProof({
1308 method: "GET",
1309 url: new URL("/api/auth/session", record.httpBaseUrl).toString(),
1310 accessToken: credential.accessToken,
1311 }).pipe(
1312 Effect.flatMap((proof) =>
1313 fetchRemoteDpopSessionState({
1314 httpBaseUrl: record.httpBaseUrl,
1315 accessToken: credential.accessToken,
1316 dpopProof: proof,
1317 }),
1318 ),
1319 ),
1320 )
1321 : webRuntime.runPromise(
1322 fetchRemoteSessionState({
1323 httpBaseUrl: record.httpBaseUrl,
1324 bearerToken: credential.token,
1325 }),
1326 ),
12441327 ]);
12451328
12461329 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
......@@ -1254,6 +1337,52 @@ async function refreshSavedEnvironmentMetadata(
12541337 .rename(record.environmentId, serverConfig.environment.label);
12551338}
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
12571386function registerConnection(connection: EnvironmentConnection): EnvironmentConnection {
12581387 const existing = environmentConnections.get(connection.environmentId);
12591388 if (existing && existing !== connection) {
......@@ -1320,8 +1449,10 @@ async function ensureSavedEnvironmentConnection(
13201449 options?: {
13211450 readonly client?: WsRpcClient;
13221451 readonly bearerToken?: string;
1452 readonly credential?: SavedEnvironmentCredential;
13231453 readonly scopes?: ReadonlyArray<AuthEnvironmentScope> | null;
13241454 readonly serverConfig?: ServerConfig | null;
1455 readonly allowManagedRenewal?: boolean;
13251456 },
13261457): Promise<EnvironmentConnection> {
13271458 const existing = environmentConnections.get(record.environmentId);
......@@ -1340,14 +1471,17 @@ async function ensureSavedEnvironmentConnection(
13401471 promise: Promise.resolve().then(async () => {
13411472 let activeRecord = record;
13421473 let scopeHint = options?.scopes ?? null;
1343 let bearerToken =
1344 options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId));
1345 if (!bearerToken) {
1474 let credential =
1475 options?.credential ??
1476 (options?.bearerToken
1477 ? ({ version: 1, method: "bearer", token: options.bearerToken } as const)
1478 : await readSavedEnvironmentCredential(record.environmentId));
1479 if (!credential) {
13461480 if (record.desktopSsh) {
13471481 const issued = await issueDesktopSshBearerSession(record);
13481482 activeRecord = issued.record;
1349 bearerToken = issued.bearerToken;
1350 scopeHint = [...AuthStandardClientScopes];
1483 credential = { version: 1, method: "bearer", token: issued.bearerToken };
1484 scopeHint = issued.scopes;
13511485 } else {
13521486 useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, {
13531487 authState: "requires-auth",
......@@ -1363,10 +1497,10 @@ async function ensureSavedEnvironmentConnection(
13631497 activeRecord = prepared.record;
13641498 }
13651499
1366 const activeBearerToken = bearerToken;
1500 const activeCredential = { current: credential };
13671501 const client =
13681502 options?.client ??
1369 createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken);
1503 createSavedEnvironmentClient(activeRecord.environmentId, activeCredential);
13701504 const initialConfigSnapshot = createDeferredPromise<ServerConfig>();
13711505 const knownEnvironment = createKnownEnvironment({
13721506 id: activeRecord.environmentId,
......@@ -1387,7 +1521,7 @@ async function ensureSavedEnvironmentConnection(
13871521 refreshMetadata: async () => {
13881522 await refreshSavedEnvironmentMetadata(
13891523 activeRecord.environmentId,
1390 activeBearerToken,
1524 activeCredential.current,
13911525 client,
13921526 );
13931527 },
......@@ -1416,7 +1550,7 @@ async function ensureSavedEnvironmentConnection(
14161550 ));
14171551 await refreshSavedEnvironmentMetadata(
14181552 activeRecord.environmentId,
1419 activeBearerToken,
1553 activeCredential.current,
14201554 client,
14211555 scopeHint,
14221556 initialServerConfig,
......@@ -1429,20 +1563,41 @@ async function ensureSavedEnvironmentConnection(
14291563 throw error;
14301564 }
14311565 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 }
14321582 await removeSavedEnvironmentBearerToken(activeRecord.environmentId);
1433 throw new Error("Saved environment credential expired. Pair it again.", {
1434 cause: error,
1435 });
1583 throw new Error(
1584 activeCredential.current.method === "dpop"
1585 ? "Managed tunnel credential expired. Connect it again from T3 Cloud."
1586 : "Saved environment credential expired. Pair it again.",
1587 {
1588 cause: error,
1589 },
1590 );
14361591 }
14371592
14381593 const issued = await issueDesktopSshBearerSession(activeRecord);
14391594 activeRecord = issued.record;
1440 bearerToken = issued.bearerToken;
1441 scopeHint = [...AuthStandardClientScopes];
1595 credential = { version: 1, method: "bearer", token: issued.bearerToken };
1596 scopeHint = issued.scopes;
14421597 await connection.dispose().catch(() => undefined);
14431598 pendingSavedEnvironmentConnections.delete(activeRecord.environmentId);
14441599 return await ensureSavedEnvironmentConnection(activeRecord, {
1445 bearerToken,
1600 credential,
14461601 scopes: scopeHint,
14471602 serverConfig: options?.serverConfig ?? null,
14481603 });
......@@ -1647,7 +1802,7 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P
16471802 await removeConnection(environmentId).catch(() => false);
16481803 await ensureSavedEnvironmentConnection(issued.record, {
16491804 bearerToken: issued.bearerToken,
1650 scopes: [...AuthStandardClientScopes],
1805 scopes: issued.scopes,
16511806 });
16521807 return;
16531808 } catch (recoveryError) {
......@@ -1686,7 +1841,7 @@ export async function addSavedEnvironment(input: {
16861841 });
16871842 const descriptor = input.desktopSsh
16881843 ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl)
1689 : await remoteHttpRuntime.runPromise(
1844 : await webRuntime.runPromise(
16901845 fetchRemoteEnvironmentDescriptor({
16911846 httpBaseUrl: resolvedTarget.httpBaseUrl,
16921847 }),
......@@ -1701,7 +1856,7 @@ export async function addSavedEnvironment(input: {
17011856
17021857 const bearerSession = input.desktopSsh
17031858 ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential)
1704 : await remoteHttpRuntime.runPromise(
1859 : await webRuntime.runPromise(
17051860 bootstrapRemoteBearerSession({
17061861 httpBaseUrl: resolvedTarget.httpBaseUrl,
17071862 credential: resolvedTarget.credential,
......@@ -1741,6 +1896,40 @@ export async function addSavedEnvironment(input: {
17411896 return record;
17421897}
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
17441933export async function connectDesktopSshEnvironment(
17451934 target: DesktopSshEnvironmentTarget,
17461935 options?: { label?: string },
apps/web/src/hooks/useTheme.ts+1-1
......@@ -109,7 +109,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
109109function syncDesktopTheme(theme: Theme) {
110110 if (typeof window === "undefined") return;
111111 const bridge = window.desktopBridge;
112 if (!bridge || lastDesktopTheme === theme) {
112 if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) {
113113 return;
114114 }
115115
apps/web/src/hostedPairing.ts+1-1
......@@ -10,7 +10,7 @@ export interface HostedPairingRequest {
1010
1111export type HostedAppChannel = "latest" | "nightly";
1212
13function configuredHostedAppUrl(): string {
13export function configuredHostedAppUrl(): string {
1414 return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL;
1515}
1616
apps/web/src/lib/runtime.ts+23-2
......@@ -8,15 +8,26 @@ import {
88 PrimaryEnvironmentHttpClient,
99 primaryEnvironmentHttpClientLive,
1010} 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
1425const primaryHttpRuntime = ManagedRuntime.make(
1526 primaryEnvironmentHttpClientLive.pipe(
1627 Layer.provide(
1728 Layer.mergeAll(
1829 remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)),
19 Layer.succeed(FetchHttpClient.RequestInit, { credentials: "include" }),
30 Layer.succeed(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit),
2031 ),
2132 ),
2233 ),
......@@ -37,3 +48,13 @@ export const runPrimaryHttp = <A, E>(effect: Effect.Effect<A, E, PrimaryEnvironm
3748export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner): void {
3849 primaryHttpRunner = runner ?? livePrimaryHttpRunner;
3950}
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
234234 setTheme: async () => undefined,
235235 showContextMenu: async () => null,
236236 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,
237249 onMenuAction: () => () => undefined,
238250 getUpdateState: async () => {
239251 throw new Error("getUpdateState not implemented in test");
apps/web/src/main.tsx+22-1
......@@ -1,5 +1,6 @@
11import React from "react";
22import ReactDOM from "react-dom/client";
3import { ClerkProvider } from "@clerk/react";
34import { RouterProvider } from "@tanstack/react-router";
45import { createHashHistory, createBrowserHistory } from "@tanstack/react-router";
56
......@@ -7,6 +8,9 @@ import "@xterm/xterm/css/xterm.css";
78import "./index.css";
89
910import { isElectron } from "./env";
11import { DesktopClerkProvider } from "./cloud/desktopClerk";
12import { ManagedRelayAuthProvider } from "./cloud/managedAuth";
13import { hasCloudPublicConfig } from "./cloud/publicConfig";
1014import { getRouter } from "./router";
1115import { APP_DISPLAY_NAME } from "./branding";
1216import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
......@@ -22,8 +26,25 @@ if (isElectron) {
2226
2327document.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
2534ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
2635 <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 )}
2849 </React.StrictMode>,
2950);
apps/web/src/routeTree.gen.ts+21
......@@ -19,6 +19,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi
1919import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
2020import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics'
2121import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
22import { Route as SettingsCloudRouteImport } from './routes/settings.cloud'
2223import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
2324import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId'
2425import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId'
......@@ -72,6 +73,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({
7273 path: '/connections',
7374 getParentRoute: () => SettingsRoute,
7475} as any)
76const SettingsCloudRoute = SettingsCloudRouteImport.update({
77 id: '/cloud',
78 path: '/cloud',
79 getParentRoute: () => SettingsRoute,
80} as any)
7581const SettingsArchivedRoute = SettingsArchivedRouteImport.update({
7682 id: '/archived',
7783 path: '/archived',
......@@ -94,6 +100,7 @@ export interface FileRoutesByFullPath {
94100 '/pair': typeof PairRoute
95101 '/settings': typeof SettingsRouteWithChildren
96102 '/settings/archived': typeof SettingsArchivedRoute
103 '/settings/cloud': typeof SettingsCloudRoute
97104 '/settings/connections': typeof SettingsConnectionsRoute
98105 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
99106 '/settings/general': typeof SettingsGeneralRoute
......@@ -107,6 +114,7 @@ export interface FileRoutesByTo {
107114 '/pair': typeof PairRoute
108115 '/settings': typeof SettingsRouteWithChildren
109116 '/settings/archived': typeof SettingsArchivedRoute
117 '/settings/cloud': typeof SettingsCloudRoute
110118 '/settings/connections': typeof SettingsConnectionsRoute
111119 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
112120 '/settings/general': typeof SettingsGeneralRoute
......@@ -123,6 +131,7 @@ export interface FileRoutesById {
123131 '/pair': typeof PairRoute
124132 '/settings': typeof SettingsRouteWithChildren
125133 '/settings/archived': typeof SettingsArchivedRoute
134 '/settings/cloud': typeof SettingsCloudRoute
126135 '/settings/connections': typeof SettingsConnectionsRoute
127136 '/settings/diagnostics': typeof SettingsDiagnosticsRoute
128137 '/settings/general': typeof SettingsGeneralRoute
......@@ -140,6 +149,7 @@ export interface FileRouteTypes {
140149 | '/pair'
141150 | '/settings'
142151 | '/settings/archived'
152 | '/settings/cloud'
143153 | '/settings/connections'
144154 | '/settings/diagnostics'
145155 | '/settings/general'
......@@ -153,6 +163,7 @@ export interface FileRouteTypes {
153163 | '/pair'
154164 | '/settings'
155165 | '/settings/archived'
166 | '/settings/cloud'
156167 | '/settings/connections'
157168 | '/settings/diagnostics'
158169 | '/settings/general'
......@@ -168,6 +179,7 @@ export interface FileRouteTypes {
168179 | '/pair'
169180 | '/settings'
170181 | '/settings/archived'
182 | '/settings/cloud'
171183 | '/settings/connections'
172184 | '/settings/diagnostics'
173185 | '/settings/general'
......@@ -257,6 +269,13 @@ declare module '@tanstack/react-router' {
257269 preLoaderRoute: typeof SettingsConnectionsRouteImport
258270 parentRoute: typeof SettingsRoute
259271 }
272 '/settings/cloud': {
273 id: '/settings/cloud'
274 path: '/cloud'
275 fullPath: '/settings/cloud'
276 preLoaderRoute: typeof SettingsCloudRouteImport
277 parentRoute: typeof SettingsRoute
278 }
260279 '/settings/archived': {
261280 id: '/settings/archived'
262281 path: '/archived'
......@@ -297,6 +316,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren)
297316
298317interface SettingsRouteChildren {
299318 SettingsArchivedRoute: typeof SettingsArchivedRoute
319 SettingsCloudRoute: typeof SettingsCloudRoute
300320 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
301321 SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute
302322 SettingsGeneralRoute: typeof SettingsGeneralRoute
......@@ -307,6 +327,7 @@ interface SettingsRouteChildren {
307327
308328const SettingsRouteChildren: SettingsRouteChildren = {
309329 SettingsArchivedRoute: SettingsArchivedRoute,
330 SettingsCloudRoute: SettingsCloudRoute,
310331 SettingsConnectionsRoute: SettingsConnectionsRoute,
311332 SettingsDiagnosticsRoute: SettingsDiagnosticsRoute,
312333 SettingsGeneralRoute: SettingsGeneralRoute,
apps/web/src/routes/__root.tsx+2
......@@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query";
1313import { APP_DISPLAY_NAME } from "../branding";
1414import { AppSidebarLayout } from "../components/AppSidebarLayout";
1515import { CommandPalette } from "../components/CommandPalette";
16import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
1617import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
1718import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
1819import {
......@@ -135,6 +136,7 @@ function RootRouteView() {
135136 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
136137 {primaryEnvironmentAuthenticated ? <ServerStateBootstrap /> : null}
137138 <EnvironmentConnectionManagerBootstrap />
139 <RelayClientInstallDialog />
138140 <SshPasswordPromptDialog />
139141 <HostedStaticEnvironmentBootstrap />
140142 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
apps/web/src/routes/_chat.index.tsx+11-4
......@@ -7,6 +7,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/
77import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar";
88import { useSavedEnvironmentRegistryStore } from "../environments/runtime";
99import { APP_DISPLAY_NAME } from "~/branding";
10import { hasCloudPublicConfig } from "~/cloud/publicConfig";
1011
1112function ChatIndexRouteView() {
1213 const { authGateState } = Route.useRouteContext();
......@@ -26,6 +27,8 @@ export const Route = createFileRoute("/_chat/")({
2627});
2728
2829function HostedStaticOnboardingState() {
30 const cloudEnabled = hasCloudPublicConfig();
31
2932 return (
3033 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
3134 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
......@@ -48,13 +51,17 @@ function HostedStaticOnboardingState() {
4851 Connect an environment to get started
4952 </EmptyTitle>
5053 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">
51 Open a pairing link from your T3 Code desktop app or add a reachable backend
52 manually. Your saved environments stay in this browser.
54 {cloudEnabled
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."}
5357 </EmptyDescription>
5458 <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 >
5663 <PlusIcon className="size-4" />
57 Add environment
64 {cloudEnabled ? "Open T3 Cloud" : "Add environment"}
5865 </Button>
5966 </div>
6067 </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 {
77 readonly VITE_WS_URL: string;
88 readonly VITE_HOSTED_APP_URL: string;
99 readonly VITE_HOSTED_APP_CHANNEL: string;
10 readonly VITE_CLERK_PUBLISHABLE_KEY: string;
11 readonly VITE_CLERK_JWT_TEMPLATE: string;
1012 readonly APP_VERSION: string;
1113}
1214
apps/web/tsconfig.json+1-1
......@@ -26,5 +26,5 @@
2626 }
2727 ]
2828 },
29 "include": ["src", "vite.config.ts", "vercel.ts", "test"]
29 "include": ["src", "vite.config.ts", "vercel.ts", "test", "../../scripts/lib/public-config.ts"]
3030}
apps/web/vite.config.ts+11
......@@ -8,9 +8,17 @@ import "vite-plus/test/config";
88import { defineConfig } from "vite-plus";
99import 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
1116const port = Number(process.env.PORT ?? 5733);
1217const host = process.env.HOST?.trim() || "localhost";
1318const 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() || "";
1422const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
1523const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
1624const configuredHostedAppUrl = (() => {
......@@ -123,6 +131,9 @@ export default defineConfig(() => {
123131 define: {
124132 // In dev mode, tell the web app where the WebSocket server lives
125133 "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),
126137 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
127138 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
128139 "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
77- Workflow: `.github/workflows/release.yml`
88- Triggers:
99 - push tag matching `v*.*.*` for stable releases
10 - scheduled nightly at `09:00 UTC`
10 - scheduled nightly check every three hours
1111 - manual `workflow_dispatch` for either channel
1212- Runs quality gates first: lint, typecheck, test.
13- Reads the shared production T3 Cloud relay URL and Clerk client configuration before packaging clients.
1314- Builds four artifacts in parallel for both channels:
1415 - macOS `arm64` DMG
1516 - macOS `x64` DMG
......@@ -29,6 +30,64 @@ This document covers the unified release workflow for stable and nightly desktop
2930 - nightly releases are aliased to the `nightly` hosted app channel
3031- 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
3291## Hosted web app release deployment
3392
3493The hosted app is intentionally not deployed by Vercel's Git integration. The
......@@ -85,7 +144,7 @@ One-time Vercel dashboard setup:
85144
86145- Workflow: `.github/workflows/release.yml`
87146- Triggers:
88 - scheduled every day at `09:00 UTC`
147 - scheduled check every three hours
89148 - manual `workflow_dispatch` with `channel=nightly`
90149- Runs the same desktop quality gates and artifact matrix as the tagged release flow.
91150- 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";
2626export * from "./archivedThreadsState.ts";
2727export * from "./checkpointDiffState.ts";
2828export * 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";
88import { EnvironmentAuthInvalidError } from "@t3tools/contracts";
99import {
1010 bootstrapRemoteBearerSession,
11 exchangeRemoteDpopAccessToken,
12 fetchRemoteDpopSessionState,
1113 fetchRemoteEnvironmentDescriptor,
1214 fetchRemoteSessionState,
15 issueRemoteDpopWebSocketTicket,
1316 issueRemoteWebSocketTicket,
1417 remoteHttpClientLayer,
1518 RemoteEnvironmentAuthInvalidJsonError,
......@@ -123,6 +126,55 @@ describe("remote", () => {
123126 }),
124127 );
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
126178 it.effect("submits optional client display metadata during bearer token exchange", () =>
127179 Effect.gen(function* () {
128180 const fetch = recordedFetch(
......@@ -186,7 +238,7 @@ describe("remote", () => {
186238 }),
187239 );
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", () =>
190242 Effect.gen(function* () {
191243 const fetch = recordedFetch(
192244 Response.json(
......@@ -257,11 +309,11 @@ describe("remote", () => {
257309 ],
258310 });
259311
260 const token = yield* issueRemoteWebSocketTicket({
312 const ticket = yield* issueRemoteWebSocketTicket({
261313 httpBaseUrl: "https://remote.example.com/",
262314 bearerToken: "bearer-token",
263315 }).pipe(provideRemoteHttp(fetch.fetchFn));
264 expect(token).toMatchObject({
316 expect(ticket).toMatchObject({
265317 ticket: "ws-ticket",
266318 });
267319
......@@ -286,6 +338,45 @@ describe("remote", () => {
286338 }),
287339 );
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
289380 it.effect("fails hung fetch requests on the configured timeout", () =>
290381 Effect.gen(function* () {
291382 const fetch = hangingFetch();
......@@ -360,27 +451,25 @@ describe("remote", () => {
360451 }),
361452 );
362453
363 it.effect(
364 "mints a websocket url that targets the rpc route with a short-lived websocket ticket",
365 () =>
366 Effect.gen(function* () {
367 const fetch = recordedFetch(
368 Response.json(
369 {
370 ticket: "ws-ticket",
371 expiresAt: "2026-05-01T12:05:00.000Z",
372 },
373 { status: 200 },
374 ),
375 );
376
377 const url = yield* resolveRemoteWebSocketConnectionUrl({
378 wsBaseUrl: "wss://remote.example.com/",
379 httpBaseUrl: "https://remote.example.com/",
380 bearerToken: "bearer-token",
381 }).pipe(provideRemoteHttp(fetch.fetchFn));
382
383 expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket");
384 }),
454 it.effect("mints a websocket url that targets the rpc route with a short-lived ticket", () =>
455 Effect.gen(function* () {
456 const fetch = recordedFetch(
457 Response.json(
458 {
459 ticket: "ws-ticket",
460 expiresAt: "2026-05-01T12:05:00.000Z",
461 },
462 { status: 200 },
463 ),
464 );
465
466 const url = yield* resolveRemoteWebSocketConnectionUrl({
467 wsBaseUrl: "wss://remote.example.com/",
468 httpBaseUrl: "https://remote.example.com/",
469 bearerToken: "bearer-token",
470 }).pipe(provideRemoteHttp(fetch.fetchFn));
471
472 expect(url).toBe("wss://remote.example.com/ws?wsTicket=ws-ticket");
473 }),
385474 );
386475});
packages/client-runtime/src/remote.ts+108-13
......@@ -7,7 +7,6 @@ import {
77 EnvironmentHttpApi,
88 EnvironmentHttpCommonError,
99} from "@t3tools/contracts";
10import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
1110import type {
1211 EnvironmentAuthInvalidError,
1312 EnvironmentInternalError,
......@@ -15,6 +14,7 @@ import type {
1514 EnvironmentRequestInvalidError,
1615 EnvironmentScopeRequiredError,
1716} from "@t3tools/contracts";
17import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
1818import * as Data from "effect/Data";
1919import * as Duration from "effect/Duration";
2020import * as Effect from "effect/Effect";
......@@ -27,7 +27,7 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
2727const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000;
2828const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
2929
30const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => {
30export const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => {
3131 const url = new URL(httpBaseUrl);
3232 url.pathname = pathname;
3333 url.search = "";
......@@ -43,6 +43,14 @@ const remoteApiBaseUrl = (httpBaseUrl: string): string => {
4343 return url.toString();
4444};
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
4654export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(
4755 "RemoteEnvironmentAuthFetchError",
4856)<{
......@@ -162,14 +170,39 @@ const executeRemoteRequest = <A, E, R>(
162170 );
163171
164172export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) =>
165 Effect.gen(function* () {
166 const httpClient = yield* HttpClient.HttpClient;
167 return yield* HttpApiClient.makeWith(EnvironmentHttpApi, {
168 httpClient,
169 baseUrl: remoteApiBaseUrl(httpBaseUrl),
170 });
173 HttpApiClient.make(EnvironmentHttpApi, {
174 baseUrl: remoteApiBaseUrl(httpBaseUrl),
171175 });
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
173206export const bootstrapRemoteBearerSession = Effect.fn(
174207 "clientRuntime.remote.bootstrapRemoteBearerSession",
175208)(function* (input: {
......@@ -184,17 +217,14 @@ export const bootstrapRemoteBearerSession = Effect.fn(
184217 remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"),
185218 input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS,
186219 client.auth.token({
220 headers: {},
187221 payload: {
188222 grant_type: AuthTokenExchangeGrantType,
189223 subject_token: input.credential,
190224 subject_token_type: AuthEnvironmentBootstrapTokenType,
191225 requested_token_type: AuthAccessTokenType,
192226 ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}),
193 ...(input.clientMetadata?.label ? { client_label: input.clientMetadata.label } : {}),
194 ...(input.clientMetadata?.deviceType
195 ? { client_device_type: input.clientMetadata.deviceType }
196 : {}),
197 ...(input.clientMetadata?.os ? { client_os: input.clientMetadata.os } : {}),
227 ...clientMetadataTokenExchangeFields(input.clientMetadata),
198228 },
199229 }),
200230 );
......@@ -219,6 +249,27 @@ export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemo
219249 },
220250);
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
222273export const fetchRemoteEnvironmentDescriptor = Effect.fn(
223274 "clientRuntime.remote.fetchRemoteEnvironmentDescriptor",
224275)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) {
......@@ -249,6 +300,27 @@ export const issueRemoteWebSocketTicket = Effect.fn(
249300 );
250301});
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
252324export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
253325 "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl",
254326)(function* (input: {
......@@ -270,3 +342,26 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
270342 url.searchParams.set("wsTicket", issued.ticket);
271343 return url.toString();
272344});
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", () => {
1111 expect(isTransportConnectionErrorMessage("SocketOpenError: ECONNREFUSED")).toBe(true);
1212 });
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
1422 it("returns true for the T3 server WebSocket message", () => {
1523 expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe(
1624 true,
packages/client-runtime/src/transportError.ts+1
......@@ -1,6 +1,7 @@
11const TRANSPORT_ERROR_PATTERNS = [
22 /\bSocketCloseError\b/i,
33 /\bSocketOpenError\b/i,
4 /\bSocket is not connected\b/i,
45 /Unable to connect to the T3 server WebSocket\./i,
56 /\bping timeout\b/i,
67] as const;
packages/client-runtime/src/wsRpcClient.ts+28
......@@ -4,6 +4,8 @@ import {
44 type GitRunStackedActionResult,
55 type LocalApi,
66 ORCHESTRATION_WS_METHODS,
7 type RelayClientInstallProgressEvent,
8 type RelayClientStatus,
79 type ServerSettingsPatch,
810 type VcsStatusResult,
911 type VcsStatusStreamEvent,
......@@ -150,6 +152,12 @@ export interface WsRpcClient {
150152 >;
151153 readonly signalProcess: RpcUnaryMethod<typeof WS_METHODS.serverSignalProcess>;
152154 };
155 readonly cloud: {
156 readonly getRelayClientStatus: RpcUnaryNoArgMethod<typeof WS_METHODS.cloudGetRelayClientStatus>;
157 readonly installRelayClient: (
158 onProgress?: (event: RelayClientInstallProgressEvent) => void,
159 ) => Promise<RelayClientStatus>;
160 };
153161 readonly orchestration: {
154162 readonly dispatchCommand: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.dispatchCommand>;
155163 readonly getTurnDiff: RpcUnaryMethod<typeof ORCHESTRATION_WS_METHODS.getTurnDiff>;
......@@ -320,6 +328,26 @@ export function createWsRpcClient(
320328 signalProcess: (input) =>
321329 transport.request((client) => client[WS_METHODS.serverSignalProcess](input)),
322330 },
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 },
323351 orchestration: {
324352 dispatchCommand: (input) =>
325353 transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)),
packages/client-runtime/src/wsTransport.test.ts+2-2
......@@ -788,7 +788,7 @@ describe("WsTransport", () => {
788788 () =>
789789 Stream.suspend(() => {
790790 attempts += 1;
791 return Stream.fail(new Error("SocketCloseError: WebSocket closed"));
791 return Stream.fail(new Error("Socket is not connected"));
792792 }),
793793 vi.fn(),
794794 { retryDelay: 10 },
......@@ -805,7 +805,7 @@ describe("WsTransport", () => {
805805 });
806806
807807 expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", {
808 error: "SocketCloseError: WebSocket closed",
808 error: "Socket is not connected",
809809 });
810810
811811 unsubscribe();
packages/contracts/package.json+4
......@@ -14,6 +14,10 @@
1414 "./settings": {
1515 "types": "./src/settings.ts",
1616 "import": "./src/settings.ts"
17 },
18 "./relay": {
19 "types": "./src/relay.ts",
20 "import": "./src/relay.ts"
1721 }
1822 },
1923 "scripts": {
packages/contracts/src/auth.ts+4-1
......@@ -63,10 +63,13 @@ export type ServerAuthBootstrapMethod = typeof ServerAuthBootstrapMethod.Type;
6363 * app after bootstrap/pairing
6464 * - `bearer-access-token`: scoped token suitable for non-cookie or
6565 * non-browser clients
66 * - `dpop-access-token`: scoped proof-of-possession token used by managed
67 * relay connections
6668 */
6769export const ServerAuthSessionMethod = Schema.Literals([
6870 "browser-session-cookie",
6971 "bearer-access-token",
72 "dpop-access-token",
7073]);
7174export type ServerAuthSessionMethod = typeof ServerAuthSessionMethod.Type;
7275
......@@ -184,7 +187,7 @@ export type AuthTokenExchangeRequest = typeof AuthTokenExchangeRequest.Type;
184187export const AuthAccessTokenResult = Schema.Struct({
185188 access_token: TrimmedNonEmptyString,
186189 issued_token_type: Schema.Literal(AuthAccessTokenType),
187 token_type: Schema.Literal("Bearer"),
190 token_type: Schema.Literals(["Bearer", "DPoP"]),
188191 expires_in: Schema.Number,
189192 scope: TrimmedNonEmptyString,
190193});
packages/contracts/src/editor.ts+1-1
......@@ -54,6 +54,6 @@ export class ExternalLauncherError extends Schema.TaggedErrorClass<ExternalLaunc
5454 "ExternalLauncherError",
5555 {
5656 message: Schema.String,
57 cause: Schema.optional(Schema.Defect),
57 cause: Schema.optional(Schema.Defect()),
5858 },
5959) {}
packages/contracts/src/environmentHttp.ts+183-1
......@@ -31,9 +31,23 @@ import {
3131 DispatchResult,
3232 OrchestrationReadModel,
3333} from "./orchestration.ts";
34import {
35 RelayCloudEnvironmentHealthRequest,
36 RelayCloudMintCredentialRequest,
37 RelayEnvironmentConfigRequest,
38 RelayEnvironmentHealthResponse,
39 RelayEnvironmentLinkProof,
40 RelayEnvironmentMintResponse,
41 RelayLinkProofRequest,
42} from "./relay.ts";
3443
3544const OptionalBearerHeaders = Schema.Struct({
3645 authorization: Schema.optionalKey(Schema.String),
46 dpop: Schema.optionalKey(Schema.String),
47});
48
49const OptionalDpopProofHeaders = Schema.Struct({
50 dpop: Schema.optionalKey(Schema.String),
3751});
3852
3953export const EnvironmentRequestInvalidReason = Schema.Literals([
......@@ -154,6 +168,81 @@ const EnvironmentAuthenticationErrors = [
154168 EnvironmentAuthInvalidError,
155169 EnvironmentInternalError,
156170] 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}
157246const EnvironmentSessionCreationErrors = [
158247 EnvironmentAuthInvalidError,
159248 EnvironmentInternalError,
......@@ -191,6 +280,7 @@ export interface EnvironmentSessionPrincipalShape {
191280 readonly subject: string;
192281 readonly method: ServerAuthSessionMethod;
193282 readonly scopes: ReadonlySet<AuthEnvironmentScope>;
283 readonly proofKeyThumbprint?: string;
194284 readonly expiresAt?: DateTime.DateTime;
195285}
196286
......@@ -206,6 +296,35 @@ export class EnvironmentAuthenticatedAuth extends HttpApiMiddleware.Service<
206296 error: EnvironmentAuthenticationErrors,
207297}) {}
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
209328export const AuthPairingLinkRevokeResult = Schema.Struct({
210329 revoked: Schema.Boolean,
211330});
......@@ -244,6 +363,7 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth")
244363 )
245364 .add(
246365 HttpApiEndpoint.post("token", "/oauth/token", {
366 headers: OptionalDpopProofHeaders,
247367 payload: AuthTokenExchangeRequest,
248368 success: AuthAccessTokenResult,
249369 error: EnvironmentTokenExchangeErrors,
......@@ -319,7 +439,69 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr
319439 }).middleware(EnvironmentAuthenticatedAuth),
320440 ) {}
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
322503export class EnvironmentHttpApi extends HttpApi.make("environment")
323504 .add(EnvironmentMetadataHttpApi)
324505 .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
2525 "FilesystemBrowseError",
2626 {
2727 message: TrimmedNonEmptyString,
28 cause: Schema.optional(Schema.Defect),
28 cause: Schema.optional(Schema.Defect()),
2929 },
3030) {}
packages/contracts/src/git.ts+3-3
......@@ -322,7 +322,7 @@ export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()(
322322 command: Schema.String,
323323 cwd: Schema.String,
324324 detail: Schema.String,
325 cause: Schema.optional(Schema.Defect),
325 cause: Schema.optional(Schema.Defect()),
326326}) {
327327 override get message(): string {
328328 return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`;
......@@ -334,7 +334,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationE
334334 {
335335 operation: Schema.String,
336336 detail: Schema.String,
337 cause: Schema.optional(Schema.Defect),
337 cause: Schema.optional(Schema.Defect()),
338338 },
339339) {
340340 override get message(): string {
......@@ -345,7 +345,7 @@ export class TextGenerationError extends Schema.TaggedErrorClass<TextGenerationE
345345export class GitManagerError extends Schema.TaggedErrorClass<GitManagerError>()("GitManagerError", {
346346 operation: Schema.String,
347347 detail: Schema.String,
348 cause: Schema.optional(Schema.Defect),
348 cause: Schema.optional(Schema.Defect()),
349349}) {
350350 override get message(): string {
351351 return `Git manager failed in ${this.operation}: ${this.detail}`;
packages/contracts/src/index.ts+1
......@@ -2,6 +2,7 @@ export * from "./baseSchemas.ts";
22export * from "./auth.ts";
33export * from "./environment.ts";
44export * from "./environmentHttp.ts";
5export * from "./relayClient.ts";
56export * from "./desktopBootstrap.ts";
67export * from "./remoteAccess.ts";
78export * from "./ipc.ts";
packages/contracts/src/ipc.ts+28
......@@ -338,6 +338,11 @@ export const PersistedSavedEnvironmentRecordSchema = Schema.Struct({
338338 createdAt: Schema.String,
339339 lastConnectedAt: Schema.NullOr(Schema.String),
340340 desktopSsh: Schema.optionalKey(DesktopSshEnvironmentTargetSchema),
341 relayManaged: Schema.optionalKey(
342 Schema.Struct({
343 relayUrl: Schema.String,
344 }),
345 ),
341346});
342347export type PersistedSavedEnvironmentRecord = typeof PersistedSavedEnvironmentRecordSchema.Type;
343348
......@@ -372,6 +377,23 @@ export const PickFolderOptionsSchema = Schema.Struct({
372377 initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)),
373378});
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
375397export interface DesktopBridge {
376398 getAppBranding: () => DesktopAppBranding | null;
377399 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
......@@ -417,6 +439,12 @@ export interface DesktopBridge {
417439 position?: { x: number; y: number },
418440 ) => Promise<T | null>;
419441 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;
420448 onMenuAction: (listener: (action: string) => void) => () => void;
421449 getUpdateState: () => Promise<DesktopUpdateState>;
422450 setUpdateChannel: (channel: DesktopUpdateChannel) => Promise<DesktopUpdateState>;
packages/contracts/src/keybindings.ts+1-1
......@@ -153,7 +153,7 @@ export class KeybindingsConfigError extends Schema.TaggedErrorClass<KeybindingsC
153153 {
154154 configPath: Schema.String,
155155 detail: Schema.String,
156 cause: Schema.optional(Schema.Defect),
156 cause: Schema.optional(Schema.Defect()),
157157 },
158158) {
159159 override get message(): string {
packages/contracts/src/orchestration.ts+5-5
......@@ -1241,7 +1241,7 @@ export class OrchestrationGetSnapshotError extends Schema.TaggedErrorClass<Orche
12411241 "OrchestrationGetSnapshotError",
12421242 {
12431243 message: TrimmedNonEmptyString,
1244 cause: Schema.optional(Schema.Defect),
1244 cause: Schema.optional(Schema.Defect()),
12451245 },
12461246) {}
12471247
......@@ -1249,7 +1249,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass<O
12491249 "OrchestrationDispatchCommandError",
12501250 {
12511251 message: TrimmedNonEmptyString,
1252 cause: Schema.optional(Schema.Defect),
1252 cause: Schema.optional(Schema.Defect()),
12531253 },
12541254) {}
12551255
......@@ -1257,7 +1257,7 @@ export class OrchestrationGetTurnDiffError extends Schema.TaggedErrorClass<Orche
12571257 "OrchestrationGetTurnDiffError",
12581258 {
12591259 message: TrimmedNonEmptyString,
1260 cause: Schema.optional(Schema.Defect),
1260 cause: Schema.optional(Schema.Defect()),
12611261 },
12621262) {}
12631263
......@@ -1265,7 +1265,7 @@ export class OrchestrationGetFullThreadDiffError extends Schema.TaggedErrorClass
12651265 "OrchestrationGetFullThreadDiffError",
12661266 {
12671267 message: TrimmedNonEmptyString,
1268 cause: Schema.optional(Schema.Defect),
1268 cause: Schema.optional(Schema.Defect()),
12691269 },
12701270) {}
12711271
......@@ -1273,6 +1273,6 @@ export class OrchestrationReplayEventsError extends Schema.TaggedErrorClass<Orch
12731273 "OrchestrationReplayEventsError",
12741274 {
12751275 message: TrimmedNonEmptyString,
1276 cause: Schema.optional(Schema.Defect),
1276 cause: Schema.optional(Schema.Defect()),
12771277 },
12781278) {}
packages/contracts/src/project.ts+2-2
......@@ -30,7 +30,7 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSe
3030 "ProjectSearchEntriesError",
3131 {
3232 message: TrimmedNonEmptyString,
33 cause: Schema.optional(Schema.Defect),
33 cause: Schema.optional(Schema.Defect()),
3434 },
3535) {}
3636
......@@ -50,6 +50,6 @@ export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteF
5050 "ProjectWriteFileError",
5151 {
5252 message: TrimmedNonEmptyString,
53 cause: Schema.optional(Schema.Defect),
53 cause: Schema.optional(Schema.Defect()),
5454 },
5555) {}
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 {
5858 OrchestrationRpcSchemas,
5959} from "./orchestration.ts";
6060import { ProviderInstanceId } from "./providerInstance.ts";
61import {
62 RelayClientInstallFailedError,
63 RelayClientInstallProgressEventSchema,
64 RelayClientStatusSchema,
65} from "./relayClient.ts";
6166import {
6267 ProjectSearchEntriesError,
6368 ProjectSearchEntriesInput,
......@@ -166,6 +171,10 @@ export const WS_METHODS = {
166171 serverGetProcessResourceHistory: "server.getProcessResourceHistory",
167172 serverSignalProcess: "server.signalProcess",
168173
174 // Cloud environment methods
175 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
176 cloudInstallRelayClient: "cloud.installRelayClient",
177
169178 // Source control methods
170179 sourceControlLookupRepository: "sourceControl.lookupRepository",
171180 sourceControlCloneRepository: "sourceControl.cloneRepository",
......@@ -263,6 +272,19 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess,
263272 error: EnvironmentAuthorizationError,
264273});
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
266288export const WsSourceControlLookupRepositoryRpc = Rpc.make(
267289 WS_METHODS.sourceControlLookupRepository,
268290 {
......@@ -536,6 +558,8 @@ export const WsRpcGroup = RpcGroup.make(
536558 WsServerGetProcessDiagnosticsRpc,
537559 WsServerGetProcessResourceHistoryRpc,
538560 WsServerSignalProcessRpc,
561 WsCloudGetRelayClientStatusRpc,
562 WsCloudInstallRelayClientRpc,
539563 WsSourceControlLookupRepositoryRpc,
540564 WsSourceControlCloneRepositoryRpc,
541565 WsSourceControlPublishRepositoryRpc,
packages/contracts/src/server.ts+1-1
......@@ -550,7 +550,7 @@ export class ServerProviderUpdateError extends Schema.TaggedErrorClass<ServerPro
550550 {
551551 provider: ProviderDriverKind,
552552 reason: TrimmedNonEmptyString,
553 cause: Schema.optional(Schema.Defect),
553 cause: Schema.optional(Schema.Defect()),
554554 },
555555) {
556556 override get message(): string {
packages/contracts/src/settings.ts+1-1
......@@ -390,7 +390,7 @@ export class ServerSettingsError extends Schema.TaggedErrorClass<ServerSettingsE
390390 {
391391 settingsPath: Schema.String,
392392 detail: Schema.String,
393 cause: Schema.optional(Schema.Defect),
393 cause: Schema.optional(Schema.Defect()),
394394 },
395395) {
396396 override get message(): string {
packages/contracts/src/sourceControl.ts+2-2
......@@ -156,7 +156,7 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceCo
156156 provider: SourceControlProviderKind,
157157 operation: Schema.String,
158158 detail: Schema.String,
159 cause: Schema.optional(Schema.Defect),
159 cause: Schema.optional(Schema.Defect()),
160160 },
161161) {
162162 override get message(): string {
......@@ -170,7 +170,7 @@ export class SourceControlRepositoryError extends Schema.TaggedErrorClass<Source
170170 provider: SourceControlProviderKind,
171171 operation: Schema.String,
172172 detail: Schema.String,
173 cause: Schema.optional(Schema.Defect),
173 cause: Schema.optional(Schema.Defect()),
174174 },
175175) {
176176 override get message(): string {
packages/contracts/src/terminal.ts+2-2
......@@ -237,7 +237,7 @@ export class TerminalCwdError extends Schema.TaggedErrorClass<TerminalCwdError>(
237237 {
238238 cwd: Schema.String,
239239 reason: Schema.Literals(["notFound", "notDirectory", "statFailed"]),
240 cause: Schema.optional(Schema.Defect),
240 cause: Schema.optional(Schema.Defect()),
241241 },
242242) {
243243 override get message() {
......@@ -266,7 +266,7 @@ export class TerminalHistoryError extends Schema.TaggedErrorClass<TerminalHistor
266266 operation: Schema.Literals(["read", "truncate", "migrate"]),
267267 threadId: Schema.String,
268268 terminalId: Schema.String,
269 cause: Schema.optional(Schema.Defect),
269 cause: Schema.optional(Schema.Defect()),
270270 },
271271) {
272272 override get message() {
packages/contracts/src/vcs.ts+3-3
......@@ -92,7 +92,7 @@ export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpaw
9292 operation: Schema.String,
9393 command: Schema.String,
9494 cwd: Schema.String,
95 cause: Schema.Defect,
95 cause: Schema.Defect(),
9696 },
9797) {
9898 override get message(): string {
......@@ -150,7 +150,7 @@ export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecod
150150 command: Schema.String,
151151 cwd: Schema.String,
152152 detail: Schema.String,
153 cause: Schema.optional(Schema.Defect),
153 cause: Schema.optional(Schema.Defect()),
154154 },
155155) {
156156 override get message(): string {
......@@ -200,7 +200,7 @@ export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepo
200200 operation: Schema.String,
201201 cwd: Schema.String,
202202 detail: Schema.String,
203 cause: Schema.optional(Schema.Defect),
203 cause: Schema.optional(Schema.Defect()),
204204 },
205205) {
206206 override get message(): string {
packages/effect-acp/src/errors.ts+4-4
......@@ -4,7 +4,7 @@ import * as AcpSchema from "./_generated/schema.gen.ts";
44
55export class AcpSpawnError extends Schema.TaggedErrorClass<AcpSpawnError>()("AcpSpawnError", {
66 command: Schema.optional(Schema.String),
7 cause: Schema.Defect,
7 cause: Schema.Defect(),
88}) {
99 override get message() {
1010 return this.command
......@@ -17,7 +17,7 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass<AcpProcessExi
1717 "AcpProcessExitedError",
1818 {
1919 code: Schema.optional(Schema.Number),
20 cause: Schema.optional(Schema.Defect),
20 cause: Schema.optional(Schema.Defect()),
2121 },
2222) {
2323 override get message() {
......@@ -31,7 +31,7 @@ export class AcpProtocolParseError extends Schema.TaggedErrorClass<AcpProtocolPa
3131 "AcpProtocolParseError",
3232 {
3333 detail: Schema.String,
34 cause: Schema.optional(Schema.Defect),
34 cause: Schema.optional(Schema.Defect()),
3535 },
3636) {
3737 override get message() {
......@@ -43,7 +43,7 @@ export class AcpTransportError extends Schema.TaggedErrorClass<AcpTransportError
4343 "AcpTransportError",
4444 {
4545 detail: Schema.String,
46 cause: Schema.Defect,
46 cause: Schema.Defect(),
4747 },
4848) {}
4949
packages/effect-codex-app-server/scripts/generate.ts+1-1
......@@ -63,7 +63,7 @@ interface JsonSchemaFile {
6363
6464class GeneratorError extends Schema.TaggedErrorClass<GeneratorError>()("GeneratorError", {
6565 detail: Schema.String,
66 cause: Schema.optional(Schema.Defect),
66 cause: Schema.optional(Schema.Defect()),
6767}) {
6868 override get message() {
6969 return this.detail;
packages/effect-codex-app-server/src/errors.ts+4-4
......@@ -10,7 +10,7 @@ export class CodexAppServerSpawnError extends Schema.TaggedErrorClass<CodexAppSe
1010 "CodexAppServerSpawnError",
1111 {
1212 command: Schema.optional(Schema.String),
13 cause: Schema.Defect,
13 cause: Schema.Defect(),
1414 },
1515) {
1616 override get message() {
......@@ -24,7 +24,7 @@ export class CodexAppServerProcessExitedError extends Schema.TaggedErrorClass<Co
2424 "CodexAppServerProcessExitedError",
2525 {
2626 code: Schema.optional(Schema.Number),
27 cause: Schema.optional(Schema.Defect),
27 cause: Schema.optional(Schema.Defect()),
2828 },
2929) {
3030 override get message() {
......@@ -38,7 +38,7 @@ export class CodexAppServerProtocolParseError extends Schema.TaggedErrorClass<Co
3838 "CodexAppServerProtocolParseError",
3939 {
4040 detail: Schema.String,
41 cause: Schema.optional(Schema.Defect),
41 cause: Schema.optional(Schema.Defect()),
4242 },
4343) {
4444 override get message() {
......@@ -50,7 +50,7 @@ export class CodexAppServerTransportError extends Schema.TaggedErrorClass<CodexA
5050 "CodexAppServerTransportError",
5151 {
5252 detail: Schema.String,
53 cause: Schema.Defect,
53 cause: Schema.Defect(),
5454 },
5555) {
5656 override get message() {
packages/shared/package.json+35
......@@ -11,6 +11,10 @@
1111 "types": "./src/advertisedEndpoint.ts",
1212 "import": "./src/advertisedEndpoint.ts"
1313 },
14 "./agentAwareness": {
15 "types": "./src/agentAwareness.ts",
16 "import": "./src/agentAwareness.ts"
17 },
1418 "./git": {
1519 "types": "./src/git.ts",
1620 "import": "./src/git.ts"
......@@ -83,6 +87,30 @@
8387 "types": "./src/remote.ts",
8488 "import": "./src/remote.ts"
8589 },
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 },
86114 "./oauthScope": {
87115 "types": "./src/oauthScope.ts",
88116 "import": "./src/oauthScope.ts"
......@@ -114,6 +142,10 @@
114142 "./terminalLabels": {
115143 "types": "./src/terminalLabels.ts",
116144 "import": "./src/terminalLabels.ts"
145 },
146 "./relayClient": {
147 "types": "./src/relayClient.ts",
148 "import": "./src/relayClient.ts"
117149 }
118150 },
119151 "scripts": {
......@@ -121,8 +153,11 @@
121153 "test": "vp test run"
122154 },
123155 "dependencies": {
156 "@noble/curves": "catalog:",
157 "@noble/hashes": "catalog:",
124158 "@t3tools/contracts": "workspace:*",
125159 "effect": "catalog:",
160 "jose": "catalog:",
126161 "yaml": "catalog:"
127162 },
128163 "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 @@
11const OAUTH_SCOPE_TOKEN = /^[\u0021\u0023-\u005b\u005d-\u007e]+$/u;
22
33/**
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.
56 */
67export function parseOAuthScope(value: string): ReadonlyArray<string> | null {
78 if (value.length === 0) {
......@@ -25,6 +26,15 @@ export function encodeOAuthScope(scopes: ReadonlyArray<string>): string {
2526 return encoded;
2627}
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
2838export function parseAllowedOAuthScope<Scope extends string>(input: {
2939 readonly value: string;
3040 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";
88import * as Result from "effect/Result";
99import * as Sink from "effect/Sink";
1010import * as Stream from "effect/Stream";
11import * as TestClock from "effect/testing/TestClock";
11import { TestClock } from "effect/testing";
1212import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1313import { 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:
77catalogs:
88 default:
99 '@effect/openapi-generator':
10 specifier: 4.0.0-beta.73
11 version: 4.0.0-beta.73
10 specifier: 4.0.0-beta.78
11 version: 4.0.0-beta.78
1212 '@effect/tsgo':
1313 specifier: 0.11.4
1414 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
1521 '@pierre/diffs':
1622 specifier: 1.1.20
1723 version: 1.1.20
......@@ -24,6 +30,9 @@ catalogs:
2430 '@vitest/runner':
2531 specifier: ^4.1.8
2632 version: 4.1.8
33 jose:
34 specifier: 6.2.2
35 version: 6.2.2
2736 typescript:
2837 specifier: ~6.0.3
2938 version: 6.0.3
......@@ -32,14 +41,21 @@ catalogs:
3241 version: 0.1.24
3342
3443overrides:
35 '@effect/atom-react': 4.0.0-beta.73
36 '@effect/platform-bun': 4.0.0-beta.73
37 '@effect/platform-node': 4.0.0-beta.73
38 '@effect/platform-node-shared': 4.0.0-beta.73
39 '@effect/sql-sqlite-bun': 4.0.0-beta.73
40 '@effect/vitest': 4.0.0-beta.73
44 '@clerk/clerk-js>@base-org/account': '-'
45 '@clerk/clerk-js>@coinbase/wallet-sdk': '-'
46 '@clerk/clerk-js>@solana/wallet-adapter-base': '-'
47 '@clerk/clerk-js>@solana/wallet-adapter-react': '-'
48 '@clerk/clerk-js>@solana/wallet-standard': '-'
49 '@clerk/clerk-js>@wallet-standard/core': '-'
50 '@effect/atom-react': 4.0.0-beta.78
51 '@effect/platform-bun': 4.0.0-beta.78
52 '@effect/platform-node': 4.0.0-beta.78
53 '@effect/platform-node-shared': 4.0.0-beta.78
54 '@effect/sql-pg': 4.0.0-beta.78
55 '@effect/sql-sqlite-bun': 4.0.0-beta.78
56 '@effect/vitest': 4.0.0-beta.78
4157 '@types/node': 24.12.4
42 effect: 4.0.0-beta.73
58 effect: 4.0.0-beta.78
4359 vite: npm:@voidzero-dev/vite-plus-core@latest
4460 vitest: npm:@voidzero-dev/vite-plus-test@latest
4561 yaml: ^2.9.0
......@@ -53,9 +69,9 @@ patchedDependencies:
5369 '@pierre/diffs@1.1.20':
5470 hash: e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0
5571 path: patches/@pierre%2Fdiffs@1.1.20.patch
56 effect@4.0.0-beta.73:
57 hash: a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01
58 path: patches/effect@4.0.0-beta.73.patch
72 effect@4.0.0-beta.78:
73 hash: 883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754
74 path: patches/effect@4.0.0-beta.78.patch
5975 react-native-nitro-modules@0.35.9:
6076 hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
6177 path: patches/react-native-nitro-modules@0.35.9.patch
......@@ -87,16 +103,16 @@ importers:
87103 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)'
88104 vite-plus:
89105 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)
91107 vitest:
92108 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
95111 apps/desktop:
96112 dependencies:
97113 '@effect/platform-node':
98 specifier: 4.0.0-beta.73
99 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
114 specifier: 4.0.0-beta.78
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)
100116 '@t3tools/client-runtime':
101117 specifier: workspace:*
102118 version: link:../../packages/client-runtime
......@@ -113,8 +129,8 @@ importers:
113129 specifier: workspace:*
114130 version: link:../../packages/tailscale
115131 effect:
116 specifier: 4.0.0-beta.73
117 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
132 specifier: 4.0.0-beta.78
133 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
118134 electron:
119135 specifier: 41.5.0
120136 version: 41.5.0
......@@ -123,8 +139,8 @@ importers:
123139 version: 6.8.3
124140 devDependencies:
125141 '@effect/vitest':
126 specifier: 4.0.0-beta.73
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))
142 specifier: 4.0.0-beta.78
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))
128144 '@types/node':
129145 specifier: 24.12.4
130146 version: 24.12.4
......@@ -136,16 +152,16 @@ importers:
136152 version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
137153 vite-plus:
138154 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)
140156 vitest:
141157 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
144160 apps/marketing:
145161 dependencies:
146162 astro:
147163 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)
149165 devDependencies:
150166 '@astrojs/check':
151167 specifier: ^0.9.7
......@@ -161,25 +177,34 @@ importers:
161177 dependencies:
162178 '@callstack/liquid-glass':
163179 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)
165184 '@effect/atom-react':
166 specifier: 4.0.0-beta.73
167 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(react@19.2.3)(scheduler@0.27.0)
185 specifier: 4.0.0-beta.78
186 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0)
168187 '@expo-google-fonts/dm-sans':
169188 specifier: ^0.4.2
170189 version: 0.4.2
171190 '@expo/ui':
172191 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)
174193 '@legendapp/list':
175194 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
177202 '@pierre/diffs':
178203 specifier: 'catalog:'
179204 version: 1.1.20(patch_hash=e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
180205 '@react-native-menu/menu':
181206 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)
183208 '@shikijs/core':
184209 specifier: 3.23.0
185210 version: 3.23.0
......@@ -214,38 +239,41 @@ importers:
214239 specifier: 8.0.3
215240 version: 8.0.3
216241 effect:
217 specifier: 4.0.0-beta.73
218 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
242 specifier: 4.0.0-beta.78
243 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
219244 expo:
220245 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)
222250 expo-build-properties:
223251 specifier: ~56.0.15
224252 version: 56.0.16(expo@56.0.8)
225253 expo-camera:
226254 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)
228256 expo-clipboard:
229257 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)
231259 expo-constants:
232260 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))
234262 expo-crypto:
235263 specifier: ~56.0.4
236264 version: 56.0.4(expo@56.0.8)
237265 expo-dev-client:
238266 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))
240268 expo-file-system:
241269 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))
243271 expo-font:
244272 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)
246274 expo-glass-effect:
247275 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)
249277 expo-haptics:
250278 specifier: ~56.0.3
251279 version: 56.0.3(expo@56.0.8)
......@@ -254,13 +282,16 @@ importers:
254282 version: 56.0.15(expo@56.0.8)
255283 expo-linking:
256284 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)
258289 expo-paste-input:
259290 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)
261292 expo-router:
262293 specifier: ~56.2.7
263 version: 56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51)
294 version: 56.2.8(c021de11d02907bd585610408f5252e8)
264295 expo-secure-store:
265296 specifier: ~56.0.4
266297 version: 56.0.4(expo@56.0.8)
......@@ -269,10 +300,16 @@ importers:
269300 version: 56.0.10(expo@56.0.8)(typescript@6.0.3)
270301 expo-symbols:
271302 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)
273304 expo-updates:
274305 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)
276313 punycode:
277314 specifier: ^2.3.1
278315 version: 2.3.1
......@@ -284,40 +321,40 @@ importers:
284321 version: 19.2.3(react@19.2.3)
285322 react-native:
286323 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)
288325 react-native-gesture-handler:
289326 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)
291328 react-native-image-viewing:
292329 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)
294331 react-native-keyboard-controller:
295332 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)
297334 react-native-nitro-markdown:
298335 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)
300337 react-native-nitro-modules:
301338 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)
303340 react-native-reanimated:
304341 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)
306343 react-native-safe-area-context:
307344 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)
309346 react-native-screens:
310347 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)
312349 react-native-shiki-engine:
313350 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)
315352 react-native-svg:
316353 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)
318355 react-native-worklets:
319356 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)
321358 shiki:
322359 specifier: 3.23.0
323360 version: 3.23.0
......@@ -326,14 +363,17 @@ importers:
326363 version: 3.6.0
327364 uniwind:
328365 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)
330367 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))
331371 '@types/react':
332372 specifier: ~19.2.0
333373 version: 19.2.16
334374 babel-preset-expo:
335375 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)
337377 tailwindcss:
338378 specifier: ^4.0.0
339379 version: 4.3.0
......@@ -347,17 +387,17 @@ importers:
347387 specifier: ^0.3.154
348388 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)
349389 '@effect/platform-bun':
350 specifier: 4.0.0-beta.73
351 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))
390 specifier: 4.0.0-beta.78
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)
352392 '@effect/platform-node':
353 specifier: 4.0.0-beta.73
354 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
393 specifier: 4.0.0-beta.78
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)
355395 '@effect/platform-node-shared':
356 specifier: 4.0.0-beta.73
357 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))
396 specifier: 4.0.0-beta.78
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)
358398 '@effect/sql-sqlite-bun':
359 specifier: 4.0.0-beta.73
360 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))
399 specifier: 4.0.0-beta.78
400 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
361401 '@opencode-ai/sdk':
362402 specifier: ^1.3.15
363403 version: 1.15.13
......@@ -365,15 +405,15 @@ importers:
365405 specifier: 'catalog:'
366406 version: 1.1.20(patch_hash=e4e35ba95100de3708f900e0d9ea62bca732b1e4486024b5055f48cd128dd9e0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
367407 effect:
368 specifier: 4.0.0-beta.73
369 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
408 specifier: 4.0.0-beta.78
409 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
370410 node-pty:
371411 specifier: ^1.1.0
372412 version: 1.1.0
373413 devDependencies:
374414 '@effect/vitest':
375 specifier: 4.0.0-beta.73
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))
415 specifier: 4.0.0-beta.78
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))
377417 '@t3tools/contracts':
378418 specifier: workspace:*
379419 version: link:../../packages/contracts
......@@ -400,16 +440,22 @@ importers:
400440 version: link:../../packages/effect-codex-app-server
401441 vite-plus:
402442 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)
404444 vitest:
405445 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
408448 apps/web:
409449 dependencies:
410450 '@base-ui/react':
411451 specifier: ^1.4.1
412452 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)
413459 '@dnd-kit/core':
414460 specifier: ^6.3.1
415461 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
......@@ -423,8 +469,8 @@ importers:
423469 specifier: ^3.2.2
424470 version: 3.2.2(react@19.2.6)
425471 '@effect/atom-react':
426 specifier: 4.0.0-beta.73
427 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(react@19.2.6)(scheduler@0.27.0)
472 specifier: 4.0.0-beta.78
473 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.6)(scheduler@0.27.0)
428474 '@formkit/auto-animate':
429475 specifier: ^0.9.0
430476 version: 0.9.0
......@@ -465,8 +511,11 @@ importers:
465511 specifier: ^0.7.1
466512 version: 0.7.1
467513 effect:
468 specifier: 4.0.0-beta.73
469 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
514 specifier: 4.0.0-beta.78
515 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
516 jose:
517 specifier: 'catalog:'
518 version: 6.2.2
470519 lexical:
471520 specifier: ^0.41.0
472521 version: 0.41.0
......@@ -493,8 +542,11 @@ importers:
493542 version: 5.0.14(@types/react@19.2.16)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6))
494543 devDependencies:
495544 '@effect/platform-node':
496 specifier: 4.0.0-beta.73
497 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
545 specifier: 4.0.0-beta.78
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))
498550 '@rolldown/plugin-babel':
499551 specifier: ^0.2.0
500552 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:
536588 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)'
537589 vite-plus:
538590 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)
540592 vitest:
541593 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)'
543595 vitest-browser-react:
544596 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
547645 oxlint-plugin-t3code:
548646 dependencies:
549647 '@effect/platform-node':
550 specifier: 4.0.0-beta.73
551 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
648 specifier: 4.0.0-beta.78
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)
552650 '@oxlint/plugins':
553651 specifier: ^1.63.0
554652 version: 1.68.0
555653 effect:
556 specifier: 4.0.0-beta.73
557 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
654 specifier: 4.0.0-beta.78
655 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
558656 devDependencies:
559657 '@effect/vitest':
560 specifier: 4.0.0-beta.73
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))
658 specifier: 4.0.0-beta.78
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))
562660 '@types/bun':
563661 specifier: 'catalog:'
564662 version: 1.3.14
565663 vite-plus:
566664 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)
568666 vitest:
569667 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
572670 packages/client-runtime:
573671 dependencies:
......@@ -578,112 +676,121 @@ importers:
578676 specifier: workspace:*
579677 version: link:../shared
580678 effect:
581 specifier: 4.0.0-beta.73
582 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
679 specifier: 4.0.0-beta.78
680 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
583681 devDependencies:
584682 '@effect/vitest':
585 specifier: 4.0.0-beta.73
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))
683 specifier: 4.0.0-beta.78
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))
587685 vite-plus:
588686 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)
590688 vitest:
591689 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
594692 packages/contracts:
595693 dependencies:
596694 effect:
597 specifier: 4.0.0-beta.73
598 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
695 specifier: 4.0.0-beta.78
696 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
599697 devDependencies:
600698 '@effect/vitest':
601 specifier: 4.0.0-beta.73
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))
699 specifier: 4.0.0-beta.78
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))
603701 vite-plus:
604702 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)
606704 vitest:
607705 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
610708 packages/effect-acp:
611709 dependencies:
612710 effect:
613 specifier: 4.0.0-beta.73
614 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
711 specifier: 4.0.0-beta.78
712 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
615713 devDependencies:
616714 '@effect/openapi-generator':
617715 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))
619717 '@effect/platform-node':
620 specifier: 4.0.0-beta.73
621 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
718 specifier: 4.0.0-beta.78
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)
622720 '@effect/vitest':
623 specifier: 4.0.0-beta.73
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))
721 specifier: 4.0.0-beta.78
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))
625723 '@types/node':
626724 specifier: 24.12.4
627725 version: 24.12.4
628726 vite-plus:
629727 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)
631729 vitest:
632730 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
635733 packages/effect-codex-app-server:
636734 dependencies:
637735 effect:
638 specifier: 4.0.0-beta.73
639 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
736 specifier: 4.0.0-beta.78
737 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
640738 devDependencies:
641739 '@effect/openapi-generator':
642740 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))
644742 '@effect/platform-node':
645 specifier: 4.0.0-beta.73
646 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
743 specifier: 4.0.0-beta.78
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)
647745 '@effect/vitest':
648 specifier: 4.0.0-beta.73
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))
746 specifier: 4.0.0-beta.78
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))
650748 '@types/node':
651749 specifier: 24.12.4
652750 version: 24.12.4
653751 vite-plus:
654752 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)
656754 vitest:
657755 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
660758 packages/shared:
661759 dependencies:
760 '@noble/curves':
761 specifier: 'catalog:'
762 version: 1.9.1
763 '@noble/hashes':
764 specifier: 'catalog:'
765 version: 1.8.0
662766 '@t3tools/contracts':
663767 specifier: workspace:*
664768 version: link:../contracts
665769 effect:
666 specifier: 4.0.0-beta.73
667 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
770 specifier: 4.0.0-beta.78
771 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
772 jose:
773 specifier: 'catalog:'
774 version: 6.2.2
668775 yaml:
669776 specifier: ^2.9.0
670777 version: 2.9.0
671778 devDependencies:
672779 '@effect/platform-node':
673 specifier: 4.0.0-beta.73
674 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
780 specifier: 4.0.0-beta.78
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)
675782 '@effect/vitest':
676 specifier: 4.0.0-beta.73
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))
783 specifier: 4.0.0-beta.78
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))
678785 '@types/node':
679786 specifier: 24.12.4
680787 version: 24.12.4
681788 vite-plus:
682789 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)
684791 vitest:
685792 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
688795 packages/ssh:
689796 dependencies:
......@@ -694,46 +801,46 @@ importers:
694801 specifier: workspace:*
695802 version: link:../shared
696803 effect:
697 specifier: 4.0.0-beta.73
698 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
804 specifier: 4.0.0-beta.78
805 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
699806 devDependencies:
700807 '@effect/platform-node':
701 specifier: 4.0.0-beta.73
702 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
808 specifier: 4.0.0-beta.78
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)
703810 '@effect/vitest':
704 specifier: 4.0.0-beta.73
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))
811 specifier: 4.0.0-beta.78
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))
706813 '@types/node':
707814 specifier: 24.12.4
708815 version: 24.12.4
709816 vite-plus:
710817 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)
712819 vitest:
713820 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
716823 packages/tailscale:
717824 dependencies:
718825 '@effect/platform-node':
719 specifier: 4.0.0-beta.73
720 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
826 specifier: 4.0.0-beta.78
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)
721828 effect:
722 specifier: 4.0.0-beta.73
723 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
829 specifier: 4.0.0-beta.78
830 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
724831 devDependencies:
725832 '@effect/vitest':
726 specifier: 4.0.0-beta.73
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))
833 specifier: 4.0.0-beta.78
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))
728835 '@types/node':
729836 specifier: 24.12.4
730837 version: 24.12.4
731838 vite-plus:
732839 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)
734841 vitest:
735842 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
738845 scripts:
739846 dependencies:
......@@ -741,8 +848,8 @@ importers:
741848 specifier: ^0.2.77
742849 version: 0.2.141(zod@4.4.3)
743850 '@effect/platform-node':
744 specifier: 4.0.0-beta.73
745 version: 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
851 specifier: 4.0.0-beta.78
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)
746853 '@t3tools/contracts':
747854 specifier: workspace:*
748855 version: link:../packages/contracts
......@@ -750,24 +857,24 @@ importers:
750857 specifier: workspace:*
751858 version: link:../packages/shared
752859 effect:
753 specifier: 4.0.0-beta.73
754 version: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
860 specifier: 4.0.0-beta.78
861 version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
755862 yaml:
756863 specifier: ^2.9.0
757864 version: 2.9.0
758865 devDependencies:
759866 '@effect/vitest':
760 specifier: 4.0.0-beta.73
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))
867 specifier: 4.0.0-beta.78
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))
762869 '@types/bun':
763870 specifier: 'catalog:'
764871 version: 1.3.14
765872 vite-plus:
766873 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)
768875 vitest:
769876 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
772879packages:
773880
......@@ -777,6 +884,13 @@ packages:
777884 '@adobe/css-tools@4.5.0':
778885 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
780894 '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141':
781895 resolution: {integrity: sha512-9HZ0ot6+FwOfQ1aeMqQLH4IJGMm/DcP08SysDxscVjBm6l2JjqleHohxi3zid0DurfGweqT+4x9GScJffwg55g==}
782896 cpu: [arm64]
......@@ -921,6 +1035,99 @@ packages:
9211035 '@astrojs/yaml2ts@0.2.4':
9221036 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
9241131 '@babel/code-frame@7.29.7':
9251132 resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
9261133 engines: {node: '>=6.9.0'}
......@@ -1349,18 +1556,193 @@ packages:
13491556 resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==}
13501557 engines: {node: '>=18'}
13511558
1559 '@clack/core@0.5.0':
1560 resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==}
1561
13521562 '@clack/core@1.4.0':
13531563 resolution: {integrity: sha512-7Wctjq6f7c1CPz8sPpkwUnz8yRgVANkpNupb81q432FjcJg4l+Sw7XANdNSdWfAKq0IHI0JTcUeK5dxs/HrGPw==}
13541564 engines: {node: '>= 20.12.0'}
13551565
1566 '@clack/prompts@0.11.0':
1567 resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
1568
13561569 '@clack/prompts@1.5.0':
13571570 resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==}
13581571 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
13601676 '@develar/schema-utils@2.6.5':
13611677 resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
13621678 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
13641746 '@dnd-kit/accessibility@3.1.1':
13651747 resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
13661748 peerDependencies:
......@@ -1389,42 +1771,50 @@ packages:
13891771 peerDependencies:
13901772 react: '>=16.8.0'
13911773
1392 '@effect/atom-react@4.0.0-beta.73':
1393 resolution: {integrity: sha512-RjNqEMV3z3iEFRBtJ1RVuxBNFMYxhDo2iRwck3kBYjrBuXWrKIRyqnunWJdL2KX97Gqp0orxaujG7qnIA5aIEA==}
1774 '@drizzle-team/brocli@0.11.0':
1775 resolution: {integrity: sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg==}
1776
1777 '@effect/atom-react@4.0.0-beta.78':
1778 resolution: {integrity: sha512-cgxDXJaD0wlbQXbp6tiEmmY+yajwurB0ynkFG20RVucvH4LsQMB3ogiHe0mt42wGggfbVYMEDxgBpQdqDRY8yA==}
13941779 peerDependencies:
1395 effect: 4.0.0-beta.73
1780 effect: 4.0.0-beta.78
13961781 react: ^19.2.4
13971782 scheduler: '*'
13981783
1399 '@effect/openapi-generator@4.0.0-beta.73':
1400 resolution: {integrity: sha512-tN1mKreMo6kypkuPy8Jt3eGoI0DberzD2/5XxEh17lTdyLg7/SUZv3LssRxtzO5+uIYzLNIHfT2zESz7gjCnwA==}
1784 '@effect/openapi-generator@4.0.0-beta.78':
1785 resolution: {integrity: sha512-qhKRcZCNQ5b0Klrct+AC/tPQgIDBxVsD0MkQLIzqvLU3qRHaNd5yHo7kxFf/DuhCyyL++xZfbHsPdq3VdLIByg==}
14011786 hasBin: true
14021787 peerDependencies:
1403 '@effect/platform-node': 4.0.0-beta.73
1404 effect: 4.0.0-beta.73
1788 '@effect/platform-node': 4.0.0-beta.78
1789 effect: 4.0.0-beta.78
14051790
1406 '@effect/platform-bun@4.0.0-beta.73':
1407 resolution: {integrity: sha512-AQcE89eEVp0xA3SafaswiHPfo6V2YPg7T6PQbQqYK4/S2Sia5cadCm/6y7pfRmuOd4jkWI6VMbNtEDkM0NOBpw==}
1791 '@effect/platform-bun@4.0.0-beta.78':
1792 resolution: {integrity: sha512-lmPCL1G7SlkCWCguX3rDPS7kKuvJ/AN4pjS7IXb/5SoauHPd67iUdc1ZbB7o6lwTChJaIfWNNPkUWygiaUeJiA==}
14081793 peerDependencies:
1409 effect: 4.0.0-beta.73
1794 effect: 4.0.0-beta.78
14101795
1411 '@effect/platform-node-shared@4.0.0-beta.73':
1412 resolution: {integrity: sha512-/7L8eftDS40VWgLx2GVtBtb8x97Vx4OF5IlRJfh14L5xDFdb9MZXxOuHfHAi+71d90pkbFpOFVtQCSOBWKRAWg==}
1796 '@effect/platform-node-shared@4.0.0-beta.78':
1797 resolution: {integrity: sha512-mo0ddTPATyCMyqzQasYDL7+NI29vozoMplom+qu9f/onDTd4xG5hvEEfGxfL0Ljygui6keG/YE/E9OZVf2z5WA==}
14131798 engines: {node: '>=18.0.0'}
14141799 peerDependencies:
1415 effect: 4.0.0-beta.73
1800 effect: 4.0.0-beta.78
14161801
1417 '@effect/platform-node@4.0.0-beta.73':
1418 resolution: {integrity: sha512-YYkUnmcs6EiRk8K/xNCU3l3jl1YbrhCHCJp+y32DWlKhhZot24/hvw0zYw0aymAtuI9dzAdPryVZkksH4+7ovw==}
1802 '@effect/platform-node@4.0.0-beta.78':
1803 resolution: {integrity: sha512-8ONrIS5/R9dq+0BJ6v3kUXNEkfjU6S3GzIYCH5gmHdiriRvIoBhXYNAITfRvZpfx1JPrKuP70cHyuQDjmJcDkQ==}
14191804 engines: {node: '>=18.0.0'}
14201805 peerDependencies:
1421 effect: 4.0.0-beta.73
1806 effect: 4.0.0-beta.78
14221807 ioredis: ^5.7.0
14231808
1424 '@effect/sql-sqlite-bun@4.0.0-beta.73':
1425 resolution: {integrity: sha512-dkTDDeBLjaKo7h1IE049Oj2BDKQOT+Kc57KbBVaSk36dRdsEmVspRMP7OGbZj3etJU3lL1GJi1BIfnb4N3uMRw==}
1809 '@effect/sql-pg@4.0.0-beta.78':
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==}
14261816 peerDependencies:
1427 effect: 4.0.0-beta.73
1817 effect: 4.0.0-beta.78
14281818
14291819 '@effect/tsgo-darwin-arm64@0.11.4':
14301820 resolution: {integrity: sha512-rslT4W8tWY8pdd48kDpY7Q+yo0xkrmcEySG1+sJfb06w7TwbywjHsGlC0BmcUaQt0qmLY+BApbWWDfZw7fgwjw==}
......@@ -1465,10 +1855,10 @@ packages:
14651855 resolution: {integrity: sha512-wliq5Dis2gLoIUKoCRT3Iv5kpO+fKrUBJPPxIsHADeirk9HMQevKvYaBpGS3Khw1tNLmoQWsSX559owJAdHgBA==}
14661856 hasBin: true
14671857
1468 '@effect/vitest@4.0.0-beta.73':
1469 resolution: {integrity: sha512-HIZa0aekh0ak1YfzCv8GTN75Vbvxp8g+W+z9KczvtUT+LvitX3i9nhLWZLKRrUP1LaJFLPJS2UG9ZGXjxgbjBg==}
1858 '@effect/vitest@4.0.0-beta.78':
1859 resolution: {integrity: sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ==}
14701860 peerDependencies:
1471 effect: 4.0.0-beta.73
1861 effect: 4.0.0-beta.78
14721862 vitest: ^3.0.0 || ^4.0.0
14731863
14741864 '@egjs/hammerjs@2.0.17':
......@@ -1548,156 +1938,312 @@ packages:
15481938 '@epic-web/invariant@1.0.0':
15491939 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
15511947 '@esbuild/aix-ppc64@0.27.7':
15521948 resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
15531949 engines: {node: '>=18'}
15541950 cpu: [ppc64]
15551951 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
15571959 '@esbuild/android-arm64@0.27.7':
15581960 resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
15591961 engines: {node: '>=18'}
15601962 cpu: [arm64]
15611963 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
15631971 '@esbuild/android-arm@0.27.7':
15641972 resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
15651973 engines: {node: '>=18'}
15661974 cpu: [arm]
15671975 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
15691983 '@esbuild/android-x64@0.27.7':
15701984 resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
15711985 engines: {node: '>=18'}
15721986 cpu: [x64]
15731987 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
15751995 '@esbuild/darwin-arm64@0.27.7':
15761996 resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
15771997 engines: {node: '>=18'}
15781998 cpu: [arm64]
15791999 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
15812007 '@esbuild/darwin-x64@0.27.7':
15822008 resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
15832009 engines: {node: '>=18'}
15842010 cpu: [x64]
15852011 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
15872019 '@esbuild/freebsd-arm64@0.27.7':
15882020 resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
15892021 engines: {node: '>=18'}
15902022 cpu: [arm64]
15912023 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
15932031 '@esbuild/freebsd-x64@0.27.7':
15942032 resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
15952033 engines: {node: '>=18'}
15962034 cpu: [x64]
15972035 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
15992043 '@esbuild/linux-arm64@0.27.7':
16002044 resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
16012045 engines: {node: '>=18'}
16022046 cpu: [arm64]
16032047 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
16052055 '@esbuild/linux-arm@0.27.7':
16062056 resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
16072057 engines: {node: '>=18'}
16082058 cpu: [arm]
16092059 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
16112067 '@esbuild/linux-ia32@0.27.7':
16122068 resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
16132069 engines: {node: '>=18'}
16142070 cpu: [ia32]
16152071 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
16172079 '@esbuild/linux-loong64@0.27.7':
16182080 resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
16192081 engines: {node: '>=18'}
16202082 cpu: [loong64]
16212083 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
16232091 '@esbuild/linux-mips64el@0.27.7':
16242092 resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
16252093 engines: {node: '>=18'}
16262094 cpu: [mips64el]
16272095 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
16292103 '@esbuild/linux-ppc64@0.27.7':
16302104 resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
16312105 engines: {node: '>=18'}
16322106 cpu: [ppc64]
16332107 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
16352115 '@esbuild/linux-riscv64@0.27.7':
16362116 resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
16372117 engines: {node: '>=18'}
16382118 cpu: [riscv64]
16392119 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
16412127 '@esbuild/linux-s390x@0.27.7':
16422128 resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
16432129 engines: {node: '>=18'}
16442130 cpu: [s390x]
16452131 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
16472139 '@esbuild/linux-x64@0.27.7':
16482140 resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
16492141 engines: {node: '>=18'}
16502142 cpu: [x64]
16512143 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
16532151 '@esbuild/netbsd-arm64@0.27.7':
16542152 resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
16552153 engines: {node: '>=18'}
16562154 cpu: [arm64]
16572155 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
16592163 '@esbuild/netbsd-x64@0.27.7':
16602164 resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
16612165 engines: {node: '>=18'}
16622166 cpu: [x64]
16632167 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
16652175 '@esbuild/openbsd-arm64@0.27.7':
16662176 resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
16672177 engines: {node: '>=18'}
16682178 cpu: [arm64]
16692179 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
16712187 '@esbuild/openbsd-x64@0.27.7':
16722188 resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
16732189 engines: {node: '>=18'}
16742190 cpu: [x64]
16752191 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
16772199 '@esbuild/openharmony-arm64@0.27.7':
16782200 resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
16792201 engines: {node: '>=18'}
16802202 cpu: [arm64]
16812203 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
16832211 '@esbuild/sunos-x64@0.27.7':
16842212 resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
16852213 engines: {node: '>=18'}
16862214 cpu: [x64]
16872215 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
16892223 '@esbuild/win32-arm64@0.27.7':
16902224 resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
16912225 engines: {node: '>=18'}
16922226 cpu: [arm64]
16932227 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
16952235 '@esbuild/win32-ia32@0.27.7':
16962236 resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
16972237 engines: {node: '>=18'}
16982238 cpu: [ia32]
16992239 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
17012247 '@esbuild/win32-x64@0.27.7':
17022248 resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
17032249 engines: {node: '>=18'}
......@@ -2136,6 +2682,10 @@ packages:
21362682 '@jridgewell/trace-mapping@0.3.31':
21372683 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
21392689 '@legendapp/list@3.0.0-beta.44':
21402690 resolution: {integrity: sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A==}
21412691 peerDependencies:
......@@ -2222,6 +2772,63 @@ packages:
22222772 peerDependencies:
22232773 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
22252832 '@malept/cross-spawn-promise@2.0.0':
22262833 resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
22272834 engines: {node: '>= 12.13.0'}
......@@ -2280,6 +2887,84 @@ packages:
22802887 '@emnapi/core': ^1.7.1
22812888 '@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
22832968 '@open-draft/deferred-promise@2.2.0':
22842969 resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
22852970
......@@ -2302,6 +2987,9 @@ packages:
23022987 '@oxc-project/types@0.127.0':
23032988 resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==}
23042989
2990 '@oxc-project/types@0.130.0':
2991 resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
2992
23052993 '@oxc-project/types@0.133.0':
23062994 resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
23072995
......@@ -2909,6 +3597,12 @@ packages:
29093597 cpu: [arm64]
29103598 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
29123606 '@rolldown/binding-android-arm64@1.0.3':
29133607 resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
29143608 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -2921,6 +3615,12 @@ packages:
29213615 cpu: [arm64]
29223616 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
29243624 '@rolldown/binding-darwin-arm64@1.0.3':
29253625 resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==}
29263626 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -2933,6 +3633,12 @@ packages:
29333633 cpu: [x64]
29343634 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
29363642 '@rolldown/binding-darwin-x64@1.0.3':
29373643 resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==}
29383644 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -2945,6 +3651,12 @@ packages:
29453651 cpu: [x64]
29463652 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
29483660 '@rolldown/binding-freebsd-x64@1.0.3':
29493661 resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==}
29503662 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -2957,6 +3669,12 @@ packages:
29573669 cpu: [arm]
29583670 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
29603678 '@rolldown/binding-linux-arm-gnueabihf@1.0.3':
29613679 resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==}
29623680 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -2969,6 +3687,12 @@ packages:
29693687 cpu: [arm64]
29703688 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
29723696 '@rolldown/binding-linux-arm64-gnu@1.0.3':
29733697 resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==}
29743698 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -2981,6 +3705,12 @@ packages:
29813705 cpu: [arm64]
29823706 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
29843714 '@rolldown/binding-linux-arm64-musl@1.0.3':
29853715 resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
29863716 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -2993,6 +3723,12 @@ packages:
29933723 cpu: [ppc64]
29943724 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
29963732 '@rolldown/binding-linux-ppc64-gnu@1.0.3':
29973733 resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
29983734 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -3005,6 +3741,12 @@ packages:
30053741 cpu: [s390x]
30063742 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
30083750 '@rolldown/binding-linux-s390x-gnu@1.0.3':
30093751 resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
30103752 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -3017,6 +3759,12 @@ packages:
30173759 cpu: [x64]
30183760 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
30203768 '@rolldown/binding-linux-x64-gnu@1.0.3':
30213769 resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
30223770 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -3029,6 +3777,12 @@ packages:
30293777 cpu: [x64]
30303778 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
30323786 '@rolldown/binding-linux-x64-musl@1.0.3':
30333787 resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
30343788 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -3041,6 +3795,12 @@ packages:
30413795 cpu: [arm64]
30423796 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
30443804 '@rolldown/binding-openharmony-arm64@1.0.3':
30453805 resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
30463806 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -3052,6 +3812,11 @@ packages:
30523812 engines: {node: ^20.19.0 || >=22.12.0}
30533813 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
30553820 '@rolldown/binding-wasm32-wasi@1.0.3':
30563821 resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==}
30573822 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -3063,6 +3828,12 @@ packages:
30633828 cpu: [arm64]
30643829 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
30663837 '@rolldown/binding-win32-arm64-msvc@1.0.3':
30673838 resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==}
30683839 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -3075,6 +3846,12 @@ packages:
30753846 cpu: [x64]
30763847 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
30783855 '@rolldown/binding-win32-x64-msvc@1.0.3':
30793856 resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==}
30803857 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -3297,9 +4074,67 @@ packages:
32974074 resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
32984075 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
33004128 '@standard-schema/spec@1.1.0':
33014129 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
33034138 '@szmarczak/http-timer@4.0.6':
33044139 resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
33054140 engines: {node: '>=10'}
......@@ -3595,6 +4430,9 @@ packages:
35954430 '@types/aria-query@5.0.4':
35964431 resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
35974432
4433 '@types/aws-lambda@8.10.161':
4434 resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==}
4435
35984436 '@types/babel__core@7.20.5':
35994437 resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
36004438
......@@ -3969,6 +4807,12 @@ packages:
39694807 '@xterm/xterm@6.0.0':
39704808 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
39724816 abbrev@4.0.0:
39734817 resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
39744818 engines: {node: ^20.17.0 || >=22.9.0}
......@@ -4021,6 +4865,38 @@ packages:
40214865 ajv@8.20.0:
40224866 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
40244900 anser@1.4.10:
40254901 resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==}
40264902
......@@ -4028,6 +4904,10 @@ packages:
40284904 resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
40294905 engines: {node: '>=8'}
40304906
4907 ansi-escapes@7.3.0:
4908 resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
4909 engines: {node: '>=18'}
4910
40314911 ansi-regex@4.1.1:
40324912 resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==}
40334913 engines: {node: '>=6'}
......@@ -4036,6 +4916,10 @@ packages:
40364916 resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
40374917 engines: {node: '>=8'}
40384918
4919 ansi-regex@6.2.2:
4920 resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
4921 engines: {node: '>=12'}
4922
40394923 ansi-styles@3.2.1:
40404924 resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
40414925 engines: {node: '>=4'}
......@@ -4048,6 +4932,10 @@ packages:
40484932 resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
40494933 engines: {node: '>=10'}
40504934
4935 ansi-styles@6.2.3:
4936 resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
4937 engines: {node: '>=12'}
4938
40514939 ansis@4.3.1:
40524940 resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==}
40534941 engines: {node: '>=14'}
......@@ -4123,6 +5011,17 @@ packages:
41235011 resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
41245012 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
41265025 axobject-query@4.1.0:
41275026 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
41285027 engines: {node: '>= 0.4'}
......@@ -4172,6 +5071,9 @@ packages:
41725071 expo-widgets:
41735072 optional: true
41745073
5074 badgin@1.2.3:
5075 resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==}
5076
41755077 bail@2.0.2:
41765078 resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
41775079
......@@ -4185,6 +5087,9 @@ packages:
41855087 barcode-detector@3.2.0:
41865088 resolution: {integrity: sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==}
41875089
5090 base-64@1.0.0:
5091 resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
5092
41885093 base64-js@1.5.1:
41895094 resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
41905095
......@@ -4193,6 +5098,9 @@ packages:
41935098 engines: {node: '>=6.0.0'}
41945099 hasBin: true
41955100
5101 before-after-hook@4.0.0:
5102 resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
5103
41965104 big-integer@1.6.52:
41975105 resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
41985106 engines: {node: '>=0.6'}
......@@ -4208,6 +5116,9 @@ packages:
42085116 resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
42095117 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
42115122 bplist-creator@0.1.0:
42125123 resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==}
42135124
......@@ -4233,6 +5144,9 @@ packages:
42335144 resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
42345145 engines: {node: '>=8'}
42355146
5147 browser-tabs-lock@1.3.0:
5148 resolution: {integrity: sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==}
5149
42365150 browserslist@4.28.2:
42375151 resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
42385152 engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
......@@ -4250,6 +5164,10 @@ packages:
42505164 buffer@5.7.1:
42515165 resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
42525166
5167 bufferutil@4.1.0:
5168 resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==}
5169 engines: {node: '>=6.14.2'}
5170
42535171 builder-util-runtime@9.5.1:
42545172 resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==}
42555173 engines: {node: '>=12.0.0'}
......@@ -4287,6 +5205,12 @@ packages:
42875205 caniuse-lite@1.0.30001793:
42885206 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
42905214 ccount@2.0.1:
42915215 resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
42925216
......@@ -4298,6 +5222,10 @@ packages:
42985222 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
42995223 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
43015229 character-entities-html4@2.1.0:
43025230 resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
43035231
......@@ -4351,10 +5279,18 @@ packages:
43515279 class-variance-authority@0.7.1:
43525280 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
43545286 cli-cursor@2.1.0:
43555287 resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==}
43565288 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
43585294 cli-spinners@2.9.2:
43595295 resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
43605296 engines: {node: '>=6'}
......@@ -4363,6 +5299,10 @@ packages:
43635299 resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
43645300 engines: {node: '>=8'}
43655301
5302 cli-truncate@5.2.0:
5303 resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
5304 engines: {node: '>=20'}
5305
43665306 cli-width@4.1.0:
43675307 resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
43685308 engines: {node: '>= 12'}
......@@ -4389,6 +5329,10 @@ packages:
43895329 resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
43905330 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
43925336 color-convert@1.9.3:
43935337 resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
43945338
......@@ -4477,6 +5421,10 @@ packages:
44775421 convert-source-map@2.0.0:
44785422 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
44805428 cookie-es@1.2.3:
44815429 resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==}
44825430
......@@ -4498,6 +5446,9 @@ packages:
44985446 core-js-compat@3.49.0:
44995447 resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
45005448
5449 core-js@3.47.0:
5450 resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==}
5451
45015452 core-util-is@1.0.2:
45025453 resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
45035454
......@@ -4523,6 +5474,9 @@ packages:
45235474 crossws@0.3.5:
45245475 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
45265480 css-select@5.2.2:
45275481 resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
45285482
......@@ -4637,6 +5591,10 @@ packages:
46375591 resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
46385592 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
46405598 detect-libc@2.1.2:
46415599 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
46425600 engines: {node: '>=8'}
......@@ -4699,6 +5657,126 @@ packages:
46995657 resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
47005658 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
47025780 dset@3.1.4:
47035781 resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==}
47045782 engines: {node: '>=4'}
......@@ -4710,8 +5788,8 @@ packages:
47105788 ee-first@1.1.1:
47115789 resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
47125790
4713 effect@4.0.0-beta.73:
4714 resolution: {integrity: sha512-rL7jjpT31JhxNiZ0X4sgu+WSJi7uwKTX2jz2OW7PrPLfz5DPjT9iXxxVrekxwpfGdgo92bvxbCc5kBlXgusQJw==}
5791 effect@4.0.0-beta.78:
5792 resolution: {integrity: sha512-j79Rl9QpHwMz/ZJWLNpZoiVj9N7zHqiLKN5EcYd/A8J1oqejILWQLfc4HPlvqHqKC8SK55LJ+X4gy4ONJ+JpfQ==}
47155793
47165794 ejs@3.1.10:
47175795 resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
......@@ -4747,6 +5825,9 @@ packages:
47475825 emmet@2.4.11:
47485826 resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==}
47495827
5828 emoji-regex@10.6.0:
5829 resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
5830
47505831 emoji-regex@8.0.0:
47515832 resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
47525833
......@@ -4777,6 +5858,10 @@ packages:
47775858 resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
47785859 engines: {node: '>=6'}
47795860
5861 environment@1.1.0:
5862 resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
5863 engines: {node: '>=18'}
5864
47805865 err-code@2.0.3:
47815866 resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
47825867
......@@ -4805,9 +5890,17 @@ packages:
48055890 resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
48065891 engines: {node: '>= 0.4'}
48075892
5893 es-toolkit@1.47.0:
5894 resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==}
5895
48085896 es6-error@4.1.1:
48095897 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
48115904 esbuild@0.27.7:
48125905 resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
48135906 engines: {node: '>=18'}
......@@ -4824,6 +5917,10 @@ packages:
48245917 resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
48255918 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
48275924 escape-string-regexp@4.0.0:
48285925 resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
48295926 engines: {node: '>=10'}
......@@ -4857,6 +5954,11 @@ packages:
48575954 resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
48585955 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
48605962 expo-asset@56.0.15:
48615963 resolution: {integrity: sha512-BHGi2IAOPQTcOelkUdcz1WIknfCTRjkcpYHX1azjMwgYenrVC+J5qcqJGaC8eUOWLCRtkRJWGnmFQRYtLU1nUQ==}
48625964 peerDependencies:
......@@ -4864,6 +5966,12 @@ packages:
48645966 react: '*'
48655967 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
48675975 expo-build-properties@56.0.16:
48685976 resolution: {integrity: sha512-C3avazYP2fR8efJBBmhx8yITjIRDaIe3ULPk0YfACP61QfnWC9u3LxaDNNaiIvYfZ+CLne30W+nS5F6pdgO/8g==}
48695977 peerDependencies:
......@@ -4997,6 +6105,13 @@ packages:
49976105 peerDependencies:
49986106 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
50006115 expo-paste-input@0.1.15:
50016116 resolution: {integrity: sha512-wV0z1s0/Q4MB+lNiSwbMfck+1Im6sDIcgyCi5Y0aF35n5kcM4FHSOjmRfzn3UbkMlvxmYfDiHeHvIqn22qatqA==}
50026117 peerDependencies:
......@@ -5078,6 +6193,19 @@ packages:
50786193 expo-dev-client:
50796194 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
50816209 expo@56.0.8:
50826210 resolution: {integrity: sha512-GzQi5450yrCk5JRSlm0epsmtURBErh0wS77uWLZImFdnPICuX912MaRWooR+Q1Sw/7aQjp9F+KXH+dvrqGxpeQ==}
50836211 hasBin: true
......@@ -5133,9 +6261,16 @@ packages:
51336261 fast-deep-equal@3.1.3:
51346262 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
51366268 fast-json-stable-stringify@2.1.0:
51376269 resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
51386270
6271 fast-sha256@1.3.0:
6272 resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
6273
51396274 fast-string-truncated-width@3.0.3:
51406275 resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
51416276
......@@ -5148,6 +6283,24 @@ packages:
51486283 fast-wrap-ansi@0.2.2:
51496284 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
51516304 fb-dotslash@0.5.8:
51526305 resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==}
51536306 engines: {node: '>=20'}
......@@ -5262,6 +6415,9 @@ packages:
52626415 function-bind@1.1.2:
52636416 resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
52646417
6418 generate-function@2.3.1:
6419 resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
6420
52656421 gensync@1.0.0-beta.2:
52666422 resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
52676423 engines: {node: '>=6.9.0'}
......@@ -5270,6 +6426,10 @@ packages:
52706426 resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
52716427 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
52736433 get-intrinsic@1.3.0:
52746434 resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
52756435 engines: {node: '>= 0.4'}
......@@ -5286,6 +6446,9 @@ packages:
52866446 resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
52876447 engines: {node: '>=8'}
52886448
6449 get-tsconfig@4.14.0:
6450 resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
6451
52896452 get-tsconfig@5.0.0-beta.4:
52906453 resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==}
52916454 engines: {node: '>=20.20.0'}
......@@ -5297,6 +6460,13 @@ packages:
52976460 github-slugger@2.0.0:
52986461 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
53006470 glob@13.0.6:
53016471 resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
53026472 engines: {node: 18 || 20 || >=22}
......@@ -5461,6 +6631,9 @@ packages:
54616631 resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
54626632 engines: {node: '>=0.10.0'}
54636633
6634 idb-keyval@6.2.1:
6635 resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==}
6636
54646637 ieee754@1.2.1:
54656638 resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
54666639
......@@ -5473,10 +6646,17 @@ packages:
54736646 engines: {node: '>=16.x'}
54746647 hasBin: true
54756648
6649 immediate@3.0.6:
6650 resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
6651
54766652 indent-string@4.0.0:
54776653 resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
54786654 engines: {node: '>=8'}
54796655
6656 indent-string@5.0.0:
6657 resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==}
6658 engines: {node: '>=12'}
6659
54806660 inflight@1.0.6:
54816661 resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
54826662 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:
54886668 resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==}
54896669 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
54916684 inline-style-parser@0.2.7:
54926685 resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
54936686
......@@ -5540,13 +6733,30 @@ packages:
55406733 engines: {node: '>=20'}
55416734 hasBin: true
55426735
6736 is-extglob@2.1.1:
6737 resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
6738 engines: {node: '>=0.10.0'}
6739
55436740 is-fullwidth-code-point@3.0.0:
55446741 resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
55456742 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
55476752 is-hexadecimal@2.0.1:
55486753 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
55506760 is-inside-container@1.0.0:
55516761 resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
55526762 engines: {node: '>=14.16'}
......@@ -5566,6 +6776,9 @@ packages:
55666776 is-promise@4.0.0:
55676777 resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
55686778
6779 is-property@1.0.2:
6780 resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
6781
55696782 is-wsl@2.2.0:
55706783 resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
55716784 engines: {node: '>=8'}
......@@ -5574,6 +6787,9 @@ packages:
55746787 resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
55756788 engines: {node: '>=16'}
55766789
6790 isarray@1.0.0:
6791 resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
6792
55776793 isbinaryfile@4.0.10:
55786794 resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
55796795 engines: {node: '>= 8.0.0'}
......@@ -5628,9 +6844,19 @@ packages:
56286844 resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
56296845 hasBin: true
56306846
6847 jose@6.2.2:
6848 resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
6849
56316850 jose@6.2.3:
56326851 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
56346860 js-tokens@4.0.0:
56356861 resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
56366862
......@@ -5638,6 +6864,9 @@ packages:
56386864 resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==}
56396865 hasBin: true
56406866
6867 jsbi@4.3.2:
6868 resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==}
6869
56416870 jsc-safe-url@0.2.4:
56426871 resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==}
56436872
......@@ -5665,6 +6894,9 @@ packages:
56656894 json-stringify-safe@5.0.1:
56666895 resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
56676896
6897 json-with-bigint@3.5.8:
6898 resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==}
6899
56686900 json5@2.2.3:
56696901 resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
56706902 engines: {node: '>=6'}
......@@ -5682,6 +6914,9 @@ packages:
56826914 jsonfile@6.2.1:
56836915 resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
56846916
6917 jszip@3.10.1:
6918 resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
6919
56856920 keyv@4.5.4:
56866921 resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
56876922
......@@ -5715,6 +6950,20 @@ packages:
57156950 engines: {node: '>=16'}
57166951 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
57186967 lighthouse-logger@1.4.2:
57196968 resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
57206969
......@@ -5942,6 +7191,9 @@ packages:
59427191 resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==}
59437192 engines: {node: '>=4'}
59447193
7194 long@5.3.2:
7195 resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
7196
59457197 longest-streak@3.1.0:
59467198 resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
59477199
......@@ -5967,6 +7219,10 @@ packages:
59677219 resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
59687220 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
59707226 lru_map@0.4.1:
59717227 resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
59727228
......@@ -6073,6 +7329,10 @@ packages:
60737329 merge-stream@2.0.0:
60747330 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
60767336 metro-babel-transformer@0.84.4:
60777337 resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==}
60787338 engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
......@@ -6254,6 +7514,10 @@ packages:
62547514 resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
62557515 engines: {node: '>=4'}
62567516
7517 mimic-fn@2.1.0:
7518 resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
7519 engines: {node: '>=6'}
7520
62577521 mimic-response@1.0.1:
62587522 resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
62597523 engines: {node: '>=4'}
......@@ -6341,6 +7605,16 @@ packages:
63417605 resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
63427606 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
63447618 nanoid@3.3.12:
63457619 resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
63467620 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
......@@ -6389,6 +7663,10 @@ packages:
63897663 resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
63907664 hasBin: true
63917665
7666 node-gyp-build@4.8.4:
7667 resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
7668 hasBin: true
7669
63927670 node-gyp@12.3.0:
63937671 resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==}
63947672 engines: {node: ^20.17.0 || >=22.9.0}
......@@ -6446,6 +7724,9 @@ packages:
64467724 resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
64477725 engines: {node: '>= 0.4'}
64487726
7727 obuf@1.1.2:
7728 resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
7729
64497730 obug@2.1.1:
64507731 resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
64517732
......@@ -6474,6 +7755,10 @@ packages:
64747755 resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==}
64757756 engines: {node: '>=4'}
64767757
7758 onetime@5.1.2:
7759 resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
7760 engines: {node: '>=6'}
7761
64777762 oniguruma-parser@0.12.2:
64787763 resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
64797764
......@@ -6488,6 +7773,10 @@ packages:
64887773 resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==}
64897774 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
64917780 outvariant@1.4.3:
64927781 resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
64937782
......@@ -6544,6 +7833,9 @@ packages:
65447833 package-manager-detector@1.6.0:
65457834 resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
65467835
7836 pako@1.0.11:
7837 resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
7838
65477839 parse-entities@4.0.2:
65487840 resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
65497841
......@@ -6561,9 +7853,17 @@ packages:
65617853 resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
65627854 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
65647860 path-browserify@1.0.1:
65657861 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
65677867 path-is-absolute@1.0.1:
65687868 resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
65697869 engines: {node: '>=0.10.0'}
......@@ -6595,8 +7895,58 @@ packages:
65957895 resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==}
65967896 engines: {node: '>=12', npm: '>=6'}
65977897
6598 pend@1.2.0:
6599 resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
7898 pend@1.2.0:
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
66017951 piccolore@0.1.3:
66027952 resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==}
......@@ -6650,6 +8000,41 @@ packages:
66508000 resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
66518001 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
66538038 postject@1.0.0-alpha.6:
66548039 resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
66558040 engines: {node: '>=14.0.0'}
......@@ -6684,10 +8069,16 @@ packages:
66848069 resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
66858070 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
66878075 progress@2.0.3:
66888076 resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
66898077 engines: {node: '>=0.4.0'}
66908078
8079 promise-limit@2.7.0:
8080 resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
8081
66918082 promise-retry@2.0.1:
66928083 resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
66938084 engines: {node: '>=10'}
......@@ -6727,6 +8118,9 @@ packages:
67278118 resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
67288119 engines: {node: '>=6'}
67298120
8121 queue-microtask@1.2.3:
8122 resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
8123
67308124 queue@6.0.2:
67318125 resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
67328126
......@@ -6874,6 +8268,11 @@ packages:
68748268 react: '*'
68758269 react-native: '*'
68768270
8271 react-native-url-polyfill@2.0.0:
8272 resolution: {integrity: sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==}
8273 peerDependencies:
8274 react-native: '*'
8275
68778276 react-native-worklets@0.8.3:
68788277 resolution: {integrity: sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg==}
68798278 peerDependencies:
......@@ -6896,6 +8295,12 @@ packages:
68968295 '@types/react':
68978296 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
68998304 react-refresh@0.14.2:
69008305 resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
69018306 engines: {node: '>=0.10.0'}
......@@ -6942,6 +8347,9 @@ packages:
69428347 resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
69438348 hasBin: true
69448349
8350 readable-stream@2.3.8:
8351 resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
8352
69458353 readdirp@4.1.2:
69468354 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
69478355 engines: {node: '>= 14.18.0'}
......@@ -7066,6 +8474,10 @@ packages:
70668474 resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
70678475 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
70698481 retext-latin@4.0.0:
70708482 resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==}
70718483
......@@ -7085,6 +8497,10 @@ packages:
70858497 rettime@0.10.1:
70868498 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
70888504 rimraf@2.6.3:
70898505 resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
70908506 deprecated: Rimraf versions prior to v4 are no longer supported
......@@ -7099,6 +8515,11 @@ packages:
70998515 engines: {node: ^20.19.0 || >=22.12.0}
71008516 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
71028523 rolldown@1.0.3:
71038524 resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
71048525 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -7113,6 +8534,12 @@ packages:
71138534 resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
71148535 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
71168543 safe-buffer@5.2.1:
71178544 resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
71188545
......@@ -7187,6 +8614,9 @@ packages:
71878614 server-only@0.0.1:
71888615 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
71908620 setprototypeof@1.2.0:
71918621 resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
71928622
......@@ -7264,6 +8694,10 @@ packages:
72648694 resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==}
72658695 engines: {node: '>=8'}
72668696
8697 slice-ansi@8.0.0:
8698 resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
8699 engines: {node: '>=20'}
8700
72678701 slugify@1.6.9:
72688702 resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==}
72698703 engines: {node: '>=8.0.0'}
......@@ -7298,9 +8732,21 @@ packages:
72988732 resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
72998733 engines: {node: '>=6'}
73008734
8735 split2@4.2.0:
8736 resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
8737 engines: {node: '>= 10.x'}
8738
73018739 sprintf-js@1.1.3:
73028740 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
73048750 stackframe@1.3.4:
73058751 resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==}
73068752
......@@ -7311,6 +8757,9 @@ packages:
73118757 standard-as-callback@2.1.0:
73128758 resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
73138759
8760 standardwebhooks@1.0.0:
8761 resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
8762
73148763 stat-mode@1.0.0:
73158764 resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
73168765 engines: {node: '>= 6'}
......@@ -7341,6 +8790,17 @@ packages:
73418790 resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
73428791 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
73448804 stringify-entities@4.0.4:
73458805 resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
73468806
......@@ -7352,10 +8812,17 @@ packages:
73528812 resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
73538813 engines: {node: '>=8'}
73548814
8815 strip-ansi@7.2.0:
8816 resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
8817 engines: {node: '>=12'}
8818
73558819 strip-indent@3.0.0:
73568820 resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
73578821 engines: {node: '>=8'}
73588822
8823 strnum@2.3.0:
8824 resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==}
8825
73598826 structured-headers@0.4.1:
73608827 resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==}
73618828
......@@ -7429,6 +8896,10 @@ packages:
74298896 resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==}
74308897 engines: {node: '>=8'}
74318898
8899 terminal-size@4.0.1:
8900 resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==}
8901 engines: {node: '>=18'}
8902
74328903 terser@5.48.0:
74338904 resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==}
74348905 engines: {node: '>=10'}
......@@ -7575,10 +9046,17 @@ packages:
75759046 resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==}
75769047 engines: {node: '>=18.17'}
75779048
9049 undici@7.27.1:
9050 resolution: {integrity: sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg==}
9051 engines: {node: '>=20.18.1'}
9052
75789053 undici@8.3.0:
75799054 resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==}
75809055 engines: {node: '>=22.19.0'}
75819056
9057 unenv@2.0.0-rc.24:
9058 resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
9059
75829060 unicode-canonical-property-names-ecmascript@2.0.1:
75839061 resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
75849062 engines: {node: '>=4'}
......@@ -7628,6 +9106,9 @@ packages:
76289106 unist-util-visit@5.1.0:
76299107 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
76319112 universalify@0.1.2:
76329113 resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
76339114 engines: {node: '>= 4.0.0'}
......@@ -7765,9 +9246,16 @@ packages:
77659246 peerDependencies:
77669247 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
77689253 utf8-byte-length@1.0.5:
77699254 resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
77709255
9256 util-deprecate@1.0.2:
9257 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
9258
77719259 utils-merge@1.0.1:
77729260 resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
77739261 engines: {node: '>= 0.4.0'}
......@@ -7942,6 +9430,10 @@ packages:
79429430 web-namespaces@2.0.1:
79439431 resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
79449432
9433 webidl-conversions@5.0.0:
9434 resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==}
9435 engines: {node: '>=8'}
9436
79459437 webpack-virtual-modules@0.6.2:
79469438 resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
79479439
......@@ -7951,6 +9443,10 @@ packages:
79519443 whatwg-url-minimum@0.1.2:
79529444 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
79549450 which-pm-runs@1.1.0:
79559451 resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
79569452 engines: {node: '>=4'}
......@@ -7970,6 +9466,15 @@ packages:
79709466 engines: {node: ^20.17.0 || >=22.9.0}
79719467 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
79739478 wrap-ansi@6.2.0:
79749479 resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
79759480 engines: {node: '>=8'}
......@@ -7978,6 +9483,10 @@ packages:
79789483 resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
79799484 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
79819490 wrappy@1.0.2:
79829491 resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
79839492
......@@ -8009,6 +9518,18 @@ packages:
80099518 resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==}
80109519 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
80129533 xml2js@0.6.0:
80139534 resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==}
80149535 engines: {node: '>=4.0.0'}
......@@ -8021,6 +9542,10 @@ packages:
80219542 resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
80229543 engines: {node: '>=8.0'}
80239544
9545 xtend@4.0.2:
9546 resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
9547 engines: {node: '>=0.4'}
9548
80249549 xxhash-wasm@1.1.0:
80259550 resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
80269551
......@@ -8078,6 +9603,9 @@ packages:
80789603 resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
80799604 engines: {node: '>=18'}
80809605
9606 yoga-layout@3.2.1:
9607 resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
9608
80819609 zod-to-json-schema@3.25.2:
80829610 resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
80839611 peerDependencies:
......@@ -8121,6 +9649,13 @@ snapshots:
81219649
81229650 '@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
81249659 '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.141':
81259660 optional: true
81269661
......@@ -8297,6 +9832,220 @@ snapshots:
82979832 dependencies:
82989833 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
830010049 '@babel/code-frame@7.29.7':
830110050 dependencies:
830210051 '@babel/helper-validator-identifier': 7.29.7
......@@ -8813,33 +10562,238 @@ snapshots:
881310562 reselect: 5.2.0
881410563 use-sync-external-store: 1.6.0(react@19.2.6)
881510564 optionalDependencies:
8816 '@types/react': 19.2.16
10565 '@types/react': 19.2.16
10566
10567 '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
10568 dependencies:
10569 react: 19.2.3
10570 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
10571
10572 '@capsizecss/unpack@4.0.0':
10573 dependencies:
10574 fontkitten: 1.0.3
10575
10576 '@clack/core@0.5.0':
10577 dependencies:
10578 picocolors: 1.1.1
10579 sisteransi: 1.0.5
10580
10581 '@clack/core@1.4.0':
10582 dependencies:
10583 fast-wrap-ansi: 0.2.2
10584 sisteransi: 1.0.5
10585
10586 '@clack/prompts@0.11.0':
10587 dependencies:
10588 '@clack/core': 0.5.0
10589 picocolors: 1.1.1
10590 sisteransi: 1.0.5
10591
10592 '@clack/prompts@1.5.0':
10593 dependencies:
10594 '@clack/core': 1.4.0
10595 fast-string-width: 3.0.2
10596 fast-wrap-ansi: 0.2.2
10597 sisteransi: 1.0.5
10598
10599 '@clerk/backend@3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
10600 dependencies:
10601 '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
10602 standardwebhooks: 1.0.0
10603 tslib: 2.8.1
10604 transitivePeerDependencies:
10605 - react
10606 - react-dom
10607
10608 '@clerk/clerk-js@6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
10609 dependencies:
10610 '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10611 '@stripe/stripe-js': 5.6.0
10612 '@swc/helpers': 0.5.21
10613 '@tanstack/query-core': 5.100.14
10614 '@zxcvbn-ts/core': 3.0.4
10615 '@zxcvbn-ts/language-common': 3.0.4
10616 alien-signals: 2.0.6
10617 browser-tabs-lock: 1.3.0
10618 core-js: 3.47.0
10619 crypto-js: 4.2.0
10620 dequal: 2.0.3
10621 transitivePeerDependencies:
10622 - react
10623 - react-dom
10624
10625 '@clerk/clerk-js@6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
10626 dependencies:
10627 '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
10628 '@stripe/stripe-js': 5.6.0
10629 '@swc/helpers': 0.5.21
10630 '@tanstack/query-core': 5.100.14
10631 '@zxcvbn-ts/core': 3.0.4
10632 '@zxcvbn-ts/language-common': 3.0.4
10633 alien-signals: 2.0.6
10634 browser-tabs-lock: 1.3.0
10635 core-js: 3.47.0
10636 crypto-js: 4.2.0
10637 dequal: 2.0.3
10638 transitivePeerDependencies:
10639 - react
10640 - react-dom
10641
10642 '@clerk/expo@3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
10643 dependencies:
10644 '@clerk/clerk-js': 6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10645 '@clerk/react': 6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10646 '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10647 base-64: 1.0.0
10648 expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)
10649 react: 19.2.3
10650 react-dom: 19.2.3(react@19.2.3)
10651 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
10652 react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
10653 tslib: 2.8.1
10654 optionalDependencies:
10655 expo-auth-session: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
10656 expo-constants: 56.0.16(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
10657 expo-crypto: 56.0.4(expo@56.0.8)
10658 expo-secure-store: 56.0.4(expo@56.0.8)
10659 expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
10660
10661 '@clerk/react@6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
10662 dependencies:
10663 '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
10664 react: 19.2.3
10665 react-dom: 19.2.3(react@19.2.3)
10666 tslib: 2.8.1
10667
10668 '@clerk/react@6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
10669 dependencies:
10670 '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
10671 react: 19.2.6
10672 react-dom: 19.2.6(react@19.2.6)
10673 tslib: 2.8.1
10674
10675 '@clerk/shared@4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
10676 dependencies:
10677 '@tanstack/query-core': 5.100.14
10678 dequal: 2.0.3
10679 glob-to-regexp: 0.4.1
10680 js-cookie: 3.0.7
10681 optionalDependencies:
10682 react: 19.2.3
10683 react-dom: 19.2.3(react@19.2.3)
10684
10685 '@clerk/shared@4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
10686 dependencies:
10687 '@tanstack/query-core': 5.100.14
10688 dequal: 2.0.3
10689 glob-to-regexp: 0.4.1
10690 js-cookie: 3.0.7
10691 optionalDependencies:
10692 react: 19.2.6
10693 react-dom: 19.2.6(react@19.2.6)
10694
10695 '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1)':
10696 dependencies:
10697 unenv: 2.0.0-rc.24
10698 optionalDependencies:
10699 workerd: 1.20260526.1
10700
10701 '@cloudflare/workerd-darwin-64@1.20260526.1':
10702 optional: true
10703
10704 '@cloudflare/workerd-darwin-arm64@1.20260526.1':
10705 optional: true
10706
10707 '@cloudflare/workerd-linux-64@1.20260526.1':
10708 optional: true
10709
10710 '@cloudflare/workerd-linux-arm64@1.20260526.1':
10711 optional: true
10712
10713 '@cloudflare/workerd-windows-64@1.20260526.1':
10714 optional: true
10715
10716 '@cloudflare/workers-types@4.20260604.1': {}
10717
10718 '@develar/schema-utils@2.6.5':
10719 dependencies:
10720 ajv: 6.15.0
10721 ajv-keywords: 3.5.2(ajv@6.15.0)
10722
10723 '@distilled.cloud/aws@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))':
10724 dependencies:
10725 '@aws-crypto/crc32': 5.2.0
10726 '@aws-crypto/util': 5.2.0
10727 '@aws-sdk/credential-providers': 3.1062.0
10728 '@aws-sdk/types': 3.973.10
10729 '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
10730 '@smithy/shared-ini-file-loader': 4.5.6
10731 '@smithy/types': 4.14.3
10732 '@smithy/util-base64': 4.4.6
10733 aws4fetch: 1.0.20
10734 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
10735 fast-xml-parser: 5.8.0
10736
10737 '@distilled.cloud/axiom@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))':
10738 dependencies:
10739 '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
10740 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
10741
10742 '@distilled.cloud/cloudflare-rolldown-plugin@0.10.5(rolldown@1.0.1)(workerd@1.20260526.1)':
10743 dependencies:
10744 '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260526.1)
10745 magic-string: 0.30.21
10746 unenv: 2.0.0-rc.24
10747 optionalDependencies:
10748 rolldown: 1.0.1
10749 transitivePeerDependencies:
10750 - workerd
10751
10752 '@distilled.cloud/cloudflare-runtime@0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))':
10753 dependencies:
10754 '@alchemy.run/node-utils': 0.0.4
10755 '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
10756 capnweb: 0.7.0
10757 chokidar: 4.0.3
10758 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
10759 workerd: 1.20260526.1
10760 xdg-app-paths: 8.3.0
10761 optionalDependencies:
10762 '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6)
10763 '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6)
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)':
881910766 dependencies:
8820 react: 19.2.3
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)
10767 '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
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))':
882410780 dependencies:
8825 fontkitten: 1.0.3
10781 '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
10782 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
882610783
8827 '@clack/core@1.4.0':
10784 '@distilled.cloud/core@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))':
882810785 dependencies:
8829 fast-wrap-ansi: 0.2.2
8830 sisteransi: 1.0.5
10786 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
883110787
8832 '@clack/prompts@1.5.0':
10788 '@distilled.cloud/neon@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))':
883310789 dependencies:
8834 '@clack/core': 1.4.0
8835 fast-string-width: 3.0.2
8836 fast-wrap-ansi: 0.2.2
8837 sisteransi: 1.0.5
10790 '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
10791 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
883810792
8839 '@develar/schema-utils@2.6.5':
10793 '@distilled.cloud/planetscale@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))':
884010794 dependencies:
8841 ajv: 6.15.0
8842 ajv-keywords: 3.5.2(ajv@6.15.0)
10795 '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))
10796 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
884310797
884410798 '@dnd-kit/accessibility@3.1.1(react@19.2.6)':
884510799 dependencies:
......@@ -8873,44 +10827,46 @@ snapshots:
887310827 react: 19.2.6
887410828 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)':
887710833 dependencies:
8878 effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
10834 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
887910835 react: 19.2.3
888010836 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)':
888310839 dependencies:
8884 effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
10840 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
888510841 react: 19.2.6
888610842 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))':
888910845 dependencies:
8890 '@effect/platform-node': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))(ioredis@5.11.0)
8891 effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
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)
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)':
889410850 dependencies:
8895 '@effect/platform-node-shared': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))
8896 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)
10852 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
889710853 transitivePeerDependencies:
889810854 - bufferutil
889910855 - 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)':
890210858 dependencies:
890310859 '@types/ws': 8.18.1
8904 effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
8905 ws: 8.21.0
10860 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
10861 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
890610862 transitivePeerDependencies:
890710863 - bufferutil
890810864 - 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)':
891110867 dependencies:
8912 '@effect/platform-node-shared': 4.0.0-beta.73(effect@4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01))
8913 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)
10869 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
891410870 ioredis: 5.11.0
891510871 mime: 4.1.0
891610872 undici: 8.3.0
......@@ -8918,9 +10874,20 @@ snapshots:
891810874 - bufferutil
891910875 - 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))':
892210889 dependencies:
8923 effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
10890 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
892410891
892510892 '@effect/tsgo-darwin-arm64@0.11.4':
892610893 optional: true
......@@ -8953,10 +10920,10 @@ snapshots:
895310920 '@effect/tsgo-win32-arm64': 0.11.4
895410921 '@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))':
895710924 dependencies:
8958 effect: 4.0.0-beta.73(patch_hash=a28d840fa97ffebabe628e5562837c537d83c40cfdad8049de73c38086f2fb01)
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)'
10925 effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)
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
896110928 '@egjs/hammerjs@2.0.17':
896210929 dependencies:
......@@ -9096,81 +11063,159 @@ snapshots:
909611063
909711064 '@epic-web/invariant@1.0.0': {}
909811065
11066 '@esbuild/aix-ppc64@0.25.12':
11067 optional: true
11068
909911069 '@esbuild/aix-ppc64@0.27.7':
910011070 optional: true
910111071
11072 '@esbuild/android-arm64@0.25.12':
11073 optional: true
11074
910211075 '@esbuild/android-arm64@0.27.7':
910311076 optional: true
910411077
11078 '@esbuild/android-arm@0.25.12':
11079 optional: true
11080
910511081 '@esbuild/android-arm@0.27.7':
910611082 optional: true
910711083
11084 '@esbuild/android-x64@0.25.12':
11085 optional: true
11086
910811087 '@esbuild/android-x64@0.27.7':
910911088 optional: true
911011089
11090 '@esbuild/darwin-arm64@0.25.12':
11091 optional: true
11092
911111093 '@esbuild/darwin-arm64@0.27.7':
911211094 optional: true
911311095
11096 '@esbuild/darwin-x64@0.25.12':
11097 optional: true
11098
911411099 '@esbuild/darwin-x64@0.27.7':
911511100 optional: true
911611101
11102 '@esbuild/freebsd-arm64@0.25.12':
11103 optional: true
11104
911711105 '@esbuild/freebsd-arm64@0.27.7':
911811106 optional: true
911911107
11108 '@esbuild/freebsd-x64@0.25.12':
11109 optional: true
11110
912011111 '@esbuild/freebsd-x64@0.27.7':
912111112 optional: true
912211113
11114 '@esbuild/linux-arm64@0.25.12':
11115 optional: true
11116
912311117 '@esbuild/linux-arm64@0.27.7':
912411118 optional: true
912511119
11120 '@esbuild/linux-arm@0.25.12':
11121 optional: true
11122
912611123 '@esbuild/linux-arm@0.27.7':
912711124 optional: true
912811125
11126 '@esbuild/linux-ia32@0.25.12':
11127 optional: true
11128
912911129 '@esbuild/linux-ia32@0.27.7':
913011130 optional: true
913111131
11132 '@esbuild/linux-loong64@0.25.12':
11133 optional: true
11134
913211135 '@esbuild/linux-loong64@0.27.7':
913311136 optional: true
913411137
11138 '@esbuild/linux-mips64el@0.25.12':
11139 optional: true
11140
913511141 '@esbuild/linux-mips64el@0.27.7':
913611142 optional: true
913711143
11144 '@esbuild/linux-ppc64@0.25.12':
11145 optional: true
11146
913811147 '@esbuild/linux-ppc64@0.27.7':
913911148 optional: true
914011149
11150 '@esbuild/linux-riscv64@0.25.12':
11151 optional: true
11152
914111153 '@esbuild/linux-riscv64@0.27.7':
914211154 optional: true
914311155
11156 '@esbuild/linux-s390x@0.25.12':
11157 optional: true
11158
914411159 '@esbuild/linux-s390x@0.27.7':
914511160 optional: true
914611161
11162 '@esbuild/linux-x64@0.25.12':
11163 optional: true
11164
914711165 '@esbuild/linux-x64@0.27.7':
914811166 optional: true
914911167
11168 '@esbuild/netbsd-arm64@0.25.12':
11169 optional: true
11170
915011171 '@esbuild/netbsd-arm64@0.27.7':
915111172 optional: true
915211173
11174 '@esbuild/netbsd-x64@0.25.12':
11175 optional: true
11176
915311177 '@esbuild/netbsd-x64@0.27.7':
915411178 optional: true
915511179
11180 '@esbuild/openbsd-arm64@0.25.12':
11181 optional: true
11182
915611183 '@esbuild/openbsd-arm64@0.27.7':
915711184 optional: true
915811185
11186 '@esbuild/openbsd-x64@0.25.12':
11187 optional: true
11188
915911189 '@esbuild/openbsd-x64@0.27.7':
916011190 optional: true
916111191
11192 '@esbuild/openharmony-arm64@0.25.12':
11193 optional: true
11194
916211195 '@esbuild/openharmony-arm64@0.27.7':
916311196 optional: true
916411197
11198 '@esbuild/sunos-x64@0.25.12':
11199 optional: true
11200
916511201 '@esbuild/sunos-x64@0.27.7':
916611202 optional: true
916711203
11204 '@esbuild/win32-arm64@0.25.12':
11205 optional: true
11206
916811207 '@esbuild/win32-arm64@0.27.7':
916911208 optional: true
917011209
11210 '@esbuild/win32-ia32@0.25.12':
11211 optional: true
11212
917111213 '@esbuild/win32-ia32@0.27.7':
917211214 optional: true
917311215
11216 '@esbuild/win32-x64@0.25.12':
11217 optional: true
11218
917411219 '@esbuild/win32-x64@0.27.7':
917511220 optional: true
917611221
......@@ -9178,7 +11223,7 @@ snapshots:
917811223
917911224 '@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)':
918211227 dependencies:
918311228 '@expo/code-signing-certificates': 0.0.6
918411229 '@expo/config': 56.0.9(typescript@6.0.3)
......@@ -9188,9 +11233,9 @@ snapshots:
918811233 '@expo/image-utils': 0.10.1(typescript@6.0.3)
918911234 '@expo/inline-modules': 0.0.10(typescript@6.0.3)
919011235 '@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)
9192 '@expo/metro': 56.0.0
9193 '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(expo@56.0.8)(typescript@6.0.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)
11237 '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
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)
919411239 '@expo/metro-file-map': 56.0.3
919511240 '@expo/osascript': 2.6.0
919611241 '@expo/package-manager': 1.12.1
......@@ -9202,7 +11247,7 @@ snapshots:
920211247 '@expo/spawn-async': 1.8.0
920311248 '@expo/ws-tunnel': 1.0.6
920411249 '@expo/xcpretty': 4.4.4
9205 '@react-native/dev-middleware': 0.85.3
11250 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
920611251 accepts: 1.3.8
920711252 arg: 5.0.2
920811253 bplist-creator: 0.1.0
......@@ -9213,7 +11258,7 @@ snapshots:
921311258 connect: 3.7.0
921411259 debug: 4.4.3
921511260 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)
921711262 expo-server: 56.0.4
921811263 fetch-nodeshim: 0.4.10
921911264 getenv: 2.0.0
......@@ -9236,11 +11281,11 @@ snapshots:
923611281 terminal-link: 2.1.1
923711282 toqr: 0.1.1
923811283 wrap-ansi: 7.0.0
9239 ws: 8.21.0
11284 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
924011285 zod: 3.25.76
924111286 optionalDependencies:
9242 expo-router: 56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51)
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)
11287 expo-router: 56.2.8(c021de11d02907bd585610408f5252e8)
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)
924411289 transitivePeerDependencies:
924511290 - '@expo/dom-webview'
924611291 - '@expo/metro-runtime'
......@@ -9302,18 +11347,18 @@ snapshots:
930211347 transitivePeerDependencies:
930311348 - 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)':
930611351 dependencies:
930711352 chalk: 4.1.2
930811353 optionalDependencies:
930911354 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)':
931311358 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)
931511360 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
931811363 '@expo/env@2.3.0':
931911364 dependencies:
......@@ -9374,16 +11419,16 @@ snapshots:
937411419 - supports-color
937511420 - 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)':
937811423 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)
938011425 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)
938211427 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)
938411429 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)':
938711432 dependencies:
938811433 '@babel/code-frame': 7.29.7
938911434 '@babel/core': 7.29.7
......@@ -9391,7 +11436,7 @@ snapshots:
939111436 '@expo/config': 56.0.9(typescript@6.0.3)
939211437 '@expo/env': 2.3.0
939311438 '@expo/json-file': 10.2.0
9394 '@expo/metro': 56.0.0
11439 '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
939511440 '@expo/require-utils': 56.1.3(typescript@6.0.3)
939611441 '@expo/spawn-async': 1.8.0
939711442 '@jridgewell/gen-mapping': 0.3.13
......@@ -9410,7 +11455,7 @@ snapshots:
941011455 postcss: 8.5.15
941111456 resolve-from: 5.0.0
941211457 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)
941411459 transitivePeerDependencies:
941511460 - bufferutil
941611461 - supports-color
......@@ -9428,26 +11473,26 @@ snapshots:
942811473 transitivePeerDependencies:
942911474 - 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)':
943211477 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)
943411479 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)
943611481 pretty-format: 29.7.0
943711482 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)
943911484 stacktrace-parser: 0.1.11
944011485 whatwg-fetch: 3.6.20
944111486 optionalDependencies:
944211487 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)':
944511490 dependencies:
9446 metro: 0.84.4
11491 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
944711492 metro-babel-transformer: 0.84.4
944811493 metro-cache: 0.84.4
944911494 metro-cache-key: 0.84.4
9450 metro-config: 0.84.4
11495 metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
945111496 metro-core: 0.84.4
945211497 metro-file-map: 0.84.4
945311498 metro-minify-terser: 0.84.4
......@@ -9456,7 +11501,7 @@ snapshots:
945611501 metro-source-map: 0.84.4
945711502 metro-symbolicate: 0.84.4
945811503 metro-transform-plugins: 0.84.4
9459 metro-transform-worker: 0.84.4
11504 metro-transform-worker: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
946011505 transitivePeerDependencies:
946111506 - bufferutil
946211507 - supports-color
......@@ -9510,14 +11555,14 @@ snapshots:
951011555 '@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)':
951111556 dependencies:
951211557 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)
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))
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)
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)
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))
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)
951611561 expo-server: 56.0.4
951711562 react: 19.2.3
951811563 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)
9520 expo-router: 56.2.8(4d9a97830dbb31ffd5bc7485c7ce9e51)
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)
11565 expo-router: 56.2.8(c021de11d02907bd585610408f5252e8)
952111566 react-dom: 19.2.3(react@19.2.3)
952211567 transitivePeerDependencies:
952311568 - supports-color
......@@ -9532,18 +11577,18 @@ snapshots:
953211577
953311578 '@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)':
953611581 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)
953811583 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)
954011585 sf-symbols-typescript: 2.2.0
954111586 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)
954211587 optionalDependencies:
954311588 '@babel/core': 7.29.7
954411589 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)
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)
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)
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)
954711592 transitivePeerDependencies:
954811593 - '@types/react'
954911594 - '@types/react-dom'
......@@ -9757,13 +11802,17 @@ snapshots:
975711802 '@jridgewell/resolve-uri': 3.1.2
975811803 '@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)':
976111810 dependencies:
976211811 react: 19.2.3
976311812 use-sync-external-store: 1.6.0(react@19.2.3)
976411813 optionalDependencies:
976511814 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
976811817 '@legendapp/list@3.0.0-beta.44(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
976911818 dependencies:
......@@ -9931,6 +11980,64 @@ snapshots:
993111980 lexical: 0.41.0
993211981 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
993412041 '@malept/cross-spawn-promise@2.0.0':
993512042 dependencies:
993612043 cross-spawn: 7.0.6
......@@ -10000,6 +12107,91 @@ snapshots:
1000012107 '@tybys/wasm-util': 0.10.2
1000112108 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
1000312195 '@open-draft/deferred-promise@2.2.0': {}
1000412196
1000512197 '@open-draft/logger@0.3.0':
......@@ -10020,6 +12212,8 @@ snapshots:
1002012212 '@oxc-project/types@0.127.0':
1002112213 optional: true
1002212214
12215 '@oxc-project/types@0.130.0': {}
12216
1002312217 '@oxc-project/types@0.133.0': {}
1002412218
1002512219 '@oxfmt/binding-android-arm-eabi@0.52.0':
......@@ -10387,15 +12581,15 @@ snapshots:
1038712581 optionalDependencies:
1038812582 '@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)':
1039112585 dependencies:
1039212586 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)':
1039612590 dependencies:
1039712591 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
1040012594 '@react-native/assets-registry@0.85.3': {}
1040112595
......@@ -10455,13 +12649,13 @@ snapshots:
1045512649 tinyglobby: 0.2.17
1045612650 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)':
1045912653 dependencies:
10460 '@react-native/dev-middleware': 0.85.3
12654 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1046112655 debug: 4.4.3
1046212656 invariant: 2.2.4
10463 metro: 0.84.4
10464 metro-config: 0.84.4
12657 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
12658 metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1046512659 metro-core: 0.84.4
1046612660 semver: 7.8.1
1046712661 optionalDependencies:
......@@ -10481,7 +12675,7 @@ snapshots:
1048112675 transitivePeerDependencies:
1048212676 - 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)':
1048512679 dependencies:
1048612680 '@isaacs/ttlcache': 1.4.1
1048712681 '@react-native/debugger-frontend': 0.85.3
......@@ -10494,7 +12688,7 @@ snapshots:
1049412688 nullthrows: 1.1.1
1049512689 open: 7.4.2
1049612690 serve-static: 1.16.3
10497 ws: 7.5.11
12691 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1049812692 transitivePeerDependencies:
1049912693 - bufferutil
1050012694 - supports-color
......@@ -10517,94 +12711,128 @@ snapshots:
1051712711 dependencies:
1051812712 '@react-native/js-polyfills': 0.85.3
1051912713 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7)
10520 metro-config: 0.84.4
12714 metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1052112715 metro-runtime: 0.84.4
1052212716 transitivePeerDependencies:
1052312717 - '@babel/core'
10524 - bufferutil
1052512718 - supports-color
10526 - utf-8-validate
1052712719
1052812720 '@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)':
1053112723 dependencies:
1053212724 invariant: 2.2.4
1053312725 nullthrows: 1.1.1
1053412726 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)
1053612728 optionalDependencies:
1053712729 '@types/react': 19.2.16
1053812730
1053912731 '@rolldown/binding-android-arm64@1.0.0-rc.17':
1054012732 optional: true
1054112733
12734 '@rolldown/binding-android-arm64@1.0.1':
12735 optional: true
12736
1054212737 '@rolldown/binding-android-arm64@1.0.3':
1054312738 optional: true
1054412739
1054512740 '@rolldown/binding-darwin-arm64@1.0.0-rc.17':
1054612741 optional: true
1054712742
12743 '@rolldown/binding-darwin-arm64@1.0.1':
12744 optional: true
12745
1054812746 '@rolldown/binding-darwin-arm64@1.0.3':
1054912747 optional: true
1055012748
1055112749 '@rolldown/binding-darwin-x64@1.0.0-rc.17':
1055212750 optional: true
1055312751
12752 '@rolldown/binding-darwin-x64@1.0.1':
12753 optional: true
12754
1055412755 '@rolldown/binding-darwin-x64@1.0.3':
1055512756 optional: true
1055612757
1055712758 '@rolldown/binding-freebsd-x64@1.0.0-rc.17':
1055812759 optional: true
1055912760
12761 '@rolldown/binding-freebsd-x64@1.0.1':
12762 optional: true
12763
1056012764 '@rolldown/binding-freebsd-x64@1.0.3':
1056112765 optional: true
1056212766
1056312767 '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17':
1056412768 optional: true
1056512769
12770 '@rolldown/binding-linux-arm-gnueabihf@1.0.1':
12771 optional: true
12772
1056612773 '@rolldown/binding-linux-arm-gnueabihf@1.0.3':
1056712774 optional: true
1056812775
1056912776 '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17':
1057012777 optional: true
1057112778
12779 '@rolldown/binding-linux-arm64-gnu@1.0.1':
12780 optional: true
12781
1057212782 '@rolldown/binding-linux-arm64-gnu@1.0.3':
1057312783 optional: true
1057412784
1057512785 '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
1057612786 optional: true
1057712787
12788 '@rolldown/binding-linux-arm64-musl@1.0.1':
12789 optional: true
12790
1057812791 '@rolldown/binding-linux-arm64-musl@1.0.3':
1057912792 optional: true
1058012793
1058112794 '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
1058212795 optional: true
1058312796
12797 '@rolldown/binding-linux-ppc64-gnu@1.0.1':
12798 optional: true
12799
1058412800 '@rolldown/binding-linux-ppc64-gnu@1.0.3':
1058512801 optional: true
1058612802
1058712803 '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
1058812804 optional: true
1058912805
12806 '@rolldown/binding-linux-s390x-gnu@1.0.1':
12807 optional: true
12808
1059012809 '@rolldown/binding-linux-s390x-gnu@1.0.3':
1059112810 optional: true
1059212811
1059312812 '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
1059412813 optional: true
1059512814
12815 '@rolldown/binding-linux-x64-gnu@1.0.1':
12816 optional: true
12817
1059612818 '@rolldown/binding-linux-x64-gnu@1.0.3':
1059712819 optional: true
1059812820
1059912821 '@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
1060012822 optional: true
1060112823
12824 '@rolldown/binding-linux-x64-musl@1.0.1':
12825 optional: true
12826
1060212827 '@rolldown/binding-linux-x64-musl@1.0.3':
1060312828 optional: true
1060412829
1060512830 '@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
1060612831 optional: true
1060712832
12833 '@rolldown/binding-openharmony-arm64@1.0.1':
12834 optional: true
12835
1060812836 '@rolldown/binding-openharmony-arm64@1.0.3':
1060912837 optional: true
1061012838
......@@ -10615,6 +12843,13 @@ snapshots:
1061512843 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
1061612844 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
1061812853 '@rolldown/binding-wasm32-wasi@1.0.3':
1061912854 dependencies:
1062012855 '@emnapi/core': 1.10.0
......@@ -10625,12 +12860,18 @@ snapshots:
1062512860 '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17':
1062612861 optional: true
1062712862
12863 '@rolldown/binding-win32-arm64-msvc@1.0.1':
12864 optional: true
12865
1062812866 '@rolldown/binding-win32-arm64-msvc@1.0.3':
1062912867 optional: true
1063012868
1063112869 '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17':
1063212870 optional: true
1063312871
12872 '@rolldown/binding-win32-x64-msvc@1.0.1':
12873 optional: true
12874
1063412875 '@rolldown/binding-win32-x64-msvc@1.0.3':
1063512876 optional: true
1063612877
......@@ -10812,8 +13053,79 @@ snapshots:
1081213053
1081313054 '@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
1081513121 '@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
1081713129 '@szmarczak/http-timer@4.0.6':
1081813130 dependencies:
1081913131 defer-to-connect: 2.0.1
......@@ -11089,6 +13401,8 @@ snapshots:
1108913401
1109013402 '@types/aria-query@5.0.4': {}
1109113403
13404 '@types/aws-lambda@8.10.161': {}
13405
1109213406 '@types/babel__core@7.20.5':
1109313407 dependencies:
1109413408 '@babel/parser': 7.29.7
......@@ -11329,7 +13643,7 @@ snapshots:
1132913643 '@voidzero-dev/vite-plus-linux-x64-musl@0.1.24':
1133013644 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)':
1133313647 dependencies:
1133413648 '@standard-schema/spec': 1.1.0
1133513649 '@types/chai': 5.2.3
......@@ -11344,7 +13658,7 @@ snapshots:
1134413658 tinyexec: 1.2.4
1134513659 tinyglobby: 0.2.17
1134613660 vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)'
11347 ws: 8.21.0
13661 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1134813662 optionalDependencies:
1134913663 '@types/node': 24.12.4
1135013664 transitivePeerDependencies:
......@@ -11433,6 +13747,12 @@ snapshots:
1143313747
1143413748 '@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
1143613756 abbrev@4.0.0: {}
1143713757
1143813758 abort-controller@3.0.0:
......@@ -11479,16 +13799,80 @@ snapshots:
1147913799 json-schema-traverse: 1.0.0
1148013800 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
1148213860 anser@1.4.10: {}
1148313861
1148413862 ansi-escapes@4.3.2:
1148513863 dependencies:
1148613864 type-fest: 0.21.3
1148713865
13866 ansi-escapes@7.3.0:
13867 dependencies:
13868 environment: 1.1.0
13869
1148813870 ansi-regex@4.1.1: {}
1148913871
1149013872 ansi-regex@5.0.1: {}
1149113873
13874 ansi-regex@6.2.2: {}
13875
1149213876 ansi-styles@3.2.1:
1149313877 dependencies:
1149413878 color-convert: 1.9.3
......@@ -11499,6 +13883,8 @@ snapshots:
1149913883
1150013884 ansi-styles@5.2.0: {}
1150113885
13886 ansi-styles@6.2.3: {}
13887
1150213888 ansis@4.3.1: {}
1150313889
1150413890 anymatch@3.1.3:
......@@ -11579,7 +13965,7 @@ snapshots:
1157913965 astral-regex@2.0.0:
1158013966 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):
1158313969 dependencies:
1158413970 '@astrojs/compiler': 4.0.0
1158513971 '@astrojs/internal-helpers': 0.10.0
......@@ -11629,7 +14015,7 @@ snapshots:
1162914015 ultrahtml: 1.6.0
1163014016 unifont: 0.7.4
1163114017 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)
1163314019 vfile: 6.0.3
1163414020 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)'
1163514021 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:
1168714073
1168814074 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
1169014082 axobject-query@4.1.0: {}
1169114083
1169214084 babel-dead-code-elimination@1.0.12:
......@@ -11738,7 +14130,7 @@ snapshots:
1173814130 transitivePeerDependencies:
1173914131 - '@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):
1174214134 dependencies:
1174314135 '@babel/generator': 7.29.7
1174414136 '@babel/helper-module-imports': 7.29.7
......@@ -11785,11 +14177,14 @@ snapshots:
1178514177 react-refresh: 0.14.2
1178614178 optionalDependencies:
1178714179 '@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)
1178914182 transitivePeerDependencies:
1179014183 - '@babel/core'
1179114184 - supports-color
1179214185
14186 badgin@1.2.3: {}
14187
1179314188 bail@2.0.2: {}
1179414189
1179514190 balanced-match@1.0.2: {}
......@@ -11802,10 +14197,14 @@ snapshots:
1180214197 transitivePeerDependencies:
1180314198 - '@types/emscripten'
1180414199
14200 base-64@1.0.0: {}
14201
1180514202 base64-js@1.5.1: {}
1180614203
1180714204 baseline-browser-mapping@2.10.33: {}
1180814205
14206 before-after-hook@4.0.0: {}
14207
1180914208 big-integer@1.6.52: {}
1181014209
1181114210 body-parser@2.2.2:
......@@ -11827,6 +14226,8 @@ snapshots:
1182714226 boolean@3.2.0:
1182814227 optional: true
1182914228
14229 bowser@2.14.1: {}
14230
1183014231 bplist-creator@0.1.0:
1183114232 dependencies:
1183214233 stream-buffers: 2.2.0
......@@ -11856,6 +14257,10 @@ snapshots:
1185614257 dependencies:
1185714258 fill-range: 7.1.1
1185814259
14260 browser-tabs-lock@1.3.0:
14261 dependencies:
14262 lodash: 4.18.1
14263
1185914264 browserslist@4.28.2:
1186014265 dependencies:
1186114266 baseline-browser-mapping: 2.10.33
......@@ -11876,6 +14281,10 @@ snapshots:
1187614281 dependencies:
1187714282 base64-js: 1.5.1
1187814283 ieee754: 1.2.1
14284
14285 bufferutil@4.1.0:
14286 dependencies:
14287 node-gyp-build: 4.8.4
1187914288 optional: true
1188014289
1188114290 builder-util-runtime@9.5.1:
......@@ -11938,6 +14347,10 @@ snapshots:
1193814347
1193914348 caniuse-lite@1.0.30001793: {}
1194014349
14350 capnweb@0.6.1: {}
14351
14352 capnweb@0.7.0: {}
14353
1194114354 ccount@2.0.1: {}
1194214355
1194314356 chalk@2.4.2:
......@@ -11951,6 +14364,8 @@ snapshots:
1195114364 ansi-styles: 4.3.0
1195214365 supports-color: 7.2.0
1195314366
14367 chalk@5.6.2: {}
14368
1195414369 character-entities-html4@2.1.0: {}
1195514370
1195614371 character-entities-legacy@3.0.0: {}
......@@ -12002,10 +14417,16 @@ snapshots:
1200214417 dependencies:
1200314418 clsx: 2.1.1
1200414419
14420 cli-boxes@3.0.0: {}
14421
1200514422 cli-cursor@2.1.0:
1200614423 dependencies:
1200714424 restore-cursor: 2.0.0
1200814425
14426 cli-cursor@4.0.0:
14427 dependencies:
14428 restore-cursor: 4.0.0
14429
1200914430 cli-spinners@2.9.2: {}
1201014431
1201114432 cli-truncate@2.1.0:
......@@ -12014,6 +14435,11 @@ snapshots:
1201414435 string-width: 4.2.3
1201514436 optional: true
1201614437
14438 cli-truncate@5.2.0:
14439 dependencies:
14440 slice-ansi: 8.0.0
14441 string-width: 8.2.1
14442
1201714443 cli-width@4.1.0: {}
1201814444
1201914445 client-only@0.0.1: {}
......@@ -12034,6 +14460,10 @@ snapshots:
1203414460
1203514461 cluster-key-slot@1.1.1: {}
1203614462
14463 code-excerpt@4.0.0:
14464 dependencies:
14465 convert-to-spaces: 2.0.1
14466
1203714467 color-convert@1.9.3:
1203814468 dependencies:
1203914469 color-name: 1.1.3
......@@ -12114,6 +14544,8 @@ snapshots:
1211414544
1211514545 convert-source-map@2.0.0: {}
1211614546
14547 convert-to-spaces@2.0.1: {}
14548
1211714549 cookie-es@1.2.3: {}
1211814550
1211914551 cookie-es@3.1.1: {}
......@@ -12128,8 +14560,9 @@ snapshots:
1212814560 dependencies:
1212914561 browserslist: 4.28.2
1213014562
12131 core-util-is@1.0.2:
12132 optional: true
14563 core-js@3.47.0: {}
14564
14565 core-util-is@1.0.2: {}
1213314566
1213414567 cors@2.8.6:
1213514568 dependencies:
......@@ -12159,6 +14592,8 @@ snapshots:
1215914592 dependencies:
1216014593 uncrypto: 0.1.3
1216114594
14595 crypto-js@4.2.0: {}
14596
1216214597 css-select@5.2.2:
1216314598 dependencies:
1216414599 boolbase: 1.0.0
......@@ -12252,6 +14687,8 @@ snapshots:
1225214687
1225314688 destroy@1.2.0: {}
1225414689
14690 detect-libc@2.0.2: {}
14691
1225514692 detect-libc@2.1.2: {}
1225614693
1225714694 detect-node-es@1.1.0: {}
......@@ -12327,6 +14764,25 @@ snapshots:
1232714764
1232814765 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
1233014786 dset@3.1.4: {}
1233114787
1233214788 dunder-proto@1.0.1:
......@@ -12337,7 +14793,7 @@ snapshots:
1233714793
1233814794 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):
1234114797 dependencies:
1234214798 '@standard-schema/spec': 1.1.0
1234314799 fast-check: 4.8.0
......@@ -12432,6 +14888,8 @@ snapshots:
1243214888 '@emmetio/abbreviation': 2.3.3
1243314889 '@emmetio/css-abbreviation': 2.1.8
1243414890
14891 emoji-regex@10.6.0: {}
14892
1243514893 emoji-regex@8.0.0: {}
1243614894
1243714895 encodeurl@1.0.2: {}
......@@ -12453,6 +14911,8 @@ snapshots:
1245314911
1245414912 env-paths@2.2.1: {}
1245514913
14914 environment@1.1.0: {}
14915
1245614916 err-code@2.0.3: {}
1245714917
1245814918 error-stack-parser@2.1.4:
......@@ -12478,9 +14938,40 @@ snapshots:
1247814938 has-tostringtag: 1.0.2
1247914939 hasown: 2.0.4
1248014940
14941 es-toolkit@1.47.0: {}
14942
1248114943 es6-error@4.1.1:
1248214944 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
1248414975 esbuild@0.27.7:
1248514976 optionalDependencies:
1248614977 '@esbuild/aix-ppc64': 0.27.7
......@@ -12516,6 +15007,8 @@ snapshots:
1251615007
1251715008 escape-string-regexp@1.0.5: {}
1251815009
15010 escape-string-regexp@2.0.0: {}
15011
1251915012 escape-string-regexp@4.0.0: {}
1252015013
1252115014 escape-string-regexp@5.0.0: {}
......@@ -12536,133 +15029,151 @@ snapshots:
1253615029 dependencies:
1253715030 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):
1254015037 dependencies:
1254115038 '@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)
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))
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)
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))
1254415041 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)
1254615043 transitivePeerDependencies:
1254715044 - supports-color
1254815045 - 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
1255015061 expo-build-properties@56.0.16(expo@56.0.8):
1255115062 dependencies:
1255215063 '@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)
1255415065 resolve-from: 5.0.0
1255515066 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):
1255815069 dependencies:
1255915070 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)
1256115072 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)
1256315074 transitivePeerDependencies:
1256415075 - '@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):
1256715078 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)
1256915080 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)):
1257315084 dependencies:
1257415085 '@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)
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)
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)
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)
1257715088 transitivePeerDependencies:
1257815089 - supports-color
1257915090
1258015091 expo-crypto@56.0.4(expo@56.0.8):
1258115092 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)):
1258515096 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)
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))
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))
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)
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))
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))
1258915100 expo-dev-menu-interface: 56.0.1(expo@56.0.8)
1259015101 expo-manifests: 56.0.4(expo@56.0.8)
1259115102 expo-updates-interface: 56.0.2(expo@56.0.8)
1259215103 transitivePeerDependencies:
1259315104 - 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)):
1259615107 dependencies:
1259715108 '@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)
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))
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)
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))
1260015111 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
1260315114 expo-dev-menu-interface@56.0.1(expo@56.0.8):
1260415115 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)):
1260815119 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)
1261015121 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
1261315124 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)):
1261615127 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)
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)
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)
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):
1262115132 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)
1262315134 fontfaceobserver: 2.3.0
1262415135 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):
1262815139 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)
1263015141 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
1263315144 expo-haptics@56.0.3(expo@56.0.8):
1263415145 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
1263715148 expo-image-loader@56.0.3(expo@56.0.8):
1263815149 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
1264115152 expo-image-picker@56.0.15(expo@56.0.8):
1264215153 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)
1264415155 expo-image-loader: 56.0.3(expo@56.0.8)
1264515156
1264615157 expo-json-utils@56.0.0: {}
1264715158
1264815159 expo-keep-awake@56.0.3(expo@56.0.8)(react@19.2.3):
1264915160 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)
1265115162 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):
1265415165 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))
1265615167 invariant: 2.2.4
1265715168 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)
1265915170 transitivePeerDependencies:
1266015171 - expo
1266115172 - supports-color
1266215173
1266315174 expo-manifests@56.0.4(expo@56.0.8):
1266415175 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)
1266615177 expo-json-utils: 56.0.0
1266715178
1266815179 expo-modules-autolinking@56.0.14(typescript@6.0.3):
......@@ -12675,47 +15186,61 @@ snapshots:
1267515186 - supports-color
1267615187 - 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):
1267915190 dependencies:
1268015191 '@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))
1268215193 invariant: 2.2.4
1268315194 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)
1268515196 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)):
1268915200 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):
1269315218 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)
1269515220 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):
1269915224 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)
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)
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)
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)
1270215227 '@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)
1270415229 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3)
1270515230 '@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)
1270715232 '@testing-library/jest-dom': 6.9.1
1270815233 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
1270915234 client-only: 0.0.1
1271015235 color: 4.2.3
1271115236 debug: 4.4.3
1271215237 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)
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))
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)
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)
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)
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))
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)
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)
1271715242 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)
1271915244 fast-deep-equal: 3.1.3
1272015245 invariant: 2.2.4
1272115246 nanoid: 3.3.12
......@@ -12723,18 +15248,18 @@ snapshots:
1272315248 react: 19.2.3
1272415249 react-fast-compare: 3.2.2
1272515250 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)
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)
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)
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)
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)
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)
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)
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)
1273015255 server-only: 0.0.1
1273115256 sf-symbols-typescript: 2.2.0
1273215257 shallowequal: 1.1.0
1273315258 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)
1273415259 optionalDependencies:
1273515260 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)
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)
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)
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)
1273815263 transitivePeerDependencies:
1273915264 - '@babel/core'
1274015265 - '@testing-library/dom'
......@@ -12746,7 +15271,7 @@ snapshots:
1274615271
1274715272 expo-secure-store@56.0.4(expo@56.0.8):
1274815273 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
1275115276 expo-server@56.0.4: {}
1275215277
......@@ -12754,7 +15279,7 @@ snapshots:
1275415279 dependencies:
1275515280 '@expo/config-plugins': 56.0.8(typescript@6.0.3)
1275615281 '@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)
1275815283 xml2js: 0.6.0
1275915284 transitivePeerDependencies:
1276015285 - supports-color
......@@ -12762,20 +15287,20 @@ snapshots:
1276215287
1276315288 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):
1276615291 dependencies:
1276715292 '@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)
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)
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)
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)
1277015295 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)
1277215297 sf-symbols-typescript: 2.2.0
1277315298
1277415299 expo-updates-interface@56.0.2(expo@56.0.8):
1277515300 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):
1277915304 dependencies:
1278015305 '@expo/code-signing-certificates': 0.0.6
1278115306 '@expo/plist': 0.7.0
......@@ -12783,7 +15308,7 @@ snapshots:
1278315308 arg: 4.1.3
1278415309 chalk: 4.1.2
1278515310 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)
1278715312 expo-eas-client: 56.0.1
1278815313 expo-manifests: 56.0.4(expo@56.0.8)
1278915314 expo-structured-headers: 56.0.0
......@@ -12793,42 +15318,62 @@ snapshots:
1279315318 ignore: 5.3.2
1279415319 nullthrows: 1.1.1
1279515320 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)
1279715322 resolve-from: 5.0.0
1279815323 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))
1280015325 transitivePeerDependencies:
1280115326 - 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):
1280415349 dependencies:
1280515350 '@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)
1280715352 '@expo/config': 56.0.9(typescript@6.0.3)
1280815353 '@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)
1281015355 '@expo/fingerprint': 0.19.3
1281115356 '@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)
12813 '@expo/metro': 56.0.0
12814 '@expo/metro-config': 56.0.13(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(expo@56.0.8)(typescript@6.0.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)
15358 '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
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)
1281515360 '@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)
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)
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))
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))
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)
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)
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)
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))
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))
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)
1282115366 expo-keep-awake: 56.0.3(expo@56.0.8)(react@19.2.3)
1282215367 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)
1282415369 pretty-format: 29.7.0
1282515370 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)
1282715372 react-refresh: 0.14.2
1282815373 whatwg-url-minimum: 0.1.2
1282915374 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)
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)
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)
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)
1283215377 react-dom: 19.2.3(react@19.2.3)
1283315378 transitivePeerDependencies:
1283415379 - '@babel/core'
......@@ -12902,8 +15447,18 @@ snapshots:
1290215447
1290315448 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
1290515458 fast-json-stable-stringify@2.1.0: {}
1290615459
15460 fast-sha256@1.3.0: {}
15461
1290715462 fast-string-truncated-width@3.0.3: {}
1290815463
1290915464 fast-string-width@3.0.2:
......@@ -12916,6 +15471,32 @@ snapshots:
1291615471 dependencies:
1291715472 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
1291915500 fb-dotslash@0.5.8: {}
1292015501
1292115502 fb-watchman@2.0.2:
......@@ -13036,10 +15617,16 @@ snapshots:
1303615617
1303715618 function-bind@1.1.2: {}
1303815619
15620 generate-function@2.3.1:
15621 dependencies:
15622 is-property: 1.0.2
15623
1303915624 gensync@1.0.0-beta.2: {}
1304015625
1304115626 get-caller-file@2.0.5: {}
1304215627
15628 get-east-asian-width@1.6.0: {}
15629
1304315630 get-intrinsic@1.3.0:
1304415631 dependencies:
1304515632 call-bind-apply-helpers: 1.0.2
......@@ -13064,6 +15651,10 @@ snapshots:
1306415651 dependencies:
1306515652 pump: 3.0.4
1306615653
15654 get-tsconfig@4.14.0:
15655 dependencies:
15656 resolve-pkg-maps: 1.0.0
15657
1306715658 get-tsconfig@5.0.0-beta.4:
1306815659 dependencies:
1306915660 resolve-pkg-maps: 1.0.0
......@@ -13072,6 +15663,12 @@ snapshots:
1307215663
1307315664 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
1307515672 glob@13.0.6:
1307615673 dependencies:
1307715674 minimatch: 10.2.5
......@@ -13340,17 +15937,23 @@ snapshots:
1334015937 dependencies:
1334115938 safer-buffer: 2.1.2
1334215939
13343 ieee754@1.2.1:
15940 idb-keyval@6.2.1:
1334415941 optional: true
1334515942
15943 ieee754@1.2.1: {}
15944
1334615945 ignore@5.3.2: {}
1334715946
1334815947 image-size@1.2.1:
1334915948 dependencies:
1335015949 queue: 6.0.2
1335115950
15951 immediate@3.0.6: {}
15952
1335215953 indent-string@4.0.0: {}
1335315954
15955 indent-string@5.0.0: {}
15956
1335415957 inflight@1.0.6:
1335515958 dependencies:
1335615959 once: 1.4.0
......@@ -13360,6 +15963,41 @@ snapshots:
1336015963
1336115964 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
1336316001 inline-style-parser@0.2.7: {}
1336416002
1336516003 invariant@2.2.4:
......@@ -13405,10 +16043,22 @@ snapshots:
1340516043
1340616044 is-docker@4.0.0: {}
1340716045
16046 is-extglob@2.1.1: {}
16047
1340816048 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
1341016058 is-hexadecimal@2.0.1: {}
1341116059
16060 is-in-ci@2.0.0: {}
16061
1341216062 is-inside-container@1.0.0:
1341316063 dependencies:
1341416064 is-docker: 3.0.0
......@@ -13421,6 +16071,8 @@ snapshots:
1342116071
1342216072 is-promise@4.0.0: {}
1342316073
16074 is-property@1.0.2: {}
16075
1342416076 is-wsl@2.2.0:
1342516077 dependencies:
1342616078 is-docker: 2.2.1
......@@ -13429,6 +16081,8 @@ snapshots:
1342916081 dependencies:
1343016082 is-inside-container: 1.0.0
1343116083
16084 isarray@1.0.0: {}
16085
1343216086 isbinaryfile@4.0.10: {}
1343316087
1343416088 isbinaryfile@5.0.7: {}
......@@ -13480,14 +16134,22 @@ snapshots:
1348016134
1348116135 jiti@2.7.0: {}
1348216136
16137 jose@6.2.2: {}
16138
1348316139 jose@6.2.3: {}
1348416140
16141 js-base64@3.7.8: {}
16142
16143 js-cookie@3.0.7: {}
16144
1348516145 js-tokens@4.0.0: {}
1348616146
1348716147 js-yaml@4.2.0:
1348816148 dependencies:
1348916149 argparse: 2.0.1
1349016150
16151 jsbi@4.3.2: {}
16152
1349116153 jsc-safe-url@0.2.4: {}
1349216154
1349316155 jsesc@3.1.0: {}
......@@ -13508,6 +16170,8 @@ snapshots:
1350816170 json-stringify-safe@5.0.1:
1350916171 optional: true
1351016172
16173 json-with-bigint@3.5.8: {}
16174
1351116175 json5@2.2.3: {}
1351216176
1351316177 jsonc-parser@2.3.1: {}
......@@ -13524,6 +16188,13 @@ snapshots:
1352416188 optionalDependencies:
1352516189 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
1352716198 keyv@4.5.4:
1352816199 dependencies:
1352916200 json-buffer: 3.0.1
......@@ -13546,6 +16217,31 @@ snapshots:
1354616217 dependencies:
1354716218 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
1354916245 lighthouse-logger@1.4.2:
1355016246 dependencies:
1355116247 debug: 2.6.9
......@@ -13710,6 +16406,8 @@ snapshots:
1371016406 dependencies:
1371116407 chalk: 2.4.2
1371216408
16409 long@5.3.2: {}
16410
1371316411 longest-streak@3.1.0: {}
1371416412
1371516413 loose-envify@1.4.0:
......@@ -13730,6 +16428,8 @@ snapshots:
1373016428 dependencies:
1373116429 yallist: 4.0.0
1373216430
16431 lru.min@1.1.4: {}
16432
1373316433 lru_map@0.4.1: {}
1373416434
1373516435 lucide-react@0.564.0(react@19.2.6):
......@@ -13936,6 +16636,8 @@ snapshots:
1393616636
1393716637 merge-stream@2.0.0: {}
1393816638
16639 merge2@1.4.1: {}
16640
1393916641 metro-babel-transformer@0.84.4:
1394016642 dependencies:
1394116643 '@babel/core': 7.29.7
......@@ -13959,12 +16661,12 @@ snapshots:
1395916661 transitivePeerDependencies:
1396016662 - 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):
1396316665 dependencies:
1396416666 connect: 3.7.0
1396516667 flow-enums-runtime: 0.0.6
1396616668 jest-validate: 29.7.0
13967 metro: 0.84.4
16669 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1396816670 metro-cache: 0.84.4
1396916671 metro-core: 0.84.4
1397016672 metro-runtime: 0.84.4
......@@ -14044,14 +16746,14 @@ snapshots:
1404416746 transitivePeerDependencies:
1404516747 - 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):
1404816750 dependencies:
1404916751 '@babel/core': 7.29.7
1405016752 '@babel/generator': 7.29.7
1405116753 '@babel/parser': 7.29.7
1405216754 '@babel/types': 7.29.7
1405316755 flow-enums-runtime: 0.0.6
14054 metro: 0.84.4
16756 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1405516757 metro-babel-transformer: 0.84.4
1405616758 metro-cache: 0.84.4
1405716759 metro-cache-key: 0.84.4
......@@ -14064,7 +16766,7 @@ snapshots:
1406416766 - supports-color
1406516767 - utf-8-validate
1406616768
14067 metro@0.84.4:
16769 metro@0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6):
1406816770 dependencies:
1406916771 '@babel/code-frame': 7.29.7
1407016772 '@babel/core': 7.29.7
......@@ -14089,7 +16791,7 @@ snapshots:
1408916791 metro-babel-transformer: 0.84.4
1409016792 metro-cache: 0.84.4
1409116793 metro-cache-key: 0.84.4
14092 metro-config: 0.84.4
16794 metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1409316795 metro-core: 0.84.4
1409416796 metro-file-map: 0.84.4
1409516797 metro-resolver: 0.84.4
......@@ -14097,13 +16799,13 @@ snapshots:
1409716799 metro-source-map: 0.84.4
1409816800 metro-symbolicate: 0.84.4
1409916801 metro-transform-plugins: 0.84.4
14100 metro-transform-worker: 0.84.4
16802 metro-transform-worker: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1410116803 mime-types: 3.0.2
1410216804 nullthrows: 1.1.1
1410316805 serialize-error: 2.1.0
1410416806 source-map: 0.5.7
1410516807 throat: 5.0.0
14106 ws: 7.5.11
16808 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1410716809 yargs: 17.7.2
1410816810 transitivePeerDependencies:
1410916811 - bufferutil
......@@ -14326,6 +17028,8 @@ snapshots:
1432617028
1432717029 mimic-fn@1.2.0: {}
1432817030
17031 mimic-fn@2.1.0: {}
17032
1432917033 mimic-response@1.0.1: {}
1433017034
1433117035 mimic-response@3.1.0: {}
......@@ -14417,6 +17121,22 @@ snapshots:
1441717121
1441817122 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
1442017140 nanoid@3.3.12: {}
1442117141
1442217142 negotiator@0.6.3: {}
......@@ -14453,6 +17173,9 @@ snapshots:
1445317173 detect-libc: 2.1.2
1445417174 optional: true
1445517175
17176 node-gyp-build@4.8.4:
17177 optional: true
17178
1445617179 node-gyp@12.3.0:
1445717180 dependencies:
1445817181 env-paths: 2.2.1
......@@ -14508,6 +17231,8 @@ snapshots:
1450817231 object-keys@1.1.1:
1450917232 optional: true
1451017233
17234 obuf@1.1.2: {}
17235
1451117236 obug@2.1.1: {}
1451217237
1451317238 ofetch@1.5.1:
......@@ -14536,6 +17261,10 @@ snapshots:
1453617261 dependencies:
1453717262 mimic-fn: 1.2.0
1453817263
17264 onetime@5.1.2:
17265 dependencies:
17266 mimic-fn: 2.1.0
17267
1453917268 oniguruma-parser@0.12.2: {}
1454017269
1454117270 oniguruma-to-es@4.3.6:
......@@ -14558,9 +17287,13 @@ snapshots:
1455817287 strip-ansi: 5.2.0
1455917288 wcwidth: 1.0.1
1456017289
17290 os-paths@7.4.0:
17291 optionalDependencies:
17292 fsevents: 2.3.3
17293
1456117294 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)):
1456417297 dependencies:
1456517298 tinypool: 2.1.0
1456617299 optionalDependencies:
......@@ -14583,7 +17316,7 @@ snapshots:
1458317316 '@oxfmt/binding-win32-arm64-msvc': 0.52.0
1458417317 '@oxfmt/binding-win32-ia32-msvc': 0.52.0
1458517318 '@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
1458817321 oxlint-tsgolint@0.23.0:
1458917322 optionalDependencies:
......@@ -14594,7 +17327,7 @@ snapshots:
1459417327 '@oxlint-tsgolint/win32-arm64': 0.23.0
1459517328 '@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)):
1459817331 optionalDependencies:
1459917332 '@oxlint/binding-android-arm-eabi': 1.67.0
1460017333 '@oxlint/binding-android-arm64': 1.67.0
......@@ -14616,7 +17349,7 @@ snapshots:
1461617349 '@oxlint/binding-win32-ia32-msvc': 1.67.0
1461717350 '@oxlint/binding-win32-x64-msvc': 1.67.0
1461817351 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
1462117354 p-cancelable@2.1.1: {}
1462217355
......@@ -14637,6 +17370,8 @@ snapshots:
1463717370
1463817371 package-manager-detector@1.6.0: {}
1463917372
17373 pako@1.0.11: {}
17374
1464017375 parse-entities@4.0.2:
1464117376 dependencies:
1464217377 '@types/unist': 2.0.11
......@@ -14666,8 +17401,12 @@ snapshots:
1466617401
1466717402 parseurl@1.3.3: {}
1466817403
17404 patch-console@2.0.0: {}
17405
1466917406 path-browserify@1.0.1: {}
1467017407
17408 path-expression-matcher@1.5.0: {}
17409
1467117410 path-is-absolute@1.0.1: {}
1467217411
1467317412 path-key@3.1.1: {}
......@@ -14691,6 +17430,59 @@ snapshots:
1469117430
1469217431 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
1469417486 piccolore@0.1.3: {}
1469517487
1469617488 picocolors@1.1.1: {}
......@@ -14735,6 +17527,28 @@ snapshots:
1473517527 picocolors: 1.1.1
1473617528 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
1473817552 postject@1.0.0-alpha.6:
1473917553 dependencies:
1474017554 commander: 9.5.0
......@@ -14764,8 +17578,12 @@ snapshots:
1476417578
1476517579 proc-log@6.1.0: {}
1476617580
17581 process-nextick-args@2.0.1: {}
17582
1476717583 progress@2.0.3: {}
1476817584
17585 promise-limit@2.7.0: {}
17586
1476917587 promise-retry@2.0.1:
1477017588 dependencies:
1477117589 err-code: 2.0.3
......@@ -14813,6 +17631,8 @@ snapshots:
1481317631 split-on-first: 1.1.0
1481417632 strict-uri-encode: 2.0.0
1481517633
17634 queue-microtask@1.2.3: {}
17635
1481617636 queue@6.0.2:
1481717637 dependencies:
1481817638 inherits: 2.0.4
......@@ -14830,10 +17650,10 @@ snapshots:
1483017650 iconv-lite: 0.7.2
1483117651 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):
1483417654 dependencies:
1483517655 shell-quote: 1.8.4
14836 ws: 7.5.11
17656 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1483717657 transitivePeerDependencies:
1483817658 - bufferutil
1483917659 - utf-8-validate
......@@ -14884,90 +17704,95 @@ snapshots:
1488417704 transitivePeerDependencies:
1488517705 - 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):
1488817708 dependencies:
1488917709 color: 4.2.3
1489017710 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)
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)
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)
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)
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)
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)
1489417714 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):
1489717717 dependencies:
1489817718 '@egjs/hammerjs': 2.0.17
1489917719 '@types/react-test-renderer': 19.1.0
1490017720 hoist-non-react-statics: 3.3.2
1490117721 invariant: 2.2.4
1490217722 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):
1490617726 dependencies:
1490717727 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):
1491117731 dependencies:
1491217732 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):
1491617736 dependencies:
1491717737 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)
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)
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)
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)
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)
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):
1492317743 dependencies:
1492417744 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)
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)
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)
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)
1492717747 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):
1493117751 dependencies:
1493217752 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):
1493617756 dependencies:
1493717757 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)
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)
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)
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)
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)
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)
1494117761 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):
1494417764 dependencies:
1494517765 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):
1494917769 dependencies:
1495017770 react: 19.2.3
1495117771 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)
1495317773 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):
1495617776 dependencies:
1495717777 '@shikijs/types': 3.23.0
1495817778 '@shikijs/vscode-textmate': 10.0.2
1495917779 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):
1496317783 dependencies:
1496417784 css-select: 5.2.2
1496517785 css-tree: 1.1.3
1496617786 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)
1496817788 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):
1497117796 dependencies:
1497217797 '@babel/core': 7.29.7
1497317798 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7)
......@@ -14982,20 +17807,20 @@ snapshots:
1498217807 '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)
1498317808 convert-source-map: 2.0.0
1498417809 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)
1498617811 semver: 7.8.1
1498717812 transitivePeerDependencies:
1498817813 - 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):
1499117816 dependencies:
1499217817 '@react-native/assets-registry': 0.85.3
1499317818 '@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)
1499517820 '@react-native/gradle-plugin': 0.85.3
1499617821 '@react-native/js-polyfills': 0.85.3
1499717822 '@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)
1499917824 abort-controller: 3.0.0
1500017825 anser: 1.4.10
1500117826 ansi-regex: 5.0.1
......@@ -15012,7 +17837,7 @@ snapshots:
1501217837 pretty-format: 29.7.0
1501317838 promise: 8.3.0
1501417839 react: 19.2.3
15015 react-devtools-core: 6.1.5
17840 react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1501617841 react-refresh: 0.14.2
1501717842 regenerator-runtime: 0.13.11
1501817843 scheduler: 0.27.0
......@@ -15020,7 +17845,7 @@ snapshots:
1502017845 stacktrace-parser: 0.1.11
1502117846 tinyglobby: 0.2.17
1502217847 whatwg-fetch: 3.6.20
15023 ws: 7.5.11
17848 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1502417849 yargs: 17.7.2
1502517850 optionalDependencies:
1502617851 '@types/react': 19.2.16
......@@ -15032,6 +17857,11 @@ snapshots:
1503217857 - supports-color
1503317858 - 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
1503517865 react-refresh@0.14.2: {}
1503617866
1503717867 react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.3):
......@@ -15071,6 +17901,16 @@ snapshots:
1507117901 transitivePeerDependencies:
1507217902 - 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
1507417914 readdirp@4.1.2: {}
1507517915
1507617916 readdirp@5.0.0: {}
......@@ -15223,6 +18063,11 @@ snapshots:
1522318063 onetime: 2.0.1
1522418064 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
1522618071 retext-latin@4.0.0:
1522718072 dependencies:
1522818073 '@types/nlcst': 2.0.3
......@@ -15252,6 +18097,8 @@ snapshots:
1525218097
1525318098 rettime@0.10.1: {}
1525418099
18100 reusify@1.1.0: {}
18101
1525518102 rimraf@2.6.3:
1525618103 dependencies:
1525718104 glob: 7.2.3
......@@ -15288,6 +18135,27 @@ snapshots:
1528818135 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
1528918136 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
1529118159 rolldown@1.0.3:
1529218160 dependencies:
1529318161 '@oxc-project/types': 0.133.0
......@@ -15351,6 +18219,12 @@ snapshots:
1535118219 transitivePeerDependencies:
1535218220 - 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
1535418228 safe-buffer@5.2.1: {}
1535518229
1535618230 safer-buffer@2.1.2: {}
......@@ -15441,6 +18315,8 @@ snapshots:
1544118315
1544218316 server-only@0.0.1: {}
1544318317
18318 setimmediate@1.0.5: {}
18319
1544418320 setprototypeof@1.2.0: {}
1544518321
1544618322 sf-symbols-typescript@2.2.0: {}
......@@ -15570,6 +18446,11 @@ snapshots:
1557018446 is-fullwidth-code-point: 3.0.0
1557118447 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
1557318454 slugify@1.6.9: {}
1557418455
1557518456 smart-buffer@4.2.0:
......@@ -15592,9 +18473,17 @@ snapshots:
1559218473
1559318474 split-on-first@1.1.0: {}
1559418475
18476 split2@4.2.0: {}
18477
1559518478 sprintf-js@1.1.3:
1559618479 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
1559818487 stackframe@1.3.4: {}
1559918488
1560018489 stacktrace-parser@0.1.11:
......@@ -15603,6 +18492,11 @@ snapshots:
1560318492
1560418493 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
1560618500 stat-mode@1.0.0: {}
1560718501
1560818502 statuses@1.5.0: {}
......@@ -15623,6 +18517,21 @@ snapshots:
1562318517 is-fullwidth-code-point: 3.0.0
1562418518 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
1562618535 stringify-entities@4.0.4:
1562718536 dependencies:
1562818537 character-entities-html4: 2.1.0
......@@ -15636,10 +18545,16 @@ snapshots:
1563618545 dependencies:
1563718546 ansi-regex: 5.0.1
1563818547
18548 strip-ansi@7.2.0:
18549 dependencies:
18550 ansi-regex: 6.2.2
18551
1563918552 strip-indent@3.0.0:
1564018553 dependencies:
1564118554 min-indent: 1.0.1
1564218555
18556 strnum@2.3.0: {}
18557
1564318558 structured-headers@0.4.1: {}
1564418559
1564518560 style-to-js@1.1.21:
......@@ -15720,6 +18635,8 @@ snapshots:
1572018635 ansi-escapes: 4.3.2
1572118636 supports-hyperlinks: 2.3.0
1572218637
18638 terminal-size@4.0.1: {}
18639
1572318640 terser@5.48.0:
1572418641 dependencies:
1572518642 '@jridgewell/source-map': 0.3.11
......@@ -15831,8 +18748,14 @@ snapshots:
1583118748
1583218749 undici@6.26.0: {}
1583318750
18751 undici@7.27.1: {}
18752
1583418753 undici@8.3.0: {}
1583518754
18755 unenv@2.0.0-rc.24:
18756 dependencies:
18757 pathe: 2.0.3
18758
1583618759 unicode-canonical-property-names-ecmascript@2.0.1: {}
1583718760
1583818761 unicode-match-property-ecmascript@2.0.0:
......@@ -15902,18 +18825,20 @@ snapshots:
1590218825 unist-util-is: 6.0.1
1590318826 unist-util-visit-parents: 6.0.2
1590418827
18828 universal-user-agent@7.0.3: {}
18829
1590518830 universalify@0.1.2: {}
1590618831
1590718832 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):
1591018835 dependencies:
1591118836 '@tailwindcss/node': 4.2.1
1591218837 '@tailwindcss/oxide': 4.2.1
1591318838 culori: 4.0.2
1591418839 lightningcss: 1.30.1
1591518840 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)
1591718842 tailwindcss: 4.3.0
1591818843
1591918844 unpipe@1.0.0: {}
......@@ -15929,7 +18854,7 @@ snapshots:
1592918854 rolldown: 1.0.0-rc.17
1593018855 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):
1593318858 dependencies:
1593418859 anymatch: 3.1.3
1593518860 chokidar: 5.0.0
......@@ -15940,6 +18865,8 @@ snapshots:
1594018865 ofetch: 1.5.1
1594118866 ufo: 1.6.4
1594218867 optionalDependencies:
18868 aws4fetch: 1.0.20
18869 idb-keyval: 6.2.1
1594318870 ioredis: 5.11.0
1594418871
1594518872 until-async@3.0.2: {}
......@@ -15981,8 +18908,15 @@ snapshots:
1598118908 dependencies:
1598218909 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
1598418916 utf8-byte-length@1.0.5: {}
1598518917
18918 util-deprecate@1.0.2: {}
18919
1598618920 utils-merge@1.0.1: {}
1598718921
1598818922 uuid@14.0.0: {}
......@@ -16024,14 +18958,14 @@ snapshots:
1602418958 '@types/unist': 3.0.3
1602518959 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):
1602818962 dependencies:
1602918963 '@oxc-project/types': 0.133.0
1603018964 '@oxlint/plugins': 1.61.0
1603118965 '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@24.12.4)(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)
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))
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))
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)
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))
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))
1603518969 oxlint-tsgolint: 0.23.0
1603618970 optionalDependencies:
1603718971 '@voidzero-dev/vite-plus-darwin-arm64': 0.1.24
......@@ -16078,11 +19012,11 @@ snapshots:
1607819012 optionalDependencies:
1607919013 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):
1608219016 dependencies:
1608319017 react: 19.2.6
1608419018 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)'
1608619020 optionalDependencies:
1608719021 '@types/react': 19.2.16
1608819022 '@types/react-dom': 19.2.3(@types/react@19.2.16)
......@@ -16198,12 +19132,20 @@ snapshots:
1619819132
1619919133 web-namespaces@2.0.1: {}
1620019134
19135 webidl-conversions@5.0.0: {}
19136
1620119137 webpack-virtual-modules@0.6.2: {}
1620219138
1620319139 whatwg-fetch@3.6.20: {}
1620419140
1620519141 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
1620719149 which-pm-runs@1.1.0: {}
1620819150
1620919151 which@2.0.2:
......@@ -16218,6 +19160,18 @@ snapshots:
1621819160 dependencies:
1621919161 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
1622119175 wrap-ansi@6.2.0:
1622219176 dependencies:
1622319177 ansi-styles: 4.3.0
......@@ -16230,17 +19184,43 @@ snapshots:
1623019184 string-width: 4.2.3
1623119185 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
1623319193 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
1623919205 xcode@3.0.1:
1624019206 dependencies:
1624119207 simple-plist: 1.3.1
1624219208 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
1624419224 xml2js@0.6.0:
1624519225 dependencies:
1624619226 sax: 1.6.0
......@@ -16250,6 +19230,8 @@ snapshots:
1625019230
1625119231 xmlbuilder@15.1.1: {}
1625219232
19233 xtend@4.0.2: {}
19234
1625319235 xxhash-wasm@1.1.0: {}
1625419236
1625519237 y18n@5.0.8: {}
......@@ -16305,6 +19287,8 @@ snapshots:
1630519287
1630619288 yoctocolors-cjs@2.1.3: {}
1630719289
19290 yoga-layout@3.2.1: {}
19291
1630819292 zod-to-json-schema@3.25.2(zod@4.4.3):
1630919293 dependencies:
1631019294 zod: 4.4.3
pnpm-workspace.yaml+24-9
......@@ -1,5 +1,6 @@
11packages:
22 - "apps/*"
3 - "infra/*"
34 - "oxlint-plugin-t3code"
45 - "packages/*"
56 - "scripts"
......@@ -15,30 +16,44 @@ ignoredBuiltDependencies:
1516 - msw
1617
1718catalog:
18 effect: 4.0.0-beta.73
19 "@effect/atom-react": 4.0.0-beta.73
20 "@effect/openapi-generator": 4.0.0-beta.73
21 "@effect/platform-bun": 4.0.0-beta.73
22 "@effect/platform-node": 4.0.0-beta.73
23 "@effect/platform-node-shared": 4.0.0-beta.73
24 "@effect/sql-sqlite-bun": 4.0.0-beta.73
25 "@effect/vitest": 4.0.0-beta.73
19 effect: 4.0.0-beta.78
20 "@effect/atom-react": 4.0.0-beta.78
21 "@effect/openapi-generator": 4.0.0-beta.78
22 "@effect/platform-bun": 4.0.0-beta.78
23 "@effect/platform-node": 4.0.0-beta.78
24 "@effect/platform-node-shared": 4.0.0-beta.78
25 "@effect/sql-pg": 4.0.0-beta.78
26 "@effect/sql-sqlite-bun": 4.0.0-beta.78
27 "@effect/vitest": 4.0.0-beta.78
2628 "@effect/tsgo": 0.11.4
29 "@noble/curves": 1.9.1
30 "@noble/hashes": 1.8.0
2731 "@pierre/diffs": 1.1.20
2832 "@vitest/runner": ^4.1.8
2933 "@types/bun": ^1.3.11
3034 "@types/node": 24.12.4
3135 "@typescript/native-preview": 7.0.0-dev.20260527.2
36 jose: 6.2.2
37 tsdown: ^0.20.3
3238 typescript: ~6.0.3
3339 vitest: npm:@voidzero-dev/vite-plus-test@latest
3440 vite: npm:@voidzero-dev/vite-plus-core@latest
3541 vite-plus: latest
3642 yaml: ^2.9.0
3743overrides:
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": "-"
3852 "@effect/atom-react": "catalog:"
3953 "@effect/platform-bun": "catalog:"
4054 "@effect/platform-node": "catalog:"
4155 "@effect/platform-node-shared": "catalog:"
56 "@effect/sql-pg": "catalog:"
4257 "@effect/sql-sqlite-bun": "catalog:"
4358 "@effect/vitest": "catalog:"
4459 "@types/node": "catalog:"
......@@ -60,5 +75,5 @@ packageExtensions:
6075patchedDependencies:
6176 "@expo/metro-config@56.0.13": patches/@expo%2Fmetro-config@56.0.13.patch
6277 "@pierre/diffs@1.1.20": patches/@pierre%2Fdiffs@1.1.20.patch
63 effect@4.0.0-beta.73: patches/effect@4.0.0-beta.73.patch
78 effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch
6479 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* (
717717 target: target === "dmg" ? [target, "zip"] : [target],
718718 icon: "icon.icns",
719719 category: "public.app-category.developer-tools",
720 protocols: [
721 {
722 name: "T3 Code",
723 schemes: ["t3code"],
724 },
725 ],
720726 };
721727 }
722728
scripts/dev-runner.ts+4
......@@ -17,6 +17,10 @@ import * as Schema from "effect/Schema";
1717import { Argument, Command, Flag } from "effect/unstable/cli";
1818import { ChildProcess } from "effect/unstable/process";
1919
20import { loadRepoEnv } from "./lib/public-config.ts";
21
22Object.assign(process.env, loadRepoEnv());
23
2024const BASE_SERVER_PORT = 13773;
2125const BASE_WEB_PORT = 5733;
2226const 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 = [
2929 "apps/mobile/modules/t3-review-diff/package.json",
3030 "apps/mobile/modules/t3-terminal/package.json",
3131 "apps/marketing/package.json",
32 "infra/relay/package.json",
3233 "oxlint-plugin-t3code/package.json",
3334 "packages/client-runtime/package.json",
3435 "packages/contracts/package.json",
vite.config.ts+7
......@@ -4,6 +4,13 @@ import { defineConfig } from "vite-plus";
44export default defineConfig({
55 test: {
66 environment: "node",
7 exclude: [
8 "**/.repos/**",
9 "**/node_modules/**",
10 "**/dist/**",
11 "**/dist-electron/**",
12 "**/.{idea,git,cache,output,temp}/**",
13 ],
714 hookTimeout: 60_000,
815 testTimeout: 60_000,
916 },