From abb0923f7092823ce69bf21a209513c0a5010e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Thu, 16 Jul 2026 14:24:31 +0200 Subject: [PATCH] standalone-hardening 0006: redirect non-editor routes to the mgmt app VITE_APP_URL (build-editor --app-base, set to app.pcbjam.com in release CI) turns on a redirect gate in App: home, project overview and mgmt-only paths bounce to the mgmt app preserving path+search; tool boots, file deep-links, lib editors and the @local pseudo-scope stay local. Unset (dev/demo) keeps today's behavior. Policy is a pure helper (lib/redirect.ts) + vitest table. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A42xfPFNdfsUt9eowkC9eM --- .github/workflows/release.yml | 5 ++- scripts/deploy/build-editor.mjs | 9 ++++ web/standalone/.env.example | 6 +++ web/standalone/src/App.tsx | 14 +++++- web/standalone/src/lib/config.ts | 10 +++++ web/standalone/src/lib/redirect.test.ts | 59 +++++++++++++++++++++++++ web/standalone/src/lib/redirect.ts | 37 ++++++++++++++++ web/standalone/src/vite-env.d.ts | 2 + 8 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 web/standalone/src/lib/redirect.test.ts create mode 100644 web/standalone/src/lib/redirect.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 160a1f3..eabd082 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,9 @@ env: # order when both change: pcbjam-private first, then this repo. EDITOR_PAGES_PROJECT: pcbjam-editor EDITOR_API_BASE: https://api.pcbjam.com + # Mgmt app origin: non-editor routes on the editor host redirect here + # (standalone-hardening 0006). The demo build never sets this. + EDITOR_APP_BASE: https://app.pcbjam.com CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} WRANGLER_CMD: npx --yes wrangler@4 @@ -176,7 +179,7 @@ jobs: - name: Build editor (remote mode) run: > node scripts/deploy/build-editor.mjs --tag "$RELEASE_TAG" - --cdn "$CDN" --api-base "$EDITOR_API_BASE" + --cdn "$CDN" --api-base "$EDITOR_API_BASE" --app-base "$EDITOR_APP_BASE" --plausible "https://plausible.io/js/pa-KjNS9YmidydULZTstsjRg.js" ${MODELS_TAG:+--models-tag "$MODELS_TAG"} diff --git a/scripts/deploy/build-editor.mjs b/scripts/deploy/build-editor.mjs index f6c95ab..e678f29 100644 --- a/scripts/deploy/build-editor.mjs +++ b/scripts/deploy/build-editor.mjs @@ -34,6 +34,9 @@ function parseArgs(argv) { // kicad-packages3D snapshot (libs/kicad-models//); omitted ⇒ 3D models off. modelsTag: null, plausible: null, + // Companion mgmt app origin; set ⇒ non-editor routes redirect there + // (standalone-hardening 0006). Omitted ⇒ every route renders locally. + appBase: null, }; for (let i = 2; i < argv.length; i++) { const next = () => argv[++i]; @@ -45,6 +48,7 @@ function parseArgs(argv) { case "--yjs-endpoint": a.yjsEndpoint = next(); break; case "--models-tag": a.modelsTag = next(); break; case "--plausible": a.plausible = next(); break; + case "--app-base": a.appBase = next(); break; default: throw new Error(`unknown arg: ${argv[i]}`); } } @@ -54,6 +58,7 @@ function parseArgs(argv) { a.apiBase = a.apiBase.replace(/\/+$/, ""); a.repo = a.repo.replace(/\/+$/, ""); a.yjsEndpoint = (a.yjsEndpoint || a.apiBase).replace(/\/+$/, ""); + if (a.appBase) a.appBase = a.appBase.replace(/\/+$/, ""); return a; } @@ -104,6 +109,9 @@ function main() { VITE_GIT_SHA: gitSha(repoRoot), VITE_REPO_URL: a.repo, ...(a.plausible ? { VITE_PLAUSIBLE_SRC: a.plausible } : {}), + // Non-editor surfaces bounce to the mgmt app (mirror of the closed repo's + // VITE_STANDALONE_URL pointing the other way). + ...(a.appBase ? { VITE_APP_URL: a.appBase } : {}), }; console.log(`build-editor: tag=${a.tag} api=${a.apiBase} cdn=${a.cdn}`); @@ -115,6 +123,7 @@ function main() { console.log(` VITE_MODELS_MANIFEST_URL=${env.VITE_MODELS_MANIFEST_URL ?? "(unset — 3D models off)"}`); console.log(` VITE_APP_TAG=${env.VITE_APP_TAG} VITE_GIT_SHA=${env.VITE_GIT_SHA || "(none)"}`); console.log(` VITE_PLAUSIBLE_SRC=${env.VITE_PLAUSIBLE_SRC || "(off)"}`); + console.log(` VITE_APP_URL=${env.VITE_APP_URL || "(unset — no non-editor redirect)"}`); // Keep the dev-only WASM symlink out of the bundle (CDN serves it). const hadWasm = existsSync(publicWasm) || isSymlink(publicWasm); diff --git a/web/standalone/.env.example b/web/standalone/.env.example index 11ebaf3..dae428e 100644 --- a/web/standalone/.env.example +++ b/web/standalone/.env.example @@ -34,6 +34,12 @@ VITE_WASM_ROOT=/wasm # Marketing / landing page the version badge links to (default https://pcbjam.com). # VITE_LANDING_URL=https://pcbjam.com +# Companion management app origin. When set (the backed editor.pcbjam.com +# deploy), non-editor routes — home, project overview, mgmt-only paths — +# redirect there, preserving path + search (standalone-hardening 0006). +# Leave UNSET for dev and the demo: every route renders locally. +# VITE_APP_URL=https://app.pcbjam.com + # Where the in-editor waitlist form POSTs (default https://www.pcbjam.com/api/waitlist). # The demo is static with no backend, so it cross-posts to the landing site's # serverless endpoint, which must send CORS for this origin (see diff --git a/web/standalone/src/App.tsx b/web/standalone/src/App.tsx index 876bee1..2d550ea 100644 --- a/web/standalone/src/App.tsx +++ b/web/standalone/src/App.tsx @@ -1,6 +1,9 @@ -import { Route, Routes } from "react-router-dom"; +import { useEffect } from "react"; +import { Route, Routes, useLocation } from "react-router-dom"; import { VersionBadge } from "@/components/VersionBadge"; import { useChromeHidden } from "@/lib/chrome-visibility"; +import { APP_URL } from "@/lib/config"; +import { redirectTargetFor } from "@/lib/redirect"; import { HomePage } from "@/pages/HomePage"; import { LibToolPage } from "@/pages/LibToolPage"; import { ProjectView } from "@/pages/ProjectView"; @@ -8,6 +11,15 @@ import { ToolPage } from "@/pages/ToolPage"; export default function App() { const chromeHidden = useChromeHidden(); + // Non-editor redirect (standalone-hardening 0006): on a deploy with a + // companion mgmt app, every surface the editor doesn't own bounces there + // before any route renders. Covers in-SPA navigations too (useLocation). + const location = useLocation(); + const target = redirectTargetFor(APP_URL, location.pathname, location.search); + useEffect(() => { + if (target) window.location.replace(target); + }, [target]); + if (target) return null; return ( <> diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts index 94c437c..3060cd2 100644 --- a/web/standalone/src/lib/config.ts +++ b/web/standalone/src/lib/config.ts @@ -50,6 +50,16 @@ export const LANDING_URL = ( import.meta.env.VITE_LANDING_URL || "https://pcbjam.com" ).replace(/\/+$/, ""); +/** + * The companion management app's origin (no trailing slash), e.g. + * "https://app.pcbjam.com". When set (the backed editor deploy), non-editor + * surfaces — home, project overview, mgmt-only paths — redirect there; see + * lib/redirect.ts (standalone-hardening 0006). Unset (dev / demo) keeps every + * route local. + */ +export const APP_URL = + (import.meta.env.VITE_APP_URL || "").replace(/\/+$/, "") || null; + /** * Where the in-editor waitlist form POSTs. The demo is a fully static deploy with * no backend, so it cross-posts to the landing site's serverless endpoint (which diff --git a/web/standalone/src/lib/redirect.test.ts b/web/standalone/src/lib/redirect.test.ts new file mode 100644 index 0000000..d78dbda --- /dev/null +++ b/web/standalone/src/lib/redirect.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { redirectTargetFor } from "./redirect"; + +const APP = "https://app.pcbjam.com"; + +describe("redirectTargetFor", () => { + it("is off when no app URL is configured (dev / demo)", () => { + expect(redirectTargetFor(null, "/")).toBeNull(); + expect(redirectTargetFor(null, "/alice/projects/board")).toBeNull(); + expect(redirectTargetFor(null, "/login")).toBeNull(); + }); + + it("redirects home — the mgmt projects page is the real home", () => { + expect(redirectTargetFor(APP, "/")).toBe(`${APP}/`); + }); + + it("redirects the project overview (3-segment path)", () => { + expect(redirectTargetFor(APP, "/alice/projects/board")).toBe( + `${APP}/alice/projects/board`, + ); + }); + + it("redirects mgmt-only paths (catch-all)", () => { + for (const p of [ + "/login", + "/libs", + "/libs/import", + "/alice", + "/alice/members", + "/admin/tasks", + "/alice/projects", + ]) { + expect(redirectTargetFor(APP, p), p).toBe(APP + p); + } + }); + + it("keeps editor surfaces local", () => { + for (const p of [ + "/alice/projects/board/-/pcbnew", + "/alice/projects/board/board.kicad_pcb", + "/alice/projects/board/nested/dir/sub.kicad_sch", + "/alice/libs/mylib", + ]) { + expect(redirectTargetFor(APP, p), p).toBeNull(); + } + }); + + it("keeps the browser-local pseudo-scope local (no mgmt counterpart)", () => { + expect(redirectTargetFor(APP, "/@local/projects/scratch")).toBeNull(); + expect(redirectTargetFor(APP, "/%40local/projects/scratch")).toBeNull(); + expect(redirectTargetFor(APP, "/@local")).toBeNull(); + }); + + it("preserves the query string", () => { + expect(redirectTargetFor(APP, "/alice/projects/board", "?tab=files")).toBe( + `${APP}/alice/projects/board?tab=files`, + ); + }); +}); diff --git a/web/standalone/src/lib/redirect.ts b/web/standalone/src/lib/redirect.ts new file mode 100644 index 0000000..54c7442 --- /dev/null +++ b/web/standalone/src/lib/redirect.ts @@ -0,0 +1,37 @@ +import { LOCAL_SCOPE } from "@pcbjam/shared"; + +/** + * Non-editor redirect (standalone-hardening 0006): when this deployment has a + * companion management app (VITE_APP_URL, e.g. https://app.pcbjam.com), every + * surface the editor host doesn't own — home, the project overview, and any + * mgmt-only path (/login, /libs, /:scope, …) — bounces there. The two hosts + * share the path grammar (docs/features/scopes), so the redirect preserves + * path + search verbatim. Only the actual editor surfaces stay local: + * + * /:scope/projects/:name/-/:tool fileless tool boot + * /:scope/projects/:name/ file deep-link + * /:scope/libs/:name lib editor + * + * plus anything under the browser-local pseudo-scope (`@local`) — IDB virtual + * projects have no mgmt counterpart. + * + * Known grammar ambiguity: mgmt sub-project pages (…/board/libs, …/board/drift) + * are indistinguishable from file deep-links, so they stay local — the mgmt app + * links to those on its own host, never through the editor. + * + * Returns the absolute URL to redirect to, or null to render locally. + * `appUrl` unset (dev / demo builds) ⇒ always null, today's behavior. + */ +export function redirectTargetFor( + appUrl: string | null, + pathname: string, + search = "", +): string | null { + if (!appUrl) return null; + const segs = pathname.split("/").filter(Boolean); + const scope = segs[0] ? decodeURIComponent(segs[0]) : null; + if (scope === LOCAL_SCOPE) return null; + if (segs[1] === "projects" && segs.length >= 4) return null; + if (segs[1] === "libs" && segs.length === 3) return null; + return appUrl + pathname + search; +} diff --git a/web/standalone/src/vite-env.d.ts b/web/standalone/src/vite-env.d.ts index ac7a2b7..2135073 100644 --- a/web/standalone/src/vite-env.d.ts +++ b/web/standalone/src/vite-env.d.ts @@ -32,6 +32,8 @@ interface ImportMetaEnv { readonly VITE_YJS_TOKEN?: string; /** Plausible pa-*.js script URL; unset ⇒ no tracking (dev default). */ readonly VITE_PLAUSIBLE_SRC?: string; + /** Management app origin (e.g. https://app.pcbjam.com); set ⇒ non-editor routes redirect there (lib/redirect.ts). */ + readonly VITE_APP_URL?: string; } interface ImportMeta {