Imported from carneirofc/deedlit.dev.portal (
AGENTS.md). Install upstream withnpx skills add carneirofc/deedlit.dev.portal. Copyright stays with the author.
deedlit.dev.portal — Public Site + Service Portal
Purpose
- Public-facing Next.js site (port 3001): home, books shelf (Goodreads metadata), image gallery, and PWA-facing work.
- Plus
/portal: an authenticated, logged-in-only dashboard listing the cluster's services and their health. Deployed to Kubernetes fromdeedlit.dev.infra/apps/portal.
Ownership
- Owns landing/content routes, the service portal, the Goodreads scrape pipeline, and PWA assets.
- Must NOT own generic reusable UI primitives — those belong in
deedlit.dev.ui.
Local Contracts
- App Router, Server Components by default:
src/app/(books,gallery,image,login,portal,api,layout.tsx,manifest.ts,robots.ts,sitemap.ts); feature folders undersrc/features/{books,gallery,home,services,showcase}/;src/components/{layout,pwa}/;src/lib/. scripts/scrape-goodreads.tspopulatessrc/features/books/data/books-metadata.jsonfrombooks-config.ts(npm run scrape-goodreads).- Consumes
@carneirofc/uias a published npm dependency from GitHub Packages (built fromdeedlit.dev.uiin thedeedlit.devmonorepo, released via that repo'spublish-ui.yml). No backend/gateway dependency. - Conventions:
.github/copilot-instructions.md(App Router, feature folders, reuse@carneirofc/ui). Note: thetest:e2escript exists but no Playwright config ore2e/specs are currently committed — treat olde2e/references (and.cursorrules) as historical. - Linting is Biome, configured once in
biome.jsoncat the repo root (ESLint is gone —next linthad been removed by Next 16 anyway).npm run linthere isbiome lint .;npm run lint:fixapplies safe fixes. This package is clean and CI gates on it, so keep it that way.
Authentication
- Auth.js v5 (
next-auth@beta) insrc/auth.ts; JWT sessions, no database.src/auth.tsis only the composition root — each provider's rules live insrc/lib/auth/{github,oidc}.ts. - Two providers, either optional: GitHub and a generic OIDC issuer. A provider is offered on
/loginonly when fully configured (signInProviders()), andcallbacks.signIndispatches onaccount.provider— an unknown or removed provider is refused, there is no fallback between them. Configure neither and nobody can sign in. - Sign-in is authorization as well, and both paths fail closed — a missing or broken config locks everyone out rather than letting everyone in:
- GitHub: active members of
AUTH_ALLOWED_GITHUB_ORG(optionally narrowed byAUTH_ALLOWED_GITHUB_TEAM), plus anyone inAUTH_ALLOWED_GITHUB_USERS. - OIDC: any one of
AUTH_ALLOWED_OIDC_SUBJECTS/_EMAILS/_DOMAINS/_GROUPSmatching. With none of the four set, every OIDC account is rejected — authenticating at the issuer is not authorization to use the portal. Email and domain rules only match a verified address (email_verified === true; a missing claim counts as unverified), so an IdP that omits it must be matched onsubor groups.
- GitHub: active members of
- Two layers guard
/portal:src/proxy.tsredirects anonymous requests (UX), andsrc/app/portal/layout.tsxre-checks the session server-side. The layout check is the security boundary; route handlers under/api/portal/*checkauth()themselves. - Env:
AUTH_SECRET(≥32 chars —signInrefuses to authorize below that, since a guessable secret makes session JWEs forgeable),AUTH_TRUST_HOST=true(required behind Traefik),AUTH_URL(see below), plus per provider:- GitHub —
AUTH_GITHUB_ID,AUTH_GITHUB_SECRET,AUTH_ALLOWED_GITHUB_ORG, optionalAUTH_ALLOWED_GITHUB_TEAM/AUTH_ALLOWED_GITHUB_USERS. Callback:<origin>/api/auth/callback/github. - OIDC —
AUTH_OIDC_ISSUER,AUTH_OIDC_ID,AUTH_OIDC_SECRET, optionalAUTH_OIDC_NAME(button label) /AUTH_OIDC_SCOPE/AUTH_OIDC_USERNAME_CLAIM/AUTH_OIDC_GROUPS_CLAIM, plus the allow-lists above. Callback:<origin>/api/auth/callback/oidc.
- GitHub —
- OIDC is discovery-only: endpoints come from
<issuer>/.well-known/openid-configuration, so no provider-specific code is needed for Keycloak/Authentik/Entra/Okta/… That discovery document decides where authorization codes are sent, so a non-httpsissuer is refused outside loopback. Group claims are read fromAUTH_OIDC_GROUPS_CLAIM(defaultgroups) and normalised from either an array or a delimited string; most IdPs need an extra scope before they emit them at all. session.user.usernamecarries the GitHub login or the OIDC username claim (display only — a login can be renamed),session.user.providersays which path issued the session, andsession.user.subjectis the provider's immutable account id. No provider access token is ever persisted into the JWT — it has done its job insignIn, and keeping it would park a live credential in the session cookie.- Authorization is never cached in the token. The JWT carries identity only (
subject,verifiedEmail,groups), andstillAuthorized()re-derives the decision in thejwtcallback, which Auth.js runs on everyauth()call; returningnullthere ends the session on that request. So removing someone from an allow-list, or switching a provider off, revokes at their next request instead of in eight hours. It is a pure env comparison — no network. The one gap is GitHub org/team membership, which cannot be re-verified without the access token this app refuses to store, so that check stands for the session lifetime. Do not "optimise" this into a stored boolean. token.verifiedEmailis separate from the standardemailclaim on purpose:emailis whatever the IdP sent, verified or not, and must never be what an allow-list matches on.AUTH_URLis not optional in a deployment.trustHostmakes Auth.js derive its origin from theHost/X-Forwarded-Hostheader, which a client controls;AUTH_URLpins it to configuration instead. Its scheme also decidesuseSecureCookies, so anhttps://value is what puts the__Secure-prefix and theSecureattribute on the session cookie. The chart sets it from the Ingress host — keep the two in step.
Security
src/proxy.tsowns the security headers and generates a per-request CSP nonce, consumed by the inline theme script insrc/app/layout.tsx. Adding another inline script means giving it that nonce — do not relax the policy tounsafe-inline.- The portal's health prober takes its target from Kubernetes annotations, so
server/discover.tsvalidates every component andserver/health.tsre-checks the final URL before dialling. Keep that; seesrc/features/services/README.md. - The cluster read surface is two read-only verbs:
liston label-selected Ingresses andgeton the Services those Ingresses name (only for named backend ports). Do not reintroduceGET /api/v1/services— listing needsliston Services cluster-wide, which turns this token into a cluster inventory. Keep the ClusterRole indeedlit.dev.inframatched to the two verbs. - Discovery + probing go through
server/snapshot.ts(15s TTL, in-flight coalescing). It exists to bound fan-out: one/portalrender is one apiserver list plus one probe per service, and every reader is authenticated, so auth alone does not stop a reload loop. CallinvalidatePortalSnapshot()from anything that must genuinely re-probe. /portaland/loginare excluded fromrobots.tsand markednoindex.- Rate limits live in
src/lib/rate-limit.ts(in-memory, per process, bounded map — deliberately not Redis for a single low-traffic replica). Applied at/api/portal/services(30/min), the refresh action (10/min, because it bypasses the snapshot cache) and sign-in (10/min). Authenticated limits key onsessionKey(session)— an identity out of an encrypted cookie, so the bucket cannot be shed by changing anything the client controls. The sign-in limit has no session, so it keys on the lastX-Forwarded-Forentry: Traefik appends the real peer, and the entries before it are attacker-written. - Transport, end to end: HSTS +
useSecureCookiesfromAUTH_URLfor the browser hop; OIDC discovery refused over plaintext outside loopback, with PKCE andstate; the apiserver call pins the cluster CA, floors at TLS 1.2, and has no verification-disabling switch to be turned on in an outage. The in-cluster health probes are plain HTTP by design — they carry no credential, send no cookie, don't follow redirects and read only a status line, so the worst an on-path attacker gets is a lie about whether a service is up. Never add an "insecure TLS" env flag to any of these paths. callbackUrlreaches/loginfrom the query string, so it is attacker-controlled.safeCallbackUrl()admits only site-relative paths and rejects//hostand/\host, which browsers read as protocol-relative. Do not pass a rawcallbackUrltoredirect().- Keep
nexton a patched release. 16.1.6 carried CVE-2026-64642 (authentication bypass) and CVE-2026-44575 (middleware bypass) — both directly defeat this app's/portalboundary. The range is^16.3.0: 16.2.x pinssharp: ^0.34.5, whose bundled libvips carries open CVEs, and only 16.3.0 moved to^0.35.3.npm audit --omit=dev --audit-level=highgates it in CI.
Deployment
Dockerfilebuilds from this repo's root (docker build --secret id=npm_token,env=NODE_AUTH_TOKEN -t portal .).@carneirofc/uiis a published GitHub Packages dependency, sonpm cineeds registry auth — the token is mounted as a BuildKit secret into a throwaway.npmrcin thedepsstage only, never copied into a later stage. Standalone output runs asnode server.js.- Runtime is distroless (
gcr.io/distroless/nodejs24-debian12) and both bases are pinned by digest. The Debian-slim runtime used to carry npm and yarn, which were the source of every CRITICAL/HIGH image finding that was not the app itself; distroless also removes the shell, so there is no interactive foothold in a compromised pod.USER 1001:1001is numeric to matchpodSecurityContext.runAsUserand satisfyrunAsNonRoot. Refresh a digest by re-pulling the tag and readingRepoDigests— the Dockerfile header has the command. - The build also deletes
node_modules/typescriptfrom the standalone output: Next's tracer pulls the compiler in via a dev-only code path innext-server, and it is 19 MB a production server never loads. .github/workflows/portal.ymltypechecks, audits, hadolints, builds and Trivy-scans the image before any push, then publishes toghcr.io/carneirofc/deedlit.dev-portaland prints the digest to pin inapps/portal/values-prd.yaml. Scanner exceptions live in.trivyignore.yamlat the repo root and every one of them must carry anexpired_at.package-lock.jsonis multi-platform and must stay that way: it carries the optional native binaries (lightningcss,@tailwindcss/oxide,sharp, …) for win32, linux-gnu, linux-musl and darwin, sonpm ciworks on a Windows workstation and in a Linux container alike. It was win32-only once, which madenpm cion Linux silently skip those binaries and the Tailwind build fail withCannot find module '../lightningcss.linux-x64-gnu.node'. If that returns, re-add the missing platforms rather than special-casing the Dockerfile —npm install --package-lock-only --include=optional --os=linux --cpu=x64 --libc=glibc(repeat per platform) on a machine with a fresh lockfile, or patch the entries in directly./api/healthis the unauthenticated liveness/readiness endpoint.
Work Guidance
- Keep changes package-local; promote reusable UI to
deedlit.dev.uirather than duplicating it here. - Service listings are data from the cluster, not code here — to add a service, annotate its Ingress in
deedlit.dev.infra.
Verification
npx tsc --noEmit --project .·npm run build·npm run dev.- Container: build from the repo root, then check
/api/health→ 200,/api/portal/services→ 401 unauthenticated,/portal→ 307 to/login. npm run lint(Biome) must stay at zero errors for this package.npm run test:e2eis defined but has no committed specs yet.
Child Guides
docs/AGENTS.md— Playwright/testing & local-setup guide for this app (port 3001, npm scripts, scraper). Reference material; verify againstpackage.jsonbefore relying on the e2e steps, since no Playwright specs are currently committed.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.