Introduces src/models/admin/, a dedicated identity and permissions module on its own nachklang_admin database, and the shared authenticator that feedback and tickets will move onto in the cutover step. Nothing swaps over yet: feedback.auth.ts and tickets.auth.ts still authenticate against the legacy calendar sessions, so production behaviour is unchanged. - better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies scoped to .nachklang.art so one sign-in covers every *.nachklang.art app. - Accounts are invite-only: public sign-up is disabled, and the invitations plugin is the only code that creates users. Tokens are stored as SHA-256 hashes and travel in the request body, never in a URL. - Per-app permissions in user_app_permissions; requireAppAccess(app) queries the database on every request (no cookie cache) so disabling a user or revoking a session takes effect immediately. - ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently and without crashing the API if the database is unreachable at boot. - Guards prevent an admin from removing their own admin permission, disabling themselves, or stripping the last active admin. The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's MysqlDialect drives the pool with callbacks, and the promise wrapper ignores them, so every query hangs silently. Only the integration tests caught this. Schema in sql/admin/001_init.sql, derived from getAuthTables() on the installed better-auth rather than the published CLI, which lags the library and omits account.issuer. app.ts is split into src/app.factory.ts so the integration tests drive the real middleware order rather than a copy of it. Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB started by test/integration/setup.ts (docker or podman). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.0 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Commands
npm run build # Compile TypeScript → dist/
npm run start # Build and start (tsc && node ./dist/app.js)
npm run debug # Start with DEBUG=* environment variable
npm run test # Run the vitest suite once with coverage (lcov + testResults/sonar-report.xml)
npm run test:watch # vitest in watch mode
npm run test:integration # Admin-module tests against a throwaway MariaDB (needs docker or podman)
Run a single test file:
npx vitest run test/some.test.ts
Architecture
Express.js REST API in TypeScript with a service-oriented layering. Domains: Calendar (events, users), Feedback (concert feedback forms, mounted at /feedback, backed by its own FEEDBACK_DB — see src/models/feedback/: public submission flow, admin CRUD, reporting, and a Salesforce newsletter-sync integration), Tickets (mounted at /tickets), and Admin (identity and permissions, mounted at /admin, backed by ADMIN_DB — see below).
src/app.factory.ts builds the Express app; app.ts only starts it. The split exists so the integration tests drive the real wiring.
Request path:
src/app.factory.tsmountsCalendar.router.tsat/calendarCalendar.router.tsdelegates toevents.router.tsandusers.router.ts- Routers call services; services call the MariaDB pool in
Calendar.db.ts
Key layers:
| Layer | Location |
|---|---|
| Router | src/models/calendar/Calendar.router.ts, …/events/events.router.ts, …/users/users.router.ts |
| Services | …/events/events.service.ts, …/users/users.service.ts, …/events/credentials.service.ts, …/events/icalgenerator.service.ts |
| DB pool | src/models/calendar/Calendar.db.ts (MariaDB, pool size 5) |
| Shared | src/common/ (base route class, nodemailer wrapper), src/middleware/logger.ts (Winston) |
Auth model: Two of them, on purpose.
Admin module (src/models/admin/) — the current one, used by feedback, tickets and the
admin app. better-auth 1.7 on its own nachklang_admin database (Kysely + mysql2; every
other domain keeps the mariadb driver), mounted at /admin/auth/* for the auth handler
and /admin for the JSON routes. Sessions are httpOnly cookies scoped to
.nachklang.art, so one sign-in covers every app. Accounts are invite-only — public
sign-up is disabled, and invitations.plugin.ts is the only code that creates users.
Permissions are per app in user_app_permissions; requireAppAccess(app) in
admin.middleware.ts is the single authenticator, and it queries the database on every
request (no cookie cache) so disabling a user takes effect at once. ADMIN_BOOTSTRAP_EMAIL
makes sure someone can always get in on a fresh database.
Legacy calendar — unchanged: users need a @nachklang.art email, and after activation
get a session token (30-day window, hash + IP stored in the DB), passed as query
parameters. Migration is planned but not started: docs/calendar-auth-migration.md.
Credentials for non-user calendar access (MEMBER_CREDENTIAL, CHOIR_CREDENTIAL,
MANAGEMENT_CREDENTIAL) come from .env.
Admin database driver: the admin pool is the callback-style mysql2, never
mysql2/promise. Kysely's MysqlDialect calls pool.getConnection((err, conn) => ...);
the promise wrapper ignores that callback, so every Kysely query hangs forever with no
error. Only the integration tests catch this.
Admin schema changes: sql/admin/NNN_*.sql, hand-maintained and mirrored in
docker/init/. The better-auth tables must match what the configured version derives from
admin.auth.ts — on every better-auth upgrade, re-derive them (getAuthTables from
better-auth/db, called with auth.options), diff, and add a numbered migration. Do not
use the published @better-auth/cli; it lags the library.
Event versioning: Events have a companion event_versions table. events.service.ts manages writes to both.
Calendar types and IDs: public (1), members (2), management (3), choir (4), birthdays (5). credentials.service.ts enforces which session/credential can read each calendar.
iCal export: icalgenerator.service.ts converts DB events to RFC 5545 format; reachable via GET /calendar/events/{calendar}/ical.
API docs: Swagger UI served at /docs, generated from JSDoc annotations in the router files.
Environment
dotenv 16 parses .env stricter than the old dotenv 8: an unquoted # starts a comment and
backslash escapes inside double quotes are expanded. Wrap any value containing #, ", \ or
surrounding spaces in single quotes (DB_PASSWORD='abc#def'), which are taken literally.
A truncated password shows up as MariaDB "Access denied ... (using password: YES)".
Copy .env.example (or create .env) with:
PORT=
DB_HOST=
DB_USER=
DB_PASSWORD=
CALENDAR_DB=
ADMIN_DB=
BETTER_AUTH_SECRET=
API_BASE_URL=
ADMIN_APP_URL=
APP_ORIGINS=
PASSKEY_RP_ID=
ADMIN_BOOTSTRAP_EMAIL=
FEEDBACK_DB=
FEEDBACK_IP_SALT=
FEEDBACK_RATE_LIMIT_MAX=
FEEDBACK_RATE_LIMIT_WINDOW_MIN=
SALESFORCE_ENABLED=
SALESFORCE_API_URL=
SALESFORCE_CLIENT_ID=
SALESFORCE_CLIENT_SECRET=
EMAIL_HOST=
EMAIL_USERNAME=
EMAIL_PASSWORD=
MEMBER_CREDENTIAL=
CHOIR_CREDENTIAL=
MANAGEMENT_CREDENTIAL=
TypeScript / module system
The API runs on Node 26 (engines in package.json, .nvmrc; Plesk runs 26 too) and is native ESM ("type": "module", module: nodenext, target ES2024, strict mode, compiled output in ./dist, inline source maps). Consequences:
- Relative imports carry the
.jssuffix (import {x} from "./x.js") even though the source file is.ts. - CommonJS dependencies are consumed via default imports (
import mariadb from "mariadb",import cors from "cors",import winston from "winston"), neverrequire(). - Tests run with vitest directly against
.tssources; importdescribe/it/expect/vifromvitestexplicitly (no globals). Module mocks usevi.mock(...)with the same.js-suffixed paths as the imports.