Files
API/CLAUDE.md
T
Paddy aa95ab2745 Drop the calendar's legacy authentication path
Step 5, the last one, of docs/calendar-auth-migration.md. Step 4 is deployed
and verified, which is what this was waiting on: it removes the fallbacks that
step 4 still leaned on.

Gone: src/models/calendar/users/ entirely - registration, login, activation,
both password-reset routes, and the session checking that the feedback and
tickets admin areas used to authenticate against - along with its mount. That
was the API's last unauthenticated account-creation and mail-sending endpoint.
A survey confirmed nothing outside that directory imported it and nothing else
touched its tables.

Also gone: the two joins against the calendar users table in events.service.ts
and the created_by_id / version_created_by_id columns they read, from the SQL,
the row mapper, the Event interface and the swagger schema; and X-Session-Id /
X-Session-Key from the CORS allowedHeaders, which nothing has read since the
first cutover and nothing has sent since the second.

An event's author still renders, because migration 002 snapshotted the names
before this could erase them. That was brought forward from this step on
purpose, and it is the reason 004 can rename the accounts aside at all.

The accounts are renamed rather than dropped - they still hold e-mail addresses
and password hashes, and a rename makes them unreachable without destroying
anything. InnoDB rewires the sessions foreign key to the new name; verified on
MariaDB 11, along with the whole 001-004 chain from the pre-cutover production
schema, which lands byte-identical to a fresh dev database.

Migration 004 must be applied AFTER deploying, not before - the reverse of step
4, whose migration only added things. Its own header and the runbook both say
so, since getting it wrong by analogy is the obvious mistake.

DEFERRED_SECURITY.md items 3 and 4 close with it: the activation and reset
tokens that never expired are gone along with the code that issued them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:32:59 +02:00

7.9 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:

  1. src/app.factory.ts mounts Calendar.router.ts at /calendar
  2. Calendar.router.ts delegates to events.router.ts and users.router.ts
  3. 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
Services …/events/events.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: One, since the calendar migration completed.

Admin module (src/models/admin/) — used by every app: calendar, feedback, tickets and the admin app itself. 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. A permission is (app, role) in user_app_permissions, keyed on (user_id, app, role) so one user can hold several roles per app. access is the only role today and means "may use this app at all"; APP_ROLES in admin.schema.ts is the contract, and a role not listed there is rejected rather than written. requireAppAccess(app) in admin.middleware.ts is the single authenticator - it takes an optional second argument to narrow to one role, and queries the database on every request (no cookie cache) so disabling a user takes effect at once. Two things to know before touching this: any count of admins must count distinct users, not permission rows, or a single admin with two roles reads as two and the last-admin guard stops guarding; and both write endpoints accept {permissions: [{app, role}]} as well as the older {apps: ['tickets']}, which means the same at the access role. ADMIN_BOOTSTRAP_EMAIL makes sure someone can always get in on a fresh database.

The calendar used to be the exception - its own users/sessions tables, and a session token passed in query parameters. That is gone: docs/calendar-auth-migration.md records the migration, finished 2026-09-06. Writes sit behind requireAppAccess('calendar'); reads resolve the same cookie optionally, because one URL serves an anonymous visitor, an iCal subscription and a signed-in editor.

Two calendar-specific things survive that migration and are easy to break:

  • The public calendar answers with no credential of any kind. nachklang.art reads it to show the next upcoming event. Pinned by test/calendar/credentials.service.test.ts and test/calendar/events.router.test.ts.
  • The shared passwords (MEMBER_CREDENTIAL, CHOIR_CREDENTIAL, MANAGEMENT_CREDENTIAL, from .env) still open the restricted calendars for reading, because an iCal client cannot send a cookie. They can never write.

An event's creator is a display name and nothing else - nothing authorises on it. It resolves from the admin module's user.name when the row carries an admin id, and otherwise from created_by_name, a snapshot taken before the legacy users table was renamed aside.

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)".

NODE_ENV is load-bearing for the admin module. Only the explicit values development and test relax anything; everything else, including unset, is treated as production (strict secrets, cross-subdomain cookies, no localhost CORS). That direction is deliberate: a Plesk vhost does not set NODE_ENV, and the inverse arrangement would silently degrade the signing key, the cookie domain and the CORS list at once. Local work needs NODE_ENV=development.

Copy .env.example (or create .env) with:

NODE_ENV=
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=
CLIENT_IP_HEADERS=
TRUSTED_PROXY_IPS=
ADMIN_LOG_INVITE_LINKS=
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 .js suffix (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"), never require().
  • Tests run with vitest directly against .ts sources; import describe/it/expect/vi from vitest explicitly (no globals). Module mocks use vi.mock(...) with the same .js-suffixed paths as the imports.