2 Commits

Author SHA1 Message Date
Paddy 1de0cc6940 Fix two security/data-integrity gaps found in code review
- Voucher generation (wildcard + personalized) now validates every
  eventId against the event_ticket_settings allow-list before minting
  codes. Previously a direct API call bypassing the admin picker could
  mint a fully-uncapped, no-deadline redeemable code for any Calendar
  event, including non-concert ones.
- Redemption now checks the Calendar event isn't DELETED (validateVoucher
  and redeemVoucher both). Previously a concert canceled/deleted after
  codes were issued stayed silently redeemable. DRAFT events remain
  eligible on purpose - vouchers are sometimes sent before a concert is
  publicly announced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:42:24 +02:00
Paddy b6499eb7b3 Add Tickets domain for the voucher-based ticket shop
New domain mirroring the Feedback module's conventions: single-use voucher
codes (wildcard + personalized), redemption with race-safe capacity and
deadline enforcement, admin CRUD for vouchers/redemptions/event settings
with an audit trail, and reuse of the existing Calendar session auth.

Backend pieces:
- Tickets domain (public redeem flow, admin vouchers/redemptions/events)
- Calendar events.service.ts: add getEventById
- Mailer: attachment/HTML support, fix a shared-mutable-state race
- Per-event capacity/deadline/address settings, with an "address
  required" option on top of "address collected"
