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:
parent
36ca706cf0
commit
abb0923f70
8 changed files with 140 additions and 2 deletions
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
59
web/standalone/src/lib/redirect.test.ts
Normal file
59
web/standalone/src/lib/redirect.test.ts
Normal 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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
37
web/standalone/src/lib/redirect.ts
Normal file
37
web/standalone/src/lib/redirect.ts
Normal 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;
|
||||
}
|
||||
2
web/standalone/src/vite-env.d.ts
vendored
2
web/standalone/src/vite-env.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue