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
+15 -2
View File
@@ -12,25 +12,31 @@
"build": "tsc",
"debug": "export DEBUG=* && npm run start",
"test": "vitest run --coverage",
"test:watch": "vitest"
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@better-auth/passkey": "^1.7.2",
"app-root-path": "^3.0.0",
"axios": "^1.20.0",
"bcrypt": "^5.0.1",
"better-auth": "^1.7.2",
"cors": "^2.8.5",
"debug": "^4.3.1",
"dotenv": "^16.6.1",
"express": "^4.18.2",
"guid-typescript": "^1.0.9",
"kysely": "^0.29.5",
"mariadb": "^3.0.2",
"mysql2": "^3.24.3",
"random-words": "^1.1.1",
"swagger-jsdoc": "^6.1.0",
"swagger-ui-express": "^4.3.0",
"winston": "^3.3.3"
"winston": "^3.3.3",
"zod": "^4.5.4"
},
"devDependencies": {
"@types/app-root-path": "^1.2.4",
@@ -40,14 +46,21 @@
"@types/express": "^4.17.15",
"@types/node": "^26.4.1",
"@types/random-words": "^1.1.2",
"@types/supertest": "^7.2.1",
"@types/swagger-jsdoc": "^6.0.1",
"@types/swagger-ui-express": "^4.1.3",
"@types/winston": "^2.4.4",
"@vitest/coverage-v8": "^5.0.0",
"is-number": "^7.0.0",
"source-map-support": "^0.5.19",
"supertest": "^7.2.2",
"typescript": "^5.9.3",
"vitest": "^5.0.0",
"vitest-sonar-reporter": "^3.0.0"
},
"overrides": {
"better-auth": {
"vitest": "$vitest"
}
}
}