diff --git a/apps/logicsrc-web/contract/apex-rewrites.contract.test.ts b/apps/logicsrc-web/contract/apex-rewrites.contract.test.ts new file mode 100644 index 0000000..ccf8699 --- /dev/null +++ b/apps/logicsrc-web/contract/apex-rewrites.contract.test.ts @@ -0,0 +1,78 @@ +// logicsrc.com serves the marketing app, but `logicsrc login` talks to /cli/*, +// which lives in apps/pwa. These rewrites are what let both live on the apex. +// +// The ordering assertion is the important one: CommandBoard owns a catch-all +// `/api/:path*`, so anything of the credentials app's that lives under /api has +// to be matched first or CLI auth silently goes to the wrong service. +import { describe, expect, it } from "vitest"; + +import { buildRewrites, commandboardRewrites, credentialsRewrites } from "../next.config"; + +const CRED = "https://creds.example"; +const CB = "https://commandboard.example"; + +const sources = (rules: { source: string }[]) => rules.map((r) => r.source); + +describe("apex rewrites", () => { + it("proxies the whole CLI login flow to the credentials app", () => { + expect(sources(credentialsRewrites(CRED))).toEqual([ + "/cli/:path*", + "/api/me", + "/api/credshare/:path*", + "/auth/:path*", + ]); + }); + + it("sends /cli to the credentials app, not the marketing app", () => { + const rule = credentialsRewrites(CRED).find((r) => r.source === "/cli/:path*"); + expect(rule?.destination).toBe(`${CRED}/cli/:path*`); + }); + + it("includes /auth, because the CLI browser flow redirects there to sign in", () => { + // /cli/authorize and /cli/device are behind requireAuth. Without /auth + // proxied, an unauthenticated visitor lands on a 404 mid-login. + expect(sources(credentialsRewrites(CRED))).toContain("/auth/:path*"); + }); + + it("matches credentials paths BEFORE CommandBoard's /api catch-all", () => { + const all = buildRewrites(CRED, CB); + const list = "afterFiles" in all ? all.afterFiles : []; + const idx = (s: string) => sources(list).indexOf(s); + + expect(idx("/api/me")).toBeGreaterThanOrEqual(0); + expect(idx("/api/:path*")).toBeGreaterThanOrEqual(0); + // The catch-all would otherwise swallow /api/me and /api/credshare/*. + expect(idx("/api/me")).toBeLessThan(idx("/api/:path*")); + expect(idx("/api/credshare/:path*")).toBeLessThan(idx("/api/:path*")); + }); + + it("keeps the existing CommandBoard rules intact", () => { + expect(sources(commandboardRewrites(CB))).toEqual(["/health", "/api/:path*"]); + }); + + it("trims a trailing slash so destinations never double up", () => { + const [first] = credentialsRewrites("https://creds.example/".replace(/\/$/, "")); + expect(first.destination).toBe("https://creds.example/cli/:path*"); + }); + + it("degrades to whichever services are configured", () => { + // Neither set: unchanged behaviour, no rewrites at all. + expect(buildRewrites(undefined, undefined)).toEqual([]); + + // CommandBoard only — exactly what shipped before this change. + const cbOnly = buildRewrites(undefined, CB); + expect("afterFiles" in cbOnly ? sources(cbOnly.afterFiles) : []).toEqual([ + "/health", + "/api/:path*", + ]); + + // Credentials only, e.g. before CommandBoard is wired up. + const credOnly = buildRewrites(CRED, undefined); + expect("afterFiles" in credOnly ? sources(credOnly.afterFiles) : []).toEqual([ + "/cli/:path*", + "/api/me", + "/api/credshare/:path*", + "/auth/:path*", + ]); + }); +}); diff --git a/apps/logicsrc-web/next.config.ts b/apps/logicsrc-web/next.config.ts index b04c5ce..11f787b 100644 --- a/apps/logicsrc-web/next.config.ts +++ b/apps/logicsrc-web/next.config.ts @@ -6,6 +6,17 @@ import type { NextConfig } from "next"; // webhooks) are filesystem routes and match before these afterFiles rewrites. const commandboardApiUrl = process.env.COMMANDBOARD_API_URL; +// The credentials app (apps/pwa) is also its own service, and it owns the CLI +// login flow: `logicsrc login` talks to /cli/*, and the browser half of that +// flow needs a session, which lives behind /auth/*. +// +// Proxying those paths is what lets all of it live on logicsrc.com. Pointing +// the apex at the pwa instead would take the marketing site down with it, since +// the pwa serves `/` too; a subdomain would work but needs a Railway custom +// domain and a DNS record. This needs neither, and it makes the CLI's default +// origin (https://logicsrc.com) correct as it already stands. +const credentialsAppUrl = process.env.CREDENTIALS_APP_URL; + const securityHeaders = [ // HSTS — site is HTTPS-only behind Railway. No `preload` (irreversible). { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }, @@ -15,20 +26,54 @@ const securityHeaders = [ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, ]; +/** + * The paths the credentials app owns. + * + * `/api/me` and `/api/credshare/*` are named individually, and the caller must + * place these BEFORE the CommandBoard `/api/:path*` catch-all — otherwise the + * catch-all swallows them and sends CLI auth to the wrong service. + */ +export function credentialsRewrites(base: string) { + return [ + // the device-code and loopback login flows themselves + { source: "/cli/:path*", destination: `${base}/cli/:path*` }, + // identity, and the credential-sharing API the CLI uses once logged in + { source: "/api/me", destination: `${base}/api/me` }, + { source: "/api/credshare/:path*", destination: `${base}/api/credshare/:path*` }, + // /cli/authorize and /cli/device sit behind requireAuth, so an + // unauthenticated visitor gets redirected here to sign in. Without this the + // browser half of the flow dead-ends on a 404. + { source: "/auth/:path*", destination: `${base}/auth/:path*` }, + ]; +} + +/** CommandBoard's paths. The `/api` entry is a catch-all, so it goes last. */ +export function commandboardRewrites(base: string) { + return [ + { source: "/health", destination: `${base}/health` }, + { source: "/api/:path*", destination: `${base}/api/:path*` }, + ]; +} + +/** Built as a function so the ordering above is testable without booting Next. */ +export function buildRewrites( + credentials = credentialsAppUrl, + commandboard = commandboardApiUrl, +) { + const afterFiles = [ + ...(credentials ? credentialsRewrites(credentials.replace(/\/$/, "")) : []), + ...(commandboard ? commandboardRewrites(commandboard.replace(/\/$/, "")) : []), + ]; + return afterFiles.length ? { afterFiles } : []; +} + const nextConfig: NextConfig = { async headers() { return [{ source: "/:path*", headers: securityHeaders }]; }, async rewrites() { - if (!commandboardApiUrl) return []; - const base = commandboardApiUrl.replace(/\/$/, ""); - return { - afterFiles: [ - { source: "/health", destination: `${base}/health` }, - { source: "/api/:path*", destination: `${base}/api/:path*` } - ] - }; - } + return buildRewrites(); + }, }; export default nextConfig;