Add admin identity module: better-auth, per-app permissions, invitations #12
@@ -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,35 @@ 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.
|
||||
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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
@@ -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`.
|
||||
Generated
+1863
-215
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,146 @@
|
||||
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';
|
||||
|
||||
// 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.
|
||||
app.all('/admin/auth/*', toNodeHandler(auth));
|
||||
|
||||
// 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;
|
||||
};
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
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.
|
||||
*/
|
||||
export const CLIENT_IP_HEADERS = parseList(process.env.CLIENT_IP_HEADERS, ['x-real-ip']);
|
||||
|
||||
export const TRUSTED_PROXY_IPS = parseList(process.env.TRUSTED_PROXY_IPS, []);
|
||||
|
||||
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.'
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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 {};
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
};
|
||||
|
||||
// `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});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('<script>');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}));
|
||||
};
|
||||
@@ -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,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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user