From 7aac07a013fc5a1334b9cd7ce6833a110f8165f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20M=C3=BCller?= Date: Sat, 5 Sep 2026 18:03:08 +0200 Subject: [PATCH] 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 --- .env.example | 14 + CLAUDE.md | 44 +- DEFERRED_SECURITY.md | 11 + app.ts | 104 +- docker-compose.test.yml | 31 + docker/init/00-databases.sql | 4 +- docker/init/04-admin-schema.sql | 164 ++ docs/calendar-auth-migration.md | 74 + package-lock.json | 2077 +++++++++++++++-- package.json | 17 +- sql/admin/001_init.sql | 144 ++ src/app.factory.ts | 138 ++ src/models/admin/Admin.db.ts | 55 + src/models/admin/Admin.router.ts | 43 + src/models/admin/admin.auth.ts | 141 ++ src/models/admin/admin.bootstrap.ts | 67 + src/models/admin/admin.config.ts | 63 + src/models/admin/admin.errors.ts | 17 + src/models/admin/admin.interface.ts | 120 + src/models/admin/admin.mail.ts | 124 + src/models/admin/admin.middleware.ts | 124 + src/models/admin/admin.schema.ts | 81 + .../admin/invitations/invitations.plugin.ts | 157 ++ .../admin/invitations/invitations.router.ts | 194 ++ .../admin/invitations/invitations.service.ts | 209 ++ src/models/admin/users/users.admin.router.ts | 229 ++ src/models/admin/users/users.admin.service.ts | 260 +++ test/admin/admin.bootstrap.test.ts | 91 + test/admin/admin.mail.test.ts | 71 + test/admin/admin.middleware.test.ts | 174 ++ test/admin/users.admin.router.test.ts | 165 ++ test/integration/admin.auth.test.ts | 272 +++ test/integration/admin.users.test.ts | 255 ++ test/integration/helpers.ts | 58 + test/integration/setup.ts | 107 + vitest.config.ts | 3 + vitest.integration.config.ts | 38 + 37 files changed, 5620 insertions(+), 320 deletions(-) create mode 100644 docker-compose.test.yml create mode 100644 docker/init/04-admin-schema.sql create mode 100644 docs/calendar-auth-migration.md create mode 100644 sql/admin/001_init.sql create mode 100644 src/app.factory.ts create mode 100644 src/models/admin/Admin.db.ts create mode 100644 src/models/admin/Admin.router.ts create mode 100644 src/models/admin/admin.auth.ts create mode 100644 src/models/admin/admin.bootstrap.ts create mode 100644 src/models/admin/admin.config.ts create mode 100644 src/models/admin/admin.errors.ts create mode 100644 src/models/admin/admin.interface.ts create mode 100644 src/models/admin/admin.mail.ts create mode 100644 src/models/admin/admin.middleware.ts create mode 100644 src/models/admin/admin.schema.ts create mode 100644 src/models/admin/invitations/invitations.plugin.ts create mode 100644 src/models/admin/invitations/invitations.router.ts create mode 100644 src/models/admin/invitations/invitations.service.ts create mode 100644 src/models/admin/users/users.admin.router.ts create mode 100644 src/models/admin/users/users.admin.service.ts create mode 100644 test/admin/admin.bootstrap.test.ts create mode 100644 test/admin/admin.mail.test.ts create mode 100644 test/admin/admin.middleware.test.ts create mode 100644 test/admin/users.admin.router.test.ts create mode 100644 test/integration/admin.auth.test.ts create mode 100644 test/integration/admin.users.test.ts create mode 100644 test/integration/helpers.ts create mode 100644 test/integration/setup.ts create mode 100644 vitest.integration.config.ts diff --git a/.env.example b/.env.example index 4422a7b..b858e3a 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,20 @@ TICKETS_DB= TICKETS_RATE_LIMIT_MAX=10 TICKETS_RATE_LIMIT_WINDOW_MIN=10 +ADMIN_DB= +# 32+ random bytes, e.g. `openssl rand -base64 48`. 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= + MEMBER_CREDENTIAL=123 CHOIR_CREDENTIAL=123 MANAGEMENT_CREDENTIAL=123 diff --git a/CLAUDE.md b/CLAUDE.md index d2198f5..5c9b4f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,7 @@ npm run start # Build and start (tsc && node ./dist/app.js) npm run debug # Start with DEBUG=* environment variable npm run test # Run the vitest suite once with coverage (lcov + testResults/sonar-report.xml) npm run test:watch # vitest in watch mode +npm run test:integration # Admin-module tests against a throwaway MariaDB (needs docker or podman) ``` Run a single test file: @@ -19,10 +20,12 @@ npx vitest run test/some.test.ts ## Architecture -Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users) and `Feedback` (concert feedback forms, mounted at `/feedback`, backed by its own `FEEDBACK_DB` — see `src/models/feedback/`: public submission flow, admin CRUD, reporting, and a Salesforce newsletter-sync integration). +Express.js REST API in TypeScript with a service-oriented layering. Domains: `Calendar` (events, users), `Feedback` (concert feedback forms, mounted at `/feedback`, backed by its own `FEEDBACK_DB` — see `src/models/feedback/`: public submission flow, admin CRUD, reporting, and a Salesforce newsletter-sync integration), `Tickets` (mounted at `/tickets`), and `Admin` (identity and permissions, mounted at `/admin`, backed by `ADMIN_DB` — see below). + +`src/app.factory.ts` builds the Express app; `app.ts` only starts it. The split exists so the integration tests drive the real wiring. **Request path:** -1. `app.ts` mounts `Calendar.router.ts` at `/calendar` +1. `src/app.factory.ts` mounts `Calendar.router.ts` at `/calendar` 2. `Calendar.router.ts` delegates to `events.router.ts` and `users.router.ts` 3. Routers call services; services call the MariaDB pool in `Calendar.db.ts` @@ -35,7 +38,35 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca | DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) | | Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) | -**Auth model:** Users must have a `@nachklang.art` email. After activation they receive a session token (30-day window); the token hash + IP are stored in the DB. Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`) come from `.env`. +**Auth model:** Two of them, on purpose. + +*Admin module (`src/models/admin/`)* — the current one, used by feedback, tickets and the +admin app. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every +other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler +and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to +`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public +sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users. +Permissions are per app in `user_app_permissions`; `requireAppAccess(app)` in +`admin.middleware.ts` is the single authenticator, and it queries the database on every +request (no cookie cache) so disabling a user takes effect at once. `ADMIN_BOOTSTRAP_EMAIL` +makes sure someone can always get in on a fresh database. + +*Legacy calendar* — unchanged: users need a `@nachklang.art` email, and after activation +get a session token (30-day window, hash + IP stored in the DB), passed as query +parameters. Migration is planned but not started: `docs/calendar-auth-migration.md`. +Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, +`MANAGEMENT_CREDENTIAL`) come from `.env`. + +**Admin database driver:** the admin pool is the **callback-style** `mysql2`, never +`mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`; +the promise wrapper ignores that callback, so every Kysely query hangs forever with no +error. Only the integration tests catch this. + +**Admin schema changes:** `sql/admin/NNN_*.sql`, hand-maintained and mirrored in +`docker/init/`. The better-auth tables must match what the configured version derives from +`admin.auth.ts` — on every better-auth upgrade, re-derive them (`getAuthTables` from +`better-auth/db`, called with `auth.options`), diff, and add a numbered migration. Do not +use the published `@better-auth/cli`; it lags the library. **Event versioning:** Events have a companion `event_versions` table. `events.service.ts` manages writes to both. @@ -59,6 +90,13 @@ 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= FEEDBACK_DB= FEEDBACK_IP_SALT= FEEDBACK_RATE_LIMIT_MAX= diff --git a/DEFERRED_SECURITY.md b/DEFERRED_SECURITY.md index 40db072..0321d1a 100644 --- a/DEFERRED_SECURITY.md +++ b/DEFERRED_SECURITY.md @@ -32,6 +32,13 @@ Currently any active user can edit, move, or delete any event regardless of who ## 3. Activation token has no expiry +> **Superseded for new accounts (2026-09-05).** The admin module +> (`src/models/admin/`) replaced account creation for the feedback, tickets and admin +> apps: accounts now come from `invitations`, whose tokens expire after 7 days and are +> stored only as a SHA-256 hash. The item below still stands for the legacy calendar +> `users` table, which the admin module deliberately left alone - see +> `docs/calendar-auth-migration.md`. + **File:** `src/models/calendar/users/users.service.ts` — `createUser` / `activateUser` The email activation link is valid indefinitely. Acceptable for a small, trusted userbase. @@ -45,6 +52,10 @@ The email activation link is valid indefinitely. Acceptable for a small, trusted ## 4. Password reset token has no expiry +> **Superseded for new accounts (2026-09-05).** Password resets for admin-module accounts +> go through better-auth, whose reset tokens expire after one hour. As with item 3, the +> text below still applies to the legacy calendar `users` table. + **File:** `src/models/calendar/users/users.service.ts` — `initiatePasswordReset` / `finalizePasswordReset` The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase. diff --git a/app.ts b/app.ts index 7684b22..7088c5a 100644 --- a/app.ts +++ b/app.ts @@ -1,16 +1,8 @@ -import express from 'express'; import * as http from 'http'; import * as dotenv from 'dotenv'; -import swaggerUi from 'swagger-ui-express'; -import swaggerJSDoc from 'swagger-jsdoc'; -import cors from 'cors'; import logger from './src/middleware/logger.js'; - -// Router imports -import {calendarRouter} from './src/models/calendar/Calendar.router.js'; -import {feedbackRouter} from './src/models/feedback/Feedback.router.js'; -import {ticketsRouter} from './src/models/tickets/Tickets.router.js'; - +import {createApp} from './src/app.factory.js'; +import {bootstrapAdmin} from './src/models/admin/admin.bootstrap.js'; dotenv.config(); @@ -21,97 +13,11 @@ if (!process.env.PORT) { const port: number = parseInt(process.env.PORT, 10); -const app: express.Application = express(); +const app = createApp(); const server: http.Server = http.createServer(app); -// Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header. -// Verify the resolved client IP is correct in staging before relying on it -// (used by the feedback rate limiter). -app.set('trust proxy', 1); - -// here we are adding middleware to parse all incoming requests as JSON -app.use(express.json()); - -// Configure CORS -let allowedHosts = [ - 'https://www.nachklang.art', - 'https://calendar.nachklang.art', - 'https://feedback.nachklang.art', - 'https://tickets.nachklang.art' -]; -const isDev = process.env.NODE_ENV !== 'production'; -const localhostRegex = /^http:\/\/localhost:\d+$/; -// Matches http://: - 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(); }); diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..ff76715 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,31 @@ +# Manual alternative for bringing up the admin tests' database by hand. +# +# `npm run test:integration` does NOT use this file: test/integration/setup.ts +# starts the container directly, because `podman compose` needs a separate +# compose provider that neither podman nor docker ships, and one container needs +# no orchestration. Keep the two in step, or delete this file if nobody uses it. +# +# Port 3307 and a throwaway data directory on purpose: it must never collide +# with, or outlive, the dev database from docker-compose.dev.yml. +services: + mariadb-test: + image: mariadb:11 + environment: + MARIADB_ROOT_PASSWORD: roottestpassword + MARIADB_DATABASE: nachklang_admin + MARIADB_USER: nachklang + MARIADB_PASSWORD: testpassword + ports: + - "3307:3306" + tmpfs: + - /var/lib/mysql + volumes: + # Applied by the entrypoint on first boot, against MARIADB_DATABASE. + # This is the very migration production runs, so a mistake in it fails + # the test run rather than the deploy. + - ./sql/admin/001_init.sql:/docker-entrypoint-initdb.d/001_init.sql:ro + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 2s + timeout: 5s + retries: 30 diff --git a/docker/init/00-databases.sql b/docker/init/00-databases.sql index 08bbe1d..73af94e 100644 --- a/docker/init/00-databases.sql +++ b/docker/init/00-databases.sql @@ -1,10 +1,12 @@ --- Local dev only. Creates the three databases + a dev user with full access. +-- Local dev only. Creates the four databases + a dev user with full access. CREATE DATABASE IF NOT EXISTS nachklang_calendar CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE IF NOT EXISTS nachklang_feedback CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE IF NOT EXISTS nachklang_tickets CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS nachklang_admin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER IF NOT EXISTS 'nachklang'@'%' IDENTIFIED BY 'devpassword'; GRANT ALL PRIVILEGES ON nachklang_calendar.* TO 'nachklang'@'%'; GRANT ALL PRIVILEGES ON nachklang_feedback.* TO 'nachklang'@'%'; GRANT ALL PRIVILEGES ON nachklang_tickets.* TO 'nachklang'@'%'; +GRANT ALL PRIVILEGES ON nachklang_admin.* TO 'nachklang'@'%'; FLUSH PRIVILEGES; diff --git a/docker/init/04-admin-schema.sql b/docker/init/04-admin-schema.sql new file mode 100644 index 0000000..a27389a --- /dev/null +++ b/docker/init/04-admin-schema.sql @@ -0,0 +1,164 @@ +-- 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. `role` is reserved for +-- per-app roles later and is 'admin' for every row today. +CREATE TABLE IF NOT EXISTS `user_app_permissions` ( + `user_id` VARCHAR(36) NOT NULL, + `app` ENUM('calendar','feedback','tickets','admin') NOT NULL, + `role` VARCHAR(32) NOT NULL DEFAULT 'admin', + `granted_by` VARCHAR(36) DEFAULT NULL, + `granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `app`), + 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, + `apps` 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', 'admin'), + ('dev-user-0000-0000-0000-000000000001', 'feedback', 'admin'), + ('dev-user-0000-0000-0000-000000000001', 'tickets', 'admin'), + ('dev-user-0000-0000-0000-000000000001', 'admin', 'admin'); diff --git a/docs/calendar-auth-migration.md b/docs/calendar-auth-migration.md new file mode 100644 index 0000000..ac3792f --- /dev/null +++ b/docs/calendar-auth-migration.md @@ -0,0 +1,74 @@ +# Migrating the Calendar domain onto the admin identity module + +Status: **not started.** Written 2026-09-05 alongside the admin module (step 2 of +`docs/plan-admin-auth.md` in the nachklang-admin repo), which deliberately left the +calendar alone. + +## Why the calendar was left out + +The admin module replaced authentication for feedback and tickets by swapping one +middleware. The calendar cannot be done that way, because its user identity is woven into +its data: + +- `users`/`sessions` live in the **calendar** database and are the same tables the + feedback and tickets admin areas used to authenticate against. +- `events.created_by_id` is an **INT** foreign key into `users.user_id`. The admin module's + user ids are **VARCHAR(36)** strings. Migrating identity means migrating that column and + every query that joins it. +- The Angular frontend passes `sessionId`/`sessionKey` as **query parameters** + (`DEFERRED_SECURITY.md` item 1). Cookie sessions remove the parameters entirely, so + every calendar route signature and the frontend's HTTP layer change together. +- `credentials.service.ts` implements a second, parallel authorisation model: the + `MEMBER_CREDENTIAL` / `CHOIR_CREDENTIAL` / `MANAGEMENT_CREDENTIAL` shared secrets that + let non-users read specific calendars. That has no equivalent in the admin module and is + not a per-user permission at all. + +What already exists today: `calendar` is a value in the `user_app_permissions.app` enum, so +permissions can be granted before anything else moves. + +## What is in place to build on + +- Cookie sessions across `*.nachklang.art`, and `requireAppAccess('calendar')` in + `src/models/admin/admin.middleware.ts` - usable the moment a calendar route wants it. +- `res.locals.admin` is `{id, email, displayName, apps}`; `id` is the string user id. +- Invitations, disable/enable and session revocation already cover calendar users, because + they are properties of the account rather than of an app. + +## Suggested sequence + +Each step is meant to leave production working on its own. + +1. **Add a bridging column.** `ALTER TABLE events ADD COLUMN created_by_user_id + VARCHAR(36) NULL`, indexed. Nothing reads it yet. +2. **Map the accounts.** For every legacy `users` row that should survive, invite the + person through the admin UI. On acceptance, backfill `events.created_by_user_id` from + `events.created_by_id` via an email-to-new-id mapping. Everyone not re-invited keeps + working on the legacy path until step 4. +3. **Dual-read.** Change `events.service.ts` to prefer `created_by_user_id` and fall back + to `created_by_id`. Writes fill both. This is the only step that is temporary code, and + it should carry a removal note pointing at step 5. +4. **Switch the routes.** Replace the query-parameter session checks in + `events.router.ts` and `users.router.ts` with `requireAppAccess('calendar')`, and change + the Angular frontend to `withCredentials: true` against the same origin list. Deploy the + API first; the calendar frontend is broken between the two deploys, so pick a quiet + time. This closes `DEFERRED_SECURITY.md` item 1. +5. **Drop the legacy path.** Remove `users.service.ts`'s session handling, the `sessions` + table, `created_by_id`, and the dual-read from step 3. Legacy `/calendar/users/*` stays + only if something still calls it - otherwise delete it too. `X-Session-Id` / + `X-Session-Key` can then come out of the CORS `allowedHeaders` list in + `src/app.factory.ts`. + +## Open questions to settle before starting + +- **The shared calendar credentials.** Do `MEMBER_CREDENTIAL` and friends stay as a + separate mechanism (they serve people with no account at all, and iCal clients that + cannot send headers), or do read-only accounts replace them? This is a product decision, + not a technical one, and it decides how much of `credentials.service.ts` survives. +- **The iCal export.** `GET /calendar/events/{calendar}/ical` takes a password in the query + string on purpose, because iCal clients cannot send headers. Cookie sessions do not help + here; this endpoint likely keeps its own scheme. +- **Which legacy accounts to keep.** Step 2 is the moment to not re-invite people who no + longer need access. +- **`event_versions.version_created_by_id`.** The same INT reference again, joined in + `events.service.ts` for the "last modified by" name. It has to move with `events`, and it + is the reason step 1's bridging column needs a sibling on `event_versions`. diff --git a/package-lock.json b/package-lock.json index 695740d..083d70a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,24 @@ "version": "0.1.0", "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", @@ -31,12 +36,14 @@ "@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" @@ -105,7 +112,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -115,7 +122,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -125,7 +132,7 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.8" @@ -141,7 +148,7 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -151,6 +158,158 @@ "node": ">=6.9.0" } }, + "node_modules/@better-auth/core": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.2.tgz", + "integrity": "sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.41.1", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.4.0", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.2.tgz", + "integrity": "sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.2", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.2.tgz", + "integrity": "sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.2", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.2.tgz", + "integrity": "sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.2", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.2.tgz", + "integrity": "sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.2", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/passkey": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/passkey/-/passkey-1.7.2.tgz", + "integrity": "sha512-KBK852b+HsCdstVPPDHsuRa9Rc+7IEuRMPQboi/OXNOwgKL8GwHpzzWD2WhiT/FXJPrLCPh8vHJQfc1wdl5OZw==", + "license": "MIT", + "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/core": "^1.7.2", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "better-auth": "^1.7.2", + "better-call": "1.4.0", + "nanostores": "^1.0.1" + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.2.tgz", + "integrity": "sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.2", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.2.tgz", + "integrity": "sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.2", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -169,11 +328,17 @@ "kuler": "^2.0.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true, + "devOptional": true, "engines": { "node": ">=6.0.0" } @@ -182,14 +347,14 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -201,6 +366,12 @@ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -220,17 +391,274 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.148.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "funding": { "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@paralleldrive/cuid2/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz", + "integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", @@ -238,7 +666,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -256,7 +683,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -274,7 +700,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -292,7 +717,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -310,7 +734,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -328,7 +751,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -346,7 +768,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -367,7 +788,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -388,7 +808,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], @@ -409,7 +828,6 @@ "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], @@ -430,7 +848,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -451,7 +868,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -472,7 +888,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -490,7 +905,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -508,7 +922,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -523,10 +936,41 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.3.tgz", + "integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/app-root-path": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/app-root-path/-/app-root-path-1.2.4.tgz", @@ -553,7 +997,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -569,6 +1013,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -592,14 +1043,14 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/express": { @@ -635,6 +1086,13 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -651,7 +1109,6 @@ "version": "26.4.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -685,6 +1142,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/swagger-jsdoc": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.1.tgz", @@ -715,7 +1196,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", @@ -744,7 +1225,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -754,7 +1235,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz", "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=22" @@ -764,7 +1245,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz", "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/istanbul-lib-coverage": "1.0.1" @@ -777,7 +1258,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", @@ -805,7 +1286,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" @@ -877,11 +1358,32 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -891,7 +1393,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", @@ -903,7 +1405,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/async": { @@ -917,6 +1419,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axios": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", @@ -947,6 +1458,140 @@ "node": ">= 10.0.0" } }, + "node_modules/better-auth": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.2.tgz", + "integrity": "sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.2", + "@better-auth/drizzle-adapter": "1.7.2", + "@better-auth/kysely-adapter": "1.7.2", + "@better-auth/memory-adapter": "1.7.2", + "@better-auth/mongo-adapter": "1.7.2", + "@better-auth/prisma-adapter": "1.7.2", + "@better-auth/telemetry": "1.7.2", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@noble/ciphers": "^2.2.0", + "@noble/hashes": "^2.2.0", + "better-call": "1.4.0", + "defu": "^6.1.4", + "jose": "^6.2.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.3.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", + "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-call/node_modules/@better-auth/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -1058,18 +1703,6 @@ "node": ">= 0.8" } }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1083,6 +1716,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-me-maybe": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", @@ -1092,7 +1741,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -1179,6 +1828,16 @@ "node": ">= 6" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1232,6 +1891,13 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -1245,11 +1911,12 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1260,6 +1927,12 @@ } } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1300,6 +1973,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -1382,7 +2066,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -1421,7 +2105,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -1447,7 +2131,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -1586,11 +2270,18 @@ "node": ">= 0.8" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1699,6 +2390,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1751,7 +2460,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1791,6 +2499,15 @@ "node": ">=10" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1970,6 +2687,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -1981,16 +2704,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "node_modules/kysely": { + "version": "0.29.5", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.5.tgz", + "integrity": "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "peer": true, "dependencies": { @@ -2024,7 +2765,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2046,7 +2786,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2068,7 +2807,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2090,7 +2828,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2112,7 +2849,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2134,7 +2870,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -2159,7 +2894,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -2184,7 +2918,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -2209,7 +2942,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -2234,7 +2966,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2256,7 +2987,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2298,11 +3028,32 @@ "triple-beam": "^1.3.0" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/magic-string": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -2312,7 +3063,7 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -2528,15 +3279,65 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz", + "integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -2552,6 +3353,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.3.tgz", + "integrity": "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2618,9 +3434,13 @@ } }, "node_modules/object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2629,7 +3449,7 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -2686,7 +3506,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "license": "ISC", "peer": true }, @@ -2694,7 +3514,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -2707,7 +3527,7 @@ "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -2754,6 +3574,24 @@ "node": ">=10" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -2849,6 +3687,12 @@ "node": ">= 6" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -2867,7 +3711,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -2898,6 +3742,12 @@ "@rolldown/binding-win32-x64-msvc": "1.2.7" } }, + "node_modules/rou3": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", + "license": "MIT" + }, "node_modules/safe-stable-stringify": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.1.tgz", @@ -2987,11 +3837,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, "node_modules/send/node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3035,14 +3880,79 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "license": "MIT" + }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3052,7 +3962,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/signal-exit": { @@ -3086,7 +3996,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3102,6 +4012,21 @@ "source-map": "^0.6.0" } }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -3114,14 +4039,14 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/string_decoder": { @@ -3175,6 +4100,82 @@ "node": ">=8" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/swagger-jsdoc": { "version": "6.2.7", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.7.tgz", @@ -3282,7 +4283,7 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20.0.0" @@ -3292,7 +4293,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -3302,7 +4303,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3319,7 +4320,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -3343,6 +4344,30 @@ "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -3373,7 +4398,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -3417,7 +4441,7 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -3496,7 +4520,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", @@ -3606,7 +4630,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", @@ -3693,6 +4717,15 @@ "engines": { "node": "^12.20.0 || >=14" } + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -3749,19 +4782,19 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true + "devOptional": true }, "@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true + "devOptional": true }, "@babel/parser": { "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "dev": true, + "devOptional": true, "requires": { "@babel/types": "^7.29.8" } @@ -3770,12 +4803,81 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, + "devOptional": true, "requires": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, + "@better-auth/core": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.2.tgz", + "integrity": "sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg==", + "requires": { + "@opentelemetry/semantic-conventions": "^1.41.1", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + } + }, + "@better-auth/drizzle-adapter": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.2.tgz", + "integrity": "sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg==", + "requires": {} + }, + "@better-auth/kysely-adapter": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.2.tgz", + "integrity": "sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ==", + "requires": {} + }, + "@better-auth/memory-adapter": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.2.tgz", + "integrity": "sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA==", + "requires": {} + }, + "@better-auth/mongo-adapter": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.2.tgz", + "integrity": "sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA==", + "requires": {} + }, + "@better-auth/passkey": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/passkey/-/passkey-1.7.2.tgz", + "integrity": "sha512-KBK852b+HsCdstVPPDHsuRa9Rc+7IEuRMPQboi/OXNOwgKL8GwHpzzWD2WhiT/FXJPrLCPh8vHJQfc1wdl5OZw==", + "requires": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.1", + "zod": "^4.3.6" + } + }, + "@better-auth/prisma-adapter": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.2.tgz", + "integrity": "sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ==", + "requires": {} + }, + "@better-auth/telemetry": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.2.tgz", + "integrity": "sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ==", + "requires": {} + }, + "@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "requires": { + "@noble/hashes": "^2.0.1" + } + }, + "@better-fetch/fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==" + }, "@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -3791,23 +4893,28 @@ "kuler": "^2.0.0" } }, + "@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==" + }, "@jridgewell/resolve-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true + "devOptional": true }, "@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true + "devOptional": true }, "@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "requires": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -3818,6 +4925,11 @@ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" }, + "@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==" + }, "@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -3834,18 +4946,201 @@ "tar": "^6.1.11" } }, + "@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==" + }, + "@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==" + }, + "@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==" + }, "@oxc-project/types": { "version": "0.148.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", - "dev": true, + "devOptional": true, "peer": true }, + "@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "requires": { + "@noble/hashes": "^1.1.5" + }, + "dependencies": { + "@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true + } + } + }, + "@peculiar/asn1-android": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz", + "integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==", + "requires": { + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-cms": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz", + "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "requires": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-csr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz", + "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "requires": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-ecc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz", + "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "requires": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-pfx": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz", + "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "requires": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-pkcs8": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz", + "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "requires": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-pkcs9": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz", + "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "requires": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-pfx": "^2.9.4", + "@peculiar/asn1-pkcs8": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-attr": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-rsa": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz", + "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "requires": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-schema": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "requires": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-x509": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz", + "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "requires": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/asn1-x509-attr": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz", + "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "requires": { + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "requires": { + "tslib": "^2.8.1" + } + }, + "@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "requires": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + } + }, "@rolldown/binding-android-arm-eabi": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", - "dev": true, "optional": true, "peer": true }, @@ -3853,7 +5148,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", - "dev": true, "optional": true, "peer": true }, @@ -3861,7 +5155,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", - "dev": true, "optional": true, "peer": true }, @@ -3869,7 +5162,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", - "dev": true, "optional": true, "peer": true }, @@ -3877,7 +5169,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", - "dev": true, "optional": true, "peer": true }, @@ -3885,7 +5176,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", - "dev": true, "optional": true, "peer": true }, @@ -3893,7 +5183,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", - "dev": true, "optional": true, "peer": true }, @@ -3901,7 +5190,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", - "dev": true, "optional": true, "peer": true }, @@ -3909,7 +5197,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", - "dev": true, "optional": true, "peer": true }, @@ -3917,7 +5204,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", - "dev": true, "optional": true, "peer": true }, @@ -3925,7 +5211,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", - "dev": true, "optional": true, "peer": true }, @@ -3933,7 +5218,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", - "dev": true, "optional": true, "peer": true }, @@ -3941,7 +5225,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", - "dev": true, "optional": true, "peer": true }, @@ -3949,7 +5232,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", - "dev": true, "optional": true, "peer": true }, @@ -3957,7 +5239,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", - "dev": true, "optional": true, "peer": true }, @@ -3965,9 +5246,34 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, + "devOptional": true, "peer": true }, + "@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==" + }, + "@simplewebauthn/server": { + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.3.tgz", + "integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==", + "requires": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + } + }, + "@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, "@types/app-root-path": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/app-root-path/-/app-root-path-1.2.4.tgz", @@ -3994,7 +5300,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "devOptional": true, "requires": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -4009,6 +5315,12 @@ "@types/node": "*" } }, + "@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true + }, "@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -4031,13 +5343,13 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true + "devOptional": true }, "@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true + "devOptional": true }, "@types/express": { "version": "4.17.15", @@ -4072,6 +5384,12 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" }, + "@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true + }, "@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -4088,7 +5406,6 @@ "version": "26.4.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", - "dev": true, "requires": { "undici-types": "~8.3.0" } @@ -4121,6 +5438,28 @@ "@types/node": "*" } }, + "@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "requires": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "requires": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "@types/swagger-jsdoc": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.1.tgz", @@ -4150,7 +5489,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==", - "dev": true, + "devOptional": true, "requires": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/istanbul-lib-coverage": "^1.0.0", @@ -4166,7 +5505,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true + "devOptional": true } } }, @@ -4174,13 +5513,13 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz", "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==", - "dev": true + "devOptional": true }, "@vitest/istanbul-lib-report": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz", "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==", - "dev": true, + "devOptional": true, "requires": { "@vitest/istanbul-lib-coverage": "1.0.1" } @@ -4189,7 +5528,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", - "dev": true, + "devOptional": true, "requires": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.0", @@ -4201,7 +5540,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", - "dev": true + "devOptional": true }, "abbrev": { "version": "1.1.1", @@ -4254,17 +5593,33 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true + }, + "asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "requires": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + } + }, "assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true + "devOptional": true }, "ast-v8-to-istanbul": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", - "dev": true, + "devOptional": true, "requires": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", @@ -4275,7 +5630,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true + "devOptional": true } } }, @@ -4289,6 +5644,11 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==" + }, "axios": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", @@ -4314,6 +5674,51 @@ "node-addon-api": "^5.0.0" } }, + "better-auth": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.2.tgz", + "integrity": "sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A==", + "requires": { + "@better-auth/core": "1.7.2", + "@better-auth/drizzle-adapter": "1.7.2", + "@better-auth/kysely-adapter": "1.7.2", + "@better-auth/memory-adapter": "1.7.2", + "@better-auth/mongo-adapter": "1.7.2", + "@better-auth/prisma-adapter": "1.7.2", + "@better-auth/telemetry": "1.7.2", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@noble/ciphers": "^2.2.0", + "@noble/hashes": "^2.2.0", + "better-call": "1.4.0", + "defu": "^6.1.4", + "jose": "^6.2.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.3.0", + "zod": "^4.3.6" + } + }, + "better-call": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", + "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", + "requires": { + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" + }, + "dependencies": { + "@better-auth/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "requires": { + "@noble/hashes": "^2.0.1" + } + } + } + }, "body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -4408,15 +5813,6 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" }, - "call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "requires": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - } - }, "call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4426,6 +5822,15 @@ "function-bind": "^1.1.2" } }, + "call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + } + }, "call-me-maybe": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", @@ -4435,7 +5840,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true + "devOptional": true }, "chownr": { "version": "2.0.0", @@ -4507,6 +5912,12 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==" }, + "component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4542,6 +5953,12 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true + }, "cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -4552,13 +5969,18 @@ } }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, + "defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==" + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4584,6 +6006,16 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" }, + "dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "requires": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -4641,7 +6073,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true + "devOptional": true }, "es-object-atoms": { "version": "1.1.2", @@ -4671,7 +6103,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, + "devOptional": true, "requires": { "@types/estree": "^1.0.0" } @@ -4690,7 +6122,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true + "devOptional": true }, "express": { "version": "4.18.2", @@ -4795,11 +6227,17 @@ } } }, + "fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true + }, "fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "requires": {} }, "fecha": { @@ -4871,6 +6309,17 @@ "mime-types": "^2.1.35" } }, + "formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "requires": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4913,7 +6362,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "optional": true, "peer": true }, @@ -4938,6 +6386,14 @@ "wide-align": "^1.1.2" } }, + "generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "requires": { + "is-property": "^1.0.2" + } + }, "get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5060,21 +6516,36 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, "is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" }, + "jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==" + }, "kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, + "kysely": { + "version": "0.29.5", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.5.tgz", + "integrity": "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==" + }, "lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, + "devOptional": true, "peer": true, "requires": { "detect-libc": "^2.0.3", @@ -5095,7 +6566,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "dev": true, "optional": true, "peer": true }, @@ -5103,7 +6573,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "dev": true, "optional": true, "peer": true }, @@ -5111,7 +6580,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "dev": true, "optional": true, "peer": true }, @@ -5119,7 +6587,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "dev": true, "optional": true, "peer": true }, @@ -5127,7 +6594,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "dev": true, "optional": true, "peer": true }, @@ -5135,7 +6601,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "dev": true, "optional": true, "peer": true }, @@ -5143,7 +6608,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "dev": true, "optional": true, "peer": true }, @@ -5151,7 +6615,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "dev": true, "optional": true, "peer": true }, @@ -5159,7 +6622,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "dev": true, "optional": true, "peer": true }, @@ -5167,7 +6629,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "dev": true, "optional": true, "peer": true }, @@ -5175,7 +6636,6 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "dev": true, "optional": true, "peer": true }, @@ -5206,11 +6666,21 @@ "triple-beam": "^1.3.0" } }, + "long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "lru.min": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz", + "integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==" + }, "magic-string": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", - "dev": true, + "devOptional": true, "requires": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -5219,7 +6689,7 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", - "dev": true, + "devOptional": true, "requires": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", @@ -5378,17 +6848,54 @@ } }, "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "mysql2": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz", + "integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==", + "requires": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "dependencies": { + "iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, + "named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "requires": { + "lru.min": "^1.1.0" + } }, "nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, + "devOptional": true, "peer": true }, + "nanostores": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.3.tgz", + "integrity": "sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==" + }, "negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -5432,15 +6939,15 @@ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, "object-inspect": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, "obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", - "dev": true + "devOptional": true }, "once": { "version": "1.4.0", @@ -5483,20 +6990,20 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "peer": true }, "picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true + "devOptional": true }, "postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, + "devOptional": true, "peer": true, "requires": { "nanoid": "^3.3.18", @@ -5518,6 +7025,19 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" }, + "pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "requires": { + "tslib": "^2.8.1" + } + }, + "pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==" + }, "qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -5591,6 +7111,11 @@ "util-deprecate": "^1.0.1" } }, + "reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -5603,7 +7128,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", - "dev": true, + "devOptional": true, "peer": true, "requires": { "@oxc-project/types": "=0.148.0", @@ -5625,6 +7150,11 @@ "@rolldown/pluginutils": "^1.0.0" } }, + "rou3": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==" + }, "safe-stable-stringify": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.1.tgz", @@ -5697,11 +7227,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, "on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -5738,21 +7263,60 @@ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" }, + "set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==" + }, "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + } + }, + "side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "requires": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + } + }, + "side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + } + }, + "side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "requires": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" } }, "siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true + "devOptional": true }, "signal-exit": { "version": "3.0.7", @@ -5784,7 +7348,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true + "devOptional": true }, "source-map-support": { "version": "0.5.21", @@ -5796,6 +7360,11 @@ "source-map": "^0.6.0" } }, + "sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==" + }, "stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -5805,13 +7374,13 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true + "devOptional": true }, "std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true + "devOptional": true }, "string_decoder": { "version": "1.3.0", @@ -5846,6 +7415,60 @@ "ansi-regex": "^5.0.1" } }, + "superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "requires": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "dependencies": { + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true + }, + "qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "requires": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + } + } + } + }, + "supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "requires": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "dependencies": { + "cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true + } + } + }, "swagger-jsdoc": { "version": "6.2.7", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.7.tgz", @@ -5929,19 +7552,19 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", - "dev": true + "devOptional": true }, "tinyexec": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", - "dev": true + "devOptional": true }, "tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, + "devOptional": true, "requires": { "fdir": "^6.5.0", "picomatch": "^4.0.4" @@ -5951,7 +7574,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true + "devOptional": true }, "toidentifier": { "version": "1.0.1", @@ -5968,6 +7591,26 @@ "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "requires": { + "tslib": "^1.9.3" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + } + } + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -5986,8 +7629,7 @@ "undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" }, "unpipe": { "version": "1.0.0", @@ -6018,7 +7660,7 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", - "dev": true, + "devOptional": true, "peer": true, "requires": { "fsevents": "~2.3.3", @@ -6033,7 +7675,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", - "dev": true, + "devOptional": true, "requires": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.0", @@ -6075,7 +7717,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, + "devOptional": true, "requires": { "siginfo": "^2.0.0", "stackback": "0.0.2" @@ -6140,6 +7782,11 @@ "optional": true } } + }, + "zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==" } } } diff --git a/package.json b/package.json index 4fb7f19..8644567 100644 --- a/package.json +++ b/package.json @@ -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" + } } } diff --git a/sql/admin/001_init.sql b/sql/admin/001_init.sql new file mode 100644 index 0000000..f3f5b2a --- /dev/null +++ b/sql/admin/001_init.sql @@ -0,0 +1,144 @@ +-- 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. `role` is reserved for +-- per-app roles later and is 'admin' for every row today. +CREATE TABLE IF NOT EXISTS `user_app_permissions` ( + `user_id` VARCHAR(36) NOT NULL, + `app` ENUM('calendar','feedback','tickets','admin') NOT NULL, + `role` VARCHAR(32) NOT NULL DEFAULT 'admin', + `granted_by` VARCHAR(36) DEFAULT NULL, + `granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `app`), + 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, + `apps` 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; diff --git a/src/app.factory.ts b/src/app.factory.ts new file mode 100644 index 0000000..28a3855 --- /dev/null +++ b/src/app.factory.ts @@ -0,0 +1,138 @@ +import express from 'express'; +import * as dotenv from 'dotenv'; +import swaggerUi from 'swagger-ui-express'; +import swaggerJSDoc from 'swagger-jsdoc'; +import cors from 'cors'; +import {toNodeHandler} from 'better-auth/node'; + +// Router imports +import {calendarRouter} from './models/calendar/Calendar.router.js'; +import {feedbackRouter} from './models/feedback/Feedback.router.js'; +import {ticketsRouter} from './models/tickets/Tickets.router.js'; +import {adminRouter} from './models/admin/Admin.router.js'; +import {auth} from './models/admin/admin.auth.js'; +import {ADMIN_ALLOWED_ORIGINS} 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 + ]; + const isDev = process.env.NODE_ENV !== 'production'; + const localhostRegex = /^http:\/\/localhost:\d+$/; + // Matches http://: - needed so the feedback form can + // be reached from a real phone over WiFi during dev (the phone's Origin is + // the dev machine's LAN IP, never "localhost"). Dev-only, same as above. + const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/; + app.use(cors({ + // X-Session-* stay allowed until the calendar module is migrated off the + // legacy header sessions (see docs/calendar-auth-migration.md). + allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'], + // The admin session lives in a cookie, so browsers must be allowed to send + // it cross-origin - this is what makes credentials: 'include' work. + credentials: true, + origin: function (origin: any, callback: any) { + // Allow requests with no origin + if (!origin) return callback(null, true); + + // Any localhost port, or a private-LAN IP, is fine outside production - + // dev servers pick whatever port is free (Next.js falls back from 3000 + // if it's taken), and real-device testing hits the dev machine by IP. + if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) { + return callback(null, true); + } + + // Block requests with wrong origin + if (allowedHosts.indexOf(origin) === -1) { + return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false); + } + + // Allow all other requests + return callback(null, true); + } + })); + + // better-auth's own handler, mounted before express.json(): it reads the raw + // request body stream itself and a parsed body would leave it hanging. + app.all('/admin/auth/*', toNodeHandler(auth)); + + // here we are adding middleware to parse all incoming requests as JSON + app.use(express.json()); + + // Swagger documentation + const swaggerDefinition = { + openapi: '3.0.0', + info: { + title: 'Nachklang e.V. REST API', + version: '1.0.0', + license: { + name: 'Licensed Under MIT', + url: 'https://spdx.org/licenses/MIT.html' + }, + contact: { + name: 'Nachklang e.V.', + url: 'https://www.nachklang.art' + } + } + }; + + const options = { + swaggerDefinition, + // Paths to files containing OpenAPI definitions + apis: [ + './src/models/**/*.interface.ts', + './src/models/**/*.router.ts' + ] + }; + + const swaggerSpec = swaggerJSDoc(options); + + app.use( + '/docs', + swaggerUi.serve, + swaggerUi.setup(swaggerSpec) + ); + + // Add routers + app.use('/calendar', calendarRouter); + app.use('/feedback', feedbackRouter); + app.use('/tickets', ticketsRouter); + // JSON routes only; the auth handler above is mounted separately. + app.use('/admin', adminRouter); + + // this is a simple route to make sure everything is working properly + app.get('/', (req: express.Request, res: express.Response) => { + res.status(200).send('Welcome to the Nachklang e.V. REST API!'); + }); + + return app; +}; diff --git a/src/models/admin/Admin.db.ts b/src/models/admin/Admin.db.ts new file mode 100644 index 0000000..d058c0d --- /dev/null +++ b/src/models/admin/Admin.db.ts @@ -0,0 +1,55 @@ +import * as dotenv from 'dotenv'; +import mysql from 'mysql2'; +import {Kysely, MysqlDialect} from 'kysely'; +import {AdminDatabase} from './admin.schema.js'; +import logger from '../../middleware/logger.js'; + +dotenv.config(); + +/** + * The admin module is the one place in this API that does not use the + * `mariadb` driver: better-auth talks to the database through Kysely, whose + * MySQL dialect expects a mysql2 pool. The other domains keep their own + * `mariadb` pools (see Feedback.db.ts) - this is an addition, not a migration. + * + * The pool is the callback-style `mysql2` one, NOT `mysql2/promise`: Kysely's + * MysqlDialect calls `pool.getConnection((err, conn) => ...)`. The promise + * wrapper ignores that callback and returns a Promise instead, so every query + * through Kysely would hang forever with no error - which is exactly what it + * did until the integration tests caught it. + * + * timezone 'Z' matters: better-auth computes session and token expiry in UTC. + * Without it mysql2 would write and read those DATETIMEs in the process's local + * zone, so sessions would expire an hour early or late depending on DST. + */ + +export namespace NachklangAdminDB { + export const pool = mysql.createPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.ADMIN_DB, + // The other modules' pools default to 3306. This one is configurable so + // the integration tests can point at a throwaway container on another + // port without touching a developer's real .env. + port: parseInt(process.env.DB_PORT || '3306', 10), + connectionLimit: 5, + timezone: 'Z' + }); + + // mysql2 emits connection trouble as an event on the pool, not only as a + // rejected query. Without a listener Node turns that into an + // uncaughtException, so a database restart would take the whole API - and + // with it the calendar, feedback and tickets domains - down with it. + // Individual queries still reject, and their callers still answer 500. + pool.on('error', (err: unknown) => { + logger.error('Admin database pool error', {detail: (err as any)?.message}); + }); + + // Handed to better-auth as `database: {dialect, type: 'mysql'}`. + export const dialect = new MysqlDialect({pool}); + + // Used by this module's own services for the two custom tables and for + // permission lookups that join better-auth's `user`. + export const db = new Kysely({dialect}); +} diff --git a/src/models/admin/Admin.router.ts b/src/models/admin/Admin.router.ts new file mode 100644 index 0000000..2e99933 --- /dev/null +++ b/src/models/admin/Admin.router.ts @@ -0,0 +1,43 @@ +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, + apps: res.locals.admin.apps + }); +}); + +adminRouter.use('/users', requireAppAccess('admin'), usersAdminRouter); +adminRouter.use('/invitations', requireAppAccess('admin'), invitationsRouter); diff --git a/src/models/admin/admin.auth.ts b/src/models/admin/admin.auth.ts new file mode 100644 index 0000000..7ab6309 --- /dev/null +++ b/src/models/admin/admin.auth.ts @@ -0,0 +1,141 @@ +import {betterAuth} from 'better-auth'; +import {APIError} from 'better-auth/api'; +import {passkey} 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, + PASSKEY_RP_ID, + isProd +} from './admin.config.js'; + +/** + * The single better-auth instance for all *.nachklang.art apps. Mounted in + * app.ts at /admin/auth/* with better-auth's own node handler, ahead of + * express.json() (it needs the raw body stream). + * + * The session cookie is what every app trusts. Everything else in this module - + * permissions, invitations, the admin UI - hangs off it. + */ + +const DAY = 60 * 60 * 24; + +// Dev runs the apps on plain localhost ports; cookies ignore the port, so +// single-sign-on across them works without fake subdomains or mkcert. +const localhostOrigins = [ + 'http://localhost:3000', + 'http://localhost:3001', + 'http://localhost:3002', + 'http://localhost:3003' +]; + +const trustedOrigins = isProd + ? ADMIN_ALLOWED_ORIGINS + : Array.from(new Set([...ADMIN_ALLOWED_ORIGINS, ...localhostOrigins])); + +export const auth = betterAuth({ + appName: 'Nachklang', + database: { + dialect: NachklangAdminDB.dialect, + type: 'mysql' + }, + basePath: '/admin/auth', + // Mandatory once crossSubDomainCookies is on: better-auth derives the + // cookie domain and its own absolute URLs from this. + baseURL: API_BASE_URL, + secret: BETTER_AUTH_SECRET, + trustedOrigins, + + emailAndPassword: { + enabled: true, + // There is no public sign-up: accounts exist only through an + // invitation (see invitations.plugin.ts). This also makes + // auth.api.signUpEmail throw, which is intended. + disableSignUp: true, + sendResetPassword: async ({user, url}) => { + await sendPasswordResetMail(user.email, user.name, url); + } + }, + + user: { + additionalFields: { + // Not `returned`, and not settable through the API: disabling is an + // admin action on /admin/users/:id/disable, never something a + // session owner can flip on themselves. + disabled: { + type: 'boolean', + defaultValue: false, + input: false, + returned: false + } + } + }, + + session: { + expiresIn: 30 * DAY, + updateAge: DAY + // Deliberately no cookieCache: requireAppAccess hits the database on + // every request anyway, and a cached session would keep a disabled + // user or a revoked session alive for the cache's lifetime. + }, + + advanced: { + // Fixes the cookie name across releases so the frontends' middleware can + // check for it: "nachklang.session_token", or + // "__Secure-nachklang.session_token" over https. + cookiePrefix: 'nachklang', + crossSubDomainCookies: isProd + ? {enabled: true, domain: '.nachklang.art'} + : {enabled: false}, + ipAddress: { + // better-auth reads the request itself and does not know about + // Express's `trust proxy`, so the header has to be named here. + // Verify against what Plesk's nginx actually sets before relying on + // the rate limiter (see the plan's pre-deploy checklist). + ipAddressHeaders: ['x-real-ip', 'x-forwarded-for'] + } + }, + + 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 + }), + 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; diff --git a/src/models/admin/admin.bootstrap.ts b/src/models/admin/admin.bootstrap.ts new file mode 100644 index 0000000..717755f --- /dev/null +++ b/src/models/admin/admin.bootstrap.ts @@ -0,0 +1,67 @@ +import * as UsersService from './users/users.admin.service.js'; +import * as InvitationsService from './invitations/invitations.service.js'; +import {sendInvitationMail} from './admin.mail.js'; +import {ADMIN_APP_URL, ADMIN_BOOTSTRAP_EMAIL, isProd} 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 => { + 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', + ['admin'], + null + ); + + const mailed = await sendInvitationMail(email, 'Nachklang Admin', invitation.token, invitation.expiresAt); + logger.info('Admin bootstrap: invitation created', {email, mailed}); + + // Outside production the Salesforce mail relay is usually off, so the + // link is logged instead - that is how a local setup gets its first + // admin. Never in production: the log would then hold a live credential. + if (!isProd) { + logger.info(`Admin bootstrap: ${ADMIN_APP_URL}/accept-invite?token=${invitation.token}`); + } + } catch (e: any) { + logger.error('Admin bootstrap failed', {detail: e?.message}); + } +}; diff --git a/src/models/admin/admin.config.ts b/src/models/admin/admin.config.ts new file mode 100644 index 0000000..d5bb027 --- /dev/null +++ b/src/models/admin/admin.config.ts @@ -0,0 +1,63 @@ +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. + * + * In production every value is mandatory: a missing BETTER_AUTH_SECRET or a + * wrong ADMIN_APP_URL is the kind of misconfiguration that fails as "login + * silently does nothing" hours later, so it fails at boot instead. In dev the + * localhost defaults below let a fresh checkout run without an .env. + */ + +export const isProd = process.env.NODE_ENV === 'production'; + +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`); + throw new Error(`${name} must be set in production`); + } + 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. +export const BETTER_AUTH_SECRET = required( + 'BETTER_AUTH_SECRET', + 'dev-only-insecure-secret-do-not-use-in-production' +); + +// 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'); + +// The apps whose frontends may talk to /admin/* with credentials. +const parseOrigins = (value: string | undefined): string[] => { + return (value || '') + .split(',') + .map(origin => origin.trim().replace(/\/$/, '')) + .filter(origin => origin.length > 0); +}; + +export const APP_ORIGINS = parseOrigins(process.env.APP_ORIGINS); + +// 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 +])); + +export const ADMIN_BOOTSTRAP_EMAIL = process.env.ADMIN_BOOTSTRAP_EMAIL || ''; diff --git a/src/models/admin/admin.errors.ts b/src/models/admin/admin.errors.ts new file mode 100644 index 0000000..266f03d --- /dev/null +++ b/src/models/admin/admin.errors.ts @@ -0,0 +1,17 @@ +import {Response} from 'express'; +import {Guid} from 'guid-typescript'; +import logger from '../../middleware/logger.js'; + +/** + * Same catch-block convention as the feedback and tickets modules: log with a + * reference guid, never hand the real error message to the client. + */ +export const sendServerError = (res: Response, e: any): void => { + const errorGuid = Guid.create().toString(); + logger.error('Error handling a request: ' + e.message, {reference: errorGuid}); + res.status(500).send({ + status: 'PROCESSING_ERROR', + message: 'Internal Server Error. Try again later.', + reference: errorGuid + }); +}; diff --git a/src/models/admin/admin.interface.ts b/src/models/admin/admin.interface.ts new file mode 100644 index 0000000..616f17a --- /dev/null +++ b/src/models/admin/admin.interface.ts @@ -0,0 +1,120 @@ +/** + * Swagger component definitions for the admin module. Picked up by + * swagger-jsdoc through the `src/models/**\/*.interface.ts` glob in + * app.factory.ts. + * + * Note what is *not* documented here: the better-auth routes under + * /admin/auth/* (sign-in, sign-out, reset-password, passkey ceremonies, and + * the invitation preview/accept endpoints). better-auth owns those paths and + * their shapes; duplicating them by hand would only drift on the next upgrade. + */ + +/** + * @swagger + * components: + * securitySchemes: + * AdminSessionCookie: + * type: apiKey + * in: cookie + * name: nachklang.session_token + * description: > + * Set by /admin/auth/sign-in/email. Over https the name is + * __Secure-nachklang.session_token and the cookie is scoped to + * .nachklang.art, so one sign-in covers every *.nachklang.art app. + * schemas: + * AdminApp: + * type: string + * enum: [calendar, feedback, tickets, admin] + * description: Holding "admin" is what allows managing users and invitations. + * AdminMe: + * type: object + * properties: + * id: + * type: string + * email: + * type: string + * fullName: + * type: string + * apps: + * type: array + * items: + * $ref: '#/components/schemas/AdminApp' + * AdminUserSession: + * type: object + * properties: + * id: + * type: string + * createdAt: + * type: string + * format: date-time + * expiresAt: + * type: string + * format: date-time + * ipAddress: + * type: string + * nullable: true + * userAgent: + * type: string + * nullable: true + * AdminUser: + * type: object + * properties: + * id: + * type: string + * email: + * type: string + * name: + * type: string + * apps: + * type: array + * items: + * $ref: '#/components/schemas/AdminApp' + * status: + * type: string + * enum: [aktiv, deaktiviert] + * description: Derived - there is no status column. + * createdAt: + * type: string + * format: date-time + * lastSignInAt: + * type: string + * format: date-time + * nullable: true + * description: Newest session's creation time; null once every session has expired. + * AdminUserDetail: + * allOf: + * - $ref: '#/components/schemas/AdminUser' + * - type: object + * properties: + * sessions: + * type: array + * items: + * $ref: '#/components/schemas/AdminUserSession' + * passkeyCount: + * type: integer + * AdminInvitation: + * type: object + * description: An open invitation. The token itself is never returned by any endpoint. + * properties: + * id: + * type: integer + * email: + * type: string + * name: + * type: string + * apps: + * type: array + * items: + * $ref: '#/components/schemas/AdminApp' + * invitedBy: + * type: string + * nullable: true + * description: Null for the invitation created by the ADMIN_BOOTSTRAP_EMAIL bootstrap. + * createdAt: + * type: string + * format: date-time + * expiresAt: + * type: string + * format: date-time + */ +export {}; diff --git a/src/models/admin/admin.mail.ts b/src/models/admin/admin.mail.ts new file mode 100644 index 0000000..6d88309 --- /dev/null +++ b/src/models/admin/admin.mail.ts @@ -0,0 +1,124 @@ +import {MailService} from '../../common/common.mail.js'; +import {ADMIN_APP_URL} from './admin.config.js'; + +/** + * The two transactional mails the admin module sends. Both go out through the + * shared MailService (Salesforce relay, see common.mail.ts), which never throws + * on a delivery failure - the invitation row and the reset token are already + * committed by the time we get here. + * + * HTML plus a plain-text body: the text part is not a fallback afterthought, + * it is what allowlist-based receivers and text-only clients actually show. + */ + +const escapeHtml = (value: string): string => { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +}; + +// `heading` is escaped here; `paragraphs` are not, because callers pass markup +// (a 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}

