From fb1a299adefe84ecf88636b62a940034fc011fa3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 4 May 2026 15:15:45 -0700 Subject: [PATCH 1/9] Deploy hosted web app during releases - Add Vercel release deployment and channel aliasing - Route app.t3.codes through middleware-based channel selection - Document hosted web release setup --- .github/workflows/release.yml | 71 +++++++++++++++++- apps/web/middleware.test.ts | 69 +++++++++++++++++ apps/web/middleware.ts | 135 ++++++++++++++++++++++++++++++++++ apps/web/package.json | 1 + apps/web/vercel.json | 4 + apps/web/vite.config.ts | 6 +- bun.lock | 5 ++ docs/release.md | 46 ++++++++++++ 8 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 apps/web/middleware.test.ts create mode 100644 apps/web/middleware.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4409c54c8ef..9f9b20b947c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -511,6 +511,74 @@ jobs: fail_on_unmatched_files: true token: ${{ steps.app_token.outputs.token }} + deploy_web: + name: Deploy hosted web app + needs: [preflight, release] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + T3CODE_WEB_ROUTER_URL: ${{ vars.T3CODE_WEB_ROUTER_URL }} + T3CODE_WEB_LATEST_DOMAIN: ${{ vars.T3CODE_WEB_LATEST_DOMAIN }} + T3CODE_WEB_NIGHTLY_DOMAIN: ${{ vars.T3CODE_WEB_NIGHTLY_DOMAIN }} + VERCEL_TEAM_SLUG: ${{ vars.VERCEL_TEAM_SLUG }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Deploy and alias channel + shell: bash + run: | + set -euo pipefail + + if [[ -z "${VERCEL_TOKEN:-}" || -z "${VERCEL_ORG_ID:-}" || -z "${VERCEL_PROJECT_ID:-}" ]]; then + echo "Missing one or more required Vercel secrets: VERCEL_TOKEN, VERCEL_ORG_ID, VERCEL_PROJECT_ID." >&2 + exit 1 + fi + + router_url="${T3CODE_WEB_ROUTER_URL:-https://app.t3.codes}" + latest_domain="${T3CODE_WEB_LATEST_DOMAIN:-latest.app.t3.codes}" + nightly_domain="${T3CODE_WEB_NIGHTLY_DOMAIN:-nightly.app.t3.codes}" + + if [[ "${{ needs.preflight.outputs.release_channel }}" == "stable" ]]; then + channel_domain="$latest_domain" + channel_name="latest" + else + channel_domain="$nightly_domain" + channel_name="nightly" + fi + + vercel_scope_args=() + if [[ -n "${VERCEL_TEAM_SLUG:-}" ]]; then + vercel_scope_args=(--scope "$VERCEL_TEAM_SLUG") + fi + + echo "Deploying hosted web app for $channel_name channel." + deployment_url="$( + bunx vercel@53.1.1 deploy apps/web \ + --prod \ + --skip-domain \ + --yes \ + --token "$VERCEL_TOKEN" \ + "${vercel_scope_args[@]}" \ + --build-env "VITE_HOSTED_APP_URL=$router_url" + )" + + echo "Aliasing $deployment_url to $channel_domain." + bunx vercel@53.1.1 alias set "$deployment_url" "$channel_domain" \ + --token "$VERCEL_TOKEN" \ + "${vercel_scope_args[@]}" + finalize: name: Finalize release if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} @@ -595,8 +663,9 @@ jobs: always() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && + needs.deploy_web.result == 'success' && (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') - needs: [preflight, release, finalize] + needs: [preflight, release, deploy_web, finalize] runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 steps: diff --git a/apps/web/middleware.test.ts b/apps/web/middleware.test.ts new file mode 100644 index 00000000000..be181dc90a2 --- /dev/null +++ b/apps/web/middleware.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + HOSTED_WEB_CHANNEL_COOKIE, + isRouterHost, + normalizeChannel, + parseCookieValue, + selectChannel, +} from "./middleware"; + +function request(path: string, cookie?: string): Request { + return new Request(`https://app.t3.codes${path}`, { + headers: cookie ? { cookie } : undefined, + }); +} + +describe("hosted web channel middleware", () => { + it("normalizes latest and nightly channel names", () => { + expect(normalizeChannel("latest")).toBe("latest"); + expect(normalizeChannel("nightly")).toBe("nightly"); + expect(normalizeChannel("mytube")).toBeNull(); + expect(normalizeChannel("unknown")).toBeNull(); + }); + + it("matches the configured router host without a port", () => { + expect(isRouterHost("app.t3.codes:443", "app.t3.codes")).toBe(true); + expect(isRouterHost("latest.app.t3.codes", "app.t3.codes")).toBe(false); + }); + + it("reads the selected channel from cookies", () => { + expect( + selectChannel(request("/settings", `theme=dark; ${HOSTED_WEB_CHANNEL_COOKIE}=nightly`)), + ).toEqual({ + channel: "nightly", + setCookie: false, + nextPath: "/settings", + }); + }); + + it("defaults invalid or missing channel cookies to latest", () => { + expect(selectChannel(request("/threads", `${HOSTED_WEB_CHANNEL_COOKIE}=bad`))).toEqual({ + channel: "latest", + setCookie: false, + nextPath: "/threads", + }); + }); + + it("handles channel opt-in requests with an internal next path only", () => { + expect(selectChannel(request("/__t3code/channel?channel=nightly&next=/pair"))).toEqual({ + channel: "nightly", + setCookie: true, + nextPath: "/pair", + }); + + expect( + selectChannel(request("/__t3code/channel?channel=latest&next=https://evil.example")), + ).toEqual({ + channel: "latest", + setCookie: true, + nextPath: "/", + }); + }); + + it("parses cookie values by exact name", () => { + expect(parseCookieValue("other=value; t3code_web_channel=nightly", "t3code_web_channel")).toBe( + "nightly", + ); + expect(parseCookieValue("x-t3code_web_channel=nightly", "t3code_web_channel")).toBeNull(); + }); +}); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 00000000000..329feeeda9a --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,135 @@ +import { next, rewrite } from "@vercel/functions"; + +export type HostedWebChannel = "latest" | "nightly"; + +export const HOSTED_WEB_CHANNEL_COOKIE = "t3code_web_channel"; + +const DEFAULT_ROUTER_HOST = "app.t3.codes"; +const DEFAULT_CHANNEL_ORIGINS = { + latest: "https://latest.app.t3.codes", + nightly: "https://nightly.app.t3.codes", +} as const satisfies Record; + +export interface ChannelRouterConfig { + readonly routerHost: string; + readonly channelOrigins: Record; +} + +export interface ChannelSelection { + readonly channel: HostedWebChannel; + readonly setCookie: boolean; + readonly nextPath: string; +} + +function envValue(name: string): string | undefined { + const value = process.env[name]?.trim(); + return value ? value : undefined; +} + +export function readChannelRouterConfig(): ChannelRouterConfig { + return { + routerHost: envValue("T3CODE_WEB_ROUTER_HOST") ?? DEFAULT_ROUTER_HOST, + channelOrigins: { + latest: envValue("T3CODE_WEB_LATEST_ORIGIN") ?? DEFAULT_CHANNEL_ORIGINS.latest, + nightly: envValue("T3CODE_WEB_NIGHTLY_ORIGIN") ?? DEFAULT_CHANNEL_ORIGINS.nightly, + }, + }; +} + +export function normalizeChannel(value: string | null | undefined): HostedWebChannel | null { + const normalized = value?.trim().toLowerCase(); + if (normalized === "latest") return "latest"; + if (normalized === "nightly") return "nightly"; + return null; +} + +export function parseCookieValue(cookieHeader: string | null, name: string): string | null { + if (!cookieHeader) return null; + + for (const segment of cookieHeader.split(";")) { + const [rawKey, ...rawValue] = segment.split("="); + if (rawKey?.trim() !== name) continue; + return rawValue.join("=").trim() || null; + } + + return null; +} + +export function isRouterHost(hostHeader: string | null, routerHost: string): boolean { + const host = hostHeader?.split(":")[0]?.trim().toLowerCase(); + return host === routerHost.trim().toLowerCase(); +} + +function safeNextPath(value: string | null): string { + if (!value?.startsWith("/") || value.startsWith("//")) { + return "/"; + } + + return value; +} + +export function selectChannel(request: Request): ChannelSelection { + const url = new URL(request.url); + + if (url.pathname === "/__t3code/channel") { + return { + channel: normalizeChannel(url.searchParams.get("channel")) ?? "latest", + setCookie: true, + nextPath: safeNextPath(url.searchParams.get("next")), + }; + } + + return { + channel: + normalizeChannel( + parseCookieValue(request.headers.get("cookie"), HOSTED_WEB_CHANNEL_COOKIE), + ) ?? "latest", + setCookie: false, + nextPath: `${url.pathname}${url.search}`, + }; +} + +function channelCookie(channel: HostedWebChannel): string { + return [ + `${HOSTED_WEB_CHANNEL_COOKIE}=${channel}`, + "Path=/", + "Max-Age=31536000", + "HttpOnly", + "Secure", + "SameSite=Lax", + ].join("; "); +} + +function buildRewriteUrl(request: Request, origin: string): URL { + const requestUrl = new URL(request.url); + const target = new URL(origin); + target.pathname = requestUrl.pathname; + target.search = requestUrl.search; + target.hash = ""; + return target; +} + +export const config = { + matcher: "/:path*", +}; + +export default function middleware(request: Request): Response { + const routerConfig = readChannelRouterConfig(); + if (!isRouterHost(request.headers.get("host"), routerConfig.routerHost)) { + return next(); + } + + const selection = selectChannel(request); + + if (selection.setCookie) { + return new Response(null, { + status: 302, + headers: { + Location: selection.nextPath, + "Set-Cookie": channelCookie(selection.channel), + }, + }); + } + + return rewrite(buildRewriteUrl(request, routerConfig.channelOrigins[selection.channel])); +} diff --git a/apps/web/package.json b/apps/web/package.json index 11e69d1248d..9d616fb296e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -51,6 +51,7 @@ "@types/babel__core": "^7.20.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vercel/functions": "^3.5.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", diff --git a/apps/web/vercel.json b/apps/web/vercel.json index ecfa7a47fc9..5bc14ec913d 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -1,5 +1,9 @@ { + "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production", + "git": { + "deploymentEnabled": false + }, "installCommand": "bun add -g turbo && bun install --filter '@t3tools/contracts' --filter '@t3tools/client-runtime' --filter '@t3tools/scripts' --filter '@t3tools/web'", "rewrites": [ { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cea7ce71ec9..f34204d5ec0 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,13 +10,17 @@ const host = process.env.HOST?.trim() || "localhost"; const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredHostedAppUrl = (() => { + const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); + if (explicitHostedAppUrl) { + return explicitHostedAppUrl; + } if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) { return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`; } if (process.env.VERCEL_URL) { return `https://${process.env.VERCEL_URL}`; } - return process.env.VITE_HOSTED_APP_URL?.trim(); + return undefined; })(); const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); diff --git a/bun.lock b/bun.lock index ffbbc6ebe65..5f7e5b3c653 100644 --- a/bun.lock +++ b/bun.lock @@ -119,6 +119,7 @@ "@types/babel__core": "^7.20.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "@vercel/functions": "^3.5.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", @@ -901,6 +902,10 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vercel/functions": ["@vercel/functions@3.5.0", "", { "dependencies": { "@vercel/oidc": "3.4.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-+RokZ+4gkYyOsKBuJ29cQ8iSZG123LLJbZfPry20kkTgrN9U0277La4feP4DnWVo3sGoYa4plCEKY9XKUYoX9g=="], + + "@vercel/oidc": ["@vercel/oidc@3.4.0", "", {}, "sha512-p0sKfHkfRmMaqqDwNL4tjnX9TgRrLMlEtUjIxfrEns8pOxz1R9ztqOVI+ehqiq93/2/HnfPe/UBZkfAZwnx0UA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitest/browser": ["@vitest/browser@4.1.0", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.0" } }, "sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ=="], diff --git a/docs/release.md b/docs/release.md index fc17d3e39db..ba8f809d7aa 100644 --- a/docs/release.md +++ b/docs/release.md @@ -24,8 +24,54 @@ This document covers the unified release workflow for stable and nightly desktop - Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file: - stable releases publish npm dist-tag `latest` - nightly releases publish npm dist-tag `nightly` +- Deploys the hosted web app to Vercel only after a release is published: + - stable releases are aliased to the `latest` hosted app channel + - nightly releases are aliased to the `nightly` hosted app channel - Signing is optional and auto-detected per platform from secrets. +## Hosted web app release deployment + +The hosted app is intentionally not deployed by Vercel's Git integration. The +web project disables automatic Git deployments in `apps/web/vercel.json` via +`git.deploymentEnabled: false`, and `.github/workflows/release.yml` deploys the +web app with Vercel CLI after the GitHub Release succeeds. + +Required GitHub Actions secrets: + +- `VERCEL_TOKEN` +- `VERCEL_ORG_ID` +- `VERCEL_PROJECT_ID` + +Optional GitHub Actions variables: + +- `VERCEL_TEAM_SLUG`: required only if the token needs an explicit Vercel team scope. +- `T3CODE_WEB_ROUTER_URL`: defaults to `https://app.t3.codes`. +- `T3CODE_WEB_LATEST_DOMAIN`: defaults to `latest.app.t3.codes`. +- `T3CODE_WEB_NIGHTLY_DOMAIN`: defaults to `nightly.app.t3.codes`. + +Required Vercel domains: + +- `app.t3.codes`: the stable router domain users open. +- `latest.app.t3.codes`: channel alias updated by stable releases. +- `nightly.app.t3.codes`: channel alias updated by nightly releases. + +The router domain runs `apps/web/middleware.ts`. Users opt into a channel by +visiting `/__t3code/channel?channel=latest` or +`/__t3code/channel?channel=nightly`; the middleware stores the +`t3code_web_channel` cookie and rewrites future requests on `app.t3.codes` to +the matching channel alias. + +One-time Vercel dashboard setup: + +1. Confirm the web project root directory remains `apps/web`. +2. Add the three domains above to the web project. +3. Disable automatic Git deployments in the dashboard if desired; the committed + `vercel.json` setting is the source-of-truth, but disconnecting Git in the + dashboard is also safe. +4. Promote or alias one deployment containing `apps/web/middleware.ts` to + `app.t3.codes` once. Future release jobs should only update the channel + aliases. + ## Nightly builds - Workflow: `.github/workflows/release.yml` From 6e88ff4bafc42ddcbb1828e5d84ea6beecad082c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 4 May 2026 15:44:35 -0700 Subject: [PATCH 2/9] Show hosted app channel in web About settings - Pass release version and hosted channel into the web build - Display the hosted update track in About for latest/nightly releases - Refresh release package versions before deployment --- .github/workflows/release.yml | 18 ++++- apps/web/src/branding.test.ts | 18 +++++ apps/web/src/branding.ts | 5 ++ .../components/settings/SettingsPanels.tsx | 73 +++++++++++-------- apps/web/src/vite-env.d.ts | 1 + apps/web/vite.config.ts | 5 +- docs/release.md | 5 ++ 7 files changed, 93 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f9b20b947c..0bfe6211aae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -536,6 +536,20 @@ jobs: with: bun-version-file: package.json + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Install release tooling dependencies + run: bun install --frozen-lockfile --filter=@t3tools/scripts --filter=@t3tools/web + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + + - name: Refresh release lockfile + run: bun install --lockfile-only --ignore-scripts + - name: Deploy and alias channel shell: bash run: | @@ -571,7 +585,9 @@ jobs: --yes \ --token "$VERCEL_TOKEN" \ "${vercel_scope_args[@]}" \ - --build-env "VITE_HOSTED_APP_URL=$router_url" + --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \ + --build-env "VITE_HOSTED_APP_URL=$router_url" \ + --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name" )" echo "Aliasing $deployment_url to $channel_domain." diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index b6643d2a72f..764b63cdb4b 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -34,4 +34,22 @@ describe("branding", () => { expect(branding.APP_STAGE_LABEL).toBe("Nightly"); expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); }); + + it("normalizes hosted app channel metadata", async () => { + vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "nightly"); + + const branding = await import("./branding"); + + expect(branding.HOSTED_APP_CHANNEL).toBe("nightly"); + expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Nightly"); + }); + + it("ignores unknown hosted app channels", async () => { + vi.stubEnv("VITE_HOSTED_APP_CHANNEL", "preview"); + + const branding = await import("./branding"); + + expect(branding.HOSTED_APP_CHANNEL).toBeNull(); + expect(branding.HOSTED_APP_CHANNEL_LABEL).toBeNull(); + }); }); diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 99775a4c55d..d8018f0d924 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -9,6 +9,7 @@ function readInjectedDesktopAppBranding(): DesktopAppBranding | null { } const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); +const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase(); export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; export const APP_STAGE_LABEL = @@ -16,3 +17,7 @@ export const APP_STAGE_LABEL = export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const HOSTED_APP_CHANNEL = + hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; +export const HOSTED_APP_CHANNEL_LABEL = + HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8fc36d4a32b..8119ab702d1 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -13,7 +13,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import { Equal } from "effect"; -import { APP_VERSION } from "../../branding"; +import { APP_VERSION, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -156,6 +156,7 @@ function AboutVersionSection() { const updateState = updateStateQuery.data ?? null; const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; + const hostedAppChannelLabel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL_LABEL; const handleUpdateChannelChange = useCallback( (channel: DesktopUpdateChannel) => { @@ -308,36 +309,48 @@ function AboutVersionSection() { } /> - { - handleUpdateChannelChange(value as DesktopUpdateChannel); - }} - > - { + handleUpdateChannelChange(value as DesktopUpdateChannel); + }} > - - {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"} - - - - - Stable - - - Nightly - - - - } - /> + + + {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"} + + + + + Stable + + + Nightly + + + + } + /> + ) : hostedAppChannelLabel ? ( + + {hostedAppChannelLabel} + + } + /> + ) : null} ); } diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 99c5a65d85e..75ee936603c 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -6,6 +6,7 @@ interface ImportMetaEnv { readonly VITE_HTTP_URL: string; readonly VITE_WS_URL: string; readonly VITE_HOSTED_APP_URL: string; + readonly VITE_HOSTED_APP_CHANNEL: string; readonly APP_VERSION: string; } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index f34204d5ec0..daa2815c4cb 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,6 +9,8 @@ const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); const configuredWsUrl = process.env.VITE_WS_URL?.trim(); +const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; +const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; const configuredHostedAppUrl = (() => { const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim(); if (explicitHostedAppUrl) { @@ -82,7 +84,8 @@ export default defineConfig({ // In dev mode, tell the web app where the WebSocket server lives "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""), "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""), - "import.meta.env.APP_VERSION": JSON.stringify(pkg.version), + "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel), + "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, resolve: { tsconfigPaths: true, diff --git a/docs/release.md b/docs/release.md index ba8f809d7aa..c8184884f64 100644 --- a/docs/release.md +++ b/docs/release.md @@ -61,6 +61,11 @@ visiting `/__t3code/channel?channel=latest` or `t3code_web_channel` cookie and rewrites future requests on `app.t3.codes` to the matching channel alias. +The release deploy job rewrites release package versions before upload so the +hosted app's About panel renders the release version. It also passes +`VITE_HOSTED_APP_CHANNEL=latest|nightly`, which renders the hosted update track +in the About panel. + One-time Vercel dashboard setup: 1. Confirm the web project root directory remains `apps/web`. From f1e0c30240c8c172147f50864c04f412997050b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 4 May 2026 15:49:41 -0700 Subject: [PATCH 3/9] Add hosted app channel selector - Route channel changes through the hosted router so the cookie updates - Add URL-building coverage and document the release flow --- .../components/settings/SettingsPanels.tsx | 35 +++++++++++++++---- apps/web/src/hostedPairing.test.ts | 17 +++++++++ apps/web/src/hostedPairing.ts | 14 ++++++++ docs/release.md | 4 ++- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8119ab702d1..8e7c7713407 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -13,7 +13,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import { Equal } from "effect"; -import { APP_VERSION, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; +import { APP_VERSION, HOSTED_APP_CHANNEL } from "../../branding"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -25,6 +25,7 @@ import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { isElectron } from "../../env"; +import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -156,7 +157,7 @@ function AboutVersionSection() { const updateState = updateStateQuery.data ?? null; const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; - const hostedAppChannelLabel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL_LABEL; + const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; const handleUpdateChannelChange = useCallback( (channel: DesktopUpdateChannel) => { @@ -340,14 +341,34 @@ function AboutVersionSection() { } /> - ) : hostedAppChannelLabel ? ( + ) : selectedHostedAppChannel ? ( - {hostedAppChannelLabel} - + } /> ) : null} diff --git a/apps/web/src/hostedPairing.test.ts b/apps/web/src/hostedPairing.test.ts index ef2b2eb5279..c41f47bedb8 100644 --- a/apps/web/src/hostedPairing.test.ts +++ b/apps/web/src/hostedPairing.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { + buildHostedChannelSelectionUrl, buildHostedPairingUrl, hasHostedPairingRequest, isHostedStaticApp, @@ -42,6 +43,22 @@ describe("hostedPairing", () => { expect(url.hash).toBe("#token=pairing-token"); }); + it("builds hosted channel selection URLs through the configured router origin", () => { + vi.stubEnv("VITE_HOSTED_APP_URL", "https://app.t3.codes"); + + const url = new URL( + buildHostedChannelSelectionUrl({ + channel: "nightly", + currentUrl: new URL("https://latest.app.t3.codes/settings?tab=about#version"), + }), + ); + + expect(url.origin).toBe("https://app.t3.codes"); + expect(url.pathname).toBe("/__t3code/channel"); + expect(url.searchParams.get("channel")).toBe("nightly"); + expect(url.searchParams.get("next")).toBe("/settings?tab=about#version"); + }); + it("ignores incomplete hosted pairing requests", () => { expect( hasHostedPairingRequest(new URL("https://app.t3.codes/pair?host=backend.example.com")), diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index a44cfa70834..e4b8c7cc102 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -8,6 +8,8 @@ export interface HostedPairingRequest { readonly label: string; } +export type HostedAppChannel = "latest" | "nightly"; + function configuredHostedAppUrl(): string { return import.meta.env.VITE_HOSTED_APP_URL?.trim() || DEFAULT_HOSTED_APP_URL; } @@ -68,3 +70,15 @@ export function buildHostedPairingUrl(input: { return setPairingTokenOnUrl(url, input.token).toString(); } + +export function buildHostedChannelSelectionUrl(input: { + readonly channel: HostedAppChannel; + readonly currentUrl?: URL | undefined; +}): string { + const currentUrl = input.currentUrl ?? new URL(window.location.href); + const nextPath = `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}` || "/"; + const url = new URL("/__t3code/channel", configuredHostedAppUrl()); + url.searchParams.set("channel", input.channel); + url.searchParams.set("next", nextPath); + return url.toString(); +} diff --git a/docs/release.md b/docs/release.md index c8184884f64..956e84eb689 100644 --- a/docs/release.md +++ b/docs/release.md @@ -64,7 +64,9 @@ the matching channel alias. The release deploy job rewrites release package versions before upload so the hosted app's About panel renders the release version. It also passes `VITE_HOSTED_APP_CHANNEL=latest|nightly`, which renders the hosted update track -in the About panel. +selector in the About panel. Changing the selector navigates through +`/__t3code/channel` on the router domain so the user's channel cookie is updated +before returning to the current app path. One-time Vercel dashboard setup: From ad418abc7dde47e74dc0424f58231046eb236d48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 4 May 2026 17:11:10 -0700 Subject: [PATCH 4/9] Harden hosted channel routing and next-path validation - Normalize router host comparisons with and without ports - Reject unsafe `next` values containing backslashes, colons, or control chars - Reuse the hosted channel label in settings --- apps/web/middleware.test.ts | 25 ++++++++++++++++++ apps/web/middleware.ts | 26 ++++++++++++++++--- .../components/settings/SettingsPanels.tsx | 6 ++--- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/apps/web/middleware.test.ts b/apps/web/middleware.test.ts index be181dc90a2..0a7bf88e330 100644 --- a/apps/web/middleware.test.ts +++ b/apps/web/middleware.test.ts @@ -23,6 +23,7 @@ describe("hosted web channel middleware", () => { it("matches the configured router host without a port", () => { expect(isRouterHost("app.t3.codes:443", "app.t3.codes")).toBe(true); + expect(isRouterHost("app.t3.codes", "app.t3.codes:443")).toBe(true); expect(isRouterHost("latest.app.t3.codes", "app.t3.codes")).toBe(false); }); @@ -58,6 +59,30 @@ describe("hosted web channel middleware", () => { setCookie: true, nextPath: "/", }); + + expect(selectChannel(request("/__t3code/channel?channel=latest&next=/\\evil.example"))).toEqual( + { + channel: "latest", + setCookie: true, + nextPath: "/", + }, + ); + + expect( + selectChannel(request("/__t3code/channel?channel=latest&next=/settings%3Adebug")), + ).toEqual({ + channel: "latest", + setCookie: true, + nextPath: "/", + }); + + expect( + selectChannel(request("/__t3code/channel?channel=latest&next=/settings%0Adebug")), + ).toEqual({ + channel: "latest", + setCookie: true, + nextPath: "/", + }); }); it("parses cookie values by exact name", () => { diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 329feeeda9a..1b4fd60ad7f 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -55,13 +55,33 @@ export function parseCookieValue(cookieHeader: string | null, name: string): str return null; } +function normalizeHost(value: string | null): string | null { + const host = value?.split(":")[0]?.trim().toLowerCase(); + return host ? host : null; +} + export function isRouterHost(hostHeader: string | null, routerHost: string): boolean { - const host = hostHeader?.split(":")[0]?.trim().toLowerCase(); - return host === routerHost.trim().toLowerCase(); + const host = normalizeHost(hostHeader); + const router = normalizeHost(routerHost); + return host !== null && host === router; +} + +function hasControlCharacter(value: string): boolean { + for (const char of value) { + const code = char.charCodeAt(0); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; } function safeNextPath(value: string | null): string { - if (!value?.startsWith("/") || value.startsWith("//")) { + if ( + !value?.startsWith("/") || + value.startsWith("//") || + value.includes("\\") || + value.includes(":") || + hasControlCharacter(value) + ) { return "/"; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8e7c7713407..15b332512fa 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -13,7 +13,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import { Equal } from "effect"; -import { APP_VERSION, HOSTED_APP_CHANNEL } from "../../branding"; +import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -356,9 +356,7 @@ function AboutVersionSection() { }} > - - {selectedHostedAppChannel === "nightly" ? "Nightly" : "Latest"} - + {HOSTED_APP_CHANNEL_LABEL} From 1d8e4354e33ae6011faf20c3e7afd5ead4079ccc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 5 May 2026 00:22:04 +0000 Subject: [PATCH 5/9] fix: remove overly broad colon check in safeNextPath The colon check in safeNextPath rejected legitimate query strings and hash fragments containing colons (e.g. URLs with port numbers as query params). The existing startsWith('/') and startsWith('//') guards already prevent protocol-based open redirect attacks, making the colon check redundant. --- apps/web/middleware.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 1b4fd60ad7f..630be56b79a 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -79,7 +79,6 @@ function safeNextPath(value: string | null): string { !value?.startsWith("/") || value.startsWith("//") || value.includes("\\") || - value.includes(":") || hasControlCharacter(value) ) { return "/"; From 73643fb0e79d682ddc1e2e76d8018970ee0abcdb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 21:46:54 -0700 Subject: [PATCH 6/9] Simplify hosted channel redirect flow - Stop carrying `next` through channel selection - Redirect hosted channel opt-ins to the app root - Update pairing tests and release notes --- apps/web/middleware.test.ts | 49 +++++++++--------------------- apps/web/middleware.ts | 26 +--------------- apps/web/src/hostedPairing.test.ts | 3 +- apps/web/src/hostedPairing.ts | 4 --- docs/release.md | 2 +- 5 files changed, 18 insertions(+), 66 deletions(-) diff --git a/apps/web/middleware.test.ts b/apps/web/middleware.test.ts index 0a7bf88e330..7f4b4018b57 100644 --- a/apps/web/middleware.test.ts +++ b/apps/web/middleware.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { HOSTED_WEB_CHANNEL_COOKIE, + default as middleware, isRouterHost, normalizeChannel, parseCookieValue, @@ -8,8 +9,13 @@ import { } from "./middleware"; function request(path: string, cookie?: string): Request { + const headers = new Headers({ host: "app.t3.codes" }); + if (cookie) { + headers.set("cookie", cookie); + } + return new Request(`https://app.t3.codes${path}`, { - headers: cookie ? { cookie } : undefined, + headers, }); } @@ -33,7 +39,6 @@ describe("hosted web channel middleware", () => { ).toEqual({ channel: "nightly", setCookie: false, - nextPath: "/settings", }); }); @@ -41,48 +46,24 @@ describe("hosted web channel middleware", () => { expect(selectChannel(request("/threads", `${HOSTED_WEB_CHANNEL_COOKIE}=bad`))).toEqual({ channel: "latest", setCookie: false, - nextPath: "/threads", }); }); - it("handles channel opt-in requests with an internal next path only", () => { + it("handles channel opt-in requests without accepting redirect paths", () => { expect(selectChannel(request("/__t3code/channel?channel=nightly&next=/pair"))).toEqual({ channel: "nightly", setCookie: true, - nextPath: "/pair", - }); - - expect( - selectChannel(request("/__t3code/channel?channel=latest&next=https://evil.example")), - ).toEqual({ - channel: "latest", - setCookie: true, - nextPath: "/", }); + }); - expect(selectChannel(request("/__t3code/channel?channel=latest&next=/\\evil.example"))).toEqual( - { - channel: "latest", - setCookie: true, - nextPath: "/", - }, + it("always redirects channel opt-in requests to root after setting the cookie", () => { + const response = middleware( + request("/__t3code/channel?channel=nightly&next=https://evil.example"), ); - expect( - selectChannel(request("/__t3code/channel?channel=latest&next=/settings%3Adebug")), - ).toEqual({ - channel: "latest", - setCookie: true, - nextPath: "/", - }); - - expect( - selectChannel(request("/__t3code/channel?channel=latest&next=/settings%0Adebug")), - ).toEqual({ - channel: "latest", - setCookie: true, - nextPath: "/", - }); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + expect(response.headers.get("set-cookie")).toContain(`${HOSTED_WEB_CHANNEL_COOKIE}=nightly`); }); it("parses cookie values by exact name", () => { diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 630be56b79a..7eeaae460bd 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -18,7 +18,6 @@ export interface ChannelRouterConfig { export interface ChannelSelection { readonly channel: HostedWebChannel; readonly setCookie: boolean; - readonly nextPath: string; } function envValue(name: string): string | undefined { @@ -66,27 +65,6 @@ export function isRouterHost(hostHeader: string | null, routerHost: string): boo return host !== null && host === router; } -function hasControlCharacter(value: string): boolean { - for (const char of value) { - const code = char.charCodeAt(0); - if (code <= 0x1f || code === 0x7f) return true; - } - return false; -} - -function safeNextPath(value: string | null): string { - if ( - !value?.startsWith("/") || - value.startsWith("//") || - value.includes("\\") || - hasControlCharacter(value) - ) { - return "/"; - } - - return value; -} - export function selectChannel(request: Request): ChannelSelection { const url = new URL(request.url); @@ -94,7 +72,6 @@ export function selectChannel(request: Request): ChannelSelection { return { channel: normalizeChannel(url.searchParams.get("channel")) ?? "latest", setCookie: true, - nextPath: safeNextPath(url.searchParams.get("next")), }; } @@ -104,7 +81,6 @@ export function selectChannel(request: Request): ChannelSelection { parseCookieValue(request.headers.get("cookie"), HOSTED_WEB_CHANNEL_COOKIE), ) ?? "latest", setCookie: false, - nextPath: `${url.pathname}${url.search}`, }; } @@ -144,7 +120,7 @@ export default function middleware(request: Request): Response { return new Response(null, { status: 302, headers: { - Location: selection.nextPath, + Location: "/", "Set-Cookie": channelCookie(selection.channel), }, }); diff --git a/apps/web/src/hostedPairing.test.ts b/apps/web/src/hostedPairing.test.ts index c41f47bedb8..ea592780db5 100644 --- a/apps/web/src/hostedPairing.test.ts +++ b/apps/web/src/hostedPairing.test.ts @@ -49,14 +49,13 @@ describe("hostedPairing", () => { const url = new URL( buildHostedChannelSelectionUrl({ channel: "nightly", - currentUrl: new URL("https://latest.app.t3.codes/settings?tab=about#version"), }), ); expect(url.origin).toBe("https://app.t3.codes"); expect(url.pathname).toBe("/__t3code/channel"); expect(url.searchParams.get("channel")).toBe("nightly"); - expect(url.searchParams.get("next")).toBe("/settings?tab=about#version"); + expect(url.searchParams.has("next")).toBe(false); }); it("ignores incomplete hosted pairing requests", () => { diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index e4b8c7cc102..8ebe7ac2572 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -73,12 +73,8 @@ export function buildHostedPairingUrl(input: { export function buildHostedChannelSelectionUrl(input: { readonly channel: HostedAppChannel; - readonly currentUrl?: URL | undefined; }): string { - const currentUrl = input.currentUrl ?? new URL(window.location.href); - const nextPath = `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}` || "/"; const url = new URL("/__t3code/channel", configuredHostedAppUrl()); url.searchParams.set("channel", input.channel); - url.searchParams.set("next", nextPath); return url.toString(); } diff --git a/docs/release.md b/docs/release.md index 956e84eb689..8cf45b5be20 100644 --- a/docs/release.md +++ b/docs/release.md @@ -66,7 +66,7 @@ hosted app's About panel renders the release version. It also passes `VITE_HOSTED_APP_CHANNEL=latest|nightly`, which renders the hosted update track selector in the About panel. Changing the selector navigates through `/__t3code/channel` on the router domain so the user's channel cookie is updated -before returning to the current app path. +before redirecting to the hosted app root. One-time Vercel dashboard setup: From c7134f8feb191ce175d05b6bf9dcc2db19cb6786 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 7 May 2026 22:05:26 -0700 Subject: [PATCH 7/9] Move hosted web routing into Vercel config - Replace middleware with `apps/web/vercel.ts` - Preserve channel cookie routing and rewrite behavior - Update release docs and Vercel dependencies --- apps/web/middleware.test.ts | 75 --------------------- apps/web/middleware.ts | 130 ------------------------------------ apps/web/package.json | 2 +- apps/web/tsconfig.json | 2 +- apps/web/vercel.json | 14 ---- apps/web/vercel.ts | 57 ++++++++++++++++ bun.lock | 26 +++++++- docs/release.md | 10 +-- 8 files changed, 89 insertions(+), 227 deletions(-) delete mode 100644 apps/web/middleware.test.ts delete mode 100644 apps/web/middleware.ts delete mode 100644 apps/web/vercel.json create mode 100644 apps/web/vercel.ts diff --git a/apps/web/middleware.test.ts b/apps/web/middleware.test.ts deleted file mode 100644 index 7f4b4018b57..00000000000 --- a/apps/web/middleware.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - HOSTED_WEB_CHANNEL_COOKIE, - default as middleware, - isRouterHost, - normalizeChannel, - parseCookieValue, - selectChannel, -} from "./middleware"; - -function request(path: string, cookie?: string): Request { - const headers = new Headers({ host: "app.t3.codes" }); - if (cookie) { - headers.set("cookie", cookie); - } - - return new Request(`https://app.t3.codes${path}`, { - headers, - }); -} - -describe("hosted web channel middleware", () => { - it("normalizes latest and nightly channel names", () => { - expect(normalizeChannel("latest")).toBe("latest"); - expect(normalizeChannel("nightly")).toBe("nightly"); - expect(normalizeChannel("mytube")).toBeNull(); - expect(normalizeChannel("unknown")).toBeNull(); - }); - - it("matches the configured router host without a port", () => { - expect(isRouterHost("app.t3.codes:443", "app.t3.codes")).toBe(true); - expect(isRouterHost("app.t3.codes", "app.t3.codes:443")).toBe(true); - expect(isRouterHost("latest.app.t3.codes", "app.t3.codes")).toBe(false); - }); - - it("reads the selected channel from cookies", () => { - expect( - selectChannel(request("/settings", `theme=dark; ${HOSTED_WEB_CHANNEL_COOKIE}=nightly`)), - ).toEqual({ - channel: "nightly", - setCookie: false, - }); - }); - - it("defaults invalid or missing channel cookies to latest", () => { - expect(selectChannel(request("/threads", `${HOSTED_WEB_CHANNEL_COOKIE}=bad`))).toEqual({ - channel: "latest", - setCookie: false, - }); - }); - - it("handles channel opt-in requests without accepting redirect paths", () => { - expect(selectChannel(request("/__t3code/channel?channel=nightly&next=/pair"))).toEqual({ - channel: "nightly", - setCookie: true, - }); - }); - - it("always redirects channel opt-in requests to root after setting the cookie", () => { - const response = middleware( - request("/__t3code/channel?channel=nightly&next=https://evil.example"), - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBe("/"); - expect(response.headers.get("set-cookie")).toContain(`${HOSTED_WEB_CHANNEL_COOKIE}=nightly`); - }); - - it("parses cookie values by exact name", () => { - expect(parseCookieValue("other=value; t3code_web_channel=nightly", "t3code_web_channel")).toBe( - "nightly", - ); - expect(parseCookieValue("x-t3code_web_channel=nightly", "t3code_web_channel")).toBeNull(); - }); -}); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts deleted file mode 100644 index 7eeaae460bd..00000000000 --- a/apps/web/middleware.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { next, rewrite } from "@vercel/functions"; - -export type HostedWebChannel = "latest" | "nightly"; - -export const HOSTED_WEB_CHANNEL_COOKIE = "t3code_web_channel"; - -const DEFAULT_ROUTER_HOST = "app.t3.codes"; -const DEFAULT_CHANNEL_ORIGINS = { - latest: "https://latest.app.t3.codes", - nightly: "https://nightly.app.t3.codes", -} as const satisfies Record; - -export interface ChannelRouterConfig { - readonly routerHost: string; - readonly channelOrigins: Record; -} - -export interface ChannelSelection { - readonly channel: HostedWebChannel; - readonly setCookie: boolean; -} - -function envValue(name: string): string | undefined { - const value = process.env[name]?.trim(); - return value ? value : undefined; -} - -export function readChannelRouterConfig(): ChannelRouterConfig { - return { - routerHost: envValue("T3CODE_WEB_ROUTER_HOST") ?? DEFAULT_ROUTER_HOST, - channelOrigins: { - latest: envValue("T3CODE_WEB_LATEST_ORIGIN") ?? DEFAULT_CHANNEL_ORIGINS.latest, - nightly: envValue("T3CODE_WEB_NIGHTLY_ORIGIN") ?? DEFAULT_CHANNEL_ORIGINS.nightly, - }, - }; -} - -export function normalizeChannel(value: string | null | undefined): HostedWebChannel | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "latest") return "latest"; - if (normalized === "nightly") return "nightly"; - return null; -} - -export function parseCookieValue(cookieHeader: string | null, name: string): string | null { - if (!cookieHeader) return null; - - for (const segment of cookieHeader.split(";")) { - const [rawKey, ...rawValue] = segment.split("="); - if (rawKey?.trim() !== name) continue; - return rawValue.join("=").trim() || null; - } - - return null; -} - -function normalizeHost(value: string | null): string | null { - const host = value?.split(":")[0]?.trim().toLowerCase(); - return host ? host : null; -} - -export function isRouterHost(hostHeader: string | null, routerHost: string): boolean { - const host = normalizeHost(hostHeader); - const router = normalizeHost(routerHost); - return host !== null && host === router; -} - -export function selectChannel(request: Request): ChannelSelection { - const url = new URL(request.url); - - if (url.pathname === "/__t3code/channel") { - return { - channel: normalizeChannel(url.searchParams.get("channel")) ?? "latest", - setCookie: true, - }; - } - - return { - channel: - normalizeChannel( - parseCookieValue(request.headers.get("cookie"), HOSTED_WEB_CHANNEL_COOKIE), - ) ?? "latest", - setCookie: false, - }; -} - -function channelCookie(channel: HostedWebChannel): string { - return [ - `${HOSTED_WEB_CHANNEL_COOKIE}=${channel}`, - "Path=/", - "Max-Age=31536000", - "HttpOnly", - "Secure", - "SameSite=Lax", - ].join("; "); -} - -function buildRewriteUrl(request: Request, origin: string): URL { - const requestUrl = new URL(request.url); - const target = new URL(origin); - target.pathname = requestUrl.pathname; - target.search = requestUrl.search; - target.hash = ""; - return target; -} - -export const config = { - matcher: "/:path*", -}; - -export default function middleware(request: Request): Response { - const routerConfig = readChannelRouterConfig(); - if (!isRouterHost(request.headers.get("host"), routerConfig.routerHost)) { - return next(); - } - - const selection = selectChannel(request); - - if (selection.setCookie) { - return new Response(null, { - status: 302, - headers: { - Location: "/", - "Set-Cookie": channelCookie(selection.channel), - }, - }); - } - - return rewrite(buildRewriteUrl(request, routerConfig.channelOrigins[selection.channel])); -} diff --git a/apps/web/package.json b/apps/web/package.json index b82f757dd54..fe91425c019 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,7 +50,7 @@ "@types/babel__core": "^7.20.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@vercel/functions": "^3.5.0", + "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c721aa85f99..055b7540e17 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -25,5 +25,5 @@ } ] }, - "include": ["src", "vite.config.ts", "test"] + "include": ["src", "vite.config.ts", "vercel.ts", "test"] } diff --git a/apps/web/vercel.json b/apps/web/vercel.json deleted file mode 100644 index 5bc14ec913d..00000000000 --- a/apps/web/vercel.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "buildCommand": "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production", - "git": { - "deploymentEnabled": false - }, - "installCommand": "bun add -g turbo && bun install --filter '@t3tools/contracts' --filter '@t3tools/client-runtime' --filter '@t3tools/scripts' --filter '@t3tools/web'", - "rewrites": [ - { - "source": "/(.*)", - "destination": "/index.html" - } - ] -} diff --git a/apps/web/vercel.ts b/apps/web/vercel.ts new file mode 100644 index 00000000000..c01b115109e --- /dev/null +++ b/apps/web/vercel.ts @@ -0,0 +1,57 @@ +import { matchers, routes, type VercelConfig } from "@vercel/config/v1"; + +const ROUTER_HOST = "app.t3.codes"; +const HOSTED_WEB_CHANNEL_COOKIE = "t3code_web_channel"; +const LATEST_ORIGIN = "https://latest.app.t3.codes"; +const NIGHTLY_ORIGIN = "https://nightly.app.t3.codes"; + +function channelCookie(channel: "latest" | "nightly"): string { + return [ + `${HOSTED_WEB_CHANNEL_COOKIE}=${channel}`, + "Path=/", + "Max-Age=31536000", + "HttpOnly", + "Secure", + "SameSite=Lax", + ].join("; "); +} + +export const config: VercelConfig = { + buildCommand: + "turbo build --filter @t3tools/web && bun ../../scripts/apply-web-brand-assets.ts production", + git: { + deploymentEnabled: false, + }, + installCommand: + "bun add -g turbo && bun install --filter '@t3tools/contracts' --filter '@t3tools/client-runtime' --filter '@t3tools/scripts' --filter '@t3tools/web'", + routes: [ + { + src: "/__t3code/channel", + has: [matchers.query("channel", "nightly")], + headers: { + Location: "/", + "Set-Cookie": channelCookie("nightly"), + }, + status: 302, + }, + { + src: "/__t3code/channel", + headers: { + Location: "/", + "Set-Cookie": channelCookie("latest"), + }, + status: 302, + }, + { + src: "/(.*)", + has: [matchers.host(ROUTER_HOST), matchers.cookie(HOSTED_WEB_CHANNEL_COOKIE, "nightly")], + dest: `${NIGHTLY_ORIGIN}/$1`, + }, + { + src: "/(.*)", + has: [matchers.host(ROUTER_HOST)], + dest: `${LATEST_ORIGIN}/$1`, + }, + ], + rewrites: [routes.rewrite("/(.*)", "/index.html")], +}; diff --git a/bun.lock b/bun.lock index 2fb19984953..1970b48e986 100644 --- a/bun.lock +++ b/bun.lock @@ -123,7 +123,7 @@ "@types/babel__core": "^7.20.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "@vercel/functions": "^3.5.0", + "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", @@ -907,10 +907,14 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vercel/config": ["@vercel/config@0.3.0", "", { "dependencies": { "@vercel/routing-utils": "6.2.0", "pretty-cache-header": "^1.0.0", "zod": "^3.22.0" }, "bin": { "config": "dist/cli.js" } }, "sha512-Tf5k5y2F478oTiQcU5R8Ntix1UejE6NdduZnI7aa1XXxjCtifX1XdRS/D2uTjiQAwIL3pLa1LSAN80ABbba+TQ=="], + "@vercel/functions": ["@vercel/functions@3.5.0", "", { "dependencies": { "@vercel/oidc": "3.4.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-+RokZ+4gkYyOsKBuJ29cQ8iSZG123LLJbZfPry20kkTgrN9U0277La4feP4DnWVo3sGoYa4plCEKY9XKUYoX9g=="], "@vercel/oidc": ["@vercel/oidc@3.4.0", "", {}, "sha512-p0sKfHkfRmMaqqDwNL4tjnX9TgRrLMlEtUjIxfrEns8pOxz1R9ztqOVI+ehqiq93/2/HnfPe/UBZkfAZwnx0UA=="], + "@vercel/routing-utils": ["@vercel/routing-utils@6.2.0", "", { "dependencies": { "path-to-regexp": "6.1.0", "path-to-regexp-updated": "npm:path-to-regexp@6.3.0" }, "optionalDependencies": { "ajv": "^6.12.3" } }, "sha512-YI2cGYZmJKEyGSEgf5Fw5rVuW7X1bTOQ7/pnLRNTFMH3YB3qqP5erCLCJGv2kIvNo1iQAPINHQRJj6ykEdGoPg=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitest/browser": ["@vitest/browser@4.1.0", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.0" } }, "sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ=="], @@ -1225,6 +1229,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], @@ -1669,6 +1675,8 @@ "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "path-to-regexp-updated": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], @@ -1691,6 +1699,8 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "pretty-cache-header": ["pretty-cache-header@1.0.0", "", { "dependencies": { "timestring": "^6.0.0" } }, "sha512-xtXazslu25CdnGnUkByU1RoOjK55TqwatJkjjJLg5ZAdz2Lngko/mmaUgeET36P2GMlNwh3fdM7FWBO717pNcw=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -1701,6 +1711,8 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@8.1.0", "", {}, "sha512-53B3MB8wetRdD6JZ4W/0gDKaOvKwuXrEmV1auQc0hASWge8rieKV4PCCVNVbJ+i24miiubb4c/B+dg8Ho0ikYw=="], "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], @@ -1881,6 +1893,8 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "timestring": ["timestring@6.0.0", "", {}, "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -2003,6 +2017,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], @@ -2177,6 +2193,12 @@ "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@vercel/config/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@vercel/routing-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "@vercel/routing-utils/path-to-regexp": ["path-to-regexp@6.1.0", "", {}, "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw=="], + "@vitest/browser/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -2301,6 +2323,8 @@ "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], diff --git a/docs/release.md b/docs/release.md index 8cf45b5be20..640fdc333c9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -32,7 +32,7 @@ This document covers the unified release workflow for stable and nightly desktop ## Hosted web app release deployment The hosted app is intentionally not deployed by Vercel's Git integration. The -web project disables automatic Git deployments in `apps/web/vercel.json` via +web project disables automatic Git deployments in `apps/web/vercel.ts` via `git.deploymentEnabled: false`, and `.github/workflows/release.yml` deploys the web app with Vercel CLI after the GitHub Release succeeds. @@ -55,9 +55,9 @@ Required Vercel domains: - `latest.app.t3.codes`: channel alias updated by stable releases. - `nightly.app.t3.codes`: channel alias updated by nightly releases. -The router domain runs `apps/web/middleware.ts`. Users opt into a channel by +The router domain uses `apps/web/vercel.ts` routes. Users opt into a channel by visiting `/__t3code/channel?channel=latest` or -`/__t3code/channel?channel=nightly`; the middleware stores the +`/__t3code/channel?channel=nightly`; the router stores the `t3code_web_channel` cookie and rewrites future requests on `app.t3.codes` to the matching channel alias. @@ -73,9 +73,9 @@ One-time Vercel dashboard setup: 1. Confirm the web project root directory remains `apps/web`. 2. Add the three domains above to the web project. 3. Disable automatic Git deployments in the dashboard if desired; the committed - `vercel.json` setting is the source-of-truth, but disconnecting Git in the + `vercel.ts` setting is the source-of-truth, but disconnecting Git in the dashboard is also safe. -4. Promote or alias one deployment containing `apps/web/middleware.ts` to +4. Promote or alias one deployment containing the router rules in `apps/web/vercel.ts` to `app.t3.codes` once. Future release jobs should only update the channel aliases. From 8a7e372a01819b03862fea1a9222480d15a9f8ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 8 May 2026 12:37:06 -0700 Subject: [PATCH 8/9] Update SettingsPanels.tsx --- apps/web/src/components/settings/SettingsPanels.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2cc4399afe0..edd75b2a2e6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -14,13 +14,8 @@ import { import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; -<<<<<<< t3code/1be1f71d -import { Equal } from "effect"; +import * as Equal from "effect/Effect"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; -======= -import * as Equal from "effect/Equal"; -import { APP_VERSION } from "../../branding"; ->>>>>>> main import { canCheckForUpdate, getDesktopUpdateButtonTooltip, From c200abced91a1cb7c0a951e929eb1293c6ec570d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 8 May 2026 12:37:29 -0700 Subject: [PATCH 9/9] Rename import from 'effect/Effect' to 'effect/Equal' --- apps/web/src/components/settings/SettingsPanels.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index edd75b2a2e6..9f0166a9d2a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -14,7 +14,7 @@ import { import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; -import * as Equal from "effect/Effect"; +import * as Equal from "effect/Equal"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { canCheckForUpdate,