Two changes to the admin module, both made now because it is not deployed yet
and neither is free later.
Permissions were "an app", with a `role` column reserved for a future
fine-grained model. Reviewing whether that reservation was enough found three
problems:
- Every row was written with role = 'admin', hardcoded, and the column
defaulted to it. On a `tickets` row that reads as "tickets administrator"
when it only ever meant "has access", and once real roles existed there
would have been no way to tell an old plain grant from a deliberate one.
- The role never left the database. /admin/me, the user list, the user detail
and both write endpoints all spoke apps: AppName[]. Adding roles would have
been a breaking change to /admin/me - and after the cutover that endpoint
has two more consumers, turning a local edit into a coordinated deploy of
three apps.
- The key (user_id, app) allowed one role per app, i.e. a tier rather than a
set of capabilities. Choosing later means an ALTER on a live table.
So: the key is now (user_id, app, role), the role is `access`, and APP_ROLES
in admin.schema.ts is the contract - a role not listed there is rejected with
400 rather than written. permissions: [{app, role}] is on the wire alongside
the derived apps: AppName[], which is kept because the three frontends only
ever ask "may I show this app?". Both write endpoints accept either shape, and
the invitation column (now `permissions`) is parsed leniently: invitations live
seven days, so a deploy that changes the shape has in-flight rows in the old
one. requireAppAccess(app, role?) takes an optional role; nothing passes one
yet.
countActiveAdminsForUpdate now counts DISTINCT users rather than rows. With
several roles per app, counting rows would make a single admin holding two
roles look like two admins and defeat the last-admin guard at exactly the
moment it matters.
Separately, passkey registration now fills `name` from the authenticator's
AAGUID via registration.afterVerification and better-auth's own
getAuthenticatorName, yielding "1Password", "iCloud Keychain", "Windows Hello".
Without it the column stayed NULL and the account page could only label every
passkey "Passkey" - useless when someone has to remove the one on the device
they just lost. A client-supplied name still wins; an unknown AAGUID still
leaves it blank.
148 unit tests (up from 131, including the new admin.schema.test.ts) and 41
integration tests pass. The integration suite applies sql/admin/001_init.sql,
so the new key is exercised rather than trusted.
No production migration is needed - the module is not deployed. An existing dev
database needs three statements: set role = 'access', drop and re-add the
primary key, rename invitations.apps to permissions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.2 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.
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.
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
.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.