Add admin identity module: better-auth, per-app permissions, invitations #12

Merged
Paddy merged 5 commits from feature/admin-auth-module into master 2026-09-06 10:41:55 +00:00
39 changed files with 6551 additions and 320 deletions
+41
View File
@@ -1,5 +1,11 @@
# Values containing #, ", \ or surrounding spaces must be single-quoted
# (dotenv 16 treats an unquoted # as a comment): DB_PASSWORD='abc#def'
# REQUIRED. The admin module treats anything other than "development" or "test"
# as production: strict secrets, cross-subdomain cookies, no relaxed CORS.
# Leaving it unset is therefore safe-by-default but will refuse to boot without
# the admin secrets below. Set it to development for local work.
NODE_ENV=development
PORT=3000
DB_HOST=
@@ -25,6 +31,41 @@ TICKETS_DB=
TICKETS_RATE_LIMIT_MAX=10
TICKETS_RATE_LIMIT_WINDOW_MIN=10
ADMIN_DB=
# 32+ random bytes, e.g. `openssl rand -base64 48`. Mandatory outside
# development/test - there is deliberately no fallback, since a hardcoded one
# would be a published signing key. Rotating it signs everyone out and
# invalidates outstanding password-reset links.
BETTER_AUTH_SECRET=
API_BASE_URL=http://localhost:3000
ADMIN_APP_URL=http://localhost:3002
# Comma-separated origins of the apps that may call /admin/* with credentials.
APP_ORIGINS=http://localhost:3001
# nachklang.art in production; passkeys are bound to this value.
PASSKEY_RP_ID=localhost
# On start-up, makes sure this address can get in (invite, or grant admin if the
# user already exists). Idempotent, safe to leave set.
ADMIN_BOOTSTRAP_EMAIL=
# The header the reverse proxy puts the real client IP in, and the proxy hops to
# trust. Get these right or better-auth cannot resolve a client IP and every
# request shares ONE rate-limit bucket (/sign-in/* allows 3 per 10 seconds, so
# one noisy client locks everyone out). Check with:
# SELECT `key` FROM rateLimit; -- a "no-trusted-ip" row means it is happening.
# The header the reverse proxy puts the real client IP in. Must be one the proxy
# actually overwrites - trusting a header it does not set lets any client send its
# own value and bypass the sign-in rate limit entirely.
# Set to "none" to trust no header at all: every request then shares one rate-limit
# bucket, which is the safe fallback if the check below fails. Verify after deploy
# with: SELECT ipAddress FROM session ORDER BY createdAt DESC LIMIT 3;
CLIENT_IP_HEADERS=x-real-ip
TRUSTED_PROXY_IPS=
# Writes invitation links to the log. That link is a live account-creation
# credential, so this is refused outside development. Needed locally, where the
# mail relay is off and only the token's hash is stored.
ADMIN_LOG_INVITE_LINKS=true
MEMBER_CREDENTIAL=123
CHOIR_CREDENTIAL=123
MANAGEMENT_CREDENTIAL=123
+60 -3
View File
@@ -10,6 +10,7 @@ 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:
@@ -19,10 +20,12 @@ npx vitest run test/some.test.ts
## Architecture
Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users) and `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).
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. `app.ts` mounts `Calendar.router.ts` at `/calendar`
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`
@@ -35,7 +38,43 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca
| 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:** Users must have a `@nachklang.art` email. After activation they receive a session token (30-day window); the token hash + IP are stored in the DB. Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`) come from `.env`.
**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.
@@ -52,13 +91,31 @@ backslash escapes inside double quotes are expanded. Wrap any value containing `
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=
+11
View File
@@ -32,6 +32,13 @@ Currently any active user can edit, move, or delete any event regardless of who
## 3. Activation token has no expiry
> **Superseded for new accounts (2026-09-05).** The admin module
> (`src/models/admin/`) replaced account creation for the feedback, tickets and admin
> apps: accounts now come from `invitations`, whose tokens expire after 7 days and are
> stored only as a SHA-256 hash. The item below still stands for the legacy calendar
> `users` table, which the admin module deliberately left alone - see
> `docs/calendar-auth-migration.md`.
**File:** `src/models/calendar/users/users.service.ts``createUser` / `activateUser`
The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
@@ -45,6 +52,10 @@ The email activation link is valid indefinitely. Acceptable for a small, trusted
## 4. Password reset token has no expiry
> **Superseded for new accounts (2026-09-05).** Password resets for admin-module accounts
> go through better-auth, whose reset tokens expire after one hour. As with item 3, the
> text below still applies to the legacy calendar `users` table.
**File:** `src/models/calendar/users/users.service.ts``initiatePasswordReset` / `finalizePasswordReset`
The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
+5 -99
View File
@@ -1,16 +1,8 @@
import express from 'express';
import * as http from 'http';
import * as dotenv from 'dotenv';
import swaggerUi from 'swagger-ui-express';
import swaggerJSDoc from 'swagger-jsdoc';
import cors from 'cors';
import logger from './src/middleware/logger.js';
// Router imports
import {calendarRouter} from './src/models/calendar/Calendar.router.js';
import {feedbackRouter} from './src/models/feedback/Feedback.router.js';
import {ticketsRouter} from './src/models/tickets/Tickets.router.js';
import {createApp} from './src/app.factory.js';
import {bootstrapAdmin} from './src/models/admin/admin.bootstrap.js';
dotenv.config();
@@ -21,97 +13,11 @@ if (!process.env.PORT) {
const port: number = parseInt(process.env.PORT, 10);
const app: express.Application = express();
const app = createApp();
const server: http.Server = http.createServer(app);
// Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header.
// Verify the resolved client IP is correct in staging before relying on it
// (used by the feedback rate limiter).
app.set('trust proxy', 1);
// here we are adding middleware to parse all incoming requests as JSON
app.use(express.json());
// Configure CORS
let allowedHosts = [
'https://www.nachklang.art',
'https://calendar.nachklang.art',
'https://feedback.nachklang.art',
'https://tickets.nachklang.art'
];
const isDev = process.env.NODE_ENV !== 'production';
const localhostRegex = /^http:\/\/localhost:\d+$/;
// Matches http://<private-LAN-IPv4>:<port> - needed so the feedback form can
// be reached from a real phone over WiFi during dev (the phone's Origin is
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
app.use(cors({
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
origin: function (origin: any, callback: any) {
// Allow requests with no origin
if (!origin) return callback(null, true);
// Any localhost port, or a private-LAN IP, is fine outside production -
// dev servers pick whatever port is free (Next.js falls back from 3000
// if it's taken), and real-device testing hits the dev machine by IP.
if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) {
return callback(null, true);
}
// Block requests with wrong origin
if (allowedHosts.indexOf(origin) === -1) {
return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false);
}
// Allow all other requests
return callback(null, true);
}
}));
// Swagger documentation
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'Nachklang e.V. REST API',
version: '0.1.0',
license: {
name: 'Licensed Under MIT',
url: 'https://spdx.org/licenses/MIT.html'
},
contact: {
name: 'Nachklang e.V.',
url: 'https://www.nachklang.art'
}
}
};
const options = {
swaggerDefinition,
// Paths to files containing OpenAPI definitions
apis: [
'./src/models/**/*.interface.ts',
'./src/models/**/*.router.ts'
]
};
const swaggerSpec = swaggerJSDoc(options);
app.use(
'/docs',
swaggerUi.serve,
swaggerUi.setup(swaggerSpec)
);
// Add routers
app.use('/calendar', calendarRouter);
app.use('/feedback', feedbackRouter);
app.use('/tickets', ticketsRouter);
// this is a simple route to make sure everything is working properly
app.get('/', (req: express.Request, res: express.Response) => {
res.status(200).send('Welcome to the Nachklang e.V. REST API!');
});
server.listen(port, () => {
logger.info('Server listening on Port ' + port);
// Makes sure ADMIN_BOOTSTRAP_EMAIL can always get in. Never throws.
void bootstrapAdmin();
});
+31
View File
@@ -0,0 +1,31 @@
# Manual alternative for bringing up the admin tests' database by hand.
#
# `npm run test:integration` does NOT use this file: test/integration/setup.ts
# starts the container directly, because `podman compose` needs a separate
# compose provider that neither podman nor docker ships, and one container needs
# no orchestration. Keep the two in step, or delete this file if nobody uses it.
#
# Port 3307 and a throwaway data directory on purpose: it must never collide
# with, or outlive, the dev database from docker-compose.dev.yml.
services:
mariadb-test:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: roottestpassword
MARIADB_DATABASE: nachklang_admin
MARIADB_USER: nachklang
MARIADB_PASSWORD: testpassword
ports:
- "3307:3306"
tmpfs:
- /var/lib/mysql
volumes:
# Applied by the entrypoint on first boot, against MARIADB_DATABASE.
# This is the very migration production runs, so a mistake in it fails
# the test run rather than the deploy.
- ./sql/admin/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 2s
timeout: 5s
retries: 30
+3 -1
View File
@@ -1,10 +1,12 @@
-- Local dev only. Creates the three databases + a dev user with full access.
-- Local dev only. Creates the four databases + a dev user with full access.
CREATE DATABASE IF NOT EXISTS nachklang_calendar CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS nachklang_feedback CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS nachklang_tickets CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS nachklang_admin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'nachklang'@'%' IDENTIFIED BY 'devpassword';
GRANT ALL PRIVILEGES ON nachklang_calendar.* TO 'nachklang'@'%';
GRANT ALL PRIVILEGES ON nachklang_feedback.* TO 'nachklang'@'%';
GRANT ALL PRIVILEGES ON nachklang_tickets.* TO 'nachklang'@'%';
GRANT ALL PRIVILEGES ON nachklang_admin.* TO 'nachklang'@'%';
FLUSH PRIVILEGES;
+170
View File
@@ -0,0 +1,170 @@
-- Local dev only. Mirrors the table definitions in sql/admin/001_init.sql -
-- keep the two in step - and seeds a ready-to-use dev account on top.
USE nachklang_admin;
CREATE TABLE IF NOT EXISTS `user` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`emailVerified` TINYINT(1) NOT NULL DEFAULT 0,
`image` TEXT DEFAULT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Nachklang addition, declared through better-auth's additionalFields so
-- the adapter knows about it. Disabling also revokes the user's sessions.
`disabled` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `user_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `session` (
`id` VARCHAR(36) NOT NULL,
`expiresAt` DATETIME NOT NULL,
`token` VARCHAR(255) NOT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ipAddress` VARCHAR(255) DEFAULT NULL,
`userAgent` TEXT DEFAULT NULL,
`userId` VARCHAR(36) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `session_token` (`token`),
KEY `session_user` (`userId`),
CONSTRAINT `session_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `account` (
`id` VARCHAR(36) NOT NULL,
-- 1.7 addition: distinguishes a local credential account
-- ("local:credential") from an OAuth issuer. Written by better-auth.
`issuer` VARCHAR(255) NOT NULL,
`accountId` VARCHAR(255) NOT NULL,
`providerId` VARCHAR(255) NOT NULL,
`userId` VARCHAR(36) NOT NULL,
`accessToken` TEXT DEFAULT NULL,
`refreshToken` TEXT DEFAULT NULL,
`idToken` TEXT DEFAULT NULL,
`accessTokenExpiresAt` DATETIME DEFAULT NULL,
`refreshTokenExpiresAt` DATETIME DEFAULT NULL,
`scope` TEXT DEFAULT NULL,
`password` TEXT DEFAULT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `account_user` (`userId`),
KEY `account_provider` (`providerId`, `accountId`),
CONSTRAINT `account_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Password-reset and e-mail-verification tokens.
CREATE TABLE IF NOT EXISTS `verification` (
`id` VARCHAR(36) NOT NULL,
`identifier` VARCHAR(255) NOT NULL,
`value` TEXT NOT NULL,
`expiresAt` DATETIME NOT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `verification_identifier` (`identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `passkey` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(255) DEFAULT NULL,
`publicKey` TEXT NOT NULL,
`userId` VARCHAR(36) NOT NULL,
`credentialID` VARCHAR(255) NOT NULL,
`counter` INT NOT NULL DEFAULT 0,
`deviceType` VARCHAR(255) NOT NULL,
`backedUp` TINYINT(1) NOT NULL DEFAULT 0,
`transports` VARCHAR(255) DEFAULT NULL,
`createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
`aaguid` VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `passkey_user` (`userId`),
KEY `passkey_credential` (`credentialID`),
CONSTRAINT `passkey_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Required by rateLimit.storage = 'database' in admin.auth.ts. Passenger may
-- run several API instances, and an in-memory limiter would give each of them
-- its own budget.
CREATE TABLE IF NOT EXISTS `rateLimit` (
`id` VARCHAR(36) NOT NULL,
`key` VARCHAR(255) NOT NULL,
`count` INT NOT NULL DEFAULT 0,
-- Epoch milliseconds, not a DATETIME: better-auth stores a number here.
`lastRequest` BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `rate_limit_key` (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Nachklang-owned tables
-- ---------------------------------------------------------------------------
-- Which apps a user may administer. `admin` is just another app: holding it is
-- what lets someone manage users and invitations. A permission is (app, role);
-- `access` is the only role today, and the key admits several per app so finer
-- ones can be added by inserting rows rather than by migrating this table.
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
`user_id` VARCHAR(36) NOT NULL,
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
-- One row per (user, app, role). `access` means "may use this app at all"
-- and is the only role today; the key allows several per app so a finer
-- permission can be added later by inserting rows, not by migrating.
`role` VARCHAR(32) NOT NULL DEFAULT 'access',
`granted_by` VARCHAR(36) DEFAULT NULL,
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- (user_id, app) is the leftmost prefix of this key, so the per-request
-- permission lookup needs no separate index.
PRIMARY KEY (`user_id`, `app`, `role`),
CONSTRAINT `uap_user_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- The only route to a new account: there is no public sign-up. Only the
-- SHA-256 of the token is stored, so a dump of this table hands out no access.
CREATE TABLE IF NOT EXISTS `invitations` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(255) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`token_hash` CHAR(64) NOT NULL,
`permissions` JSON NOT NULL,
`invited_by` VARCHAR(36) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` DATETIME NOT NULL,
`accepted_at` DATETIME DEFAULT NULL,
`revoked_at` DATETIME DEFAULT NULL,
UNIQUE KEY `inv_token_hash` (`token_hash`),
KEY `inv_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Dev seed: dev@nachklang.art / devpassword, with every app permission.
-- Local only - this account exists nowhere but in this container.
--
-- The password hash is better-auth's own scrypt format (salt:hash), produced
-- with better-auth 1.7.2's hashPassword(). Regenerate it if better-auth ever
-- changes that format; a hash it cannot parse shows up as "invalid password"
-- on an otherwise correct sign-in.
--
-- `issuer` must be exactly 'local:credential' - it is how better-auth 1.7
-- recognises a local password account when signing in.
-- ---------------------------------------------------------------------------
INSERT INTO `user` (`id`, `name`, `email`, `emailVerified`, `disabled`)
VALUES ('dev-user-0000-0000-0000-000000000001', 'Dev Admin', 'dev@nachklang.art', 1, 0);
INSERT INTO `account` (`id`, `issuer`, `accountId`, `providerId`, `userId`, `password`)
VALUES (
'dev-acct-0000-0000-0000-000000000001',
'local:credential',
'dev-user-0000-0000-0000-000000000001',
'credential',
'dev-user-0000-0000-0000-000000000001',
'e6a0485feb04b8fa64453db87badd8e1:85aaffd845e1e44fabc5be97d684c0f533845ed11088bd3f4d533ef277ead71da8372b5c6a72c7eae2d48e3270c50e2d13cffa4fcfb0b5cec456100b8f007ed2'
);
INSERT INTO `user_app_permissions` (`user_id`, `app`, `role`) VALUES
('dev-user-0000-0000-0000-000000000001', 'calendar', 'access'),
('dev-user-0000-0000-0000-000000000001', 'feedback', 'access'),
('dev-user-0000-0000-0000-000000000001', 'tickets', 'access'),
('dev-user-0000-0000-0000-000000000001', 'admin', 'access');
+74
View File
@@ -0,0 +1,74 @@
# Migrating the Calendar domain onto the admin identity module
Status: **not started.** Written 2026-09-05 alongside the admin module (step 2 of
`docs/plan-admin-auth.md` in the nachklang-admin repo), which deliberately left the
calendar alone.
## Why the calendar was left out
The admin module replaced authentication for feedback and tickets by swapping one
middleware. The calendar cannot be done that way, because its user identity is woven into
its data:
- `users`/`sessions` live in the **calendar** database and are the same tables the
feedback and tickets admin areas used to authenticate against.
- `events.created_by_id` is an **INT** foreign key into `users.user_id`. The admin module's
user ids are **VARCHAR(36)** strings. Migrating identity means migrating that column and
every query that joins it.
- The Angular frontend passes `sessionId`/`sessionKey` as **query parameters**
(`DEFERRED_SECURITY.md` item 1). Cookie sessions remove the parameters entirely, so
every calendar route signature and the frontend's HTTP layer change together.
- `credentials.service.ts` implements a second, parallel authorisation model: the
`MEMBER_CREDENTIAL` / `CHOIR_CREDENTIAL` / `MANAGEMENT_CREDENTIAL` shared secrets that
let non-users read specific calendars. That has no equivalent in the admin module and is
not a per-user permission at all.
What already exists today: `calendar` is a value in the `user_app_permissions.app` enum, so
permissions can be granted before anything else moves.
## What is in place to build on
- Cookie sessions across `*.nachklang.art`, and `requireAppAccess('calendar')` in
`src/models/admin/admin.middleware.ts` - usable the moment a calendar route wants it.
- `res.locals.admin` is `{id, email, displayName, apps}`; `id` is the string user id.
- Invitations, disable/enable and session revocation already cover calendar users, because
they are properties of the account rather than of an app.
## Suggested sequence
Each step is meant to leave production working on its own.
1. **Add a bridging column.** `ALTER TABLE events ADD COLUMN created_by_user_id
VARCHAR(36) NULL`, indexed. Nothing reads it yet.
2. **Map the accounts.** For every legacy `users` row that should survive, invite the
person through the admin UI. On acceptance, backfill `events.created_by_user_id` from
`events.created_by_id` via an email-to-new-id mapping. Everyone not re-invited keeps
working on the legacy path until step 4.
3. **Dual-read.** Change `events.service.ts` to prefer `created_by_user_id` and fall back
to `created_by_id`. Writes fill both. This is the only step that is temporary code, and
it should carry a removal note pointing at step 5.
4. **Switch the routes.** Replace the query-parameter session checks in
`events.router.ts` and `users.router.ts` with `requireAppAccess('calendar')`, and change
the Angular frontend to `withCredentials: true` against the same origin list. Deploy the
API first; the calendar frontend is broken between the two deploys, so pick a quiet
time. This closes `DEFERRED_SECURITY.md` item 1.
5. **Drop the legacy path.** Remove `users.service.ts`'s session handling, the `sessions`
table, `created_by_id`, and the dual-read from step 3. Legacy `/calendar/users/*` stays
only if something still calls it - otherwise delete it too. `X-Session-Id` /
`X-Session-Key` can then come out of the CORS `allowedHeaders` list in
`src/app.factory.ts`.
## Open questions to settle before starting
- **The shared calendar credentials.** Do `MEMBER_CREDENTIAL` and friends stay as a
separate mechanism (they serve people with no account at all, and iCal clients that
cannot send headers), or do read-only accounts replace them? This is a product decision,
not a technical one, and it decides how much of `credentials.service.ts` survives.
- **The iCal export.** `GET /calendar/events/{calendar}/ical` takes a password in the query
string on purpose, because iCal clients cannot send headers. Cookie sessions do not help
here; this endpoint likely keeps its own scheme.
- **Which legacy accounts to keep.** Step 2 is the moment to not re-invite people who no
longer need access.
- **`event_versions.version_created_by_id`.** The same INT reference again, joined in
`events.service.ts` for the "last modified by" name. It has to move with `events`, and it
is the reason step 1's bridging column needs a sibling on `event_versions`.
+1863 -215
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -12,25 +12,32 @@
"build": "tsc",
"debug": "export DEBUG=* && npm run start",
"test": "vitest run --coverage",
"test:watch": "vitest"
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@better-auth/core": "^1.7.2",
"@better-auth/passkey": "^1.7.2",
"app-root-path": "^3.0.0",
"axios": "^1.20.0",
"bcrypt": "^5.0.1",
"better-auth": "^1.7.2",
"cors": "^2.8.5",
"debug": "^4.3.1",
"dotenv": "^16.6.1",
"express": "^4.18.2",
"guid-typescript": "^1.0.9",
"kysely": "^0.29.5",
"mariadb": "^3.0.2",
"mysql2": "^3.24.3",
"random-words": "^1.1.1",
"swagger-jsdoc": "^6.1.0",
"swagger-ui-express": "^4.3.0",
"winston": "^3.3.3"
"winston": "^3.3.3",
"zod": "^4.5.4"
},
"devDependencies": {
"@types/app-root-path": "^1.2.4",
@@ -40,14 +47,21 @@
"@types/express": "^4.17.15",
"@types/node": "^26.4.1",
"@types/random-words": "^1.1.2",
"@types/supertest": "^7.2.1",
"@types/swagger-jsdoc": "^6.0.1",
"@types/swagger-ui-express": "^4.1.3",
"@types/winston": "^2.4.4",
"@vitest/coverage-v8": "^5.0.0",
"is-number": "^7.0.0",
"source-map-support": "^0.5.19",
"supertest": "^7.2.2",
"typescript": "^5.9.3",
"vitest": "^5.0.0",
"vitest-sonar-reporter": "^3.0.0"
},
"overrides": {
"better-auth": {
"vitest": "$vitest"
}
}
}
+150
View File
@@ -0,0 +1,150 @@
-- nachklang_admin: identity, sessions and per-app permissions for every
-- *.nachklang.art app.
--
-- The better-auth tables below (user, session, account, verification, passkey,
-- rateLimit) mirror what better-auth 1.7.2 derives from the configuration in
-- src/models/admin/admin.auth.ts, including the `disabled` additionalField on
-- `user` and the `rateLimit` table that rateLimit.storage='database' requires.
-- On every better-auth upgrade: re-derive the table list, diff it against this
-- file, and add a numbered migration - never edit this one in place.
--
-- Table and column names are better-auth's own ("camel" casing, so `rateLimit`
-- and `userId`). MariaDB on Linux compares table names case-sensitively, so the
-- casing here is load-bearing. The two Nachklang-owned tables at the bottom use
-- the snake_case convention of the rest of this repo's SQL.
CREATE TABLE IF NOT EXISTS `user` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`emailVerified` TINYINT(1) NOT NULL DEFAULT 0,
`image` TEXT DEFAULT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Nachklang addition, declared through better-auth's additionalFields so
-- the adapter knows about it. Disabling also revokes the user's sessions.
`disabled` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `user_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `session` (
`id` VARCHAR(36) NOT NULL,
`expiresAt` DATETIME NOT NULL,
`token` VARCHAR(255) NOT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ipAddress` VARCHAR(255) DEFAULT NULL,
`userAgent` TEXT DEFAULT NULL,
`userId` VARCHAR(36) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `session_token` (`token`),
KEY `session_user` (`userId`),
CONSTRAINT `session_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `account` (
`id` VARCHAR(36) NOT NULL,
-- 1.7 addition: distinguishes a local credential account
-- ("local:credential") from an OAuth issuer. Written by better-auth.
`issuer` VARCHAR(255) NOT NULL,
`accountId` VARCHAR(255) NOT NULL,
`providerId` VARCHAR(255) NOT NULL,
`userId` VARCHAR(36) NOT NULL,
`accessToken` TEXT DEFAULT NULL,
`refreshToken` TEXT DEFAULT NULL,
`idToken` TEXT DEFAULT NULL,
`accessTokenExpiresAt` DATETIME DEFAULT NULL,
`refreshTokenExpiresAt` DATETIME DEFAULT NULL,
`scope` TEXT DEFAULT NULL,
`password` TEXT DEFAULT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `account_user` (`userId`),
KEY `account_provider` (`providerId`, `accountId`),
CONSTRAINT `account_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Password-reset and e-mail-verification tokens.
CREATE TABLE IF NOT EXISTS `verification` (
`id` VARCHAR(36) NOT NULL,
`identifier` VARCHAR(255) NOT NULL,
`value` TEXT NOT NULL,
`expiresAt` DATETIME NOT NULL,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `verification_identifier` (`identifier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `passkey` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(255) DEFAULT NULL,
`publicKey` TEXT NOT NULL,
`userId` VARCHAR(36) NOT NULL,
`credentialID` VARCHAR(255) NOT NULL,
`counter` INT NOT NULL DEFAULT 0,
`deviceType` VARCHAR(255) NOT NULL,
`backedUp` TINYINT(1) NOT NULL DEFAULT 0,
`transports` VARCHAR(255) DEFAULT NULL,
`createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
`aaguid` VARCHAR(255) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `passkey_user` (`userId`),
KEY `passkey_credential` (`credentialID`),
CONSTRAINT `passkey_user_fk` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Required by rateLimit.storage = 'database' in admin.auth.ts. Passenger may
-- run several API instances, and an in-memory limiter would give each of them
-- its own budget.
CREATE TABLE IF NOT EXISTS `rateLimit` (
`id` VARCHAR(36) NOT NULL,
`key` VARCHAR(255) NOT NULL,
`count` INT NOT NULL DEFAULT 0,
-- Epoch milliseconds, not a DATETIME: better-auth stores a number here.
`lastRequest` BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `rate_limit_key` (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Nachklang-owned tables
-- ---------------------------------------------------------------------------
-- Which apps a user may administer. `admin` is just another app: holding it is
-- what lets someone manage users and invitations. A permission is (app, role);
-- `access` is the only role today, and the key admits several per app so finer
-- ones can be added by inserting rows rather than by migrating this table.
CREATE TABLE IF NOT EXISTS `user_app_permissions` (
`user_id` VARCHAR(36) NOT NULL,
`app` ENUM('calendar','feedback','tickets','admin') NOT NULL,
-- One row per (user, app, role). `access` means "may use this app at all"
-- and is the only role today; the key allows several per app so a finer
-- permission can be added later by inserting rows, not by migrating.
`role` VARCHAR(32) NOT NULL DEFAULT 'access',
`granted_by` VARCHAR(36) DEFAULT NULL,
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- (user_id, app) is the leftmost prefix of this key, so the per-request
-- permission lookup needs no separate index.
PRIMARY KEY (`user_id`, `app`, `role`),
CONSTRAINT `uap_user_fk` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- The only route to a new account: there is no public sign-up. Only the
-- SHA-256 of the token is stored, so a dump of this table hands out no access.
CREATE TABLE IF NOT EXISTS `invitations` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(255) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`token_hash` CHAR(64) NOT NULL,
`permissions` JSON NOT NULL,
`invited_by` VARCHAR(36) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`expires_at` DATETIME NOT NULL,
`accepted_at` DATETIME DEFAULT NULL,
`revoked_at` DATETIME DEFAULT NULL,
UNIQUE KEY `inv_token_hash` (`token_hash`),
KEY `inv_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+166
View File
@@ -0,0 +1,166 @@
import express from 'express';
import * as dotenv from 'dotenv';
import swaggerUi from 'swagger-ui-express';
import swaggerJSDoc from 'swagger-jsdoc';
import cors from 'cors';
import {toNodeHandler} from 'better-auth/node';
import logger from './middleware/logger.js';
// Router imports
import {calendarRouter} from './models/calendar/Calendar.router.js';
import {feedbackRouter} from './models/feedback/Feedback.router.js';
import {ticketsRouter} from './models/tickets/Tickets.router.js';
import {adminRouter} from './models/admin/Admin.router.js';
import {auth} from './models/admin/admin.auth.js';
import {ADMIN_ALLOWED_ORIGINS, isProd} from './models/admin/admin.config.js';
dotenv.config();
/**
* Builds the Express app with every router and middleware in place.
*
* Separate from app.ts so the integration tests can drive the *real* wiring
* with supertest instead of a hand-rolled copy of it. The order below is not
* cosmetic - CORS has to precede the better-auth handler so preflights get
* their headers, and the better-auth handler has to precede express.json()
* because it reads the raw body stream itself.
*/
export const createApp = (): express.Application => {
const app: express.Application = express();
// Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header.
// Verify the resolved client IP is correct in staging before relying on it
// (used by the feedback rate limiter).
app.set('trust proxy', 1);
// Configure CORS. This has to run before the better-auth handler below, so
// that preflights for /admin/auth/* get their headers, which is why it now
// sits above express.json() instead of after it.
let allowedHosts = [
'https://www.nachklang.art',
'https://calendar.nachklang.art',
'https://feedback.nachklang.art',
'https://tickets.nachklang.art',
'https://admin.nachklang.art',
// The admin app's origin comes from ADMIN_APP_URL, so a rename or a
// staging host does not need a code change here.
...ADMIN_ALLOWED_ORIGINS
];
// `isProd` from admin.config, NOT `NODE_ENV !== 'production'`. The two are not
// the same when NODE_ENV is unset, which is exactly what a fresh Plesk vhost
// gives you: the old test called that "dev" and opened the loopback and
// private-LAN exceptions below. With `credentials: true` on this CORS config
// and a session cookie scoped to .nachklang.art, that let any page served
// from localhost read a signed-in admin's data cross-origin. admin.config
// treats anything but an explicit 'development'/'test' as production, so an
// unset value now fails closed.
const isDev = !isProd;
const localhostRegex = /^http:\/\/localhost:\d+$/;
// Matches http://<private-LAN-IPv4>:<port> - needed so the feedback form can
// be reached from a real phone over WiFi during dev (the phone's Origin is
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
app.use(cors({
// X-Session-* stay allowed until the calendar module is migrated off the
// legacy header sessions (see docs/calendar-auth-migration.md).
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
// The admin session lives in a cookie, so browsers must be allowed to send
// it cross-origin - this is what makes credentials: 'include' work.
credentials: true,
origin: function (origin: any, callback: any) {
// Allow requests with no origin
if (!origin) return callback(null, true);
// Any localhost port, or a private-LAN IP, is fine outside production -
// dev servers pick whatever port is free (Next.js falls back from 3000
// if it's taken), and real-device testing hits the dev machine by IP.
if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) {
return callback(null, true);
}
// Block requests with wrong origin
if (allowedHosts.indexOf(origin) === -1) {
return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false);
}
// Allow all other requests
return callback(null, true);
}
}));
// better-auth's own handler, mounted before express.json(): it reads the raw
// request body stream itself and a parsed body would leave it hanging.
//
// Wrapped, because Express 4 does not await an async handler: a rejected
// promise escapes as an unhandled rejection instead of becoming a response.
// Nearly every better-auth route touches the admin database, so a database
// blip would leave the request hanging with no answer at all while the
// process logged an uncaughtException - observed by pointing ADMIN_DB at a
// database the user cannot open. Answer 503 instead: the caller learns, and
// the other domains keep serving.
const authHandler = toNodeHandler(auth);
app.all('/admin/auth/*', (req, res) => {
Promise.resolve(authHandler(req, res)).catch((e: any) => {
logger.error('Admin auth handler failed', {path: req.path, detail: e?.message});
if (!res.headersSent) {
res.status(503).send({
status: 'SERVICE_UNAVAILABLE',
message: 'Die Anmeldung ist derzeit nicht verfügbar. Bitte versuche es später erneut.'
});
}
});
});
// here we are adding middleware to parse all incoming requests as JSON
app.use(express.json());
// Swagger documentation
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'Nachklang e.V. REST API',
version: '1.0.0',
license: {
name: 'Licensed Under MIT',
url: 'https://spdx.org/licenses/MIT.html'
},
contact: {
name: 'Nachklang e.V.',
url: 'https://www.nachklang.art'
}
}
};
const options = {
swaggerDefinition,
// Paths to files containing OpenAPI definitions
apis: [
'./src/models/**/*.interface.ts',
'./src/models/**/*.router.ts'
]
};
const swaggerSpec = swaggerJSDoc(options);
app.use(
'/docs',
swaggerUi.serve,
swaggerUi.setup(swaggerSpec)
);
// Add routers
app.use('/calendar', calendarRouter);
app.use('/feedback', feedbackRouter);
app.use('/tickets', ticketsRouter);
// JSON routes only; the auth handler above is mounted separately.
app.use('/admin', adminRouter);
// this is a simple route to make sure everything is working properly
app.get('/', (req: express.Request, res: express.Response) => {
res.status(200).send('Welcome to the Nachklang e.V. REST API!');
});
return app;
};
+55
View File
@@ -0,0 +1,55 @@
import * as dotenv from 'dotenv';
import mysql from 'mysql2';
import {Kysely, MysqlDialect} from 'kysely';
import {AdminDatabase} from './admin.schema.js';
import logger from '../../middleware/logger.js';
dotenv.config();
/**
* The admin module is the one place in this API that does not use the
* `mariadb` driver: better-auth talks to the database through Kysely, whose
* MySQL dialect expects a mysql2 pool. The other domains keep their own
* `mariadb` pools (see Feedback.db.ts) - this is an addition, not a migration.
*
* The pool is the callback-style `mysql2` one, NOT `mysql2/promise`: Kysely's
* MysqlDialect calls `pool.getConnection((err, conn) => ...)`. The promise
* wrapper ignores that callback and returns a Promise instead, so every query
* through Kysely would hang forever with no error - which is exactly what it
* did until the integration tests caught it.
*
* timezone 'Z' matters: better-auth computes session and token expiry in UTC.
* Without it mysql2 would write and read those DATETIMEs in the process's local
* zone, so sessions would expire an hour early or late depending on DST.
*/
export namespace NachklangAdminDB {
export const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.ADMIN_DB,
// The other modules' pools default to 3306. This one is configurable so
// the integration tests can point at a throwaway container on another
// port without touching a developer's real .env.
port: parseInt(process.env.DB_PORT || '3306', 10),
connectionLimit: 5,
timezone: 'Z'
});
// mysql2 emits connection trouble as an event on the pool, not only as a
// rejected query. Without a listener Node turns that into an
// uncaughtException, so a database restart would take the whole API - and
// with it the calendar, feedback and tickets domains - down with it.
// Individual queries still reject, and their callers still answer 500.
pool.on('error', (err: unknown) => {
logger.error('Admin database pool error', {detail: (err as any)?.message});
});
// Handed to better-auth as `database: {dialect, type: 'mysql'}`.
export const dialect = new MysqlDialect({pool});
// Used by this module's own services for the two custom tables and for
// permission lookups that join better-auth's `user`.
export const db = new Kysely<AdminDatabase>({dialect});
}
+48
View File
@@ -0,0 +1,48 @@
import express, {Request, Response} from 'express';
import {requireAppAccess, requireSignedIn} from './admin.middleware.js';
import {usersAdminRouter} from './users/users.admin.router.js';
import {invitationsRouter} from './invitations/invitations.router.js';
/**
* The admin module's JSON routes. Deliberately *not* the better-auth handler:
* that one is mounted separately in app.ts, ahead of express.json(), because it
* needs the raw request body stream.
*
* Mounted at /admin, so the tree is:
* /admin/me any signed-in account
* /admin/users/* admin permission
* /admin/invitations/* admin permission
*/
export const adminRouter = express.Router();
/**
* @swagger
* /admin/me:
* get:
* summary: The current user's identity and app permissions
* description: Used by every frontend to decide what to show. The API remains the real gate.
* tags: [admin]
* responses:
* 200:
* description: Success
* 401:
* description: Not signed in
* 403:
* description: Account disabled
*/
adminRouter.get('/me', requireSignedIn, (req: Request, res: Response) => {
res.status(200).send({
id: res.locals.admin.id,
email: res.locals.admin.email,
fullName: res.locals.admin.displayName,
// `permissions` is the full (app, role) truth; `apps` is the distinct
// apps within it. Both are sent because the three frontends only ever ask
// "may I show this app?", and keeping `apps` means a finer permission can
// land here without a coordinated deploy of all of them.
permissions: res.locals.admin.permissions,
apps: res.locals.admin.apps
});
});
adminRouter.use('/users', requireAppAccess('admin'), usersAdminRouter);
adminRouter.use('/invitations', requireAppAccess('admin'), invitationsRouter);
+162
View File
@@ -0,0 +1,162 @@
import {betterAuth} from 'better-auth';
import {APIError} from 'better-auth/api';
import {passkey, getAuthenticatorName} from '@better-auth/passkey';
import {NachklangAdminDB} from './Admin.db.js';
import {invitationsPlugin} from './invitations/invitations.plugin.js';
import {sendPasswordResetMail} from './admin.mail.js';
import * as UsersService from './users/users.admin.service.js';
import logger from '../../middleware/logger.js';
import {
ADMIN_ALLOWED_ORIGINS,
API_BASE_URL,
BETTER_AUTH_SECRET,
CLIENT_IP_HEADERS,
PASSKEY_RP_ID,
TRUSTED_PROXY_IPS,
isProd
} from './admin.config.js';
/**
* The single better-auth instance for all *.nachklang.art apps. Mounted in
* app.ts at /admin/auth/* with better-auth's own node handler, ahead of
* express.json() (it needs the raw body stream).
*
* The session cookie is what every app trusts. Everything else in this module -
* permissions, invitations, the admin UI - hangs off it.
*/
const DAY = 60 * 60 * 24;
// Dev runs the apps on plain localhost ports; cookies ignore the port, so
// single-sign-on across them works without fake subdomains or mkcert.
const localhostOrigins = [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:3002',
'http://localhost:3003'
];
const trustedOrigins = isProd
? ADMIN_ALLOWED_ORIGINS
: Array.from(new Set([...ADMIN_ALLOWED_ORIGINS, ...localhostOrigins]));
export const auth = betterAuth({
appName: 'Nachklang',
database: {
dialect: NachklangAdminDB.dialect,
type: 'mysql'
},
basePath: '/admin/auth',
// Mandatory once crossSubDomainCookies is on: better-auth derives the
// cookie domain and its own absolute URLs from this.
baseURL: API_BASE_URL,
secret: BETTER_AUTH_SECRET,
trustedOrigins,
emailAndPassword: {
enabled: true,
// There is no public sign-up: accounts exist only through an
// invitation (see invitations.plugin.ts). This also makes
// auth.api.signUpEmail throw, which is intended.
disableSignUp: true,
sendResetPassword: async ({user, url}) => {
await sendPasswordResetMail(user.email, user.name, url);
}
},
user: {
additionalFields: {
// Not `returned`, and not settable through the API: disabling is an
// admin action on /admin/users/:id/disable, never something a
// session owner can flip on themselves.
disabled: {
type: 'boolean',
defaultValue: false,
input: false,
returned: false
}
}
},
session: {
expiresIn: 30 * DAY,
updateAge: DAY
// Deliberately no cookieCache: requireAppAccess hits the database on
// every request anyway, and a cached session would keep a disabled
// user or a revoked session alive for the cache's lifetime.
},
advanced: {
// Fixes the cookie name across releases so the frontends' middleware can
// check for it: "nachklang.session_token", or
// "__Secure-nachklang.session_token" over https.
cookiePrefix: 'nachklang',
crossSubDomainCookies: isProd
? {enabled: true, domain: '.nachklang.art'}
: {enabled: false},
ipAddress: {
// better-auth reads the request itself and does not know about
// Express's `trust proxy`, so both the header and the trusted hops
// have to be named here. Getting this wrong does not fail loudly -
// it collapses every client into one rate-limit bucket. See the
// commentary on CLIENT_IP_HEADERS in admin.config.ts.
ipAddressHeaders: CLIENT_IP_HEADERS,
...(TRUSTED_PROXY_IPS.length > 0 ? {trustedProxies: TRUSTED_PROXY_IPS} : {})
}
},
rateLimit: {
enabled: true,
// Passenger may run more than one instance; an in-memory limiter would
// then give each of them its own budget.
storage: 'database'
},
plugins: [
passkey({
rpID: PASSKEY_RP_ID,
rpName: 'Nachklang',
origin: ADMIN_ALLOWED_ORIGINS,
registration: {
// Without this, every passkey is stored with name = NULL and the
// account page can only label them all "Passkey" - useless at the
// one moment that list matters, when someone has to remove the
// passkey on the device they just lost.
//
// The AAGUID identifies the authenticator *model* (not a device
// and not a person), and better-auth ships the lookup table, so
// this yields "1Password", "iCloud Keychain", "Windows Hello".
// It only fills a blank: a name the client sent always wins, and
// an unknown AAGUID leaves the column NULL as before.
afterVerification: async ({verification}) => {
const name = getAuthenticatorName(verification.registrationInfo?.aaguid);
return name ? {name} : undefined;
}
}
}),
invitationsPlugin()
],
databaseHooks: {
session: {
create: {
before: async session => {
const access = await UsersService.loadAccess(session.userId);
if (access?.disabled) {
logger.warn('Admin: sign-in attempt by a disabled account', {userId: session.userId});
// Throwing rather than returning false: `false` aborts
// the session write silently and the caller sees a
// confusing success-shaped response with no cookie.
throw new APIError('FORBIDDEN', {
code: 'ACCOUNT_DISABLED',
message: 'Dieses Konto ist deaktiviert.'
});
}
}
}
}
}
});
export type AdminAuth = typeof auth;
+68
View File
@@ -0,0 +1,68 @@
import * as UsersService from './users/users.admin.service.js';
import * as InvitationsService from './invitations/invitations.service.js';
import {sendInvitationMail} from './admin.mail.js';
import {ACCESS_ROLE} from './admin.schema.js';
import {ADMIN_APP_URL, ADMIN_BOOTSTRAP_EMAIL, LOG_INVITE_LINKS} from './admin.config.js';
import logger from '../../middleware/logger.js';
/**
* Solves the empty-database problem: with invite-only accounts and no public
* sign-up, a fresh nachklang_admin has nobody who can invite anybody. Rather
* than a CLI script somebody has to remember to run against production, the API
* makes sure on every start that ADMIN_BOOTSTRAP_EMAIL can get in.
*
* Idempotent by design - it is safe on every restart:
* - an active admin already exists -> do nothing
* - the address exists as a user -> grant it `admin`
* - an open invitation exists -> do nothing (do not re-mail on restart)
* - otherwise -> invite, and mail the link
*
* Never throws: a database blip at boot must not stop the API from serving the
* calendar, feedback and tickets domains.
*/
export const bootstrapAdmin = async (): Promise<void> => {
try {
if (!ADMIN_BOOTSTRAP_EMAIL) {
return;
}
const email = ADMIN_BOOTSTRAP_EMAIL.trim().toLowerCase();
if ((await UsersService.countActiveAdmins()) > 0) {
return;
}
const existing = await UsersService.findUserByEmail(email);
if (existing) {
await UsersService.grantPermission(existing.id, 'admin', null);
logger.info('Admin bootstrap: granted the admin permission to the existing bootstrap user', {email});
return;
}
// An expired invitation is not "open", so the next restart re-issues
// one - which is the recovery path if the first mail never arrived.
if (await InvitationsService.hasOpenInvitationFor(email)) {
logger.info('Admin bootstrap: an open invitation already exists', {email});
return;
}
const invitation = await InvitationsService.createInvitation(
email,
'Nachklang Admin',
[{app: 'admin', role: ACCESS_ROLE}],
null
);
const mailed = await sendInvitationMail(email, 'Nachklang Admin', invitation.token, invitation.expiresAt);
logger.info('Admin bootstrap: invitation created', {email, mailed});
// With the mail relay off, the logged link is how a local setup gets its
// first admin. Explicit opt-in (see LOG_INVITE_LINKS): the link is a
// live credential, so this must never depend on NODE_ENV alone.
if (LOG_INVITE_LINKS) {
logger.info(`Admin bootstrap: ${ADMIN_APP_URL}/accept-invite?token=${invitation.token}`);
}
} catch (e: any) {
logger.error('Admin bootstrap failed', {detail: e?.message});
}
};
+170
View File
@@ -0,0 +1,170 @@
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
import logger from '../../middleware/logger.js';
dotenv.config();
/**
* One place that reads the admin module's environment. Both admin.auth.ts
* (better-auth trustedOrigins, passkey origins) and app.ts (CORS) need the
* same origin list, and a second parser would drift from this one.
*
* Read this before changing the environment handling below: several security
* properties depend on it, and they are deliberately arranged to fail *safe*.
*
* `NODE_ENV` is opt-in to relaxed behaviour, not opt-in to strict behaviour.
* Only the explicit values 'development' and 'test' relax anything; anything
* else - including NODE_ENV being unset, which is exactly what a fresh Plesk
* vhost gives you - is treated as production. The inverse arrangement is a
* trap: it degrades the cookie domain, the CORS origin list and the signing
* key all at once, and every one of those failures is silent.
*
* The signing key is never allowed to be a known constant. In dev, an unset
* BETTER_AUTH_SECRET becomes a random per-process value: sessions do not
* survive a restart, which is mildly annoying and much better than a default
* secret that can be copied out of this file and used against production.
*/
const nodeEnv = process.env.NODE_ENV;
// Explicitly relaxed environments. Everything else, unset included, is strict.
const isRelaxedEnv = nodeEnv === 'development' || nodeEnv === 'test';
export const isProd = !isRelaxedEnv;
const required = (name: string, devDefault: string): string => {
const value = process.env[name];
if (value) {
return value;
}
if (isProd) {
logger.error(
`Admin module: ${name} is not set (NODE_ENV=${nodeEnv ?? 'unset'}, so strict mode applies; ` +
'set NODE_ENV=development for local work)'
);
throw new Error(`${name} must be set unless NODE_ENV is development or test`);
}
return devDefault;
};
export const API_BASE_URL = required('API_BASE_URL', 'http://localhost:3000');
export const ADMIN_APP_URL = required('ADMIN_APP_URL', 'http://localhost:3002');
// 32+ random bytes; better-auth signs cookies and reset tokens with it.
// Rotating it invalidates every session, which is why it is not derived.
// There is no hardcoded fallback on purpose: a constant committed here would
// be a published signing key the moment someone deploys without setting it.
export const BETTER_AUTH_SECRET = required(
'BETTER_AUTH_SECRET',
crypto.randomBytes(48).toString('base64')
);
// Passkeys are bound to this: a credential registered for "nachklang.art"
// works on every *.nachklang.art host, one registered for "localhost" only
// works in dev. Changing it invalidates every registered passkey.
export const PASSKEY_RP_ID = process.env.PASSKEY_RP_ID || (isProd ? 'nachklang.art' : 'localhost');
const parseList = (value: string | undefined, fallback: string[]): string[] => {
const parsed = (value || '')
.split(',')
.map(entry => entry.trim())
.filter(entry => entry.length > 0);
return parsed.length > 0 ? parsed : fallback;
};
// The apps whose frontends may talk to /admin/* with credentials.
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, []).map(origin => origin.replace(/\/$/, ''));
// Kept in sync by construction rather than by three separate lists: the admin
// app itself always counts, and dev adds the local ports.
export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
ADMIN_APP_URL.replace(/\/$/, ''),
...APP_ORIGINS
]));
/**
* The header the reverse proxy puts the real client IP in, and the proxy hops
* to trust when reading it.
*
* This matters more than it looks. better-auth does not know about Express's
* `trust proxy`; it reads the request itself. If it cannot resolve a client IP
* it falls back to a single shared bucket ("no-trusted-ip") for the whole
* process - and /sign-in/* carries a default of 3 requests per 10 seconds, so
* one noisy client would lock every user out of every app.
*
* Without TRUSTED_PROXY_IPS, better-auth rejects a multi-value
* x-forwarded-for outright (it cannot tell which hop is the client), which is
* exactly the case that produces that shared bucket. Set it to the address or
* CIDR of Plesk's nginx. Conversely, listing a header the proxy does not
* overwrite lets a client set its own IP and mint itself an unlimited
* brute-force budget - so the default is the single header nginx sets, not a
* permissive list.
*/
/**
* `CLIENT_IP_HEADERS=none` trusts no header at all.
*
* This is the escape hatch for the one case where the wrong setting is worse
* than no setting: if the proxy turns out NOT to overwrite the header we are
* trusting, any client can send it and mint itself an unlimited brute-force
* budget against /sign-in. Falling back to the shared bucket is bad (one noisy
* client can lock the organisation out for ten seconds at a time) but it is
* bad in a way that fails closed, and it can be reverted from the environment
* without a deploy.
*
* Reach for it only after a check has actually failed - `SELECT ipAddress FROM
* session ORDER BY createdAt DESC` showing 127.0.0.1 or NULL for a real remote
* sign-in - and take it back out once the header is configured.
*
* An empty or unset value still means "use the default", not "trust nothing":
* a stray blank line in a .env must not silently change how requests are
* bucketed. Only the explicit word does that.
*/
const TRUST_NO_HEADER = 'none';
export const TRUST_NO_CLIENT_IP_HEADER =
(process.env.CLIENT_IP_HEADERS || '').trim().toLowerCase() === TRUST_NO_HEADER;
// An empty array is what better-auth reads as "no headers": it only falls back
// to its own default when the option is absent, and `[]` is truthy.
export const CLIENT_IP_HEADERS = TRUST_NO_CLIENT_IP_HEADER
? []
: parseList(process.env.CLIENT_IP_HEADERS, ['x-real-ip']);
export const TRUSTED_PROXY_IPS = parseList(process.env.TRUSTED_PROXY_IPS, []);
if (isProd && TRUST_NO_CLIENT_IP_HEADER) {
logger.warn(
'Admin module: CLIENT_IP_HEADERS=none - no client-IP header is trusted, so every ' +
'request shares one rate-limit bucket and /sign-in allows 3 attempts per 10 seconds ' +
'for everyone combined. This is the safe fallback, not a destination: configure the ' +
'header the proxy actually sets and remove it.'
);
} else if (isProd && TRUSTED_PROXY_IPS.length === 0) {
logger.warn(
'Admin module: TRUSTED_PROXY_IPS is not set. If the proxy sends a multi-value ' +
`${CLIENT_IP_HEADERS.join('/')}, better-auth cannot resolve a client IP and every ` +
'request shares one rate-limit bucket. Verify with: SELECT `key` FROM rateLimit - ' +
'a "no-trusted-ip" row means this is happening. A single-value header needs no ' +
'trusted proxies, so this warning is expected on a plain single-proxy setup.'
);
}
export const ADMIN_BOOTSTRAP_EMAIL = process.env.ADMIN_BOOTSTRAP_EMAIL || '';
/**
* Whether to write invitation links to the log. An invitation link is a live
* account-creation credential, so this is an explicit opt-in rather than
* something inferred from NODE_ENV: local work needs it (the mail relay is
* usually off, and only the token's hash is stored, so there is otherwise no
* way to walk the accept flow), and production must never have it.
*
* Refused outright in strict mode, so setting it in a production .env by
* accident fails at boot instead of quietly filling the log with credentials.
*/
export const LOG_INVITE_LINKS = process.env.ADMIN_LOG_INVITE_LINKS === 'true' && !isProd;
if (process.env.ADMIN_LOG_INVITE_LINKS === 'true' && isProd) {
logger.error('Admin module: ADMIN_LOG_INVITE_LINKS is set outside development - refusing to log invitation tokens');
}
+17
View File
@@ -0,0 +1,17 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
/**
* Same catch-block convention as the feedback and tickets modules: log with a
* reference guid, never hand the real error message to the client.
*/
export const sendServerError = (res: Response, e: any): void => {
const errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
};
+120
View File
@@ -0,0 +1,120 @@
/**
* Swagger component definitions for the admin module. Picked up by
* swagger-jsdoc through the `src/models/**\/*.interface.ts` glob in
* app.factory.ts.
*
* Note what is *not* documented here: the better-auth routes under
* /admin/auth/* (sign-in, sign-out, reset-password, passkey ceremonies, and
* the invitation preview/accept endpoints). better-auth owns those paths and
* their shapes; duplicating them by hand would only drift on the next upgrade.
*/
/**
* @swagger
* components:
* securitySchemes:
* AdminSessionCookie:
* type: apiKey
* in: cookie
* name: nachklang.session_token
* description: >
* Set by /admin/auth/sign-in/email. Over https the name is
* __Secure-nachklang.session_token and the cookie is scoped to
* .nachklang.art, so one sign-in covers every *.nachklang.art app.
* schemas:
* AdminApp:
* type: string
* enum: [calendar, feedback, tickets, admin]
* description: Holding "admin" is what allows managing users and invitations.
* AdminMe:
* type: object
* properties:
* id:
* type: string
* email:
* type: string
* fullName:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* AdminUserSession:
* type: object
* properties:
* id:
* type: string
* createdAt:
* type: string
* format: date-time
* expiresAt:
* type: string
* format: date-time
* ipAddress:
* type: string
* nullable: true
* userAgent:
* type: string
* nullable: true
* AdminUser:
* type: object
* properties:
* id:
* type: string
* email:
* type: string
* name:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* status:
* type: string
* enum: [aktiv, deaktiviert]
* description: Derived - there is no status column.
* createdAt:
* type: string
* format: date-time
* lastSignInAt:
* type: string
* format: date-time
* nullable: true
* description: Newest session's creation time; null once every session has expired.
* AdminUserDetail:
* allOf:
* - $ref: '#/components/schemas/AdminUser'
* - type: object
* properties:
* sessions:
* type: array
* items:
* $ref: '#/components/schemas/AdminUserSession'
* passkeyCount:
* type: integer
* AdminInvitation:
* type: object
* description: An open invitation. The token itself is never returned by any endpoint.
* properties:
* id:
* type: integer
* email:
* type: string
* name:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* invitedBy:
* type: string
* nullable: true
* description: Null for the invitation created by the ADMIN_BOOTSTRAP_EMAIL bootstrap.
* createdAt:
* type: string
* format: date-time
* expiresAt:
* type: string
* format: date-time
*/
export {};
+124
View File
@@ -0,0 +1,124 @@
import {MailService} from '../../common/common.mail.js';
import {ADMIN_APP_URL} from './admin.config.js';
/**
* The two transactional mails the admin module sends. Both go out through the
* shared MailService (Salesforce relay, see common.mail.ts), which never throws
* on a delivery failure - the invitation row and the reset token are already
* committed by the time we get here.
*
* HTML plus a plain-text body: the text part is not a fallback afterthought,
* it is what allowlist-based receivers and text-only clients actually show.
*/
const escapeHtml = (value: string): string => {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
// `heading` is escaped here; `paragraphs` are not, because callers pass markup
// (a <strong> around the expiry date) and escape their own interpolations.
const layout = (heading: string, paragraphs: string[], buttonLabel: string, buttonUrl: string): string => {
const body = paragraphs.map(p => `<p style="margin:0 0 16px;">${p}</p>`).join('');
return `<!doctype html>
<html lang="de">
<body style="margin:0;padding:24px;background:#f5f5f4;font-family:Helvetica,Arial,sans-serif;color:#1c1917;">
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:8px;padding:32px;">
<h1 style="margin:0 0 24px;font-size:20px;">${escapeHtml(heading)}</h1>
${body}
<p style="margin:24px 0;">
<a href="${escapeHtml(buttonUrl)}" style="display:inline-block;background:#1c1917;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:6px;">${escapeHtml(buttonLabel)}</a>
</p>
<p style="margin:0;font-size:13px;color:#57534e;">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<span style="word-break:break-all;">${escapeHtml(buttonUrl)}</span></p>
</div>
</body>
</html>`;
};
/**
* Invitation mail. The link carries the raw token in the query string; the
* admin app strips it from the URL as soon as it has read it (see the plan's
* §3b - the token must never reach an API access log or a Referer header).
*/
export const sendInvitationMail = async (
recipientAddress: string,
name: string,
token: string,
expiresAt: Date
): Promise<boolean> => {
const url = `${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`;
const expiry = expiresAt.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit', year: 'numeric'});
const subject = 'Dein Zugang zu Nachklang';
const text = [
`Hallo ${name},`,
'',
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über diesen Link vergibst du dein Passwort:',
'',
url,
'',
`Der Link ist bis zum ${expiry} gültig.`,
'',
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.',
'',
'Viele Grüße',
'Nachklang e.V.'
].join('\n');
const html = layout(
`Hallo ${name},`,
[
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über den Button vergibst du dein Passwort.',
`Der Link ist bis zum <strong>${escapeHtml(expiry)}</strong> gültig.`,
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.'
],
'Konto einrichten',
url
);
return MailService.sendMail(recipientAddress, subject, text, {html});
};
/**
* Password reset. better-auth builds the URL (it embeds its own token and the
* redirectTo the admin app passed), so this only wraps it in our templates.
*/
export const sendPasswordResetMail = async (
recipientAddress: string,
name: string,
url: string
): Promise<boolean> => {
const subject = 'Passwort zurücksetzen';
const text = [
`Hallo ${name},`,
'',
'über diesen Link kannst du ein neues Passwort vergeben:',
'',
url,
'',
'Der Link ist eine Stunde gültig.',
'',
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.',
'',
'Viele Grüße',
'Nachklang e.V.'
].join('\n');
const html = layout(
`Hallo ${name},`,
[
'über den Button kannst du ein neues Passwort vergeben.',
'Der Link ist eine Stunde gültig.',
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.'
],
'Neues Passwort vergeben',
url
);
return MailService.sendMail(recipientAddress, subject, text, {html});
};
+140
View File
@@ -0,0 +1,140 @@
import express from 'express';
import {fromNodeHeaders} from 'better-auth/node';
import {auth} from './admin.auth.js';
import * as UsersService from './users/users.admin.service.js';
import {AppName, AppPermission, AppRole} from './admin.schema.js';
import {sendServerError} from './admin.errors.js';
/**
* The one authenticator for every admin area in this API. It replaces
* feedback.auth.ts and tickets.auth.ts, which each re-implemented the same
* header-session check against the calendar users table.
*
* Two things are checked on every request, deliberately without any caching:
* that the session cookie is valid (better-auth), and that the user is still
* enabled and still holds the permission for this app (one database query).
* That is what makes "disable a user" and "revoke a session" take effect
* immediately rather than whenever a cached session happens to expire.
*/
// The shape the feedback and tickets services already expect - unchanged, so
// nothing downstream of the authenticator needs to know this file replaced
// their own.
export interface AdminIdentity {
id: string;
email: string;
displayName: string;
}
export interface AdminAccess extends AdminIdentity {
disabled: boolean;
/** Every (app, role) grant. */
permissions: AppPermission[];
/** The distinct apps those grants cover. */
apps: AppName[];
}
const unauthorized = (res: express.Response): void => {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
};
const forbidden = (res: express.Response, message: string): void => {
res.status(403).send({status: 'FORBIDDEN', message});
};
/**
* Resolves the session cookie to a user with their permissions, or null.
* One database query, no cache. Throws only on infrastructure errors.
*/
export const resolveAccess = async (req: express.Request): Promise<AdminAccess | null> => {
const session = await auth.api.getSession({headers: fromNodeHeaders(req.headers)});
if (!session?.user) {
return null;
}
const access = await UsersService.loadAccess(session.user.id);
if (!access) {
return null;
}
return {
id: access.id,
email: access.email,
displayName: access.displayName,
disabled: access.disabled,
permissions: access.permissions,
apps: access.apps
};
};
/**
* Any signed-in account, no permission required. Used by /admin/me and the
* account-management routes: a user with no app permissions at all still has
* to be able to see that, and to manage their own password and passkeys.
*
* The disabled check is not redundant with the session-create hook: that hook
* stops a disabled user from signing in, this stops one who was disabled while
* holding a live cookie. Disabling revokes sessions, so the window is small -
* but "small" is not "closed".
*/
export const requireSignedIn: express.RequestHandler = async (req, res, next) => {
try {
const access = await resolveAccess(req);
if (!access) {
unauthorized(res);
return;
}
if (access.disabled) {
forbidden(res, 'Dieses Konto ist deaktiviert.');
return;
}
res.locals.admin = access;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
/**
* The gate every admin area sits behind. `requireAppAccess('feedback')` is
* what feedback.auth.ts's requireAdminAuth used to be, except that it now
* answers 403 for a signed-in user without that app's permission instead of
* letting any activated @nachklang.art account in.
*
* The optional second argument narrows it to one role within the app. Nothing
* passes it today - every app has exactly the `access` role - but it is the
* seam a finer permission arrives through.
*/
export const requireAppAccess = (app: AppName, role?: AppRole): express.RequestHandler => {
return async (req, res, next) => {
try {
const access = await resolveAccess(req);
if (!access) {
unauthorized(res);
return;
}
if (access.disabled) {
forbidden(res, 'Dieses Konto ist deaktiviert.');
return;
}
// Without a role this asks "may they open this app at all?", which is
// any grant on it. With one it asks for that specific grant - the hook
// a finer permission plugs into, without touching existing call sites.
const allowed = role === undefined
? access.apps.includes(app)
: access.permissions.some(permission => permission.app === app && permission.role === role);
if (!allowed) {
forbidden(res, 'Für diesen Bereich fehlt dir die Berechtigung.');
return;
}
res.locals.admin = access;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
};
+179
View File
@@ -0,0 +1,179 @@
import {Generated} from 'kysely';
/**
* Kysely table types for `nachklang_admin`. Only the columns this module
* actually reads or writes are declared - better-auth owns the full shape of
* its own tables and does not use this interface, it is here so the users and
* invitations services get compile-time checking instead of `any`.
*
* Column names follow better-auth's default "camel" casing for its tables
* (`emailVerified`, `userId`, `createdAt`); our own two tables use the
* snake_case convention of the rest of the repo's SQL.
*/
export type AppName = 'calendar' | 'feedback' | 'tickets' | 'admin';
export const APP_NAMES: AppName[] = ['calendar', 'feedback', 'tickets', 'admin'];
export const isAppName = (value: unknown): value is AppName => {
return typeof value === 'string' && (APP_NAMES as string[]).includes(value);
};
/**
* A permission is (app, role), not just an app. Today every app has exactly one
* role - `access`, "may use this app at all" - so the model looks like a plain
* list of apps and the UI renders one checkbox each. It is written this way
* anyway because the alternative gets expensive fast: `user_app_permissions`
* has primary key (user_id, app, role), so a user can hold several roles for
* the same app, and adding one later is a string in APP_ROLES plus rows - never
* a schema migration and never a change to the shape on the wire.
*
* Note the role is deliberately NOT called `admin`, which is what the column
* defaulted to before: on a `tickets` row that reads as "tickets administrator"
* when it only ever meant "has access", and once real roles exist there would
* be no way to tell the two apart.
*/
export const ACCESS_ROLE = 'access';
export type AppRole = string;
/** Every role that exists, per app, in display order. Extend to add one. */
export const APP_ROLES: Record<AppName, readonly AppRole[]> = {
calendar: [ACCESS_ROLE],
feedback: [ACCESS_ROLE],
tickets: [ACCESS_ROLE],
admin: [ACCESS_ROLE]
};
export interface AppPermission {
app: AppName;
role: AppRole;
}
export const isAppRole = (app: AppName, role: unknown): role is AppRole => {
return typeof role === 'string' && APP_ROLES[app].includes(role);
};
export const isAppPermission = (value: unknown): value is AppPermission => {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as {app?: unknown; role?: unknown};
return isAppName(candidate.app) && isAppRole(candidate.app, candidate.role);
};
/**
* Normalises whatever a caller sent into a valid, duplicate-free permission
* list. Accepts the richer `{app, role}` form and the plain `AppName` form,
* because `{apps: ['tickets']}` is still what the older callers send and it
* means exactly "tickets at the access role".
*/
export const toPermissions = (value: unknown): AppPermission[] | null => {
if (!Array.isArray(value)) {
return null;
}
const permissions: AppPermission[] = [];
for (const entry of value) {
if (isAppName(entry)) {
permissions.push({app: entry, role: ACCESS_ROLE});
} else if (isAppPermission(entry)) {
permissions.push({app: entry.app, role: entry.role});
} else {
return null;
}
}
const seen = new Set<string>();
return permissions.filter(permission => {
const key = `${permission.app}:${permission.role}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
/** The distinct apps a permission list grants any access to. */
export const appsOf = (permissions: AppPermission[]): AppName[] => {
return APP_NAMES.filter(app => permissions.some(permission => permission.app === app));
};
export interface UserTable {
id: string;
name: string;
email: string;
emailVerified: boolean;
image: string | null;
createdAt: Date;
updatedAt: Date;
// Added via better-auth `additionalFields` (see admin.auth.ts).
disabled: boolean;
}
export interface SessionTable {
id: string;
token: string;
userId: string;
expiresAt: Date;
createdAt: Date;
updatedAt: Date;
ipAddress: string | null;
userAgent: string | null;
}
export interface PasskeyTable {
id: string;
name: string | null;
userId: string;
createdAt: Date;
}
export interface UserAppPermissionTable {
user_id: string;
app: AppName;
role: AppRole;
granted_by: string | null;
granted_at: Generated<Date>;
}
export interface InvitationTable {
// AUTO_INCREMENT: present on select, never supplied on insert.
id: Generated<number>;
email: string;
name: string;
token_hash: string;
// JSON column holding an AppPermission[]. Older rows may hold a plain
// AppName[]; `parsePermissions` reads both.
permissions: string;
invited_by: string | null;
created_at: Generated<Date>;
expires_at: Date;
accepted_at: Date | null;
revoked_at: Date | null;
}
export interface VerificationTable {
id: string;
identifier: string;
value: string;
expiresAt: Date;
}
export interface RateLimitTable {
id: string;
key: string;
count: number;
lastRequest: number;
}
export interface AdminDatabase {
user: UserTable;
session: SessionTable;
passkey: PasskeyTable;
verification: VerificationTable;
rateLimit: RateLimitTable;
user_app_permissions: UserAppPermissionTable;
invitations: InvitationTable;
}
@@ -0,0 +1,196 @@
import * as z from 'zod';
import {APIError, createAuthEndpoint} from 'better-auth/api';
import {setSessionCookie} from 'better-auth/cookies';
import {createLocalAccountIssuer} from 'better-auth/db';
import {runWithTransaction} from '@better-auth/core/context';
import type {BetterAuthPlugin} from 'better-auth';
import * as InvitationsService from './invitations.service.js';
import * as UsersService from '../users/users.admin.service.js';
import logger from '../../../middleware/logger.js';
/**
* The two public endpoints of the invitation flow, implemented as a better-auth
* plugin rather than as plain Express routes on the admin router.
*
* Why a plugin: `emailAndPassword.disableSignUp` is on, which makes
* `auth.api.signUpEmail` refuse - deliberately, there is no public sign-up.
* Accepting an invitation still has to create a user, hash a password, write a
* credential account and sign the person in. All four are better-auth
* internals reachable only from inside an endpoint's context, so this is where
* account creation lives. Nothing outside this file may create users.
*
* Because they are plugin endpoints they sit under better-auth's basePath:
* POST /admin/auth/invitations/preview
* POST /admin/auth/invitations/accept
*
* The token travels in the request *body*, never in the path or query, so it
* cannot end up in an access log or a Referer header.
*/
// Unknown, expired, revoked and already-accepted tokens must be
// indistinguishable to the caller: one shared error, one shared message.
const invalidToken = (): APIError => {
return new APIError('BAD_REQUEST', {
code: 'INVALID_INVITATION',
message: 'Diese Einladung ist nicht mehr gültig.'
});
};
export const invitationsPlugin = () => {
return {
id: 'nachklang-invitations',
endpoints: {
/**
* Lets the accept-invite page show who the invitation is for before
* asking for a password. Returns only name and email - never the
* granted apps, which is information the invitee has no need for
* and an attacker with a stolen link should not get either.
*/
previewInvitation: createAuthEndpoint(
'/invitations/preview',
{
method: 'POST',
body: z.object({
token: z.string().min(1)
})
},
async ctx => {
const invitation = await InvitationsService.findByToken(ctx.body.token);
if (!invitation) {
throw invalidToken();
}
return ctx.json({email: invitation.email, name: invitation.name});
}
),
/**
* Redeems the invitation: creates the user, its credential account
* and its permissions, then signs the person straight in so they
* land in the app instead of on a login form.
*/
acceptInvitation: createAuthEndpoint(
'/invitations/accept',
{
method: 'POST',
body: z.object({
token: z.string().min(1),
password: z.string().min(8).max(128)
})
},
async ctx => {
const invitation = await InvitationsService.findByToken(ctx.body.token);
if (!invitation) {
throw invalidToken();
}
// An account for this address already exists: the right fix
// is for an admin to grant permissions on the existing user,
// not to create a second one. Reported distinctly because
// the person holds a valid token - this leaks nothing they
// do not already know about their own mailbox.
const existing = await UsersService.findUserByEmail(invitation.email);
if (existing) {
throw new APIError('CONFLICT', {
code: 'USER_ALREADY_EXISTS',
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Melde dich stattdessen an.'
});
}
// Claim the invitation before creating anything. The update
// is conditional on it still being open, so two concurrent
// submissions of the same link cannot both end up creating a
// user.
const claimed = await InvitationsService.markAccepted(invitation.id);
if (!claimed) {
throw invalidToken();
}
// The user and its credential account go in one better-auth
// transaction, the way better-auth's own sign-up route does
// it: a half-created account with no password is not
// recoverable through any route this API exposes.
//
// It cannot cover everything, though. `user_app_permissions`
// and `invitations` are written through this module's own
// Kysely pool, which is a different connection, so no single
// transaction spans both. What follows is therefore
// compensated by hand rather than rolled back.
let user: Awaited<ReturnType<typeof ctx.context.internalAdapter.createUser>> | null = null;
try {
user = await runWithTransaction(ctx.context.adapter, async () => {
const created = await ctx.context.internalAdapter.createUser(
{
email: invitation.email,
name: invitation.name,
// Accepting a link sent to that mailbox *is*
// the proof of address ownership, so there is
// no separate verification mail (plan
// decision 15).
emailVerified: true,
disabled: false
},
{method: 'email-password'}
);
// Same call better-auth's own sign-up route makes,
// down to the synthetic issuer - a credential account
// written any other way is not found on sign-in.
await ctx.context.internalAdapter.linkAccount({
userId: created.id,
providerId: 'credential',
issuer: createLocalAccountIssuer('credential'),
accountId: created.id,
password: await ctx.context.password.hash(ctx.body.password)
});
return created;
});
await UsersService.setPermissions(user.id, invitation.permissions, null);
const session = await ctx.context.internalAdapter.createSession(user.id);
await setSessionCookie(ctx, {session, user});
return ctx.json({
user: {id: user.id, email: user.email, name: user.name}
});
} catch (e: any) {
// Undo what committed, so the invitee can use their link
// again instead of being stranded with a burnt token, an
// account they cannot sign into, and an admin who cannot
// re-invite them (the create route 409s on an existing
// user, and there is no delete route by design).
//
// Deleting the user is safe here: it was created moments
// ago in this request, and acceptance already established
// that no account for this address existed before.
try {
if (user) {
await ctx.context.internalAdapter.deleteUser(user.id);
}
await InvitationsService.unmarkAccepted(invitation.id);
} catch (compensationError: any) {
// Now the state really is inconsistent, and only a
// human can sort it out. Say so loudly and precisely.
logger.error('Admin: invitation acceptance failed AND its rollback failed', {
invitationId: invitation.id,
email: invitation.email,
userId: user?.id,
detail: e?.message,
compensationDetail: compensationError?.message
});
throw e;
}
logger.error('Admin: invitation acceptance failed and was rolled back', {
invitationId: invitation.id,
detail: e?.message
});
throw e;
}
}
)
}
} satisfies BetterAuthPlugin;
};
@@ -0,0 +1,208 @@
import express, {Request, Response} from 'express';
import * as InvitationsService from './invitations.service.js';
import * as UsersService from '../users/users.admin.service.js';
import {toPermissions} from '../admin.schema.js';
import {sendInvitationMail} from '../admin.mail.js';
import {ADMIN_APP_URL, LOG_INVITE_LINKS} from '../admin.config.js';
import {sendServerError} from '../admin.errors.js';
import logger from '../../../middleware/logger.js';
export const invitationsRouter = express.Router();
/**
* The admin-facing half of invitations (create, resend, revoke). The public
* half - preview and accept - lives in invitations.plugin.ts, because
* redeeming an invitation has to create a user through better-auth internals.
*
* Mounted behind requireAppAccess('admin').
*/
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* With the mail relay off (the normal local setup) the invitation mail never
* arrives, and only the token's hash is stored, so there would be no way to
* walk through the accept flow. Logging the link closes that.
*
* Gated on an explicit opt-in rather than on NODE_ENV: the link is a live
* account-creation credential, and "not production" is too weak a condition to
* hang that on. See LOG_INVITE_LINKS in admin.config.ts.
*/
const logInviteLinkInDev = (token: string): void => {
if (LOG_INVITE_LINKS) {
logger.info(`Admin: invitation link ${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`);
}
};
/**
* @swagger
* /admin/invitations:
* get:
* summary: List open (unaccepted, unrevoked, unexpired) invitations
* tags: [admin]
* responses:
* 200:
* description: Success
*/
invitationsRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send(await InvitationsService.listOpenInvitations());
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/invitations:
* post:
* summary: Invite someone and mail them an acceptance link
* tags: [admin]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, name, permissions]
* properties:
* email:
* type: string
* name:
* type: string
* permissions:
* type: array
* description: >
* One entry per (app, role). A plain array of app names is
* accepted too and means the same at the `access` role.
* items:
* type: object
* properties:
* app:
* type: string
* role:
* type: string
* responses:
* 201:
* description: Invitation created and mailed
* 400:
* description: Invalid input
* 409:
* description: A user with this address already exists
*/
invitationsRouter.post('/', async (req: Request, res: Response) => {
try {
const email = String(req.body?.email || '').trim().toLowerCase();
const name = String(req.body?.name || '').trim();
// Same two accepted shapes as PUT /admin/users/:id/permissions.
const permissions = toPermissions(req.body?.permissions ?? req.body?.apps);
if (!EMAIL_PATTERN.test(email) || name.length === 0 || !permissions) {
res.status(400).send({
status: 'BAD_REQUEST',
message: 'E-Mail, Name und Berechtigungen sind erforderlich.'
});
return;
}
// Inviting someone who already has an account would strand them on an
// accept page that can only fail. Granting permissions on the existing
// user is the operation they actually want.
if (await UsersService.findUserByEmail(email)) {
res.status(409).send({
status: 'CONFLICT',
message: 'Für diese E-Mail-Adresse gibt es bereits ein Konto. Vergib dort die Berechtigungen.'
});
return;
}
const invitation = await InvitationsService.createInvitation(
email,
name,
permissions,
res.locals.admin.id
);
const mailed = await sendInvitationMail(email, name, invitation.token, invitation.expiresAt);
if (!mailed) {
logger.warn('Admin: invitation created but the mail was not accepted', {email});
}
logInviteLinkInDev(invitation.token);
res.status(201).send({id: invitation.id, email, name, expiresAt: invitation.expiresAt, mailed});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/invitations/{invitationId}/resend:
* post:
* summary: Issue a new token for an open invitation and mail it again
* description: The previous link stops working.
* tags: [admin]
* responses:
* 200:
* description: Resent
* 404:
* description: No open invitation with this id
*/
invitationsRouter.post('/:invitationId/resend', async (req: Request, res: Response) => {
try {
const invitationId = parseInt(req.params.invitationId, 10);
if (Number.isNaN(invitationId)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'});
return;
}
const resent = await InvitationsService.resendInvitation(invitationId);
if (!resent) {
res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'});
return;
}
const mailed = await sendInvitationMail(resent.email, resent.name, resent.token, resent.expiresAt);
if (!mailed) {
logger.warn('Admin: invitation resent but the mail was not accepted', {email: resent.email});
}
logInviteLinkInDev(resent.token);
res.status(200).send({id: invitationId, expiresAt: resent.expiresAt, mailed});
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/invitations/{invitationId}:
* delete:
* summary: Revoke an open invitation
* tags: [admin]
* responses:
* 204:
* description: Revoked
* 404:
* description: No open invitation with this id
*/
invitationsRouter.delete('/:invitationId', async (req: Request, res: Response) => {
try {
const invitationId = parseInt(req.params.invitationId, 10);
if (Number.isNaN(invitationId)) {
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Einladungs-ID.'});
return;
}
const revoked = await InvitationsService.revokeInvitation(invitationId);
if (!revoked) {
res.status(404).send({status: 'NOT_FOUND', message: 'Einladung nicht gefunden.'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,239 @@
import * as crypto from 'crypto';
import {NachklangAdminDB} from '../Admin.db.js';
import {AppPermission, isAppName, isAppPermission, ACCESS_ROLE} from '../admin.schema.js';
const db = NachklangAdminDB.db;
/**
* Invitations are this API's only path to a new account (there is no public
* sign-up). The raw token exists exactly twice: in the mail we send and in the
* request body when it comes back. What we store is its SHA-256 hash, so a
* database dump does not hand out account access - the same reasoning as the
* calendar module's session key hashing, and the reason lookups go through
* `findByToken` rather than any query on a plaintext column.
*/
export const INVITATION_TTL_DAYS = 7;
export interface OpenInvitation {
id: number;
email: string;
name: string;
permissions: AppPermission[];
invitedBy: string | null;
createdAt: Date;
expiresAt: Date;
}
export interface AcceptableInvitation {
id: number;
email: string;
name: string;
permissions: AppPermission[];
}
const hashToken = (token: string): string => {
return crypto.createHash('sha256').update(token).digest('hex');
};
const generateToken = (): string => {
// 32 bytes, url-safe: it travels in a mail link's query string.
return crypto.randomBytes(32).toString('base64url');
};
/**
* Reads the stored permission list. Two shapes are accepted: the current
* `[{app, role}]`, and a bare `['tickets', ...]` from before roles existed,
* which means the same thing at the `access` role. Invitations live for seven
* days, so a deploy that changes the shape has in-flight rows in the old one -
* tolerating both is what stops those invitees from being stranded.
*
* mysql2 hands back a JSON column already parsed; a driver or column-type
* change that turns it into a string must not break the read path either.
*/
const parsePermissions = (value: unknown): AppPermission[] => {
const raw = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(raw)) {
return [];
}
return raw.flatMap((entry): AppPermission[] => {
if (isAppName(entry)) {
return [{app: entry, role: ACCESS_ROLE}];
}
return isAppPermission(entry) ? [{app: entry.app, role: entry.role}] : [];
});
};
const expiryFromNow = (): Date => {
return new Date(Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000);
};
/**
* Creates an invitation and returns the raw token for the mail. Any earlier
* open invitation for the same address is revoked first: two valid links for
* one mailbox is a needless second live credential, and "resend" would
* otherwise quietly accumulate them.
*/
export const createInvitation = async (
email: string,
name: string,
permissions: AppPermission[],
invitedBy: string | null
): Promise<{id: number; token: string; expiresAt: Date}> => {
const token = generateToken();
const expiresAt = expiryFromNow();
const valid = permissions.filter(isAppPermission);
const id = await db.transaction().execute(async trx => {
await trx
.updateTable('invitations')
.set({revoked_at: new Date()})
.where('email', '=', email)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.execute();
const result = await trx
.insertInto('invitations')
.values({
email,
name,
token_hash: hashToken(token),
permissions: JSON.stringify(valid),
invited_by: invitedBy,
created_at: new Date(),
expires_at: expiresAt
})
.executeTakeFirst();
return Number(result.insertId);
});
return {id, token, expiresAt};
};
/**
* Looks up a still-usable invitation by raw token. Callers must not
* distinguish "unknown", "expired", "revoked" and "already accepted" to the
* client: all four answer with the same shape, so a stranger cannot probe which
* tokens ever existed.
*/
export const findByToken = async (token: string): Promise<AcceptableInvitation | null> => {
const row = await db
.selectFrom('invitations')
.select(['id', 'email', 'name', 'permissions'])
.where('token_hash', '=', hashToken(token))
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.where('expires_at', '>', new Date())
.executeTakeFirst();
if (!row) {
return null;
}
return {id: row.id, email: row.email, name: row.name, permissions: parsePermissions(row.permissions)};
};
/** Marks the invitation accepted. Conditional on it still being open so two
* concurrent accepts of the same link cannot both create an account. */
export const markAccepted = async (invitationId: number): Promise<boolean> => {
const result = await db
.updateTable('invitations')
.set({accepted_at: new Date()})
.where('id', '=', invitationId)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.executeTakeFirst();
return Number(result.numUpdatedRows) > 0;
};
/**
* Reverses markAccepted. Used only to compensate a failed acceptance: the user
* could not be created, so the link must become usable again rather than
* stranding the invitee with a burnt token and no account.
*/
export const unmarkAccepted = async (invitationId: number): Promise<void> => {
await db
.updateTable('invitations')
.set({accepted_at: null})
.where('id', '=', invitationId)
.execute();
};
export const listOpenInvitations = async (): Promise<OpenInvitation[]> => {
const rows = await db
.selectFrom('invitations')
.select(['id', 'email', 'name', 'permissions', 'invited_by', 'created_at', 'expires_at'])
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.where('expires_at', '>', new Date())
.orderBy('created_at', 'desc')
.execute();
return rows.map(row => ({
id: row.id,
email: row.email,
name: row.name,
permissions: parsePermissions(row.permissions),
invitedBy: row.invited_by,
createdAt: row.created_at,
expiresAt: row.expires_at
}));
};
export const getOpenInvitation = async (invitationId: number): Promise<OpenInvitation | null> => {
const all = await listOpenInvitations();
return all.find(invitation => invitation.id === invitationId) ?? null;
};
/** Resend issues a *new* token and expiry and invalidates the old one, rather
* than re-mailing the existing link: if the first mail leaked, resending it
* would extend the leak's lifetime. */
export const resendInvitation = async (
invitationId: number
): Promise<{token: string; email: string; name: string; expiresAt: Date} | null> => {
const invitation = await getOpenInvitation(invitationId);
if (!invitation) {
return null;
}
const token = generateToken();
const expiresAt = expiryFromNow();
await db
.updateTable('invitations')
.set({token_hash: hashToken(token), expires_at: expiresAt, created_at: new Date()})
.where('id', '=', invitationId)
.execute();
return {token, email: invitation.email, name: invitation.name, expiresAt};
};
export const revokeInvitation = async (invitationId: number): Promise<boolean> => {
const result = await db
.updateTable('invitations')
.set({revoked_at: new Date()})
.where('id', '=', invitationId)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.executeTakeFirst();
return Number(result.numUpdatedRows) > 0;
};
/** Used by the bootstrap to stay idempotent across restarts. */
export const hasOpenInvitationFor = async (email: string): Promise<boolean> => {
const row = await db
.selectFrom('invitations')
.select('id')
.where('email', '=', email)
.where('accepted_at', 'is', null)
.where('revoked_at', 'is', null)
.where('expires_at', '>', new Date())
.executeTakeFirst();
return Boolean(row);
};
@@ -0,0 +1,247 @@
import express, {Request, Response} from 'express';
import * as UsersService from './users.admin.service.js';
import {toPermissions} from '../admin.schema.js';
import {sendServerError} from '../admin.errors.js';
export const usersAdminRouter = express.Router();
/**
* User administration. Mounted behind requireAppAccess('admin'), so every
* handler here can assume res.locals.admin is an admin.
*
* The guards below exist because this API can lock its own operators out: the
* only way to grant a permission is through these routes, so an admin who
* removes the last `admin` permission leaves nobody who can put it back short
* of a manual SQL statement in production.
*/
const conflict = (res: Response, message: string): void => {
res.status(409).send({status: 'CONFLICT', message});
};
const notFound = (res: Response): void => {
res.status(404).send({status: 'NOT_FOUND', message: 'Benutzer nicht gefunden.'});
};
/**
* @swagger
* /admin/users:
* get:
* summary: List all users with their app permissions and status
* tags: [admin]
* responses:
* 200:
* description: Success
* 401:
* description: Not signed in
* 403:
* description: Missing the admin permission
*/
usersAdminRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send(await UsersService.listUsers());
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/users/{userId}:
* get:
* summary: One user with their active sessions and passkey count
* tags: [admin]
* parameters:
* - in: path
* name: userId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Success
* 404:
* description: Unknown user
*/
usersAdminRouter.get('/:userId', async (req: Request, res: Response) => {
try {
const detail = await UsersService.getUserDetail(req.params.userId);
if (!detail) {
notFound(res);
return;
}
res.status(200).send(detail);
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/users/{userId}/permissions:
* put:
* summary: Replace a user's app permissions
* description: Refuses to remove the caller's own admin permission or the last remaining active admin.
* tags: [admin]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* permissions:
* type: array
* description: >
* One entry per (app, role). `access` is the only role today.
* A plain array of app names is also accepted and means the
* same at the `access` role.
* items:
* type: object
* properties:
* app:
* type: string
* enum: [calendar, feedback, tickets, admin]
* role:
* type: string
* enum: [access]
* responses:
* 200:
* description: Success
* 400:
* description: Invalid app or role
* 409:
* description: Would lock the last admin out
*/
usersAdminRouter.put('/:userId/permissions', async (req: Request, res: Response) => {
try {
const userId = req.params.userId;
// `permissions: [{app, role}]` is the real shape; `apps: ['tickets']` is
// accepted as shorthand for the same thing at the `access` role, so a
// caller that predates roles keeps working.
const permissions = toPermissions(req.body?.permissions ?? req.body?.apps);
if (!permissions) {
res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige Berechtigungsliste.'});
return;
}
if (!(await UsersService.userExists(userId))) {
notFound(res);
return;
}
const target = await UsersService.loadAccess(userId);
const keepsAdmin = permissions.some(permission => permission.app === 'admin');
const losesAdmin = Boolean(target?.apps.includes('admin')) && !keepsAdmin;
// Self-lockout is checked here because it needs the caller's identity,
// which the service has no business knowing. The last-admin check is
// NOT done here: it has to be inside the write transaction to survive
// two admins acting at the same time (see setPermissionsGuarded).
if (losesAdmin && userId === res.locals.admin.id) {
conflict(res, 'Du kannst dir die Admin-Berechtigung nicht selbst entziehen.');
return;
}
const result = await UsersService.setPermissionsGuarded(userId, permissions, res.locals.admin.id);
if (result === 'last-admin') {
conflict(res, 'Die letzte Admin-Berechtigung kann nicht entzogen werden.');
return;
}
res.status(200).send(await UsersService.getUserDetail(userId));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/users/{userId}/disable:
* post:
* summary: Disable a user and revoke all of their sessions
* tags: [admin]
* responses:
* 200:
* description: Success
* 409:
* description: Would disable the caller or the last admin
*/
usersAdminRouter.post('/:userId/disable', async (req: Request, res: Response) => {
try {
const userId = req.params.userId;
if (userId === res.locals.admin.id) {
conflict(res, 'Du kannst dich nicht selbst deaktivieren.');
return;
}
const target = await UsersService.loadAccess(userId);
if (!target) {
notFound(res);
return;
}
const result = await UsersService.disableUserGuarded(userId);
if (result === 'last-admin') {
conflict(res, 'Der letzte aktive Admin kann nicht deaktiviert werden.');
return;
}
res.status(200).send(await UsersService.getUserDetail(userId));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/users/{userId}/enable:
* post:
* summary: Re-enable a disabled user
* description: Does not restore sessions - the user signs in again.
* tags: [admin]
* responses:
* 200:
* description: Success
*/
usersAdminRouter.post('/:userId/enable', async (req: Request, res: Response) => {
try {
if (!(await UsersService.userExists(req.params.userId))) {
notFound(res);
return;
}
await UsersService.enableUser(req.params.userId);
res.status(200).send(await UsersService.getUserDetail(req.params.userId));
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /admin/users/{userId}/sessions/{sessionId}:
* delete:
* summary: Revoke one session of a user
* tags: [admin]
* responses:
* 204:
* description: Revoked
* 404:
* description: Unknown session for this user
*/
usersAdminRouter.delete('/:userId/sessions/:sessionId', async (req: Request, res: Response) => {
try {
const revoked = await UsersService.revokeSession(req.params.userId, req.params.sessionId);
if (!revoked) {
res.status(404).send({status: 'NOT_FOUND', message: 'Sitzung nicht gefunden.'});
return;
}
res.status(204).send();
} catch (e: any) {
sendServerError(res, e);
}
});
@@ -0,0 +1,439 @@
import {Transaction} from 'kysely';
import {NachklangAdminDB} from '../Admin.db.js';
import {
AdminDatabase,
AppName,
AppPermission,
AppRole,
ACCESS_ROLE,
appsOf,
isAppName,
isAppRole
} from '../admin.schema.js';
const db = NachklangAdminDB.db;
/**
* Everything that reads or writes permissions. Two callers with very different
* hot-path requirements share this file: admin.middleware.ts runs
* `loadAccess` on *every* admin-authenticated request (which is why it is one
* query joining `user.disabled` and the permission rows - see the plan's
* decision to run without better-auth's cookieCache), and the /admin/users
* routes run the rest.
*/
export interface UserAccess {
id: string;
email: string;
displayName: string;
disabled: boolean;
/** Every (app, role) grant this user holds. */
permissions: AppPermission[];
/** The distinct apps the above grants any access to. Derived, kept because
* most callers only ever ask "may they open this app at all?". */
apps: AppName[];
}
export type UserStatus = 'aktiv' | 'deaktiviert';
export interface UserListEntry {
id: string;
email: string;
name: string;
permissions: AppPermission[];
apps: AppName[];
status: UserStatus;
createdAt: Date;
lastSignInAt: Date | null;
}
export interface UserSessionEntry {
id: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string | null;
userAgent: string | null;
}
export interface UserDetail extends UserListEntry {
sessions: UserSessionEntry[];
passkeyCount: number;
}
/**
* The single per-request lookup behind requireAppAccess. Returns null when the
* user row is gone; `disabled` is returned rather than filtered so the
* middleware can answer 403 (account deactivated) instead of a misleading 401.
*/
export const loadAccess = async (userId: string): Promise<UserAccess | null> => {
const rows = await db
.selectFrom('user')
.leftJoin('user_app_permissions', 'user_app_permissions.user_id', 'user.id')
.where('user.id', '=', userId)
.select([
'user.id as id',
'user.email as email',
'user.name as name',
'user.disabled as disabled',
'user_app_permissions.app as app',
'user_app_permissions.role as role'
])
.execute();
if (rows.length === 0) {
return null;
}
const permissions = toPermissionRows(rows);
return {
id: rows[0].id,
email: rows[0].email,
displayName: rows[0].name,
// MySQL TINYINT(1) comes back as 0/1 through mysql2.
disabled: Boolean(rows[0].disabled),
permissions,
apps: appsOf(permissions)
};
};
/**
* Turns joined permission rows into AppPermission[]. The left join produces one
* row with a null app for a user who holds nothing, and a role written directly
* into the database that no longer appears in APP_ROLES is dropped rather than
* trusted - the table is the store, APP_ROLES is the contract.
*/
const toPermissionRows = (rows: {app: AppName | null; role: string | null}[]): AppPermission[] => {
return rows
.filter((row): row is {app: AppName; role: string} =>
isAppName(row.app) && isAppRole(row.app, row.role))
.map(row => ({app: row.app, role: row.role}));
};
export const listUsers = async (): Promise<UserListEntry[]> => {
const users = await db
.selectFrom('user')
.select(['id', 'email', 'name', 'disabled', 'createdAt'])
.orderBy('name', 'asc')
.execute();
const permissions = await db
.selectFrom('user_app_permissions')
.select(['user_id', 'app', 'role'])
.execute();
// Last sign-in is derived from the newest session rather than stored: a
// session row is created on every sign-in and we never update its
// createdAt, so max(createdAt) is exactly that, with no extra column to
// keep in sync.
//
// The expiry filter must match getUserDetail's. better-auth only deletes an
// expired session when someone actually presents it, so expired rows linger
// - without this, the list would report a last sign-in for someone the
// detail view shows as never having signed in.
const lastSessions = await db
.selectFrom('session')
.where('expiresAt', '>', new Date())
.select(({fn}) => ['userId', fn.max('createdAt').as('lastSignInAt')])
.groupBy('userId')
.execute();
const permissionsByUser = new Map<string, AppPermission[]>();
for (const row of permissions) {
if (!isAppRole(row.app, row.role)) {
continue;
}
const held = permissionsByUser.get(row.user_id) || [];
held.push({app: row.app, role: row.role});
permissionsByUser.set(row.user_id, held);
}
const lastSignInByUser = new Map<string, Date | null>(
lastSessions.map(row => [row.userId, row.lastSignInAt as Date | null])
);
return users.map(user => {
const held = permissionsByUser.get(user.id) || [];
return {
id: user.id,
email: user.email,
name: user.name,
permissions: held,
apps: appsOf(held),
status: user.disabled ? ('deaktiviert' as const) : ('aktiv' as const),
createdAt: user.createdAt,
lastSignInAt: lastSignInByUser.get(user.id) ?? null
};
});
};
export const getUserDetail = async (userId: string): Promise<UserDetail | null> => {
const user = await db
.selectFrom('user')
.select(['id', 'email', 'name', 'disabled', 'createdAt'])
.where('id', '=', userId)
.executeTakeFirst();
if (!user) {
return null;
}
const [permissions, sessions, passkeys] = await Promise.all([
db
.selectFrom('user_app_permissions')
.select(['app', 'role'])
.where('user_id', '=', userId)
.execute(),
db
.selectFrom('session')
.select(['id', 'createdAt', 'expiresAt', 'ipAddress', 'userAgent'])
.where('userId', '=', userId)
.where('expiresAt', '>', new Date())
.orderBy('createdAt', 'desc')
.execute(),
db
.selectFrom('passkey')
.select(({fn}) => fn.countAll<number>().as('count'))
.where('userId', '=', userId)
.executeTakeFirst()
]);
const held = toPermissionRows(permissions);
return {
id: user.id,
email: user.email,
name: user.name,
permissions: held,
apps: appsOf(held),
status: user.disabled ? 'deaktiviert' : 'aktiv',
createdAt: user.createdAt,
lastSignInAt: sessions.length > 0 ? sessions[0].createdAt : null,
sessions,
passkeyCount: Number(passkeys?.count ?? 0)
};
};
/**
* Replaces a user's permission set. Written as delete-then-insert inside one
* transaction rather than a diff: the set is at most four rows, and a diff
* would only add branches for no measurable gain.
*/
/** The rows a permission list becomes. One row per (app, role). */
const permissionRows = (
userId: string,
permissions: AppPermission[],
grantedBy: string | null
) => {
return permissions.map(permission => ({
user_id: userId,
app: permission.app,
role: permission.role,
granted_by: grantedBy,
granted_at: new Date()
}));
};
/** Drops anything not in APP_ROLES and de-duplicates on (app, role). */
const validPermissions = (permissions: AppPermission[]): AppPermission[] => {
const seen = new Set<string>();
return permissions.filter(permission => {
if (!isAppName(permission.app) || !isAppRole(permission.app, permission.role)) {
return false;
}
const key = `${permission.app}:${permission.role}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
export const setPermissions = async (
userId: string,
permissions: AppPermission[],
grantedBy: string | null
): Promise<void> => {
const valid = validPermissions(permissions);
await db.transaction().execute(async trx => {
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
if (valid.length > 0) {
await trx
.insertInto('user_app_permissions')
.values(permissionRows(userId, valid, grantedBy))
.execute();
}
});
};
/**
* Why the guards live down here rather than in the router: they are
* check-then-act, and the check has to happen inside the same transaction as
* the write, over locked rows. Two admins each removing the other's `admin`
* permission at the same moment would otherwise both read a count of 2, both
* pass, and both commit - leaving nobody who can administer anything, with
* ADMIN_BOOTSTRAP_EMAIL at the next restart as the only way back in.
*
* `SELECT ... FOR UPDATE` makes the second transaction wait and re-read the
* count the first one just changed.
*/
export type LastAdminGuardResult = 'ok' | 'last-admin';
const countActiveAdminsForUpdate = async (trx: Transaction<AdminDatabase>): Promise<number> => {
const row = await trx
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.app', '=', 'admin')
.where('user.disabled', '=', false)
// countDistinct, not countAll: with (user_id, app, role) as the key one
// user can hold several roles on `admin`, and counting rows would make a
// single admin with two roles look like two admins - defeating the guard
// at exactly the moment it matters.
.select(({fn}) => fn.count<number>('user_app_permissions.user_id').distinct().as('count'))
.forUpdate()
.executeTakeFirst();
return Number(row?.count ?? 0);
};
/**
* Replaces a user's permissions, refusing to remove the last active admin.
* Returns 'last-admin' instead of throwing so the router can answer 409.
*/
export const setPermissionsGuarded = async (
userId: string,
permissions: AppPermission[],
grantedBy: string | null
): Promise<LastAdminGuardResult> => {
const valid = validPermissions(permissions);
const keepsAdmin = valid.some(permission => permission.app === 'admin');
return db.transaction().execute(async trx => {
const target = await trx
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.user_id', '=', userId)
.where('user_app_permissions.app', '=', 'admin')
.select(['user.disabled as disabled'])
.limit(1)
.forUpdate()
.executeTakeFirst();
const losesAdmin = Boolean(target) && !keepsAdmin;
if (losesAdmin && !target?.disabled && (await countActiveAdminsForUpdate(trx)) <= 1) {
return 'last-admin';
}
await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute();
if (valid.length > 0) {
await trx
.insertInto('user_app_permissions')
.values(permissionRows(userId, valid, grantedBy))
.execute();
}
return 'ok';
});
};
/**
* Disables a user and revokes every session, refusing to disable the last
* active admin. Same locking rationale as setPermissionsGuarded.
*/
export const disableUserGuarded = async (userId: string): Promise<LastAdminGuardResult> => {
return db.transaction().execute(async trx => {
const isAdmin = await trx
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.user_id', '=', userId)
.where('user_app_permissions.app', '=', 'admin')
.where('user.disabled', '=', false)
.select('user_app_permissions.user_id')
.limit(1)
.forUpdate()
.executeTakeFirst();
if (isAdmin && (await countActiveAdminsForUpdate(trx)) <= 1) {
return 'last-admin';
}
await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
await trx.deleteFrom('session').where('userId', '=', userId).execute();
return 'ok';
});
};
export const grantPermission = async (
userId: string,
app: AppName,
grantedBy: string | null,
role: AppRole = ACCESS_ROLE
): Promise<void> => {
await db
.insertInto('user_app_permissions')
.values({user_id: userId, app, role, granted_by: grantedBy, granted_at: new Date()})
// The row already existing is the success case - this is "make sure they
// hold it", not "re-grant it" - so nothing is overwritten and granted_by
// keeps naming whoever granted it first.
.onDuplicateKeyUpdate({role})
.execute();
};
/** Disabling revokes every session: a disabled user must lose access now, not
* when their 30-day cookie happens to expire. */
export const disableUser = async (userId: string): Promise<void> => {
await db.transaction().execute(async trx => {
await trx.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
await trx.deleteFrom('session').where('userId', '=', userId).execute();
});
};
export const enableUser = async (userId: string): Promise<void> => {
await db.updateTable('user').set({disabled: false}).where('id', '=', userId).execute();
};
export const revokeSession = async (userId: string, sessionId: string): Promise<boolean> => {
const result = await db
.deleteFrom('session')
.where('id', '=', sessionId)
.where('userId', '=', userId)
.executeTakeFirst();
return Number(result.numDeletedRows) > 0;
};
/**
* Guard input for the self-lockout rules: how many enabled users still hold the
* `admin` permission. Disabled admins do not count - they cannot sign in, so
* leaving only disabled admins is the same lockout as leaving none.
*/
export const countActiveAdmins = async (): Promise<number> => {
const row = await db
.selectFrom('user_app_permissions')
.innerJoin('user', 'user.id', 'user_app_permissions.user_id')
.where('user_app_permissions.app', '=', 'admin')
.where('user.disabled', '=', false)
.select(({fn}) => fn.countAll<number>().as('count'))
.executeTakeFirst();
return Number(row?.count ?? 0);
};
export const userExists = async (userId: string): Promise<boolean> => {
const row = await db.selectFrom('user').select('id').where('id', '=', userId).executeTakeFirst();
return Boolean(row);
};
export const findUserByEmail = async (email: string): Promise<{id: string; email: string} | null> => {
const row = await db
.selectFrom('user')
.select(['id', 'email'])
.where('email', '=', email)
.executeTakeFirst();
return row ?? null;
};
+96
View File
@@ -0,0 +1,96 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
countActiveAdmins: vi.fn(),
findUserByEmail: vi.fn(),
grantPermission: vi.fn()
}));
vi.mock('../../src/models/admin/invitations/invitations.service.js', () => ({
hasOpenInvitationFor: vi.fn(),
createInvitation: vi.fn()
}));
vi.mock('../../src/models/admin/admin.mail.js', () => ({
sendInvitationMail: vi.fn()
}));
vi.mock('../../src/models/admin/admin.config.js', () => ({
ADMIN_BOOTSTRAP_EMAIL: 'boss@nachklang.art',
ADMIN_APP_URL: 'http://localhost:3002',
isProd: false
}));
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import {sendInvitationMail} from '../../src/models/admin/admin.mail.js';
import {bootstrapAdmin} from '../../src/models/admin/admin.bootstrap.js';
const countActiveAdmins = UsersService.countActiveAdmins as Mock;
const findUserByEmail = UsersService.findUserByEmail as Mock;
const grantPermission = UsersService.grantPermission as Mock;
const hasOpenInvitationFor = InvitationsService.hasOpenInvitationFor as Mock;
const createInvitation = InvitationsService.createInvitation as Mock;
const mockMail = sendInvitationMail as Mock;
beforeEach(() => {
countActiveAdmins.mockReset();
findUserByEmail.mockReset();
grantPermission.mockReset();
hasOpenInvitationFor.mockReset();
createInvitation.mockReset();
mockMail.mockReset();
mockMail.mockResolvedValue(true);
createInvitation.mockResolvedValue({id: 1, token: 'raw-token', expiresAt: new Date()});
});
describe('bootstrapAdmin', () => {
it('does nothing when an active admin already exists', async () => {
countActiveAdmins.mockResolvedValue(1);
await bootstrapAdmin();
expect(createInvitation).not.toHaveBeenCalled();
expect(grantPermission).not.toHaveBeenCalled();
});
it('grants admin directly when the bootstrap address is already a user', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue({id: 'u9', email: 'boss@nachklang.art'});
await bootstrapAdmin();
expect(grantPermission).toHaveBeenCalledWith('u9', 'admin', null);
expect(createInvitation).not.toHaveBeenCalled();
});
it('does not re-invite (or re-mail) while an open invitation exists', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue(null);
hasOpenInvitationFor.mockResolvedValue(true);
await bootstrapAdmin();
expect(createInvitation).not.toHaveBeenCalled();
expect(mockMail).not.toHaveBeenCalled();
});
it('invites with the admin permission when there is nothing to work with', async () => {
countActiveAdmins.mockResolvedValue(0);
findUserByEmail.mockResolvedValue(null);
hasOpenInvitationFor.mockResolvedValue(false);
await bootstrapAdmin();
expect(createInvitation).toHaveBeenCalledWith(
'boss@nachklang.art',
'Nachklang Admin',
[{app: 'admin', role: 'access'}],
null
);
expect(mockMail).toHaveBeenCalled();
});
it('never throws when the database is unreachable at boot', async () => {
countActiveAdmins.mockRejectedValue(new Error('connect ECONNREFUSED'));
await expect(bootstrapAdmin()).resolves.toBeUndefined();
});
});
+109
View File
@@ -0,0 +1,109 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
// admin.config calls dotenv.config(), which would read the repo's own .env and
// quietly reintroduce NODE_ENV=development - the exact value several of these
// cases exist to remove. Stub it so the tests see only what they set.
vi.mock('dotenv', () => ({config: vi.fn()}));
/**
* admin.config reads the environment once at import, so every case here has to
* reset the module registry and re-import it. The two things worth pinning are
* the ones that are silent when wrong: which client-IP header is trusted, and
* whether an unset NODE_ENV counts as production.
*/
const ORIGINAL_ENV = {...process.env};
const loadConfig = async () => {
vi.resetModules();
return import('../../src/models/admin/admin.config.js');
};
beforeEach(() => {
process.env = {...ORIGINAL_ENV};
// dotenv.config() in admin.config does not overwrite what is already set,
// so setting these here is enough to keep the local .env out of the test.
process.env.NODE_ENV = 'test';
delete process.env.CLIENT_IP_HEADERS;
delete process.env.TRUSTED_PROXY_IPS;
});
afterEach(() => {
process.env = {...ORIGINAL_ENV};
});
describe('CLIENT_IP_HEADERS', () => {
it('defaults to the single header Plesk nginx sets', async () => {
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
it('reads a comma-separated list', async () => {
process.env.CLIENT_IP_HEADERS = 'x-real-ip, cf-connecting-ip';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip', 'cf-connecting-ip']);
});
it('trusts nothing when set to "none"', async () => {
// The escape hatch. An empty list is what better-auth reads as "no
// headers" - it only falls back to its own default when the option is
// absent - so this really does stop any header being believed.
process.env.CLIENT_IP_HEADERS = 'none';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual([]);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(true);
});
it('accepts the hatch case-insensitively and with stray whitespace', async () => {
process.env.CLIENT_IP_HEADERS = ' NONE ';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual([]);
});
it('treats an empty value as "use the default", not as the hatch', async () => {
// A blank line in a .env must not silently change how requests are
// bucketed - only the explicit word does that.
process.env.CLIENT_IP_HEADERS = '';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
it('does not mistake a header actually named none-ish for the hatch', async () => {
process.env.CLIENT_IP_HEADERS = 'x-none';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-none']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
});
describe('isProd', () => {
it('is false only for the explicit relaxed environments', async () => {
process.env.NODE_ENV = 'development';
expect((await loadConfig()).isProd).toBe(false);
process.env.NODE_ENV = 'test';
expect((await loadConfig()).isProd).toBe(false);
});
it('treats an unset NODE_ENV as production, which is what a bare vhost gives', async () => {
delete process.env.NODE_ENV;
// Strict mode refuses to boot without these; supply them so the import
// gets far enough to answer the question being asked.
process.env.BETTER_AUTH_SECRET = 'x'.repeat(48);
process.env.API_BASE_URL = 'https://api.nachklang.art';
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
expect((await loadConfig()).isProd).toBe(true);
});
it('refuses to start without a signing key outside development', async () => {
delete process.env.NODE_ENV;
delete process.env.BETTER_AUTH_SECRET;
process.env.API_BASE_URL = 'https://api.nachklang.art';
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
await expect(loadConfig()).rejects.toThrow(/BETTER_AUTH_SECRET/);
});
});
+71
View File
@@ -0,0 +1,71 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/common/common.mail.js', () => ({
MailService: {sendMail: vi.fn()}
}));
vi.mock('../../src/models/admin/admin.config.js', () => ({
ADMIN_APP_URL: 'https://admin.nachklang.art'
}));
import {MailService} from '../../src/common/common.mail.js';
import {sendInvitationMail, sendPasswordResetMail} from '../../src/models/admin/admin.mail.js';
const sendMail = MailService.sendMail as Mock;
beforeEach(() => {
sendMail.mockReset();
sendMail.mockResolvedValue(true);
});
describe('sendInvitationMail', () => {
it('points at the admin app and carries the token in the query string', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok-en_123', new Date('2026-09-12T10:00:00Z'));
const [to, subject, text, options] = sendMail.mock.calls[0];
expect(to).toBe('a@nachklang.art');
expect(subject).toBeTruthy();
expect(text).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
expect(options.html).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
});
it('url-encodes a token containing url-significant characters', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'a+b/c=d', new Date());
const [, , text] = sendMail.mock.calls[0];
expect(text).toContain('token=a%2Bb%2Fc%3Dd');
});
it('sends both a text and an html part', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date());
const [, , text, options] = sendMail.mock.calls[0];
expect(text.length).toBeGreaterThan(0);
expect(options.html).toContain('<html');
});
it('escapes a name that contains html', async () => {
await sendInvitationMail('a@nachklang.art', '<script>alert(1)</script>', 'tok', new Date());
const [, , , options] = sendMail.mock.calls[0];
expect(options.html).not.toContain('<script>');
expect(options.html).toContain('&lt;script&gt;');
});
it('reports a delivery failure to the caller rather than throwing', async () => {
sendMail.mockResolvedValue(false);
await expect(sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date())).resolves.toBe(false);
});
});
describe('sendPasswordResetMail', () => {
it('uses the url better-auth generated, unchanged', async () => {
const url = 'https://api.nachklang.art/admin/auth/reset-password/abc?callbackURL=x';
await sendPasswordResetMail('a@nachklang.art', 'Anna', url);
const [, , text, options] = sendMail.mock.calls[0];
expect(text).toContain(url);
expect(options.html).toContain('https://api.nachklang.art/admin/auth/reset-password/abc');
});
});
+222
View File
@@ -0,0 +1,222 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import {Request, Response} from 'express';
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models/admin/admin.middleware.js';
const mockGetSession = auth.api.getSession as unknown as Mock;
const mockLoadAccess = UsersService.loadAccess as Mock;
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeRes = (): Response => {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.locals = {};
return res as Response;
};
const activeUser = {
id: 'u1',
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
permissions: [
{app: 'feedback', role: 'access'},
{app: 'admin', role: 'access'}
],
apps: ['feedback', 'admin']
};
describe('resolveAccess', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('returns null without a valid session', async () => {
mockGetSession.mockResolvedValue(null);
expect(await resolveAccess(makeReq())).toBeNull();
expect(mockLoadAccess).not.toHaveBeenCalled();
});
it('returns null when the session points at a user row that is gone', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(null);
expect(await resolveAccess(makeReq())).toBeNull();
});
it('resolves identity and permissions in a single permission query', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
expect(await resolveAccess(makeReq())).toEqual({
id: 'u1',
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
permissions: [
{app: 'feedback', role: 'access'},
{app: 'admin', role: 'access'}
],
apps: ['feedback', 'admin']
});
// No cookieCache: exactly one lookup per request, never zero.
expect(mockLoadAccess).toHaveBeenCalledTimes(1);
});
});
describe('requireSignedIn', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null);
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('403s a disabled user that still holds a valid cookie', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('admits a signed-in user with no app permissions at all', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, apps: []});
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin.apps).toEqual([]);
});
});
describe('requireAppAccess', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(401);
});
it('403s a signed-in user without that app permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'feedback', role: 'access'}],
apps: ['feedback']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('403s a disabled user even when they hold the permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('passes through and exposes the identity the feedback/tickets services expect', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'A'});
});
it('500s (never allows through) when the permission query throws', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockRejectedValue(new Error('db down'));
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(next).not.toHaveBeenCalled();
});
// The seam a finer per-app permission arrives through. Nothing passes a role
// today, so these two pin the behaviour before there is anything to break.
it('403s when a specific role is required and the user only holds another', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'tickets', role: 'access'}],
apps: ['tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('passes when the user holds exactly the required role', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [
{app: 'tickets', role: 'access'},
{app: 'tickets', role: 'refund'}
],
apps: ['tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
expect(next).toHaveBeenCalled();
});
});
+99
View File
@@ -0,0 +1,99 @@
import {describe, expect, it} from 'vitest';
import {
ACCESS_ROLE,
appsOf,
isAppPermission,
isAppRole,
toPermissions
} from '../../src/models/admin/admin.schema.js';
/**
* The permission model is (app, role). These tests pin the two properties the
* rest of the module leans on: that the older `['tickets']` shape still means
* "tickets at the access role", and that nothing outside APP_ROLES gets in.
*/
describe('toPermissions', () => {
it('reads the full (app, role) form', () => {
expect(toPermissions([{app: 'tickets', role: 'access'}])).toEqual([
{app: 'tickets', role: 'access'}
]);
});
it('reads a plain app list as that app at the access role', () => {
expect(toPermissions(['feedback', 'admin'])).toEqual([
{app: 'feedback', role: ACCESS_ROLE},
{app: 'admin', role: ACCESS_ROLE}
]);
});
it('accepts the two forms mixed, which is what a half-migrated caller sends', () => {
expect(toPermissions(['feedback', {app: 'tickets', role: 'access'}])).toEqual([
{app: 'feedback', role: ACCESS_ROLE},
{app: 'tickets', role: ACCESS_ROLE}
]);
});
it('drops duplicates of the same (app, role)', () => {
expect(toPermissions(['tickets', {app: 'tickets', role: 'access'}])).toEqual([
{app: 'tickets', role: ACCESS_ROLE}
]);
});
it('rejects rather than silently dropping an unknown app', () => {
// Silently ignoring it would let "grant calendar + nonsense" look like a
// success while granting less than the caller asked for.
expect(toPermissions(['calendar', 'nonsense'])).toBeNull();
});
it('rejects an unknown role', () => {
expect(toPermissions([{app: 'tickets', role: 'refund'}])).toBeNull();
});
it('rejects anything that is not a list', () => {
expect(toPermissions('admin')).toBeNull();
expect(toPermissions(null)).toBeNull();
expect(toPermissions({app: 'admin', role: 'access'})).toBeNull();
});
it('reads an empty list as "no permissions", not as invalid', () => {
expect(toPermissions([])).toEqual([]);
});
});
describe('isAppRole', () => {
it('accepts the access role for every app', () => {
expect(isAppRole('admin', ACCESS_ROLE)).toBe(true);
expect(isAppRole('calendar', ACCESS_ROLE)).toBe(true);
});
it('rejects a role that does not exist yet', () => {
expect(isAppRole('tickets', 'refund')).toBe(false);
});
});
describe('isAppPermission', () => {
it('needs both halves to be valid', () => {
expect(isAppPermission({app: 'tickets', role: ACCESS_ROLE})).toBe(true);
expect(isAppPermission({app: 'tickets'})).toBe(false);
expect(isAppPermission({role: ACCESS_ROLE})).toBe(false);
expect(isAppPermission(null)).toBe(false);
});
});
describe('appsOf', () => {
it('collapses several roles on one app to a single entry', () => {
// The point of the derived list: a user with two roles on tickets has
// access to tickets once, not twice.
const apps = appsOf([
{app: 'tickets', role: ACCESS_ROLE},
{app: 'tickets', role: 'future-role'},
{app: 'admin', role: ACCESS_ROLE}
]);
expect(apps).toEqual(['tickets', 'admin']);
});
it('is empty for no permissions', () => {
expect(appsOf([])).toEqual([]);
});
});
+199
View File
@@ -0,0 +1,199 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import express from 'express';
import request from 'supertest';
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
listUsers: vi.fn(),
getUserDetail: vi.fn(),
loadAccess: vi.fn(),
setPermissions: vi.fn(),
setPermissionsGuarded: vi.fn(),
disableUser: vi.fn(),
disableUserGuarded: vi.fn(),
enableUser: vi.fn(),
revokeSession: vi.fn(),
countActiveAdmins: vi.fn(),
userExists: vi.fn()
}));
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {usersAdminRouter} from '../../src/models/admin/users/users.admin.router.js';
const service = UsersService as unknown as Record<string, Mock>;
// The router always runs behind requireAppAccess('admin'), which is what puts
// res.locals.admin there; this stands in for it.
const makeApp = (callerId = 'me') => {
const app = express();
app.use(express.json());
app.use((req, res, next) => {
res.locals.admin = {id: callerId, email: 'me@nachklang.art', displayName: 'Me', apps: ['admin']};
next();
});
app.use('/admin/users', usersAdminRouter);
return app;
};
beforeEach(() => {
for (const fn of Object.values(service)) {
if (typeof fn?.mockReset === 'function') {
fn.mockReset();
}
}
service.getUserDetail.mockResolvedValue({id: 'other', apps: []});
service.userExists.mockResolvedValue(true);
service.setPermissionsGuarded.mockResolvedValue('ok');
service.disableUserGuarded.mockResolvedValue('ok');
});
describe('PUT /admin/users/:id/permissions', () => {
it('rejects an unknown app name', async () => {
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: ['calendar', 'nope']});
expect(res.status).toBe(400);
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
});
it('rejects a non-array body', async () => {
const res = await request(makeApp()).put('/admin/users/other/permissions').send({apps: 'admin'});
expect(res.status).toBe(400);
});
it('404s for an unknown user', async () => {
service.userExists.mockResolvedValue(false);
const res = await request(makeApp()).put('/admin/users/ghost/permissions').send({apps: []});
expect(res.status).toBe(404);
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
});
it('refuses to remove the caller\'s own admin permission', async () => {
service.loadAccess.mockResolvedValue({id: 'me', disabled: false, apps: ['admin']});
service.countActiveAdmins.mockResolvedValue(5);
const res = await request(makeApp('me')).put('/admin/users/me/permissions').send({apps: ['feedback']});
expect(res.status).toBe(409);
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
});
// The last-admin decision is made inside the write transaction (so two
// admins acting at once cannot both pass a check-then-act); the router's
// job is only to turn that verdict into a 409.
it('answers 409 when the service reports the last admin would be removed', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.setPermissionsGuarded.mockResolvedValue('last-admin');
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: []});
expect(res.status).toBe(409);
});
it('allows removing an admin while another active admin remains', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['tickets']});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'tickets', role: 'access'}],
'me'
);
});
it('accepts the richer {permissions} body', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me'))
.put('/admin/users/other/permissions')
.send({permissions: [{app: 'tickets', role: 'access'}]});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'tickets', role: 'access'}],
'me'
);
});
it('rejects a role that does not exist', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me'))
.put('/admin/users/other/permissions')
.send({permissions: [{app: 'tickets', role: 'refund'}]});
expect(res.status).toBe(400);
expect(service.setPermissionsGuarded).not.toHaveBeenCalled();
});
it('allows granting permissions to someone who has none', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: []});
const res = await request(makeApp('me')).put('/admin/users/other/permissions').send({apps: ['feedback', 'tickets']});
expect(res.status).toBe(200);
expect(service.setPermissionsGuarded).toHaveBeenCalledWith(
'other',
[{app: 'feedback', role: 'access'}, {app: 'tickets', role: 'access'}],
'me'
);
});
});
describe('POST /admin/users/:id/disable', () => {
it('refuses to disable the caller', async () => {
const res = await request(makeApp('me')).post('/admin/users/me/disable');
expect(res.status).toBe(409);
expect(service.disableUserGuarded).not.toHaveBeenCalled();
});
it('answers 409 when the service reports the last active admin would be disabled', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['admin']});
service.disableUserGuarded.mockResolvedValue('last-admin');
const res = await request(makeApp('me')).post('/admin/users/other/disable');
expect(res.status).toBe(409);
});
it('disables a non-admin user', async () => {
service.loadAccess.mockResolvedValue({id: 'other', disabled: false, apps: ['feedback']});
const res = await request(makeApp('me')).post('/admin/users/other/disable');
expect(res.status).toBe(200);
expect(service.disableUserGuarded).toHaveBeenCalledWith('other');
});
it('404s for an unknown user', async () => {
service.loadAccess.mockResolvedValue(null);
const res = await request(makeApp('me')).post('/admin/users/ghost/disable');
expect(res.status).toBe(404);
});
});
describe('DELETE /admin/users/:id/sessions/:sid', () => {
it('404s when the session does not belong to that user', async () => {
service.revokeSession.mockResolvedValue(false);
const res = await request(makeApp()).delete('/admin/users/other/sessions/s1');
expect(res.status).toBe(404);
});
it('204s on a successful revoke', async () => {
service.revokeSession.mockResolvedValue(true);
const res = await request(makeApp()).delete('/admin/users/other/sessions/s1');
expect(res.status).toBe(204);
expect(service.revokeSession).toHaveBeenCalledWith('other', 's1');
});
});
+273
View File
@@ -0,0 +1,273 @@
import {describe, it, expect, beforeAll, beforeEach, afterAll} from 'vitest';
import request from 'supertest';
import type {Application} from 'express';
import {createApp} from '../../src/app.factory.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {
closeDatabase,
createAndAcceptInvitation,
resetDatabase,
sessionCookieFrom,
SESSION_COOKIE,
accessTo
} from './helpers.js';
/**
* End-to-end against a real MariaDB (docker-compose.test.yml) and the real
* Express wiring from app.factory.ts. Mocks would not catch what this module
* can actually get wrong: the Kysely MySQL dialect, cookie attributes, and the
* middleware order that lets better-auth read the raw request body.
*/
let app: Application;
beforeAll(() => {
app = createApp();
});
beforeEach(async () => {
await resetDatabase();
});
afterAll(async () => {
await closeDatabase();
});
describe('sign-up is closed', () => {
it('refuses the public sign-up endpoint', async () => {
const res = await request(app)
.post('/admin/auth/sign-up/email')
.send({email: 'stranger@example.com', password: 'password123', name: 'Stranger'});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(await UsersService.findUserByEmail('stranger@example.com')).toBeNull();
});
});
describe('invitation acceptance', () => {
it('creates the user, its permissions and a session cookie', async () => {
const {agent, userId} = await createAndAcceptInvitation(
app,
'anna@nachklang.art',
'Anna',
['feedback', 'tickets']
);
const access = await UsersService.loadAccess(userId);
expect(access?.email).toBe('anna@nachklang.art');
expect(access?.apps.sort()).toEqual(['feedback', 'tickets']);
expect(access?.disabled).toBe(false);
// The cookie works on a subsequent request.
const me = await agent.get('/admin/me');
expect(me.status).toBe(200);
expect(me.body.email).toBe('anna@nachklang.art');
expect(me.body.apps.sort()).toEqual(['feedback', 'tickets']);
});
it('sets the session cookie under the configured prefix', async () => {
const invitation = await InvitationsService.createInvitation('b@nachklang.art', 'B', accessTo('feedback'), null);
const res = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
const cookie = sessionCookieFrom(res);
expect(cookie).toBeDefined();
expect(cookie).toContain('HttpOnly');
});
it('lets the new account sign in with the password it just set', async () => {
await createAndAcceptInvitation(app, 'c@nachklang.art', 'C', ['feedback'], 'my-password-1');
const res = await request(app)
.post('/admin/auth/sign-in/email')
.send({email: 'c@nachklang.art', password: 'my-password-1'});
expect(res.status).toBe(200);
expect(sessionCookieFrom(res)).toBeDefined();
});
it('previews an invitation without revealing the granted apps', async () => {
const invitation = await InvitationsService.createInvitation('d@nachklang.art', 'D', accessTo('admin'), null);
const res = await request(app)
.post('/admin/auth/invitations/preview')
.send({token: invitation.token});
expect(res.status).toBe(200);
expect(res.body).toEqual({email: 'd@nachklang.art', name: 'D'});
});
it('answers an unknown token exactly like an expired one', async () => {
const invitation = await InvitationsService.createInvitation('e@nachklang.art', 'E', accessTo('feedback'), null);
await InvitationsService.revokeInvitation(invitation.id);
const unknown = await request(app).post('/admin/auth/invitations/preview').send({token: 'no-such-token'});
const revoked = await request(app).post('/admin/auth/invitations/preview').send({token: invitation.token});
expect(unknown.status).toBe(revoked.status);
expect(unknown.body).toEqual(revoked.body);
});
it('cannot be redeemed twice', async () => {
const invitation = await InvitationsService.createInvitation('f@nachklang.art', 'F', accessTo('feedback'), null);
const first = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
const second = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
expect(first.status).toBe(200);
expect(second.status).toBeGreaterThanOrEqual(400);
});
it('rejects an expired invitation', async () => {
const invitation = await InvitationsService.createInvitation('g@nachklang.art', 'G', accessTo('feedback'), null);
// Reach past the service to age it: there is deliberately no API for this.
const {NachklangAdminDB} = await import('../../src/models/admin/Admin.db.js');
await NachklangAdminDB.db
.updateTable('invitations')
.set({expires_at: new Date(Date.now() - 1000)})
.where('id', '=', invitation.id)
.execute();
const res = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('rejects a password below the minimum length', async () => {
const invitation = await InvitationsService.createInvitation('h@nachklang.art', 'H', accessTo('feedback'), null);
const res = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'short'});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(await UsersService.findUserByEmail('h@nachklang.art')).toBeNull();
});
});
describe('sessions', () => {
it('signs out and stops accepting the cookie', async () => {
const {agent} = await createAndAcceptInvitation(app, 'i@nachklang.art', 'I', ['feedback']);
expect((await agent.get('/admin/me')).status).toBe(200);
const signOut = await agent.post('/admin/auth/sign-out').send({});
expect(signOut.status).toBe(200);
expect((await agent.get('/admin/me')).status).toBe(401);
});
it('rejects a disabled user who still holds a valid cookie', async () => {
const {agent, userId} = await createAndAcceptInvitation(app, 'j@nachklang.art', 'J', ['feedback']);
// Strip the permission check out of the picture: disable without going
// through disableUser's session revocation, so the cookie stays live.
const {NachklangAdminDB} = await import('../../src/models/admin/Admin.db.js');
await NachklangAdminDB.db.updateTable('user').set({disabled: true}).where('id', '=', userId).execute();
const res = await agent.get('/admin/me');
expect(res.status).toBe(403);
});
it('disabling a user revokes their sessions immediately', async () => {
const {agent, userId} = await createAndAcceptInvitation(app, 'k@nachklang.art', 'K', ['feedback']);
await UsersService.disableUser(userId);
const res = await agent.get('/admin/me');
expect(res.status).toBe(401);
});
it('refuses to sign a disabled user back in', async () => {
const {userId} = await createAndAcceptInvitation(app, 'l@nachklang.art', 'L', ['feedback'], 'my-password-1');
await UsersService.disableUser(userId);
const res = await request(app)
.post('/admin/auth/sign-in/email')
.send({email: 'l@nachklang.art', password: 'my-password-1'});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(sessionCookieFrom(res)).toBeUndefined();
});
});
describe('requireAppAccess', () => {
it('401s an anonymous request', async () => {
expect((await request(app).get('/admin/me')).status).toBe(401);
expect((await request(app).get('/admin/users')).status).toBe(401);
});
it('403s a signed-in user without the admin permission', async () => {
const {agent} = await createAndAcceptInvitation(app, 'm@nachklang.art', 'M', ['feedback']);
const res = await agent.get('/admin/users');
expect(res.status).toBe(403);
});
it('lets an admin through', async () => {
const {agent} = await createAndAcceptInvitation(app, 'n@nachklang.art', 'N', ['admin']);
const res = await agent.get('/admin/users');
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
// Step 2 deliberately does NOT swap the feedback and tickets authenticators:
// they still authenticate against the legacy calendar sessions, so an admin
// cookie means nothing to them yet. This asserts that boundary rather than
// the end state - when step 4 lands, these two expectations become 200/403
// and this comment goes away.
it('leaves the feedback and tickets admin areas on their legacy authenticator', async () => {
const user = await createAndAcceptInvitation(app, 'o@nachklang.art', 'O', ['feedback', 'tickets']);
expect((await user.agent.get('/feedback/admin/me')).status).toBe(401);
expect((await user.agent.get('/tickets/admin/me')).status).toBe(401);
});
});
describe('origin checks', () => {
it('rejects a cookie-bearing request from an untrusted origin', async () => {
const {agent} = await createAndAcceptInvitation(app, 'p@nachklang.art', 'P', ['admin']);
const res = await agent
.post('/admin/auth/sign-out')
.set('Origin', 'https://evil.example')
.send({});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('accepts the admin app origin', async () => {
const {agent} = await createAndAcceptInvitation(app, 'q@nachklang.art', 'Q', ['admin']);
const res = await agent
.post('/admin/auth/sign-out')
.set('Origin', 'http://localhost:3002')
.send({});
expect(res.status).toBe(200);
});
});
describe('the session cookie is not readable by scripts', () => {
it('is HttpOnly and SameSite=Lax', async () => {
const invitation = await InvitationsService.createInvitation('r@nachklang.art', 'R', accessTo('feedback'), null);
const res = await request(app)
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password: 'devpassword123'});
const cookie = sessionCookieFrom(res) || '';
expect(cookie).toContain(SESSION_COOKIE);
expect(cookie).toContain('HttpOnly');
expect(cookie.toLowerCase()).toContain('samesite=lax');
});
});
+255
View File
@@ -0,0 +1,255 @@
import {describe, it, expect, beforeAll, beforeEach, afterAll} from 'vitest';
import request from 'supertest';
import type {Application} from 'express';
import {createApp} from '../../src/app.factory.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {bootstrapAdmin} from '../../src/models/admin/admin.bootstrap.js';
import {accessTo, closeDatabase, createAndAcceptInvitation, resetDatabase} from './helpers.js';
let app: Application;
beforeAll(() => {
app = createApp();
});
beforeEach(async () => {
await resetDatabase();
});
afterAll(async () => {
await closeDatabase();
});
/** Most tests here need somebody who may administer. */
const signedInAdmin = async (email = 'admin@nachklang.art') => {
return createAndAcceptInvitation(app, email, 'Admin', ['admin']);
};
describe('GET /admin/users', () => {
it('lists users with their permissions and derived status', async () => {
const {agent} = await signedInAdmin();
await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent.get('/admin/users');
expect(res.status).toBe(200);
const listed = res.body.find((u: any) => u.email === 'user@nachklang.art');
expect(listed.apps).toEqual(['feedback']);
expect(listed.status).toBe('aktiv');
expect(listed.lastSignInAt).not.toBeNull();
});
it('shows a disabled user as deaktiviert', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
await UsersService.disableUser(other.userId);
const res = await agent.get('/admin/users');
const listed = res.body.find((u: any) => u.email === 'user@nachklang.art');
expect(listed.status).toBe('deaktiviert');
});
});
describe('GET /admin/users/:id', () => {
it('returns active sessions and the passkey count', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent.get(`/admin/users/${other.userId}`);
expect(res.status).toBe(200);
expect(res.body.sessions.length).toBe(1);
expect(res.body.passkeyCount).toBe(0);
});
it('404s for an unknown id', async () => {
const {agent} = await signedInAdmin();
expect((await agent.get('/admin/users/does-not-exist')).status).toBe(404);
});
});
describe('permission changes', () => {
it('replaces the permission set', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent
.put(`/admin/users/${other.userId}/permissions`)
.send({apps: ['tickets', 'calendar']});
expect(res.status).toBe(200);
const access = await UsersService.loadAccess(other.userId);
expect(access?.apps.sort()).toEqual(['calendar', 'tickets']);
});
it('takes effect on the next request the affected user makes', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['admin']);
expect((await other.agent.get('/admin/users')).status).toBe(200);
await agent.put(`/admin/users/${other.userId}/permissions`).send({apps: ['feedback']});
// No cookie cache: the very next request is already denied, on the same
// still-valid session cookie.
expect((await other.agent.get('/admin/users')).status).toBe(403);
});
it('refuses to strip the last admin', async () => {
const {agent, userId} = await signedInAdmin();
const res = await agent.put(`/admin/users/${userId}/permissions`).send({apps: ['feedback']});
expect(res.status).toBe(409);
expect((await UsersService.loadAccess(userId))?.apps).toContain('admin');
});
it('refuses to disable the caller themselves', async () => {
const {agent, userId} = await signedInAdmin();
const res = await agent.post(`/admin/users/${userId}/disable`);
expect(res.status).toBe(409);
expect((await UsersService.loadAccess(userId))?.disabled).toBe(false);
});
it('allows disabling a second admin', async () => {
const {agent} = await signedInAdmin();
const second = await createAndAcceptInvitation(app, 'admin2@nachklang.art', 'Admin2', ['admin']);
expect((await agent.post(`/admin/users/${second.userId}/disable`)).status).toBe(200);
expect((await second.agent.get('/admin/me')).status).toBe(401);
});
it('re-enables a disabled user without restoring their old sessions', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
await agent.post(`/admin/users/${other.userId}/disable`);
expect((await agent.post(`/admin/users/${other.userId}/enable`)).status).toBe(200);
expect((await UsersService.loadAccess(other.userId))?.disabled).toBe(false);
// The revoked session stays revoked; they sign in again.
expect((await other.agent.get('/admin/me')).status).toBe(401);
});
});
describe('session revocation', () => {
it('revokes one session of another user', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const detail = await agent.get(`/admin/users/${other.userId}`);
const sessionId = detail.body.sessions[0].id;
const res = await agent.delete(`/admin/users/${other.userId}/sessions/${sessionId}`);
expect(res.status).toBe(204);
expect((await other.agent.get('/admin/me')).status).toBe(401);
});
});
describe('invitations', () => {
it('creates one and lists it as open', async () => {
const {agent} = await signedInAdmin();
const created = await agent
.post('/admin/invitations')
.send({email: 'new@nachklang.art', name: 'New', apps: ['feedback']});
expect(created.status).toBe(201);
// Mail is disabled in tests, and the token must never be returned.
expect(created.body.token).toBeUndefined();
const list = await agent.get('/admin/invitations');
expect(list.body.map((i: any) => i.email)).toContain('new@nachklang.art');
});
it('refuses to invite an address that already has an account', async () => {
const {agent} = await signedInAdmin();
await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent
.post('/admin/invitations')
.send({email: 'user@nachklang.art', name: 'User', apps: ['feedback']});
expect(res.status).toBe(409);
});
it('rejects an invalid email or app name', async () => {
const {agent} = await signedInAdmin();
expect((await agent.post('/admin/invitations').send({email: 'nope', name: 'X', apps: []})).status).toBe(400);
expect((await agent.post('/admin/invitations').send({email: 'a@b.de', name: 'X', apps: ['nope']})).status).toBe(400);
});
it('invalidates the previous link on resend', async () => {
const {agent} = await signedInAdmin();
const original = await InvitationsService.createInvitation('new@nachklang.art', 'New', accessTo('feedback'), null);
const resent = await agent.post(`/admin/invitations/${original.id}/resend`);
expect(resent.status).toBe(200);
const oldLink = await request(app)
.post('/admin/auth/invitations/preview')
.send({token: original.token});
expect(oldLink.status).toBeGreaterThanOrEqual(400);
});
it('revokes an invitation', async () => {
const {agent} = await signedInAdmin();
const invitation = await InvitationsService.createInvitation('new@nachklang.art', 'New', accessTo('feedback'), null);
expect((await agent.delete(`/admin/invitations/${invitation.id}`)).status).toBe(204);
expect((await agent.delete(`/admin/invitations/${invitation.id}`)).status).toBe(404);
const preview = await request(app)
.post('/admin/auth/invitations/preview')
.send({token: invitation.token});
expect(preview.status).toBeGreaterThanOrEqual(400);
});
});
describe('bootstrap', () => {
it('creates an admin invitation on an empty database', async () => {
await bootstrapAdmin();
expect(await InvitationsService.hasOpenInvitationFor('boot@nachklang.art')).toBe(true);
});
it('is idempotent across restarts', async () => {
await bootstrapAdmin();
await bootstrapAdmin();
const open = await InvitationsService.listOpenInvitations();
expect(open.filter(i => i.email === 'boot@nachklang.art').length).toBe(1);
});
it('grants admin to an address that already has an account', async () => {
const user = await createAndAcceptInvitation(app, 'boot@nachklang.art', 'Boot', ['feedback']);
await bootstrapAdmin();
expect((await UsersService.loadAccess(user.userId))?.apps).toContain('admin');
});
it('does nothing once an active admin exists', async () => {
await signedInAdmin();
await bootstrapAdmin();
expect(await InvitationsService.hasOpenInvitationFor('boot@nachklang.art')).toBe(false);
});
});
describe('passkey endpoints', () => {
it('requires a session to list passkeys', async () => {
const anonymous = await request(app).get('/admin/auth/passkey/list-user-passkeys');
expect(anonymous.status).toBeGreaterThanOrEqual(400);
const {agent} = await signedInAdmin();
const res = await agent.get('/admin/auth/passkey/list-user-passkeys');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
+77
View File
@@ -0,0 +1,77 @@
import {expect} from 'vitest';
import type {Application} from 'express';
import request from 'supertest';
import {NachklangAdminDB} from '../../src/models/admin/Admin.db.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import {ACCESS_ROLE, AppName, AppPermission, toPermissions} from '../../src/models/admin/admin.schema.js';
const db = NachklangAdminDB.db;
// Dev/test cookie name: advanced.cookiePrefix is 'nachklang', and the __Secure-
// prefix is only added over https.
export const SESSION_COOKIE = 'nachklang.session_token';
/**
* Wipes every table between test files. Child tables first - the FKs to `user`
* are ON DELETE CASCADE, but the rest are not.
*
* `rateLimit` matters more than it looks: the limiter is enabled during the
* suite, and better-auth caps /sign-in/* at 3 requests per 10 seconds. All
* tests resolve to the same client IP, so they share one bucket - without this
* reset the suite would start failing with 429s that look like auth bugs as
* soon as a third sign-in assertion is added.
*/
export const resetDatabase = async (): Promise<void> => {
await db.deleteFrom('session').execute();
await db.deleteFrom('user_app_permissions').execute();
await db.deleteFrom('passkey').execute();
await db.deleteFrom('invitations').execute();
await db.deleteFrom('verification').execute();
await db.deleteFrom('rateLimit').execute();
await db.deleteFrom('user').execute();
};
export const closeDatabase = async (): Promise<void> => {
await db.destroy();
};
/**
* Creates an invitation straight through the service (so the test gets the raw
* token, which the API deliberately never returns) and redeems it through the
* public endpoint. Returns an agent that carries the resulting session cookie.
*/
export const createAndAcceptInvitation = async (
app: Application,
email: string,
name: string,
// Takes the shorthand as well as the full form: most tests only care that
// someone can open an app, and `['tickets']` says that with less noise.
grants: (AppName | AppPermission)[],
password = 'devpassword123'
) => {
const permissions = toPermissions(grants) ?? [];
const invitation = await InvitationsService.createInvitation(email, name, permissions, null);
const agent = request.agent(app);
const res = await agent
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password});
expect(res.status).toBe(200);
return {agent, userId: res.body.user.id, token: invitation.token};
};
export const cookieHeader = (res: request.Response): string[] => {
const raw = res.headers['set-cookie'];
return Array.isArray(raw) ? raw : raw ? [raw] : [];
};
export const sessionCookieFrom = (res: request.Response): string | undefined => {
return cookieHeader(res).find(cookie => cookie.startsWith(SESSION_COOKIE));
};
/** `accessTo('feedback')` reads better than the (app, role) literal in tests
* that only care that someone can open an app. */
export const accessTo = (...apps: AppName[]): AppPermission[] => {
return apps.map(app => ({app, role: ACCESS_ROLE}));
};
+107
View File
@@ -0,0 +1,107 @@
import {execFile} from 'child_process';
import {promisify} from 'util';
import {createRequire} from 'module';
const run = promisify(execFile);
const require = createRequire(import.meta.url);
/**
* vitest globalSetup for the admin integration tests: starts a throwaway
* MariaDB before the suite and removes it afterwards, so a run leaves nothing
* behind and never touches a shared database.
*
* The container is started directly rather than through compose, because
* `podman compose` needs a separate compose provider that neither podman nor
* docker ships. One container needs no orchestration, and this works with
* whichever of the two runtimes is installed.
*/
export const CONTAINER_NAME = 'nachklang-admin-test-db';
export const TEST_DB_PORT = 3307;
const IMAGE = 'docker.io/library/mariadb:11';
const runtime = async (): Promise<string> => {
for (const candidate of ['docker', 'podman']) {
try {
await run(candidate, ['info'], {timeout: 60_000});
return candidate;
} catch {
// Not installed, or its daemon/machine is not running - try the next.
}
}
throw new Error(
'The admin integration tests need a container runtime. Install docker or podman ' +
'(with podman: `podman machine start`), then re-run npm run test:integration.'
);
};
/**
* Ready means "the entrypoint has applied 001_init.sql", not just "the port
* answers": MariaDB accepts connections while it is still running its init
* scripts, and a test that started then would fail on a missing table.
*/
const waitForSchema = async (): Promise<void> => {
const mysql = require('mysql2/promise');
const deadline = Date.now() + 120_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const connection = await mysql.createConnection({
host: '127.0.0.1',
port: TEST_DB_PORT,
user: 'nachklang',
password: 'testpassword',
database: 'nachklang_admin',
connectTimeout: 5_000
});
const [rows] = await connection.query(
"SELECT COUNT(*) AS n FROM information_schema.tables " +
"WHERE table_schema = 'nachklang_admin' AND table_name IN ('user', 'user_app_permissions', 'invitations')"
);
await connection.end();
if (Number((rows as any[])[0]?.n) === 3) {
return;
}
lastError = new Error('schema not applied yet');
} catch (e) {
lastError = e;
}
await new Promise(resolve => setTimeout(resolve, 1_000));
}
throw new Error(`Test database never became ready: ${(lastError as any)?.message}`);
};
export const setup = async () => {
const engine = await runtime();
// A container left behind by an interrupted run would still hold the old
// schema and rows, so always start from scratch.
await run(engine, ['rm', '-f', CONTAINER_NAME], {timeout: 60_000}).catch(() => undefined);
await run(engine, [
'run', '-d',
'--name', CONTAINER_NAME,
'-e', 'MARIADB_ROOT_PASSWORD=roottestpassword',
'-e', 'MARIADB_DATABASE=nachklang_admin',
'-e', 'MARIADB_USER=nachklang',
'-e', 'MARIADB_PASSWORD=testpassword',
'-p', `${TEST_DB_PORT}:3306`,
// The very migration production runs, applied by the entrypoint on first
// boot - so a mistake in it fails the test run rather than the deploy.
'-v', `${process.cwd()}/sql/admin/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro`,
// Data lives in the container layer and dies with it.
IMAGE
], {timeout: 300_000});
await waitForSchema();
};
export const teardown = async () => {
const engine = await runtime().catch(() => null);
if (engine) {
await run(engine, ['rm', '-f', CONTAINER_NAME], {timeout: 60_000}).catch(() => undefined);
}
};
+3
View File
@@ -3,6 +3,9 @@ import {defineConfig} from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
// The integration suite needs a Docker MariaDB and runs separately via
// npm run test:integration (vitest.integration.config.ts).
exclude: ['test/integration/**'],
environment: 'node',
// feedback.ratelimit throws at import time without a salt (see
// test/feedback/ratelimit.salt-guard.test.ts). Set one here so the suite
+38
View File
@@ -0,0 +1,38 @@
import {defineConfig} from 'vitest/config';
/**
* The admin module's integration tests. Separate from vitest.config.ts because
* these need Docker: they run against a real MariaDB (see
* docker-compose.test.yml) rather than mocks, which is the only way to catch
* the Kysely/MariaDB dialect and cookie-attribute problems this module can have.
*
* Run with: npm run test:integration
*/
export default defineConfig({
test: {
include: ['test/integration/**/*.test.ts'],
environment: 'node',
globalSetup: ['test/integration/setup.ts'],
// One database, shared state: parallel files would fight over the same
// user and invitation rows.
fileParallelism: false,
testTimeout: 30_000,
hookTimeout: 180_000,
env: {
NODE_ENV: 'test',
FEEDBACK_IP_SALT: 'vitest-salt',
DB_HOST: '127.0.0.1',
DB_PORT: '3307',
DB_USER: 'nachklang',
DB_PASSWORD: 'testpassword',
ADMIN_DB: 'nachklang_admin',
API_BASE_URL: 'http://localhost:3000',
ADMIN_APP_URL: 'http://localhost:3002',
APP_ORIGINS: 'http://localhost:3001',
BETTER_AUTH_SECRET: 'integration-test-secret-not-used-anywhere-else',
PASSKEY_RP_ID: 'localhost',
ADMIN_BOOTSTRAP_EMAIL: 'boot@nachklang.art',
SALESFORCE_ENABLED: 'false'
}
}
});