- Email format validation on redeem and admin voucher/redemption input
- Docker Compose dev stack (MariaDB + seed data) for local testing

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:47:50 +02:00
118 changed files with 8284 additions and 11322 deletions
-43
View File
@@ -1,11 +1,3 @@
# 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=
@@ -31,41 +23,6 @@ TICKETS_DB=
TICKETS_RATE_LIMIT_MAX=10
TICKETS_RATE_LIMIT_WINDOW_MIN=10
ADMIN_DB=
# 32+ random bytes, e.g. `openssl rand -base64 48`. Mandatory outside
# development/test - there is deliberately no fallback, since a hardcoded one
# would be a published signing key. Rotating it signs everyone out and
# invalidates outstanding password-reset links.
BETTER_AUTH_SECRET=
API_BASE_URL=http://localhost:3000
ADMIN_APP_URL=http://localhost:3002
# Comma-separated origins of the apps that may call /admin/* with credentials.
APP_ORIGINS=http://localhost:3001
# nachklang.art in production; passkeys are bound to this value.
PASSKEY_RP_ID=localhost
# On start-up, makes sure this address can get in (invite, or grant admin if the
# user already exists). Idempotent, safe to leave set.
ADMIN_BOOTSTRAP_EMAIL=
# The header the reverse proxy puts the real client IP in, and the proxy hops to
# trust. Get these right or better-auth cannot resolve a client IP and every
# request shares ONE rate-limit bucket (/sign-in/* allows 3 per 10 seconds, so
# one noisy client locks everyone out). Check with:
# SELECT `key` FROM rateLimit; -- a "no-trusted-ip" row means it is happening.
# The header the reverse proxy puts the real client IP in. Must be one the proxy
# actually overwrites - trusting a header it does not set lets any client send its
# own value and bypass the sign-in rate limit entirely.
# Set to "none" to trust no header at all: every request then shares one rate-limit
# bucket, which is the safe fallback if the check below fails. Verify after deploy
# with: SELECT ipAddress FROM session ORDER BY createdAt DESC LIMIT 3;
CLIENT_IP_HEADERS=x-real-ip
TRUSTED_PROXY_IPS=
# Writes invitation links to the log. That link is a live account-creation
# credential, so this is refused outside development. Needed locally, where the
# mail relay is off and only the token's hash is stored.
ADMIN_LOG_INVITE_LINKS=true
MEMBER_CREDENTIAL=123
CHOIR_CREDENTIAL=123
MANAGEMENT_CREDENTIAL=123
-1
View File
@@ -1 +0,0 @@
26
+9 -89
View File
@@ -8,24 +8,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
npm run build # Compile TypeScript → dist/
npm run start # Build and start (tsc && node ./dist/app.js)
npm run debug # Start with DEBUG=* environment variable
npm run test # Run the vitest suite once with coverage (lcov + testResults/sonar-report.xml)
npm run test:watch # vitest in watch mode
npm run test:integration # Admin-module tests against a throwaway MariaDB (needs docker or podman)
npm run test # Run Jest tests with coverage (outputs sonar-report.xml)
```
Run a single test file:
```bash
npx vitest run test/some.test.ts
npx jest test/some.test.ts
```
## Architecture
Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users), `Feedback` (concert feedback forms, mounted at `/feedback`, backed by its own `FEEDBACK_DB` — see `src/models/feedback/`: public submission flow, admin CRUD, reporting, and a Salesforce newsletter-sync integration), `Tickets` (mounted at `/tickets`), and `Admin` (identity and permissions, mounted at `/admin`, backed by `ADMIN_DB` — see below).
`src/app.factory.ts` builds the Express app; `app.ts` only starts it. The split exists so the integration tests drive the real wiring.
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).
**Request path:**
1. `src/app.factory.ts` mounts `Calendar.router.ts` at `/calendar`
1. `app.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`
@@ -33,61 +29,12 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca
| Layer | Location |
|---|---|
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts` |
| Services | `…/events/events.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` |
| Services | `…/events/events.service.ts`, `…/users/users.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) |
| Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
**Auth model:** One, since the calendar migration completed.
*Admin module (`src/models/admin/`)* — used by every app: calendar, feedback, tickets and the
admin app itself. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every
other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler
and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users.
A permission is **(app, role)** in `user_app_permissions`, keyed on
`(user_id, app, role)` so one user can hold several roles per app. `access` is the only role
today and means "may use this app at all"; `APP_ROLES` in `admin.schema.ts` is the contract,
and a role not listed there is rejected rather than written. `requireAppAccess(app)` in
`admin.middleware.ts` is the single authenticator - it takes an optional second argument to
narrow to one role, and queries the database on every request (no cookie cache) so disabling
a user takes effect at once. Two things to know before touching this: any count of admins
must count **distinct users**, not permission rows, or a single admin with two roles reads as
two and the last-admin guard stops guarding; and both write endpoints accept
`{permissions: [{app, role}]}` as well as the older `{apps: ['tickets']}`, which means the
same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL`
makes sure someone can always get in on a fresh database.
*The calendar* used to be the exception - its own `users`/`sessions` tables, and a session
token passed in **query parameters**. That is gone: `docs/calendar-auth-migration.md` records
the migration, finished 2026-09-06. Writes sit behind `requireAppAccess('calendar')`; reads
resolve the same cookie optionally, because one URL serves an anonymous visitor, an iCal
subscription and a signed-in editor.
Two calendar-specific things survive that migration and are easy to break:
- **The `public` calendar answers with no credential of any kind.** nachklang.art reads it to
show the next upcoming event. Pinned by `test/calendar/credentials.service.test.ts` and
`test/calendar/events.router.test.ts`.
- **The shared passwords (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`,
from `.env`) still open the restricted calendars for reading**, because an iCal client
cannot send a cookie. They can never write.
An event's creator is a display name and nothing else - nothing authorises on it. It resolves
from the admin module's `user.name` when the row carries an admin id, and otherwise from
`created_by_name`, a snapshot taken before the legacy `users` table was renamed aside.
**Admin database driver:** the admin pool is the **callback-style** `mysql2`, never
`mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`;
the promise wrapper ignores that callback, so every Kysely query hangs forever with no
error. Only the integration tests catch this.
**Admin schema changes:** `sql/admin/NNN_*.sql`, hand-maintained and mirrored in
`docker/init/`. The better-auth tables must match what the configured version derives from
`admin.auth.ts` — on every better-auth upgrade, re-derive them (`getAuthTables` from
`better-auth/db`, called with `auth.options`), diff, and add a numbered migration. Do not
use the published `@better-auth/cli`; it lags the library.
**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`.
**Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both.
@@ -99,36 +46,13 @@ use the published `@better-auth/cli`; it lags the library.
## Environment
dotenv 16 parses `.env` stricter than the old dotenv 8: an unquoted `#` starts a comment and
backslash escapes inside double quotes are expanded. Wrap any value containing `#`, `"`, `\` or
surrounding spaces in single quotes (`DB_PASSWORD='abc#def'`), which are taken literally.
A truncated password shows up as MariaDB "Access denied ... (using password: YES)".
**`NODE_ENV` is load-bearing for the admin module.** Only the explicit values
`development` and `test` relax anything; everything else, *including unset*, is treated as
production (strict secrets, cross-subdomain cookies, no localhost CORS). That direction is
deliberate: a Plesk vhost does not set `NODE_ENV`, and the inverse arrangement would
silently degrade the signing key, the cookie domain and the CORS list at once. Local work
needs `NODE_ENV=development`.
Copy `.env.example` (or create `.env`) with:
```
NODE_ENV=
PORT=
DB_HOST=
DB_USER=
DB_PASSWORD=
CALENDAR_DB=
ADMIN_DB=
BETTER_AUTH_SECRET=
API_BASE_URL=
ADMIN_APP_URL=
APP_ORIGINS=
PASSKEY_RP_ID=
ADMIN_BOOTSTRAP_EMAIL=
CLIENT_IP_HEADERS=
TRUSTED_PROXY_IPS=
ADMIN_LOG_INVITE_LINKS=
FEEDBACK_DB=
FEEDBACK_IP_SALT=
FEEDBACK_RATE_LIMIT_MAX=
@@ -145,10 +69,6 @@ CHOIR_CREDENTIAL=
MANAGEMENT_CREDENTIAL=
```
## TypeScript / module system
## TypeScript config
The API runs on Node 26 (`engines` in package.json, `.nvmrc`; Plesk runs 26 too) and is native ESM (`"type": "module"`, `module: nodenext`, target ES2024, strict mode, compiled output in `./dist`, inline source maps). Consequences:
- Relative imports carry the `.js` suffix (`import {x} from "./x.js"`) even though the source file is `.ts`.
- CommonJS dependencies are consumed via default imports (`import mariadb from "mariadb"`, `import cors from "cors"`, `import winston from "winston"`), never `require()`.
- Tests run with vitest directly against `.ts` sources; import `describe`/`it`/`expect`/`vi` from `vitest` explicitly (no globals). Module mocks use `vi.mock(...)` with the same `.js`-suffixed paths as the imports.
Strict mode enabled, target ES2016, compiled output in `./dist`, inline source maps. Tests run through `ts-jest` directly against `.ts` sources.
+21 -31
View File
@@ -5,28 +5,15 @@ These items were identified during a security review on 2026-05-02 and conscious
---
## 1. Session credentials in URL query parameters (logged-in users) — CLOSED 2026-09-06
## 1. Session credentials in URL query parameters (logged-in users)
**Files:** `src/models/calendar/events/events.router.ts` — all GET/PUT/DELETE handlers
`sessionId` and `sessionKey` were read from query parameters, which meant they appeared in
server access logs, browser history, proxy logs, and `Referer` headers.
`sessionId` and `sessionKey` are currently read from query parameters, which means they appear in server access logs, browser history, proxy logs, and `Referer` headers.
**Fixed** by step 4 of `docs/calendar-auth-migration.md`: the calendar's write routes now sit
behind `requireAppAccess('calendar')` against the better-auth session cookie, and the read
routes resolve the same cookie optionally. No route reads `sessionId`/`sessionKey` any more,
and the Angular frontend sends `withCredentials` instead of appending them to every URL. That
closed the item outright rather than moving the credential somewhere safer.
**Fix:** Move to request headers (`X-Session-Id` / `X-Session-Key`) or the request body. Requires a corresponding frontend update.
Two things this did *not* change, both deliberate:
- The shared calendar `password` parameter stays. An iCal client cannot send a cookie, so
this is the one caller that genuinely needs a credential in the URL. It grants read access
to one calendar and nothing else - `test/calendar/events.router.test.ts` pins that it can
never be used to write.
- ~~The legacy `/calendar/users/*` routes still exist.~~ Removed by step 5 on 2026-09-06,
along with the `users` and `sessions` tables they used - renamed aside rather than dropped,
so nothing was destroyed.
> Note: the shared calendar `password` parameter in query params is intentional (iCal clients don't support headers) and is acceptable for the current setup.
---
@@ -37,29 +24,32 @@ Two things this did *not* change, both deliberate:
- `PUT /move/:eventId` (move)
- `DELETE /:eventId` (delete)
Currently any account holding the `calendar` permission can edit, move, or delete any event regardless of who created it. This is acceptable while everyone holding it is a trusted admin.
Currently any active user can edit, move, or delete any event regardless of who created it. This is acceptable while all users are trusted admins.
**Fix (updated 2026-09-06):** fetch the event first and verify `event.createdByUserId === res.locals.admin.id` before allowing the mutation`createdById`, the legacy INT, is no longer written and is gone at step 5. Rather than an `isAdmin` flag, the bypass belongs in the permission model that already exists: `requireAppAccess('calendar', 'manage')` alongside the current `access` role, which needs a row in `APP_ROLES` on both sides and nothing else.
**Fix:** When non-admin users are introduced, fetch the event first and verify `event.createdById === user.userId` before allowing the mutation. Add an `isAdmin` flag to the user model to let admins bypass the check.
---
## 3. Activation token has no expiry — CLOSED 2026-09-06
## 3. Activation token has no expiry
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted.
**File:** `src/models/calendar/users/users.service.ts``createUser` / `activateUser`
The e-mail activation link was valid indefinitely. Closed not by adding an expiry but by
removing the thing that issued it: step 5 of `docs/calendar-auth-migration.md` deleted the
calendar's own account system. Accounts now come only from the admin module's `invitations`,
whose tokens expire after 7 days and are stored as a SHA-256 hash.
The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
Any activation link still sitting in an inbox now 404s. It only ever activated a legacy
account, which no longer opens anything.
**Fix:**
1. Add an `activation_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 24 HOUR` in `createUser`.
3. Check `activation_expires > NOW()` in `activateUser` before accepting the token.
---
## 4. Password reset token has no expiry — CLOSED 2026-09-06
## 4. Password reset token has no expiry
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted.
**File:** `src/models/calendar/users/users.service.ts``initiatePasswordReset` / `finalizePasswordReset`
Same as item 3: `pw_reset_token_hash` never expired, and the code that set it no longer
exists. Password resets go through better-auth, whose reset tokens expire after one hour.
The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
**Fix:**
1. Add a `pw_reset_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 15 MINUTE` in `initiatePasswordReset`.
3. Check `pw_reset_expires > NOW()` in `finalizePasswordReset` before accepting the token.
+101 -6
View File
@@ -1,8 +1,17 @@
import express from 'express';
import * as http from 'http';
import * as dotenv from 'dotenv';
import logger from './src/middleware/logger.js';
import {createApp} from './src/app.factory.js';
import {bootstrapAdmin} from './src/models/admin/admin.bootstrap.js';
import swaggerUi from 'swagger-ui-express';
import swaggerJSDoc from 'swagger-jsdoc';
import logger from './src/middleware/logger';
// Router imports
import {calendarRouter} from './src/models/calendar/Calendar.router';
import {feedbackRouter} from './src/models/feedback/Feedback.router';
import {ticketsRouter} from './src/models/tickets/Tickets.router';
let cors = require('cors');
dotenv.config();
@@ -13,11 +22,97 @@ if (!process.env.PORT) {
const port: number = parseInt(process.env.PORT, 10);
const app = createApp();
const app: express.Application = express();
const server: http.Server = http.createServer(app);
// Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header.
// Verify the resolved client IP is correct in staging before relying on it
// (used by the feedback rate limiter).
app.set('trust proxy', 1);
// here we are adding middleware to parse all incoming requests as JSON
app.use(express.json());
// Configure CORS
let allowedHosts = [
'https://www.nachklang.art',
'https://calendar.nachklang.art',
'https://feedback.nachklang.art',
'https://tickets.nachklang.art'
];
const isDev = process.env.NODE_ENV !== 'production';
const localhostRegex = /^http:\/\/localhost:\d+$/;
// Matches http://<private-LAN-IPv4>:<port> - needed so the feedback form can
// be reached from a real phone over WiFi during dev (the phone's Origin is
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
app.use(cors({
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
origin: function (origin: any, callback: any) {
// Allow requests with no origin
if (!origin) return callback(null, true);
// Any localhost port, or a private-LAN IP, is fine outside production -
// dev servers pick whatever port is free (Next.js falls back from 3000
// if it's taken), and real-device testing hits the dev machine by IP.
if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) {
return callback(null, true);
}
// Block requests with wrong origin
if (allowedHosts.indexOf(origin) === -1) {
return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false);
}
// Allow all other requests
return callback(null, true);
}
}));
// Swagger documentation
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'Nachklang e.V. REST API',
version: '0.1.0',
license: {
name: 'Licensed Under MIT',
url: 'https://spdx.org/licenses/MIT.html'
},
contact: {
name: 'Nachklang e.V.',
url: 'https://www.nachklang.art'
}
}
};
const options = {
swaggerDefinition,
// Paths to files containing OpenAPI definitions
apis: [
'./src/models/**/*.interface.ts',
'./src/models/**/*.router.ts'
]
};
const swaggerSpec = swaggerJSDoc(options);
app.use(
'/docs',
swaggerUi.serve,
swaggerUi.setup(swaggerSpec)
);
// Add routers
app.use('/calendar', calendarRouter);
app.use('/feedback', feedbackRouter);
app.use('/tickets', ticketsRouter);
// this is a simple route to make sure everything is working properly
app.get('/', (req: express.Request, res: express.Response) => {
res.status(200).send('Welcome to the Nachklang e.V. REST API!');
});
server.listen(port, () => {
logger.info('Server listening on Port ' + port);
// Makes sure ADMIN_BOOTSTRAP_EMAIL can always get in. Never throws.
void bootstrapAdmin();
});
-31
View File
@@ -1,31 +0,0 @@
# 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 -3
View File
@@ -1,12 +1,10 @@
-- Local dev only. Creates the four databases + a dev user with full access.
-- Local dev only. Creates the three 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;
+46 -36
View File
@@ -1,13 +1,5 @@
-- Local dev only. Derived from the real schema, which was provided directly by
-- the repo owner (calendars, events, event_versions, and the sessions/users
-- tables that step 5 of docs/calendar-auth-migration.md renamed aside).
--
-- There is no users or sessions table here: a fresh dev database has no legacy
-- accounts to archive, so it starts where production ends up.
--
-- Changes made by this repo's own migrations under sql/calendar/ are folded in
-- here rather than appended, so a fresh dev container matches production once
-- every migration has been applied. Keep the two in step.
-- Local dev only. Real schema, provided directly by the repo owner
-- (calendars, events, event_versions, sessions, users) - not a guess.
USE nachklang_calendar;
CREATE TABLE `calendars` (
@@ -17,20 +9,41 @@ CREATE TABLE `calendars` (
PRIMARY KEY (`calendar_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `users` (
`user_id` int(11) NOT NULL AUTO_INCREMENT,
`full_name` text NOT NULL,
`password_hash` text DEFAULT NULL,
`email` text NOT NULL,
`is_active` tinyint(1) DEFAULT 0,
`pw_reset_token_hash` text DEFAULT NULL,
`activation_token` text DEFAULT NULL,
PRIMARY KEY (`user_id`),
UNIQUE KEY `email` (`email`) USING HASH
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `sessions` (
`session_id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`session_key_hash` text DEFAULT NULL,
`created_date` datetime DEFAULT current_timestamp(),
`valid_until` datetime DEFAULT (current_timestamp() + interval 30 day),
`last_ip` text DEFAULT NULL,
PRIMARY KEY (`session_id`),
KEY `sessions_users_user_id_fk` (`user_id`),
CONSTRAINT `sessions_users_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `events` (
`event_id` int(11) NOT NULL AUTO_INCREMENT,
`calendar_id` int(11) NOT NULL,
`uuid` text NOT NULL,
`created_date` datetime DEFAULT current_timestamp(),
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
`created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- Creator name, archived before the legacy users table went away;
-- see sql/calendar/002_snapshot_legacy_creator_names.sql.
`created_by_name` varchar(255) DEFAULT NULL,
`created_by_id` int(11) NOT NULL,
PRIMARY KEY (`event_id`),
KEY `events_calendars_calendar_id_fk` (`calendar_id`),
KEY `events_created_by_user_idx` (`created_by_user_id`),
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`)
KEY `events_users_user_id_fk` (`created_by_id`),
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`),
CONSTRAINT `events_users_user_id_fk` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `event_versions` (
@@ -44,16 +57,14 @@ CREATE TABLE `event_versions` (
`repeat_frequency` text DEFAULT NULL,
`location` text DEFAULT NULL,
`url` text DEFAULT NULL,
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
`version_created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- Archived editor name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
`version_created_by_name` varchar(255) DEFAULT NULL,
`version_created_by_id` int(11) DEFAULT NULL,
`status` text DEFAULT NULL,
`version_created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`event_version_id`),
KEY `event_versions_events_event_id_fk` (`event_id`),
KEY `event_versions_created_by_user_idx` (`version_created_by_user_id`),
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE
KEY `event_versions_users_user_id_fk` (`version_created_by_id`),
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `event_versions_users_user_id_fk` FOREIGN KEY (`version_created_by_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
@@ -63,17 +74,16 @@ INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
(4, 'choir', '[]'),
(5, 'birthdays', '[]');
-- Two events carry only an archived creator name, as every pre-cutover event
-- does, and one carries an admin user id whose name is resolved live. Dev
-- therefore exercises both name sources rather than only one. The one with an
-- id is deliberately a PUBLIC event, so the anonymous listing the website uses
-- covers both. The id is the dev admin from 04-admin-schema.sql.
INSERT INTO events (calendar_id, uuid, created_by_user_id, created_by_name) VALUES
(1, UUID(), NULL, 'Dev Admin'),
(1, UUID(), 'dev-user-0000-0000-0000-000000000001', NULL),
(1, UUID(), NULL, 'Dev Admin');
-- Dev admin, password: devpassword
INSERT INTO users (email, password_hash, full_name, is_active) VALUES
('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1);
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_user_id, version_created_by_name) VALUES
(1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', NULL, 'Dev Admin'),
(2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 'dev-user-0000-0000-0000-000000000001', NULL),
(3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', NULL, 'Dev Admin');
INSERT INTO events (calendar_id, uuid, created_by_id) VALUES
(1, UUID(), 1),
(1, UUID(), 1),
(1, UUID(), 1);
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id) VALUES
(1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', 1),
(2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 1),
(3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', 1);
-170
View File
@@ -1,170 +0,0 @@
-- 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');
-278
View File
@@ -1,278 +0,0 @@
# Migrating the Calendar domain onto the admin identity module
Status: **complete and deployed, 2026-09-06.** Every step is live. Step 2 was dropped by
decision and part of step 5 brought forward; the rest went out as written.
The calendar now shares one identity with the tickets, feedback and admin apps: writes sit
behind `requireAppAccess('calendar')` against the shared session cookie, reads resolve that
cookie optionally, and the calendar's own `users`/`sessions` tables are renamed aside and
referenced by nothing. `DEFERRED_SECURITY.md` items 1, 3 and 4 closed with it.
Verified in production after the final deploy: the public calendar answers anonymously on all
three endpoints (23 events, 23 VEVENTs in the iCal feed, the next-event teaser intact),
restricted calendars still refuse without a credential, unauthenticated writes answer 401,
all six `/calendar/users/*` routes answer 404, CORS grants only `Content-Type`, sign-out from
`calendar.nachklang.art` succeeds, and **every event kept its author** - rendered from the
`created_by_name` snapshot, since the table it was copied from no longer exists under that
name. That last one is the whole reason part of step 5 was brought forward.
Remaining, at your leisure: `DROP TABLE sessions_legacy_archive, users_legacy_archive;`
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. Steps 1-4 of that plan
are now live, so the calendar is the last module still on the legacy query-parameter
sessions.
## 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.~~ **Done 2026-09-06**, as
`sql/calendar/001_add_admin_user_bridge.sql` - the first migration this repo owns for the
calendar schema, mirrored into `docker/init/01-calendar-schema-dev.sql`. It covers both
`events.created_by_user_id` and `event_versions.version_created_by_user_id`, and carries
no foreign key (see "What the code actually looks like" below). The dev seed leaves two
events on the legacy path and gives one an admin id, so step 3's dual-read has both cases
to exercise. Verified by applying the pre-migration schema and then the migration to a
throwaway MariaDB 11 container, and diffing `SHOW CREATE TABLE` against a fresh dev
schema: identical. Applied to the running dev database on the same day; a dev container
created before then needs it applied, or recreating.
2. ~~**Map the accounts.**~~ **Dropped 2026-09-06.** There is no backfill: since the
creator is only ever a display name (see below), old events keep resolving through the
legacy join until step 5 and then simply lose the name. Re-inviting the people who
actually still need calendar access remains an operational task, but it is no longer a
migration step and nothing is blocked on it.
3. **Dual-read.** ~~Change `events.service.ts` to prefer `created_by_user_id` and fall back
to `created_by_id`. Writes fill both.~~ **Done 2026-09-06.** `events.service.ts` now reads
both columns and prefers the admin one, resolving the name through a single
`findDisplayNames` lookup against the admin database per result set (added to
`users.admin.service.ts` for this). Four copies of the same SELECT and four copies of the
row mapper were collapsed into one of each first - the dual read would otherwise have had
to be written four times.
A name now has three possible sources, tried weakest first: the legacy join, then the
`created_by_name` snapshot from migration 002, then the live admin lookup - which wins
because it is the only one that follows an account being renamed. An admin id that no
longer resolves falls back rather than blanking, and a failure to reach the admin database
is caught and logged rather than propagated, so an anonymous read of the public calendar
never depends on the admin database being up. Covered by
`test/calendar/events.service.test.ts`.
**Writes are not dual-written**, contrary to the original plan: before the cutover the
request only ever carries a legacy session, so there is no admin id available to write.
Writes start filling `created_by_user_id` (and stop filling `created_by_id`) in step 4.
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`.~~ **Done 2026-09-06.** `DEFERRED_SECURITY.md` item 1 is closed:
no route reads `sessionId`/`sessionKey` any more.
How it came out, route by route:
- The four write routes sit behind `requireAppAccess('calendar')` as middleware. They
answer 401 when signed out and 403 without the permission, where they used to answer 403
for both.
- The three read routes cannot use middleware - the same URL serves an anonymous visitor,
an iCal subscription holding a shared password, and a signed-in editor who should see
drafts. They call `resolveAccess` optionally instead (`signedInEditor` in the router),
and a signed-in user *without* the calendar permission is treated as anonymous rather
than refused, so they keep their access to the public calendar.
- `credentials.service.ts` lost its session half entirely and is now just the password
table. `hasAccess(calendar, password)`.
- `/calendar/users/*` was left alone. Nothing calls it and a session it mints opens
nothing, but they are live password-accepting endpoints - step 5 removes them.
Also: `calendar.nachklang.art` joined `DEFAULT_APP_ORIGINS` (better-auth `trustedOrigins`,
without which sign-out from the calendar fails while everything else works), and
`localhost:4200` joined the dev origins for the same reason.
Two things this step had to carry that the original sequence put in step 5:
- **`sql/calendar/003_allow_null_legacy_creator.sql` makes `events.created_by_id` nullable**
(`MODIFY created_by_id INT NULL`). It is `NOT NULL` today, so the first event created after the
cutover would otherwise fail to insert - there is no legacy int id to write any more.
`event_versions.version_created_by_id` is already nullable. The foreign key can stay
until step 5; it permits NULL. It also re-runs 002's idempotent name backfill, to catch
anything created between the two migrations. Applying it early is safe - widening a
column to accept NULL cannot break the running pre-cutover build.
- **The public calendar stays anonymous.** `hasAccess('public')` returns true before any
credential check, and nachklang.art reads `/calendar/events/public/json` and
`/public/json/next` with no session at all. Pinned at both levels - the password table in
`test/calendar/credentials.service.test.ts`, the routes themselves in
`test/calendar/events.router.test.ts` - so this cannot regress quietly.
### Deploy checklist
Production has **none** of the three migrations: 001 and 002 were only ever applied to the
dev database. The API build below selects `created_by_user_id` and `created_by_name` on
every read, so deploying it against a database missing them fails every calendar request
including the anonymous public feed the website uses. In order:
1. **Apply `sql/calendar/001`, `002`, `003`, in that order**, against `CALENDAR_DB`. All
three are re-runnable, so applying one that is already applied is a no-op. Verify
before continuing:
`SHOW COLUMNS FROM events LIKE '%by_user%'; SHOW COLUMNS FROM events LIKE '%by_name%';`
- four rows across the two tables, and `created_by_id` nullable.
2. **Check `APP_ORIGINS` on the API vhost.** `calendar.nachklang.art` is in the code's
default list, but the environment variable *replaces* that list rather than adding to
it - so if it is set at all (the tickets/feedback cutover may have set it), append
`https://calendar.nachklang.art` or the calendar's sign-out will 403 while everything
else works. That is the failure mode the comment in `admin.config.ts` warns about.
3. **Deploy the API.**
4. **Deploy the calendar frontend immediately after.** Do not leave a gap - see below.
5. **Re-run 002's two `UPDATE` statements.** Between step 1 and step 3 the old API was
still writing `created_by_id` with no snapshot; those few rows would otherwise lose
their author at step 5.
6. **Rebuild the admin app** if `NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS` does not already
contain `https://calendar.nachklang.art`. It is a **build-time** value, so a restart
does nothing.
**The window between steps 3 and 4 does not look broken, which is the danger.** The old
Angular bundle starts by calling `POST /calendar/users/checkSessionValid`, and those
legacy routes are untouched - so it still succeeds and the page renders as signed in. What
the user then sees is an empty event table and saves that silently do nothing. It looks
like the calendar lost its data, not like a deploy in progress. Keep the gap to minutes,
or take the frontend offline for it.
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
than `?password=` stops working permanently. The shared-password URLs are unaffected.
5. **Drop the legacy path.** **Deployed 2026-09-06.** Gated on step 4 being live, which it
was.
What went:
- `src/models/calendar/users/` in its entirety - registration, login, activation, both
password-reset routes, and the session checking the feedback and tickets admin areas
used to authenticate against - plus its mount in `Calendar.router.ts`. That was the
API's last unauthenticated account-creation and mail-sending endpoint.
- The two `LEFT OUTER JOIN users` clauses in `events.service.ts` and the `legacy_*`
aliases they fed, along with `created_by_id` / `version_created_by_id` in the SELECT, the
row mapper and the `Event` interface. One name source remains besides the live admin
lookup: the snapshot, which is what made this safe.
- `X-Session-Id` / `X-Session-Key` from the CORS `allowedHeaders`. Nothing had read them
since the tickets and feedback cutover, or sent them since this one.
- The tripwire in `test/admin/auth-binding.ts` asserting neither module fell back to a
calendar header session. There is nothing left to fall back to.
`DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring)
close with it - not by adding expiries but by deleting the code that issued them.
### Deploy checklist — kept for the record; the order was REVERSED from step 4
Step 4's migration only added columns, so it went first. `004` *removes* columns and a
table that the currently running build still selects and joins, so running it first fails
every calendar read including the public feed. The step 5 build references none of them and
runs happily against the old schema. Therefore:
1. **Confirm the snapshot is complete.** Both must return 0:
```sql
SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
```
A non-zero count is an event whose author `004` would erase. Re-run 002's backfill first.
2. **Deploy the API.** No frontend deploy is needed: the calendar frontend never read
`createdById` (its `Event` model has only the name), and nothing else is known to.
3. **Confirm the calendar still works** - the public feed, a signed-in read, and one save.
At this point the old columns and tables still exist, unused, so this step is fully
reversible by redeploying the previous build.
Note that `tsc` does not remove output for deleted sources, so a build over an existing
`dist/` leaves `dist/src/models/calendar/users/*.js` behind. Nothing imports it and the
routes 404, but the deployed artifact still contains the code - clear `dist/` in the
pipeline if you want the artifact to match the source.
4. **Apply `sql/calendar/004_drop_legacy_auth.sql`.** This is the point of no return for
the columns; the accounts themselves are only renamed aside.
5. Optionally, later and at a quiet moment:
`DROP TABLE sessions_legacy_archive, users_legacy_archive;`
**One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check
anything reading `/calendar/events/*/json` that is not the calendar frontend.
**Observed during the deploy, worth keeping.** A browser holding a *cached pre-cutover*
Angular bundle looked signed in and showed every event's status as "Error". The old bundle
called `/calendar/users/checkSessionValid`, which still existed between step 4 and step 5,
so it rendered as authenticated - then fetched events with no cookie, got the anonymous
listing, which omits `status`, and the UI's status switch fell through to its error label.
Signing out and back in fixed it. After this step that route 404s, so a stale bundle now
fails honestly instead of faking a session. This is the same "does not look broken" window
the step 4 checklist warns about, seen from the other side.
## What the code actually looks like (surveyed 2026-09-06)
Four things found while doing step 1 that change how the later steps should be built:
- **`created_by_id` is display-only.** Nothing authorises on it. `events.router.ts` gates
PUT, POST, DELETE and `/move` on `user?.isActive` alone - there is no "only the creator may
edit" rule anywhere - and the column is read back solely to render `created_by_name` and
`last_modified_by_name`. That de-risks steps 2, 3 and 5 considerably: an event whose
creator never gets re-invited loses a name in the UI, it does not become uneditable or
invisible. It also means the step 2 backfill is best-effort, not a precondition.
- **The two schemas are separate databases.** `nachklang_calendar` and `nachklang_admin`
have their own connection pools (`Calendar.db.ts` vs the admin module's Kysely instance).
So the bridging columns get no foreign key, and - the part the original sequence missed -
**the `LEFT OUTER JOIN users` that produces the creator's name cannot simply be repointed**.
It would have to become a cross-schema join, which hardcodes the admin database name into
calendar SQL and ties the two schemas together exactly as an FK would. Recommendation for
step 3: drop the join for the new path and resolve names in the service layer instead -
collect the distinct ids from the result set and do one lookup against the admin users
service. One extra query per listing, no coupling, and it keeps working if the admin
database ever moves.
- **`events.created_by_id` is `NOT NULL`.** Step 5 cannot simply stop writing it; that step
has to drop the column (and its FK to `users`) in the same migration that stops the writes,
or make it nullable first.
- **Every calendar read already hits the session table.** `/:calendar/json` calls
`UserService.checkSession` before falling back to `credentials.service.ts`, so the shared
credentials are the *fallback*, not the primary path. Step 4 replaces the first half of
that with `requireAppAccess('calendar')` and has to decide what happens to the second half
- which is the first open question below.
## Open questions to settle before starting
**Settled 2026-09-06:**
- **The shared calendar credentials keep working, but only for iCal.** The web app goes
cookie-only at step 4; `MEMBER_CREDENTIAL` and friends survive on
`GET /calendar/events/{calendar}/ical`, which is the one case where the client genuinely
cannot send a cookie. Everything else in `credentials.service.ts` goes with step 5.
`public` stays anonymous everywhere - see the note under step 4.
- **The iCal export keeps its own scheme.** Same reasoning; it is the reason the shared
credentials survive at all rather than an exception to their removal.
- **No account backfill.** See step 2 above.
- **Pre-cutover authorship is archived, not discarded.** `events.created_by_name` and
`event_versions.version_created_by_name`, backfilled once by migration 002 and never
written again. This was originally listed as a step 5 question; it was brought forward so
the data is safe well before the table that holds it is dropped.
**Nothing is open.** The last one - ~~`event_versions.version_created_by_id`~~, the same INT
reference on the version rows - was handled in passing: step 1 gave it a sibling bridging
column, step 2 a sibling snapshot, and step 3 reads it exactly like `events`.
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: [
'test'
]
};
+5913 -3823
View File
File diff suppressed because it is too large Load Diff
+18 -28
View File
@@ -3,65 +3,55 @@
"version": "0.1.0",
"description": "",
"main": "index.js",
"type": "module",
"engines": {
"node": ">=26"
},
"scripts": {
"start": "tsc && node ./dist/app.js",
"build": "tsc",
"debug": "export DEBUG=* && npm run start",
"test": "vitest run --coverage",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts"
"test": "jest --coverage --testResultsProcessor ./node_modules/jest-sonar-reporter/index.js"
},
"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",
"axios": "^0.24.0",
"bcrypt": "^5.0.1",
"better-auth": "^1.7.2",
"cors": "^2.8.5",
"debug": "^4.3.1",
"dotenv": "^16.6.1",
"dotenv": "^8.2.0",
"express": "^4.18.2",
"guid-typescript": "^1.0.9",
"kysely": "^0.29.5",
"mariadb": "^3.0.2",
"mysql2": "^3.24.3",
"nodemailer": "^6.9.8",
"random-words": "^1.1.1",
"swagger-jsdoc": "^6.1.0",
"swagger-ui-express": "^4.3.0",
"winston": "^3.3.3",
"zod": "^4.5.4"
"winston": "^3.3.3"
},
"devDependencies": {
"@types/app-root-path": "^1.2.4",
"@types/bcrypt": "^3.0.1",
"@types/cors": "^2.8.19",
"@types/debug": "^4.1.5",
"@types/express": "^4.17.15",
"@types/node": "^26.4.1",
"@types/jest": "^28.1.3",
"@types/node": "^18.11.17",
"@types/nodemailer": "^6.4.14",
"@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",
"jest": "^28.1.1",
"jest-sonar-reporter": "^2.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"
"ts-jest": "^28.0.5",
"tslint": "^6.1.3",
"typescript": "^4.9.4"
},
"overrides": {
"better-auth": {
"vitest": "$vitest"
}
"jestSonar": {
"sonar56x": true,
"reportPath": "testResults",
"reportFile": "sonar-report.xml",
"indent": 4
}
}
-150
View File
@@ -1,150 +0,0 @@
-- 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;
@@ -1,38 +0,0 @@
-- Nachklang e.V. Calendar module — step 1 of docs/calendar-auth-migration.md.
-- Adds the bridging columns that let an event record who created it as an
-- *admin* user id (VARCHAR(36)) alongside the legacy calendar users.user_id
-- (INT). Apply manually against the CALENDAR_DB database:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 001_add_admin_user_bridge.sql
--
-- Numbered 001 because this is the first migration this repo owns for the
-- calendar schema: the tables themselves predate it and were provided by the
-- repo owner (mirrored for dev in docker/init/01-calendar-schema-dev.sql).
--
-- Nothing reads these columns yet — step 3 introduces the dual-read. Adding
-- them first means the backfill in step 2 has somewhere to write, and this
-- migration can be applied to production on its own without any code change.
--
-- No foreign key, on purpose. The admin `user` table lives in a *different*
-- database (nachklang_admin) behind a different connection pool, and a
-- cross-schema FK would tie the two schemas' lifecycles together: you could no
-- longer dump, restore or move one without the other. The reference is
-- enforced in application code, which is also where the legacy/new fallback
-- lives.
--
-- The collation is pinned to the admin database's (utf8mb4_unicode_ci) rather
-- than inherited from the calendar tables' utf8mb4_general_ci. These columns
-- hold ids that only ever compare against nachklang_admin.user.id, and a
-- mismatched collation makes any such comparison fail at runtime with
-- "Illegal mix of collations" instead of at review time.
ALTER TABLE `events`
ADD COLUMN IF NOT EXISTS `created_by_user_id` VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL AFTER `created_by_id`,
ADD KEY IF NOT EXISTS `events_created_by_user_idx` (`created_by_user_id`);
ALTER TABLE `event_versions`
ADD COLUMN IF NOT EXISTS `version_created_by_user_id` VARCHAR(36)
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL AFTER `version_created_by_id`,
ADD KEY IF NOT EXISTS `event_versions_created_by_user_idx` (`version_created_by_user_id`);
@@ -1,42 +0,0 @@
-- Nachklang e.V. Calendar module — step 5 preparation, brought forward.
-- Apply manually against the CALENDAR_DB database, after 001:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 002_snapshot_legacy_creator_names.sql
--
-- Snapshots the creator's and last editor's *name* onto the event itself.
--
-- Why: the creator is only ever rendered as a name (nothing authorises on it),
-- and today that name comes from joining the calendar's own `users` table.
-- Step 5 drops that table, which would silently erase the authorship of every
-- event created before the cutover. There is no account backfill to save them
-- either - that was dropped deliberately, see docs/calendar-auth-migration.md.
-- One text column per reference keeps the history at no ongoing cost.
--
-- These columns are an archive, not a source of truth. Nothing writes them
-- after this backfill: events created from the cutover onwards carry an admin
-- user id, whose name is resolved live so that renaming an account updates
-- everywhere. The read path prefers the live admin name, falls back to this
-- snapshot, and falls back again to the join until step 5 removes it.
--
-- The whole file is re-runnable: IF NOT EXISTS on the columns, and the backfill
-- only touches rows with no snapshot yet. Step 4's migration re-runs the
-- backfill, to catch anything created between this migration and the cutover.
--
-- No charset clause: unlike 001's id columns these hold display text that is
-- only ever compared against other calendar data, so they inherit the tables'
-- utf8mb4_general_ci like the columns they are copied from.
ALTER TABLE `events`
ADD COLUMN IF NOT EXISTS `created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `created_by_user_id`;
ALTER TABLE `event_versions`
ADD COLUMN IF NOT EXISTS `version_created_by_name` VARCHAR(255) NULL DEFAULT NULL AFTER `version_created_by_user_id`;
UPDATE `events` e
JOIN `users` u ON u.user_id = e.created_by_id
SET e.created_by_name = u.full_name
WHERE e.created_by_name IS NULL;
UPDATE `event_versions` v
JOIN `users` u ON u.user_id = v.version_created_by_id
SET v.version_created_by_name = u.full_name
WHERE v.version_created_by_name IS NULL;
@@ -1,32 +0,0 @@
-- Nachklang e.V. Calendar module — step 4 of docs/calendar-auth-migration.md,
-- the cutover. Apply manually against the CALENDAR_DB database, after 002,
-- and BEFORE deploying the API build that goes with it:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 003_allow_null_legacy_creator.sql
--
-- From the cutover on, an event's creator is an admin-module user id. There is
-- no legacy calendar user id to write any more, and `events.created_by_id` is
-- NOT NULL - so without this the very first event created after the deploy
-- fails to insert. `event_versions.version_created_by_id` is already nullable.
--
-- The foreign key to `users` is kept: it permits NULL, so it costs nothing
-- until step 5 drops the column and the table together.
--
-- Applying this early is harmless. Widening a column to accept NULL cannot
-- break the running pre-cutover build, which always supplies a value, so this
-- can go out ahead of the deploy rather than during it.
ALTER TABLE `events`
MODIFY COLUMN `created_by_id` INT(11) NULL DEFAULT NULL;
-- Re-run of 002's backfill, to catch anything created between the two
-- migrations while the legacy path was still writing events. Idempotent by
-- construction: it only touches rows that have no snapshot yet.
UPDATE `events` e
JOIN `users` u ON u.user_id = e.created_by_id
SET e.created_by_name = u.full_name
WHERE e.created_by_name IS NULL;
UPDATE `event_versions` v
JOIN `users` u ON u.user_id = v.version_created_by_id
SET v.version_created_by_name = u.full_name
WHERE v.version_created_by_name IS NULL;
-60
View File
@@ -1,60 +0,0 @@
-- Nachklang e.V. Calendar module — step 5 of docs/calendar-auth-migration.md.
-- Apply manually against the CALENDAR_DB database, after 003:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 004_drop_legacy_auth.sql
--
-- *** APPLY THIS AFTER DEPLOYING THE API, NOT BEFORE. ***
--
-- This is the opposite order from the step 4 cutover, and getting it wrong by
-- analogy is the obvious mistake. Step 4's migration only *added* things, so it
-- was safe ahead of the deploy. This one removes columns and a table that the
-- currently running build still selects and joins - applying it first fails
-- every calendar read, including the anonymous public feed the website uses.
-- The step 5 build touches none of them, so it runs happily against the old
-- schema; deploy it, confirm the calendar works, then run this.
--
-- Nothing here loses information that is still reachable: the creators' display
-- names were snapshotted into events.created_by_name and
-- event_versions.version_created_by_name by migration 002, and the step 4
-- runbook re-ran that backfill after the deploy. Verify before running:
--
-- SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
-- SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
--
-- Both must be 0. A non-zero count is an event whose author this migration
-- would erase; re-run 002's backfill first.
-- The foreign keys have to go before the columns they are declared on.
-- IF EXISTS so that a re-run after a partial failure gets past them.
ALTER TABLE `events`
DROP FOREIGN KEY IF EXISTS `events_users_user_id_fk`;
ALTER TABLE `event_versions`
DROP FOREIGN KEY IF EXISTS `event_versions_users_user_id_fk`;
ALTER TABLE `events`
DROP INDEX IF EXISTS `events_users_user_id_fk`,
DROP COLUMN IF EXISTS `created_by_id`;
ALTER TABLE `event_versions`
DROP INDEX IF EXISTS `event_versions_users_user_id_fk`,
DROP COLUMN IF EXISTS `version_created_by_id`;
-- The accounts themselves are renamed aside rather than dropped.
--
-- Nothing visible depends on them any more - the names are snapshotted, and no
-- code has referenced these tables since the step 4 cutover. But they still
-- hold e-mail addresses and password hashes, and a rename makes them
-- unreachable without destroying anything.
--
-- `sessions` has a foreign key into `users`; InnoDB rewires it to the new name
-- on rename, so after this it reads REFERENCES `users_legacy_archive` and the
-- pair stays internally consistent whichever order they are renamed in.
-- Verified on MariaDB 11.
--
-- Unlike the statements above this is not re-runnable, and that is the safe
-- behaviour: a second run fails on a missing `sessions` rather than doing
-- anything. Drop them for real whenever you like, at a moment when nobody is
-- mid-deploy:
-- DROP TABLE `sessions_legacy_archive`, `users_legacy_archive`;
RENAME TABLE `sessions` TO `sessions_legacy_archive`;
RENAME TABLE `users` TO `users_legacy_archive`;
@@ -1,7 +0,0 @@
-- Nachklang e.V. Tickets module — records the outcome of the redemption
-- confirmation email on the redemption itself, so the admin UI can flag a
-- failed send and offer a resend. NULL until the post-commit send resolves.
-- Apply manually against TICKETS_DB, after 002_add_require_address.sql:
-- mysql -h <DB_HOST> -u <DB_USER> -p <TICKETS_DB> < 003_add_confirmation_email_status.sql
ALTER TABLE redemptions
ADD COLUMN confirmation_email_status ENUM('SENT','FAILED') NULL DEFAULT NULL AFTER redeemed_at;
-169
View File
@@ -1,169 +0,0 @@
import express from 'express';
import * as dotenv from 'dotenv';
import swaggerUi from 'swagger-ui-express';
import swaggerJSDoc from 'swagger-jsdoc';
import cors from 'cors';
import {toNodeHandler} from 'better-auth/node';
import logger from './middleware/logger.js';
// Router imports
import {calendarRouter} from './models/calendar/Calendar.router.js';
import {feedbackRouter} from './models/feedback/Feedback.router.js';
import {ticketsRouter} from './models/tickets/Tickets.router.js';
import {adminRouter} from './models/admin/Admin.router.js';
import {auth} from './models/admin/admin.auth.js';
import {ADMIN_ALLOWED_ORIGINS, isProd} from './models/admin/admin.config.js';
dotenv.config();
/**
* Builds the Express app with every router and middleware in place.
*
* Separate from app.ts so the integration tests can drive the *real* wiring
* with supertest instead of a hand-rolled copy of it. The order below is not
* cosmetic - CORS has to precede the better-auth handler so preflights get
* their headers, and the better-auth handler has to precede express.json()
* because it reads the raw body stream itself.
*/
export const createApp = (): express.Application => {
const app: express.Application = express();
// Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header.
// Verify the resolved client IP is correct in staging before relying on it
// (used by the feedback rate limiter).
app.set('trust proxy', 1);
// Configure CORS. This has to run before the better-auth handler below, so
// that preflights for /admin/auth/* get their headers, which is why it now
// sits above express.json() instead of after it.
let allowedHosts = [
'https://www.nachklang.art',
'https://calendar.nachklang.art',
'https://feedback.nachklang.art',
'https://tickets.nachklang.art',
'https://admin.nachklang.art',
// The admin app's origin comes from ADMIN_APP_URL, so a rename or a
// staging host does not need a code change here.
...ADMIN_ALLOWED_ORIGINS
];
// `isProd` from admin.config, NOT `NODE_ENV !== 'production'`. The two are not
// the same when NODE_ENV is unset, which is exactly what a fresh Plesk vhost
// gives you: the old test called that "dev" and opened the loopback and
// private-LAN exceptions below. With `credentials: true` on this CORS config
// and a session cookie scoped to .nachklang.art, that let any page served
// from localhost read a signed-in admin's data cross-origin. admin.config
// treats anything but an explicit 'development'/'test' as production, so an
// unset value now fails closed.
const isDev = !isProd;
const localhostRegex = /^http:\/\/localhost:\d+$/;
// Matches http://<private-LAN-IPv4>:<port> - needed so the feedback form can
// be reached from a real phone over WiFi during dev (the phone's Origin is
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
app.use(cors({
// Content-Type alone. X-Session-Id / X-Session-Key were allowed here
// through the two cutovers so that a browser still holding a pre-cutover
// bundle got a clean 401 rather than a confusing CORS preflight failure.
// Nothing has read them since the first cutover and nothing has sent them
// since the second, so they came out with the rest of the legacy path.
allowedHeaders: ['Content-Type'],
// The admin session lives in a cookie, so browsers must be allowed to send
// it cross-origin - this is what makes credentials: 'include' work.
credentials: true,
origin: function (origin: any, callback: any) {
// Allow requests with no origin
if (!origin) return callback(null, true);
// Any localhost port, or a private-LAN IP, is fine outside production -
// dev servers pick whatever port is free (Next.js falls back from 3000
// if it's taken), and real-device testing hits the dev machine by IP.
if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) {
return callback(null, true);
}
// Block requests with wrong origin
if (allowedHosts.indexOf(origin) === -1) {
return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false);
}
// Allow all other requests
return callback(null, true);
}
}));
// better-auth's own handler, mounted before express.json(): it reads the raw
// request body stream itself and a parsed body would leave it hanging.
//
// Wrapped, because Express 4 does not await an async handler: a rejected
// promise escapes as an unhandled rejection instead of becoming a response.
// Nearly every better-auth route touches the admin database, so a database
// blip would leave the request hanging with no answer at all while the
// process logged an uncaughtException - observed by pointing ADMIN_DB at a
// database the user cannot open. Answer 503 instead: the caller learns, and
// the other domains keep serving.
const authHandler = toNodeHandler(auth);
app.all('/admin/auth/*', (req, res) => {
Promise.resolve(authHandler(req, res)).catch((e: any) => {
logger.error('Admin auth handler failed', {path: req.path, detail: e?.message});
if (!res.headersSent) {
res.status(503).send({
status: 'SERVICE_UNAVAILABLE',
message: 'Die Anmeldung ist derzeit nicht verfügbar. Bitte versuche es später erneut.'
});
}
});
});
// here we are adding middleware to parse all incoming requests as JSON
app.use(express.json());
// Swagger documentation
const swaggerDefinition = {
openapi: '3.0.0',
info: {
title: 'Nachklang e.V. REST API',
version: '1.0.0',
license: {
name: 'Licensed Under MIT',
url: 'https://spdx.org/licenses/MIT.html'
},
contact: {
name: 'Nachklang e.V.',
url: 'https://www.nachklang.art'
}
}
};
const options = {
swaggerDefinition,
// Paths to files containing OpenAPI definitions
apis: [
'./src/models/**/*.interface.ts',
'./src/models/**/*.router.ts'
]
};
const swaggerSpec = swaggerJSDoc(options);
app.use(
'/docs',
swaggerUi.serve,
swaggerUi.setup(swaggerSpec)
);
// Add routers
app.use('/calendar', calendarRouter);
app.use('/feedback', feedbackRouter);
app.use('/tickets', ticketsRouter);
// JSON routes only; the auth handler above is mounted separately.
app.use('/admin', adminRouter);
// this is a simple route to make sure everything is working properly
app.get('/', (req: express.Request, res: express.Response) => {
res.status(200).send('Welcome to the Nachklang e.V. REST API!');
});
return app;
};
+42
View File
@@ -0,0 +1,42 @@
import * as nodemailer from 'nodemailer';
export namespace MailService {
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
pool: true,
port: 465,
secure: true,
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD
},
tls: {rejectUnauthorized: false}
});
export interface MailAttachment {
filename: string;
content: string | Buffer;
contentType?: string;
}
export interface SendMailOptions {
html?: string;
attachments?: MailAttachment[];
}
// Builds a fresh options object per call rather than mutating a shared
// module-level one - the transporter is pooled, so overlapping sendMail
// calls (e.g. two guests redeeming at once) previously risked one
// call's recipient/subject/body being overwritten by another's before
// transporter.sendMail() read it.
export const sendMail = async (recipientAddress: string, subject: string, body: string, options?: SendMailOptions) => {
await transporter.sendMail({
from: 'noreply@nachklang.art',
to: recipientAddress,
subject: subject,
text: body,
html: options?.html,
attachments: options?.attachments
});
};
}
-107
View File
@@ -1,107 +0,0 @@
import logger from '../middleware/logger.js';
import {salesforceApexRestPost, salesforceEnabled} from './salesforce.client.js';
// Transactional email for the ticketing/calendar flows (voucher redemption
// confirmations, account activation links, password-reset tokens) is relayed
// through the Nachklang Salesforce org rather than sent over our own SMTP host:
// that host's IP reputation gets it blocked by allowlist-based receivers
// (notably t-online.de). Salesforce's MTA plus the org's DKIM signature for
// nachklang.art get the mail delivered. The org endpoint is EmailSendResource
// (POST /services/apexrest/email/send); the From address is fixed server-side
// there and is never sent from here.
//
// sendMail never throws on a delivery problem. Every caller has already
// committed its own work (a registration, a password-reset token, a
// redemption) by the time mail goes out, so a mail failure must not surface as
// a user-facing error. It returns whether the mail was accepted so the one
// caller that shows failures to staff (the voucher confirmation) can record it.
export namespace MailService {
export interface MailAttachment {
filename: string;
content: string | Buffer;
contentType?: string;
}
export interface SendMailOptions {
html?: string;
attachments?: MailAttachment[];
}
interface EmailSendResponse {
status: 'SENT';
}
// Practical ceiling, well under Apex REST's 6 MB request-body limit once
// base64 inflation (~33%) is accounted for. The only attachment today is a
// ~1 KB .ics file.
const MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024;
const isRetriable = (err: any): boolean => {
const status = err?.response?.status;
if (status !== undefined) {
return status >= 500;
}
// No response at all - network error or timeout.
return true;
};
/**
* Relays one email through the Salesforce org. Retries once on a transient
* failure (5xx / network / timeout), then logs and returns false rather
* than throwing. Returns false immediately (without a callout) when the
* Salesforce integration is disabled.
*/
export const sendMail = async (
recipientAddress: string,
subject: string,
body: string,
options?: SendMailOptions
): Promise<boolean> => {
if (!salesforceEnabled()) {
logger.info('MailService: SALESFORCE_ENABLED is false, would have sent', {recipientAddress, subject});
return false;
}
let attachments: {filename: string; contentType?: string; contentBase64: string}[];
try {
attachments = (options?.attachments ?? []).map(attachment => {
const buffer = Buffer.isBuffer(attachment.content)
? attachment.content
: Buffer.from(attachment.content, 'utf-8');
if (buffer.byteLength > MAX_ATTACHMENT_BYTES) {
throw new Error(`attachment ${attachment.filename} is ${buffer.byteLength} bytes, over the ${MAX_ATTACHMENT_BYTES} limit`);
}
return {filename: attachment.filename, contentType: attachment.contentType, contentBase64: buffer.toString('base64')};
});
} catch (err: any) {
logger.error('MailService: could not prepare attachments', {recipientAddress, subject, detail: err?.message});
return false;
}
const payload = {
to: recipientAddress,
subject,
textBody: body,
htmlBody: options?.html ?? null,
attachments
};
for (let attempt = 1; attempt <= 2; attempt++) {
try {
await salesforceApexRestPost<EmailSendResponse>('/services/apexrest/email/send', payload);
return true;
} catch (err: any) {
const status = err?.response?.status;
const detail = err?.response?.data?.errorCode || err?.response?.data?.message || err?.message || 'unknown error';
if (attempt === 1 && isRetriable(err)) {
logger.warn('MailService: send failed, retrying once', {recipientAddress, subject, status, detail});
continue;
}
logger.error('MailService: send failed', {recipientAddress, subject, status, detail});
return false;
}
}
return false;
};
}
-66
View File
@@ -1,66 +0,0 @@
import axios from 'axios';
// Shared server-to-server access to the one Nachklang Salesforce org. Both the
// newsletter-signup sync (feedback module) and the transactional-email relay
// (common.mail) authenticate the same way - OAuth 2.0 client credentials
// against the nk_Nachklang_API_Integration external client app - so the token
// cache and the retry-once-on-401 live here rather than being duplicated.
//
// Salesforce's client-credentials token response does not reliably include
// expires_in, so the cache lifetime is a conservative guess rather than a
// value read from the response - a 401 on the next call just triggers a fresh
// fetch (see salesforceApexRestPost).
const TOKEN_CACHE_MS = 15 * 60 * 1000;
let cachedToken: {accessToken: string; fetchedAt: number} | null = null;
export const salesforceEnabled = (): boolean => process.env.SALESFORCE_ENABLED === 'true';
const readConfig = (): {instanceUrl: string; clientId: string; clientSecret: string} => {
const instanceUrl = process.env.SALESFORCE_API_URL;
const clientId = process.env.SALESFORCE_CLIENT_ID;
const clientSecret = process.env.SALESFORCE_CLIENT_SECRET;
if (!instanceUrl || !clientId || !clientSecret) {
throw new Error('SALESFORCE_ENABLED is true but SALESFORCE_API_URL/SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET are not fully configured.');
}
return {instanceUrl, clientId, clientSecret};
};
const getAccessToken = async (forceRefresh: boolean): Promise<string> => {
if (!forceRefresh && cachedToken && Date.now() - cachedToken.fetchedAt < TOKEN_CACHE_MS) {
return cachedToken.accessToken;
}
const {instanceUrl, clientId, clientSecret} = readConfig();
const res = await axios.post(
`${instanceUrl}/services/oauth2/token`,
new URLSearchParams({grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret}).toString(),
{headers: {'Content-Type': 'application/x-www-form-urlencoded'}, timeout: 10000}
);
cachedToken = {accessToken: res.data.access_token, fetchedAt: Date.now()};
return cachedToken.accessToken;
};
/**
* POSTs a JSON body to an Apex REST path (e.g. '/services/apexrest/newsletter/signup')
* and returns the parsed response body. Retries once with a forced token
* refresh on a 401 - the server-side token may have expired even though our
* conservative local TTL has not. All other errors propagate to the caller.
*/
export const salesforceApexRestPost = async <T>(path: string, body: unknown): Promise<T> => {
const {instanceUrl} = readConfig();
const url = `${instanceUrl}${path}`;
try {
const token = await getAccessToken(false);
const res = await axios.post<T>(url, body, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
} catch (err: any) {
if (err?.response?.status === 401) {
const token = await getAccessToken(true);
const res = await axios.post<T>(url, body, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
}
throw err;
}
};
+5 -5
View File
@@ -1,10 +1,10 @@
import appRoot from 'app-root-path';
import winston from 'winston';
import * as appRoot from 'app-root-path';
import * as winston from 'winston';
const options = {
file_info: {
level: 'info',
filename: `${appRoot.path}/logs/app.log`,
filename: `${appRoot}/logs/app.log`,
handleExceptions: true,
json: true,
maxsize: 5242880, // 5MB
@@ -13,7 +13,7 @@ const options = {
},
file_error: {
level: 'error',
filename: `${appRoot.path}/logs/error.log`,
filename: `${appRoot}/logs/error.log`,
handleExceptions: true,
json: true,
maxsize: 5242880, // 5MB
@@ -22,7 +22,7 @@ const options = {
},
file_debug: {
level: 'debug',
filename: `${appRoot.path}/logs/debug.log`,
filename: `${appRoot}/logs/debug.log`,
handleExceptions: true,
json: true,
maxsize: 5242880, // 5MB
-55
View File
@@ -1,55 +0,0 @@
import * as dotenv from 'dotenv';
import mysql from 'mysql2';
import {Kysely, MysqlDialect} from 'kysely';
import {AdminDatabase} from './admin.schema.js';
import logger from '../../middleware/logger.js';
dotenv.config();
/**
* The admin module is the one place in this API that does not use the
* `mariadb` driver: better-auth talks to the database through Kysely, whose
* MySQL dialect expects a mysql2 pool. The other domains keep their own
* `mariadb` pools (see Feedback.db.ts) - this is an addition, not a migration.
*
* The pool is the callback-style `mysql2` one, NOT `mysql2/promise`: Kysely's
* MysqlDialect calls `pool.getConnection((err, conn) => ...)`. The promise
* wrapper ignores that callback and returns a Promise instead, so every query
* through Kysely would hang forever with no error - which is exactly what it
* did until the integration tests caught it.
*
* timezone 'Z' matters: better-auth computes session and token expiry in UTC.
* Without it mysql2 would write and read those DATETIMEs in the process's local
* zone, so sessions would expire an hour early or late depending on DST.
*/
export namespace NachklangAdminDB {
export const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.ADMIN_DB,
// The other modules' pools default to 3306. This one is configurable so
// the integration tests can point at a throwaway container on another
// port without touching a developer's real .env.
port: parseInt(process.env.DB_PORT || '3306', 10),
connectionLimit: 5,
timezone: 'Z'
});
// mysql2 emits connection trouble as an event on the pool, not only as a
// rejected query. Without a listener Node turns that into an
// uncaughtException, so a database restart would take the whole API - and
// with it the calendar, feedback and tickets domains - down with it.
// Individual queries still reject, and their callers still answer 500.
pool.on('error', (err: unknown) => {
logger.error('Admin database pool error', {detail: (err as any)?.message});
});
// Handed to better-auth as `database: {dialect, type: 'mysql'}`.
export const dialect = new MysqlDialect({pool});
// Used by this module's own services for the two custom tables and for
// permission lookups that join better-auth's `user`.
export const db = new Kysely<AdminDatabase>({dialect});
}
-48
View File
@@ -1,48 +0,0 @@
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);
-166
View File
@@ -1,166 +0,0 @@
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',
// The Angular calendar frontend; `ng serve` defaults to 4200. Missing from
// this list, sign-out from the calendar answers 403 in dev only, which is a
// confusing thing to debug against a production config that is fine.
'http://localhost:4200'
];
const trustedOrigins = isProd
? ADMIN_ALLOWED_ORIGINS
: Array.from(new Set([...ADMIN_ALLOWED_ORIGINS, ...localhostOrigins]));
export const auth = betterAuth({
appName: 'Nachklang',
database: {
dialect: NachklangAdminDB.dialect,
type: 'mysql'
},
basePath: '/admin/auth',
// Mandatory once crossSubDomainCookies is on: better-auth derives the
// cookie domain and its own absolute URLs from this.
baseURL: API_BASE_URL,
secret: BETTER_AUTH_SECRET,
trustedOrigins,
emailAndPassword: {
enabled: true,
// There is no public sign-up: accounts exist only through an
// invitation (see invitations.plugin.ts). This also makes
// auth.api.signUpEmail throw, which is intended.
disableSignUp: true,
sendResetPassword: async ({user, url}) => {
await sendPasswordResetMail(user.email, user.name, url);
}
},
user: {
additionalFields: {
// Not `returned`, and not settable through the API: disabling is an
// admin action on /admin/users/:id/disable, never something a
// session owner can flip on themselves.
disabled: {
type: 'boolean',
defaultValue: false,
input: false,
returned: false
}
}
},
session: {
expiresIn: 30 * DAY,
updateAge: DAY
// Deliberately no cookieCache: requireAppAccess hits the database on
// every request anyway, and a cached session would keep a disabled
// user or a revoked session alive for the cache's lifetime.
},
advanced: {
// Fixes the cookie name across releases so the frontends' middleware can
// check for it: "nachklang.session_token", or
// "__Secure-nachklang.session_token" over https.
cookiePrefix: 'nachklang',
crossSubDomainCookies: isProd
? {enabled: true, domain: '.nachklang.art'}
: {enabled: false},
ipAddress: {
// better-auth reads the request itself and does not know about
// Express's `trust proxy`, so both the header and the trusted hops
// have to be named here. Getting this wrong does not fail loudly -
// it collapses every client into one rate-limit bucket. See the
// commentary on CLIENT_IP_HEADERS in admin.config.ts.
ipAddressHeaders: CLIENT_IP_HEADERS,
...(TRUSTED_PROXY_IPS.length > 0 ? {trustedProxies: TRUSTED_PROXY_IPS} : {})
}
},
rateLimit: {
enabled: true,
// Passenger may run more than one instance; an in-memory limiter would
// then give each of them its own budget.
storage: 'database'
},
plugins: [
passkey({
rpID: PASSKEY_RP_ID,
rpName: 'Nachklang',
origin: ADMIN_ALLOWED_ORIGINS,
registration: {
// Without this, every passkey is stored with name = NULL and the
// account page can only label them all "Passkey" - useless at the
// one moment that list matters, when someone has to remove the
// passkey on the device they just lost.
//
// The AAGUID identifies the authenticator *model* (not a device
// and not a person), and better-auth ships the lookup table, so
// this yields "1Password", "iCloud Keychain", "Windows Hello".
// It only fills a blank: a name the client sent always wins, and
// an unknown AAGUID leaves the column NULL as before.
afterVerification: async ({verification}) => {
const name = getAuthenticatorName(verification.registrationInfo?.aaguid);
return name ? {name} : undefined;
}
}
}),
invitationsPlugin()
],
databaseHooks: {
session: {
create: {
before: async session => {
const access = await UsersService.loadAccess(session.userId);
if (access?.disabled) {
logger.warn('Admin: sign-in attempt by a disabled account', {userId: session.userId});
// Throwing rather than returning false: `false` aborts
// the session write silently and the caller sees a
// confusing success-shaped response with no cookie.
throw new APIError('FORBIDDEN', {
code: 'ACCOUNT_DISABLED',
message: 'Dieses Konto ist deaktiviert.'
});
}
}
}
}
}
});
export type AdminAuth = typeof auth;
-68
View File
@@ -1,68 +0,0 @@
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});
}
};
-191
View File
@@ -1,191 +0,0 @@
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.
*
* They feed better-auth's `trustedOrigins`, which is what lets the tickets and
* feedback admin areas call /admin/auth/sign-out from their own origin. That
* became load-bearing with the step 4 cutover: before it, the only browser
* origin that ever reached /admin/auth was the admin app itself. The calendar
* joined them with its own cutover (docs/calendar-auth-migration.md step 4).
*
* Hence the production default rather than an empty list. An origin missing
* here fails in a way that is easy to misread - sign-in works, the app works,
* and only sign-out returns an origin error - so the two frontends we know
* about are named here and APP_ORIGINS overrides them for a staging host.
* Dev adds the localhost ports separately (see admin.auth.ts).
*/
const DEFAULT_APP_ORIGINS = [
'https://tickets.nachklang.art',
'https://feedback.nachklang.art',
'https://calendar.nachklang.art'
];
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, DEFAULT_APP_ORIGINS)
.map(origin => origin.replace(/\/$/, ''));
// Kept in sync by construction rather than by three separate lists: the admin
// app itself always counts, and dev adds the local ports.
export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
ADMIN_APP_URL.replace(/\/$/, ''),
...APP_ORIGINS
]));
/**
* The header the reverse proxy puts the real client IP in, and the proxy hops
* to trust when reading it.
*
* This matters more than it looks. better-auth does not know about Express's
* `trust proxy`; it reads the request itself. If it cannot resolve a client IP
* it falls back to a single shared bucket ("no-trusted-ip") for the whole
* process - and /sign-in/* carries a default of 3 requests per 10 seconds, so
* one noisy client would lock every user out of every app.
*
* Without TRUSTED_PROXY_IPS, better-auth rejects a multi-value
* x-forwarded-for outright (it cannot tell which hop is the client), which is
* exactly the case that produces that shared bucket. Set it to the address or
* CIDR of Plesk's nginx. Conversely, listing a header the proxy does not
* overwrite lets a client set its own IP and mint itself an unlimited
* brute-force budget - so the default is the single header nginx sets, not a
* permissive list.
*/
/**
* `CLIENT_IP_HEADERS=none` trusts no header at all.
*
* This is the escape hatch for the one case where the wrong setting is worse
* than no setting: if the proxy turns out NOT to overwrite the header we are
* trusting, any client can send it and mint itself an unlimited brute-force
* budget against /sign-in. Falling back to the shared bucket is bad (one noisy
* client can lock the organisation out for ten seconds at a time) but it is
* bad in a way that fails closed, and it can be reverted from the environment
* without a deploy.
*
* Reach for it only after a check has actually failed - `SELECT ipAddress FROM
* session ORDER BY createdAt DESC` showing 127.0.0.1 or NULL for a real remote
* sign-in - and take it back out once the header is configured.
*
* An empty or unset value still means "use the default", not "trust nothing":
* a stray blank line in a .env must not silently change how requests are
* bucketed. Only the explicit word does that.
*/
const TRUST_NO_HEADER = 'none';
export const TRUST_NO_CLIENT_IP_HEADER =
(process.env.CLIENT_IP_HEADERS || '').trim().toLowerCase() === TRUST_NO_HEADER;
// An empty array is what better-auth reads as "no headers": it only falls back
// to its own default when the option is absent, and `[]` is truthy.
export const CLIENT_IP_HEADERS = TRUST_NO_CLIENT_IP_HEADER
? []
: parseList(process.env.CLIENT_IP_HEADERS, ['x-real-ip']);
export const TRUSTED_PROXY_IPS = parseList(process.env.TRUSTED_PROXY_IPS, []);
if (isProd && TRUST_NO_CLIENT_IP_HEADER) {
logger.warn(
'Admin module: CLIENT_IP_HEADERS=none - no client-IP header is trusted, so every ' +
'request shares one rate-limit bucket and /sign-in allows 3 attempts per 10 seconds ' +
'for everyone combined. This is the safe fallback, not a destination: configure the ' +
'header the proxy actually sets and remove it.'
);
} else if (isProd && TRUSTED_PROXY_IPS.length === 0) {
logger.warn(
'Admin module: TRUSTED_PROXY_IPS is not set. If the proxy sends a multi-value ' +
`${CLIENT_IP_HEADERS.join('/')}, better-auth cannot resolve a client IP and every ` +
'request shares one rate-limit bucket. Verify with: SELECT `key` FROM rateLimit - ' +
'a "no-trusted-ip" row means this is happening. A single-value header needs no ' +
'trusted proxies, so this warning is expected on a plain single-proxy setup.'
);
}
export const ADMIN_BOOTSTRAP_EMAIL = process.env.ADMIN_BOOTSTRAP_EMAIL || '';
/**
* Whether to write invitation links to the log. An invitation link is a live
* account-creation credential, so this is an explicit opt-in rather than
* something inferred from NODE_ENV: local work needs it (the mail relay is
* usually off, and only the token's hash is stored, so there is otherwise no
* way to walk the accept flow), and production must never have it.
*
* Refused outright in strict mode, so setting it in a production .env by
* accident fails at boot instead of quietly filling the log with credentials.
*/
export const LOG_INVITE_LINKS = process.env.ADMIN_LOG_INVITE_LINKS === 'true' && !isProd;
if (process.env.ADMIN_LOG_INVITE_LINKS === 'true' && isProd) {
logger.error('Admin module: ADMIN_LOG_INVITE_LINKS is set outside development - refusing to log invitation tokens');
}
-17
View File
@@ -1,17 +0,0 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
/**
* Same catch-block convention as the feedback and tickets modules: log with a
* reference guid, never hand the real error message to the client.
*/
export const sendServerError = (res: Response, e: any): void => {
const errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
};
-120
View File
@@ -1,120 +0,0 @@
/**
* Swagger component definitions for the admin module. Picked up by
* swagger-jsdoc through the `src/models/**\/*.interface.ts` glob in
* app.factory.ts.
*
* Note what is *not* documented here: the better-auth routes under
* /admin/auth/* (sign-in, sign-out, reset-password, passkey ceremonies, and
* the invitation preview/accept endpoints). better-auth owns those paths and
* their shapes; duplicating them by hand would only drift on the next upgrade.
*/
/**
* @swagger
* components:
* securitySchemes:
* AdminSessionCookie:
* type: apiKey
* in: cookie
* name: nachklang.session_token
* description: >
* Set by /admin/auth/sign-in/email. Over https the name is
* __Secure-nachklang.session_token and the cookie is scoped to
* .nachklang.art, so one sign-in covers every *.nachklang.art app.
* schemas:
* AdminApp:
* type: string
* enum: [calendar, feedback, tickets, admin]
* description: Holding "admin" is what allows managing users and invitations.
* AdminMe:
* type: object
* properties:
* id:
* type: string
* email:
* type: string
* fullName:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* AdminUserSession:
* type: object
* properties:
* id:
* type: string
* createdAt:
* type: string
* format: date-time
* expiresAt:
* type: string
* format: date-time
* ipAddress:
* type: string
* nullable: true
* userAgent:
* type: string
* nullable: true
* AdminUser:
* type: object
* properties:
* id:
* type: string
* email:
* type: string
* name:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* status:
* type: string
* enum: [aktiv, deaktiviert]
* description: Derived - there is no status column.
* createdAt:
* type: string
* format: date-time
* lastSignInAt:
* type: string
* format: date-time
* nullable: true
* description: Newest session's creation time; null once every session has expired.
* AdminUserDetail:
* allOf:
* - $ref: '#/components/schemas/AdminUser'
* - type: object
* properties:
* sessions:
* type: array
* items:
* $ref: '#/components/schemas/AdminUserSession'
* passkeyCount:
* type: integer
* AdminInvitation:
* type: object
* description: An open invitation. The token itself is never returned by any endpoint.
* properties:
* id:
* type: integer
* email:
* type: string
* name:
* type: string
* apps:
* type: array
* items:
* $ref: '#/components/schemas/AdminApp'
* invitedBy:
* type: string
* nullable: true
* description: Null for the invitation created by the ADMIN_BOOTSTRAP_EMAIL bootstrap.
* createdAt:
* type: string
* format: date-time
* expiresAt:
* type: string
* format: date-time
*/
export {};
-124
View File
@@ -1,124 +0,0 @@
import {MailService} from '../../common/common.mail.js';
import {ADMIN_APP_URL} from './admin.config.js';
/**
* The two transactional mails the admin module sends. Both go out through the
* shared MailService (Salesforce relay, see common.mail.ts), which never throws
* on a delivery failure - the invitation row and the reset token are already
* committed by the time we get here.
*
* HTML plus a plain-text body: the text part is not a fallback afterthought,
* it is what allowlist-based receivers and text-only clients actually show.
*/
const escapeHtml = (value: string): string => {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
};
// `heading` is escaped here; `paragraphs` are not, because callers pass markup
// (a <strong> around the expiry date) and escape their own interpolations.
const layout = (heading: string, paragraphs: string[], buttonLabel: string, buttonUrl: string): string => {
const body = paragraphs.map(p => `<p style="margin:0 0 16px;">${p}</p>`).join('');
return `<!doctype html>
<html lang="de">
<body style="margin:0;padding:24px;background:#f5f5f4;font-family:Helvetica,Arial,sans-serif;color:#1c1917;">
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:8px;padding:32px;">
<h1 style="margin:0 0 24px;font-size:20px;">${escapeHtml(heading)}</h1>
${body}
<p style="margin:24px 0;">
<a href="${escapeHtml(buttonUrl)}" style="display:inline-block;background:#1c1917;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:6px;">${escapeHtml(buttonLabel)}</a>
</p>
<p style="margin:0;font-size:13px;color:#57534e;">Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<span style="word-break:break-all;">${escapeHtml(buttonUrl)}</span></p>
</div>
</body>
</html>`;
};
/**
* Invitation mail. The link carries the raw token in the query string; the
* admin app strips it from the URL as soon as it has read it (see the plan's
* §3b - the token must never reach an API access log or a Referer header).
*/
export const sendInvitationMail = async (
recipientAddress: string,
name: string,
token: string,
expiresAt: Date
): Promise<boolean> => {
const url = `${ADMIN_APP_URL}/accept-invite?token=${encodeURIComponent(token)}`;
const expiry = expiresAt.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit', year: 'numeric'});
const subject = 'Dein Zugang zu Nachklang';
const text = [
`Hallo ${name},`,
'',
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über diesen Link vergibst du dein Passwort:',
'',
url,
'',
`Der Link ist bis zum ${expiry} gültig.`,
'',
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.',
'',
'Viele Grüße',
'Nachklang e.V.'
].join('\n');
const html = layout(
`Hallo ${name},`,
[
'du wurdest eingeladen, ein Nachklang-Konto anzulegen. Über den Button vergibst du dein Passwort.',
`Der Link ist bis zum <strong>${escapeHtml(expiry)}</strong> gültig.`,
'Wenn du damit nichts anfangen kannst, ignoriere diese E-Mail einfach.'
],
'Konto einrichten',
url
);
return MailService.sendMail(recipientAddress, subject, text, {html});
};
/**
* Password reset. better-auth builds the URL (it embeds its own token and the
* redirectTo the admin app passed), so this only wraps it in our templates.
*/
export const sendPasswordResetMail = async (
recipientAddress: string,
name: string,
url: string
): Promise<boolean> => {
const subject = 'Passwort zurücksetzen';
const text = [
`Hallo ${name},`,
'',
'über diesen Link kannst du ein neues Passwort vergeben:',
'',
url,
'',
'Der Link ist eine Stunde gültig.',
'',
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.',
'',
'Viele Grüße',
'Nachklang e.V.'
].join('\n');
const html = layout(
`Hallo ${name},`,
[
'über den Button kannst du ein neues Passwort vergeben.',
'Der Link ist eine Stunde gültig.',
'Wenn du kein neues Passwort angefordert hast, ist nichts passiert - ignoriere diese E-Mail.'
],
'Neues Passwort vergeben',
url
);
return MailService.sendMail(recipientAddress, subject, text, {html});
};
-140
View File
@@ -1,140 +0,0 @@
import express from 'express';
import {fromNodeHeaders} from 'better-auth/node';
import {auth} from './admin.auth.js';
import * as UsersService from './users/users.admin.service.js';
import {AppName, AppPermission, AppRole} from './admin.schema.js';
import {sendServerError} from './admin.errors.js';
/**
* The one authenticator for every admin area in this API. It replaces
* feedback.auth.ts and tickets.auth.ts, which each re-implemented the same
* header-session check against the calendar users table.
*
* Two things are checked on every request, deliberately without any caching:
* that the session cookie is valid (better-auth), and that the user is still
* enabled and still holds the permission for this app (one database query).
* That is what makes "disable a user" and "revoke a session" take effect
* immediately rather than whenever a cached session happens to expire.
*/
// The shape the feedback and tickets services already expect - unchanged, so
// nothing downstream of the authenticator needs to know this file replaced
// their own.
export interface AdminIdentity {
id: string;
email: string;
displayName: string;
}
export interface AdminAccess extends AdminIdentity {
disabled: boolean;
/** Every (app, role) grant. */
permissions: AppPermission[];
/** The distinct apps those grants cover. */
apps: AppName[];
}
const unauthorized = (res: express.Response): void => {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
};
const forbidden = (res: express.Response, message: string): void => {
res.status(403).send({status: 'FORBIDDEN', message});
};
/**
* Resolves the session cookie to a user with their permissions, or null.
* One database query, no cache. Throws only on infrastructure errors.
*/
export const resolveAccess = async (req: express.Request): Promise<AdminAccess | null> => {
const session = await auth.api.getSession({headers: fromNodeHeaders(req.headers)});
if (!session?.user) {
return null;
}
const access = await UsersService.loadAccess(session.user.id);
if (!access) {
return null;
}
return {
id: access.id,
email: access.email,
displayName: access.displayName,
disabled: access.disabled,
permissions: access.permissions,
apps: access.apps
};
};
/**
* Any signed-in account, no permission required. Used by /admin/me and the
* account-management routes: a user with no app permissions at all still has
* to be able to see that, and to manage their own password and passkeys.
*
* The disabled check is not redundant with the session-create hook: that hook
* stops a disabled user from signing in, this stops one who was disabled while
* holding a live cookie. Disabling revokes sessions, so the window is small -
* but "small" is not "closed".
*/
export const requireSignedIn: express.RequestHandler = async (req, res, next) => {
try {
const access = await resolveAccess(req);
if (!access) {
unauthorized(res);
return;
}
if (access.disabled) {
forbidden(res, 'Dieses Konto ist deaktiviert.');
return;
}
res.locals.admin = access;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
/**
* The gate every admin area sits behind. `requireAppAccess('feedback')` is
* what feedback.auth.ts's requireAdminAuth used to be, except that it now
* answers 403 for a signed-in user without that app's permission instead of
* letting any activated @nachklang.art account in.
*
* The optional second argument narrows it to one role within the app. Nothing
* passes it today - every app has exactly the `access` role - but it is the
* seam a finer permission arrives through.
*/
export const requireAppAccess = (app: AppName, role?: AppRole): express.RequestHandler => {
return async (req, res, next) => {
try {
const access = await resolveAccess(req);
if (!access) {
unauthorized(res);
return;
}
if (access.disabled) {
forbidden(res, 'Dieses Konto ist deaktiviert.');
return;
}
// Without a role this asks "may they open this app at all?", which is
// any grant on it. With one it asks for that specific grant - the hook
// a finer permission plugs into, without touching existing call sites.
const allowed = role === undefined
? access.apps.includes(app)
: access.permissions.some(permission => permission.app === app && permission.role === role);
if (!allowed) {
forbidden(res, 'Für diesen Bereich fehlt dir die Berechtigung.');
return;
}
res.locals.admin = access;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
};
-179
View File
@@ -1,179 +0,0 @@
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;
}
@@ -1,196 +0,0 @@
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;
};
@@ -1,208 +0,0 @@
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);
}
});
@@ -1,239 +0,0 @@
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);
};
@@ -1,247 +0,0 @@
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);
}
});
@@ -1,466 +0,0 @@
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;
};
/**
* Display names for a set of user ids, as an id -> name map. Ids that no
* longer exist are simply absent from the map rather than mapping to a
* placeholder, so callers can distinguish "deleted account" from "never had
* one" and choose their own fallback.
*
* This exists for the calendar migration (docs/calendar-auth-migration.md
* step 3): the calendar lives in a different database, so it cannot join
* against `user` to render "created by". One lookup per result set keeps that
* cheap without coupling the two schemas.
*/
export const findDisplayNames = async (ids: readonly string[]): Promise<Map<string, string>> => {
const distinct = Array.from(new Set(ids.filter(id => id)));
if (distinct.length === 0) {
// Kysely renders `in ()` for an empty list, which MariaDB rejects.
return new Map();
}
const rows = await db
.selectFrom('user')
.select(['id', 'name'])
.where('id', 'in', distinct)
.execute();
return new Map(rows.map(row => [row.id, row.name]));
};
+4 -2
View File
@@ -1,5 +1,6 @@
import * as dotenv from 'dotenv';
import mariadb from 'mariadb';
const mariadb = require('mariadb');
dotenv.config();
@@ -9,7 +10,8 @@ export namespace NachklangCalendarDB {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.CALENDAR_DB,
connectionLimit: 5
connectionLimit: 5,
autoCommit: false
});
export const getConnection = async () => {
+4 -9
View File
@@ -3,8 +3,9 @@
*/
import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
import {eventsRouter} from './events/events.router.js';
import logger from '../../middleware/logger';
import {eventsRouter} from './events/events.router';
import {usersRouter} from './users/users.router';
/**
* Router Definition
@@ -12,13 +13,7 @@ import {eventsRouter} from './events/events.router.js';
export const calendarRouter = express.Router();
calendarRouter.use('/events', eventsRouter);
/*
* There is no /calendar/users any more. It held this module's own accounts -
* registration, login, activation, password reset, and the session table the
* feedback and tickets admin areas used to authenticate against - and every one
* of those moved to the admin module. See docs/calendar-auth-migration.md.
*/
calendarRouter.use('/users', usersRouter);
/**
@@ -1,55 +1,73 @@
import * as dotenv from 'dotenv';
import * as UserService from '../users/users.service';
dotenv.config();
/**
* The shared calendar passwords, and nothing else.
*
* Before the step 4 cutover each function here also took a sessionId/sessionKey
* pair and checked it against the calendar's own sessions table, so "is this a
* signed-in user?" and "did they send the right shared password?" were tangled
* together in five places. Signed-in access is now decided by
* requireAppAccess('calendar') before the handler runs; what is left is the
* fallback for people who have no account at all.
*
* That fallback survives on purpose, for one reason: an iCal client subscribing
* to a calendar URL cannot send a cookie. Everything the Angular app does goes
* through the session cookie instead. See docs/calendar-auth-migration.md.
*
* `public` is deliberately open to everyone with no credential of any kind -
* nachklang.art reads it anonymously to show the next upcoming event. Pinned by
* test/calendar/credentials.service.test.ts.
* Checks if the password gives admin privileges (view / create / edit / delete)
* @param password
*/
const credentialFor = (calendarName: string): string | undefined => {
switch (calendarName) {
case 'members':
return process.env.MEMBER_CREDENTIAL;
case 'choir':
case 'birthdays':
return process.env.CHOIR_CREDENTIAL;
case 'management':
return process.env.MANAGEMENT_CREDENTIAL;
default:
return undefined;
export const checkAdminPrivileges = async (sessionId: string, sessionKey: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
};
return false;
}
/**
* Whether the given shared password opens the given calendar. Answers false
* for an unknown calendar, and - importantly - for a calendar whose credential
* is not configured at all: an unset MEMBER_CREDENTIAL must not turn into
* "everyone with an empty password gets in".
* Checks if the password gives member view privileges
* @param password
*/
export const hasAccess = async (calendarName: string, password: string): Promise<boolean> => {
if (calendarName === 'public') {
return true;
export const checkMemberPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
const expected = credentialFor(calendarName);
if (!expected) {
return false;
return password == process.env.MEMBER_CREDENTIAL;
}
/**
* Checks if the password gives choir view privileges
* @param password
*/
export const checkChoirPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password === expected;
};
return password == process.env.CHOIR_CREDENTIAL;
}
/**
* Checks if the password gives management view privileges
* @param password
*/
export const checkManagementPrivileges = async (sessionId: string, sessionKey: string, password: string, ip: string) => {
if(sessionId) {
let user = await UserService.checkSession(sessionId, sessionKey, ip);
return user?.isActive ?? false;
}
return password == process.env.MANAGEMENT_CREDENTIAL;
}
export const hasAccess = async (calendarName: string, sessionId: string, sessionKey: string, password: string, ip: string) => {
switch (calendarName) {
case 'public':
return true;
case 'members':
return await checkMemberPrivileges(sessionId, sessionKey, password, ip);
case 'choir':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
case 'management':
return await checkManagementPrivileges(sessionId, sessionKey, password, ip);
case 'birthdays':
return await checkChoirPrivileges(sessionId, sessionKey, password, ip);
default:
return false;
}
}
+11 -15
View File
@@ -14,6 +14,7 @@
* - endDateTime
* - createdDate
* - location
* - createdById
* - url
* - wholeDay
* properties:
@@ -64,20 +65,18 @@
* type: string
* description: The name of the user who created the event
* example: "John Doe"
* createdByUserId:
* type: string
* nullable: true
* description: The admin-module user id of the creator, once it has one
* example: "8f1c0f2e-0f1a-4b9e-9a7c-2d5f1b3c4d5e"
* createdById:
* type: integer
* description: The ID of the user who created the event
* example: 456
* lastModifiedBy:
* type: string
* description: The name of the user who last modified the event
* example: "John Doe"
* lastModifiedByUserId:
* type: string
* nullable: true
* description: The admin-module user id of the last editor, once it has one
* example: "8f1c0f2e-0f1a-4b9e-9a7c-2d5f1b3c4d5e"
* lastModifiedById:
* type: integer
* description: The ID of the user who last modified the event
* example: 456
* url:
* type: string
* description: A URL with more information about the event
@@ -103,13 +102,10 @@ export interface Event {
createdDate: Date;
lastModifiedDate?: Date;
location: string;
/** Display name of the creator: the live admin name when the id below
* resolves, otherwise the name archived before the legacy users table was
* removed. See docs/calendar-auth-migration.md. */
createdBy?: string;
createdByUserId?: string | null;
createdById: number;
lastModifiedBy?: string;
lastModifiedByUserId?: string | null;
lastModifiedById?: number;
url: string;
wholeDay: boolean;
repeatFrequency: string;
+191 -202
View File
@@ -3,13 +3,13 @@
*/
import express, {Request, Response} from 'express';
import {Event} from './event.interface.js';
import * as EventService from './events.service.js';
import * as iCalService from './icalgenerator.service.js';
import * as CredentialService from './credentials.service.js';
import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
import {Event} from './event.interface';
import * as EventService from './events.service';
import * as iCalService from './icalgenerator.service';
import * as CredentialService from './credentials.service';
import * as UserService from '../users/users.service';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js';
import logger from '../../../middleware/logger';
/**
@@ -29,44 +29,6 @@ export const calendarNames = new Map<string, any>([
['birthdays', {id: 5, name: 'Nachklang_birthday_calendar'}]
]);
/**
* The gate on everything that writes. Step 4 of
* docs/calendar-auth-migration.md replaced a sessionId/sessionKey pair in the
* query string (DEFERRED_SECURITY.md item 1) with the same session cookie the
* other three apps use, and "any activated @nachklang.art account" with an
* explicit per-user calendar permission.
*/
const requireCalendarAccess = requireAppAccess('calendar');
/** Set by requireCalendarAccess; the writer's admin identity. */
const adminOf = (res: Response): AdminAccess => res.locals.admin as AdminAccess;
/**
* Resolves a signed-in calendar user for the *read* routes, or null.
*
* Reads cannot use the middleware: the same URL serves an anonymous visitor
* (the public calendar the website polls), someone holding a shared password
* (an iCal subscription), and a signed-in editor who should see drafts. So it
* answers "who is this, if anyone?" instead of refusing the request, and each
* handler decides what that means.
*
* A failure to reach the admin database is swallowed for the same reason the
* name lookup in events.service.ts swallows one: it must not be able to take
* the anonymous public calendar down.
*/
const signedInEditor = async (req: Request): Promise<AdminAccess | null> => {
try {
const access = await resolveAccess(req);
if (!access || access.disabled || !access.apps.includes('calendar')) {
return null;
}
return access;
} catch (e: any) {
logger.warn('Calendar: could not resolve the session, continuing as anonymous: ' + e.message);
return null;
}
};
/**
* Controller Definitions
@@ -77,10 +39,7 @@ const signedInEditor = async (req: Request): Promise<AdminAccess | null> => {
* /calendar/events/{calendar}/json:
* get:
* summary: Get all events from a specific calendar in JSON format
* description: >
* Returns the calendar's events. The public calendar is open to everyone; the
* others need either a signed-in account with the calendar permission - which
* also unlocks drafts - or the calendar's shared password.
* description: Returns all events from the specified calendar in JSON format. Authentication required.
* tags:
* - calendar
* parameters:
@@ -89,13 +48,23 @@ const signedInEditor = async (req: Request): Promise<AdminAccess | null> => {
* required: true
* schema:
* type: string
* enum: [public, members, choir, management, birthdays]
* enum: [public, members, choir, management]
* description: The name of the calendar to get events from
* - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password
* schema:
* type: string
* description: The calendar's shared password, for callers with no account
* description: Password for calendar access (if not using session authentication)
* responses:
* 200:
* description: Success
@@ -140,7 +109,10 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
try {
// Get request params
let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -154,19 +126,23 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id;
const editor = await signedInEditor(req);
let user = await UserService.checkSession(sessionId, sessionKey, ip);
// Not signed in: fall back to the shared password for this calendar.
if (!editor && ! await CredentialService.hasAccess(calendarName, password)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
// If no user was found, check if the password gives access to the calendar
if(user === null || !user.isActive) {
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
}
// Editors get the admin view (drafts included, calendar includes ignored);
// everyone else gets published events only.
let events: Event[] = editor
? await EventService.getAllEventsAdmin(calendarId)
: await EventService.getAllEvents(calendarId);
let events: Event[];
if(user?.isActive) {
events = await EventService.getAllEventsAdmin(calendarId);
} else {
events = await EventService.getAllEvents(calendarId);
}
// Send the events back
res.status(200).send(events);
@@ -182,10 +158,7 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* /calendar/events/{calendar}/json/next:
* get:
* summary: Get the next upcoming event from a calendar
* description: >
* The next upcoming event. The public calendar is open to everyone; the
* others need either a signed-in account with the calendar permission or the
* calendar's shared password.
* description: Returns the next upcoming event from the specified calendar. Authentication required.
* tags:
* - calendar
* parameters:
@@ -194,13 +167,23 @@ eventsRouter.get('/:calendar/json', async (req: Request, res: Response) => {
* required: true
* schema:
* type: string
* enum: [public, members, choir, management, birthdays]
* enum: [public, members, choir, management]
* description: The name of the calendar to get the next event from
* - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password
* schema:
* type: string
* description: The calendar's shared password, for callers with no account
* description: Password for calendar access (if not using session authentication)
* responses:
* 200:
* description: Success
@@ -259,7 +242,10 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
try {
// Get request params
let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -273,19 +259,7 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
let calendarId: number = calendarNames.get(calendarName)!.id;
// Holding the calendar's shared password, or signed in. The password path
// is what keeps iCal subscriptions working - a calendar client cannot
// send a cookie.
//
// The password is checked FIRST so that `public`, which needs no
// credential at all, short-circuits before signedInEditor runs. Otherwise
// every request from a browser that happens to hold a .nachklang.art
// cookie - which is any signed-in user on any of the four apps - would put
// an admin-database query in front of the anonymous public feed, with no
// timeout. Both operands are side-effect free, so the order is free to
// choose; this order is the one that keeps the public calendar
// independent of the admin database.
if (! await CredentialService.hasAccess(calendarName, password) && !await signedInEditor(req)) {
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
@@ -316,10 +290,7 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* /calendar/events/{calendar}/ical:
* get:
* summary: Get all events from a specific calendar in iCal format
* description: >
* The calendar in iCal format. The public calendar is open to everyone; the
* others take the calendar's shared password in the query string, which is
* why that mechanism survives - an iCal client cannot send a cookie.
* description: Returns all events from the specified calendar in iCal format for calendar applications. Authentication required.
* tags:
* - calendar
* parameters:
@@ -328,13 +299,23 @@ eventsRouter.get('/:calendar/json/next', async (req: Request, res: Response) =>
* required: true
* schema:
* type: string
* enum: [public, members, choir, management, birthdays]
* enum: [public, members, choir, management]
* description: The name of the calendar to get events from
* - in: query
* name: sessionId
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* schema:
* type: string
* description: Session key for authentication
* - in: query
* name: password
* schema:
* type: string
* description: The calendar's shared password, for callers with no account
* description: Password for calendar access (if not using session authentication)
* responses:
* 200:
* description: Success - returns iCal file
@@ -384,7 +365,10 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
try {
// Get request params
let calendarName: string = req.params.calendar as string ?? '';
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let password: string = req.query.password as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
if (calendarName.length < 1) {
res.status(400).send({'message': 'Please state the name of the calendar you want events from.'});
@@ -398,19 +382,7 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
let calendarId: number = calendarNames.get(calendarName)!.id;
// Holding the calendar's shared password, or signed in. The password path
// is what keeps iCal subscriptions working - a calendar client cannot
// send a cookie.
//
// The password is checked FIRST so that `public`, which needs no
// credential at all, short-circuits before signedInEditor runs. Otherwise
// every request from a browser that happens to hold a .nachklang.art
// cookie - which is any signed-in user on any of the four apps - would put
// an admin-database query in front of the anonymous public feed, with no
// timeout. Both operands are side-effect free, so the order is free to
// choose; this order is the one that keeps the public calendar
// independent of the admin database.
if (! await CredentialService.hasAccess(calendarName, password) && !await signedInEditor(req)) {
if (! await CredentialService.hasAccess(calendarName, sessionId, sessionKey, password, ip)) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
@@ -441,11 +413,22 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* /calendar/events:
* post:
* summary: Create a new event
* description: Creates a new event. Requires a signed-in account with the calendar permission.
* description: Creates a new event in the specified calendar. Authentication required.
* tags:
* - calendar
* security:
* - AdminSessionCookie: []
* parameters:
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody:
* required: true
* content:
@@ -512,32 +495,16 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* message:
* type: string
* example: Required parameters missing
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* description: Forbidden - no access to create events
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* example: You do not have access to the specified calendar.
* 500:
* description: Server error
* content:
@@ -555,9 +522,19 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response) => {
eventsRouter.post('/', async (req: Request, res: Response) => {
try {
const admin = adminOf(res);
// Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if (
req.body.calendarId === undefined ||
@@ -579,9 +556,7 @@ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response
endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(),
location: req.body.location ?? '',
// LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
createdById: user.userId ?? -1,
url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '',
@@ -610,11 +585,9 @@ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response
* /calendar/events/{eventId}:
* put:
* summary: Update an existing event
* description: Updates an existing event. Requires a signed-in account with the calendar permission.
* description: Updates an existing event with the provided data. Authentication required.
* tags:
* - calendar
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
@@ -622,6 +595,18 @@ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response
* schema:
* type: integer
* description: The ID of the event to update
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody:
* required: true
* content:
@@ -654,6 +639,9 @@ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response
* location:
* type: string
* example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url:
* type: string
* example: "https://www.nachklang.art/events/concert"
@@ -685,32 +673,16 @@ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response
* message:
* type: string
* example: Required parameters missing
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* description: Forbidden - no access to update events
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* example: You do not have access to the specified calendar.
* 500:
* description: Server error
* content:
@@ -728,9 +700,19 @@ eventsRouter.post('/', requireCalendarAccess, async (req: Request, res: Response
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
eventsRouter.put('/:eventId', async (req: Request, res: Response) => {
try {
const admin = adminOf(res);
// Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if (
req.params.eventId === undefined ||
@@ -753,9 +735,8 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(),
location: req.body.location ?? '',
// LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
createdBy: req.body.createdBy ?? '',
createdById: user.userId ?? -1,
url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '',
@@ -787,11 +768,9 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* /calendar/events/move/{eventId}:
* put:
* summary: Move an event to a different calendar
* description: Moves an event to a different calendar. Requires a signed-in account with the calendar permission.
* description: Moves an existing event to a different calendar. Authentication required.
* tags:
* - calendar
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
@@ -799,6 +778,18 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* schema:
* type: integer
* description: The ID of the event to move
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* requestBody:
* required: true
* content:
@@ -829,6 +820,9 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* location:
* type: string
* example: "Musikhochschule, Karlsruhe"
* createdBy:
* type: string
* example: "John Doe"
* url:
* type: string
* example: "https://www.nachklang.art/events/concert"
@@ -860,32 +854,16 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* message:
* type: string
* example: Required parameters missing
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* description: Forbidden - no access to move events
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* example: You do not have access to the specified calendar.
* 500:
* description: Server error
* content:
@@ -903,9 +881,19 @@ eventsRouter.put('/:eventId', requireCalendarAccess, async (req: Request, res: R
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
eventsRouter.put('/move/:eventId', async (req: Request, res: Response) => {
try {
const admin = adminOf(res);
// Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if (
req.params.eventId === undefined ||
@@ -925,9 +913,8 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
endDateTime: new Date(req.body.endDateTime),
createdDate: new Date(),
location: req.body.location ?? '',
// LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
createdBy: req.body.createdBy ?? '',
createdById: user.userId ?? -1,
url: req.body.url ?? '',
wholeDay: req.body.wholeDay ?? false,
repeatFrequency: req.body.repeatFrequency ?? '',
@@ -957,11 +944,9 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
* /calendar/events/{eventId}:
* delete:
* summary: Delete an event
* description: Deletes an event. Requires a signed-in account with the calendar permission.
* description: Deletes an existing event. Authentication required.
* tags:
* - calendar
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: eventId
@@ -969,6 +954,18 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
* schema:
* type: integer
* description: The ID of the event to delete
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* description: Session ID for authentication
* - in: query
* name: sessionKey
* required: true
* schema:
* type: string
* description: Session key for authentication
* responses:
* 200:
* description: Event deleted successfully
@@ -990,32 +987,16 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
* message:
* type: string
* example: Required parameters missing
* 401:
* description: Unauthorized - not signed in
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: UNAUTHORIZED
* message:
* type: string
* example: Anmeldung erforderlich.
* 403:
* description: Forbidden - the account lacks the calendar permission
* description: Forbidden - no access to delete events
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: FORBIDDEN
* message:
* type: string
* example: "Für diesen Bereich fehlt dir die Berechtigung."
* example: You do not have access to the specified calendar.
* 500:
* description: Server error
* content:
@@ -1033,9 +1014,19 @@ eventsRouter.put('/move/:eventId', requireCalendarAccess, async (req: Request, r
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
eventsRouter.delete('/:eventId', requireCalendarAccess, async (req: Request, res: Response) => {
eventsRouter.delete('/:eventId', async (req: Request, res: Response) => {
try {
const admin = adminOf(res);
// Get params
let sessionId: string = req.query.sessionId as string ?? '';
let sessionKey: string = req.query.sessionKey as string ?? '';
let ip: string = req.socket.remoteAddress ?? '';
let user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user?.isActive) {
res.status(403).send({'message': 'You do not have access to the specified calendar.'});
return;
}
if (
req.params.eventId === undefined
@@ -1055,9 +1046,7 @@ eventsRouter.delete('/:eventId', requireCalendarAccess, async (req: Request, res
createdDate: new Date(),
location: '',
createdBy: '',
// LEGACY createdById is deliberately not set: there is no calendar
// user id any more, and migration 003 made the column nullable.
createdByUserId: admin.id,
createdById: user.userId ?? -1,
url: '',
wholeDay: false,
repeatFrequency: '',
+162 -167
View File
@@ -1,164 +1,66 @@
import * as dotenv from 'dotenv';
import {Guid} from 'guid-typescript';
import {Event} from './event.interface.js';
import {NachklangCalendarDB} from '../Calendar.db.js';
import * as AdminUsersService from '../../admin/users/users.admin.service.js';
import logger from '../../../middleware/logger.js';
import {Event} from './event.interface';
import {NachklangCalendarDB} from '../Calendar.db';
dotenv.config();
/**
* How an event's creator is resolved, after step 5 of
* docs/calendar-auth-migration.md removed the legacy path.
*
* The creator is only ever rendered as a name - nothing authorises on it, there
* is no "only the creator may edit" rule anywhere - which is why an unresolvable
* one degrades to blank rather than to an error.
*
* Two sources remain, weaker first:
*
* 1. `created_by_name`, a snapshot of the name as it stood when the calendar
* had its own `users` table. Migration 002 took it, migration 004 dropped
* the table it was taken from, and nothing has written it since. It exists
* so the authorship of pre-cutover events survived that removal.
* 2. The admin module's `user.name`, looked up live for rows carrying an admin
* id. It wins, because it is the only one that follows a rename.
*
* The third source - joining the calendar's own `users` table on
* `created_by_id` - is gone with the table and the column.
*/
/**
* The one SELECT the four read paths share; callers append their own WHERE and
* ORDER BY. `v.*` carries the version row's own creator columns, so only the
* `events` columns need naming.
*
* There are no joins to a users table any more. There is no users table.
*/
const EVENT_SELECT = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_user_id, e.created_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version`;
/**
* Maps a result row to an Event. `status` is included only where it always
* was: the admin views and the by-id lookup return it, the two public listings
* do not.
*/
const toEvent = (row: any, includeStatus: boolean): Event => {
const event: Event = {
eventId: row.event_id,
calendarId: row.calendar_id,
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
// The archived name. resolveAdminNames below overwrites it for rows that
// carry an admin id, which is the only source that follows a rename.
createdBy: row.created_by_name,
createdByUserId: row.created_by_user_id ?? null,
lastModifiedBy: row.version_created_by_name,
lastModifiedByUserId: row.version_created_by_user_id ?? null,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency
};
if (includeStatus) {
event.status = row.status;
}
return event;
};
/**
* Fills in creator/editor names for rows that carry an admin user id, by way
* of a single lookup against the admin database. The calendar cannot join
* against `user` - it is a different schema behind a different pool - and
* making it one would tie the two schemas together as tightly as a foreign key
* would.
*
* A failure here is swallowed on purpose. These endpoints include the public
* calendar the website reads anonymously, and a name is decoration: if the
* admin database is unreachable, an event should still render with whatever
* the snapshot holds rather than 500 the whole listing. The alternative
* would widen the public calendar's blast radius to include the admin
* database, which it has never depended on before.
*/
const resolveAdminNames = async (events: Event[]): Promise<void> => {
const ids = events
.flatMap(event => [event.createdByUserId, event.lastModifiedByUserId])
.filter((id): id is string => Boolean(id));
if (ids.length === 0) {
return;
}
let names: Map<string, string>;
try {
names = await AdminUsersService.findDisplayNames(ids);
} catch (e: any) {
logger.warn('Calendar: could not resolve creator names from the admin database: ' + e.message);
return;
}
for (const event of events) {
const createdBy = event.createdByUserId ? names.get(event.createdByUserId) : undefined;
if (createdBy) {
event.createdBy = createdBy;
}
const lastModifiedBy = event.lastModifiedByUserId ? names.get(event.lastModifiedByUserId) : undefined;
if (lastModifiedBy) {
event.lastModifiedBy = lastModifiedBy;
}
}
};
/**
* The calendars a listing has to cover: the requested one plus whatever it
* declares in `includes_calendars`.
*/
const calendarsToFetch = async (conn: any, calendarId: number): Promise<number[]> => {
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendars: number[] = [calendarId];
for (let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendars = [...calendars, ...includes];
}
return calendars;
};
/**
* Returns all events for the given calendar
* @param calendarId The calendar Id
*/
export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection();
let eventRows: Event[] = [];
try {
const calendars = await calendarsToFetch(conn, calendarId);
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendarsToFetch: number[] = [calendarId];
for(let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendarsToFetch = [...calendarsToFetch, ...includes];
}
const eventsQuery = `${EVENT_SELECT}
const eventsQuery = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC'
ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, [calendars]);
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch]);
const events = eventsRes.map((row: any) => toEvent(row, false));
await resolveAdminNames(events);
for (let row of eventsRes) {
eventRows.push({
eventId: row.event_id,
calendarId: row.calendar_id,
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency
});
}
return events;
return eventRows;
} catch (err) {
throw err;
} finally {
@@ -174,16 +76,48 @@ export const getAllEvents = async (calendarId: number): Promise<Event[]> => {
*/
export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> => {
let conn = await NachklangCalendarDB.getConnection();
let eventRows: Event[] = [];
try {
const eventsQuery = `${EVENT_SELECT}
const eventsQuery = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.calendar_id = ?
ORDER BY e.event_id`;
const eventsRes = await conn.query(eventsQuery, calendarId);
const events = eventsRes.map((row: any) => toEvent(row, true));
await resolveAdminNames(events);
for (let row of eventsRes) {
eventRows.push({
eventId: row.event_id,
calendarId: row.calendar_id,
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency,
status: row.status
});
}
return events;
return eventRows;
} catch (err) {
throw err;
} finally {
@@ -202,7 +136,18 @@ export const getAllEventsAdmin = async (calendarId: number): Promise<Event[]> =>
export const getEventById = async (eventId: number): Promise<Event | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
const eventsQuery = `${EVENT_SELECT}
const eventsQuery = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.event_id = ?`;
const eventsRes = await conn.query(eventsQuery, eventId);
@@ -210,10 +155,27 @@ export const getEventById = async (eventId: number): Promise<Event | null> => {
return null;
}
const event = toEvent(eventsRes[0], true);
await resolveAdminNames([event]);
return event;
const row = eventsRes[0];
return {
eventId: row.event_id,
calendarId: row.calendar_id,
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency,
status: row.status
} as Event;
} catch (err) {
throw err;
} finally {
@@ -231,11 +193,11 @@ export const createEvent = async (event: Event): Promise<number> => {
try {
await conn.beginTransaction();
let eventUUID = Guid.create().toString();
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_user_id) VALUES (?,?,?) RETURNING event_id';
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdByUserId ?? null]);
const eventsQuery = 'INSERT INTO events (calendar_id, uuid, created_by_id) VALUES (?,?,?) RETURNING event_id';
const eventsRes = await conn.execute(eventsQuery, [event.calendarId, eventUUID, event.createdById]);
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
await conn.execute(versionQuery, [eventsRes[0].event_id, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]);
await conn.commit();
@@ -256,8 +218,8 @@ export const updateEvent = async (event: Event): Promise<number> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_user_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdByUserId ?? null]);
const versionQuery = 'INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, repeat_frequency, location, url, status, version_created_by_id) VALUES (?,?,?,?,?,?,?,?,?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, event.name, event.description, event.startDateTime, event.endDateTime, event.wholeDay, event.repeatFrequency, event.location, event.url, event.status, event.createdById]);
await conn.commit();
@@ -278,8 +240,8 @@ export const deleteEvent = async (event: Event): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_user_id) VALUES (?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdByUserId ?? null]);
const versionQuery = 'INSERT INTO event_versions (event_id, status, version_created_by_id) VALUES (?,?,?);'
const versionRes = await conn.execute(versionQuery, [event.eventId, 'DELETED', event.createdById]);
await conn.commit();
@@ -321,23 +283,56 @@ export const moveEvent = async (event: Event): Promise<boolean> => {
export const getNextUpcomingEvent = async (calendarId: number): Promise<Event | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
const calendars = await calendarsToFetch(conn, calendarId);
const calendarQuery = 'SELECT calendar_id, includes_calendars FROM calendars WHERE calendar_id = ?';
const calendarRes = await conn.query(calendarQuery, calendarId);
let calendarsToFetch: number[] = [calendarId];
for(let row of calendarRes) {
let includes: number[] = JSON.parse(row.includes_calendars);
calendarsToFetch = [...calendarsToFetch, ...includes];
}
const now = new Date();
const eventsQuery = `${EVENT_SELECT}
const eventsQuery = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, u.full_name as created_by_name, u2.full_name as last_modified_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
GROUP BY event_id
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id
WHERE e.calendar_id IN (?) AND v.status = 'PUBLIC' AND v.start_datetime > ?
ORDER BY v.start_datetime ASC
LIMIT 1`;
const eventsRes = await conn.query(eventsQuery, [calendars, now]);
const eventsRes = await conn.query(eventsQuery, [calendarsToFetch, now]);
if (eventsRes.length === 0) {
return null;
}
const event = toEvent(eventsRes[0], false);
await resolveAdminNames([event]);
return event;
const row = eventsRes[0];
return {
eventId: row.event_id,
calendarId: row.calendar_id,
uuid: row.uuid,
name: row.name,
description: row.description,
startDateTime: row.start_datetime,
endDateTime: row.end_datetime,
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
createdBy: row.created_by_name,
createdById: row.created_by_id,
lastModifiedBy: row.last_modified_by_name,
lastModifiedById: row.version_created_by_id,
url: row.url,
wholeDay: row.whole_day,
repeatFrequency: row.repeat_frequency
} as Event;
} catch (err) {
throw err;
} finally {
@@ -1,4 +1,4 @@
import {Event} from './event.interface.js';
import {Event} from './event.interface';
/**
* Interface to external classes - Turns the given events into an ical string
@@ -0,0 +1,53 @@
/**
* @swagger
* components:
* schemas:
* Session:
* type: object
* required:
* - sessionId
* - userId
* - sessionKey
* - sessionKeyHash
* - lastIP
* properties:
* sessionId:
* type: integer
* description: The unique identifier for the session
* example: 789
* userId:
* type: integer
* description: The ID of the user this session belongs to
* example: 456
* sessionKey:
* type: string
* description: The session key used for authentication
* example: "abc123def456"
* sessionKeyHash:
* type: string
* description: The hashed session key (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* createdDate:
* type: string
* format: date-time
* description: The date and time when the session was created
* example: "2023-05-01T10:00:00.000Z"
* validUntil:
* type: string
* format: date-time
* description: The date and time until when the session is valid
* example: "2023-05-08T10:00:00.000Z"
* lastIP:
* type: string
* description: The last IP address used with this session
* example: "192.168.1.1"
*/
export interface Session {
sessionId: number;
userId: number;
sessionKey: string;
sessionKeyHash: string;
createdDate?: Date;
validUntil?: Date;
lastIP: string;
}
@@ -0,0 +1,42 @@
/**
* @swagger
* components:
* schemas:
* User:
* type: object
* required:
* - userId
* - fullName
* - passwordHash
* - email
* - isActive
* properties:
* userId:
* type: integer
* description: The unique identifier for the user
* example: 456
* fullName:
* type: string
* description: The full name of the user
* example: "John Doe"
* passwordHash:
* type: string
* description: The hashed password of the user (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* email:
* type: string
* format: email
* description: The email address of the user
* example: "john.doe@nachklang.art"
* isActive:
* type: boolean
* description: Whether the user account is active
* example: true
*/
export interface User {
userId: number;
fullName: string;
passwordHash: string;
email: string;
isActive: boolean;
}
+671
View File
@@ -0,0 +1,671 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as UserService from './users.service';
import {Session} from './session.interface';
import {User} from './user.interface';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
/**
* Router Definition
*/
export const usersRouter = express.Router();
/**
* Controller Definitions
*/
/**
* @swagger
* /calendar/users/register:
* post:
* summary: Register a new user
* description: Creates a new user account with the provided email, password, and full name. Only accepts official Nachklang email addresses.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* - fullName
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* description: Must be an official Nachklang email address
* password:
* type: string
* format: password
* example: securePassword123
* fullName:
* type: string
* example: John Doe
* responses:
* 201:
* description: User registered successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/register
usersRouter.post('/register', async (req: Request, res: Response) => {
try {
const password: string = req.body.password;
const email: string = req.body.email;
const fullName: string = req.body.fullName;
const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email || !fullName) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/;
if(!emailRegex.test(email)) {
res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'}));
return;
}
// Create the user and a session
const session: Session = await UserService.createUser(email, password, fullName, ip);
// Send the session details back to the user
res.status(201).send({
sessionId: session.sessionId,
sessionKey: session.sessionKey
});
} catch (e: any) {
let 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
});
}
});
/**
* @swagger
* /calendar/users/activate:
* get:
* summary: Activate a user account
* description: Activates a user account using the provided user ID and activation token.
* tags:
* - calendar
* parameters:
* - in: query
* name: id
* required: true
* schema:
* type: integer
* description: The ID of the user to activate
* - in: query
* name: token
* required: true
* schema:
* type: string
* description: The activation token sent to the user's email
* responses:
* 200:
* description: User activated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: OK
* message:
* type: string
* example: User activated
* 400:
* description: Bad request - missing parameters or activation failed
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Error activating user. Please contact your administrator.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// GET /users/activate
usersRouter.get('/activate', async (req: Request, res: Response) => {
try {
const userId: number = parseInt(req.query.id as string ?? '-1', 10);
const token: string = req.query.token as string ?? '';
if (!userId || !token) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create the user and a session
const success: boolean = await UserService.activateUser(userId, token);
// Send the session details back to the user
if(success) {
res.status(200).send({
'status': 'OK',
'message': 'User activated'
});
return;
}
res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'});
} catch (e: any) {
let 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
});
}
});
/**
* @swagger
* /calendar/users/login:
* post:
* summary: Login a user
* description: Authenticates a user with the provided email and password and returns a session.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* password:
* type: string
* format: password
* example: securePassword123
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 401:
* description: Unauthorized - invalid credentials
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Wrong username and / or password
* sessionId:
* type: integer
* example: -1
* sessionKey:
* type: string
* example: ""
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/login
usersRouter.post('/login', async (req: Request, res: Response) => {
try {
const password: string = req.body.password;
const email: string = req.body.email;
const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create a session
const session: Session | null = await UserService.login(email, password, ip);
if (!session || !session.sessionId) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''}));
return;
}
// Send the session details back to the user
res.status(200).send({
sessionId: session.sessionId,
sessionKey: session.sessionKey
});
} catch (e: any) {
let 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
});
}
});
/**
* @swagger
* /calendar/users/checkSessionValid:
* post:
* summary: Check if a session is valid
* description: Checks if the provided session is valid and returns the user information if it is.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* - sessionKey
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* responses:
* 200:
* description: Session is valid
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Unauthorized - invalid session
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: ["Invalid session"]
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/checkSessionValid
usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
try {
const ip: string = req.socket.remoteAddress ?? '';
const session_id = req.body.sessionId;
const session_key = req.body.sessionKey;
if (!session_id || !session_key) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['No session detected']}));
return;
}
const user: User | null = await UserService.checkSession(session_id, session_key, ip);
if (!user || !user.userId) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Invalid session']}));
return;
}
res.status(200).send(user);
} catch (e: any) {
let 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
});
}
});
/**
* @swagger
* /calendar/users/initiatePasswordReset:
* post:
* summary: Initiates a password reset
* description: Checks if the user exists and if so, initiates a password reset by sending an email to the user.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
*/
usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username given']}));
return;
}
const success: boolean = await UserService.initiatePasswordReset(username);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} catch (e: any) {
let 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
});
}
});
/**
* @swagger
* /calendar/users/finalizePasswordReset:
* post:
* summary: Finalizes the password reset
* description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
* token:
* type: string
* example: 3ccd147f-720b-4e29-a8b7-46b63de31555
* password:
* type: string
* example: ExtremelyBadPassword
*/
usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
const token = req.body.token;
const newPassword = req.body.password;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username, token or password given']}));
return;
}
const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} catch (e: any) {
let 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
});
}
});
+293
View File
@@ -0,0 +1,293 @@
import * as dotenv from 'dotenv';
import * as bcrypt from 'bcrypt';
import {Guid} from 'guid-typescript';
import {User} from './user.interface';
import {Session} from './session.interface';
import {NachklangCalendarDB} from '../Calendar.db';
import {MailService} from "../../../common/common.mail.nodemailer";
dotenv.config();
/**
* Data Model Interfaces
*/
/**
* Service Methods
*/
/**
* Creates a user record in the database, also creates a session. Returns the session if successful.
*/
export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise<Session> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Hash password and generate + hash session key
const pwHash = bcrypt.hashSync(password, 10);
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
const activationToken = Guid.create().toString();
const activationTokenHash = bcrypt.hashSync(activationToken, 10);
// Create user entry in SQL
const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id';
const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]);
// Get user id of the created user
let userId: number = -1;
for (const row of userIdRes) {
userId = row.user_id;
}
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
// Send email with activation link (after commit so we don't block on email delivery)
await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`);
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const activateUser = async (userId: number, token: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0';
const userNameRes = await conn.query(checkTokenQuery, [userId]);
let storedTokenHash = '';
for (const row of userNameRes) {
storedTokenHash = row.activation_token;
}
if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) {
return false;
}
const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?';
const activateRes = await conn.execute(activateQuery, [userId]);
await conn.commit();
return activateRes.affectedRows !== 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
/**
* Checks if the given credentials are valid and creates a new session if they are.
* Returns the session information in case of a successful login
*/
export const login = async (email: string, password: string, ip: string): Promise<Session | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved password hash
const query = 'SELECT user_id, password_hash FROM users WHERE email = ?';
const userRows = await conn.query(query, email);
let savedHash = '';
let userId = -1;
for (const row of userRows) {
savedHash = row.password_hash;
userId = row.user_id;
}
// Check for correct password
if (!bcrypt.compareSync(password, savedHash)) {
return null;
}
// Generate + hash session key
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Checks if the given session information are valid and returns the user information if they are
*/
export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved session key hash
const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?';
const sessionRows = await conn.query(query, sessionId);
let savedHash = '';
let userId = -1;
let validUntil = new Date();
for (const row of sessionRows) {
savedHash = row.session_key_hash;
userId = row.user_id;
validUntil = row.valid_until;
}
// Check for correct key
if (!bcrypt.compareSync(sessionKey, savedHash)) {
return null;
}
// Check if the session is still valid
if (validUntil <= new Date()) {
return null;
}
// Update session entry in SQL
const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?';
await conn.query(updateSessionsQuery, [ip, sessionId]);
await conn.commit();
// Get the other required user information
const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?';
const userRows = await conn.query(userQuery, userId);
let email = '';
let fullName = '';
let is_active = false;
for (const row of userRows) {
email = row.email;
fullName = row.full_name;
is_active = row.is_active;
}
// Everything is fine, return user information
return {
userId: userId,
email: email,
passwordHash: 'HIDDEN',
fullName: fullName,
isActive: is_active
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const initiatePasswordReset = async (email: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?';
const userNameRes = await conn.query(checkUsernameQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: number = -1;
let fullName: string = '';
for(let row of userNameRes) {
userId = row.user_id;
fullName = row.full_Name;
}
let resetToken = Guid.create().toString();
let resetTokenHash = bcrypt.hashSync(resetToken, 10);
const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?';
const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]);
if(updateRes.affectedRows === 0) {
return false;
}
await conn.commit();
await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`);
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?';
const userNameRes = await conn.query(checkTokenQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: string = '';
let tokenHash: string = '';
for(let row of userNameRes) {
userId = row.user_id;
tokenHash = row.pw_reset_token_hash;
}
if(!bcrypt.compareSync(token, tokenHash)) {
return false;
}
const pwHash = bcrypt.hashSync(newPassword, 10);
const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?';
const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]);
if(updateRes.affectedRows > 0) {
await conn.commit();
return true;
}
return false;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
+4 -2
View File
@@ -1,5 +1,6 @@
import * as dotenv from 'dotenv';
import mariadb from 'mariadb';
const mariadb = require('mariadb');
dotenv.config();
@@ -9,7 +10,8 @@ export namespace NachklangFeedbackDB {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.FEEDBACK_DB,
connectionLimit: 5
connectionLimit: 5,
autoCommit: false
});
export const getConnection = async () => {
+3 -3
View File
@@ -2,9 +2,9 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {publicRouter} from './public/public.router.js';
import {adminRouter} from './admin/admin.router.js';
import {sendServerError} from './feedback.errors.js';
import {publicRouter} from './public/public.router';
import {adminRouter} from './admin/admin.router';
import {sendServerError} from './feedback.errors';
/**
* Router Definition
+14 -1
View File
@@ -1,6 +1,19 @@
/**
* @swagger
* components:
* parameters:
* SessionIdHeader:
* in: header
* name: X-Session-Id
* required: true
* schema:
* type: string
* SessionKeyHeader:
* in: header
* name: X-Session-Key
* required: true
* schema:
* type: string
* schemas:
* EventAdminSummary:
* type: object
@@ -68,7 +81,7 @@
* type: boolean
*/
import {QuestionType, Song} from '../feedback.interface.js';
import {QuestionType, Song} from '../feedback.interface';
export interface EventAdminSummary {
eventId: number;
+12 -15
View File
@@ -2,13 +2,13 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../feedback.auth.js';
import {sendServerError} from '../feedback.errors.js';
import {eventsAdminRouter} from './events.admin.router.js';
import {songsAdminRouter} from './songs.admin.router.js';
import {questionsAdminRouter} from './questions.admin.router.js';
import {reportsAdminRouter} from './reports.admin.router.js';
import * as ReportsAdminService from './reports.admin.service.js';
import {requireAdminAuth} from '../feedback.auth';
import {sendServerError} from '../feedback.errors';
import {eventsAdminRouter} from './events.admin.router';
import {songsAdminRouter} from './songs.admin.router';
import {questionsAdminRouter} from './questions.admin.router';
import {reportsAdminRouter} from './reports.admin.router';
import * as ReportsAdminService from './reports.admin.service';
/**
* Router Definition
@@ -26,8 +26,9 @@ adminRouter.use(requireAdminAuth);
* summary: Validate the current admin session
* description: Used by the Next.js middleware/proxy to gate /admin. Returns the authenticated admin's identity.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
@@ -42,8 +43,6 @@ adminRouter.use(requireAdminAuth);
* type: string
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
@@ -56,9 +55,9 @@ adminRouter.get('/me', (req: Request, res: Response) => {
* summary: Delete a single submission
* description: Removes the submission and everything under it (its answers, guest book entry, newsletter signup) - for removing an individual abusive or inappropriate entry. Not a bulk moderation tool.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: submissionId
* required: true
@@ -71,8 +70,6 @@ adminRouter.get('/me', (req: Request, res: Response) => {
* description: Unknown submission
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Response) => {
try {
+2 -2
View File
@@ -1,5 +1,5 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {formatDatetime} from '../feedback.dates.js';
import {NachklangFeedbackDB} from '../Feedback.db';
import {formatDatetime} from '../feedback.dates';
const CSV_SEPARATOR = ';';
const UTF8_BOM = '';
@@ -2,9 +2,9 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as EventsAdminService from './events.admin.service.js';
import * as SongsAdminService from './songs.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
import * as EventsAdminService from './events.admin.service';
import * as SongsAdminService from './songs.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -18,8 +18,9 @@ export const eventsAdminRouter = express.Router();
* summary: List all events (admin)
* description: All events, published or not, past or future, with submission counts.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
@@ -31,14 +32,13 @@ export const eventsAdminRouter = express.Router();
* $ref: '#/components/schemas/EventAdminSummary'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post:
* summary: Create an event
* description: Auto-generates the slug from the name and event year; defaults feedback_deadline to event_date + 14 days 23:59:59 unless supplied.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody:
* required: true
* content:
@@ -68,8 +68,6 @@ export const eventsAdminRouter = express.Router();
* description: Missing required fields
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
@@ -103,9 +101,9 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* summary: Get one event (admin)
* description: Full event detail including setlist and assigned questions.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -122,14 +120,12 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown event
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* put:
* summary: Update an event
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -142,15 +138,13 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown event
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete:
* summary: Delete an event
* description: Refuses with 409 if submissions exist unless ?force=true is passed.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -169,8 +163,6 @@ eventsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Submissions exist and force was not set
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/:eventId', async (req: Request, res: Response) => {
try {
@@ -222,9 +214,9 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
* get:
* summary: Get an event's setlist
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -235,14 +227,12 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post:
* summary: Add a song to an event's setlist
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -267,8 +257,6 @@ eventsAdminRouter.delete('/:eventId', async (req: Request, res: Response) => {
* description: Missing title
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/:eventId/songs', async (req: Request, res: Response) => {
try {
@@ -304,9 +292,9 @@ eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) =>
* summary: Bulk reorder an event's setlist
* description: Rewrites song positions as a dense 0..n-1 sequence in one transaction.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -329,8 +317,6 @@ eventsAdminRouter.post('/:eventId/songs', async (req: Request, res: Response) =>
* description: Reordered
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Response) => {
try {
@@ -348,9 +334,9 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
* get:
* summary: Get an event's assigned questions
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -361,15 +347,13 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* put:
* summary: Bulk-set an event's assigned questions
* description: One transaction - inserts new, updates existing, deletes removed. Keeps the admin UI a simple save-the-whole-list form.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -399,8 +383,6 @@ eventsAdminRouter.put('/:eventId/songs/order', async (req: Request, res: Respons
* description: Saved
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/:eventId/questions', async (req: Request, res: Response) => {
try {
@@ -1,7 +1,7 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {Song} from '../feedback.interface.js';
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface.js';
import {formatDatetime} from '../feedback.dates.js';
import {NachklangFeedbackDB} from '../Feedback.db';
import {Song} from '../feedback.interface';
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface';
import {formatDatetime} from '../feedback.dates';
const UMLAUT_MAP: Record<string, string> = {
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
@@ -2,8 +2,8 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as QuestionsAdminService from './questions.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
import * as QuestionsAdminService from './questions.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -16,9 +16,9 @@ export const questionsAdminRouter = express.Router();
* get:
* summary: List the question library
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query
* name: includeArchived
* schema:
@@ -34,13 +34,12 @@ export const questionsAdminRouter = express.Router();
* $ref: '#/components/schemas/AdminQuestion'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* post:
* summary: Create a question
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody:
* required: true
* content:
@@ -62,8 +61,6 @@ export const questionsAdminRouter = express.Router();
* description: Missing or invalid fields
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
questionsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
@@ -97,9 +94,9 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
* summary: Edit a question's label/help text
* description: question_type is immutable after creation - the admin UI offers "archive and create new" instead.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: questionId
* required: true
@@ -126,15 +123,13 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown question
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete:
* summary: Archive (or hard-delete) a question
* description: Archives the question if it has ever been used; hard-deletes it if it has never been assigned to any event.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: questionId
* required: true
@@ -147,8 +142,6 @@ questionsAdminRouter.post('/', async (req: Request, res: Response) => {
* description: Unknown question
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
questionsAdminRouter.put('/:questionId', async (req: Request, res: Response) => {
try {
@@ -1,6 +1,6 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {QuestionType} from '../feedback.interface.js';
import {AdminQuestion} from './admin.interface.js';
import {NachklangFeedbackDB} from '../Feedback.db';
import {QuestionType} from '../feedback.interface';
import {AdminQuestion} from './admin.interface';
const mapRow = (row: any): AdminQuestion => ({
questionId: row.question_id,
@@ -1,4 +1,4 @@
import {QuestionType} from '../feedback.interface.js';
import {QuestionType} from '../feedback.interface';
export interface SongPickResult {
songId: number;
@@ -2,10 +2,10 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as ReportsAdminService from './reports.admin.service.js';
import * as CsvService from './csv.service.js';
import * as EventsAdminService from './events.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
import * as ReportsAdminService from './reports.admin.service';
import * as CsvService from './csv.service';
import * as EventsAdminService from './events.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -19,9 +19,9 @@ export const reportsAdminRouter = express.Router();
* summary: Aggregated feedback report for one event
* description: Song-pick vote counts, song-rating averages, capped free-text list, guest book count, and newsletter sync counts.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -34,8 +34,6 @@ export const reportsAdminRouter = express.Router();
* description: Unknown event
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) => {
try {
@@ -57,9 +55,9 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
* summary: Guest Book entries for one event
* description: Newest first, paginated.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -83,8 +81,6 @@ reportsAdminRouter.get('/:eventId/report', async (req: Request, res: Response) =
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response) => {
try {
@@ -105,9 +101,9 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
* summary: Newsletter signups for one event
* description: Includes sync_status, so failures can be handled manually. Newest first, paginated.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -131,8 +127,6 @@ reportsAdminRouter.get('/:eventId/guestbook', async (req: Request, res: Response
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Response) => {
try {
@@ -153,9 +147,9 @@ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Respons
* summary: CSV export of all answers for one event
* description: Long format, one row per answer. UTF-8 BOM, `;` separator, RFC 4180 escaping, formula-injection guard.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -168,8 +162,6 @@ reportsAdminRouter.get('/:eventId/newsletter', async (req: Request, res: Respons
* text/csv: {}
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, res: Response) => {
try {
@@ -195,9 +187,9 @@ reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, re
* get:
* summary: CSV export of Guest Book entries for one event
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -210,8 +202,6 @@ reportsAdminRouter.get('/:eventId/export/responses.csv', async (req: Request, re
* text/csv: {}
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
reportsAdminRouter.get('/:eventId/export/guestbook.csv', async (req: Request, res: Response) => {
try {
@@ -1,7 +1,7 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {NachklangFeedbackDB} from '../Feedback.db';
import {
AnswerRow, EventReport, FreeTextReport, SongPickReport, SongRatingReport
} from './reports.admin.interface.js';
} from './reports.admin.interface';
const FREE_TEXT_CAP = 500;
@@ -2,8 +2,8 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as SongsAdminService from './songs.admin.service.js';
import {sendServerError} from '../feedback.errors.js';
import * as SongsAdminService from './songs.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -16,9 +16,9 @@ export const songsAdminRouter = express.Router();
* put:
* summary: Edit a song's title/composer
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: songId
* required: true
@@ -45,15 +45,13 @@ export const songsAdminRouter = express.Router();
* description: Unknown song
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* delete:
* summary: Remove a song
* description: Past answers keep their song_title_snapshot even after the song is removed.
* tags: [feedback-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: songId
* required: true
@@ -66,8 +64,6 @@ export const songsAdminRouter = express.Router();
* description: Unknown song
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
try {
@@ -1,4 +1,4 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {NachklangFeedbackDB} from '../Feedback.db';
export const addSong = async (eventId: number, title: string, composer: string | null): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
+64 -24
View File
@@ -1,38 +1,78 @@
import {requireAppAccess} from '../admin/admin.middleware.js';
import express from 'express';
import * as UserService from '../calendar/users/users.service';
import {sendServerError} from './feedback.errors';
/**
* This file is the ONLY place in the feedback module that knows how admin
* authentication works. No route handler and no service outside this file may
* read session headers or resolve a user itself.
* authentication works today. No route handler and no service outside this
* file may import users.service, read session headers, or touch bcrypt.
*
* Today: the shared admin identity in `src/models/admin/`. A session cookie
* set by /admin/auth on admin.nachklang.art, plus a `feedback` permission on
* the account. Both are re-checked on every request, so disabling a user or
* taking their feedback permission away takes effect immediately.
* Today: reuses the existing Calendar users/sessions mechanism. Any
* activated @nachklang.art account may administer feedback — no roles.
* Migrating to Keycloak later means writing a keycloakJwtAuthenticator
* below and changing the one `activeAuthenticator` binding (plus the
* frontend's login route handler) — nothing else in the feedback module
* needs to change.
*
* Before 2026-09-06 this was a header session against the calendar users
* table, and any activated @nachklang.art account could administer feedback.
* That is why the swap is a one-line binding: everything downstream only ever
* saw `requireAdminAuth` and `res.locals.admin`, and both still mean what
* they meant. What changed is that access is now granted per user rather than
* implied by having an account.
*
* Explicitly forbidden: accepting session credentials from query parameters,
* even "temporarily". That is the exact mistake documented in
* DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials end
* up in access logs, browser history, proxy logs, and Referer headers.
* Explicitly forbidden: accepting sessionId/sessionKey from query
* parameters, even "temporarily". That is the exact mistake documented in
* DEFERRED_SECURITY.md item 1 for the Calendar domain, where credentials
* end up in access logs, browser history, proxy logs, and Referer headers.
* Headers only.
*/
// The only thing the rest of the feedback module knows about an admin. The
// shared middleware puts a superset of this on res.locals.admin.
// The only thing the rest of the feedback module knows about an admin.
export interface AdminIdentity {
id: string;
email: string;
displayName: string;
}
// Pluggable strategy: extract + verify credentials from a request.
// Returns the identity, or null if unauthenticated. Throws only on
// infrastructure errors (e.g. the DB being unreachable).
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
// Current implementation: reads X-Session-Id / X-Session-Key headers,
// delegates to the existing calendar UserService.checkSession(...).
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
const sessionId = req.header('X-Session-Id');
const sessionKey = req.header('X-Session-Key');
if (!sessionId || !sessionKey) {
return null;
}
const ip = req.ip || '';
const user = await UserService.checkSession(sessionId, sessionKey, ip);
// Mirrors the Calendar domain's own convention: a valid session on an
// inactive (not yet activated) account is not sufficient.
if (!user || !user.isActive) {
return null;
}
return {
id: String(user.userId),
email: user.email,
displayName: user.fullName
};
};
// Swap point: change this one binding to migrate to Keycloak.
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
// Express middleware used by every admin route. On success:
// res.locals.admin = AdminAccess (an AdminIdentity plus permissions), calls
// next(). On failure: 401 when not signed in, 403 when signed in without the
// feedback permission.
export const requireAdminAuth = requireAppAccess('feedback');
// res.locals.admin = AdminIdentity, calls next(). On failure: 401.
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
try {
const identity = await activeAuthenticator(req);
if (!identity) {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
return;
}
res.locals.admin = identity;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
import logger from '../../middleware/logger';
/**
* The feedback module's standard catch-block response: log with a
+1 -1
View File
@@ -1,6 +1,6 @@
import * as crypto from 'crypto';
import * as dotenv from 'dotenv';
import {NachklangFeedbackDB} from './Feedback.db.js';
import {NachklangFeedbackDB} from './Feedback.db';
dotenv.config();
@@ -1,6 +1,6 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import logger from '../../../middleware/logger.js';
import {salesforceApexRestPost} from '../../../common/salesforce.client.js';
import axios from 'axios';
import {NachklangFeedbackDB} from '../Feedback.db';
import logger from '../../../middleware/logger';
// Newsletter opt-ins sync to Salesforce, which already runs a full
// double-opt-in subscription flow (Person Account for existing constituents,
@@ -9,8 +9,7 @@ import {salesforceApexRestPost} from '../../../common/salesforce.client.js';
// This is the one file that knows that contract exists; submissions.service.ts
// only ever calls syncNewsletterSignup(signupId) after its own transaction
// commits, fire-and-forget, so a Salesforce outage can never delay or fail a
// visitor's feedback submission. The OAuth token cache and retry-once-on-401
// live in common/salesforce.client.ts, shared with the transactional-email relay.
// visitor's feedback submission.
interface SalesforceSuccessResponse {
status: 'PENDING_CONFIRMATION' | 'ALREADY_SUBSCRIBED';
@@ -27,8 +26,54 @@ interface NewsletterSignupRow {
event_name: string;
}
const postSignup = (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise<SalesforceSuccessResponse> =>
salesforceApexRestPost<SalesforceSuccessResponse>('/services/apexrest/newsletter/signup', payload);
// Salesforce's client-credentials token response does not reliably include
// expires_in, so the cache lifetime is a conservative guess rather than a
// value read from the response - a 401 on the next call just triggers a
// fresh fetch (see the retry-once logic in postSignup).
const TOKEN_CACHE_MS = 15 * 60 * 1000;
let cachedToken: {accessToken: string; fetchedAt: number} | null = null;
const getAccessToken = async (forceRefresh: boolean): Promise<string> => {
if (!forceRefresh && cachedToken && Date.now() - cachedToken.fetchedAt < TOKEN_CACHE_MS) {
return cachedToken.accessToken;
}
const instanceUrl = process.env.SALESFORCE_API_URL;
const clientId = process.env.SALESFORCE_CLIENT_ID;
const clientSecret = process.env.SALESFORCE_CLIENT_SECRET;
if (!instanceUrl || !clientId || !clientSecret) {
throw new Error('SALESFORCE_ENABLED is true but SALESFORCE_API_URL/SALESFORCE_CLIENT_ID/SALESFORCE_CLIENT_SECRET are not fully configured.');
}
const res = await axios.post(
`${instanceUrl}/services/oauth2/token`,
new URLSearchParams({grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret}).toString(),
{headers: {'Content-Type': 'application/x-www-form-urlencoded'}, timeout: 10000}
);
cachedToken = {accessToken: res.data.access_token, fetchedAt: Date.now()};
return cachedToken.accessToken;
};
const postSignup = async (payload: {firstName: string; lastName: string; email: string; eventName: string}): Promise<SalesforceSuccessResponse> => {
const instanceUrl = process.env.SALESFORCE_API_URL;
const url = `${instanceUrl}/services/apexrest/newsletter/signup`;
try {
const token = await getAccessToken(false);
const res = await axios.post<SalesforceSuccessResponse>(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
} catch (err: any) {
// The cached token may have expired server-side even though our
// conservative local TTL hasn't - retry once with a forced refresh
// before treating this as a real failure.
if (err?.response?.status === 401) {
const token = await getAccessToken(true);
const res = await axios.post<SalesforceSuccessResponse>(url, payload, {headers: {Authorization: `Bearer ${token}`}, timeout: 10000});
return res.data;
}
throw err;
}
};
const markSynced = async (signupId: number, externalId: string): Promise<void> => {
let conn = await NachklangFeedbackDB.getConnection();
@@ -1,5 +1,5 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {EventConfig, EventSummary, Question, Song} from '../feedback.interface.js';
import {NachklangFeedbackDB} from '../Feedback.db';
import {EventConfig, EventSummary, Question, Song} from '../feedback.interface';
/**
* Returns all events currently eligible to receive feedback:
+5 -5
View File
@@ -2,11 +2,11 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import logger from '../../../middleware/logger.js';
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service.js';
import {submitFeedback} from './submissions.service.js';
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit.js';
import {sendServerError} from '../feedback.errors.js';
import logger from '../../../middleware/logger';
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service';
import {submitFeedback} from './submissions.service';
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -1,9 +1,9 @@
import {NachklangFeedbackDB} from '../Feedback.db.js';
import {QuestionType} from '../feedback.interface.js';
import {getEventConfigBySlug} from './events.public.service.js';
import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface.js';
import {syncNewsletterSignup} from '../integrations/salesforce.service.js';
import logger from '../../../middleware/logger.js';
import {NachklangFeedbackDB} from '../Feedback.db';
import {QuestionType} from '../feedback.interface';
import {getEventConfigBySlug} from './events.public.service';
import {AnswerInput, GuestBookInput, NewsletterInput, SubmissionRequestBody} from './submission.interface';
import {syncNewsletterSignup} from '../integrations/salesforce.service';
import logger from '../../../middleware/logger';
// Bump when the privacy/consent copy shown next to the newsletter opt-in
// changes; recorded per-signup so a past consent's exact wording is provable.
+4 -2
View File
@@ -1,5 +1,6 @@
import * as dotenv from 'dotenv';
import mariadb from 'mariadb';
const mariadb = require('mariadb');
dotenv.config();
@@ -9,7 +10,8 @@ export namespace NachklangTicketsDB {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.TICKETS_DB,
connectionLimit: 5
connectionLimit: 5,
autoCommit: false
});
export const getConnection = async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import express from 'express';
import {adminRouter} from './admin/admin.router.js';
import {publicRouter} from './public/public.router.js';
import {adminRouter} from './admin/admin.router';
import {publicRouter} from './public/public.router';
export const ticketsRouter = express.Router();
+7 -8
View File
@@ -1,8 +1,8 @@
import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../tickets.auth.js';
import {vouchersAdminRouter} from './vouchers.admin.router.js';
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js';
import {eventsAdminRouter} from './events.admin.router.js';
import {requireAdminAuth} from '../tickets.auth';
import {vouchersAdminRouter} from './vouchers.admin.router';
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router';
import {eventsAdminRouter} from './events.admin.router';
export const adminRouter = express.Router();
@@ -16,15 +16,14 @@ adminRouter.use(requireAdminAuth);
* get:
* summary: Validate the current admin session
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName});
+14 -22
View File
@@ -1,6 +1,6 @@
import express, {Request, Response} from 'express';
import * as EventsAdminService from './events.admin.service.js';
import {sendServerError} from '../tickets.errors.js';
import * as EventsAdminService from './events.admin.service';
import {sendServerError} from '../tickets.errors';
export const eventsAdminRouter = express.Router();
@@ -11,15 +11,14 @@ export const eventsAdminRouter = express.Router();
* summary: List concerts for the admin event picker
* description: Wraps the Calendar module's public-calendar admin listing (includes DRAFT events).
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
@@ -36,15 +35,14 @@ eventsAdminRouter.get('/', async (req: Request, res: Response) => {
* summary: List public-calendar events not yet added to the ticket shop
* description: Source list for the "add a concert" picker - the public calendar holds more than concerts, so events only appear in the ticket shop once explicitly added.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
try {
@@ -60,9 +58,9 @@ eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
* get:
* summary: Get a concert's voucher/capacity stats
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -77,8 +75,6 @@ eventsAdminRouter.get('/available', async (req: Request, res: Response) => {
* $ref: '#/components/schemas/EventStats'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) => {
try {
@@ -95,9 +91,9 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
* summary: Set a concert's voucher settings
* description: Upserts capacity (null = uncapped), redemption deadline (null = none), and whether to collect a mailing address.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -127,8 +123,6 @@ eventsAdminRouter.get('/:eventId/stats', async (req: Request, res: Response) =>
* description: Saved
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response) => {
try {
@@ -155,9 +149,9 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
* summary: Remove an event from the ticket shop
* description: Deletes its settings row, so it drops out of the picker and reappears in the "add" list. Refused with 409 if vouchers already reference the event - existing vouchers/redemptions stay valid either way.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: eventId
* required: true
@@ -170,8 +164,6 @@ eventsAdminRouter.put('/:eventId/settings', async (req: Request, res: Response)
* description: Vouchers already reference this event
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
eventsAdminRouter.delete('/:eventId/settings', async (req: Request, res: Response) => {
try {
@@ -1,7 +1,7 @@
import * as CalendarEventsService from '../../calendar/events/events.service.js';
import {NachklangTicketsDB} from '../Tickets.db.js';
import {getEventTicketState} from '../tickets.capacity.js';
import {EventStats, EventTicketSettings} from '../tickets.interface.js';
import * as CalendarEventsService from '../../calendar/events/events.service';
import {NachklangTicketsDB} from '../Tickets.db';
import {getEventTicketState} from '../tickets.capacity';
import {EventStats, EventTicketSettings} from '../tickets.interface';
// Concerts are managed on the public calendar (calendarId 1) - see
// docs/plan-ticket-shop.md. getAllEventsAdmin includes DRAFT events so
@@ -1,6 +1,6 @@
import express, {Request, Response} from 'express';
import * as RedemptionsAdminService from './redemptions.admin.service.js';
import {sendServerError} from '../tickets.errors.js';
import * as RedemptionsAdminService from './redemptions.admin.service';
import {sendServerError} from '../tickets.errors';
export const redemptionsAdminRouter = express.Router();
@@ -11,9 +11,9 @@ export const redemptionsAdminRouter = express.Router();
* summary: List redemptions (admin)
* description: Filterable by event and status (ACTIVE/UNDONE).
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query
* name: eventId
* schema:
@@ -33,8 +33,6 @@ export const redemptionsAdminRouter = express.Router();
* $ref: '#/components/schemas/RedemptionSummary'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
try {
@@ -52,9 +50,9 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
* get:
* summary: Get a single redemption (admin)
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: redemptionId
* required: true
@@ -67,15 +65,13 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
* description: Unknown redemption
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
* patch:
* summary: Edit a redemption's contact info and/or guest list
* description: Only fields present in the body are changed. Growing the guest count is re-checked against the voucher's max guests and the event's remaining capacity. Logs to the audit trail with an optional admin-supplied reason.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: redemptionId
* required: true
@@ -109,8 +105,6 @@ redemptionsAdminRouter.get('/', async (req: Request, res: Response) => {
* description: Not active, exceeds max guests, or exceeds remaining capacity
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
redemptionsAdminRouter.get('/:redemptionId', async (req: Request, res: Response) => {
try {
@@ -167,9 +161,9 @@ redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Respons
* summary: Undo a redemption
* description: Reopens the code (back to UNUSED) and marks the redemption UNDONE. Guest data is kept for the audit trail; a later re-redemption creates a new redemption record.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: redemptionId
* required: true
@@ -192,8 +186,6 @@ redemptionsAdminRouter.patch('/:redemptionId', async (req: Request, res: Respons
* description: Redemption is not active
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Response) => {
try {
@@ -212,66 +204,15 @@ redemptionsAdminRouter.post('/:redemptionId/undo', async (req: Request, res: Res
}
});
/**
* @swagger
* /tickets/admin/redemptions/{redemptionId}/resend-confirmation:
* post:
* summary: Resend the redemption confirmation email
* description: Rebuilds the confirmation email from the stored redemption data and sends it again, then records the outcome on the redemption. Intended for redemptions whose original confirmation email failed.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - in: path
* name: redemptionId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The email was accepted for delivery
* 404:
* description: Unknown redemption
* 409:
* description: Redemption is not active
* 502:
* description: The email relay rejected the send
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Request, res: Response) => {
try {
const result = await RedemptionsAdminService.resendRedemptionConfirmation(Number(req.params.redemptionId));
switch (result) {
case 'SENT':
res.status(200).send({status: 'OK'});
return;
case 'NOT_FOUND':
res.status(404).send({status: 'NOT_FOUND'});
return;
case 'NOT_ACTIVE':
res.status(409).send({status: 'NOT_ACTIVE', message: 'This redemption is not active.'});
return;
case 'FAILED':
res.status(502).send({status: 'SEND_FAILED', message: 'Die E-Mail konnte nicht versendet werden. Bitte später erneut versuchen.'});
return;
}
} catch (e: any) {
sendServerError(res, e);
}
});
/**
* @swagger
* /tickets/admin/vouchers/{code}/history:
* get:
* summary: Get a voucher's admin-action audit trail
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: code
* required: true
@@ -288,8 +229,6 @@ redemptionsAdminRouter.post('/:redemptionId/resend-confirmation', async (req: Re
* $ref: '#/components/schemas/AuditLogEntry'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
export const voucherHistoryRouter = express.Router();
voucherHistoryRouter.get('/:code/history', async (req: Request, res: Response) => {
@@ -1,8 +1,7 @@
import {NachklangTicketsDB} from '../Tickets.db.js';
import {getEventTicketState} from '../tickets.capacity.js';
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email.js';
import {AuditLogEntry, RedemptionSummary} from '../tickets.interface.js';
import {isValidEmail} from '../tickets.validation.js';
import {NachklangTicketsDB} from '../Tickets.db';
import {getEventTicketState} from '../tickets.capacity';
import {AuditLogEntry, RedemptionSummary} from '../tickets.interface';
import {isValidEmail} from '../tickets.validation';
const mapRedemptionRow = (row: any, guests: string[]): RedemptionSummary => ({
redemptionId: row.redemption_id,
@@ -14,8 +13,7 @@ const mapRedemptionRow = (row: any, guests: string[]): RedemptionSummary => ({
contactAddress: row.contact_address,
guestCount: row.guest_count,
guests,
redeemedAt: row.redeemed_at,
confirmationEmailStatus: row.confirmation_email_status ?? null
redeemedAt: row.redeemed_at
});
export interface ListRedemptionsFilter {
@@ -229,29 +227,6 @@ export const editRedemption = async (redemptionId: number, input: EditRedemption
}
};
export type ResendConfirmationResult = 'SENT' | 'FAILED' | 'NOT_FOUND' | 'NOT_ACTIVE';
/**
* Rebuilds and re-sends the redemption confirmation email from the stored
* redemption data, then records the new outcome on the row. Used by the admin
* UI's "resend" action on a redemption whose confirmation email failed. Only
* active redemptions can be resent.
*/
export const resendRedemptionConfirmation = async (redemptionId: number): Promise<ResendConfirmationResult> => {
const redemption = await getRedemption(redemptionId);
if (!redemption) return 'NOT_FOUND';
if (redemption.status !== 'ACTIVE') return 'NOT_ACTIVE';
const sent = await sendRedemptionConfirmation({
eventId: redemption.eventId,
contactName: redemption.contactName,
contactEmail: redemption.contactEmail,
guestNames: redemption.guests
});
await recordConfirmationEmailResult(redemptionId, sent);
return sent ? 'SENT' : 'FAILED';
};
export const getAuditHistory = async (code: string): Promise<AuditLogEntry[]> => {
let conn = await NachklangTicketsDB.getConnection();
try {
@@ -1,6 +1,6 @@
import express, {Request, Response} from 'express';
import * as VouchersAdminService from './vouchers.admin.service.js';
import {sendServerError} from '../tickets.errors.js';
import * as VouchersAdminService from './vouchers.admin.service';
import {sendServerError} from '../tickets.errors';
export const vouchersAdminRouter = express.Router();
@@ -11,9 +11,9 @@ export const vouchersAdminRouter = express.Router();
* summary: List vouchers (admin)
* description: Filterable by event and status.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: query
* name: eventId
* schema:
@@ -33,8 +33,6 @@ export const vouchersAdminRouter = express.Router();
* $ref: '#/components/schemas/VoucherCode'
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
try {
@@ -53,8 +51,9 @@ vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
* summary: Batch-generate wildcard codes
* description: Generates `quantity` codes sharing the same eligible events and max-guest count, grouped under one batchId.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody:
* required: true
* content:
@@ -88,8 +87,6 @@ vouchersAdminRouter.get('/', async (req: Request, res: Response) => {
* description: Invalid input
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
try {
@@ -115,8 +112,9 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
* summary: Bulk-create personalized codes
* description: One code per row (name, email, eligible events, max guests), grouped under one batchId.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* requestBody:
* required: true
* content:
@@ -149,8 +147,6 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
* description: Invalid input
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) => {
try {
@@ -173,9 +169,9 @@ vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) =>
* get:
* summary: Get a single voucher (admin)
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: code
* required: true
@@ -188,8 +184,6 @@ vouchersAdminRouter.post('/personalized', async (req: Request, res: Response) =>
* description: Unknown code
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
try {
@@ -211,9 +205,9 @@ vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
* summary: Void an unredeemed code
* description: Only allowed while the code is UNUSED. Logs to the voucher's audit trail.
* tags: [tickets-admin]
* security:
* - AdminSessionCookie: []
* parameters:
* - $ref: '#/components/parameters/SessionIdHeader'
* - $ref: '#/components/parameters/SessionKeyHeader'
* - in: path
* name: code
* required: true
@@ -236,8 +230,6 @@ vouchersAdminRouter.get('/:code', async (req: Request, res: Response) => {
* description: Code is not in UNUSED status
* 401:
* description: Unauthorized
* 403:
* description: Signed in without the permission for this app, or account disabled
*/
vouchersAdminRouter.post('/:code/void', async (req: Request, res: Response) => {
try {
@@ -1,8 +1,8 @@
import {Guid} from 'guid-typescript';
import {NachklangTicketsDB} from '../Tickets.db.js';
import {generateUniqueCode} from '../tickets.codes.js';
import {VoucherCode, VoucherStatus} from '../tickets.interface.js';
import {isValidEmail} from '../tickets.validation.js';
import {NachklangTicketsDB} from '../Tickets.db';
import {generateUniqueCode} from '../tickets.codes';
import {VoucherCode, VoucherStatus} from '../tickets.interface';
import {isValidEmail} from '../tickets.validation';
export interface WildcardGenerateInput {
eventIds: number[];
+3 -3
View File
@@ -1,7 +1,7 @@
import express, {Request, Response} from 'express';
import * as VoucherPublicService from './voucher.public.service.js';
import {sendServerError} from '../tickets.errors.js';
import {hashIp, redeemLimiter, validateLimiter} from '../tickets.ratelimit.js';
import * as VoucherPublicService from './voucher.public.service';
import {sendServerError} from '../tickets.errors';
import {hashIp, redeemLimiter, validateLimiter} from '../tickets.ratelimit';
export const publicRouter = express.Router();
@@ -1,10 +1,19 @@
import * as EventsService from '../../calendar/events/events.service.js';
import logger from '../../../middleware/logger.js';
import {NachklangTicketsDB} from '../Tickets.db.js';
import {getEventTicketState} from '../tickets.capacity.js';
import {recordConfirmationEmailResult, sendRedemptionConfirmation} from '../tickets.confirmation-email.js';
import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface.js';
import {isValidEmail} from '../tickets.validation.js';
import * as EventsService from '../../calendar/events/events.service';
import * as IcalService from '../../calendar/events/icalgenerator.service';
import {MailService} from '../../../common/common.mail.nodemailer';
import logger from '../../../middleware/logger';
import {NachklangTicketsDB} from '../Tickets.db';
import {getEventTicketState} from '../tickets.capacity';
import {EligibleEvent, RedeemRequest, VoucherValidation} from '../tickets.interface';
import {isValidEmail} from '../tickets.validation';
const formatGermanDateTime = (date: Date): string => {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'Europe/Berlin'
}).format(date);
};
/**
* Builds the eligible-events list for a code: for each event it's linked
@@ -164,21 +173,43 @@ export const redeemVoucher = async (code: string, request: RedeemRequest): Promi
}
// Sent after commit, mirroring the Calendar/Feedback convention: a mail
// delivery failure shouldn't roll back a successful redemption, and the
// guest has in fact already secured their spot. The send itself no longer
// throws on a delivery problem; its result is recorded on the redemption
// so staff can spot and resend a failed confirmation from the admin UI.
// delivery failure shouldn't roll back a successful redemption. Caught
// rather than left to propagate - the redemption already succeeded, so
// a mail-server hiccup must not turn into a false failure response to
// a guest who has, in fact, already secured their spot.
try {
const sent = await sendRedemptionConfirmation({
eventId,
contactName: request.contactName,
contactEmail: request.contactEmail,
guestNames: request.guests.map(g => g.name)
});
await recordConfirmationEmailResult(redemptionId, sent);
await sendConfirmationEmail(eventId, request, redemptionId);
} catch (e: any) {
logger.error('Redemption ' + redemptionId + ' committed but the confirmation email step failed: ' + e.message);
logger.error('Redemption ' + redemptionId + ' succeeded but confirmation email failed to send: ' + e.message);
}
return {status: 'OK', redemptionId};
};
const sendConfirmationEmail = async (eventId: number, request: RedeemRequest, redemptionId: number): Promise<void> => {
const event = await EventsService.getEventById(eventId);
if (!event) return;
const guestList = request.guests.map(g => `- ${g.name}`).join('\n');
const body = `Hallo ${request.contactName},\n\n` +
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
`Ort: ${event.location}\n\n` +
`Angemeldete Gäste:\n${guestList}\n\n` +
`Wir freuen uns auf dich!\n\nDein Nachklang-Team`;
let icsAttachment;
try {
const ics = await IcalService.convertToIcal([event]);
icsAttachment = [{filename: 'konzert.ics', content: ics, contentType: 'text/calendar'}];
} catch {
icsAttachment = undefined;
}
await MailService.sendMail(
request.contactEmail,
`Bestätigung: ${event.name}`,
body,
{attachments: icsAttachment}
);
};
+49 -17
View File
@@ -1,23 +1,20 @@
import {requireAppAccess} from '../admin/admin.middleware.js';
import express from 'express';
import * as UserService from '../calendar/users/users.service';
import {sendServerError} from './tickets.errors';
/**
* Mirrors the Feedback module's feedback.auth.ts: this is the ONLY place in
* the tickets module that knows how admin authentication works. No route
* handler and no service outside this file may read session headers or
* resolve a user itself.
* handler and no service outside this file may import users.service, read
* session headers, or touch bcrypt.
*
* Today: the shared admin identity in `src/models/admin/`. A session cookie
* set by /admin/auth on admin.nachklang.art, plus a `tickets` permission on
* the account. Both are re-checked on every request, so disabling a user or
* taking their tickets permission away takes effect immediately.
* Today: reuses the existing Calendar users/sessions mechanism. Any
* activated @nachklang.art account may administer vouchers - no roles, same
* policy as Feedback (see docs/plan-ticket-shop.md). A dedicated
* roles/permissions model is explicitly out of scope for v1.
*
* Before 2026-09-06 this was a header session against the calendar users
* table, and any activated @nachklang.art account could administer vouchers
* (see docs/plan-ticket-shop.md, which called a roles model out of scope for
* v1). It is in scope now, and lives in the admin module rather than here.
*
* Explicitly forbidden: accepting session credentials from query parameters -
* see DEFERRED_SECURITY.md item 1.
* Explicitly forbidden: accepting sessionId/sessionKey from query
* parameters - headers only (see DEFERRED_SECURITY.md item 1).
*/
export interface AdminIdentity {
@@ -26,6 +23,41 @@ export interface AdminIdentity {
displayName: string;
}
// On failure: 401 when not signed in, 403 when signed in without the tickets
// permission.
export const requireAdminAuth = requireAppAccess('tickets');
export type AdminAuthenticator = (req: express.Request) => Promise<AdminIdentity | null>;
export const sessionHeaderAuthenticator: AdminAuthenticator = async (req) => {
const sessionId = req.header('X-Session-Id');
const sessionKey = req.header('X-Session-Key');
if (!sessionId || !sessionKey) {
return null;
}
const ip = req.ip || '';
const user = await UserService.checkSession(sessionId, sessionKey, ip);
if (!user || !user.isActive) {
return null;
}
return {
id: String(user.userId),
email: user.email,
displayName: user.fullName
};
};
export const activeAuthenticator: AdminAuthenticator = sessionHeaderAuthenticator;
export const requireAdminAuth: express.RequestHandler = async (req, res, next) => {
try {
const identity = await activeAuthenticator(req);
if (!identity) {
res.status(401).send({status: 'UNAUTHORIZED', message: 'Anmeldung erforderlich.'});
return;
}
res.locals.admin = identity;
next();
} catch (e: any) {
sendServerError(res, e);
}
};
@@ -1,76 +0,0 @@
import * as EventsService from '../calendar/events/events.service.js';
import * as IcalService from '../calendar/events/icalgenerator.service.js';
import {MailService} from '../../common/common.mail.js';
import logger from '../../middleware/logger.js';
import {NachklangTicketsDB} from './Tickets.db.js';
export type ConfirmationEmailStatus = 'SENT' | 'FAILED';
// The redemption confirmation email is built and sent from here so the public
// redeem path and the admin "resend" action share one copy of the German text
// and the .ics attachment logic.
const formatGermanDateTime = (date: Date): string =>
new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'Europe/Berlin'
}).format(date);
export interface ConfirmationRecipient {
eventId: number;
contactName: string;
contactEmail: string;
guestNames: string[];
}
/**
* Sends the redemption confirmation email for one redemption. Returns whether
* the mail was accepted by the relay. Never throws: a missing event is treated
* as "not sent", and MailService.sendMail already swallows delivery failures.
*/
export const sendRedemptionConfirmation = async (recipient: ConfirmationRecipient): Promise<boolean> => {
const event = await EventsService.getEventById(recipient.eventId);
if (!event) {
logger.error('Confirmation email skipped: event ' + recipient.eventId + ' no longer exists');
return false;
}
const guestList = recipient.guestNames.map(name => `- ${name}`).join('\n');
const body =
`Hallo ${recipient.contactName},\n\n` +
`vielen Dank für deine Anmeldung zu "${event.name}"!\n\n` +
`Termin: ${formatGermanDateTime(event.startDateTime)}\n` +
`Ort: ${event.location}\n\n` +
`Angemeldete Gäste:\n${guestList}\n\n` +
`Wir freuen uns auf dich!\n\nDein Nachklang-Team`;
let icsAttachment;
try {
const ics = await IcalService.convertToIcal([event]);
icsAttachment = [{filename: 'konzert.ics', content: ics, contentType: 'text/calendar'}];
} catch (e: any) {
// Non-fatal: the confirmation still goes out, just without the calendar file.
logger.warn('Confirmation email for event ' + recipient.eventId + ' sent without .ics attachment: ' + e?.message);
icsAttachment = undefined;
}
return MailService.sendMail(recipient.contactEmail, `Bestätigung: ${event.name}`, body, {attachments: icsAttachment});
};
/**
* Records the outcome of a confirmation-email send on the redemption row so the
* admin UI can flag failures. Best-effort: a failure to write the flag is
* logged, never thrown - the redemption itself already succeeded.
*/
export const recordConfirmationEmailResult = async (redemptionId: number, sent: boolean): Promise<void> => {
const status: ConfirmationEmailStatus = sent ? 'SENT' : 'FAILED';
let conn = await NachklangTicketsDB.getConnection();
try {
await conn.query('UPDATE redemptions SET confirmation_email_status = ? WHERE redemption_id = ?', [status, redemptionId]);
} catch (err: any) {
logger.error('Could not record confirmation email status for redemption ' + redemptionId + ': ' + err?.message);
} finally {
await conn.end();
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
import logger from '../../middleware/logger';
/**
* The tickets module's standard catch-block response: log with a reference
-7
View File
@@ -109,11 +109,6 @@
* redeemedAt:
* type: string
* format: date-time
* confirmationEmailStatus:
* type: string
* enum: [SENT, FAILED]
* nullable: true
* description: Outcome of the redemption confirmation email. null until the send resolves.
* VoucherCode:
* type: object
* required: [code, status, maxGuests, createdByEmail, createdAt, eligibleEventIds]
@@ -263,8 +258,6 @@ export interface RedemptionSummary {
guestCount: number;
guests: string[];
redeemedAt: Date;
// null until the post-redemption confirmation email send resolves.
confirmationEmailStatus: 'SENT' | 'FAILED' | null;
}
export interface VoucherCode {
-96
View File
@@ -1,96 +0,0 @@
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();
});
});
-144
View File
@@ -1,144 +0,0 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
// admin.config calls dotenv.config(), which would read the repo's own .env and
// quietly reintroduce NODE_ENV=development - the exact value several of these
// cases exist to remove. Stub it so the tests see only what they set.
vi.mock('dotenv', () => ({config: vi.fn()}));
/**
* admin.config reads the environment once at import, so every case here has to
* reset the module registry and re-import it. The two things worth pinning are
* the ones that are silent when wrong: which client-IP header is trusted, and
* whether an unset NODE_ENV counts as production.
*/
const ORIGINAL_ENV = {...process.env};
const loadConfig = async () => {
vi.resetModules();
return import('../../src/models/admin/admin.config.js');
};
beforeEach(() => {
process.env = {...ORIGINAL_ENV};
// dotenv.config() in admin.config does not overwrite what is already set,
// so setting these here is enough to keep the local .env out of the test.
process.env.NODE_ENV = 'test';
delete process.env.CLIENT_IP_HEADERS;
delete process.env.TRUSTED_PROXY_IPS;
});
afterEach(() => {
process.env = {...ORIGINAL_ENV};
});
describe('CLIENT_IP_HEADERS', () => {
it('defaults to the single header Plesk nginx sets', async () => {
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
it('reads a comma-separated list', async () => {
process.env.CLIENT_IP_HEADERS = 'x-real-ip, cf-connecting-ip';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip', 'cf-connecting-ip']);
});
it('trusts nothing when set to "none"', async () => {
// The escape hatch. An empty list is what better-auth reads as "no
// headers" - it only falls back to its own default when the option is
// absent - so this really does stop any header being believed.
process.env.CLIENT_IP_HEADERS = 'none';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual([]);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(true);
});
it('accepts the hatch case-insensitively and with stray whitespace', async () => {
process.env.CLIENT_IP_HEADERS = ' NONE ';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual([]);
});
it('treats an empty value as "use the default", not as the hatch', async () => {
// A blank line in a .env must not silently change how requests are
// bucketed - only the explicit word does that.
process.env.CLIENT_IP_HEADERS = '';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-real-ip']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
it('does not mistake a header actually named none-ish for the hatch', async () => {
process.env.CLIENT_IP_HEADERS = 'x-none';
const config = await loadConfig();
expect(config.CLIENT_IP_HEADERS).toEqual(['x-none']);
expect(config.TRUST_NO_CLIENT_IP_HEADER).toBe(false);
});
});
describe('APP_ORIGINS', () => {
beforeEach(() => {
delete process.env.APP_ORIGINS;
});
// These reach better-auth's trustedOrigins, and the step 4 cutover made the
// tickets and feedback origins load-bearing: without them their sign-out
// call is rejected while everything else still works.
it('defaults to the three production frontends', async () => {
const config = await loadConfig();
expect(config.APP_ORIGINS).toEqual([
'https://tickets.nachklang.art',
'https://feedback.nachklang.art',
'https://calendar.nachklang.art'
]);
});
it('is overridden wholesale by the environment, for a staging host', async () => {
process.env.APP_ORIGINS = 'https://tickets.staging.example, https://feedback.staging.example/';
const config = await loadConfig();
expect(config.APP_ORIGINS).toEqual([
'https://tickets.staging.example',
// Trailing slash stripped: an origin with one never matches.
'https://feedback.staging.example'
]);
});
it('always includes the admin app itself in ADMIN_ALLOWED_ORIGINS', async () => {
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
const config = await loadConfig();
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://admin.nachklang.art');
expect(config.ADMIN_ALLOWED_ORIGINS).toContain('https://tickets.nachklang.art');
});
});
describe('isProd', () => {
it('is false only for the explicit relaxed environments', async () => {
process.env.NODE_ENV = 'development';
expect((await loadConfig()).isProd).toBe(false);
process.env.NODE_ENV = 'test';
expect((await loadConfig()).isProd).toBe(false);
});
it('treats an unset NODE_ENV as production, which is what a bare vhost gives', async () => {
delete process.env.NODE_ENV;
// Strict mode refuses to boot without these; supply them so the import
// gets far enough to answer the question being asked.
process.env.BETTER_AUTH_SECRET = 'x'.repeat(48);
process.env.API_BASE_URL = 'https://api.nachklang.art';
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
expect((await loadConfig()).isProd).toBe(true);
});
it('refuses to start without a signing key outside development', async () => {
delete process.env.NODE_ENV;
delete process.env.BETTER_AUTH_SECRET;
process.env.API_BASE_URL = 'https://api.nachklang.art';
process.env.ADMIN_APP_URL = 'https://admin.nachklang.art';
await expect(loadConfig()).rejects.toThrow(/BETTER_AUTH_SECRET/);
});
});
-71
View File
@@ -1,71 +0,0 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/common/common.mail.js', () => ({
MailService: {sendMail: vi.fn()}
}));
vi.mock('../../src/models/admin/admin.config.js', () => ({
ADMIN_APP_URL: 'https://admin.nachklang.art'
}));
import {MailService} from '../../src/common/common.mail.js';
import {sendInvitationMail, sendPasswordResetMail} from '../../src/models/admin/admin.mail.js';
const sendMail = MailService.sendMail as Mock;
beforeEach(() => {
sendMail.mockReset();
sendMail.mockResolvedValue(true);
});
describe('sendInvitationMail', () => {
it('points at the admin app and carries the token in the query string', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok-en_123', new Date('2026-09-12T10:00:00Z'));
const [to, subject, text, options] = sendMail.mock.calls[0];
expect(to).toBe('a@nachklang.art');
expect(subject).toBeTruthy();
expect(text).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
expect(options.html).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123');
});
it('url-encodes a token containing url-significant characters', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'a+b/c=d', new Date());
const [, , text] = sendMail.mock.calls[0];
expect(text).toContain('token=a%2Bb%2Fc%3Dd');
});
it('sends both a text and an html part', async () => {
await sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date());
const [, , text, options] = sendMail.mock.calls[0];
expect(text.length).toBeGreaterThan(0);
expect(options.html).toContain('<html');
});
it('escapes a name that contains html', async () => {
await sendInvitationMail('a@nachklang.art', '<script>alert(1)</script>', 'tok', new Date());
const [, , , options] = sendMail.mock.calls[0];
expect(options.html).not.toContain('<script>');
expect(options.html).toContain('&lt;script&gt;');
});
it('reports a delivery failure to the caller rather than throwing', async () => {
sendMail.mockResolvedValue(false);
await expect(sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date())).resolves.toBe(false);
});
});
describe('sendPasswordResetMail', () => {
it('uses the url better-auth generated, unchanged', async () => {
const url = 'https://api.nachklang.art/admin/auth/reset-password/abc?callbackURL=x';
await sendPasswordResetMail('a@nachklang.art', 'Anna', url);
const [, , text, options] = sendMail.mock.calls[0];
expect(text).toContain(url);
expect(options.html).toContain('https://api.nachklang.art/admin/auth/reset-password/abc');
});
});
-222
View File
@@ -1,222 +0,0 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import {Request, Response} from 'express';
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models/admin/admin.middleware.js';
const mockGetSession = auth.api.getSession as unknown as Mock;
const mockLoadAccess = UsersService.loadAccess as Mock;
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeRes = (): Response => {
const res: any = {};
res.status = vi.fn().mockReturnValue(res);
res.send = vi.fn().mockReturnValue(res);
res.locals = {};
return res as Response;
};
const activeUser = {
id: 'u1',
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
permissions: [
{app: 'feedback', role: 'access'},
{app: 'admin', role: 'access'}
],
apps: ['feedback', 'admin']
};
describe('resolveAccess', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('returns null without a valid session', async () => {
mockGetSession.mockResolvedValue(null);
expect(await resolveAccess(makeReq())).toBeNull();
expect(mockLoadAccess).not.toHaveBeenCalled();
});
it('returns null when the session points at a user row that is gone', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(null);
expect(await resolveAccess(makeReq())).toBeNull();
});
it('resolves identity and permissions in a single permission query', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
expect(await resolveAccess(makeReq())).toEqual({
id: 'u1',
email: 'a@nachklang.art',
displayName: 'A',
disabled: false,
permissions: [
{app: 'feedback', role: 'access'},
{app: 'admin', role: 'access'}
],
apps: ['feedback', 'admin']
});
// No cookieCache: exactly one lookup per request, never zero.
expect(mockLoadAccess).toHaveBeenCalledTimes(1);
});
});
describe('requireSignedIn', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null);
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it('403s a disabled user that still holds a valid cookie', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('admits a signed-in user with no app permissions at all', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, apps: []});
const res = makeRes();
const next = vi.fn();
await requireSignedIn(makeReq(), res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin.apps).toEqual([]);
});
});
describe('requireAppAccess', () => {
beforeEach(() => {
mockGetSession.mockReset();
mockLoadAccess.mockReset();
});
it('401s without a session', async () => {
mockGetSession.mockResolvedValue(null);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(401);
});
it('403s a signed-in user without that app permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'feedback', role: 'access'}],
apps: ['feedback']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('403s a disabled user even when they hold the permission', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({...activeUser, disabled: true});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('passes through and exposes the identity the feedback/tickets services expect', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'A'});
});
it('500s (never allows through) when the permission query throws', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockRejectedValue(new Error('db down'));
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(next).not.toHaveBeenCalled();
});
// The seam a finer per-app permission arrives through. Nothing passes a role
// today, so these two pin the behaviour before there is anything to break.
it('403s when a specific role is required and the user only holds another', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'tickets', role: 'access'}],
apps: ['tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('passes when the user holds exactly the required role', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [
{app: 'tickets', role: 'access'},
{app: 'tickets', role: 'refund'}
],
apps: ['tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets', 'refund')(makeReq(), res, next);
expect(next).toHaveBeenCalled();
});
});
-99
View File
@@ -1,99 +0,0 @@
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([]);
});
});
-107
View File
@@ -1,107 +0,0 @@
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
import express, {Request, Response} from 'express';
/**
* Shared body for the two cutover tests (2026-09-06). feedback.auth.ts and
* tickets.auth.ts used to carry their own header-session authenticator against
* the calendar users table; both are now one binding to the shared admin gate.
*
* What is worth asserting is not how that gate works - admin.middleware.test.ts
* owns that - but that each module is bound to *its own* app. The mocks live in
* the calling file because vi.mock is per-module-graph; only the assertions are
* shared.
*
* There used to be a tripwire here asserting neither module fell back to the
* calendar's header sessions. It went with step 5: the calendar users service
* no longer exists, so there is nothing left to fall back to and nothing to
* assert against.
*/
export interface BindingMocks {
/** auth.api.getSession from the mocked admin.auth.js */
getSession: Mock;
/** loadAccess from the mocked users.admin.service.js */
loadAccess: 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 userWith = (...apps: string[]) => ({
id: 'u1',
email: 'a@nachklang.art',
displayName: 'Anna Admin',
disabled: false,
permissions: apps.map(app => ({app, role: 'access'})),
apps
});
export const describeAdminBinding = (
app: string,
otherApp: string,
middleware: express.RequestHandler,
mocks: () => BindingMocks
): void => {
describe(`${app} requireAdminAuth`, () => {
let m: BindingMocks;
const run = async () => {
const res = makeRes();
const next = vi.fn();
await middleware(makeReq(), res, next);
return {res, next};
};
beforeEach(() => {
m = mocks();
m.getSession.mockReset();
m.loadAccess.mockReset();
});
it('responds 401 and does not call next() without a session', async () => {
m.getSession.mockResolvedValue(null);
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});
it(`responds 403 for a signed-in user who only has ${otherApp}`, async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue(userWith(otherApp));
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('responds 403 for a disabled user who still holds the permission', async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue({...userWith(app), disabled: true});
const {res, next} = await run();
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
});
it('sets res.locals.admin and calls next() with the permission', async () => {
m.getSession.mockResolvedValue({user: {id: 'u1'}});
m.loadAccess.mockResolvedValue(userWith(app));
const {res, next} = await run();
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
});
});
};
-199
View File
@@ -1,199 +0,0 @@
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');
});
});
-49
View File
@@ -1,49 +0,0 @@
import {describe, expect, it, beforeEach} from 'vitest';
import * as CredentialService from '../../src/models/calendar/events/credentials.service.js';
/**
* The public calendar is read anonymously by nachklang.art to show the next
* upcoming event. That is a load-bearing property, not an accident: the step 4
* cutover moved every signed-in path onto session cookies and left these shared
* passwords behind only for iCal subscriptions, and the failure mode of getting
* it wrong is the public website silently losing its events feed.
*
* So this pins both halves: public needs nothing, and the restricted calendars
* still need something.
*/
describe('hasAccess', () => {
beforeEach(() => {
process.env.MEMBER_CREDENTIAL = 'member-secret';
process.env.CHOIR_CREDENTIAL = 'choir-secret';
process.env.MANAGEMENT_CREDENTIAL = 'management-secret';
});
it('lets anyone read the public calendar with no password at all', async () => {
await expect(CredentialService.hasAccess('public', '')).resolves.toBe(true);
});
it.each([
['members', 'member-secret'],
['choir', 'choir-secret'],
['management', 'management-secret'],
['birthdays', 'choir-secret']
])('refuses %s without the credential and allows it with one', async (calendar, secret) => {
await expect(CredentialService.hasAccess(calendar, '')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, 'wrong')).resolves.toBe(false);
await expect(CredentialService.hasAccess(calendar, secret)).resolves.toBe(true);
});
it('refuses an unknown calendar outright', async () => {
await expect(CredentialService.hasAccess('nope', 'member-secret')).resolves.toBe(false);
});
it('refuses a calendar whose credential is not configured', async () => {
// An unset MEMBER_CREDENTIAL must not become "any password works", and in
// particular must not become "an absent password works".
delete process.env.MEMBER_CREDENTIAL;
await expect(CredentialService.hasAccess('members', '')).resolves.toBe(false);
await expect(CredentialService.hasAccess('members', undefined as any)).resolves.toBe(false);
});
});
-224
View File
@@ -1,224 +0,0 @@
import {describe, expect, it, vi, beforeEach} from 'vitest';
import express from 'express';
import request from 'supertest';
vi.mock('../../src/models/calendar/events/events.service.js', () => ({
getAllEvents: vi.fn(),
getAllEventsAdmin: vi.fn(),
getEventById: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
moveEvent: vi.fn(),
getNextUpcomingEvent: vi.fn()
}));
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 * as EventService from '../../src/models/calendar/events/events.service.js';
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {eventsRouter} from '../../src/models/calendar/events/events.router.js';
/**
* Step 4 of docs/calendar-auth-migration.md at the route level. The unit test
* on credentials.service covers the password table; this covers the thing that
* table is wired into, which is where the interesting mistakes live:
*
* - the public calendar has to stay readable with no session and no password,
* - the shared password has to keep working for the restricted calendars,
* because iCal clients cannot send a cookie,
* - and every write has to be behind the session cookie *and* an explicit
* calendar permission, not merely behind "is signed in".
*/
const app = express();
app.use(express.json());
app.use('/calendar/events', eventsRouter);
const signedInAs = (apps: string[], disabled = false) => {
(auth.api.getSession as any).mockResolvedValue({user: {id: 'admin-1'}});
(UsersService.loadAccess as any).mockResolvedValue({
id: 'admin-1',
email: 'a@nachklang.art',
displayName: 'A',
disabled,
permissions: apps.map(app => ({app, role: 'access'})),
apps
});
};
const signedOut = () => {
(auth.api.getSession as any).mockResolvedValue(null);
};
const validEvent = {
calendarId: 1,
name: 'Konzert',
startDateTime: '2026-04-18T19:00:00Z',
endDateTime: '2026-04-18T21:00:00Z'
};
beforeEach(() => {
vi.clearAllMocks();
process.env.MEMBER_CREDENTIAL = 'member-secret';
(EventService.getAllEvents as any).mockResolvedValue([]);
(EventService.getAllEventsAdmin as any).mockResolvedValue([]);
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
(EventService.createEvent as any).mockResolvedValue(1);
(EventService.updateEvent as any).mockResolvedValue(1);
(EventService.moveEvent as any).mockResolvedValue(true);
(EventService.deleteEvent as any).mockResolvedValue(true);
signedOut();
});
describe('reading', () => {
it('serves the public calendar anonymously', async () => {
// The property nachklang.art depends on. No cookie, no password.
await request(app).get('/calendar/events/public/json').expect(200);
// And as the non-admin view: an anonymous caller must not see drafts.
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
});
it('refuses a restricted calendar with neither session nor password', async () => {
await request(app).get('/calendar/events/members/json').expect(403);
});
it('serves a restricted calendar to a shared password, without drafts', async () => {
await request(app)
.get('/calendar/events/members/json')
.query({password: 'member-secret'})
.expect(200);
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
});
it('gives a signed-in editor the admin view instead', async () => {
signedInAs(['calendar']);
await request(app).get('/calendar/events/members/json').expect(200);
expect(EventService.getAllEventsAdmin).toHaveBeenCalled();
expect(EventService.getAllEvents).not.toHaveBeenCalled();
});
it('treats a signed-in user without the calendar permission as anonymous', async () => {
// Not a 403: they may still read the public calendar like anyone else.
signedInAs(['tickets']);
await request(app).get('/calendar/events/public/json').expect(200);
expect(EventService.getAllEvents).toHaveBeenCalled();
expect(EventService.getAllEventsAdmin).not.toHaveBeenCalled();
await request(app).get('/calendar/events/members/json').expect(403);
});
it('still serves the public calendar when the admin database is down', async () => {
(auth.api.getSession as any).mockRejectedValue(new Error('ECONNREFUSED'));
await request(app).get('/calendar/events/public/json').expect(200);
});
// The endpoint www.nachklang.art actually calls for its next-event teaser.
// Tested separately from /json because it takes a different code path - it
// has no admin view and no editor branch - so covering /json proves nothing
// about it, and its failure is invisible until someone notices the website
// has gone quiet.
it('serves the next upcoming event anonymously on the public calendar', async () => {
await request(app).get('/calendar/events/public/json/next').expect(200);
// And without asking the admin database who the caller is: the public
// feed must not acquire a dependency it has never had.
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('refuses the next upcoming event on a restricted calendar without a credential', async () => {
await request(app).get('/calendar/events/members/json/next').expect(403);
});
it('serves the next upcoming event to a shared password', async () => {
await request(app)
.get('/calendar/events/members/json/next')
.query({password: 'member-secret'})
.expect(200);
});
it('serves the next upcoming event to a signed-in editor', async () => {
signedInAs(['calendar']);
await request(app).get('/calendar/events/members/json/next').expect(200);
});
it('does not consult the admin database for the anonymous public iCal export', async () => {
await request(app).get('/calendar/events/public/ical').expect(200);
expect(auth.api.getSession).not.toHaveBeenCalled();
});
it('keeps the shared password working on the iCal export', async () => {
(EventService.getAllEvents as any).mockResolvedValue([]);
await request(app).get('/calendar/events/public/ical').expect(200);
await request(app).get('/calendar/events/members/ical').expect(403);
await request(app)
.get('/calendar/events/members/ical')
.query({password: 'member-secret'})
.expect(200);
});
});
describe('writing', () => {
it.each([
['post', '/calendar/events'],
['put', '/calendar/events/1'],
['put', '/calendar/events/move/1'],
['delete', '/calendar/events/1']
])('%s %s answers 401 when signed out', async (method, path) => {
await (request(app) as any)[method](path).send(validEvent).expect(401);
});
it.each([
['post', '/calendar/events'],
['put', '/calendar/events/1'],
['put', '/calendar/events/move/1'],
['delete', '/calendar/events/1']
])('%s %s answers 403 without the calendar permission', async (method, path) => {
signedInAs(['tickets', 'feedback', 'admin']);
await (request(app) as any)[method](path).send(validEvent).expect(403);
});
it('answers 403 for a disabled account that still holds the permission', async () => {
signedInAs(['calendar'], true);
await request(app).post('/calendar/events').send(validEvent).expect(403);
});
it('records the writer as an admin user id, never a legacy one', async () => {
signedInAs(['calendar']);
await request(app).post('/calendar/events').send(validEvent).expect(201);
const written = (EventService.createEvent as any).mock.calls[0][0];
expect(written.createdByUserId).toBe('admin-1');
// Migration 003 made the legacy column nullable precisely so this can be unset.
expect(written.createdById).toBeUndefined();
});
it('refuses the shared password as a way to write', async () => {
// The passwords are a read fallback for clients that cannot hold a
// session. They must never become an editing credential.
await request(app)
.post('/calendar/events')
.query({password: 'member-secret'})
.send(validEvent)
.expect(401);
});
});
-169
View File
@@ -1,169 +0,0 @@
import {describe, expect, it, vi, beforeEach} from 'vitest';
const connection = {
query: vi.fn(),
execute: vi.fn(),
beginTransaction: vi.fn(),
commit: vi.fn(),
rollback: vi.fn(),
end: vi.fn()
};
vi.mock('../../src/models/calendar/Calendar.db.js', () => ({
NachklangCalendarDB: {getConnection: vi.fn(async () => connection)}
}));
vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
findDisplayNames: vi.fn()
}));
import {NachklangCalendarDB} from '../../src/models/calendar/Calendar.db.js';
import * as AdminUsersService from '../../src/models/admin/users/users.admin.service.js';
import * as EventService from '../../src/models/calendar/events/events.service.js';
/**
* Step 3 of docs/calendar-auth-migration.md. The property under test is that
* an event's creator resolves from the stronger of its two remaining sources -
* the live admin name, else the name archived before the legacy users table was
* removed - and that a failure to reach the admin database costs a name rather
* than the whole response: the public calendar is read anonymously by the
* website and has never depended on the admin database being up.
*/
// One row of the shape the shared SELECT produces.
const row = (over: Record<string, unknown> = {}) => ({
event_id: 1,
calendar_id: 1,
uuid: 'uuid-1',
name: 'Konzert',
description: '',
start_datetime: new Date('2026-04-18T19:00:00Z'),
end_datetime: new Date('2026-04-18T21:00:00Z'),
created_date: new Date('2026-01-01T00:00:00Z'),
version_created_at: new Date('2026-01-02T00:00:00Z'),
location: '',
created_by_user_id: null,
created_by_name: 'Archived Person',
version_created_by_user_id: null,
version_created_by_name: 'Archived Person',
url: '',
whole_day: 0,
repeat_frequency: '',
status: 'PUBLIC',
...over
});
/** getAllEvents runs the calendars lookup first, then the events query. */
const givenEvents = (...rows: unknown[]) => {
connection.query.mockReset();
connection.query
.mockResolvedValueOnce([{calendar_id: 1, includes_calendars: '[]'}])
.mockResolvedValueOnce(rows);
};
beforeEach(() => {
vi.clearAllMocks();
connection.end.mockResolvedValue(undefined);
(NachklangCalendarDB.getConnection as any).mockResolvedValue(connection);
});
describe('creator names', () => {
it('uses the archived name when the row has no admin id', async () => {
givenEvents(row());
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Archived Person');
expect(events[0].createdByUserId).toBeNull();
// Nothing to resolve, so the admin database is not touched at all.
expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled();
});
it('prefers the admin name when the row carries an admin id', async () => {
givenEvents(row({
created_by_user_id: 'admin-1',
version_created_by_user_id: 'admin-2'
}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(
new Map([['admin-1', 'Neue Person'], ['admin-2', 'Andere Person']])
);
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Neue Person');
expect(events[0].lastModifiedBy).toBe('Andere Person');
expect(events[0].createdByUserId).toBe('admin-1');
});
it('prefers the live admin name over the snapshot', async () => {
// A renamed account has to win over an archive that was correct when it
// was taken - otherwise renaming someone would leave stale names behind.
givenEvents(row({created_by_user_id: 'admin-1', created_by_name: 'Archived Person'}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']]));
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Neue Person');
});
it('falls back to the archived name when the admin account is gone', async () => {
givenEvents(row({created_by_user_id: 'deleted'}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Archived Person');
});
it('leaves the name blank when a row has neither source', async () => {
// A post-cutover event whose author was later deleted from the admin
// module: no snapshot was ever taken for it, and the id resolves to
// nothing. Blank is the designed outcome - the creator is decoration.
givenEvents(row({created_by_user_id: 'deleted', created_by_name: null}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBeNull();
expect(events).toHaveLength(1);
});
it('resolves a mixed result set in a single lookup', async () => {
givenEvents(
row({event_id: 1}),
row({event_id: 2, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'}),
row({event_id: 3, created_by_user_id: 'admin-1', version_created_by_user_id: 'admin-1'})
);
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map([['admin-1', 'Neue Person']]));
const events = await EventService.getAllEvents(1);
expect(events.map(e => e.createdBy)).toEqual(['Archived Person', 'Neue Person', 'Neue Person']);
expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1);
});
it('still returns the events when the admin database is unreachable', async () => {
givenEvents(row({created_by_user_id: 'admin-1'}));
(AdminUsersService.findDisplayNames as any).mockRejectedValue(new Error('ECONNREFUSED'));
const events = await EventService.getAllEvents(1);
expect(events).toHaveLength(1);
expect(events[0].name).toBe('Konzert');
// Degrades to the archived name rather than failing the request.
expect(events[0].createdBy).toBe('Archived Person');
});
});
describe('status', () => {
it('is omitted from the public listing and present in the admin one', async () => {
givenEvents(row());
const publicEvents = await EventService.getAllEvents(1);
expect(publicEvents[0].status).toBeUndefined();
connection.query.mockReset();
connection.query.mockResolvedValueOnce([row()]);
const adminEvents = await EventService.getAllEventsAdmin(1);
expect(adminEvents[0].status).toBe('PUBLIC');
});
});
-116
View File
@@ -1,116 +0,0 @@
// common.mail relays one email through the Salesforce org (see
// src/common/common.mail.ts). These tests mock the shared Salesforce client so
// no network is touched, and check: the payload shape, base64 attachment
// encoding, the attachment size cap, the retry-once-on-transient-failure
// behaviour, and that a delivery failure is swallowed (returns false, never
// throws).
import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest';
vi.mock('../../src/common/salesforce.client.js');
vi.mock('../../src/middleware/logger.js', () => ({
__esModule: true,
default: {info: vi.fn(), warn: vi.fn(), error: vi.fn()}
}));
import {MailService} from '../../src/common/common.mail.js';
import {salesforceApexRestPost, salesforceEnabled} from '../../src/common/salesforce.client.js';
const mockPost = salesforceApexRestPost as Mock;
const mockEnabled = salesforceEnabled as Mock;
const httpError = (status: number, body?: any): any => {
const err: any = new Error('request failed with ' + status);
err.response = {status, data: body};
return err;
};
beforeEach(() => {
vi.clearAllMocks();
mockEnabled.mockReturnValue(true);
mockPost.mockResolvedValue({status: 'SENT'});
});
describe('MailService.sendMail', () => {
it('returns false without a callout when Salesforce is disabled', async () => {
mockEnabled.mockReturnValue(false);
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(false);
expect(mockPost).not.toHaveBeenCalled();
});
it('posts the email to the Apex REST endpoint and returns true on success', async () => {
const result = await MailService.sendMail('guest@example.com', 'Bestätigung', 'Hallo', {html: '<p>Hallo</p>'});
expect(result).toBe(true);
expect(mockPost).toHaveBeenCalledWith('/services/apexrest/email/send', {
to: 'guest@example.com',
subject: 'Bestätigung',
textBody: 'Hallo',
htmlBody: '<p>Hallo</p>',
attachments: []
});
});
it('sends htmlBody as null when no HTML is given', async () => {
await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(mockPost).toHaveBeenCalledWith('/services/apexrest/email/send', expect.objectContaining({htmlBody: null}));
});
it('base64-encodes attachments', async () => {
await MailService.sendMail('guest@example.com', 'Hi', 'Hallo', {
attachments: [{filename: 'konzert.ics', content: 'BEGIN:VCALENDAR', contentType: 'text/calendar'}]
});
expect(mockPost).toHaveBeenCalledWith(
'/services/apexrest/email/send',
expect.objectContaining({
attachments: [{
filename: 'konzert.ics',
contentType: 'text/calendar',
contentBase64: Buffer.from('BEGIN:VCALENDAR', 'utf-8').toString('base64')
}]
})
);
});
it('rejects an attachment over the size cap without sending', async () => {
const huge = Buffer.alloc(3 * 1024 * 1024 + 1);
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo', {
attachments: [{filename: 'big.pdf', content: huge}]
});
expect(result).toBe(false);
expect(mockPost).not.toHaveBeenCalled();
});
it('retries once on a 5xx and returns false when the retry also fails', async () => {
mockPost.mockRejectedValue(httpError(503));
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(false);
expect(mockPost).toHaveBeenCalledTimes(2);
});
it('retries once on a network error (no response) then succeeds', async () => {
mockPost.mockRejectedValueOnce(new Error('socket hang up')).mockResolvedValueOnce({status: 'SENT'});
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(true);
expect(mockPost).toHaveBeenCalledTimes(2);
});
it('does not retry on a 4xx (e.g. the 429 limit response) and returns false', async () => {
mockPost.mockRejectedValue(httpError(429, {errorCode: 'LIMIT_REACHED'}));
const result = await MailService.sendMail('guest@example.com', 'Hi', 'Hallo');
expect(result).toBe(false);
expect(mockPost).toHaveBeenCalledTimes(1);
});
});
-125
View File
@@ -1,125 +0,0 @@
// salesforce.client caches the OAuth token at module scope, so every test
// resets the module registry for a clean cache and re-imports axios + the
// module under test after the reset (same approach as
// test/feedback/salesforce.service.test.ts). Mocked modules survive
// vi.resetModules(), so mock state is reset explicitly in beforeEach.
import {vi, describe, it, expect, beforeEach, afterAll} from 'vitest';
vi.mock('axios');
const freshImports = async () => {
const axios: any = (await import('axios')).default;
const {salesforceApexRestPost, salesforceEnabled} = await import('../../src/common/salesforce.client.js');
return {axios, salesforceApexRestPost, salesforceEnabled};
};
const ORIGINAL_ENV = {...process.env};
const ENABLED_ENV = {
...ORIGINAL_ENV,
SALESFORCE_ENABLED: 'true',
SALESFORCE_API_URL: 'https://example.my.salesforce.com',
SALESFORCE_CLIENT_ID: 'client-id',
SALESFORCE_CLIENT_SECRET: 'client-secret'
};
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
process.env = {...ENABLED_ENV};
});
afterAll(() => {
process.env = {...ORIGINAL_ENV};
});
describe('salesforceEnabled', () => {
it('is true only when SALESFORCE_ENABLED === "true"', async () => {
process.env.SALESFORCE_ENABLED = 'true';
expect((await freshImports()).salesforceEnabled()).toBe(true);
vi.resetModules();
process.env.SALESFORCE_ENABLED = 'false';
expect((await freshImports()).salesforceEnabled()).toBe(false);
});
});
describe('salesforceApexRestPost', () => {
it('fetches a token, posts to the given Apex REST path, and returns the response body', async () => {
const {axios, salesforceApexRestPost} = await freshImports();
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
return Promise.resolve({data: {ok: true}});
});
const result = await salesforceApexRestPost('/services/apexrest/email/send', {to: 'x@example.com'});
expect(result).toEqual({ok: true});
expect(axios.post).toHaveBeenCalledWith(
'https://example.my.salesforce.com/services/oauth2/token',
expect.any(String),
expect.objectContaining({headers: {'Content-Type': 'application/x-www-form-urlencoded'}})
);
expect(axios.post).toHaveBeenCalledWith(
'https://example.my.salesforce.com/services/apexrest/email/send',
{to: 'x@example.com'},
expect.objectContaining({headers: {Authorization: 'Bearer tok-1'}})
);
});
it('reuses the cached token across calls instead of fetching twice', async () => {
const {axios, salesforceApexRestPost} = await freshImports();
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
return Promise.resolve({data: {}});
});
await salesforceApexRestPost('/services/apexrest/email/send', {});
await salesforceApexRestPost('/services/apexrest/email/send', {});
const tokenCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/services/oauth2/token'));
expect(tokenCalls).toHaveLength(1);
});
it('retries once with a fresh token on a 401, then succeeds', async () => {
const {axios, salesforceApexRestPost} = await freshImports();
let tokenFetches = 0;
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) {
tokenFetches += 1;
return Promise.resolve({data: {access_token: `tok-${tokenFetches}`}});
}
if (tokenFetches === 1) {
const err: any = new Error('Unauthorized');
err.response = {status: 401};
return Promise.reject(err);
}
return Promise.resolve({data: {ok: true}});
});
const result = await salesforceApexRestPost('/services/apexrest/email/send', {});
expect(result).toEqual({ok: true});
expect(tokenFetches).toBe(2);
});
it('does not retry on a non-401 error and rethrows it', async () => {
const {axios, salesforceApexRestPost} = await freshImports();
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
const err: any = new Error('Server error');
err.response = {status: 500};
return Promise.reject(err);
});
await expect(salesforceApexRestPost('/services/apexrest/email/send', {})).rejects.toThrow('Server error');
const endpointCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/email/send'));
expect(endpointCalls).toHaveLength(1);
});
it('throws a clear error when client credentials are not configured', async () => {
process.env.SALESFORCE_CLIENT_ID = '';
const {salesforceApexRestPost} = await freshImports();
await expect(salesforceApexRestPost('/services/apexrest/email/send', {})).rejects.toThrow('SALESFORCE_CLIENT_ID');
});
});
+2 -3
View File
@@ -1,6 +1,5 @@
import {describe, it, expect} from 'vitest';
import {escapeCsvField} from '../../src/models/feedback/admin/csv.service.js';
import {formatDatetime} from '../../src/models/feedback/feedback.dates.js';
import {escapeCsvField} from '../../src/models/feedback/admin/csv.service';
import {formatDatetime} from '../../src/models/feedback/feedback.dates';
describe('escapeCsvField', () => {
it('passes plain text through unchanged', () => {
+1 -2
View File
@@ -1,5 +1,4 @@
import {describe, it, expect} from 'vitest';
import {computeDefaultDeadline, slugBase, slugifyName} from '../../src/models/feedback/admin/events.admin.service.js';
import {computeDefaultDeadline, slugBase, slugifyName} from '../../src/models/feedback/admin/events.admin.service';
describe('slugifyName', () => {
it('lowercases and hyphenates', () => {

Some files were not shown because too many files have changed in this diff Show More