33489585a0
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>
142 lines
7.2 KiB
Markdown
142 lines
7.2 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.
|
|
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 `.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.
|