| title | Developer Onboarding |
|---|---|
| description | Get the app running, learn the codebase layout, and ship your first change. |
| group | General |
| order | 40 |
Audience: developers joining the codebase. Get it running, learn the layout, and ship your first change.
| Tool | Version | Notes |
|---|---|---|
| Node.js | 24.x | Pinned via .nvmrc (24) and package.json engines (node 24.x, pnpm >=10); CI, the Docker image and Vercel all run Node 24 — keep them on the same major. engines.node names the exact major on purpose: an open range (>=24) would let Vercel move production to a newer major on its own (a Node-24-only failure inside the Next runtime once passed every check on Node 22 CI, see #400). |
| pnpm | 10.33.2 | Pinned via package.json → packageManager, with the release's +sha512.… integrity hash (review #226) — Corepack verifies the tarball before running it. Enable with corepack enable; change the version only with corepack use pnpm@<version>, which rewrites the hash too. |
| Docker | recent | Only used to run PostgreSQL locally. A managed Postgres works too. |
| PostgreSQL | 17 | The local Docker image is pgvector/pgvector:pg17. |
| openssl (or any CSPRNG) | — | For generating secrets. |
| Git | — | — |
Windows note: the repo is developed on Windows and POSIX shells. Commands below are POSIX; PowerShell equivalents are noted where they differ.
git clone <repository-url> devresponsekit
cd devresponsekit
corepack enable # makes the pinned pnpm available
pnpm install # installs from the frozen lockfile
cp .env.example .env # PowerShell: Copy-Item .env.example .envAdding or updating a dependency?
.npmrcsetsminimum-release-age=1440, so pnpm will not RESOLVE a version published in the last 24 hours (review #226 — see SECURITY.md, "Install-time supply chain"). Installs from the lockfile are unaffected; to take an urgent security release inside that window, run the one command with--config.minimum-release-age=0and say why in the PR.
Then set, at minimum, BETTER_AUTH_SECRET in .env to a strong random value (the default is a placeholder). Generate one with:
openssl rand -base64 32To launch SSO handoffs locally (the satellite rig, or the SSO e2e) the primary also needs SSO_HANDOFF_PRIVATE_KEY — an Ed25519 private JWK:
node -e "import('jose').then(async j=>{const {privateKey}=await j.generateKeyPair('EdDSA',{extractable:true});console.log(JSON.stringify(await j.exportJWK(privateKey)))})"See Configuration for the full variable list.
pnpm db:up # start PostgreSQL in Docker (host port 5444)
pnpm db:provision # one shot: Better Auth tables + app schema + seed (auth:migrate → app:migrate → seed)
pnpm dev # start the dev server → http://localhost:3000All tables are deployed into the
authschema (configurable viaDB_SCHEMA; the migrate steps create it automatically). If you inspect the DB withpsql, the tables won't be inpublic— use\dt auth.*orSET search_path = auth, public;. See Configuration →DB_SCHEMA.
db:provision/db:seedare safe to re-run. The seed's inserts areon conflict do nothing, and its only update — relaxing the platform sign-up default toauto_active(verify → active, no approval step) — runs only on a never-administered row. If you changed the policy under Administrator → Platform sign-up defaults, a re-seed keeps your setting and logs[seed] platform sign-up policy left as configured (admin-managed). See Sign-up policy §5.
Sign in with the seeded admin (defaults from .env):
- Email:
admin@devresponse.local - Password:
ChangeMe-LocalOnly-123!
Want multi-tenant test data? Load the dev fixture — 3 organizations × 7 users (all pre-approved), plus 3 cross-org members (one account in all three orgs), two groups with members, and back-dated registrations + audit history so the dashboard charts and recent-activity feed are populated:
pnpm db:seed:devThe dev fixture is local-only by construction. Before opening a connection it refuses (exit 1, nothing written) unless the
DATABASE_URLhost is local —localhost,127.0.0.1,::1,0.0.0.0, or no host — regardless ofNODE_ENV, and it separately refuses underNODE_ENV=production. A Neon / RDS / any hosted URL in your.envtherefore cannot be seeded by accident. If you genuinely want the fixture on a remote disposable database, pass--force(pnpm db:seed:dev --force) or setDEV_SEED_ALLOW_REMOTE=1;NODE_ENV=productionadditionally needsDEV_SEED_ALLOW_PROD=1.pnpm db:resetapplies the same host check (src/db/guards.ts).
Every fixture account shares the password DevPassword123! (override with DEV_SEED_PASSWORD):
| Account | Authority |
|---|---|
superuser@orga.local (also orgb/orgc) |
Cross-organization superadmin |
orgadmin@orga.local (also orgb/orgc) |
Full admin.* catalog, scoped to that one org |
user1..5@orga.local (also orgb/orgc) |
Plain member — shell.view only |
multi1..3@shared.local |
Member of all three orgs (exercises the org switcher) |
Need a clean slate? (Destructive — local only.)
pnpm db:reset # DRY RUN: lists what it would drop, changes nothing
pnpm db:reset:reload # drop everything, re-migrate, and re-seed in one steppnpm typecheck # tsc --noEmit
pnpm lint # eslint .
pnpm format:check # prettier --check (use `pnpm format` to auto-fix)
pnpm test:coverage # vitest with the coverage ratchetThe DB-backed suite runs against a real, migrated Postgres: the database in DATABASE_TEST_URL, or DATABASE_URL when that is unset. It refuses a host that is not local, like db:seed:dev. .env.example's DATABASE_TEST_URL names devresponse_db_test, which you create and migrate once (Testing §3 has the commands):
pnpm test:db # vitest run --config vitest.db.config.tsSlower, browser-based suites (also run in CI):
pnpm build # next build (catches config/server errors early)
pnpm test:e2e # Playwright end-to-end
pnpm test:a11y # Playwright + axe-core accessibilityThere's a single convenience target that runs the whole gate:
pnpm test:allSee Testing for details on each suite, the sharded runner, and the coverage ratchet.
src/
├── app/
│ ├── (root)/ # locale-independent entry (root layout, error)
│ ├── [locale]/ # everything localized lives here
│ │ ├── (public)/ # marketing/landing, about, public docs
│ │ ├── (auth)/ # sign-in, sign-up, verify-email, invite, reset, pending/blocked
│ │ └── (secure)/ # authenticated app — authz boundary is here
│ │ ├── layout.tsx # loads access context + decideSecureAccess
│ │ └── app/
│ │ ├── account/ # profile, preferences, security, api-keys
│ │ ├── docs/ # in-app docs viewer
│ │ └── administrator/ # the admin console
│ │ └── _components/administrator-navigation.ts # canonical nav
│ └── api/ # HTTP API surface
│ ├── auth/[...all]/ # Better Auth catch-all
│ ├── account/ · preferences/ · navigation/
│ ├── sso/ # launch + consume (handoff)
│ ├── docs/asset/ # docs images
│ └── v1/ # versioned machine API
├── lib/
│ ├── auth.ts # Better Auth config
│ ├── auth-status.ts # getUserAccessContext, decideSecureAccess
│ ├── admin/ # guards, access-scope, rate-limit, audit, list-query
│ ├── api-auth/ # API keys, JWT, scopes, caller resolution
│ ├── account/ · email/ · sso* # self-service, outbox email, SSO handoff
│ └── env.ts # environment loading/validation
├── db/
│ ├── database.ts # Kysely instance + pg pool (shared with Better Auth)
│ ├── schema/app-schema.ts # table types
│ ├── migrations/ # 0001-initial-schema.sql + runners
│ ├── seeds/ # seed-local.ts, dev-init.ts
│ └── reset-database.ts # destructive reset tooling
├── components/ # shadcn/ui, app shell, data grid, navigation
├── i18n/ # next-intl request config
└── messages/ # en.json, fr.json, es.json, uk.json, pt.json, zh.json, hi.json, ja.json
tests/ # unit, component, integration, security, e2e, accessibility
scripts/test-shards.mjs # sharded vitest runner
docker/postgres/init/ # Postgres init SQL (extensions)
| Entry point | File |
|---|---|
| Edge proxy (redirect + locale) | src/proxy.ts |
| Root layout | src/app/(root)/layout.tsx |
| Locale layout | src/app/[locale]/layout.tsx |
| Authorization boundary | src/app/[locale]/(secure)/layout.tsx |
| Admin console nav (source of truth) | src/app/[locale]/(secure)/app/administrator/_components/administrator-navigation.ts |
| Better Auth config | src/lib/auth.ts |
| Access-context resolution | src/lib/auth-status.ts |
| Scope primitives | src/lib/admin/access-scope.server.ts |
| DB connection | src/db/database.ts |
| Next.js config (headers, plugins) | next.config.mjs |
- Server-first. Components are Server Components unless they need interactivity; add
"use client"only at the boundary. Server-only modules end in.server.tsand/or importserver-only. - TypeScript strict.
pnpm typecheckmust pass with zero errors. NotenoUncheckedIndexedAccessis on — indexed access yieldsT | undefined. - Validate at the edge with Zod. Route handlers parse request bodies with a Zod schema and return a uniform error envelope on failure.
- Authorize through the primitives. Never re-derive tenant scope inline — call
requireAdminPermission/resolveOrgScope/canAccessOrg. An admin route that doesn't reference a scope primitive fails the CI invariant test. - Rate-limit every admin mutation. Each
POST/PATCH/DELETEadmin handler callsenforceRateLimit(...)right after the permission check (also enforced by an invariant test). - Audit every mutation. Use the
audit*Actionhelpers; pass the request so thex-request-idis correlated. - Internationalize all user-facing text. Every leaf key must exist in all 8 locale files (en, fr, es, uk, pt, zh, hi, ja) — a parity test enforces it. Add keys to
en.jsonfirst, then the rest. - Format dates and numbers through the app formatter.
const format = useAppFormatter()in a client component,const format = await getAppFormatter(locale)in a server one, thenformat.date/format.dateTime/format.number. It applies the viewer's saved time zone, date format and number format, and renders the same text on the server and in the browser. A newIntl.DateTimeFormat,toLocaleString()or next-intluseFormatterundersrc/apporsrc/componentsfails an invariant test (F-37). - Formatting & linting are enforced by Prettier (with the Tailwind plugin) and ESLint (
eslint-config-next+typescript-eslint). Runpnpm formatbefore committing. - Commit & PR hygiene (from project memory): land each logically-complete change as its own PR; PRs auto-merge on green; don't pipe
pnpm buildthroughhead/Select -First(it truncates and breaks the build log) — redirect to a file instead.
A typical admin feature (mirror an existing one such as Roles or Groups):
- Schema (if needed): add a new numbered forward migration (
src/db/migrations/000N-….sql) —0001-initial-schema.sqlis frozen/append-only, never edit it — and add types tosrc/db/schema/app-schema.ts. - Permissions: add keys to
ADMIN_PERMISSION_CATALOGinsrc/lib/admin/permissions.ts(they flow into theadmin.platform/superuserroles automatically). Update the catalog-count test. - API: add a route handler under
src/app/api/administrator/<feature>/.... UserequireAdminPermission,resolveOrgScope/canAccessOrg,enforceRateLimit, Zod validation, the list-query helper, the error envelope, and an audit call. - UI: add pages under
src/app/[locale]/(secure)/app/administrator/<feature>/and a nav entry inadministrator-navigation.tswith arequirespermission. Reuse the sharedDataGridand form patterns. - i18n: add strings to all 8
src/messages/*.jsonfiles (en, fr, es, uk, pt, zh, hi, ja). - Tests: integration tests for the routes, component tests for client UI, and update the invariant/coverage tests as needed.
- Docs: update the relevant file in
/docs.
Browser-observable change? Verify it in a running instance rather than asking a reviewer to check manually.
- Server logs print to the
pnpm devterminal — server component and route-handler errors land here. - Liveness/readiness:
GET /api/health(process up) andGET /api/health/ready(env valid, database reachable, both schemas migrated). A503names itsreason:database_unreachablemeans check Postgres before anything else,schema_behindmeans runpnpm db:auth:migrate && pnpm db:app:migrateand restartpnpm dev, andconfig_invalidmeans a variable in.env(the terminal names it). - Request correlation: every admin and v1 response carries
x-request-id, a thrown500 internal_errorincluded (bar the public, cacheable/api/v1/jwks.jsonand/api/v1/openapi.json); grep the terminal log,app_audit_events(and Sentry, if enabled) for that id. A new route handler must be exported throughwithAdminRoute/withV1Route(src/lib/route-handler.server.ts), ortests/unit/route-request-id-invariant.test.tsfails. The audit trail is often the fastest answer to "what did the app actually do?"
- Connect with any Postgres client to
postgresql://devresponse:devresponse@localhost:5444/devresponse_db. - Every table lives in the
authschema (DB_SCHEMA), notpublic— use\dt auth.*orSET search_path = auth, public;. If you find app tables inpublic, a migration/seed ran without the connection-level search path:DB_SEARCH_PATH_VIA_OPTIONS=0is set. That flag exists only for transaction-pooling endpoints (Neon pooled, PgBouncer) paired with a server-sideALTER ROLE … SET search_path; against local/direct Postgres, leave it unset. Recover by unsetting it, dropping the straypublictables, and re-runningpnpm db:reset:reload. - Inspect
app_audit_eventsto see what the app recorded for an action;app_sso_handoff_noncesshows one row per SSO launch (consumed_atstamps on use — anullfor an old token means the consume POST never arrived).
- Check the
session/accounttables and the Better Auth catch-all responses; confirmBETTER_AUTH_URLmatches the origin you're hitting. - Remember cookies are host-scoped, port-agnostic: two apps on
localhost(any ports) share the samebetter-auth.session_tokencookie slot and will clobber each other; put local test apps on distinct hostnames (see §9.5) when that matters.
With no EMAIL_PROVIDER, messages are written to app_outbox with status logged and visible under Administrator → Email — nothing is ever sent.
The bodies shown there are redacted: a password-reset, verification or invitation link reads …/reset-password/[redacted]?… / …?token=[redacted] because the admin outbox must never hand an org admin a live credential (review #21). To follow such a link locally, read the DB-only delivery_payload column (never served by the API):
select delivery_payload->>'text' from app_outbox
where to_email = 'you@example.com' order by created_at desc limit 1;The e2e suites do the same through tests/e2e/helpers/outbox-db.ts.
To debug cross-subdomain SSO (or any multi-app flow) on one machine, use the suggested subdomain setup in the Satellite Apps Integration Guide §6.6: the kit on http://devresponse.local:3000 and the three satellites on app1/app2/app3.devresponse.local — true subdomains that mirror a live fleet (per-subdomain session cookies for the handoff apps, which still receive the primary's parent-domain cookie, and a parent-domain shared session for Option C). It is one security domain on one database: none of its satellites is contained (§1.1). One elevated run of scripts/setup-local-subdomains.ps1 maps the four hosts to 127.0.0.1 (idempotent; -Remove undoes it); *.localtest.me is the no-admin-rights fallback. The guide includes the copy-paste steps (seed, secret, SQL registration, per-app env, next dev -H …) and the two dev-only gotchas (host binding for absolute URLs; the CSP upgrade-insecure-requests directive silently killing form POSTs on http non-localhost hosts).
| Symptom | Likely cause / fix |
|---|---|
pnpm install fails on version |
Run corepack enable so the pinned pnpm 10.33.2 is used. |
| App can't reach the database | Is pnpm db:up running? Is the port 5444 (not 5432)? Check DATABASE_URL. |
| Boot error about a secret/JWK | BETTER_AUTH_SECRET unset, API_JWT_ENABLED=1 without API_JWT_PRIVATE_KEY, or an Ed25519 key (SSO_HANDOFF_* / API_JWT_*, previous keys included) that cannot be used: the schema checks each one's shape and the Node boot hook imports it, so a truncated value, a stray quote or a mismatched x fails here. The error names the variable and the rule. |
| Boot error about a URL | An origin-valued variable (BETTER_AUTH_URL, SSO_HANDOFF_ISSUER, ADMIN_TRUSTED_ORIGINS, …) is not an http(s) origin, or COOKIE_DOMAIN does not cover BETTER_AUTH_URL. http://localhost:3000 is always fine; see Configuration §1. |
/api/sso/launch returns 503 sso_not_configured |
No SSO_HANDOFF_PRIVATE_KEY on this instance — it can consume handoffs but not issue them. |
403/404 on an admin call you expected to work |
Tenant scope — a non-superadmin only sees their own org; out-of-scope resources return 404 by design. |
Tables ended up in public instead of auth |
DB_SEARCH_PATH_VIA_OPTIONS=0 is set locally — a pooler-only setting. Unset it, drop the strays, re-run pnpm db:reset:reload (see §9.2). |
| Locale-parity test fails | A new text key is missing from one of the 8 locale files (en, fr, es, uk, pt, zh, hi, ja). Add it everywhere. |
| Coverage gate fails but tests pass | New untested code dropped global coverage below the ratchet — add tests (the local sharded runner does not compute coverage; run pnpm test:coverage). |
| Flaky/odd test failures with "not a function" | Run the sharded runner (pnpm test), not a single in-process Vitest run — see Testing. |
More in Troubleshooting.
Next: API Reference · Testing