`).join(''); + return ` + + +
+

${escapeHtml(heading)}

+${body} +

+${escapeHtml(buttonLabel)} +

+

Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
+${escapeHtml(buttonUrl)}

+
+ +`; +}; + +/** + * 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 => { + 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 ${escapeHtml(expiry)} 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 => { + 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}); +}; diff --git a/src/models/admin/admin.middleware.ts b/src/models/admin/admin.middleware.ts new file mode 100644 index 0000000..2ff7fd7 --- /dev/null +++ b/src/models/admin/admin.middleware.ts @@ -0,0 +1,124 @@ +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} 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; + 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 => { + 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, + 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. + */ +export const requireAppAccess = (app: AppName): 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; + } + if (!access.apps.includes(app)) { + forbidden(res, 'Für diesen Bereich fehlt dir die Berechtigung.'); + return; + } + + res.locals.admin = access; + next(); + } catch (e: any) { + sendServerError(res, e); + } + }; +}; diff --git a/src/models/admin/admin.schema.ts b/src/models/admin/admin.schema.ts new file mode 100644 index 0000000..d26763d --- /dev/null +++ b/src/models/admin/admin.schema.ts @@ -0,0 +1,81 @@ +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); +}; + +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: string; + granted_by: string | null; + granted_at: Generated; +} + +export interface InvitationTable { + // AUTO_INCREMENT: present on select, never supplied on insert. + id: Generated; + email: string; + name: string; + token_hash: string; + // JSON column holding an AppName[]. + apps: string; + invited_by: string | null; + created_at: Generated; + expires_at: Date; + accepted_at: Date | null; + revoked_at: Date | null; +} + +export interface AdminDatabase { + user: UserTable; + session: SessionTable; + passkey: PasskeyTable; + user_app_permissions: UserAppPermissionTable; + invitations: InvitationTable; +} diff --git a/src/models/admin/invitations/invitations.plugin.ts b/src/models/admin/invitations/invitations.plugin.ts new file mode 100644 index 0000000..37ec281 --- /dev/null +++ b/src/models/admin/invitations/invitations.plugin.ts @@ -0,0 +1,157 @@ +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 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(); + } + + try { + const user = 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 would not be found on sign-in. + await ctx.context.internalAdapter.linkAccount({ + userId: user.id, + providerId: 'credential', + issuer: createLocalAccountIssuer('credential'), + accountId: user.id, + password: await ctx.context.password.hash(ctx.body.password) + }); + + await UsersService.setPermissions(user.id, invitation.apps, 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) { + // The invitation is already marked accepted at this + // point. Leaving it that way is deliberate: a token that + // has been through a half-completed account creation + // should not stay usable. The admin can send a new + // invitation, and this log says why one is needed. + logger.error('Admin: invitation accepted but account creation failed', { + invitationId: invitation.id, + detail: e?.message + }); + throw e; + } + } + ) + } + } satisfies BetterAuthPlugin; +}; diff --git a/src/models/admin/invitations/invitations.router.ts b/src/models/admin/invitations/invitations.router.ts new file mode 100644 index 0000000..cc5433f --- /dev/null +++ b/src/models/admin/invitations/invitations.router.ts @@ -0,0 +1,194 @@ +import express, {Request, Response} from 'express'; +import * as InvitationsService from './invitations.service.js'; +import * as UsersService from '../users/users.admin.service.js'; +import {isAppName, AppName} from '../admin.schema.js'; +import {sendInvitationMail} from '../admin.mail.js'; +import {ADMIN_APP_URL} from '../admin.config.js'; +import {sendServerError} from '../admin.errors.js'; +import logger from '../../../middleware/logger.js'; +import {isProd} from '../admin.config.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@]+$/; + +/** + * Outside production the Salesforce relay is normally off, so the invitation + * mail never arrives and only the token's hash is stored - there would be no + * way to walk through the accept flow locally. Logging the link closes that, + * and mirrors what the bootstrap already does. Never in production: the log + * would then hold a live credential. + */ +const logInviteLinkInDev = (token: string): void => { + if (!isProd) { + 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, apps] + * properties: + * email: + * type: string + * name: + * type: string + * apps: + * type: array + * items: + * 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(); + const apps: unknown = req.body?.apps; + + if (!EMAIL_PATTERN.test(email) || name.length === 0 || !Array.isArray(apps) || !apps.every(isAppName)) { + res.status(400).send({status: 'BAD_REQUEST', message: 'E-Mail, Name und App-Liste 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, + apps as AppName[], + 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); + } +}); diff --git a/src/models/admin/invitations/invitations.service.ts b/src/models/admin/invitations/invitations.service.ts new file mode 100644 index 0000000..50e6157 --- /dev/null +++ b/src/models/admin/invitations/invitations.service.ts @@ -0,0 +1,209 @@ +import * as crypto from 'crypto'; +import {NachklangAdminDB} from '../Admin.db.js'; +import {AppName, APP_NAMES, isAppName} 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; + apps: AppName[]; + invitedBy: string | null; + createdAt: Date; + expiresAt: Date; +} + +export interface AcceptableInvitation { + id: number; + email: string; + name: string; + apps: AppName[]; +} + +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'); +}; + +const parseApps = (value: unknown): AppName[] => { + // 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. + const raw = typeof value === 'string' ? JSON.parse(value) : value; + return Array.isArray(raw) ? raw.filter(isAppName) : []; +}; + +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, + apps: AppName[], + invitedBy: string | null +): Promise<{id: number; token: string; expiresAt: Date}> => { + const token = generateToken(); + const expiresAt = expiryFromNow(); + const validApps = Array.from(new Set(apps)).filter(app => APP_NAMES.includes(app)); + + 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), + apps: JSON.stringify(validApps), + 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 => { + const row = await db + .selectFrom('invitations') + .select(['id', 'email', 'name', 'apps']) + .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, apps: parseApps(row.apps)}; +}; + +/** 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 => { + 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; +}; + +export const listOpenInvitations = async (): Promise => { + const rows = await db + .selectFrom('invitations') + .select(['id', 'email', 'name', 'apps', '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, + apps: parseApps(row.apps), + invitedBy: row.invited_by, + createdAt: row.created_at, + expiresAt: row.expires_at + })); +}; + +export const getOpenInvitation = async (invitationId: number): Promise => { + 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 => { + 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 => { + 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); +}; diff --git a/src/models/admin/users/users.admin.router.ts b/src/models/admin/users/users.admin.router.ts new file mode 100644 index 0000000..b22251c --- /dev/null +++ b/src/models/admin/users/users.admin.router.ts @@ -0,0 +1,229 @@ +import express, {Request, Response} from 'express'; +import * as UsersService from './users.admin.service.js'; +import {AppName, isAppName} 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: + * apps: + * type: array + * items: + * type: string + * enum: [calendar, feedback, tickets, admin] + * responses: + * 200: + * description: Success + * 400: + * description: Invalid app name + * 409: + * description: Would lock the last admin out + */ +usersAdminRouter.put('/:userId/permissions', async (req: Request, res: Response) => { + try { + const userId = req.params.userId; + const apps: unknown = req.body?.apps; + + if (!Array.isArray(apps) || !apps.every(isAppName)) { + res.status(400).send({status: 'BAD_REQUEST', message: 'Ungültige App-Liste.'}); + return; + } + + if (!(await UsersService.userExists(userId))) { + notFound(res); + return; + } + + const target = await UsersService.loadAccess(userId); + const losesAdmin = Boolean(target?.apps.includes('admin')) && !(apps as AppName[]).includes('admin'); + + if (losesAdmin && userId === res.locals.admin.id) { + conflict(res, 'Du kannst dir die Admin-Berechtigung nicht selbst entziehen.'); + return; + } + + // Only an enabled admin counts; see countActiveAdmins. + if (losesAdmin && !target?.disabled && (await UsersService.countActiveAdmins()) <= 1) { + conflict(res, 'Die letzte Admin-Berechtigung kann nicht entzogen werden.'); + return; + } + + await UsersService.setPermissions(userId, apps as AppName[], res.locals.admin.id); + 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; + } + + if (target.apps.includes('admin') && !target.disabled && (await UsersService.countActiveAdmins()) <= 1) { + conflict(res, 'Der letzte aktive Admin kann nicht deaktiviert werden.'); + return; + } + + await UsersService.disableUser(userId); + 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); + } +}); diff --git a/src/models/admin/users/users.admin.service.ts b/src/models/admin/users/users.admin.service.ts new file mode 100644 index 0000000..d23de61 --- /dev/null +++ b/src/models/admin/users/users.admin.service.ts @@ -0,0 +1,260 @@ +import {NachklangAdminDB} from '../Admin.db.js'; +import {AppName, APP_NAMES} 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; + apps: AppName[]; +} + +export type UserStatus = 'aktiv' | 'deaktiviert'; + +export interface UserListEntry { + id: string; + email: string; + name: string; + 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 => { + 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' + ]) + .execute(); + + if (rows.length === 0) { + return null; + } + + 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), + apps: rows.map(row => row.app).filter((app): app is AppName => app !== null) + }; +}; + +export const listUsers = async (): Promise => { + 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']) + .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. Sessions are pruned on expiry, so this goes back to null + // for someone who has not signed in for over 30 days. + const lastSessions = await db + .selectFrom('session') + .select(({fn}) => ['userId', fn.max('createdAt').as('lastSignInAt')]) + .groupBy('userId') + .execute(); + + const appsByUser = new Map(); + for (const row of permissions) { + const apps = appsByUser.get(row.user_id) || []; + apps.push(row.app); + appsByUser.set(row.user_id, apps); + } + + const lastSignInByUser = new Map( + lastSessions.map(row => [row.userId, row.lastSignInAt as Date | null]) + ); + + return users.map(user => ({ + id: user.id, + email: user.email, + name: user.name, + apps: appsByUser.get(user.id) || [], + status: user.disabled ? 'deaktiviert' : 'aktiv', + createdAt: user.createdAt, + lastSignInAt: lastSignInByUser.get(user.id) ?? null + })); +}; + +export const getUserDetail = async (userId: string): Promise => { + 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').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().as('count')) + .where('userId', '=', userId) + .executeTakeFirst() + ]); + + return { + id: user.id, + email: user.email, + name: user.name, + apps: permissions.map(row => row.app), + 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. + */ +export const setPermissions = async ( + userId: string, + apps: AppName[], + grantedBy: string | null +): Promise => { + const unique = Array.from(new Set(apps)).filter(app => APP_NAMES.includes(app)); + + await db.transaction().execute(async trx => { + await trx.deleteFrom('user_app_permissions').where('user_id', '=', userId).execute(); + if (unique.length > 0) { + await trx + .insertInto('user_app_permissions') + .values(unique.map(app => ({ + user_id: userId, + app, + role: 'admin', + granted_by: grantedBy, + granted_at: new Date() + }))) + .execute(); + } + }); +}; + +export const grantPermission = async ( + userId: string, + app: AppName, + grantedBy: string | null +): Promise => { + await db + .insertInto('user_app_permissions') + .values({user_id: userId, app, role: 'admin', granted_by: grantedBy, granted_at: new Date()}) + .onDuplicateKeyUpdate({role: 'admin'}) + .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 => { + 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 => { + await db.updateTable('user').set({disabled: false}).where('id', '=', userId).execute(); +}; + +export const revokeSession = async (userId: string, sessionId: string): Promise => { + 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 => { + 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().as('count')) + .executeTakeFirst(); + + return Number(row?.count ?? 0); +}; + +export const userExists = async (userId: string): Promise => { + 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; +}; diff --git a/test/admin/admin.bootstrap.test.ts b/test/admin/admin.bootstrap.test.ts new file mode 100644 index 0000000..190e7a9 --- /dev/null +++ b/test/admin/admin.bootstrap.test.ts @@ -0,0 +1,91 @@ +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', ['admin'], 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(); + }); +}); diff --git a/test/admin/admin.mail.test.ts b/test/admin/admin.mail.test.ts new file mode 100644 index 0000000..d567fa1 --- /dev/null +++ b/test/admin/admin.mail.test.ts @@ -0,0 +1,71 @@ +import {vi, describe, it, expect, beforeEach, type Mock} from 'vitest'; + +vi.mock('../../src/common/common.mail.js', () => ({ + MailService: {sendMail: vi.fn()} +})); +vi.mock('../../src/models/admin/admin.config.js', () => ({ + ADMIN_APP_URL: 'https://admin.nachklang.art' +})); + +import {MailService} from '../../src/common/common.mail.js'; +import {sendInvitationMail, sendPasswordResetMail} from '../../src/models/admin/admin.mail.js'; + +const sendMail = MailService.sendMail as Mock; + +beforeEach(() => { + sendMail.mockReset(); + sendMail.mockResolvedValue(true); +}); + +describe('sendInvitationMail', () => { + it('points at the admin app and carries the token in the query string', async () => { + await sendInvitationMail('a@nachklang.art', 'Anna', 'tok-en_123', new Date('2026-09-12T10:00:00Z')); + + const [to, subject, text, options] = sendMail.mock.calls[0]; + expect(to).toBe('a@nachklang.art'); + expect(subject).toBeTruthy(); + expect(text).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123'); + expect(options.html).toContain('https://admin.nachklang.art/accept-invite?token=tok-en_123'); + }); + + it('url-encodes a token containing url-significant characters', async () => { + await sendInvitationMail('a@nachklang.art', 'Anna', 'a+b/c=d', new Date()); + + const [, , text] = sendMail.mock.calls[0]; + expect(text).toContain('token=a%2Bb%2Fc%3Dd'); + }); + + it('sends both a text and an html part', async () => { + await sendInvitationMail('a@nachklang.art', 'Anna', 'tok', new Date()); + + const [, , text, options] = sendMail.mock.calls[0]; + expect(text.length).toBeGreaterThan(0); + expect(options.html).toContain(' { + await sendInvitationMail('a@nachklang.art', '', 'tok', new Date()); + + const [, , , options] = sendMail.mock.calls[0]; + expect(options.html).not.toContain('