Next.js 15 + Auth.js v5 skeleton with host→slug middleware, tenant-context layout, and an OIDC sign-in flow against the dev-stack Keycloak.
13 vitest tests, 100% coverage of src/lib/ (the only testable pure code so far).
All four CI gates green locally: pnpm lint / typecheck / test --coverage / build.
Why
Unblock M5.1 portal work for local dev. Pairs with platform/orca-platform#4 (dev stack) and platform/tenant-registry#4 (Go skeleton). Once all three merge, make dev-up + make dev × 2 + open http://acme.localhost:3000 completes a real OIDC dance and renders /acme/dashboard with session claims propagated.
Linked milestone: M5.1 (skeleton)
How
Host → slug: src/middleware.ts calls parseHost(request.headers.get('host')) from src/lib/host.ts and rewrites the request to inject the slug as a path prefix. URL bar is unchanged. backstage.<apex> is reserved and rewrites to /__backstage__/* (route doesn't exist yet — backstage shell lands in M5.2).
Apex iteration order: APEX_HOSTS is iterated longest-first so stage.breakpilot.com matches before breakpilot.com — caught by a test (acme.stage.breakpilot.com → {kind:tenant, slug:acme}).
Auth.js v5 Keycloak provider: Public PKCE client. Auth.js structurally requires a clientSecret field even for public clients; we pass a placeholder unused-public-client and rely on PKCE for the code grant. The realm's dev-portal client has publicClient: true + pkce.code.challenge.method: S256. Token / session callbacks lift the breakpilot-dev realm's custom claims (tenant_id, tenant_slug, org_roles, products, plan, tenant_status) into the session so downstream pages can read them off auth().
Server actions for signIn / signOut on /[slug]/dashboard — no client component needed.
Coverage scoped to src/lib/ in vitest.config.ts — the rest of src/ is Next.js pages/middleware that vitest doesn't run. Same 100% requirement; the scope just narrows what we measure. When real logic lands outside src/lib/, widen the include.
Test plan
pnpm lint (next lint, max-warnings 0) ✅
pnpm typecheck (tsc --noEmit) ✅
pnpm test — 13 tests, 100% coverage of src/lib/ ✅
pnpm build — compiles all routes, output: standalone ✅
Manual OIDC smoke: bring up dev stack + tenant-registry, hit http://acme.localhost:3000, complete the Keycloak login, land on /acme/dashboard with the test user's session claims visible. (Will do once all 3 PRs merge.)
Risk
Blast radius: dev only. No prod manifest references this image yet.
What could break:
Auth.js v5 is beta (currently 5.0.0-beta.25). The session-claim callback shape may shift before GA — I'm using Record<string, unknown> casts to keep types honest while letting the runtime do its thing.
pnpm install --frozen-lockfile in CI requires the committed pnpm-lock.yaml to match package.json exactly. If a future PR forgets to commit a lockfile bump, CI fails fast — which is the point.
The unused-public-client clientSecret is a load-bearing tiny lie. If anyone copies this pattern into prod with a confidential client, the auth flow breaks loudly (Keycloak rejects). The placeholder is local-only; prod gets a real secret per Infisical.
Rollback plan: revert the PR. Nothing in prod or stage depends on this yet.
Checklist
Unit tests added (13, 100% of src/lib/ coverage)
Docs updated (README + CHANGELOG)
Secrets — none in repo. .env.example documents what dev needs.
Migration — n/a (no DB)
Tenant scoping — enforced via middleware host parse + tenant-registry lookup; real RBAC gates land in M5.2
OpenAPI — n/a (consumer, not provider)
CHANGELOG entry under "Added"
## What
- Next.js 15 + Auth.js v5 skeleton with host→slug middleware, tenant-context layout, and an OIDC sign-in flow against the dev-stack Keycloak.
- 13 vitest tests, 100% coverage of `src/lib/` (the only testable pure code so far).
- All four CI gates green locally: `pnpm lint` / `typecheck` / `test --coverage` / `build`.
## Why
Unblock M5.1 portal work for local dev. Pairs with `platform/orca-platform#4` (dev stack) and `platform/tenant-registry#4` (Go skeleton). Once all three merge, `make dev-up` + `make dev` × 2 + `open http://acme.localhost:3000` completes a real OIDC dance and renders `/acme/dashboard` with session claims propagated.
Linked milestone: **M5.1** (skeleton)
## How
- **Host → slug**: `src/middleware.ts` calls `parseHost(request.headers.get('host'))` from `src/lib/host.ts` and rewrites the request to inject the slug as a path prefix. URL bar is unchanged. `backstage.<apex>` is reserved and rewrites to `/__backstage__/*` (route doesn't exist yet — backstage shell lands in M5.2).
- **Apex iteration order**: `APEX_HOSTS` is iterated longest-first so `stage.breakpilot.com` matches before `breakpilot.com` — caught by a test (`acme.stage.breakpilot.com` → `{kind:tenant, slug:acme}`).
- **Auth.js v5 Keycloak provider**: Public PKCE client. Auth.js structurally requires a `clientSecret` field even for public clients; we pass a placeholder `unused-public-client` and rely on PKCE for the code grant. The realm's `dev-portal` client has `publicClient: true` + `pkce.code.challenge.method: S256`. Token / session callbacks lift the breakpilot-dev realm's custom claims (`tenant_id`, `tenant_slug`, `org_roles`, `products`, `plan`, `tenant_status`) into the session so downstream pages can read them off `auth()`.
- **Server actions** for `signIn` / `signOut` on `/[slug]/dashboard` — no client component needed.
- **Coverage scoped to `src/lib/`** in `vitest.config.ts` — the rest of `src/` is Next.js pages/middleware that vitest doesn't run. Same 100% requirement; the scope just narrows what we measure. When real logic lands outside `src/lib/`, widen the include.
## Test plan
- [x] `pnpm lint` (next lint, max-warnings 0) ✅
- [x] `pnpm typecheck` (tsc --noEmit) ✅
- [x] `pnpm test` — 13 tests, 100% coverage of src/lib/ ✅
- [x] `pnpm build` — compiles all routes, output: standalone ✅
- [ ] Manual OIDC smoke: bring up dev stack + tenant-registry, hit `http://acme.localhost:3000`, complete the Keycloak login, land on `/acme/dashboard` with the test user's session claims visible. (Will do once all 3 PRs merge.)
## Risk
**Blast radius:** dev only. No prod manifest references this image yet.
**What could break:**
- Auth.js v5 is **beta** (currently 5.0.0-beta.25). The session-claim callback shape may shift before GA — I'm using `Record<string, unknown>` casts to keep types honest while letting the runtime do its thing.
- `pnpm install --frozen-lockfile` in CI requires the committed `pnpm-lock.yaml` to match `package.json` exactly. If a future PR forgets to commit a lockfile bump, CI fails fast — which is the point.
- The `unused-public-client` clientSecret is a load-bearing tiny lie. If anyone copies this pattern into prod with a confidential client, the auth flow breaks loudly (Keycloak rejects). The placeholder is local-only; prod gets a real secret per Infisical.
**Rollback plan:** revert the PR. Nothing in prod or stage depends on this yet.
## Checklist
- [x] Unit tests added (13, 100% of `src/lib/` coverage)
- [x] Docs updated (README + CHANGELOG)
- [x] Secrets — none in repo. `.env.example` documents what dev needs.
- [ ] Migration — n/a (no DB)
- [ ] Tenant scoping — enforced via middleware host parse + tenant-registry lookup; real RBAC gates land in M5.2
- [ ] OpenAPI — n/a (consumer, not provider)
- [x] CHANGELOG entry under "Added"
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)
CODEOWNERS rules
requested review from Benjamin_Boenisch 2026-05-18 20:55:27 +00:00
trivy fs scan failed the M0.2 CI gate on the skeleton commit because
next 15.0.3 has 9 known vulns (CRITICAL CVE-2025-29927 auth bypass in
middleware, plus 7 HIGH advisories). 16.2.6 is current latest and
covers every fixed-version range trivy listed.
Side effects of the major bump:
- next 16 dropped 'next lint' — switched the lint script to call eslint
directly ('eslint . --max-warnings 0').
- eslint-config-next 16 ships flat-config exports natively, so
eslint.config.mjs imports core-web-vitals + typescript directly
(no FlatCompat shim, no @eslint/eslintrc dep).
- Typed vi.fn<typeof fetch>() in tenant-registry.test to satisfy
stricter tuple inference under the new types.
All 4 gates green locally:
pnpm lint / typecheck / test --coverage (100% on src/lib) / build
Refs: M5.1 (skeleton)
Gitea's act_runner doesn't evaluate hashFiles() at job-level if:
conditions, so the gate I added in M0.2 universally skipped the test
job even when package.json was committed. Drop it for portal —
package.json is a permanent fixture in this repo so we always want
test + e2e to run. e2e/image jobs keep their other conditions
(push-to-main, etc.)
Refs: M5.1
Two CI bugs the M0.2 ci-typescript.yaml template carried into portal:
1. 'pnpm test --coverage' is parsed as a pnpm option, not script args
('Unknown option: coverage'). Drop the extra flag; the package.json
test script already runs 'vitest run --coverage'.
2. 'next build' requires AUTH_SECRET at compile time because Auth.js
v5 reads it during route generation. Inject a per-build dummy
secret in CI (production gets the real one via Orca env from
Infisical).
Refs: M5.1
sharang
merged commit e7a1290246 into main2026-05-19 09:35:06 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
src/lib/(the only testable pure code so far).pnpm lint/typecheck/test --coverage/build.Why
Unblock M5.1 portal work for local dev. Pairs with
platform/orca-platform#4(dev stack) andplatform/tenant-registry#4(Go skeleton). Once all three merge,make dev-up+make dev× 2 +open http://acme.localhost:3000completes a real OIDC dance and renders/acme/dashboardwith session claims propagated.Linked milestone: M5.1 (skeleton)
How
src/middleware.tscallsparseHost(request.headers.get('host'))fromsrc/lib/host.tsand rewrites the request to inject the slug as a path prefix. URL bar is unchanged.backstage.<apex>is reserved and rewrites to/__backstage__/*(route doesn't exist yet — backstage shell lands in M5.2).APEX_HOSTSis iterated longest-first sostage.breakpilot.commatches beforebreakpilot.com— caught by a test (acme.stage.breakpilot.com→{kind:tenant, slug:acme}).clientSecretfield even for public clients; we pass a placeholderunused-public-clientand rely on PKCE for the code grant. The realm'sdev-portalclient haspublicClient: true+pkce.code.challenge.method: S256. Token / session callbacks lift the breakpilot-dev realm's custom claims (tenant_id,tenant_slug,org_roles,products,plan,tenant_status) into the session so downstream pages can read them offauth().signIn/signOuton/[slug]/dashboard— no client component needed.src/lib/invitest.config.ts— the rest ofsrc/is Next.js pages/middleware that vitest doesn't run. Same 100% requirement; the scope just narrows what we measure. When real logic lands outsidesrc/lib/, widen the include.Test plan
pnpm lint(next lint, max-warnings 0) ✅pnpm typecheck(tsc --noEmit) ✅pnpm test— 13 tests, 100% coverage of src/lib/ ✅pnpm build— compiles all routes, output: standalone ✅http://acme.localhost:3000, complete the Keycloak login, land on/acme/dashboardwith the test user's session claims visible. (Will do once all 3 PRs merge.)Risk
Blast radius: dev only. No prod manifest references this image yet.
What could break:
Record<string, unknown>casts to keep types honest while letting the runtime do its thing.pnpm install --frozen-lockfilein CI requires the committedpnpm-lock.yamlto matchpackage.jsonexactly. If a future PR forgets to commit a lockfile bump, CI fails fast — which is the point.unused-public-clientclientSecret is a load-bearing tiny lie. If anyone copies this pattern into prod with a confidential client, the auth flow breaks loudly (Keycloak rejects). The placeholder is local-only; prod gets a real secret per Infisical.Rollback plan: revert the PR. Nothing in prod or stage depends on this yet.
Checklist
src/lib/coverage).env.exampledocuments what dev needs.trivy fs scan failed the M0.2 CI gate on the skeleton commit because next 15.0.3 has 9 known vulns (CRITICAL CVE-2025-29927 auth bypass in middleware, plus 7 HIGH advisories). 16.2.6 is current latest and covers every fixed-version range trivy listed. Side effects of the major bump: - next 16 dropped 'next lint' — switched the lint script to call eslint directly ('eslint . --max-warnings 0'). - eslint-config-next 16 ships flat-config exports natively, so eslint.config.mjs imports core-web-vitals + typescript directly (no FlatCompat shim, no @eslint/eslintrc dep). - Typed vi.fn<typeof fetch>() in tenant-registry.test to satisfy stricter tuple inference under the new types. All 4 gates green locally: pnpm lint / typecheck / test --coverage (100% on src/lib) / build Refs: M5.1 (skeleton)cb91109b66toc051ae0626Two CI bugs the M0.2 ci-typescript.yaml template carried into portal: 1. 'pnpm test --coverage' is parsed as a pnpm option, not script args ('Unknown option: coverage'). Drop the extra flag; the package.json test script already runs 'vitest run --coverage'. 2. 'next build' requires AUTH_SECRET at compile time because Auth.js v5 reads it during route generation. Inject a per-build dummy secret in CI (production gets the real one via Orca env from Infisical). Refs: M5.1