7aac07a013
Introduces src/models/admin/, a dedicated identity and permissions module on its own nachklang_admin database, and the shared authenticator that feedback and tickets will move onto in the cutover step. Nothing swaps over yet: feedback.auth.ts and tickets.auth.ts still authenticate against the legacy calendar sessions, so production behaviour is unchanged. - better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies scoped to .nachklang.art so one sign-in covers every *.nachklang.art app. - Accounts are invite-only: public sign-up is disabled, and the invitations plugin is the only code that creates users. Tokens are stored as SHA-256 hashes and travel in the request body, never in a URL. - Per-app permissions in user_app_permissions; requireAppAccess(app) queries the database on every request (no cookie cache) so disabling a user or revoking a session takes effect immediately. - ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently and without crashing the API if the database is unreachable at boot. - Guards prevent an admin from removing their own admin permission, disabling themselves, or stripping the last active admin. The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's MysqlDialect drives the pool with callbacks, and the promise wrapper ignores them, so every query hangs silently. Only the integration tests caught this. Schema in sql/admin/001_init.sql, derived from getAuthTables() on the installed better-auth rather than the published CLI, which lags the library and omits account.issuer. app.ts is split into src/app.factory.ts so the integration tests drive the real middleware order rather than a copy of it. Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB started by test/integration/setup.ts (docker or podman). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
6.0 KiB
Markdown
123 lines
6.0 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`, `…/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)".
|
|
|
|
Copy `.env.example` (or create `.env`) with:
|
|
```
|
|
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=
|
|
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.
|