Skip to content

Commit 3649f78

Browse files
ralyodioclaude
andauthored
feat(web): serve the CLI login flow from the apex (#106)
`logicsrc login` defaults to https://logicsrc.com (#104), but every path it needs returns 404 there: the apex runs the marketing app, while /cli/* lives in apps/pwa on its own service. Proxy those paths from the app that owns the apex, the same way CommandBoard is already proxied. No DNS record, no Railway custom domain, and no subdomain -- and it makes the CLI's existing default origin correct rather than requiring another change to chase it. Pointing the apex at the pwa instead was the obvious alternative and is wrong: the pwa serves `/` too, so it would take the marketing site down with it. Proxied: /cli/:path* the device-code and loopback login flows /api/me identity /api/credshare/:path* the credential-sharing API used after login /auth/:path* /cli/authorize and /cli/device are behind requireAuth, so an unauthenticated visitor is redirected here; without it the browser half of the flow dead-ends on a 404 Order matters and is asserted: CommandBoard owns a catch-all /api/:path*, so /api/me and /api/credshare/* have to match first or CLI auth silently goes to the wrong service. Rewrite construction is factored into pure functions so the ordering is testable without booting Next, and degrades cleanly: with CREDENTIALS_APP_URL unset the output is byte-identical to what shipped before. Requires CREDENTIALS_APP_URL on the logicsrc-web service, pointing at the credentials app's origin. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 60c0cbf commit 3649f78

2 files changed

Lines changed: 132 additions & 9 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// logicsrc.com serves the marketing app, but `logicsrc login` talks to /cli/*,
2+
// which lives in apps/pwa. These rewrites are what let both live on the apex.
3+
//
4+
// The ordering assertion is the important one: CommandBoard owns a catch-all
5+
// `/api/:path*`, so anything of the credentials app's that lives under /api has
6+
// to be matched first or CLI auth silently goes to the wrong service.
7+
import { describe, expect, it } from "vitest";
8+
9+
import { buildRewrites, commandboardRewrites, credentialsRewrites } from "../next.config";
10+
11+
const CRED = "https://creds.example";
12+
const CB = "https://commandboard.example";
13+
14+
const sources = (rules: { source: string }[]) => rules.map((r) => r.source);
15+
16+
describe("apex rewrites", () => {
17+
it("proxies the whole CLI login flow to the credentials app", () => {
18+
expect(sources(credentialsRewrites(CRED))).toEqual([
19+
"/cli/:path*",
20+
"/api/me",
21+
"/api/credshare/:path*",
22+
"/auth/:path*",
23+
]);
24+
});
25+
26+
it("sends /cli to the credentials app, not the marketing app", () => {
27+
const rule = credentialsRewrites(CRED).find((r) => r.source === "/cli/:path*");
28+
expect(rule?.destination).toBe(`${CRED}/cli/:path*`);
29+
});
30+
31+
it("includes /auth, because the CLI browser flow redirects there to sign in", () => {
32+
// /cli/authorize and /cli/device are behind requireAuth. Without /auth
33+
// proxied, an unauthenticated visitor lands on a 404 mid-login.
34+
expect(sources(credentialsRewrites(CRED))).toContain("/auth/:path*");
35+
});
36+
37+
it("matches credentials paths BEFORE CommandBoard's /api catch-all", () => {
38+
const all = buildRewrites(CRED, CB);
39+
const list = "afterFiles" in all ? all.afterFiles : [];
40+
const idx = (s: string) => sources(list).indexOf(s);
41+
42+
expect(idx("/api/me")).toBeGreaterThanOrEqual(0);
43+
expect(idx("/api/:path*")).toBeGreaterThanOrEqual(0);
44+
// The catch-all would otherwise swallow /api/me and /api/credshare/*.
45+
expect(idx("/api/me")).toBeLessThan(idx("/api/:path*"));
46+
expect(idx("/api/credshare/:path*")).toBeLessThan(idx("/api/:path*"));
47+
});
48+
49+
it("keeps the existing CommandBoard rules intact", () => {
50+
expect(sources(commandboardRewrites(CB))).toEqual(["/health", "/api/:path*"]);
51+
});
52+
53+
it("trims a trailing slash so destinations never double up", () => {
54+
const [first] = credentialsRewrites("https://creds.example/".replace(/\/$/, ""));
55+
expect(first.destination).toBe("https://creds.example/cli/:path*");
56+
});
57+
58+
it("degrades to whichever services are configured", () => {
59+
// Neither set: unchanged behaviour, no rewrites at all.
60+
expect(buildRewrites(undefined, undefined)).toEqual([]);
61+
62+
// CommandBoard only — exactly what shipped before this change.
63+
const cbOnly = buildRewrites(undefined, CB);
64+
expect("afterFiles" in cbOnly ? sources(cbOnly.afterFiles) : []).toEqual([
65+
"/health",
66+
"/api/:path*",
67+
]);
68+
69+
// Credentials only, e.g. before CommandBoard is wired up.
70+
const credOnly = buildRewrites(CRED, undefined);
71+
expect("afterFiles" in credOnly ? sources(credOnly.afterFiles) : []).toEqual([
72+
"/cli/:path*",
73+
"/api/me",
74+
"/api/credshare/:path*",
75+
"/auth/:path*",
76+
]);
77+
});
78+
});

apps/logicsrc-web/next.config.ts

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ import type { NextConfig } from "next";
66
// webhooks) are filesystem routes and match before these afterFiles rewrites.
77
const commandboardApiUrl = process.env.COMMANDBOARD_API_URL;
88

9+
// The credentials app (apps/pwa) is also its own service, and it owns the CLI
10+
// login flow: `logicsrc login` talks to /cli/*, and the browser half of that
11+
// flow needs a session, which lives behind /auth/*.
12+
//
13+
// Proxying those paths is what lets all of it live on logicsrc.com. Pointing
14+
// the apex at the pwa instead would take the marketing site down with it, since
15+
// the pwa serves `/` too; a subdomain would work but needs a Railway custom
16+
// domain and a DNS record. This needs neither, and it makes the CLI's default
17+
// origin (https://logicsrc.com) correct as it already stands.
18+
const credentialsAppUrl = process.env.CREDENTIALS_APP_URL;
19+
920
const securityHeaders = [
1021
// HSTS — site is HTTPS-only behind Railway. No `preload` (irreversible).
1122
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
@@ -15,20 +26,54 @@ const securityHeaders = [
1526
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
1627
];
1728

29+
/**
30+
* The paths the credentials app owns.
31+
*
32+
* `/api/me` and `/api/credshare/*` are named individually, and the caller must
33+
* place these BEFORE the CommandBoard `/api/:path*` catch-all — otherwise the
34+
* catch-all swallows them and sends CLI auth to the wrong service.
35+
*/
36+
export function credentialsRewrites(base: string) {
37+
return [
38+
// the device-code and loopback login flows themselves
39+
{ source: "/cli/:path*", destination: `${base}/cli/:path*` },
40+
// identity, and the credential-sharing API the CLI uses once logged in
41+
{ source: "/api/me", destination: `${base}/api/me` },
42+
{ source: "/api/credshare/:path*", destination: `${base}/api/credshare/:path*` },
43+
// /cli/authorize and /cli/device sit behind requireAuth, so an
44+
// unauthenticated visitor gets redirected here to sign in. Without this the
45+
// browser half of the flow dead-ends on a 404.
46+
{ source: "/auth/:path*", destination: `${base}/auth/:path*` },
47+
];
48+
}
49+
50+
/** CommandBoard's paths. The `/api` entry is a catch-all, so it goes last. */
51+
export function commandboardRewrites(base: string) {
52+
return [
53+
{ source: "/health", destination: `${base}/health` },
54+
{ source: "/api/:path*", destination: `${base}/api/:path*` },
55+
];
56+
}
57+
58+
/** Built as a function so the ordering above is testable without booting Next. */
59+
export function buildRewrites(
60+
credentials = credentialsAppUrl,
61+
commandboard = commandboardApiUrl,
62+
) {
63+
const afterFiles = [
64+
...(credentials ? credentialsRewrites(credentials.replace(/\/$/, "")) : []),
65+
...(commandboard ? commandboardRewrites(commandboard.replace(/\/$/, "")) : []),
66+
];
67+
return afterFiles.length ? { afterFiles } : [];
68+
}
69+
1870
const nextConfig: NextConfig = {
1971
async headers() {
2072
return [{ source: "/:path*", headers: securityHeaders }];
2173
},
2274
async rewrites() {
23-
if (!commandboardApiUrl) return [];
24-
const base = commandboardApiUrl.replace(/\/$/, "");
25-
return {
26-
afterFiles: [
27-
{ source: "/health", destination: `${base}/health` },
28-
{ source: "/api/:path*", destination: `${base}/api/:path*` }
29-
]
30-
};
31-
}
75+
return buildRewrites();
76+
},
3277
};
3378

3479
export default nextConfig;

0 commit comments

Comments
 (0)