Files
API/CLAUDE.md
T
Paddy dbcd5b56f6 Harden the admin module after a fresh-context review
Six defects found by an independent review of 7aac07a.

Environment handling now fails safe. NODE_ENV=production was gating the
signing key, the cookie domain, the CORS origin list and invitation-token
logging all at once, and it was documented nowhere - an unset value, which is
what a fresh Plesk vhost gives you, silently degraded all four. Only
'development' and 'test' relax anything now; everything else, unset included,
is strict. The hardcoded fallback secret is gone (dev gets a random
per-process one, so no committed value can ever sign a production cookie),
and invitation-link logging is an explicit ADMIN_LOG_INVITE_LINKS opt-in that
is refused in strict mode.

Rate limiting no longer collapses into a single global bucket. Without
trustedProxies, better-auth rejects a multi-value x-forwarded-for, resolves no
client IP, and keys every request to "no-trusted-ip" - where /sign-in/*
allows 3 requests per 10 seconds, so one noisy client could lock the whole
organisation out. CLIENT_IP_HEADERS and TRUSTED_PROXY_IPS make this explicit,
the unspecified x-forwarded-for fallback is gone, and strict mode warns at
boot when no trusted proxy is configured.

Invite acceptance is transactional. The user and its credential account go in
one runWithTransaction, as better-auth's own sign-up route does. A transaction
cannot span the permission and invitation writes - those use this module's own
pool - so a failure there is compensated: the user row is deleted and the
invitation un-marked, so the link works again instead of leaving the invitee
with a burnt token and an account no route can repair.

The last-admin guards were check-then-act. Two admins each removing the
other's admin permission could both pass the check and both commit, leaving
nobody able to administer anything. The count now runs inside the write
transaction under SELECT ... FOR UPDATE.

Also: lastSignInAt filtered expired sessions in the detail endpoint but not
the list, so the two disagreed; and the integration suite never reset
rateLimit, leaving it one added sign-in away from 429s that look like auth
bugs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 18:28:52 +02:00

6.5 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, …/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)".

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.