feat(app): Next.js 15 + Auth.js v5 portal skeleton
Lands the minimum surface so a developer can: cd platform/orca-platform && make dev-up cd platform/tenant-registry && make dev cd platform/portal && make install && make dev open http://acme.localhost:3000 and complete a real OIDC sign-in against the breakpilot-dev realm. Layout: src/middleware.ts host→slug URL rewrite; backstage carve-out src/auth.ts Auth.js v5 Keycloak provider; passes tenant_id/slug/org_roles/products/plan/status claims through to the session src/app/api/auth/[...nextauth]/ Auth.js handlers (GET, POST) src/app/layout.tsx root html shell src/app/page.tsx apex landing src/app/[slug]/layout.tsx fetches tenant via lib/tenant-registry src/app/[slug]/page.tsx redirect to /dashboard src/app/[slug]/dashboard/page.tsx signed-out → Sign in with Keycloak signed-in → welcome + Sign out src/lib/host.ts testable host parser (apex/tenant/backstage) src/lib/tenant-registry.ts fetch client for the Go service Tooling: vitest 13 tests, 100% coverage of src/lib/ Next.js 15 build compiles all routes; output: standalone ESLint flat config next/core-web-vitals + next/typescript Real RBAC enforcement, the rest of the customer-area surfaces, and the backstage shell land per the M5.2 / M10.1 schedule. This is just enough to be the first thing a developer codes in. Refs: M5.1 (skeleton)
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
// Host → tenant slug parser for the portal middleware.
|
||||
//
|
||||
// In dev we serve at <slug>.localhost:3000 (e.g. acme.localhost:3000). In
|
||||
// prod we serve at <slug>.breakpilot.com. Backstage lives at the apex —
|
||||
// no subdomain — and resolves to a fixed `__backstage__` slug.
|
||||
|
||||
export type HostMatch =
|
||||
| { kind: "tenant"; slug: string }
|
||||
| { kind: "backstage" }
|
||||
| { kind: "apex" }
|
||||
| { kind: "unknown" };
|
||||
|
||||
// Longest-first so `stage.breakpilot.com` is matched before `breakpilot.com`.
|
||||
const APEX_HOSTS = ["stage.breakpilot.com", "breakpilot.com", "localhost"];
|
||||
const APEX_SET = new Set(APEX_HOSTS);
|
||||
|
||||
export function parseHost(host: string | null | undefined): HostMatch {
|
||||
if (!host) return { kind: "unknown" };
|
||||
|
||||
const hostNoPort = host.split(":")[0].toLowerCase();
|
||||
|
||||
if (APEX_SET.has(hostNoPort)) return { kind: "apex" };
|
||||
|
||||
// Strip the known apex suffix to extract the subdomain.
|
||||
for (const apex of APEX_HOSTS) {
|
||||
const suffix = `.${apex}`;
|
||||
if (hostNoPort.endsWith(suffix)) {
|
||||
const sub = hostNoPort.slice(0, -suffix.length);
|
||||
if (!sub) return { kind: "apex" };
|
||||
// Backstage is reserved.
|
||||
if (sub === "backstage") return { kind: "backstage" };
|
||||
// Slugs are [a-z0-9-]{2,40} per the tenant-registry schema check.
|
||||
if (/^[a-z0-9-]{2,40}$/.test(sub)) {
|
||||
return { kind: "tenant", slug: sub };
|
||||
}
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
Reference in New Issue
Block a user