Files
API/CLAUDE.md
T
PaddyandClaude Opus 5.5 bfb8f2ff9c Add a read-only role per app for calendar, feedback and tickets
A grant can now be `read` instead of `access`: requireAppAccess lets it
through for GET/HEAD/OPTIONS and answers 403 to every other method, so
new write routes are covered without being marked. `read` and `access`
on the same app are rejected together; user management has no read role.
/tickets/admin/me and /feedback/admin/me report `readOnly`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:24:28 +01:00

9.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:

  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. Two roles exist: access (full access) and read (read-only, not for admin) - see docs/admin-roles.md, including how requireAppAccess enforces read by HTTP method; 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.

docker-compose.dev.yml builds a fresh local dev database from docker/init/, not from sql/<domain>/ directly - the two domains use different mechanisms and both need to be kept in sync by hand whenever a migration is added: docker/init/03-tickets-schema.sql and 02-feedback-schema.sql are thin files that SOURCE every sql/<domain>/NNN_*.sql in order (add the new migration's SOURCE line there too); 01-calendar-schema-dev.sql and 04-admin-schema.sql instead fold each migration's effect directly into one reconstructed CREATE-TABLE schema (own header comment: "keep the two in step") - no SOURCE list to extend, edit the reconstructed schema itself. Found 03-tickets-schema.sql missing the SOURCE line for 003_add_confirmation_email_status.sql while adding 004 - every tickets dev DB spun up since that migration was added has been silently missing the column (mail sends still succeed, recordConfirmationEmailResult's UPDATE just fails and logs). Fixed.

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.