Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions apps/logicsrc-web/contract/apex-rewrites.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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*",
]);
});
});
63 changes: 54 additions & 9 deletions apps/logicsrc-web/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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;
Loading