logicsrc/apps/logicsrc-web/next.config.ts
Anthony Ettinger 3649f78fb6
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>
2026-07-30 10:25:16 -07:00

79 lines
3.3 KiB
TypeScript

import type { NextConfig } from "next";
// The CommandBoard API (boards, tasks, plugins, /health) runs as its own
// service. In the old custom server.js it was mounted in-process; here we proxy
// those paths to it via rewrites. Our own /api routes (hire-us, oauth/coinpay,
// 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" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ 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() {
return buildRewrites();
},
};
export default nextConfig;