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

Introduces src/models/admin/, a dedicated identity and permissions module on
its own nachklang_admin database, and the shared authenticator that feedback
and tickets will move onto in the cutover step. Nothing swaps over yet:
feedback.auth.ts and tickets.auth.ts still authenticate against the legacy
calendar sessions, so production behaviour is unchanged.

- better-auth 1.7 mounted at /admin/auth/*, sessions as httpOnly cookies
  scoped to .nachklang.art so one sign-in covers every *.nachklang.art app.
- Accounts are invite-only: public sign-up is disabled, and the invitations
  plugin is the only code that creates users. Tokens are stored as SHA-256
  hashes and travel in the request body, never in a URL.
- Per-app permissions in user_app_permissions; requireAppAccess(app) queries
  the database on every request (no cookie cache) so disabling a user or
  revoking a session takes effect immediately.
- ADMIN_BOOTSTRAP_EMAIL guarantees a way in on an empty database, idempotently
  and without crashing the API if the database is unreachable at boot.
- Guards prevent an admin from removing their own admin permission, disabling
  themselves, or stripping the last active admin.

The admin pool uses the callback-style mysql2, not mysql2/promise: Kysely's
MysqlDialect drives the pool with callbacks, and the promise wrapper ignores
them, so every query hangs silently. Only the integration tests caught this.

Schema in sql/admin/001_init.sql, derived from getAuthTables() on the
installed better-auth rather than the published CLI, which lags the library
and omits account.issuer.

app.ts is split into src/app.factory.ts so the integration tests drive the
real middleware order rather than a copy of it.

Tests: 131 unit, plus 41 integration tests against a throwaway MariaDB
started by test/integration/setup.ts (docker or podman).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 18:03:08 +02:00
parent bf7f45acce
commit 7aac07a013
37 changed files with 5620 additions and 320 deletions
+58
View File
@@ -0,0 +1,58 @@
import {expect} from 'vitest';
import type {Application} from 'express';
import request from 'supertest';
import {NachklangAdminDB} from '../../src/models/admin/Admin.db.js';
import * as InvitationsService from '../../src/models/admin/invitations/invitations.service.js';
import {AppName} from '../../src/models/admin/admin.schema.js';
const db = NachklangAdminDB.db;
// Dev/test cookie name: advanced.cookiePrefix is 'nachklang', and the __Secure-
// prefix is only added over https.
export const SESSION_COOKIE = 'nachklang.session_token';
/** Wipes every table between test files. Child tables first - the FKs to
* `user` are ON DELETE CASCADE, but rateLimit and invitations are not. */
export const resetDatabase = async (): Promise<void> => {
await db.deleteFrom('session').execute();
await db.deleteFrom('user_app_permissions').execute();
await db.deleteFrom('passkey').execute();
await db.deleteFrom('invitations').execute();
await db.deleteFrom('user').execute();
};
export const closeDatabase = async (): Promise<void> => {
await db.destroy();
};
/**
* Creates an invitation straight through the service (so the test gets the raw
* token, which the API deliberately never returns) and redeems it through the
* public endpoint. Returns an agent that carries the resulting session cookie.
*/
export const createAndAcceptInvitation = async (
app: Application,
email: string,
name: string,
apps: AppName[],
password = 'devpassword123'
) => {
const invitation = await InvitationsService.createInvitation(email, name, apps, null);
const agent = request.agent(app);
const res = await agent
.post('/admin/auth/invitations/accept')
.send({token: invitation.token, password});
expect(res.status).toBe(200);
return {agent, userId: res.body.user.id, token: invitation.token};
};
export const cookieHeader = (res: request.Response): string[] => {
const raw = res.headers['set-cookie'];
return Array.isArray(raw) ? raw : raw ? [raw] : [];
};
export const sessionCookieFrom = (res: request.Response): string | undefined => {
return cookieHeader(res).find(cookie => cookie.startsWith(SESSION_COOKIE));
};