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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A42xfPFNdfsUt9eowkC9eM
This commit is contained in:
Gergő Törcsvári 2026-07-16 14:24:31 +02:00
commit abb0923f70
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 140 additions and 2 deletions

View file

@ -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"}

View file

@ -34,6 +34,9 @@ function parseArgs(argv) {
// kicad-packages3D snapshot (libs/kicad-models/<tag>/); 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);

View file

@ -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

View file

@ -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 (
<>
<Routes>

View file

@ -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

View file

@ -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`,
);
});
});

View file

@ -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> 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;
}

View file

@ -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 {