aa95ab2745
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>
155 lines
7.9 KiB
Markdown
155 lines
7.9 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
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:
|
|
```bash
|
|
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.
|